From bc6983a62e3c1537bb0625076103d8388f84f519 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 3 Sep 2026 20:48:20 -0400 Subject: [PATCH] chore(oss): audit the server package and resync the SDK mirrors Split the 657-line operation service into eor, submit, and verify modules, add the AGENT_REVOKED and KEY_RETIRED codes the Backend already emits, and condense every multi-line comment. Mirror Node, Python, and Go at 2.1.0 with the deep-health 503, non-idempotent retry, rotation grace, and path-encoding fixes. Document which routes and response fields the self-hosted server does not provide. Claude-Session: https://claude.ai/code/session_01Agoc9bRVE9KL7mmWtey9Vq --- README.md | 10 +- packages/server/src/adapters/bullmq.ts | 8 +- packages/server/src/adapters/interfaces.ts | 43 +- packages/server/src/adapters/minio.ts | 10 +- packages/server/src/adapters/postgres.ts | 21 +- packages/server/src/adapters/redis-cache.ts | 7 +- packages/server/src/i18n/messages.ts | 38 +- packages/server/src/index.ts | 6 +- packages/server/src/lib/auth.ts | 18 +- packages/server/src/middleware/auth.ts | 9 +- .../server/src/middleware/error-handler.ts | 62 +- packages/server/src/middleware/i18n.ts | 9 +- packages/server/src/middleware/rbac.ts | 31 +- packages/server/src/middleware/request-id.ts | 8 +- packages/server/src/routes/agents.ts | 4 +- packages/server/src/routes/audit.ts | 10 +- packages/server/src/routes/auth.ts | 22 +- packages/server/src/routes/epochs.ts | 8 +- packages/server/src/routes/exports.ts | 12 +- packages/server/src/routes/jwks.ts | 13 +- packages/server/src/routes/operations.ts | 10 +- packages/server/src/scheduler.ts | 6 +- packages/server/src/services/agent-service.ts | 7 +- .../server/src/services/api-token-service.ts | 4 +- packages/server/src/services/audit-service.ts | 8 +- packages/server/src/services/auth-service.ts | 8 +- packages/server/src/services/epoch-service.ts | 19 +- .../server/src/services/export-service.ts | 10 +- packages/server/src/services/operation-eor.ts | 82 ++ .../server/src/services/operation-service.ts | 616 +-------- .../server/src/services/operation-submit.ts | 256 ++++ .../server/src/services/operation-verify.ts | 209 +++ .../server/src/shared/constants/errors.ts | 2 + packages/server/src/shared/types/enums.ts | 2 + packages/server/src/shared/types/protocol.ts | 25 +- packages/server/src/types.ts | 12 +- packages/server/src/utils/crypto.ts | 82 +- packages/server/src/utils/merkle.ts | 30 +- packages/server/src/utils/pagination.ts | 23 +- packages/server/src/utils/pdf.ts | 29 +- packages/server/src/utils/tsa.ts | 42 +- packages/server/src/utils/uuid.ts | 26 +- packages/server/src/worker.ts | 6 +- sdks/go/README.md | 71 +- sdks/go/admin_events.go | 16 + sdks/go/agents.go | 45 +- sdks/go/auth.go | 114 +- sdks/go/client.go | 282 ++-- sdks/go/cmd/elydora/cli_secrets.go | 3 - sdks/go/cmd/elydora/main.go | 27 +- sdks/go/cmd/elydora/main_test.go | 6 +- sdks/go/cmd/elydora/plugins/augment.go | 9 +- .../cmd/elydora/plugins/augment_contract.go | 80 +- .../elydora/plugins/augment_installation.go | 196 +-- sdks/go/cmd/elydora/plugins/augment_io.go | 44 - sdks/go/cmd/elydora/plugins/augment_status.go | 313 +---- .../plugins/augment_transaction_test.go | 7 +- sdks/go/cmd/elydora/plugins/claudecode.go | 9 +- .../elydora/plugins/claudecode_contract.go | 22 +- .../plugins/claudecode_installation.go | 172 +-- sdks/go/cmd/elydora/plugins/claudecode_io.go | 35 - .../elydora/plugins/claudecode_ownership.go | 83 +- .../plugins/claudecode_security_test.go | 4 +- .../cmd/elydora/plugins/claudecode_status.go | 230 +--- .../plugins/claudecode_transaction_test.go | 7 +- sdks/go/cmd/elydora/plugins/cline.go | 3 +- sdks/go/cmd/elydora/plugins/cline_contract.go | 45 +- .../cmd/elydora/plugins/cline_installation.go | 154 +-- sdks/go/cmd/elydora/plugins/cline_io.go | 15 +- sdks/go/cmd/elydora/plugins/cline_status.go | 205 +-- sdks/go/cmd/elydora/plugins/codex.go | 12 +- sdks/go/cmd/elydora/plugins/codex_command.go | 192 +-- sdks/go/cmd/elydora/plugins/codex_contract.go | 61 +- .../cmd/elydora/plugins/codex_installation.go | 147 +- sdks/go/cmd/elydora/plugins/codex_io.go | 150 +- .../elydora/plugins/codex_recovery_test.go | 2 +- sdks/go/cmd/elydora/plugins/codex_test.go | 6 +- sdks/go/cmd/elydora/plugins/copilot.go | 8 +- .../cmd/elydora/plugins/copilot_contract.go | 188 +-- .../elydora/plugins/copilot_installation.go | 162 +-- sdks/go/cmd/elydora/plugins/copilot_io.go | 9 +- sdks/go/cmd/elydora/plugins/copilot_schema.go | 2 +- sdks/go/cmd/elydora/plugins/copilot_status.go | 260 +--- sdks/go/cmd/elydora/plugins/cursor.go | 80 +- .../go/cmd/elydora/plugins/cursor_contract.go | 130 +- sdks/go/cmd/elydora/plugins/cursor_io.go | 287 +--- .../elydora/plugins/cursor_recovery_test.go | 4 +- sdks/go/cmd/elydora/plugins/droid.go | 10 +- sdks/go/cmd/elydora/plugins/droid_config.go | 2 +- sdks/go/cmd/elydora/plugins/droid_contract.go | 170 +-- .../cmd/elydora/plugins/droid_installation.go | 179 +-- sdks/go/cmd/elydora/plugins/droid_status.go | 250 +--- sdks/go/cmd/elydora/plugins/droid_test.go | 2 +- .../plugins/droid_test_helpers_test.go | 13 +- .../elydora/plugins/droid_transaction_test.go | 10 +- sdks/go/cmd/elydora/plugins/gemini.go | 9 +- sdks/go/cmd/elydora/plugins/gemini_command.go | 89 +- .../go/cmd/elydora/plugins/gemini_contract.go | 28 +- .../elydora/plugins/gemini_installation.go | 180 +-- sdks/go/cmd/elydora/plugins/gemini_io.go | 35 - .../elydora/plugins/gemini_security_test.go | 4 +- sdks/go/cmd/elydora/plugins/gemini_status.go | 230 +--- sdks/go/cmd/elydora/plugins/gemini_test.go | 8 +- .../plugins/gemini_test_helpers_test.go | 2 +- .../plugins/gemini_transaction_test.go | 9 +- sdks/go/cmd/elydora/plugins/grok.go | 4 +- sdks/go/cmd/elydora/plugins/grok_command.go | 123 +- sdks/go/cmd/elydora/plugins/grok_contract.go | 49 +- .../cmd/elydora/plugins/grok_installation.go | 152 +-- sdks/go/cmd/elydora/plugins/grok_io.go | 25 +- .../cmd/elydora/plugins/grok_security_test.go | 4 +- sdks/go/cmd/elydora/plugins/grok_status.go | 231 +--- sdks/go/cmd/elydora/plugins/grok_test.go | 16 +- .../elydora/plugins/grok_test_helpers_test.go | 3 +- .../elydora/plugins/grok_transaction_test.go | 8 +- sdks/go/cmd/elydora/plugins/guardtemplate.go | 24 +- sdks/go/cmd/elydora/plugins/hooktemplate.go | 49 +- sdks/go/cmd/elydora/plugins/jsonc.go | 27 +- sdks/go/cmd/elydora/plugins/jsonutil.go | 34 +- sdks/go/cmd/elydora/plugins/kimi.go | 21 +- sdks/go/cmd/elydora/plugins/kimi_command.go | 69 +- sdks/go/cmd/elydora/plugins/kimi_contract.go | 42 +- .../cmd/elydora/plugins/kimi_installation.go | 186 +-- sdks/go/cmd/elydora/plugins/kimi_io.go | 2 +- sdks/go/cmd/elydora/plugins/kimi_status.go | 192 +-- .../elydora/plugins/kimi_transaction_test.go | 15 +- sdks/go/cmd/elydora/plugins/kirocli.go | 2 +- .../cmd/elydora/plugins/kirocli_contract.go | 12 +- sdks/go/cmd/elydora/plugins/kirocli_io.go | 17 - .../go/cmd/elydora/plugins/kiroide_command.go | 103 +- .../cmd/elydora/plugins/kiroide_contract.go | 48 +- .../elydora/plugins/kiroide_installation.go | 137 +- sdks/go/cmd/elydora/plugins/kiroide_status.go | 282 +--- sdks/go/cmd/elydora/plugins/kiroide_test.go | 6 +- .../plugins/kiroide_transaction_test.go | 4 +- .../elydora/plugins/legacy_hook_entries.go | 15 +- sdks/go/cmd/elydora/plugins/letta.go | 160 +-- .../go/cmd/elydora/plugins/managed_changes.go | 95 ++ .../go/cmd/elydora/plugins/managed_runtime.go | 413 ++++++ sdks/go/cmd/elydora/plugins/opencode.go | 102 +- sdks/go/cmd/elydora/plugins/opencode_test.go | 3 + sdks/go/cmd/elydora/plugins/qwen.go | 4 +- sdks/go/cmd/elydora/plugins/qwen_command.go | 116 -- sdks/go/cmd/elydora/plugins/qwen_contract.go | 86 +- .../cmd/elydora/plugins/qwen_installation.go | 184 +-- sdks/go/cmd/elydora/plugins/qwen_io.go | 266 ---- .../cmd/elydora/plugins/qwen_recovery_test.go | 2 +- sdks/go/cmd/elydora/plugins/qwen_sources.go | 5 +- .../cmd/elydora/plugins/qwen_sources_test.go | 6 +- sdks/go/cmd/elydora/plugins/qwen_status.go | 5 + sdks/go/cmd/elydora/plugins/qwen_test.go | 2 +- .../elydora/plugins/qwen_test_helpers_test.go | 8 +- sdks/go/cmd/elydora/plugins/registry.go | 3 +- sdks/go/cmd/elydora/plugins/runtime_files.go | 39 +- .../plugins/runtime_reader_template.go | 22 + sdks/go/cmd/elydora/plugins/shell_command.go | 252 ++++ sdks/go/cmd/elydora/plugins/transaction.go | 90 +- .../elydora/plugins/transaction_journal.go | 23 +- .../plugins/transaction_subprocess_test.go | 276 ---- .../transaction_subprocess_worker_test.go | 287 ++++ sdks/go/crypto.go | 57 +- sdks/go/exports.go | 36 +- sdks/go/health.go | 79 +- sdks/go/members.go | 10 + sdks/go/operations.go | 13 +- sdks/go/sdk_compat_integration_test.go | 163 +++ sdks/go/types.go | 314 ++--- sdks/go/types_api.go | 236 ++++ sdks/go/types_test.go | 33 - sdks/go/utils.go | 8 +- sdks/go/webhooks.go | 29 + sdks/node/README.md | 42 +- sdks/node/package-lock.json | 4 +- sdks/node/package.json | 5 +- sdks/node/src/cli.ts | 21 +- sdks/node/src/client.ts | 146 +- sdks/node/src/crypto.ts | 76 +- sdks/node/src/index.ts | 17 +- sdks/node/src/plugins/augment-contract.ts | 49 +- sdks/node/src/plugins/augment-installation.ts | 16 +- sdks/node/src/plugins/augment-io.ts | 105 +- sdks/node/src/plugins/claudecode-contract.ts | 15 +- .../src/plugins/claudecode-installation.ts | 15 +- sdks/node/src/plugins/claudecode-io.ts | 89 +- sdks/node/src/plugins/cline-contract.ts | 28 +- sdks/node/src/plugins/cline-installation.ts | 12 +- sdks/node/src/plugins/cline-io.ts | 98 +- sdks/node/src/plugins/codex-contract.ts | 144 +- sdks/node/src/plugins/codex-installation.ts | 15 +- sdks/node/src/plugins/codex-io.ts | 163 +-- sdks/node/src/plugins/common.ts | 50 + sdks/node/src/plugins/copilot-contract.ts | 106 +- sdks/node/src/plugins/copilot-installation.ts | 13 +- sdks/node/src/plugins/copilot-io.ts | 95 +- sdks/node/src/plugins/cursor-contract.ts | 113 +- sdks/node/src/plugins/cursor-installation.ts | 29 +- sdks/node/src/plugins/cursor-io.ts | 235 +--- sdks/node/src/plugins/droid-config.ts | 35 +- sdks/node/src/plugins/droid-contract.ts | 109 +- sdks/node/src/plugins/droid-installation.ts | 19 +- sdks/node/src/plugins/droid-io.ts | 97 +- sdks/node/src/plugins/droid-policy.ts | 7 +- sdks/node/src/plugins/gemini-command.ts | 137 +- sdks/node/src/plugins/gemini-config.ts | 95 +- sdks/node/src/plugins/gemini-contract.ts | 8 +- sdks/node/src/plugins/gemini-installation.ts | 16 +- sdks/node/src/plugins/gemini-io.ts | 86 +- sdks/node/src/plugins/grok-command.ts | 157 +-- sdks/node/src/plugins/grok-contract.ts | 8 +- sdks/node/src/plugins/grok-installation.ts | 16 +- sdks/node/src/plugins/grok-io.ts | 82 +- sdks/node/src/plugins/hook-template.ts | 3 - sdks/node/src/plugins/jsonc-edit.ts | 23 + sdks/node/src/plugins/kimi-command.ts | 157 +-- sdks/node/src/plugins/kimi-contract.ts | 17 +- sdks/node/src/plugins/kimi-installation.ts | 10 +- sdks/node/src/plugins/kimi-io.ts | 92 +- sdks/node/src/plugins/kirocli-contract.ts | 44 +- sdks/node/src/plugins/kirocli-installation.ts | 10 +- sdks/node/src/plugins/kirocli-io.ts | 7 +- sdks/node/src/plugins/kiroide-command.ts | 149 +- sdks/node/src/plugins/kiroide-contract.ts | 16 +- sdks/node/src/plugins/kiroide-installation.ts | 14 +- sdks/node/src/plugins/letta-command.ts | 126 +- sdks/node/src/plugins/letta-config.ts | 42 +- sdks/node/src/plugins/letta-contract.ts | 9 +- sdks/node/src/plugins/letta-installation.ts | 15 +- sdks/node/src/plugins/letta-sources.ts | 18 +- sdks/node/src/plugins/managed-files.ts | 15 +- sdks/node/src/plugins/managed-installation.ts | 18 +- .../src/plugins/managed-runtime-status.ts | 29 +- sdks/node/src/plugins/managed-transaction.ts | 60 +- sdks/node/src/plugins/opencode-contract.ts | 16 +- .../node/src/plugins/opencode-installation.ts | 14 +- sdks/node/src/plugins/opencode-io.ts | 15 +- sdks/node/src/plugins/qwen-command.ts | 119 +- sdks/node/src/plugins/qwen-config.ts | 96 +- sdks/node/src/plugins/qwen-contract.ts | 9 +- sdks/node/src/plugins/qwen-installation.ts | 15 +- sdks/node/src/plugins/qwen-io.ts | 94 +- sdks/node/src/plugins/qwen-sources.ts | 22 +- sdks/node/src/plugins/shell-command.ts | 128 ++ sdks/node/src/plugins/strict-json.ts | 7 + sdks/node/src/types.ts | 134 +- sdks/node/src/utils.ts | 20 +- .../test-support/augment-test-helpers.mjs | 183 +-- .../test-support/claudecode-test-helpers.mjs | 172 +-- sdks/node/test-support/cline-test-helpers.mjs | 156 +-- sdks/node/test-support/codex-test-helpers.mjs | 181 +-- .../test-support/copilot-test-helpers.mjs | 181 +-- .../node/test-support/cursor-test-helpers.mjs | 160 +-- sdks/node/test-support/droid-test-helpers.mjs | 177 +-- .../node/test-support/gemini-test-helpers.mjs | 194 +-- sdks/node/test-support/grok-test-helpers.mjs | 178 +-- sdks/node/test-support/harness.mjs | 202 +++ sdks/node/test-support/kimi-test-helpers.mjs | 191 +-- .../test-support/kirocli-test-helpers.mjs | 180 +-- .../test-support/kiroide-test-helpers.mjs | 178 +-- sdks/node/test-support/letta.mjs | 187 +-- .../test-support/opencode-test-helpers.mjs | 168 +-- sdks/node/test-support/qwen.mjs | 210 +-- sdks/node/test/cli-security.test.mjs | 1 + sdks/node/test/guard-template.test.mjs | 4 +- sdks/node/test/letta-runtime.test.mjs | 4 +- sdks/python/README.md | 93 +- sdks/python/elydora/__init__.py | 58 +- sdks/python/elydora/_client_common.py | 115 ++ sdks/python/elydora/_version.py | 2 +- sdks/python/elydora/async_client.py | 376 +++-- sdks/python/elydora/cli.py | 31 +- sdks/python/elydora/client.py | 348 ++--- sdks/python/elydora/crypto.py | 96 +- sdks/python/elydora/plugins/_durable_file.py | 13 +- sdks/python/elydora/plugins/_file_io.py | 49 +- sdks/python/elydora/plugins/_managed_files.py | 75 +- .../elydora/plugins/_opaque_snapshot.py | 28 +- .../elydora/plugins/_pinned_directory.py | 74 +- .../elydora/plugins/_private_artifact.py | 5 +- sdks/python/elydora/plugins/_runtime.py | 391 ++++++ sdks/python/elydora/plugins/_shell_command.py | 160 +++ sdks/python/elydora/plugins/_transaction.py | 27 +- .../elydora/plugins/_transaction_commit.py | 40 +- .../elydora/plugins/_transaction_journal.py | 14 +- .../elydora/plugins/_transaction_recovery.py | 95 +- .../elydora/plugins/_transaction_staging.py | 23 +- .../elydora/plugins/_windows_security.py | 7 - .../elydora/plugins/augment_contract.py | 25 +- .../elydora/plugins/augment_installation.py | 167 +-- sdks/python/elydora/plugins/augment_io.py | 227 +-- .../elydora/plugins/claudecode_contract.py | 34 +- .../plugins/claudecode_installation.py | 155 +-- sdks/python/elydora/plugins/claudecode_io.py | 207 +-- sdks/python/elydora/plugins/cline_contract.py | 22 +- .../elydora/plugins/cline_installation.py | 177 +-- sdks/python/elydora/plugins/cline_io.py | 229 +--- sdks/python/elydora/plugins/codex.py | 199 +-- sdks/python/elydora/plugins/codex_contract.py | 204 +-- sdks/python/elydora/plugins/codex_io.py | 137 +- .../elydora/plugins/copilot_contract.py | 128 +- .../elydora/plugins/copilot_installation.py | 198 +-- sdks/python/elydora/plugins/copilot_io.py | 206 +-- sdks/python/elydora/plugins/cursor.py | 198 +-- .../python/elydora/plugins/cursor_contract.py | 134 +- sdks/python/elydora/plugins/cursor_io.py | 175 +-- sdks/python/elydora/plugins/droid_config.py | 2 +- sdks/python/elydora/plugins/droid_contract.py | 133 +- .../elydora/plugins/droid_installation.py | 230 +--- sdks/python/elydora/plugins/droid_io.py | 230 +--- sdks/python/elydora/plugins/gemini_command.py | 187 +-- .../python/elydora/plugins/gemini_contract.py | 8 +- .../elydora/plugins/gemini_installation.py | 155 +-- sdks/python/elydora/plugins/gemini_io.py | 217 +-- sdks/python/elydora/plugins/grok_command.py | 188 +-- sdks/python/elydora/plugins/grok_contract.py | 7 +- .../elydora/plugins/grok_installation.py | 166 +-- sdks/python/elydora/plugins/grok_io.py | 189 +-- sdks/python/elydora/plugins/hook_template.py | 3 - sdks/python/elydora/plugins/kimi_command.py | 188 +-- sdks/python/elydora/plugins/kimi_contract.py | 7 +- .../elydora/plugins/kimi_installation.py | 169 +-- sdks/python/elydora/plugins/kimi_io.py | 194 +-- sdks/python/elydora/plugins/kirocli.py | 115 +- sdks/python/elydora/plugins/kiroide.py | 6 +- .../python/elydora/plugins/kiroide_command.py | 212 +-- .../elydora/plugins/kiroide_contract.py | 11 +- .../elydora/plugins/kiroide_installation.py | 118 +- sdks/python/elydora/plugins/kiroide_io.py | 168 +-- sdks/python/elydora/plugins/letta.py | 4 +- sdks/python/elydora/plugins/letta_command.py | 132 +- sdks/python/elydora/plugins/letta_contract.py | 15 +- .../elydora/plugins/letta_installation.py | 162 +-- sdks/python/elydora/plugins/letta_io.py | 205 +-- sdks/python/elydora/plugins/letta_sources.py | 17 +- sdks/python/elydora/plugins/qwen.py | 4 +- sdks/python/elydora/plugins/qwen_command.py | 120 +- sdks/python/elydora/plugins/qwen_contract.py | 11 +- .../elydora/plugins/qwen_installation.py | 158 +-- sdks/python/elydora/plugins/qwen_io.py | 204 +-- sdks/python/elydora/plugins/qwen_sources.py | 17 +- sdks/python/elydora/types.py | 199 ++- sdks/python/elydora/utils.py | 23 +- sdks/python/pyproject.toml | 3 + sdks/python/tests/kiroide_support.py | 286 ++++ sdks/python/tests/runtime_removal_support.py | 83 ++ .../tests/test_agent_registration_contract.py | 8 +- sdks/python/tests/test_cursor_plugin.py | 1 - sdks/python/tests/test_gemini_plugin.py | 1 - sdks/python/tests/test_guard_template.py | 2 +- sdks/python/tests/test_kirocli_plugin.py | 1 - sdks/python/tests/test_kiroide_commands.py | 222 +++ sdks/python/tests/test_kiroide_plugin.py | 1214 +---------------- sdks/python/tests/test_kiroide_uninstall.py | 359 +++++ sdks/python/tests/test_kiroide_workspace.py | 367 +++++ sdks/python/tests/test_letta_plugin.py | 1 - sdks/python/tests/test_opencode_plugin.py | 13 +- sdks/python/tests/test_pinned_directory.py | 2 +- sdks/python/tests/test_retry_contract.py | 4 +- sdks/python/tests/test_runtime_removal.py | 577 +------- .../tests/test_runtime_removal_directories.py | 222 +++ .../tests/test_runtime_removal_error_log.py | 286 ++++ .../tests/test_transaction_directories.py | 245 ++++ .../python/tests/test_transaction_rollback.py | 404 ++++++ .../python/tests/test_transaction_security.py | 708 +--------- sdks/python/tests/transaction_support.py | 42 + 364 files changed, 10180 insertions(+), 23349 deletions(-) create mode 100644 packages/server/src/services/operation-eor.ts create mode 100644 packages/server/src/services/operation-submit.ts create mode 100644 packages/server/src/services/operation-verify.ts create mode 100644 sdks/go/admin_events.go create mode 100644 sdks/go/cmd/elydora/plugins/managed_changes.go create mode 100644 sdks/go/cmd/elydora/plugins/managed_runtime.go delete mode 100644 sdks/go/cmd/elydora/plugins/qwen_command.go delete mode 100644 sdks/go/cmd/elydora/plugins/qwen_io.go create mode 100644 sdks/go/cmd/elydora/plugins/qwen_status.go create mode 100644 sdks/go/cmd/elydora/plugins/shell_command.go create mode 100644 sdks/go/cmd/elydora/plugins/transaction_subprocess_worker_test.go create mode 100644 sdks/go/members.go create mode 100644 sdks/go/sdk_compat_integration_test.go create mode 100644 sdks/go/types_api.go create mode 100644 sdks/go/webhooks.go create mode 100644 sdks/node/src/plugins/common.ts create mode 100644 sdks/node/src/plugins/jsonc-edit.ts create mode 100644 sdks/node/src/plugins/shell-command.ts create mode 100644 sdks/node/test-support/harness.mjs create mode 100644 sdks/python/elydora/_client_common.py create mode 100644 sdks/python/elydora/plugins/_runtime.py create mode 100644 sdks/python/elydora/plugins/_shell_command.py create mode 100644 sdks/python/tests/kiroide_support.py create mode 100644 sdks/python/tests/runtime_removal_support.py create mode 100644 sdks/python/tests/test_kiroide_commands.py create mode 100644 sdks/python/tests/test_kiroide_uninstall.py create mode 100644 sdks/python/tests/test_kiroide_workspace.py create mode 100644 sdks/python/tests/test_runtime_removal_directories.py create mode 100644 sdks/python/tests/test_runtime_removal_error_log.py create mode 100644 sdks/python/tests/test_transaction_directories.py create mode 100644 sdks/python/tests/test_transaction_rollback.py create mode 100644 sdks/python/tests/transaction_support.py diff --git a/README.md b/README.md index 4e3205a..027ae76 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,8 @@ Agent SDK Server All endpoints are prefixed with the configured base URL (default: `https://api.elydora.com`). All authenticated endpoints require a `Bearer` token in the `Authorization` header. +This self-hosted server mounts `/.well-known/elydora/jwks.json`, `/api/auth/*`, `/v1/auth`, `/v1/agents`, `/v1/operations`, `/v1/audit`, `/v1/epochs`, and `/v1/exports`. The SDKs also expose `/v1/auth/rotate`, `/v1/health/deep`, `/v1/webhooks`, `/v1/members`, and `/v1/admin/events`, which only the hosted API implements; those methods return 404 here. SDK response types follow the hosted API, so fields such as `token_id`, `current_organization`, `onboarding_completed`, `capabilities`, `previous_status`, and the organization's `description` and `ba_org_id` can be absent in self-hosted responses. + Errors are returned as: ```json @@ -287,7 +289,7 @@ Register a new agent. Required role: **integration_engineer**. "responsible_entity": "engineering-team@example.com", "keys": [ { - "kid": "my-agent-v1-key-v1", + "kid": "my-agent-v1-key-1", "public_key": "", "algorithm": "ed25519" } @@ -353,7 +355,7 @@ Re-activate a frozen agent. Required role: **security_admin**. Revoke a specific signing key. The agent remains registered but the key can no longer be used to sign new operations. Required role: **security_admin**. -**Request body:** `{ "kid": "my-agent-v1-key-v1", "reason": "Key rotation" }` +**Request body:** `{ "kid": "my-agent-v1-key-1", "reason": "Key rotation" }` --- @@ -571,7 +573,7 @@ await client.registerAgent({ agent_id: 'order-processor-v1', integration_type: 'sdk', display_name: 'Order Processor', - keys: [{ kid: 'order-processor-v1-key-v1', public_key: client.getPublicKey(), algorithm: 'ed25519' }], + keys: [{ kid: 'order-processor-v1-key-1', public_key: client.getPublicKey(), algorithm: 'ed25519' }], }); // 4. Submit an operation @@ -633,7 +635,7 @@ client.register_agent({ "agent_id": "order-processor-v1", "integration_type": "sdk", "display_name": "Order Processor", - "keys": [{"kid": "order-processor-v1-key-v1", + "keys": [{"kid": "order-processor-v1-key-1", "public_key": derive_public_key(os.environ["AGENT_PRIVATE_KEY"]), "algorithm": "ed25519"}], }) diff --git a/packages/server/src/adapters/bullmq.ts b/packages/server/src/adapters/bullmq.ts index ea5233a..712c1a3 100644 --- a/packages/server/src/adapters/bullmq.ts +++ b/packages/server/src/adapters/bullmq.ts @@ -1,10 +1,4 @@ -/** - * BullMQ queue adapter implementing the MessageQueue interface. - * - * Wraps a BullMQ Queue instance to replace Cloudflare's Queue producer. - * Jobs are enqueued with retry-on-failure semantics (3 attempts, - * exponential back-off) matching the Cloudflare Queues default behaviour. - */ +/** BullMQ queue adapter implementing the MessageQueue interface. */ import { Queue as BullQueue } from 'bullmq'; import type { ConnectionOptions } from 'bullmq'; diff --git a/packages/server/src/adapters/interfaces.ts b/packages/server/src/adapters/interfaces.ts index 44432f4..1ab9374 100644 --- a/packages/server/src/adapters/interfaces.ts +++ b/packages/server/src/adapters/interfaces.ts @@ -1,24 +1,12 @@ -/** - * Adapter interfaces for ElydoraOpenSource infrastructure services. - * - * These interfaces mirror the shape of Cloudflare's D1, R2, KV, and Queue - * APIs so that all existing service code can run unchanged against - * PostgreSQL, MinIO, Redis, and BullMQ implementations. - */ +/** Adapter interfaces for ElydoraOpenSource infrastructure services. */ // --------------------------------------------------------------------------- // Database (replaces D1Database) // --------------------------------------------------------------------------- -/** - * A prepared (and optionally bound) SQL statement. - * Mirrors D1PreparedStatement's interface exactly. - */ +/** A prepared (and optionally bound) SQL statement. */ export interface PreparedStatement { - /** - * Bind positional parameters (? placeholders) to the statement. - * Returns a new statement with the values set. - */ + /** Bind positional parameters (? placeholders) to the statement. */ bind(...values: unknown[]): PreparedStatement; /** Execute and return the first row, or null if no rows match. */ @@ -31,18 +19,12 @@ export interface PreparedStatement { run(): Promise<{ success: boolean }>; } -/** - * Relational database adapter. - * Mirrors the subset of D1Database methods used by Elydora services. - */ +/** Relational database adapter. */ export interface Database { /** Prepare a SQL statement for execution. */ prepare(sql: string): PreparedStatement; - /** - * Execute multiple prepared statements atomically (within a transaction). - * Mirrors D1Database.batch(). - */ + /** Execute multiple prepared statements atomically (within a transaction). */ batch(statements: PreparedStatement[]): Promise<{ results: unknown[]; success: boolean }[]>; } @@ -73,10 +55,7 @@ export interface ObjectStoreHead { readonly httpMetadata?: { readonly contentType?: string }; } -/** - * Object/blob storage adapter. - * Mirrors the subset of R2Bucket methods used by Elydora services. - */ +/** Object/blob storage adapter. */ export interface ObjectStore { /** Upload an object to the store. */ put( @@ -96,10 +75,7 @@ export interface ObjectStore { // Cache (replaces KVNamespace) // --------------------------------------------------------------------------- -/** - * Key/value cache adapter. - * Mirrors the subset of KVNamespace methods used by Elydora services. - */ +/** Key/value cache adapter. */ export interface Cache { /** Retrieve a cached value, or null if not found / expired. */ get(key: string): Promise; @@ -112,10 +88,7 @@ export interface Cache { // Message queue (replaces Queue) // --------------------------------------------------------------------------- -/** - * Message queue adapter. - * Mirrors the subset of Cloudflare Queue methods used by Elydora services. - */ +/** Message queue adapter. */ export interface MessageQueue { /** Enqueue a message under the caller's durable message ID. */ send(messageId: string, body: unknown): Promise; diff --git a/packages/server/src/adapters/minio.ts b/packages/server/src/adapters/minio.ts index d8b48e9..6c4a5d8 100644 --- a/packages/server/src/adapters/minio.ts +++ b/packages/server/src/adapters/minio.ts @@ -1,10 +1,4 @@ -/** - * MinIO / S3-compatible object storage adapter implementing the ObjectStore interface. - * - * Uses the AWS SDK v3 S3 client configured with a custom endpoint for MinIO. - * Responses are buffered in memory so that both streaming (body) and random - * access (json/text) are available on the returned ObjectStoreObject. - */ +/** MinIO / S3-compatible object storage adapter implementing the ObjectStore interface. */ import { S3Client, @@ -64,7 +58,7 @@ export class MinioAdapter implements ObjectStore { } else if (body instanceof Uint8Array) { bodyData = body; } else { - // Web ReadableStream — collect into buffer + // Web ReadableStream: collect into buffer const chunks: Uint8Array[] = []; const reader = body.getReader(); for (;;) { diff --git a/packages/server/src/adapters/postgres.ts b/packages/server/src/adapters/postgres.ts index df4b3f6..030c023 100644 --- a/packages/server/src/adapters/postgres.ts +++ b/packages/server/src/adapters/postgres.ts @@ -1,13 +1,4 @@ -/** - * PostgreSQL adapter implementing the Database interface. - * - * Translates the D1-style API (prepare/bind/first/all/run/batch) into - * standard `pg` Pool queries. SQLite `?` placeholders are converted to - * PostgreSQL `$N` positional parameters automatically. - * - * Batch execution runs all statements inside a single transaction to - * preserve the atomicity guarantee of D1.batch(). - */ +/** PostgreSQL adapter implementing the Database interface. */ import pg from 'pg'; import type { Database, PreparedStatement } from './interfaces.js'; @@ -25,10 +16,7 @@ types.setTypeParser(20, (val: string) => Number(val)); // Placeholder conversion // --------------------------------------------------------------------------- -/** - * Convert SQLite `?` positional placeholders to PostgreSQL `$N` style. - * Only replaces `?` outside of SQL string literals (safe for all Elydora queries). - */ +/** Convert SQLite `?` positional placeholders to PostgreSQL `$N` style. */ function convertPlaceholders(sql: string): string { let index = 0; return sql.replace(/\?/g, () => `$${++index}`); @@ -38,10 +26,7 @@ function convertPlaceholders(sql: string): string { // Statement implementation // --------------------------------------------------------------------------- -/** - * Internal PostgreSQL statement that implements PreparedStatement. - * Stores the SQL and bound values; executes lazily on first/all/run/executeWith. - */ +/** Internal PostgreSQL statement that implements PreparedStatement. */ class PostgresStatement implements PreparedStatement { constructor( private readonly pool: Pool, diff --git a/packages/server/src/adapters/redis-cache.ts b/packages/server/src/adapters/redis-cache.ts index d724916..de26f04 100644 --- a/packages/server/src/adapters/redis-cache.ts +++ b/packages/server/src/adapters/redis-cache.ts @@ -1,9 +1,4 @@ -/** - * Redis cache adapter implementing the Cache interface. - * - * Uses ioredis to back the nonce-deduplication and chain-hash caching - * that was previously served by Cloudflare KVNamespace. - */ +/** Redis cache adapter implementing the Cache interface. */ import { Redis } from 'ioredis'; import type { Cache } from './interfaces.js'; diff --git a/packages/server/src/i18n/messages.ts b/packages/server/src/i18n/messages.ts index 3405c85..74a8cef 100644 --- a/packages/server/src/i18n/messages.ts +++ b/packages/server/src/i18n/messages.ts @@ -1,15 +1,4 @@ -/** - * Internationalization (i18n) translation map. - * - * Provides bilingual (English / Chinese) translations for all user-facing - * error and validation messages returned by the Elydora API. - * - * Message keys follow a dot-separated convention: - * . - * - * Some messages accept parameters via simple {param} placeholders that - * callers must replace before returning the message to the client. - */ +/** Internationalization (i18n) translation map. */ export type Lang = 'en' | 'zh'; @@ -29,6 +18,14 @@ const messages: Record> = { en: 'The signing key has been revoked.', zh: '\u7b7e\u540d\u5bc6\u94a5\u5df2\u88ab\u64a4\u9500\u3002', }, + 'error.keyRetired': { + en: 'The signing key has been retired.', + zh: '\u7b7e\u540d\u5bc6\u94a5\u5df2\u9000\u5f79\u3002', + }, + 'error.agentRevoked': { + en: 'The agent has been revoked.', + zh: '\u8be5\u4ee3\u7406\u5df2\u88ab\u540a\u9500\u3002', + }, 'error.agentFrozen': { en: 'The agent is frozen and cannot submit operations.', zh: '\u8be5\u4ee3\u7406\u5df2\u88ab\u51bb\u7ed3\uff0c\u65e0\u6cd5\u63d0\u4ea4\u64cd\u4f5c\u3002', @@ -363,15 +360,7 @@ const messages: Record> = { }, }; -/** - * Retrieve a translated message by key. - * - * Supports simple {param} placeholder interpolation via the optional - * `params` argument. - * - * Falls back to English if the requested language is missing, and - * ultimately falls back to the raw key if no translation exists at all. - */ +/** Retrieve a translated message by key. */ export function getMessage( key: string, lang: Lang = 'en', @@ -386,12 +375,7 @@ export function getMessage( return msg; } -/** - * Detect the preferred language from an Accept-Language header value. - * - * Returns 'zh' if the header contains any Chinese locale indicator, - * otherwise defaults to 'en'. - */ +/** Detect the preferred language from an Accept-Language header value. */ export function detectLanguage(acceptLanguage: string | null | undefined): Lang { if (!acceptLanguage) return 'en'; if (acceptLanguage.includes('zh')) return 'zh'; diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 02fec73..8fb9113 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -1,6 +1,4 @@ -/** - * Elydora API — Node.js entry point. - */ +/** Elydora API: Node.js entry point. */ import 'dotenv/config'; import { serve } from '@hono/node-server'; @@ -90,7 +88,7 @@ app.use('/*', async (c, next) => { await next(); }); -// CORS — always restrict to the configured allowlist +// CORS: always restrict to the configured allowlist app.use( '/*', cors({ diff --git a/packages/server/src/lib/auth.ts b/packages/server/src/lib/auth.ts index e828c9e..7524987 100644 --- a/packages/server/src/lib/auth.ts +++ b/packages/server/src/lib/auth.ts @@ -1,16 +1,4 @@ -/** - * Better Auth configuration for ElydoraOpenSource. - * - * Creates a Better Auth instance backed by PostgreSQL. The instance is - * configured to: - * - * - Use the existing `users` table via field-mapping (no schema change) - * - Support legacy PBKDF2-SHA256 password hashes alongside Better Auth's - * default scrypt so existing users can sign in without re-hashing - * - Expose bearer-token auth for API clients - * - Provide organization support with Elydora's 5-role RBAC hierarchy - * - Provide admin user-management endpoints - */ +/** Better Auth configuration for ElydoraOpenSource. */ import { betterAuth } from 'better-auth'; import { verifyPassword as verifyScryptPassword } from 'better-auth/crypto'; @@ -60,7 +48,7 @@ async function verifyLegacyPbkdf2(password: string, stored: string): Promise = { INVALID_SIGNATURE: 'error.invalidSignature', UNKNOWN_AGENT: 'error.unknownAgent', KEY_REVOKED: 'error.keyRevoked', + KEY_RETIRED: 'error.keyRetired', AGENT_FROZEN: 'error.agentFrozen', + AGENT_REVOKED: 'error.agentRevoked', TTL_EXPIRED: 'error.ttlExpired', REPLAY_DETECTED: 'error.replayDetected', PREV_HASH_MISMATCH: 'error.prevHashMismatch', @@ -42,16 +27,7 @@ const ERROR_CODE_TO_I18N_KEY: Record = { VALIDATION_ERROR: 'error.validationError', }; -/** - * Custom error class that carries an HTTP status and Elydora error code. - * - * Supports three modes: - * 1. Code-only: `new AppError(status, code)` — uses ErrorCode default message - * 2. i18n key: `new AppError(status, code, { key: 'msg.key', params: {...} })` — - * the message is resolved at response time from the translation map - * 3. Raw string: `new AppError(status, code, 'raw message')` — message is used as-is - * (should be avoided in new code; prefer mode 2) - */ +/** Custom error class that carries an HTTP status and Elydora error code. */ export class AppError extends Error { public readonly statusCode: number; public readonly errorCode: ErrorCode; @@ -68,13 +44,13 @@ export class AppError extends Error { details?: Record, ) { if (typeof messageOrOpts === 'object' && messageOrOpts !== null) { - // i18n mode — store the key; resolve later in the error handler + // i18n mode: store the key; resolve later in the error handler super(getMessage(messageOrOpts.key, 'en', messageOrOpts.params)); this.messageKey = messageOrOpts.key; this.messageParams = messageOrOpts.params; this.usesDefaultMessage = false; } else if (messageOrOpts === undefined) { - // Code-only mode — will be resolved via ERROR_CODE_TO_I18N_KEY + // Code-only mode: will be resolved via ERROR_CODE_TO_I18N_KEY super(ERROR_CODES[errorCode]); this.usesDefaultMessage = true; } else { @@ -88,15 +64,7 @@ export class AppError extends Error { this.details = details; } - /** - * Resolve the user-facing message for a given language. - * - * Resolution order: - * 1. If a messageKey was provided, use that with the translation map. - * 2. If the error used the default ErrorCode message, translate via - * the ERROR_CODE_TO_I18N_KEY mapping. - * 3. Otherwise return the raw message string. - */ + /** Resolve the user-facing message for a given language. */ resolveMessage(lang: Lang): string { if (this.messageKey) { return getMessage(this.messageKey, lang, this.messageParams); @@ -111,12 +79,7 @@ export class AppError extends Error { } } -/** - * Build a standardised error response body. - * - * When `lang` is provided and no explicit `message` is given, the - * ErrorCode default message is resolved from the translation map. - */ +/** Build a standardised error response body. */ export function buildErrorResponse( code: ErrorCode, requestId: string, @@ -139,12 +102,7 @@ export function buildErrorResponse( }; } -/** - * Hono error handler that converts all errors into the ErrorResponse format. - * - * Resolves i18n message keys using the `lang` context variable set by - * the i18n middleware. - */ +/** Hono error handler that converts all errors into the ErrorResponse format. */ export const globalErrorHandler: ErrorHandler<{ Bindings: Env; Variables: AppVariables; diff --git a/packages/server/src/middleware/i18n.ts b/packages/server/src/middleware/i18n.ts index 3c74b84..37a3a00 100644 --- a/packages/server/src/middleware/i18n.ts +++ b/packages/server/src/middleware/i18n.ts @@ -1,11 +1,4 @@ -/** - * i18n middleware — language detection from Accept-Language header. - * - * Reads the Accept-Language request header, detects the preferred - * language (English or Chinese), and stores it in the Hono context - * so that all downstream handlers and services can access it via - * `c.get('lang')`. - */ +/** i18n middleware: language detection from Accept-Language header. */ import type { MiddlewareHandler } from 'hono'; import type { Env, AppVariables } from '../types.js'; diff --git a/packages/server/src/middleware/rbac.ts b/packages/server/src/middleware/rbac.ts index a9c4e1f..242c483 100644 --- a/packages/server/src/middleware/rbac.ts +++ b/packages/server/src/middleware/rbac.ts @@ -1,25 +1,11 @@ -/** - * Role-based access control (RBAC) middleware. - * - * The Elydora console defines five roles with a clear privilege hierarchy: - * - * org_owner > security_admin > compliance_auditor > readonly_investigator - * integration_engineer (parallel to compliance_auditor, agent-management focused) - * - * This middleware checks whether the role extracted by the auth middleware - * satisfies the minimum required role for a given route. - */ +/** Role-based access control (RBAC) middleware. */ import type { MiddlewareHandler } from 'hono'; import type { RbacRole } from '../shared/index.js'; import type { Env, AppVariables } from '../types.js'; import { AppError } from './error-handler.js'; -/** - * Privilege level map. Higher number = more privilege. - * integration_engineer is a special role with limited admin capabilities - * for agent management only. - */ +/** Privilege level map. */ const ROLE_LEVEL: Record = { readonly_investigator: 10, integration_engineer: 20, @@ -28,21 +14,12 @@ const ROLE_LEVEL: Record = { org_owner: 50, }; -/** - * Check if a role has at least the given privilege level. - */ +/** Check if a role has at least the given privilege level. */ function hasMinimumRole(userRole: RbacRole, requiredRole: RbacRole): boolean { return ROLE_LEVEL[userRole] >= ROLE_LEVEL[requiredRole]; } -/** - * Create an RBAC middleware that requires a minimum role. - * - * Usage: - * app.post('/v1/agents/:id/freeze', requireRole('security_admin'), handler); - * - * @param minimumRole - The minimum RBAC role required to access the endpoint - */ +/** Create an RBAC middleware that requires a minimum role. */ export function requireRole(minimumRole: RbacRole): MiddlewareHandler<{ Bindings: Env; Variables: AppVariables; diff --git a/packages/server/src/middleware/request-id.ts b/packages/server/src/middleware/request-id.ts index fc400a0..06db9c2 100644 --- a/packages/server/src/middleware/request-id.ts +++ b/packages/server/src/middleware/request-id.ts @@ -1,10 +1,4 @@ -/** - * Request ID middleware. - * - * Generates a UUIDv7 for every incoming request and stores it in the - * Hono context variables. The ID is also set as the X-Request-Id - * response header so clients can reference it when reporting issues. - */ +/** Request ID middleware. */ import type { MiddlewareHandler } from 'hono'; import type { Env, AppVariables } from '../types.js'; diff --git a/packages/server/src/routes/agents.ts b/packages/server/src/routes/agents.ts index 8f229b0..069b6d2 100644 --- a/packages/server/src/routes/agents.ts +++ b/packages/server/src/routes/agents.ts @@ -1,6 +1,4 @@ -/** - * Agent routes — registration, lookup, freezing, and key revocation. - */ +/** Agent routes: registration, lookup, freezing, and key revocation. */ import { Hono } from 'hono'; import type { Env, AppVariables } from '../types.js'; diff --git a/packages/server/src/routes/audit.ts b/packages/server/src/routes/audit.ts index f2aaf06..9042255 100644 --- a/packages/server/src/routes/audit.ts +++ b/packages/server/src/routes/audit.ts @@ -1,10 +1,4 @@ -/** - * Audit routes — query the tamper-evident audit log. - * - * Per the OpenAPI spec, audit queries use POST /v1/audit/query with - * filter parameters in the request body. This avoids URL length limits - * and supports complex filter expressions. - */ +/** Audit routes: query the tamper-evident audit log. */ import { Hono } from 'hono'; import type { Env, AppVariables } from '../types.js'; @@ -19,7 +13,7 @@ const audit = new Hono<{ Bindings: Env; Variables: AppVariables }>(); audit.use('/*', authMiddleware); // --------------------------------------------------------------------------- -// POST /v1/audit/query — Query the audit log +// POST /v1/audit/query: Query the audit log // --------------------------------------------------------------------------- audit.post( '/query', diff --git a/packages/server/src/routes/auth.ts b/packages/server/src/routes/auth.ts index 4e46233..b240150 100644 --- a/packages/server/src/routes/auth.ts +++ b/packages/server/src/routes/auth.ts @@ -1,12 +1,4 @@ -/** - * Auth routes — registration, login, profile retrieval, and token refresh. - * - * /register and /login are public (no auth required). - * /me and /refresh require a valid Better Auth session or API token; /token requires a session. - * - * Uses Better Auth for session management while preserving the existing - * API surface (endpoint paths and response formats). - */ +/** Auth routes: registration, login, profile retrieval, and token refresh. */ import { Hono } from 'hono'; import type { Env, AppVariables } from '../types.js'; @@ -20,7 +12,7 @@ import { getMessage } from '../i18n/messages.js'; const auth = new Hono<{ Bindings: Env; Variables: AppVariables }>(); // --------------------------------------------------------------------------- -// POST /v1/auth/register — Create a new user and organization +// POST /v1/auth/register: Create a new user and organization // --------------------------------------------------------------------------- auth.post('/register', async (c) => { const body = await c.req.json(); @@ -93,7 +85,7 @@ auth.post('/register', async (c) => { }); // --------------------------------------------------------------------------- -// POST /v1/auth/login — Authenticate and receive a session token +// POST /v1/auth/login: Authenticate and receive a session token // --------------------------------------------------------------------------- auth.post('/login', async (c) => { const body = await c.req.json(); @@ -134,7 +126,7 @@ auth.post('/login', async (c) => { }); // --------------------------------------------------------------------------- -// GET /v1/auth/me — Get current user profile (auth required) +// GET /v1/auth/me: Get current user profile (auth required) // --------------------------------------------------------------------------- auth.get('/me', authMiddleware, async (c) => { const userId = c.get('actor'); @@ -144,7 +136,7 @@ auth.get('/me', authMiddleware, async (c) => { }); // --------------------------------------------------------------------------- -// POST /v1/auth/refresh — Issue a new session token (auth required) +// POST /v1/auth/refresh: Issue a new session token (auth required) // --------------------------------------------------------------------------- auth.post('/refresh', authMiddleware, async (c) => { const betterAuthInstance = createAuth( @@ -154,7 +146,7 @@ auth.post('/refresh', authMiddleware, async (c) => { c.env.ALLOWED_ORIGINS, ); - // Get the current session and return its token — Better Auth handles + // Get the current session and return its token: Better Auth handles // session refresh automatically based on the updateAge setting const session = await betterAuthInstance.api.getSession({ headers: c.req.raw.headers, @@ -171,7 +163,7 @@ auth.post('/refresh', authMiddleware, async (c) => { }); // --------------------------------------------------------------------------- -// POST /v1/auth/token — Issue an opaque API token (session required) +// POST /v1/auth/token: Issue an opaque API token (session required) // --------------------------------------------------------------------------- auth.post('/token', authMiddleware, async (c) => { if (c.get('auth_token_type') !== 'session') { diff --git a/packages/server/src/routes/epochs.ts b/packages/server/src/routes/epochs.ts index 0f5bdeb..3110cac 100644 --- a/packages/server/src/routes/epochs.ts +++ b/packages/server/src/routes/epochs.ts @@ -1,6 +1,4 @@ -/** - * Epoch routes — retrieve epoch root records with optional TSA anchors. - */ +/** Epoch routes: retrieve epoch root records with optional TSA anchors. */ import { Hono } from 'hono'; import type { Env, AppVariables } from '../types.js'; @@ -14,7 +12,7 @@ const epochs = new Hono<{ Bindings: Env; Variables: AppVariables }>(); epochs.use('/*', authMiddleware); // --------------------------------------------------------------------------- -// GET /v1/epochs — List all epochs for the organization +// GET /v1/epochs: List all epochs for the organization // --------------------------------------------------------------------------- epochs.get('/', requireRole('readonly_investigator'), async (c) => { const orgId = c.get('org_id'); @@ -24,7 +22,7 @@ epochs.get('/', requireRole('readonly_investigator'), async (c) => { }); // --------------------------------------------------------------------------- -// GET /v1/epochs/:epoch_id — Retrieve an epoch root +// GET /v1/epochs/:epoch_id: Retrieve an epoch root // --------------------------------------------------------------------------- epochs.get( '/:epoch_id', diff --git a/packages/server/src/routes/exports.ts b/packages/server/src/routes/exports.ts index dda6ded..578dd02 100644 --- a/packages/server/src/routes/exports.ts +++ b/packages/server/src/routes/exports.ts @@ -1,6 +1,4 @@ -/** - * Export routes — create and retrieve compliance export jobs. - */ +/** Export routes: create and retrieve compliance export jobs. */ import { Hono } from 'hono'; import type { Env, AppVariables } from '../types.js'; @@ -16,7 +14,7 @@ const exports_ = new Hono<{ Bindings: Env; Variables: AppVariables }>(); exports_.use('/*', authMiddleware); // --------------------------------------------------------------------------- -// GET /v1/exports — List all exports for the organization +// GET /v1/exports: List all exports for the organization // --------------------------------------------------------------------------- exports_.get('/', requireRole('compliance_auditor'), async (c) => { const orgId = c.get('org_id'); @@ -26,7 +24,7 @@ exports_.get('/', requireRole('compliance_auditor'), async (c) => { }); // --------------------------------------------------------------------------- -// POST /v1/exports — Create a compliance export job +// POST /v1/exports: Create a compliance export job // --------------------------------------------------------------------------- exports_.post( '/', @@ -55,7 +53,7 @@ exports_.post( ); // --------------------------------------------------------------------------- -// GET /v1/exports/:export_id — Retrieve export status + download URL +// GET /v1/exports/:export_id: Retrieve export status + download URL // --------------------------------------------------------------------------- exports_.get( '/:export_id', @@ -77,7 +75,7 @@ exports_.get( ); // --------------------------------------------------------------------------- -// GET /v1/exports/:export_id/download — Stream the export file from R2 +// GET /v1/exports/:export_id/download: Stream the export file from R2 // --------------------------------------------------------------------------- exports_.get( '/:export_id/download', diff --git a/packages/server/src/routes/jwks.ts b/packages/server/src/routes/jwks.ts index 8bb052b..7dbb66c 100644 --- a/packages/server/src/routes/jwks.ts +++ b/packages/server/src/routes/jwks.ts @@ -1,13 +1,4 @@ -/** - * JWKS route — public key discovery endpoint. - * - * Returns the JSON Web Key Set containing the Elydora server's public - * keys used for signing attestation receipts (EAR) and epoch roots (EER). - * External verifiers can use these keys to independently validate - * signatures without contacting the Elydora API. - * - * This endpoint requires NO authentication (per OpenAPI spec and RFC 7517). - */ +/** JWKS route: public key discovery endpoint. */ import { Hono } from 'hono'; import type { Env, AppVariables } from '../types.js'; @@ -17,7 +8,7 @@ import { deriveEd25519PublicKey } from '../utils/crypto.js'; const jwks = new Hono<{ Bindings: Env; Variables: AppVariables }>(); // --------------------------------------------------------------------------- -// GET /.well-known/elydora/jwks.json — Retrieve the platform JWKS +// GET /.well-known/elydora/jwks.json: Retrieve the platform JWKS // --------------------------------------------------------------------------- jwks.get('/', async (c) => { // Derive the public key from the server signing key diff --git a/packages/server/src/routes/operations.ts b/packages/server/src/routes/operations.ts index f9fa638..bffb05c 100644 --- a/packages/server/src/routes/operations.ts +++ b/packages/server/src/routes/operations.ts @@ -1,6 +1,4 @@ -/** - * Operation routes — submit, retrieve, and verify signed operation records. - */ +/** Operation routes: submit, retrieve, and verify signed operation records. */ import { Hono } from 'hono'; import type { Env, AppVariables } from '../types.js'; @@ -13,7 +11,7 @@ import * as operationService from '../services/operation-service.js'; const operations = new Hono<{ Bindings: Env; Variables: AppVariables }>(); // --------------------------------------------------------------------------- -// POST /v1/operations — Submit a signed EOR +// POST /v1/operations: Submit a signed EOR // --------------------------------------------------------------------------- // Operation submission uses the EOR's embedded signature for authentication // rather than a Bearer token. The agent authenticates via its Ed25519 signature. @@ -47,7 +45,7 @@ operations.post( ); // --------------------------------------------------------------------------- -// GET /v1/operations/:operation_id — Retrieve an operation +// GET /v1/operations/:operation_id: Retrieve an operation // --------------------------------------------------------------------------- operations.get( '/:operation_id', @@ -65,7 +63,7 @@ operations.get( ); // --------------------------------------------------------------------------- -// POST /v1/operations/:operation_id/verify — Verify operation integrity +// POST /v1/operations/:operation_id/verify: Verify operation integrity // --------------------------------------------------------------------------- operations.post( '/:operation_id/verify', diff --git a/packages/server/src/scheduler.ts b/packages/server/src/scheduler.ts index 9a2035a..4862b3f 100644 --- a/packages/server/src/scheduler.ts +++ b/packages/server/src/scheduler.ts @@ -1,8 +1,4 @@ -/** - * Epoch scheduler — runs every 5 minutes to create Merkle tree rollups. - * - * Run as a separate Node.js process: node dist/scheduler.js - */ +/** Epoch scheduler: runs every 5 minutes to create Merkle tree rollups. */ import 'dotenv/config'; import cron from 'node-cron'; diff --git a/packages/server/src/services/agent-service.ts b/packages/server/src/services/agent-service.ts index cbb628c..420907b 100644 --- a/packages/server/src/services/agent-service.ts +++ b/packages/server/src/services/agent-service.ts @@ -1,9 +1,4 @@ -/** - * Agent service — business logic for agent lifecycle management. - * - * Handles registration, lookup, freezing, and key revocation. All - * administrative mutations are recorded as admin_events for audit. - */ +/** Agent service: business logic for agent lifecycle management. */ import type { Agent, diff --git a/packages/server/src/services/api-token-service.ts b/packages/server/src/services/api-token-service.ts index 87868cb..7dfcef7 100644 --- a/packages/server/src/services/api-token-service.ts +++ b/packages/server/src/services/api-token-service.ts @@ -1,6 +1,4 @@ -/** - * Opaque API tokens: the raw value is returned once; only its SHA-256 hash is stored. - */ +/** Opaque API tokens: the raw value is returned once; only its SHA-256 hash is stored. */ import { createHash, randomBytes } from 'node:crypto'; import type { Database } from '../adapters/interfaces.js'; diff --git a/packages/server/src/services/audit-service.ts b/packages/server/src/services/audit-service.ts index e4856bb..67d0c01 100644 --- a/packages/server/src/services/audit-service.ts +++ b/packages/server/src/services/audit-service.ts @@ -1,10 +1,4 @@ -/** - * Audit service — business logic for querying the tamper-evident audit log. - * - * Builds dynamic SQL queries with optional filters (org_id, agent_id, - * operation_type, time range) and supports cursor-based pagination for - * efficient traversal of large result sets. - */ +/** Audit service: business logic for querying the tamper-evident audit log. */ import type { Operation, AuditQueryRequest, AuditQueryResponse } from '../shared/index.js'; import { DEFAULT_QUERY_LIMIT, MAX_QUERY_LIMIT } from '../shared/index.js'; diff --git a/packages/server/src/services/auth-service.ts b/packages/server/src/services/auth-service.ts index 8bf70fa..c6e01ae 100644 --- a/packages/server/src/services/auth-service.ts +++ b/packages/server/src/services/auth-service.ts @@ -1,10 +1,4 @@ -/** - * Auth service — password hashing and user profile retrieval. - * - * Authentication is handled by Better Auth (see lib/auth.ts). - * This service provides password utilities for legacy PBKDF2 hash - * verification and the getMe profile query. - */ +/** Auth service: password hashing and user profile retrieval. */ import { AppError } from '../middleware/error-handler.js'; import type { RbacRole } from '../shared/index.js'; diff --git a/packages/server/src/services/epoch-service.ts b/packages/server/src/services/epoch-service.ts index 9f7f6c8..3878cf2 100644 --- a/packages/server/src/services/epoch-service.ts +++ b/packages/server/src/services/epoch-service.ts @@ -1,11 +1,4 @@ -/** - * Epoch service — business logic for retrieving epoch root records. - * - * Epochs are periodic Merkle-tree rollups that anchor a set of operations - * to a single root hash. Optionally, the epoch may carry a TSA - * (Timestamping Authority) anchor that binds the root to an external - * trusted timestamp for non-repudiation. - */ +/** Epoch service: business logic for retrieving epoch root records. */ import type { Epoch, EER, GetEpochResponse, ListEpochsResponse } from '../shared/index.js'; import { AppError } from '../middleware/error-handler.js'; @@ -82,13 +75,7 @@ export async function getEpoch( // Create epoch // --------------------------------------------------------------------------- -/** - * Create a new epoch by building a Merkle tree over operations in a time - * window, signing the Epoch Record, and persisting to D1 + R2. - * - * Returns null if the epoch already exists, or if no operations fall within - * the given time range. - */ +/** Create a new epoch by building a Merkle tree over operations in a time window, signing the Epoch Record, and p */ export async function createEpoch( db: Database, r2: ObjectStore, @@ -97,7 +84,7 @@ export async function createEpoch( startTime: number, endTime: number, ): Promise { - // 1. Idempotency check — skip if epoch already exists for this window + // 1. Idempotency check: skip if epoch already exists for this window const existing = await db .prepare( 'SELECT epoch_id FROM epochs WHERE org_id = ? AND start_time = ? AND end_time = ?', diff --git a/packages/server/src/services/export-service.ts b/packages/server/src/services/export-service.ts index 9e7343f..c26bf89 100644 --- a/packages/server/src/services/export-service.ts +++ b/packages/server/src/services/export-service.ts @@ -1,12 +1,4 @@ -/** - * Export service — business logic for compliance export job management. - * - * Exports are asynchronous jobs that package operations matching specified - * criteria into a downloadable bundle (JSON or PDF). The job is enqueued - * via a message queue and processed by the queue consumer. Once complete, - * the result is stored in the object store and the database record is - * updated with a reference to the object key. - */ +/** Export service: business logic for compliance export job management. */ import type { Export, CreateExportRequest, CreateExportResponse, GetExportResponse, ListExportsResponse } from '../shared/index.js'; import { generateUUIDv7 } from '../utils/uuid.js'; diff --git a/packages/server/src/services/operation-eor.ts b/packages/server/src/services/operation-eor.ts new file mode 100644 index 0000000..3803b68 --- /dev/null +++ b/packages/server/src/services/operation-eor.ts @@ -0,0 +1,82 @@ +import type { EOR } from '../shared/index.js'; +import { MAX_PAYLOAD_SIZE, MAX_TTL_MS, MIN_TTL_MS, MAX_NONCE_LENGTH } from '../shared/index.js'; +import { AppError } from '../middleware/error-handler.js'; + +/** Genesis chain hash for an agent's first operation. */ +export const GENESIS_CHAIN_HASH = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; + +/** Server key ID carried in receipts. */ +export const ELYDORA_KID = 'elydora-server-key-v1'; + +/** The EOR fields covered by the agent signature. */ +export function buildSignableEOR(eor: EOR): Record { + return { + op_version: eor.op_version, + operation_id: eor.operation_id, + org_id: eor.org_id, + agent_id: eor.agent_id, + issued_at: eor.issued_at, + ttl_ms: eor.ttl_ms, + nonce: eor.nonce, + operation_type: eor.operation_type, + subject: eor.subject, + action: eor.action, + payload: eor.payload, + payload_hash: eor.payload_hash, + prev_chain_hash: eor.prev_chain_hash, + agent_pubkey_kid: eor.agent_pubkey_kid, + }; +} + +export function validateEORFields(eor: EOR, receivedAt: number): void { + if (eor.op_version !== '1.0') { + throw new AppError(400, 'VALIDATION_ERROR', { key: 'operation.unsupportedVersion', params: { version: eor.op_version } }); + } + + const requiredStrings: Array<[string, unknown]> = [ + ['operation_id', eor.operation_id], + ['org_id', eor.org_id], + ['agent_id', eor.agent_id], + ['nonce', eor.nonce], + ['operation_type', eor.operation_type], + ['payload_hash', eor.payload_hash], + ['prev_chain_hash', eor.prev_chain_hash], + ['agent_pubkey_kid', eor.agent_pubkey_kid], + ['signature', eor.signature], + ]; + + for (const [field, value] of requiredStrings) { + if (!value || typeof value !== 'string' || value.trim().length === 0) { + throw new AppError(400, 'VALIDATION_ERROR', { key: 'operation.missingField', params: { field } }); + } + } + + if (eor.nonce.length > MAX_NONCE_LENGTH) { + throw new AppError(400, 'VALIDATION_ERROR', { key: 'operation.nonceTooLong', params: { max: MAX_NONCE_LENGTH } }); + } + + if (typeof eor.issued_at !== 'number' || eor.issued_at <= 0) { + throw new AppError(400, 'VALIDATION_ERROR', { key: 'operation.invalidIssuedAt' }); + } + + if (typeof eor.ttl_ms !== 'number') { + throw new AppError(400, 'VALIDATION_ERROR', { key: 'operation.missingTtl' }); + } + if (eor.ttl_ms < MIN_TTL_MS) { + throw new AppError(400, 'VALIDATION_ERROR', { key: 'operation.ttlTooLow', params: { min: MIN_TTL_MS } }); + } + if (eor.ttl_ms > MAX_TTL_MS) { + throw new AppError(400, 'VALIDATION_ERROR', { key: 'operation.ttlTooHigh', params: { max: MAX_TTL_MS } }); + } + + if (eor.issued_at + eor.ttl_ms < receivedAt) { + throw new AppError(400, 'TTL_EXPIRED'); + } + + if (eor.payload !== null && eor.payload !== undefined) { + const payloadStr = typeof eor.payload === 'string' ? eor.payload : JSON.stringify(eor.payload); + if (new TextEncoder().encode(payloadStr).byteLength > MAX_PAYLOAD_SIZE) { + throw new AppError(400, 'PAYLOAD_TOO_LARGE'); + } + } +} diff --git a/packages/server/src/services/operation-service.ts b/packages/server/src/services/operation-service.ts index 9bbaa62..de72e56 100644 --- a/packages/server/src/services/operation-service.ts +++ b/packages/server/src/services/operation-service.ts @@ -1,328 +1,9 @@ -/** - * Operation service — business logic for EOR submission, retrieval, and verification. - * - * This is the core of the Elydora protocol implementation. It performs: - * 1. EOR field validation - * 2. Agent and key status checks - * 3. Ed25519 signature verification - * 4. Hash chain validation and computation - * 5. Persistence to D1 and R2 - * 6. Queue enqueue for async processing - * 7. EAR (receipt) generation with server signature - */ - -import type { - EOR, - EAR, - Agent, - AgentKey, - Operation, - Receipt, - GetOperationResponse, - VerifyOperationResponse, -} from '../shared/index.js'; -import { - MAX_PAYLOAD_SIZE, - MAX_TTL_MS, - MIN_TTL_MS, - MAX_NONCE_LENGTH, -} from '../shared/index.js'; -import { - base64urlDecode, - verifyEd25519Signature, - jcsCanonicalise, - computeChainHash, - computeReceiptHash, - signEd25519, -} from '../utils/crypto.js'; -import { generateUUIDv7 } from '../utils/uuid.js'; +import type { Operation, Receipt, GetOperationResponse } from '../shared/index.js'; import { AppError } from '../middleware/error-handler.js'; -import type { Database, ObjectStore, Cache, MessageQueue, PreparedStatement } from '../adapters/interfaces.js'; +import type { Database, ObjectStore } from '../adapters/interfaces.js'; -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - -/** The genesis chain hash used for an agent's first operation. */ -const GENESIS_CHAIN_HASH = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; - -/** Elydora server key ID used in receipts. */ -const ELYDORA_KID = 'elydora-server-key-v1'; - -// --------------------------------------------------------------------------- -// Submit operation -// --------------------------------------------------------------------------- - -export async function submitOperation( - db: Database, - r2: ObjectStore, - kv: Cache, - queue: MessageQueue, - eor: EOR, - signingKey: string, -): Promise<{ receipt: EAR }> { - const receivedAt = Date.now(); - - // ------------------------------------------------------------------ - // Step 1: Validate EOR fields - // ------------------------------------------------------------------ - validateEORFields(eor, receivedAt); - - // ------------------------------------------------------------------ - // Step 2: Replay detection via nonce in KV - // ------------------------------------------------------------------ - const nonceKey = `nonce:${eor.org_id}:${eor.nonce}`; - const existingNonce = await kv.get(nonceKey); - if (existingNonce) { - throw new AppError(400, 'REPLAY_DETECTED'); - } - // Store nonce with TTL equal to the operation's TTL (in seconds) - const nonceTtlSeconds = Math.ceil(eor.ttl_ms / 1000); - await kv.put(nonceKey, '1', { expirationTtl: nonceTtlSeconds }); - - // ------------------------------------------------------------------ - // Step 3: Look up agent and check status - // ------------------------------------------------------------------ - const agent = await db - .prepare('SELECT * FROM agents WHERE agent_id = ? AND org_id = ?') - .bind(eor.agent_id, eor.org_id) - .first(); - - if (!agent) { - throw new AppError(404, 'UNKNOWN_AGENT', { key: 'operation.agentNotRegistered', params: { id: eor.agent_id } }); - } - - if (agent.status === 'frozen') { - throw new AppError(403, 'AGENT_FROZEN'); - } - - if (agent.status === 'revoked') { - throw new AppError(403, 'AGENT_FROZEN', { key: 'operation.agentRevoked' }); - } - - // ------------------------------------------------------------------ - // Step 4: Look up key and check status - // ------------------------------------------------------------------ - const agentKey = await db - .prepare('SELECT * FROM agent_keys WHERE kid = ? AND agent_id = ?') - .bind(eor.agent_pubkey_kid, eor.agent_id) - .first(); - - if (!agentKey) { - throw new AppError(400, 'INVALID_SIGNATURE', { key: 'operation.keyNotFoundForAgent', params: { kid: eor.agent_pubkey_kid, id: eor.agent_id } }); - } - - if (agentKey.status === 'revoked') { - throw new AppError(403, 'KEY_REVOKED'); - } - - if (agentKey.status === 'retired') { - throw new AppError(403, 'KEY_REVOKED', { key: 'operation.keyRetired' }); - } - - // ------------------------------------------------------------------ - // Step 5: Verify Ed25519 signature - // ------------------------------------------------------------------ - const signableEOR = buildSignableEOR(eor); - const canonicalData = jcsCanonicalise(signableEOR); - const dataBytes = new TextEncoder().encode(canonicalData); - const signatureBytes = base64urlDecode(eor.signature); - const publicKeyBytes = base64urlDecode(agentKey.public_key); - - const signatureValid = await verifyEd25519Signature(publicKeyBytes, signatureBytes, dataBytes); - - if (!signatureValid) { - throw new AppError(400, 'INVALID_SIGNATURE'); - } - - // ------------------------------------------------------------------ - // Step 6: Verify prev_chain_hash against the latest chain_hash - // ------------------------------------------------------------------ - const latestOp = await db - .prepare( - 'SELECT chain_hash, seq_no FROM operations WHERE agent_id = ? ORDER BY seq_no DESC LIMIT 1', - ) - .bind(eor.agent_id) - .first<{ chain_hash: string; seq_no: number }>(); - - const expectedPrevHash = latestOp ? latestOp.chain_hash : GENESIS_CHAIN_HASH; - const nextSeqNo = latestOp ? latestOp.seq_no + 1 : 1; - - if (eor.prev_chain_hash !== expectedPrevHash) { - throw new AppError(400, 'PREV_HASH_MISMATCH', { key: 'operation.prevHashMismatch', params: { expected: expectedPrevHash, actual: eor.prev_chain_hash } }); - } - - // ------------------------------------------------------------------ - // Step 7: Compute new chain_hash - // ------------------------------------------------------------------ - const chainHash = await computeChainHash( - eor.prev_chain_hash, - eor.payload_hash, - eor.operation_id, - eor.issued_at, - ); - - // ------------------------------------------------------------------ - // Step 8: Store operation in D1 - // ------------------------------------------------------------------ - const r2PayloadKey = `${eor.org_id}/${eor.agent_id}/${eor.operation_id}`; - - const operation: Operation = { - operation_id: eor.operation_id, - org_id: eor.org_id, - agent_id: eor.agent_id, - seq_no: nextSeqNo, - operation_type: eor.operation_type, - issued_at: eor.issued_at, - ttl_ms: eor.ttl_ms, - nonce: eor.nonce, - subject: JSON.stringify(eor.subject), - action: JSON.stringify(eor.action), - payload_hash: eor.payload_hash, - prev_chain_hash: eor.prev_chain_hash, - chain_hash: chainHash, - agent_pubkey_kid: eor.agent_pubkey_kid, - signature: eor.signature, - r2_payload_key: r2PayloadKey, - created_at: receivedAt, - }; - - // ------------------------------------------------------------------ - // Step 9: Store the full EOR envelope in R2 - // ------------------------------------------------------------------ - const eorJson = JSON.stringify(eor); - await r2.put(r2PayloadKey, eorJson, { - httpMetadata: { contentType: 'application/json' }, - customMetadata: { - org_id: eor.org_id, - agent_id: eor.agent_id, - operation_type: eor.operation_type, - }, - }); - - // ------------------------------------------------------------------ - // Step 10: Reserve the queue message ID; the send follows the commit - // ------------------------------------------------------------------ - const queueMessageId = generateUUIDv7(); - const receiptId = generateUUIDv7(); - - // ------------------------------------------------------------------ - // Step 11: Generate EAR receipt - // ------------------------------------------------------------------ - const receiptFields = { - receipt_version: '1.0', - receipt_id: receiptId, - operation_id: eor.operation_id, - org_id: eor.org_id, - agent_id: eor.agent_id, - server_received_at: receivedAt, - seq_no: nextSeqNo, - chain_hash: chainHash, - queue_message_id: queueMessageId, - }; - - const receiptHash = await computeReceiptHash(receiptFields); - - // Sign the receipt hash with the server key - const receiptDataToSign = new TextEncoder().encode(receiptHash); - const elydoraSignature = await signEd25519(signingKey, receiptDataToSign); - - const ear: EAR = { - ...receiptFields, - receipt_hash: receiptHash, - elydora_kid: ELYDORA_KID, - elydora_signature: elydoraSignature, - }; - - // Store receipt in R2 - const r2ReceiptKey = `${eor.org_id}/${eor.agent_id}/receipts/${eor.operation_id}`; - await r2.put(r2ReceiptKey, JSON.stringify(ear), { - httpMetadata: { contentType: 'application/json' }, - }); - - // ------------------------------------------------------------------ - // Step 12: Persist operation and receipt to D1 - // ------------------------------------------------------------------ - const statements: PreparedStatement[] = []; - - statements.push( - db - .prepare( - `INSERT INTO operations (operation_id, org_id, agent_id, seq_no, operation_type, issued_at, ttl_ms, nonce, subject, action, payload_hash, prev_chain_hash, chain_hash, agent_pubkey_kid, signature, r2_payload_key, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ) - .bind( - operation.operation_id, - operation.org_id, - operation.agent_id, - operation.seq_no, - operation.operation_type, - operation.issued_at, - operation.ttl_ms, - operation.nonce, - operation.subject, - operation.action, - operation.payload_hash, - operation.prev_chain_hash, - operation.chain_hash, - operation.agent_pubkey_kid, - operation.signature, - operation.r2_payload_key, - operation.created_at, - ), - ); - - statements.push( - db - .prepare( - `INSERT INTO receipts (receipt_id, operation_id, r2_receipt_key, created_at) - VALUES (?, ?, ?, ?)`, - ) - .bind(receiptId, eor.operation_id, r2ReceiptKey, receivedAt), - ); - - try { - await db.batch(statements); - } catch (error) { - if (!isSeqNoConflict(error)) throw error; - const winner = await db - .prepare('SELECT chain_hash FROM operations WHERE agent_id = ? ORDER BY seq_no DESC LIMIT 1') - .bind(eor.agent_id) - .first<{ chain_hash: string }>(); - if (!winner) throw new Error('seq_no conflict without a stored operation.'); - throw new AppError(400, 'PREV_HASH_MISMATCH', { - key: 'operation.prevHashMismatch', - params: { expected: winner.chain_hash, actual: eor.prev_chain_hash }, - }); - } - - try { - await queue.send(queueMessageId, { - type: 'operation', - operation_id: eor.operation_id, - org_id: eor.org_id, - agent_id: eor.agent_id, - }); - } catch (error) { - console.error('operation.enqueue_failed', { - operation_id: eor.operation_id, - queue_message_id: queueMessageId, - error: error instanceof Error ? error.message : String(error), - }); - } - - return { receipt: ear }; -} - -function isSeqNoConflict(error: unknown): boolean { - if (typeof error !== 'object' || error === null) return false; - const { code, constraint } = error as { code?: unknown; constraint?: unknown }; - return code === '23505' && constraint === 'operations_agent_id_seq_no_key'; -} - -// --------------------------------------------------------------------------- -// Get operation -// --------------------------------------------------------------------------- +export { submitOperation } from './operation-submit.js'; +export { verifyOperation } from './operation-verify.js'; export async function getOperation( db: Database, @@ -354,7 +35,7 @@ export async function getOperation( payload = (eor.payload as Record) ?? undefined; } } catch { - // Payload fetch is best-effort — don't fail the request + // Payload fetch is best-effort: don't fail the request } } @@ -368,290 +49,3 @@ export async function getOperation( // --------------------------------------------------------------------------- // Verify operation // --------------------------------------------------------------------------- - -export async function verifyOperation( - db: Database, - r2: ObjectStore, - operationId: string, - orgId: string, -): Promise { - const errors: string[] = []; - let signatureCheck = false; - let chainCheck = false; - let receiptCheck = false; - - // Load the operation - const operation = await db - .prepare('SELECT * FROM operations WHERE operation_id = ? AND org_id = ?') - .bind(operationId, orgId) - .first(); - - if (!operation) { - throw new AppError(404, 'NOT_FOUND', { key: 'operation.notFound', params: { id: operationId } }); - } - - // Load the EOR from R2 - if (!operation.r2_payload_key) { - errors.push('Operation has no R2 payload key.'); - return { valid: false, checks: { signature: false, chain: false, receipt: false }, errors }; - } - const eorObject = await r2.get(operation.r2_payload_key); - if (!eorObject) { - errors.push('EOR evidence not found in R2.'); - return { valid: false, checks: { signature: false, chain: false, receipt: false }, errors }; - } - - let eor: EOR; - try { - eor = JSON.parse(await eorObject.text()) as EOR; - } catch { - errors.push('Failed to parse EOR evidence from R2.'); - return { valid: false, checks: { signature: false, chain: false, receipt: false }, errors }; - } - - // ---- Signature verification ---- - try { - const agentKey = await db - .prepare('SELECT * FROM agent_keys WHERE kid = ? AND agent_id = ?') - .bind(eor.agent_pubkey_kid, eor.agent_id) - .first(); - - if (agentKey) { - const signableEOR = buildSignableEOR(eor); - const canonicalData = jcsCanonicalise(signableEOR); - const dataBytes = new TextEncoder().encode(canonicalData); - const signatureBytes = base64urlDecode(eor.signature); - const publicKeyBytes = base64urlDecode(agentKey.public_key); - - signatureCheck = await verifyEd25519Signature(publicKeyBytes, signatureBytes, dataBytes); - if (!signatureCheck) { - errors.push('Ed25519 signature verification failed.'); - } - } else { - errors.push(`Signing key "${eor.agent_pubkey_kid}" not found.`); - } - } catch (e) { - errors.push(`Signature check error: ${e instanceof Error ? e.message : String(e)}`); - } - - // ---- Chain hash verification ---- - try { - const expectedChainHash = await computeChainHash( - operation.prev_chain_hash, - operation.payload_hash, - operation.operation_id, - operation.issued_at, - ); - - chainCheck = expectedChainHash === operation.chain_hash; - if (!chainCheck) { - errors.push(`Chain hash mismatch: expected "${expectedChainHash}", stored "${operation.chain_hash}".`); - } - - // Also verify the prev_chain_hash links correctly - if (operation.seq_no > 1) { - const prevOp = await db - .prepare('SELECT chain_hash FROM operations WHERE agent_id = ? AND seq_no = ?') - .bind(operation.agent_id, operation.seq_no - 1) - .first<{ chain_hash: string }>(); - - if (prevOp && prevOp.chain_hash !== operation.prev_chain_hash) { - chainCheck = false; - errors.push(`prev_chain_hash does not match previous operation's chain_hash.`); - } - } - } catch (e) { - errors.push(`Chain check error: ${e instanceof Error ? e.message : String(e)}`); - } - - // ---- Receipt verification ---- - try { - const receiptRow = await db - .prepare('SELECT * FROM receipts WHERE operation_id = ?') - .bind(operationId) - .first(); - - if (receiptRow) { - const receiptObject = await r2.get(receiptRow.r2_receipt_key); - if (receiptObject) { - const ear = JSON.parse(await receiptObject.text()) as EAR; - - // Recompute receipt hash - const receiptFields = { - receipt_version: ear.receipt_version, - receipt_id: ear.receipt_id, - operation_id: ear.operation_id, - org_id: ear.org_id, - agent_id: ear.agent_id, - server_received_at: ear.server_received_at, - seq_no: ear.seq_no, - chain_hash: ear.chain_hash, - queue_message_id: ear.queue_message_id, - }; - - const expectedHash = await computeReceiptHash(receiptFields); - if (expectedHash === ear.receipt_hash) { - receiptCheck = true; - } else { - errors.push('Receipt hash does not match recomputed value.'); - } - } else { - errors.push('Receipt evidence not found in R2.'); - } - } else { - errors.push('No receipt found for this operation.'); - } - } catch (e) { - errors.push(`Receipt check error: ${e instanceof Error ? e.message : String(e)}`); - } - - // ---- Merkle inclusion verification ---- - let merkleCheck: boolean | undefined; - - // Find epoch by operation's created_at timestamp - const epochRecord = await db - .prepare('SELECT * FROM epochs WHERE org_id = ? AND start_time <= ? AND end_time > ?') - .bind(orgId, operation.created_at, operation.created_at) - .first<{ epoch_id: string; org_id: string; start_time: number; end_time: number; root_hash: string; leaf_count: number; r2_epoch_key: string; created_at: number }>(); - - if (epochRecord) { - try { - const epochObj = await r2.get(epochRecord.r2_epoch_key); - if (epochObj) { - const epochData = JSON.parse(await epochObj.text()) as { - merkle: { leafOps: string[]; layers: string[][] }; - }; - // Check if operation's chain_hash is in the leaf layer - const leaves = epochData.merkle.layers[0]; - if (leaves && leaves.includes(operation.chain_hash)) { - // Verify the tree root matches the epoch's root_hash - const { getMerkleProof, verifyMerkleProof } = await import('../utils/merkle.js'); - const tree = { - root: epochRecord.root_hash, - leaves, - leafOps: epochData.merkle.leafOps, - layers: epochData.merkle.layers, - }; - const proof = getMerkleProof(tree, operation.chain_hash); - if (proof) { - merkleCheck = await verifyMerkleProof(proof); - if (!merkleCheck) { - errors.push('Merkle inclusion proof verification failed.'); - } - } else { - merkleCheck = false; - errors.push('Operation chain_hash found in leaves but proof generation failed.'); - } - } else { - merkleCheck = false; - errors.push('Operation chain_hash not found in epoch Merkle tree leaves.'); - } - } - } catch { - // If R2 read fails, leave merkle as undefined (pending) - } - } - // If no epoch found, merkleCheck remains undefined (pending) - - const valid = signatureCheck && chainCheck && receiptCheck; - - return { - valid, - checks: { - signature: signatureCheck, - chain: chainCheck, - receipt: receiptCheck, - ...(merkleCheck !== undefined ? { merkle: merkleCheck } : {}), - }, - errors: errors.length > 0 ? errors : undefined, - }; -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/** - * Build the signable portion of an EOR (excludes the signature field itself). - */ -function buildSignableEOR(eor: EOR): Record { - return { - op_version: eor.op_version, - operation_id: eor.operation_id, - org_id: eor.org_id, - agent_id: eor.agent_id, - issued_at: eor.issued_at, - ttl_ms: eor.ttl_ms, - nonce: eor.nonce, - operation_type: eor.operation_type, - subject: eor.subject, - action: eor.action, - payload: eor.payload, - payload_hash: eor.payload_hash, - prev_chain_hash: eor.prev_chain_hash, - agent_pubkey_kid: eor.agent_pubkey_kid, - }; -} - -/** - * Validate all required EOR fields before processing. - */ -function validateEORFields(eor: EOR, receivedAt: number): void { - // Protocol version - if (eor.op_version !== '1.0') { - throw new AppError(400, 'VALIDATION_ERROR', { key: 'operation.unsupportedVersion', params: { version: eor.op_version } }); - } - - // Required string fields - const requiredStrings: Array<[string, unknown]> = [ - ['operation_id', eor.operation_id], - ['org_id', eor.org_id], - ['agent_id', eor.agent_id], - ['nonce', eor.nonce], - ['operation_type', eor.operation_type], - ['payload_hash', eor.payload_hash], - ['prev_chain_hash', eor.prev_chain_hash], - ['agent_pubkey_kid', eor.agent_pubkey_kid], - ['signature', eor.signature], - ]; - - for (const [field, value] of requiredStrings) { - if (!value || typeof value !== 'string' || value.trim().length === 0) { - throw new AppError(400, 'VALIDATION_ERROR', { key: 'operation.missingField', params: { field } }); - } - } - - // Nonce length - if (eor.nonce.length > MAX_NONCE_LENGTH) { - throw new AppError(400, 'VALIDATION_ERROR', { key: 'operation.nonceTooLong', params: { max: MAX_NONCE_LENGTH } }); - } - - // issued_at must be a number - if (typeof eor.issued_at !== 'number' || eor.issued_at <= 0) { - throw new AppError(400, 'VALIDATION_ERROR', { key: 'operation.invalidIssuedAt' }); - } - - // TTL bounds - if (typeof eor.ttl_ms !== 'number') { - throw new AppError(400, 'VALIDATION_ERROR', { key: 'operation.missingTtl' }); - } - if (eor.ttl_ms < MIN_TTL_MS) { - throw new AppError(400, 'VALIDATION_ERROR', { key: 'operation.ttlTooLow', params: { min: MIN_TTL_MS } }); - } - if (eor.ttl_ms > MAX_TTL_MS) { - throw new AppError(400, 'VALIDATION_ERROR', { key: 'operation.ttlTooHigh', params: { max: MAX_TTL_MS } }); - } - - // TTL expiration check - if (eor.issued_at + eor.ttl_ms < receivedAt) { - throw new AppError(400, 'TTL_EXPIRED'); - } - - // Payload size check (approximate via JSON serialization) - if (eor.payload !== null && eor.payload !== undefined) { - const payloadStr = typeof eor.payload === 'string' ? eor.payload : JSON.stringify(eor.payload); - if (new TextEncoder().encode(payloadStr).byteLength > MAX_PAYLOAD_SIZE) { - throw new AppError(400, 'PAYLOAD_TOO_LARGE'); - } - } -} diff --git a/packages/server/src/services/operation-submit.ts b/packages/server/src/services/operation-submit.ts new file mode 100644 index 0000000..95274d8 --- /dev/null +++ b/packages/server/src/services/operation-submit.ts @@ -0,0 +1,256 @@ +import type { EOR, EAR, Agent, AgentKey, Operation } from '../shared/index.js'; +import { + base64urlDecode, + verifyEd25519Signature, + jcsCanonicalise, + computeChainHash, + computeReceiptHash, + signEd25519, +} from '../utils/crypto.js'; +import { generateUUIDv7 } from '../utils/uuid.js'; +import { AppError } from '../middleware/error-handler.js'; +import type { Database, ObjectStore, Cache, MessageQueue, PreparedStatement } from '../adapters/interfaces.js'; +import { ELYDORA_KID, GENESIS_CHAIN_HASH, buildSignableEOR, validateEORFields } from './operation-eor.js'; + +export async function submitOperation( + db: Database, + r2: ObjectStore, + kv: Cache, + queue: MessageQueue, + eor: EOR, + signingKey: string, +): Promise<{ receipt: EAR }> { + const receivedAt = Date.now(); + + // Step 1: Validate EOR fields + validateEORFields(eor, receivedAt); + + // Step 2: Replay detection via nonce in KV + const nonceKey = `nonce:${eor.org_id}:${eor.nonce}`; + const existingNonce = await kv.get(nonceKey); + if (existingNonce) { + throw new AppError(400, 'REPLAY_DETECTED'); + } + // Store nonce with TTL equal to the operation's TTL (in seconds) + const nonceTtlSeconds = Math.ceil(eor.ttl_ms / 1000); + await kv.put(nonceKey, '1', { expirationTtl: nonceTtlSeconds }); + + // Step 3: Look up agent and check status + const agent = await db + .prepare('SELECT * FROM agents WHERE agent_id = ? AND org_id = ?') + .bind(eor.agent_id, eor.org_id) + .first(); + + if (!agent) { + throw new AppError(404, 'UNKNOWN_AGENT', { key: 'operation.agentNotRegistered', params: { id: eor.agent_id } }); + } + + if (agent.status === 'frozen') { + throw new AppError(403, 'AGENT_FROZEN'); + } + + if (agent.status === 'revoked') { + throw new AppError(403, 'AGENT_REVOKED', { key: 'operation.agentRevoked' }); + } + + // Step 4: Look up key and check status + const agentKey = await db + .prepare('SELECT * FROM agent_keys WHERE kid = ? AND agent_id = ?') + .bind(eor.agent_pubkey_kid, eor.agent_id) + .first(); + + if (!agentKey) { + throw new AppError(400, 'INVALID_SIGNATURE', { key: 'operation.keyNotFoundForAgent', params: { kid: eor.agent_pubkey_kid, id: eor.agent_id } }); + } + + if (agentKey.status === 'revoked') { + throw new AppError(403, 'KEY_REVOKED'); + } + + if (agentKey.status === 'retired') { + throw new AppError(403, 'KEY_RETIRED', { key: 'operation.keyRetired' }); + } + + // Step 5: Verify Ed25519 signature + const signableEOR = buildSignableEOR(eor); + const canonicalData = jcsCanonicalise(signableEOR); + const dataBytes = new TextEncoder().encode(canonicalData); + const signatureBytes = base64urlDecode(eor.signature); + const publicKeyBytes = base64urlDecode(agentKey.public_key); + + const signatureValid = await verifyEd25519Signature(publicKeyBytes, signatureBytes, dataBytes); + + if (!signatureValid) { + throw new AppError(400, 'INVALID_SIGNATURE'); + } + + // Step 6: Verify prev_chain_hash against the latest chain_hash + const latestOp = await db + .prepare( + 'SELECT chain_hash, seq_no FROM operations WHERE agent_id = ? ORDER BY seq_no DESC LIMIT 1', + ) + .bind(eor.agent_id) + .first<{ chain_hash: string; seq_no: number }>(); + + const expectedPrevHash = latestOp ? latestOp.chain_hash : GENESIS_CHAIN_HASH; + const nextSeqNo = latestOp ? latestOp.seq_no + 1 : 1; + + if (eor.prev_chain_hash !== expectedPrevHash) { + throw new AppError(400, 'PREV_HASH_MISMATCH', { key: 'operation.prevHashMismatch', params: { expected: expectedPrevHash, actual: eor.prev_chain_hash } }); + } + + // Step 7: Compute new chain_hash + const chainHash = await computeChainHash( + eor.prev_chain_hash, + eor.payload_hash, + eor.operation_id, + eor.issued_at, + ); + + // Step 8: Store operation in D1 + const r2PayloadKey = `${eor.org_id}/${eor.agent_id}/${eor.operation_id}`; + + const operation: Operation = { + operation_id: eor.operation_id, + org_id: eor.org_id, + agent_id: eor.agent_id, + seq_no: nextSeqNo, + operation_type: eor.operation_type, + issued_at: eor.issued_at, + ttl_ms: eor.ttl_ms, + nonce: eor.nonce, + subject: JSON.stringify(eor.subject), + action: JSON.stringify(eor.action), + payload_hash: eor.payload_hash, + prev_chain_hash: eor.prev_chain_hash, + chain_hash: chainHash, + agent_pubkey_kid: eor.agent_pubkey_kid, + signature: eor.signature, + r2_payload_key: r2PayloadKey, + created_at: receivedAt, + }; + + // Step 9: Store the full EOR envelope in R2 + const eorJson = JSON.stringify(eor); + await r2.put(r2PayloadKey, eorJson, { + httpMetadata: { contentType: 'application/json' }, + customMetadata: { + org_id: eor.org_id, + agent_id: eor.agent_id, + operation_type: eor.operation_type, + }, + }); + + // Step 10: Reserve the queue message ID; the send follows the commit + const queueMessageId = generateUUIDv7(); + const receiptId = generateUUIDv7(); + + // Step 11: Generate EAR receipt + const receiptFields = { + receipt_version: '1.0', + receipt_id: receiptId, + operation_id: eor.operation_id, + org_id: eor.org_id, + agent_id: eor.agent_id, + server_received_at: receivedAt, + seq_no: nextSeqNo, + chain_hash: chainHash, + queue_message_id: queueMessageId, + }; + + const receiptHash = await computeReceiptHash(receiptFields); + + // Sign the receipt hash with the server key + const receiptDataToSign = new TextEncoder().encode(receiptHash); + const elydoraSignature = await signEd25519(signingKey, receiptDataToSign); + + const ear: EAR = { + ...receiptFields, + receipt_hash: receiptHash, + elydora_kid: ELYDORA_KID, + elydora_signature: elydoraSignature, + }; + + // Store receipt in R2 + const r2ReceiptKey = `${eor.org_id}/${eor.agent_id}/receipts/${eor.operation_id}`; + await r2.put(r2ReceiptKey, JSON.stringify(ear), { + httpMetadata: { contentType: 'application/json' }, + }); + + // Step 12: Persist operation and receipt to D1 + const statements: PreparedStatement[] = []; + + statements.push( + db + .prepare( + `INSERT INTO operations (operation_id, org_id, agent_id, seq_no, operation_type, issued_at, ttl_ms, nonce, subject, action, payload_hash, prev_chain_hash, chain_hash, agent_pubkey_kid, signature, r2_payload_key, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .bind( + operation.operation_id, + operation.org_id, + operation.agent_id, + operation.seq_no, + operation.operation_type, + operation.issued_at, + operation.ttl_ms, + operation.nonce, + operation.subject, + operation.action, + operation.payload_hash, + operation.prev_chain_hash, + operation.chain_hash, + operation.agent_pubkey_kid, + operation.signature, + operation.r2_payload_key, + operation.created_at, + ), + ); + + statements.push( + db + .prepare( + `INSERT INTO receipts (receipt_id, operation_id, r2_receipt_key, created_at) + VALUES (?, ?, ?, ?)`, + ) + .bind(receiptId, eor.operation_id, r2ReceiptKey, receivedAt), + ); + + try { + await db.batch(statements); + } catch (error) { + if (!isSeqNoConflict(error)) throw error; + const winner = await db + .prepare('SELECT chain_hash FROM operations WHERE agent_id = ? ORDER BY seq_no DESC LIMIT 1') + .bind(eor.agent_id) + .first<{ chain_hash: string }>(); + if (!winner) throw new Error('seq_no conflict without a stored operation.'); + throw new AppError(400, 'PREV_HASH_MISMATCH', { + key: 'operation.prevHashMismatch', + params: { expected: winner.chain_hash, actual: eor.prev_chain_hash }, + }); + } + + try { + await queue.send(queueMessageId, { + type: 'operation', + operation_id: eor.operation_id, + org_id: eor.org_id, + agent_id: eor.agent_id, + }); + } catch (error) { + console.error('operation.enqueue_failed', { + operation_id: eor.operation_id, + queue_message_id: queueMessageId, + error: error instanceof Error ? error.message : String(error), + }); + } + + return { receipt: ear }; +} + +function isSeqNoConflict(error: unknown): boolean { + if (typeof error !== 'object' || error === null) return false; + const { code, constraint } = error as { code?: unknown; constraint?: unknown }; + return code === '23505' && constraint === 'operations_agent_id_seq_no_key'; +} diff --git a/packages/server/src/services/operation-verify.ts b/packages/server/src/services/operation-verify.ts new file mode 100644 index 0000000..97f5a81 --- /dev/null +++ b/packages/server/src/services/operation-verify.ts @@ -0,0 +1,209 @@ +import type { EOR, EAR, AgentKey, Operation, Receipt, VerifyOperationResponse } from '../shared/index.js'; +import { + base64urlDecode, + verifyEd25519Signature, + jcsCanonicalise, + computeChainHash, + computeReceiptHash, +} from '../utils/crypto.js'; +import { AppError } from '../middleware/error-handler.js'; +import { getMerkleProof, verifyMerkleProof } from '../utils/merkle.js'; +import type { Database, ObjectStore } from '../adapters/interfaces.js'; +import { buildSignableEOR } from './operation-eor.js'; + +export async function verifyOperation( + db: Database, + r2: ObjectStore, + operationId: string, + orgId: string, +): Promise { + const errors: string[] = []; + let signatureCheck = false; + let chainCheck = false; + let receiptCheck = false; + + // Load the operation + const operation = await db + .prepare('SELECT * FROM operations WHERE operation_id = ? AND org_id = ?') + .bind(operationId, orgId) + .first(); + + if (!operation) { + throw new AppError(404, 'NOT_FOUND', { key: 'operation.notFound', params: { id: operationId } }); + } + + // Load the EOR from R2 + if (!operation.r2_payload_key) { + errors.push('Operation has no R2 payload key.'); + return { valid: false, checks: { signature: false, chain: false, receipt: false }, errors }; + } + const eorObject = await r2.get(operation.r2_payload_key); + if (!eorObject) { + errors.push('EOR evidence not found in R2.'); + return { valid: false, checks: { signature: false, chain: false, receipt: false }, errors }; + } + + let eor: EOR; + try { + eor = JSON.parse(await eorObject.text()) as EOR; + } catch { + errors.push('Failed to parse EOR evidence from R2.'); + return { valid: false, checks: { signature: false, chain: false, receipt: false }, errors }; + } + + // ---- Signature verification ---- + try { + const agentKey = await db + .prepare('SELECT * FROM agent_keys WHERE kid = ? AND agent_id = ?') + .bind(eor.agent_pubkey_kid, eor.agent_id) + .first(); + + if (agentKey) { + const signableEOR = buildSignableEOR(eor); + const canonicalData = jcsCanonicalise(signableEOR); + const dataBytes = new TextEncoder().encode(canonicalData); + const signatureBytes = base64urlDecode(eor.signature); + const publicKeyBytes = base64urlDecode(agentKey.public_key); + + signatureCheck = await verifyEd25519Signature(publicKeyBytes, signatureBytes, dataBytes); + if (!signatureCheck) { + errors.push('Ed25519 signature verification failed.'); + } + } else { + errors.push(`Signing key "${eor.agent_pubkey_kid}" not found.`); + } + } catch (e) { + errors.push(`Signature check error: ${e instanceof Error ? e.message : String(e)}`); + } + + // ---- Chain hash verification ---- + try { + const expectedChainHash = await computeChainHash( + operation.prev_chain_hash, + operation.payload_hash, + operation.operation_id, + operation.issued_at, + ); + + chainCheck = expectedChainHash === operation.chain_hash; + if (!chainCheck) { + errors.push(`Chain hash mismatch: expected "${expectedChainHash}", stored "${operation.chain_hash}".`); + } + + // Also verify the prev_chain_hash links correctly + if (operation.seq_no > 1) { + const prevOp = await db + .prepare('SELECT chain_hash FROM operations WHERE agent_id = ? AND seq_no = ?') + .bind(operation.agent_id, operation.seq_no - 1) + .first<{ chain_hash: string }>(); + + if (prevOp && prevOp.chain_hash !== operation.prev_chain_hash) { + chainCheck = false; + errors.push(`prev_chain_hash does not match previous operation's chain_hash.`); + } + } + } catch (e) { + errors.push(`Chain check error: ${e instanceof Error ? e.message : String(e)}`); + } + + // ---- Receipt verification ---- + try { + const receiptRow = await db + .prepare('SELECT * FROM receipts WHERE operation_id = ?') + .bind(operationId) + .first(); + + if (receiptRow) { + const receiptObject = await r2.get(receiptRow.r2_receipt_key); + if (receiptObject) { + const ear = JSON.parse(await receiptObject.text()) as EAR; + + // Recompute receipt hash + const receiptFields = { + receipt_version: ear.receipt_version, + receipt_id: ear.receipt_id, + operation_id: ear.operation_id, + org_id: ear.org_id, + agent_id: ear.agent_id, + server_received_at: ear.server_received_at, + seq_no: ear.seq_no, + chain_hash: ear.chain_hash, + queue_message_id: ear.queue_message_id, + }; + + const expectedHash = await computeReceiptHash(receiptFields); + if (expectedHash === ear.receipt_hash) { + receiptCheck = true; + } else { + errors.push('Receipt hash does not match recomputed value.'); + } + } else { + errors.push('Receipt evidence not found in R2.'); + } + } else { + errors.push('No receipt found for this operation.'); + } + } catch (e) { + errors.push(`Receipt check error: ${e instanceof Error ? e.message : String(e)}`); + } + + // ---- Merkle inclusion verification ---- + let merkleCheck: boolean | undefined; + + // Find epoch by operation's created_at timestamp + const epochRecord = await db + .prepare('SELECT * FROM epochs WHERE org_id = ? AND start_time <= ? AND end_time > ?') + .bind(orgId, operation.created_at, operation.created_at) + .first<{ epoch_id: string; org_id: string; start_time: number; end_time: number; root_hash: string; leaf_count: number; r2_epoch_key: string; created_at: number }>(); + + if (epochRecord) { + try { + const epochObj = await r2.get(epochRecord.r2_epoch_key); + if (epochObj) { + const epochData = JSON.parse(await epochObj.text()) as { + merkle: { leafOps: string[]; layers: string[][] }; + }; + // Check if operation's chain_hash is in the leaf layer + const leaves = epochData.merkle.layers[0]; + if (leaves && leaves.includes(operation.chain_hash)) { + // Verify the tree root matches the epoch's root_hash + const tree = { + root: epochRecord.root_hash, + leaves, + leafOps: epochData.merkle.leafOps, + layers: epochData.merkle.layers, + }; + const proof = getMerkleProof(tree, operation.chain_hash); + if (proof) { + merkleCheck = await verifyMerkleProof(proof); + if (!merkleCheck) { + errors.push('Merkle inclusion proof verification failed.'); + } + } else { + merkleCheck = false; + errors.push('Operation chain_hash found in leaves but proof generation failed.'); + } + } else { + merkleCheck = false; + errors.push('Operation chain_hash not found in epoch Merkle tree leaves.'); + } + } + } catch { + // If R2 read fails, leave merkle as undefined (pending) + } + } + // If no epoch found, merkleCheck remains undefined (pending) + + const valid = signatureCheck && chainCheck && receiptCheck; + + return { + valid, + checks: { + signature: signatureCheck, + chain: chainCheck, + receipt: receiptCheck, + ...(merkleCheck !== undefined ? { merkle: merkleCheck } : {}), + }, + errors: errors.length > 0 ? errors : undefined, + }; +} diff --git a/packages/server/src/shared/constants/errors.ts b/packages/server/src/shared/constants/errors.ts index c8d406b..b4a0ba4 100644 --- a/packages/server/src/shared/constants/errors.ts +++ b/packages/server/src/shared/constants/errors.ts @@ -5,7 +5,9 @@ export const ERROR_CODES: Readonly> = { INVALID_SIGNATURE: 'The operation signature is invalid.', UNKNOWN_AGENT: 'The specified agent does not exist.', KEY_REVOKED: 'The signing key has been revoked.', + KEY_RETIRED: 'The signing key has been retired.', AGENT_FROZEN: 'The agent is frozen and cannot submit operations.', + AGENT_REVOKED: 'The agent has been revoked.', TTL_EXPIRED: 'The operation TTL has expired.', REPLAY_DETECTED: 'A duplicate nonce was detected (replay attack).', PREV_HASH_MISMATCH: 'The previous chain hash does not match.', diff --git a/packages/server/src/shared/types/enums.ts b/packages/server/src/shared/types/enums.ts index b6401e4..8cb4fc0 100644 --- a/packages/server/src/shared/types/enums.ts +++ b/packages/server/src/shared/types/enums.ts @@ -56,7 +56,9 @@ export type ErrorCode = | 'INVALID_SIGNATURE' | 'UNKNOWN_AGENT' | 'KEY_REVOKED' + | 'KEY_RETIRED' | 'AGENT_FROZEN' + | 'AGENT_REVOKED' | 'TTL_EXPIRED' | 'REPLAY_DETECTED' | 'PREV_HASH_MISMATCH' diff --git a/packages/server/src/shared/types/protocol.ts b/packages/server/src/shared/types/protocol.ts index 5d99cd2..c4fc54a 100644 --- a/packages/server/src/shared/types/protocol.ts +++ b/packages/server/src/shared/types/protocol.ts @@ -1,9 +1,4 @@ -/** - * Elydora Operation Record (EOR) - * - * The fundamental unit of auditable activity in the Elydora protocol. - * Signed by the agent's Ed25519 key and chained via prev_chain_hash. - */ +/** Elydora Operation Record (EOR) The fundamental unit of auditable activity in the Elydora protocol. */ export interface EOR { /** Protocol version, always "1.0" */ readonly op_version: '1.0'; @@ -37,11 +32,7 @@ export interface EOR { readonly signature: string; } -/** - * Elydora Chain Hash (ECH) - * - * Represents the chain hash computation linking operations together. - */ +/** Elydora Chain Hash (ECH) Represents the chain hash computation linking operations together. */ export interface ECH { /** Previous chain hash (base64url) */ readonly prev_ech: string; @@ -55,11 +46,7 @@ export interface ECH { readonly chain_hash: string; } -/** - * Elydora Acknowledgment Receipt (EAR) - * - * Server-issued receipt confirming acceptance of an operation. - */ +/** Elydora Acknowledgment Receipt (EAR) Server-issued receipt confirming acceptance of an operation. */ export interface EAR { /** Receipt version */ readonly receipt_version: string; @@ -87,11 +74,7 @@ export interface EAR { readonly elydora_signature: string; } -/** - * Elydora Epoch Record (EER) - * - * Periodic Merkle-tree rollup anchoring operations to a root hash. - */ +/** Elydora Epoch Record (EER) Periodic Merkle-tree rollup anchoring operations to a root hash. */ export interface EER { /** Unique epoch identifier */ readonly epoch_id: string; diff --git a/packages/server/src/types.ts b/packages/server/src/types.ts index 5ce18a5..82e2ca0 100644 --- a/packages/server/src/types.ts +++ b/packages/server/src/types.ts @@ -1,10 +1,4 @@ -/** - * Environment bindings for the ElydoraOpenSource Node.js server. - * - * Replaces Cloudflare-specific bindings (D1Database, R2Bucket, KVNamespace, - * Queue) with adapter interfaces backed by PostgreSQL, MinIO, Redis, and - * BullMQ respectively. - */ +/** Environment bindings for the ElydoraOpenSource Node.js server. */ import type { Database, ObjectStore, Cache, MessageQueue } from './adapters/interfaces.js'; @@ -46,9 +40,7 @@ export interface Env { TSA_URL?: string; } -/** - * Variables set by middleware and available via `c.get(...)` / `c.var`. - */ +/** Variables set by middleware and available via `c.get(...)` / `c.var`. */ export interface AppVariables { /** UUIDv7 request identifier injected by the request-id middleware */ request_id: string; diff --git a/packages/server/src/utils/crypto.ts b/packages/server/src/utils/crypto.ts index 726bd57..ee338c6 100644 --- a/packages/server/src/utils/crypto.ts +++ b/packages/server/src/utils/crypto.ts @@ -1,9 +1,4 @@ -/** - * Cryptographic utilities for the Elydora API. - * - * All operations use the Web Crypto API (SubtleCrypto) which is available - * natively in Node.js 20+ with full Ed25519 support. - */ +/** Cryptographic utilities for the Elydora API. */ // --------------------------------------------------------------------------- // Base64url helpers (RFC 4648 section 5) @@ -52,11 +47,7 @@ export async function sha256Base64url(data: BufferSource | string): Promise { return crypto.subtle.importKey( 'raw', @@ -67,14 +58,7 @@ export async function importEd25519PublicKey(publicKeyBytes: Uint8Array): Promis ); } -/** - * Verify an Ed25519 signature over data. - * - * @param publicKeyBytes - 32-byte raw public key - * @param signatureBytes - 64-byte Ed25519 signature - * @param data - The signed data - * @returns true if the signature is valid - */ +/** Verify an Ed25519 signature over data. */ export async function verifyEd25519Signature( publicKeyBytes: Uint8Array, signatureBytes: Uint8Array, @@ -97,15 +81,7 @@ export async function verifyEd25519Signature( // Ed25519 signing (server-side receipt signing) // --------------------------------------------------------------------------- -/** - * PKCS8 DER prefix for wrapping a 32-byte Ed25519 seed into a valid PKCS8 key. - * - * SEQUENCE { - * INTEGER 0 - * SEQUENCE { OID 1.3.101.112 } - * OCTET STRING { OCTET STRING { <32 bytes> } } - * } - */ +/** PKCS8 DER prefix for wrapping a 32-byte Ed25519 seed into a valid PKCS8 key. */ const ED25519_PKCS8_PREFIX = new Uint8Array([ 0x30, 0x2e, // SEQUENCE (46 bytes) 0x02, 0x01, 0x00, // INTEGER 0 @@ -123,14 +99,7 @@ function wrapSeedAsPkcs8(seed: Uint8Array): Uint8Array { return pkcs8; } -/** - * Import a raw Ed25519 private key for signing. - * - * Node.js expects PKCS8 format for Ed25519 private keys. - * We wrap the 32-byte seed in a minimal PKCS8 envelope. - * - * @param privateKeyBase64url - base64url-encoded 32-byte Ed25519 private key seed - */ +/** Import a raw Ed25519 private key for signing. */ export async function importEd25519PrivateKey(privateKeyBase64url: string): Promise { const seed = base64urlDecode(privateKeyBase64url); const pkcs8 = wrapSeedAsPkcs8(seed); @@ -144,13 +113,7 @@ export async function importEd25519PrivateKey(privateKeyBase64url: string): Prom ); } -/** - * Sign data with an Ed25519 private key. - * - * @param privateKeyBase64url - base64url-encoded 32-byte private key seed - * @param data - data to sign - * @returns base64url-encoded 64-byte signature - */ +/** Sign data with an Ed25519 private key. */ export async function signEd25519( privateKeyBase64url: string, data: Uint8Array, @@ -164,12 +127,7 @@ export async function signEd25519( return base64urlEncode(new Uint8Array(signature)); } -/** - * Export the public key from an Ed25519 private key (for JWKS). - * - * @param privateKeyBase64url - base64url-encoded 32-byte private key seed - * @returns base64url-encoded 32-byte public key - */ +/** Export the public key from an Ed25519 private key (for JWKS). */ export async function deriveEd25519PublicKey(privateKeyBase64url: string): Promise { const seed = base64urlDecode(privateKeyBase64url); const pkcs8 = wrapSeedAsPkcs8(seed); @@ -178,7 +136,7 @@ export async function deriveEd25519PublicKey(privateKeyBase64url: string): Promi 'pkcs8', pkcs8 as unknown as Uint8Array, { name: 'Ed25519' }, - true, // extractable — needed to export the public key + true, // extractable: needed to export the public key ['sign'], ); // Export as JWK to get the public 'x' parameter @@ -191,15 +149,7 @@ export async function deriveEd25519PublicKey(privateKeyBase64url: string): Promi // JCS (JSON Canonicalization Scheme - RFC 8785) // --------------------------------------------------------------------------- -/** - * Canonicalize a JSON value according to JCS (RFC 8785). - * - * JCS specifies: - * - Object keys sorted lexicographically by UTF-16 code units - * - No whitespace - * - Numbers serialized using ES2015 Number serialization - * - Strings serialized with minimal escaping per JSON spec - */ +/** Canonicalize a JSON value according to JCS (RFC 8785). */ export function jcsCanonicalise(value: unknown): string { if (value === null || value === undefined) { return 'null'; @@ -237,13 +187,7 @@ export function jcsCanonicalise(value: unknown): string { // Chain hash computation // --------------------------------------------------------------------------- -/** - * Compute the chain hash for an operation. - * - * chain_hash = SHA-256(prev_chain_hash + payload_hash + operation_id + issued_at) - * - * All inputs are concatenated as UTF-8 strings separated by '|'. - */ +/** Compute the chain hash for an operation. */ export async function computeChainHash( prevChainHash: string, payloadHash: string, @@ -258,11 +202,7 @@ export async function computeChainHash( // Receipt hash computation // --------------------------------------------------------------------------- -/** - * Compute the hash of an EAR receipt for signing. - * - * Hashes the canonicalized receipt fields (excluding the signature fields themselves). - */ +/** Compute the hash of an EAR receipt for signing. */ export async function computeReceiptHash(receiptFields: { receipt_version: string; receipt_id: string; diff --git a/packages/server/src/utils/merkle.ts b/packages/server/src/utils/merkle.ts index 70293d6..ba0993e 100644 --- a/packages/server/src/utils/merkle.ts +++ b/packages/server/src/utils/merkle.ts @@ -1,10 +1,4 @@ -/** - * SHA-256 Merkle tree implementation for epoch rollups. - * - * Leaf hashes are sorted lexicographically before tree construction. - * Internal nodes are computed by concatenating the raw bytes (not base64url - * strings) of their children and hashing the result with SHA-256. - */ +/** SHA-256 Merkle tree implementation for epoch rollups. */ import { sha256Base64url, base64urlDecode } from './crypto.js'; @@ -38,10 +32,7 @@ export interface MerkleProof { // Internal helper // --------------------------------------------------------------------------- -/** - * Compute the parent hash from two child hashes. - * Decodes both base64url hashes to raw bytes, concatenates, then SHA-256. - */ +/** Compute the parent hash from two child hashes. */ async function hashPair(left: string, right: string): Promise { const leftBytes = base64urlDecode(left); const rightBytes = base64urlDecode(right); @@ -55,12 +46,7 @@ async function hashPair(left: string, right: string): Promise { // Build // --------------------------------------------------------------------------- -/** - * Build a Merkle tree from leaf hashes and their corresponding operation IDs. - * - * Leaves are sorted lexicographically (with operationIds reordered to match). - * If a layer has an odd number of nodes the last node is duplicated. - */ +/** Build a Merkle tree from leaf hashes and their corresponding operation IDs. */ export async function buildMerkleTree( leafHashes: string[], operationIds: string[], @@ -103,11 +89,7 @@ export async function buildMerkleTree( // Proof generation // --------------------------------------------------------------------------- -/** - * Generate a Merkle inclusion proof for a given leaf hash. - * - * Returns null if the leaf is not found in the tree. - */ +/** Generate a Merkle inclusion proof for a given leaf hash. */ export function getMerkleProof( tree: MerkleTree, leafHash: string, @@ -142,9 +124,7 @@ export function getMerkleProof( // Proof verification // --------------------------------------------------------------------------- -/** - * Verify a Merkle inclusion proof by recomputing the root. - */ +/** Verify a Merkle inclusion proof by recomputing the root. */ export async function verifyMerkleProof(proof: MerkleProof): Promise { let current = proof.leaf; diff --git a/packages/server/src/utils/pagination.ts b/packages/server/src/utils/pagination.ts index 5764b55..0309318 100644 --- a/packages/server/src/utils/pagination.ts +++ b/packages/server/src/utils/pagination.ts @@ -1,11 +1,4 @@ -/** - * Cursor-based pagination helpers. - * - * Cursors are opaque base64url-encoded JSON objects containing the - * created_at timestamp and primary key of the last item in the current - * page. This design avoids OFFSET-based pagination which degrades at - * scale and is susceptible to drift when rows are inserted concurrently. - */ +/** Cursor-based pagination helpers. */ import { base64urlDecode, base64urlEncode } from './crypto.js'; @@ -17,24 +10,14 @@ export interface CursorPayload { readonly id: string; } -/** - * Encode a cursor payload into an opaque cursor string. - * - * @param payload - The cursor data to encode - * @returns An opaque base64url-encoded cursor string - */ +/** Encode a cursor payload into an opaque cursor string. */ export function encodeCursor(payload: CursorPayload): string { const json = JSON.stringify(payload); const bytes = new TextEncoder().encode(json); return base64urlEncode(bytes); } -/** - * Decode an opaque cursor string back into its payload. - * - * @param cursor - The opaque cursor string - * @returns The decoded cursor payload, or null if invalid - */ +/** Decode an opaque cursor string back into its payload. */ export function decodeCursor(cursor: string): CursorPayload | null { try { const bytes = base64urlDecode(cursor); diff --git a/packages/server/src/utils/pdf.ts b/packages/server/src/utils/pdf.ts index a961263..f9e9a22 100644 --- a/packages/server/src/utils/pdf.ts +++ b/packages/server/src/utils/pdf.ts @@ -1,15 +1,4 @@ -/** - * Branded PDF generator for Elydora compliance exports. - * - * Produces professional multi-page PDF/1.4 documents with: - * - Elydora logo and brand header bar - * - Structured metadata section - * - Formatted operations table with alternating row stripes - * - Branded footer with page numbers - * - * Uses only built-in PDF Type1 fonts (Helvetica, Courier) — - * no external dependencies, works entirely in the Node.js runtime. - */ +/** Branded PDF generator for Elydora compliance exports. */ // --------------------------------------------------------------------------- // Types @@ -46,15 +35,15 @@ const CW = PW - ML - MR; // 500pt content width // Header bar const HDR_H = 56; -const HDR_Y = PH - HDR_H; // 736 — bottom edge of header +const HDR_Y = PH - HDR_H; // 736: bottom edge of header // Footer const FTR_LINE = 44; // y of separator line const FTR_TEXT = 28; // y of footer text baseline // Content area -const CT = HDR_Y - 20; // 716 — content top -const CB = FTR_LINE + 12; // 56 — content bottom +const CT = HDR_Y - 20; // 716: content top +const CB = FTR_LINE + 12; // 56 : content bottom // Operations table const ROW_H = 12; // row height @@ -114,11 +103,7 @@ function txt(s: string, x: number, y: number, font: string, size: number, rgb: s return `BT ${rgb} rg ${font} ${size} Tf 1 0 0 1 ${x} ${y} Tm (${esc(s)}) Tj ET`; } -/** - * Draw the Elydora "E" logo mark. - * SVG source: M3 2H21V7H8V10H17V15H8V18H21V23H3V2Z (viewBox 0 0 24 24) - * Transform flips Y for PDF coordinate system. - */ +/** Draw the Elydora "E" logo mark. */ function drawLogo(x: number, y: number, size: number, rgb: string): string { const sc = size / 24; return [ @@ -131,7 +116,7 @@ function drawLogo(x: number, y: number, size: number, rgb: string): string { } // --------------------------------------------------------------------------- -// Section renderers — each appends drawing instructions to the page array +// Section renderers: each appends drawing instructions to the page array // --------------------------------------------------------------------------- /** Dark header bar with logo and brand text (every page). */ @@ -321,7 +306,7 @@ function assemblePDF(pageStreams: string[]): Uint8Array { // Obj 1: Catalog objContents.push('<< /Type /Catalog /Pages 2 0 R >>'); - // Obj 2: Pages (placeholder — patched after page objects are created) + // Obj 2: Pages (placeholder: patched after page objects are created) objContents.push(''); // Obj 3–5: Built-in fonts diff --git a/packages/server/src/utils/tsa.ts b/packages/server/src/utils/tsa.ts index dc80817..4be04a1 100644 --- a/packages/server/src/utils/tsa.ts +++ b/packages/server/src/utils/tsa.ts @@ -1,49 +1,27 @@ -/** - * RFC 3161 Timestamping Authority (TSA) client utilities. - * - * Constructs a minimal DER-encoded TimeStampReq and submits it to a - * public TSA endpoint. No ASN.1 library required — the 59-byte request - * is hand-assembled using the same DER-prefix pattern as the PKCS8 - * wrapper in crypto.ts. - */ +/** RFC 3161 Timestamping Authority (TSA) client utilities. */ /** Default public TSA endpoint (Sectigo / Comodo). */ export const DEFAULT_TSA_URL = 'http://timestamp.sectigo.com'; -/** - * Build a minimal RFC 3161 TimeStampReq (DER-encoded, 59 bytes). - * - * Structure: - * SEQUENCE { - * INTEGER 1 -- version - * SEQUENCE { -- messageImprint - * SEQUENCE { -- hashAlgorithm (SHA-256) - * OID 2.16.840.1.101.3.4.2.1 - * NULL - * } - * OCTET STRING <32 bytes> -- hashedMessage - * } - * BOOLEAN TRUE -- certReq - * } - */ +/** Build a minimal RFC 3161 TimeStampReq (DER-encoded, 59 bytes). */ export function buildTimeStampReq(sha256Hash: Uint8Array): Uint8Array { if (sha256Hash.length !== 32) { throw new Error(`Expected 32-byte SHA-256 hash, got ${sha256Hash.length}`); } - // DER prefix (24 bytes) — everything before the 32-byte hash + // DER prefix (24 bytes): everything before the 32-byte hash const prefix = new Uint8Array([ 0x30, 0x39, // SEQUENCE (57 bytes) 0x02, 0x01, 0x01, // INTEGER 1 (version v1) - 0x30, 0x31, // SEQUENCE (49 bytes) — MessageImprint - 0x30, 0x0d, // SEQUENCE (13 bytes) — AlgorithmIdentifier + 0x30, 0x31, // SEQUENCE (49 bytes): MessageImprint + 0x30, 0x0d, // SEQUENCE (13 bytes): AlgorithmIdentifier 0x06, 0x09, // OID (9 bytes) 0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x01, // SHA-256 (2.16.840.1.101.3.4.2.1) 0x05, 0x00, // NULL (parameters) 0x04, 0x20, // OCTET STRING (32 bytes) ]); - // DER suffix (3 bytes) — certReq = TRUE + // DER suffix (3 bytes): certReq = TRUE const suffix = new Uint8Array([0x01, 0x01, 0xff]); // Assemble: prefix + hash + suffix = 24 + 32 + 3 = 59 bytes @@ -55,13 +33,7 @@ export function buildTimeStampReq(sha256Hash: Uint8Array): Uint8Array { return req; } -/** - * Submit a SHA-256 hash to a TSA and return the raw DER TimeStampResp. - * - * @param sha256Hash - 32-byte SHA-256 digest to timestamp - * @param tsaUrl - TSA endpoint (defaults to Sectigo) - * @returns Raw DER-encoded TimeStampResp bytes - */ +/** Submit a SHA-256 hash to a TSA and return the raw DER TimeStampResp. */ export async function requestTimestamp( sha256Hash: Uint8Array, tsaUrl: string = DEFAULT_TSA_URL, diff --git a/packages/server/src/utils/uuid.ts b/packages/server/src/utils/uuid.ts index 3c97d44..7c9bc6e 100644 --- a/packages/server/src/utils/uuid.ts +++ b/packages/server/src/utils/uuid.ts @@ -1,24 +1,6 @@ -/** - * UUIDv7 generator conforming to RFC 9562. - * - * UUIDv7 embeds a Unix-millisecond timestamp in the most-significant 48 bits, - * making identifiers lexicographically sortable and eliminating the need for - * a separate created_at column in many cases. - * - * This implementation uses the Web Crypto API (crypto.getRandomValues) which - * is available natively in Node.js 20+. - */ +/** UUIDv7 generator conforming to RFC 9562. */ -/** - * Generate a UUIDv7 string. - * - * Layout (128 bits total): - * bits 0-47 : Unix timestamp in milliseconds - * bits 48-51 : version (0b0111 = 7) - * bits 52-63 : random_a (12 bits) - * bits 64-65 : variant (0b10) - * bits 66-127: random_b (62 bits) - */ +/** Generate a UUIDv7 string. */ export function generateUUIDv7(): string { const now = Date.now(); const bytes = new Uint8Array(16); @@ -46,9 +28,7 @@ export function generateUUIDv7(): string { return formatUUID(bytes); } -/** - * Format a 16-byte array as a standard UUID string (8-4-4-4-12). - */ +/** Format a 16-byte array as a standard UUID string (8-4-4-4-12). */ function formatUUID(bytes: Uint8Array): string { const hex = Array.from(bytes) .map((b) => b.toString(16).padStart(2, '0')) diff --git a/packages/server/src/worker.ts b/packages/server/src/worker.ts index 5bfcbd2..645a447 100644 --- a/packages/server/src/worker.ts +++ b/packages/server/src/worker.ts @@ -1,8 +1,4 @@ -/** - * BullMQ queue worker — processes operation, export, and TSA jobs. - * - * Run as a separate Node.js process: node dist/worker.js - */ +/** BullMQ queue worker: processes operation, export, and TSA jobs. */ import 'dotenv/config'; import { Worker, type Job } from 'bullmq'; diff --git a/sdks/go/README.md b/sdks/go/README.md index 99a2d4a..4ee7ca0 100644 --- a/sdks/go/README.md +++ b/sdks/go/README.md @@ -10,6 +10,8 @@ go get github.com/Elydora-Infrastructure/Elydora-Go-SDK/v2 Requires Go 1.21+. The CLI uses focused TOML and JSONC parsers to preserve user-owned agent configuration. +Upgrading from 2.0.x: run `elydora install` again for every agent. The generated runtimes changed, and `elydora status` does not detect stale runtimes for every adapter. + ## Quick Start ```go @@ -23,15 +25,12 @@ import ( ) func main() { - // Initialize the client with your API token. - // Obtain an API token by signing in via the Elydora console or: - // POST /api/auth/sign-in/email -> get session token - // POST /v1/auth/token -> exchange for long-lived API token + // Create client with an API token issued in the Console client, err := elydora.NewClient(&elydora.Config{ OrgID: "org-123", AgentID: "my-agent-id", PrivateKey: "", - Token: "your-api-token", + Token: "", }) if err != nil { log.Fatal(err) @@ -110,8 +109,9 @@ Qwen Code `0.20.0` installation writes native user hooks to `$QWEN_HOME/settings |---------|-------------| | `elydora install` | Install Elydora audit hook for a coding agent | | `elydora uninstall` | Remove Elydora audit hook for a coding agent | -| `elydora status` | Show installation status for all agents | +| `elydora status` | Show installation status for one agent or all agents | | `elydora agents` | List supported coding agents | +| `elydora version` | Print the CLI version | ### Supported Agents @@ -158,24 +158,24 @@ client.SetToken("new-api-token") ### Authentication -Authentication uses Better Auth. Register and sign in via the Elydora console or the Better Auth endpoints, then issue a long-lived API token for SDK use: - ```go -// Sign up (Better Auth) — use the console or call directly: -// POST /api/auth/sign-up/email { email, password, name } -// -// Sign in (Better Auth) — get a session token: -// POST /api/auth/sign-in/email { email, password } -// -// Issue a long-lived API token from an active session: +// Get the authenticated user and current organization +me, err := client.GetMe() +// me.User.Email, me.User.Role, me.CurrentOrganization.OrgID + +// Issue an API token; this call needs a client whose Token is a Console session token tokenResp, err := client.IssueApiToken(&elydora.IssueApiTokenRequest{ TTLSeconds: &ttlSeconds, }) +client.SetToken(tokenResp.Token) -// Update the token on an existing client instance at runtime -client.SetToken("new-api-token") +// Rotate the installed API token; the response carries the replacement +rotated, err := client.RotateApiToken() +client.SetToken(rotated.Token) ``` +`elydora.Register` and `elydora.Login` remain for password accounts and are deprecated; use the Console sign-in flow. + ### Operations ```go @@ -215,8 +215,11 @@ agent, err := client.RegisterAgent(&elydora.RegisterAgentRequest{ // Get agent details details, err := client.GetAgent(agentID) +// Change the integration type +updated, err := client.UpdateAgent(agentID, elydora.IntegrationTypeClaudecode) + // Freeze an agent -err := client.FreezeAgent(agentID, "security review") +frozen, err := client.FreezeAgent(agentID, "security review") // Revoke a key err := client.RevokeKey(agentID, kid, "key rotation") @@ -225,7 +228,7 @@ err := client.RevokeKey(agentID, kid, "key rotation") agents, err := client.ListAgents() // Unfreeze a previously frozen agent -err := client.UnfreezeAgent(agentID, "review complete") +unfrozen, err := client.UnfreezeAgent(agentID, "review complete") // Delete an agent permanently deleted, err := client.DeleteAgent(agentID) @@ -266,6 +269,25 @@ detail, err := client.GetExport(exportID) data, err := client.DownloadExport(exportID) ``` +### Webhooks + +```go +webhook, err := client.RegisterWebhook(&elydora.RegisterWebhookRequest{ + EndpointURL: "https://example.com/elydora", + Events: []string{"operation.accepted"}, + Secret: "", +}) +webhooks, err := client.ListWebhooks() +err = client.DeleteWebhook(webhook.Webhook.WebhookID) +``` + +### Organization + +```go +members, err := client.ListMembers() +events, err := client.ListAdminEvents(50) +``` + ### JWKS ```go @@ -277,7 +299,11 @@ jwks, err := client.GetJWKS() ```go // Check API health (no authentication required, does not need a client) health, err := elydora.Health("https://api.elydora.com") -// health.Status, health.Version, health.ProtocolVersion, health.Timestamp +// health.Status, health.Version, health.ProtocolVersion, health.Capabilities, health.Timestamp + +// Check dependency health (D1, auth storage, R2, KV) +deep, err := elydora.DeepHealth("https://api.elydora.com") +// deep.Dependencies.D1.Status, deep.Dependencies.KV.LatencyMs ``` ### Constants @@ -295,8 +321,11 @@ elydora.GenesisChainHash // "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" | `KeyStatus` | `KeyStatusActive`, `KeyStatusRetired`, `KeyStatusRevoked` | | `ExportStatus` | `ExportStatusQueued`, `ExportStatusRunning`, `ExportStatusDone`, `ExportStatusFailed` | | `ExportFormat` | `ExportFormatJSON`, `ExportFormatPDF` | +| `WebhookStatus` | `WebhookStatusActive`, `WebhookStatusDisabled` | +| `IntegrationType` | One constant per supported agent key plus `IntegrationTypeEnterprise`, `IntegrationTypeGUI`, `IntegrationTypeSDK`, `IntegrationTypeOther` | +| `AdminAction` | One constant per admin event action, for example `AdminActionAgentFreeze` | | `RbacRole` | `RbacRoleOrgOwner`, `RbacRoleSecurityAdmin`, `RbacRoleComplianceAuditor`, `RbacRoleReadonlyInvestigator`, `RbacRoleIntegrationEngineer` | -| `ErrorCode` | `ErrorCodeInvalidSignature`, `ErrorCodeUnknownAgent`, `ErrorCodeKeyRevoked`, `ErrorCodeAgentFrozen`, `ErrorCodeTTLExpired`, `ErrorCodeReplayDetected`, `ErrorCodePrevHashMismatch`, `ErrorCodePayloadTooLarge`, `ErrorCodeRateLimited`, `ErrorCodeInternalError`, `ErrorCodeUnauthorized`, `ErrorCodeForbidden`, `ErrorCodeNotFound`, `ErrorCodeValidationError` | +| `ErrorCode` | `ErrorCodeInvalidSignature`, `ErrorCodeUnknownAgent`, `ErrorCodeKeyRevoked`, `ErrorCodeKeyRetired`, `ErrorCodeAgentFrozen`, `ErrorCodeAgentRevoked`, `ErrorCodeTTLExpired`, `ErrorCodeReplayDetected`, `ErrorCodePrevHashMismatch`, `ErrorCodePayloadTooLarge`, `ErrorCodeRateLimited`, `ErrorCodeInternalError`, `ErrorCodeUnauthorized`, `ErrorCodeForbidden`, `ErrorCodeNotFound`, `ErrorCodeValidationError` | ## Error Handling diff --git a/sdks/go/admin_events.go b/sdks/go/admin_events.go new file mode 100644 index 0000000..bb5d221 --- /dev/null +++ b/sdks/go/admin_events.go @@ -0,0 +1,16 @@ +package elydora + +import "fmt" + +// ListAdminEvents retrieves recent administrative events; limit 0 uses the server default. +func (c *Client) ListAdminEvents(limit int) (*ListAdminEventsResponse, error) { + path := "/v1/admin/events" + if limit > 0 { + path = fmt.Sprintf("%s?limit=%d", path, limit) + } + var result ListAdminEventsResponse + if err := c.doGet(path, &result); err != nil { + return nil, err + } + return &result, nil +} diff --git a/sdks/go/agents.go b/sdks/go/agents.go index ff894da..fc66323 100644 --- a/sdks/go/agents.go +++ b/sdks/go/agents.go @@ -3,17 +3,11 @@ package elydora import ( "fmt" "net/http" + "net/url" ) // RegisterAgent registers a new AI agent within the organization. func (c *Client) RegisterAgent(req *RegisterAgentRequest) (*RegisterAgentResponse, error) { - if req == nil { - return nil, fmt.Errorf("elydora: register agent request must not be nil") - } - if !req.IntegrationType.IsValid() { - return nil, fmt.Errorf("elydora: invalid integration_type %q", req.IntegrationType) - } - var result RegisterAgentResponse if err := c.doPost("/v1/agents/register", req, &result); err != nil { return nil, err @@ -24,22 +18,26 @@ func (c *Client) RegisterAgent(req *RegisterAgentRequest) (*RegisterAgentRespons // GetAgent retrieves an agent by ID. func (c *Client) GetAgent(agentID string) (*GetAgentResponse, error) { var result GetAgentResponse - if err := c.doGet(fmt.Sprintf("/v1/agents/%s", agentID), &result); err != nil { + if err := c.doGet(fmt.Sprintf("/v1/agents/%s", url.PathEscape(agentID)), &result); err != nil { return nil, err } return &result, nil } // FreezeAgent freezes an agent, preventing it from submitting operations. -func (c *Client) FreezeAgent(agentID, reason string) error { - return c.doPost(fmt.Sprintf("/v1/agents/%s/freeze", agentID), &FreezeAgentRequest{ +func (c *Client) FreezeAgent(agentID, reason string) (*FreezeAgentResponse, error) { + var result FreezeAgentResponse + if err := c.doPost(fmt.Sprintf("/v1/agents/%s/freeze", url.PathEscape(agentID)), &FreezeAgentRequest{ Reason: reason, - }, nil) + }, &result); err != nil { + return nil, err + } + return &result, nil } // RevokeKey revokes a specific key for an agent. func (c *Client) RevokeKey(agentID, kid, reason string) error { - return c.doPost(fmt.Sprintf("/v1/agents/%s/revoke", agentID), &RevokeAgentRequest{ + return c.doPost(fmt.Sprintf("/v1/agents/%s/revoke", url.PathEscape(agentID)), &RevokeAgentRequest{ KID: kid, Reason: reason, }, nil) @@ -55,16 +53,31 @@ func (c *Client) ListAgents() (*ListAgentsResponse, error) { } // UnfreezeAgent unfreezes a previously frozen agent. -func (c *Client) UnfreezeAgent(agentID, reason string) error { - return c.doPost(fmt.Sprintf("/v1/agents/%s/unfreeze", agentID), &UnfreezeAgentRequest{ +func (c *Client) UnfreezeAgent(agentID, reason string) (*UnfreezeAgentResponse, error) { + var result UnfreezeAgentResponse + if err := c.doPost(fmt.Sprintf("/v1/agents/%s/unfreeze", url.PathEscape(agentID)), &UnfreezeAgentRequest{ Reason: reason, - }, nil) + }, &result); err != nil { + return nil, err + } + return &result, nil +} + +// UpdateAgent updates an agent's integration type. +func (c *Client) UpdateAgent(agentID string, integrationType IntegrationType) (*UpdateAgentResponse, error) { + var result UpdateAgentResponse + if err := c.doRequest(http.MethodPatch, fmt.Sprintf("/v1/agents/%s", url.PathEscape(agentID)), &UpdateAgentRequest{ + IntegrationType: integrationType, + }, &result); err != nil { + return nil, err + } + return &result, nil } // DeleteAgent permanently deletes an agent and all associated data. func (c *Client) DeleteAgent(agentID string) (*DeleteAgentResponse, error) { var result DeleteAgentResponse - if err := c.doRequest(http.MethodDelete, fmt.Sprintf("/v1/agents/%s", agentID), nil, &result); err != nil { + if err := c.doRequest(http.MethodDelete, fmt.Sprintf("/v1/agents/%s", url.PathEscape(agentID)), nil, &result); err != nil { return nil, err } return &result, nil diff --git a/sdks/go/auth.go b/sdks/go/auth.go index a332d44..01526a6 100644 --- a/sdks/go/auth.go +++ b/sdks/go/auth.go @@ -1,51 +1,30 @@ package elydora -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - "strings" - "time" -) +import "net/http" -// RegisterOption is a functional option for the Register function. +// RegisterOption configures a registration request. type RegisterOption func(*AuthRegisterRequest) // WithDisplayName sets the display_name on a registration request. func WithDisplayName(name string) RegisterOption { - return func(req *AuthRegisterRequest) { - req.DisplayName = name - } + return func(req *AuthRegisterRequest) { req.DisplayName = name } } // WithOrgName sets the org_name on a registration request. func WithOrgName(name string) RegisterOption { - return func(req *AuthRegisterRequest) { - req.OrgName = name - } + return func(req *AuthRegisterRequest) { req.OrgName = name } } // Register creates a new user and organization. // -// Deprecated: Use Better Auth endpoints directly. See docs. +// Deprecated: use the Console sign-up flow; password registration is being phased out. func Register(baseURL, email, password string, opts ...RegisterOption) (*AuthRegisterResponse, error) { - if baseURL == "" { - baseURL = defaultBaseURL - } - baseURL = strings.TrimRight(baseURL, "/") - - req := &AuthRegisterRequest{ - Email: email, - Password: password, - } + req := &AuthRegisterRequest{Email: email, Password: password} for _, opt := range opts { opt(req) } - var result AuthRegisterResponse - if err := authPost(baseURL, "/v1/auth/register", req, &result); err != nil { + if err := publicRequest(http.MethodPost, normalizeBaseURL(baseURL)+"/v1/auth/register", req, &result); err != nil { return nil, err } return &result, nil @@ -53,80 +32,16 @@ func Register(baseURL, email, password string, opts ...RegisterOption) (*AuthReg // Login authenticates a user and returns a session token. // -// Deprecated: Use Better Auth endpoints directly. See docs. +// Deprecated: use the Console sign-in flow; password login is being phased out. func Login(baseURL, email, password string) (*AuthLoginResponse, error) { - if baseURL == "" { - baseURL = defaultBaseURL - } - baseURL = strings.TrimRight(baseURL, "/") - - req := &AuthLoginRequest{ - Email: email, - Password: password, - } - + req := &AuthLoginRequest{Email: email, Password: password} var result AuthLoginResponse - if err := authPost(baseURL, "/v1/auth/login", req, &result); err != nil { + if err := publicRequest(http.MethodPost, normalizeBaseURL(baseURL)+"/v1/auth/login", req, &result); err != nil { return nil, err } return &result, nil } -// authPost is a helper for unauthenticated POST requests used by Register and Login. -func authPost(baseURL, path string, body interface{}, result interface{}) error { - u := baseURL + path - - data, err := json.Marshal(body) - if err != nil { - return fmt.Errorf("elydora: marshal request body: %w", err) - } - - client := &http.Client{Timeout: 30 * time.Second} - req, err := http.NewRequest(http.MethodPost, u, bytes.NewReader(data)) - if err != nil { - return fmt.Errorf("elydora: create request: %w", err) - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") - - resp, err := client.Do(req) - if err != nil { - return fmt.Errorf("elydora: http request: %w", err) - } - defer resp.Body.Close() - - respBody, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20)) - if err != nil { - return fmt.Errorf("elydora: read response body: %w", err) - } - - if resp.StatusCode >= 200 && resp.StatusCode < 300 { - if result != nil && len(respBody) > 0 { - if err := json.Unmarshal(respBody, result); err != nil { - return fmt.Errorf("elydora: unmarshal response: %w", err) - } - } - return nil - } - - var errResp errorResponse - if err := json.Unmarshal(respBody, &errResp); err == nil && errResp.Error.Code != "" { - return &ElydoraError{ - StatusCode: resp.StatusCode, - Code: errResp.Error.Code, - Message: errResp.Error.Message, - RequestID: errResp.Error.RequestID, - Details: errResp.Error.Details, - } - } - - return &ElydoraError{ - StatusCode: resp.StatusCode, - Code: ErrorCodeInternalError, - Message: string(respBody), - } -} - // GetMe retrieves the current authenticated user's profile. func (c *Client) GetMe() (*GetMeResponse, error) { var result GetMeResponse @@ -144,3 +59,12 @@ func (c *Client) IssueApiToken(req *IssueApiTokenRequest) (*IssueApiTokenRespons } return &result, nil } + +// RotateApiToken rotates the current API token; the old token stays valid for 24h. +func (c *Client) RotateApiToken() (*RotateApiTokenResponse, error) { + var result RotateApiTokenResponse + if err := c.doPost("/v1/auth/rotate", map[string]interface{}{}, &result); err != nil { + return nil, err + } + return &result, nil +} diff --git a/sdks/go/client.go b/sdks/go/client.go index da15b37..14d70c7 100644 --- a/sdks/go/client.go +++ b/sdks/go/client.go @@ -14,6 +14,8 @@ const ( defaultBaseURL = "https://api.elydora.com" defaultTTLMs = 30000 defaultMaxRetries = 3 + maxResponseBytes = 10 << 20 + requestTimeout = 30 * time.Second ) // Config holds the configuration for creating a new Client. @@ -44,135 +46,195 @@ func NewClient(cfg *Config) (*Client, error) { if cfg == nil { return nil, fmt.Errorf("elydora: config must not be nil") } - - baseURL := cfg.BaseURL - if baseURL == "" { - baseURL = defaultBaseURL - } - baseURL = strings.TrimRight(baseURL, "/") - ttlMs := cfg.TTLMs if ttlMs <= 0 { ttlMs = defaultTTLMs } - maxRetries := cfg.MaxRetries if maxRetries <= 0 { maxRetries = defaultMaxRetries } - return &Client{ orgID: cfg.OrgID, agentID: cfg.AgentID, privateKey: cfg.PrivateKey, - baseURL: baseURL, + baseURL: normalizeBaseURL(cfg.BaseURL), ttlMs: ttlMs, maxRetries: maxRetries, token: cfg.Token, - httpClient: &http.Client{ - Timeout: 30 * time.Second, - }, + httpClient: &http.Client{Timeout: requestTimeout}, }, nil } -// doRequest executes an HTTP request with retries and error handling. -func (c *Client) doRequest(method, path string, body interface{}, result interface{}) error { - u := c.baseURL + path - - var bodyReader io.Reader - if body != nil { - data, err := json.Marshal(body) - if err != nil { - return fmt.Errorf("elydora: marshal request body: %w", err) - } - bodyReader = bytes.NewReader(data) - } - - var lastErr error - for attempt := 0; attempt <= c.maxRetries; attempt++ { - if attempt > 0 { - // Reset body reader for retry - if body != nil { - data, _ := json.Marshal(body) - bodyReader = bytes.NewReader(data) - } - time.Sleep(time.Duration(attempt*attempt) * 100 * time.Millisecond) - } - - req, err := http.NewRequest(method, u, bodyReader) - if err != nil { - return fmt.Errorf("elydora: create request: %w", err) - } - - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") - if c.token != "" { - req.Header.Set("Authorization", "Bearer "+c.token) - } - - resp, err := c.httpClient.Do(req) - if err != nil { - lastErr = fmt.Errorf("elydora: http request: %w", err) - continue - } - - respBody, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20)) - resp.Body.Close() - if err != nil { - lastErr = fmt.Errorf("elydora: read response body: %w", err) - continue - } - - if resp.StatusCode >= 200 && resp.StatusCode < 300 { - if result != nil && len(respBody) > 0 { - if err := json.Unmarshal(respBody, result); err != nil { - return fmt.Errorf("elydora: unmarshal response: %w", err) - } - } - return nil - } - - // Parse error response - var errResp errorResponse - if err := json.Unmarshal(respBody, &errResp); err == nil && errResp.Error.Code != "" { - apiErr := &ElydoraError{ - StatusCode: resp.StatusCode, - Code: errResp.Error.Code, - Message: errResp.Error.Message, - RequestID: errResp.Error.RequestID, - Details: errResp.Error.Details, - } - // Don't retry 4xx errors (except 429) - if resp.StatusCode < 500 && resp.StatusCode != 429 { - return apiErr - } - lastErr = apiErr - continue - } - - lastErr = &ElydoraError{ - StatusCode: resp.StatusCode, - Code: ErrorCodeInternalError, - Message: string(respBody), - } - if resp.StatusCode < 500 && resp.StatusCode != 429 { - return lastErr - } - } - return lastErr -} - -// doGet performs a GET request. -func (c *Client) doGet(path string, result interface{}) error { - return c.doRequest(http.MethodGet, path, nil, result) -} - -// doPost performs a POST request. -func (c *Client) doPost(path string, body interface{}, result interface{}) error { - return c.doRequest(http.MethodPost, path, body, result) -} - // SetToken sets the API token used for authenticated API requests. func (c *Client) SetToken(token string) { c.token = token } + +func normalizeBaseURL(baseURL string) string { + if baseURL == "" { + baseURL = defaultBaseURL + } + return strings.TrimRight(baseURL, "/") +} + +func newRequest(method, url string, body []byte, token, accept string) (*http.Request, error) { + var reader io.Reader + if body != nil { + reader = bytes.NewReader(body) + } + req, err := http.NewRequest(method, url, reader) + if err != nil { + return nil, fmt.Errorf("elydora: create request: %w", err) + } + if accept != "" { + req.Header.Set("Accept", accept) + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + return req, nil +} + +func marshalBody(body interface{}) ([]byte, error) { + if body == nil { + return nil, nil + } + data, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("elydora: marshal request body: %w", err) + } + return data, nil +} + +func readBody(resp *http.Response, limit int64) ([]byte, error) { + defer resp.Body.Close() + var reader io.Reader = resp.Body + if limit > 0 { + reader = io.LimitReader(resp.Body, limit) + } + body, err := io.ReadAll(reader) + if err != nil { + return nil, fmt.Errorf("elydora: read response body: %w", err) + } + return body, nil +} + +func decodeSuccess(body []byte, result interface{}) error { + if result == nil { + return nil + } + if err := json.Unmarshal(body, result); err != nil { + return fmt.Errorf("elydora: unmarshal response: %w", err) + } + return nil +} + +func apiError(status int, body []byte) *ElydoraError { + var errResp errorResponse + if err := json.Unmarshal(body, &errResp); err == nil && errResp.Error.Code != "" { + return &ElydoraError{ + StatusCode: status, + Code: errResp.Error.Code, + Message: errResp.Error.Message, + RequestID: errResp.Error.RequestID, + Details: errResp.Error.Details, + } + } + return &ElydoraError{StatusCode: status, Code: ErrorCodeInternalError, Message: string(body)} +} + +func retryable(status int) bool { + return status >= 500 || status == http.StatusTooManyRequests +} + +// A lost response to a non-idempotent request must not be replayed. +func idempotent(method string) bool { + switch method { + case http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodPut, http.MethodDelete: + return true + } + return false +} + +// doRequest executes an authenticated request; only idempotent methods retry. +func (c *Client) doRequest(method, path string, body interface{}, result interface{}) error { + data, err := marshalBody(body) + if err != nil { + return err + } + var lastErr error + for attempt := 0; attempt <= c.maxRetries; attempt++ { + if attempt > 0 { + time.Sleep(time.Duration(attempt*attempt) * 100 * time.Millisecond) + } + req, err := newRequest(method, c.baseURL+path, data, c.token, "application/json") + if err != nil { + return err + } + resp, err := c.httpClient.Do(req) + if err != nil { + lastErr = fmt.Errorf("elydora: http request: %w", err) + if !idempotent(method) { + return lastErr + } + continue + } + respBody, err := readBody(resp, maxResponseBytes) + if err != nil { + lastErr = err + if !idempotent(method) { + return lastErr + } + continue + } + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return decodeSuccess(respBody, result) + } + lastErr = apiError(resp.StatusCode, respBody) + if !retryable(resp.StatusCode) || !idempotent(method) { + return lastErr + } + } + return lastErr +} + +func (c *Client) doGet(path string, result interface{}) error { + return c.doRequest(http.MethodGet, path, nil, result) +} + +func (c *Client) doPost(path string, body interface{}, result interface{}) error { + return c.doRequest(http.MethodPost, path, body, result) +} + +// publicRequest sends one unauthenticated request and decodes accepted statuses. +func publicRequest(method, url string, body interface{}, result interface{}, accepted ...int) error { + data, err := marshalBody(body) + if err != nil { + return err + } + req, err := newRequest(method, url, data, "", "application/json") + if err != nil { + return err + } + resp, err := (&http.Client{Timeout: requestTimeout}).Do(req) + if err != nil { + return fmt.Errorf("elydora: http request: %w", err) + } + respBody, err := readBody(resp, maxResponseBytes) + if err != nil { + return err + } + for _, status := range accepted { + if resp.StatusCode == status { + return decodeSuccess(respBody, result) + } + } + if len(accepted) == 0 && resp.StatusCode >= 200 && resp.StatusCode < 300 { + return decodeSuccess(respBody, result) + } + return apiError(resp.StatusCode, respBody) +} diff --git a/sdks/go/cmd/elydora/cli_secrets.go b/sdks/go/cmd/elydora/cli_secrets.go index 664f22e..a262090 100644 --- a/sdks/go/cmd/elydora/cli_secrets.go +++ b/sdks/go/cmd/elydora/cli_secrets.go @@ -119,9 +119,6 @@ func readSecretFileWithOpen( if err := validateSecretFile(before, path, label); err != nil { return "", err } - if !os.SameFile(before, before) { - return "", fmt.Errorf("establish %s file identity at %s", label, path) - } file, err := openFile(path) if err != nil { diff --git a/sdks/go/cmd/elydora/main.go b/sdks/go/cmd/elydora/main.go index d4552ec..f5c61a2 100644 --- a/sdks/go/cmd/elydora/main.go +++ b/sdks/go/cmd/elydora/main.go @@ -11,7 +11,7 @@ import ( "github.com/Elydora-Infrastructure/Elydora-Go-SDK/v2/cmd/elydora/plugins" ) -const version = "2.0.1" +const version = "2.1.0" func main() { if len(os.Args) < 2 { @@ -57,10 +57,6 @@ Run "elydora -h" for details on each command. `) } -// --------------------------------------------------------------------------- -// install -// --------------------------------------------------------------------------- - func cmdInstall(args []string) { if err := rejectLegacySecretArguments(args); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) @@ -160,10 +156,6 @@ func cmdInstall(args []string) { fmt.Printf(" Guard script: %s\n", guardScriptPath) } -// --------------------------------------------------------------------------- -// uninstall -// --------------------------------------------------------------------------- - func cmdUninstall(args []string) { fs := flag.NewFlagSet("uninstall", flag.ExitOnError) agent := fs.String("agent", "", "Agent name (required)") @@ -209,10 +201,6 @@ func cmdUninstall(args []string) { } } -// --------------------------------------------------------------------------- -// status -// --------------------------------------------------------------------------- - func cmdStatus(args []string) { fs := flag.NewFlagSet("status", flag.ExitOnError) agent := fs.String("agent", "", "Agent name (optional — omit to show all)") @@ -256,10 +244,6 @@ func showAgentStatus(name string) { fmt.Printf(" %-14s %-20s %s\n", name, status.DisplayName, state) } -// --------------------------------------------------------------------------- -// agents -// --------------------------------------------------------------------------- - func cmdAgents() { fmt.Println("Supported agents:") fmt.Println() @@ -282,12 +266,3 @@ func sortedAgentNames() []string { sort.Strings(names) return names } - -// guardScriptPathForAgent returns the path to ~/.elydora//guard.js. -func guardScriptPathForAgent(agentId string) (string, error) { - agentDirectory, err := plugins.ResolveAgentRuntimeDirectory(agentId) - if err != nil { - return "", err - } - return filepath.Join(agentDirectory, "guard.js"), nil -} diff --git a/sdks/go/cmd/elydora/main_test.go b/sdks/go/cmd/elydora/main_test.go index 00d290f..bc8fc4a 100644 --- a/sdks/go/cmd/elydora/main_test.go +++ b/sdks/go/cmd/elydora/main_test.go @@ -5,6 +5,8 @@ import ( "path/filepath" "strings" "testing" + + "github.com/Elydora-Infrastructure/Elydora-Go-SDK/v2/cmd/elydora/plugins" ) func setMainTestHome(t *testing.T) string { @@ -17,9 +19,9 @@ func setMainTestHome(t *testing.T) string { func TestGuardScriptPathRejectsUnsafeAgentID(t *testing.T) { setMainTestHome(t) - _, err := guardScriptPathForAgent("../escape") + _, err := plugins.ResolveAgentRuntimeDirectory("../escape") if err == nil || !strings.Contains(err.Error(), "invalid agent ID") { - t.Fatalf("guardScriptPathForAgent() error = %v", err) + t.Fatalf("ResolveAgentRuntimeDirectory() error = %v", err) } } diff --git a/sdks/go/cmd/elydora/plugins/augment.go b/sdks/go/cmd/elydora/plugins/augment.go index b09a1f4..cb7ef10 100644 --- a/sdks/go/cmd/elydora/plugins/augment.go +++ b/sdks/go/cmd/elydora/plugins/augment.go @@ -10,8 +10,7 @@ type AugmentPlugin struct { rename renameFunc } -// ManagesGuardRuntime reports that Auggie commits its provider guard together -// with both wrappers, the audit runtime, and user settings. +// ManagesGuardRuntime reports that the guard is committed with the audit runtime. func (p *AugmentPlugin) ManagesGuardRuntime() bool { return true } @@ -57,13 +56,14 @@ func (p *AugmentPlugin) Install(config InstallConfig) error { if err != nil { return err } - if err := writeAugmentChanges( + if err := writeManagedChanges( changes, "Install Augment Code CLI hooks", p.rename, paths.runtimeRoot, paths.agentDirectory, filepath.Dir(document.configPath), + "Auggie configuration directory", ); err != nil { return err } @@ -99,13 +99,14 @@ func (p *AugmentPlugin) Uninstall(agentID string) error { if err != nil { return err } - return writeAugmentChanges( + return writeManagedChanges( []*fileChange{change}, "Uninstall Augment Code CLI hooks", p.rename, "", "", filepath.Dir(document.configPath), + "Auggie configuration directory", ) } diff --git a/sdks/go/cmd/elydora/plugins/augment_contract.go b/sdks/go/cmd/elydora/plugins/augment_contract.go index ddab87f..d38ef1f 100644 --- a/sdks/go/cmd/elydora/plugins/augment_contract.go +++ b/sdks/go/cmd/elydora/plugins/augment_contract.go @@ -12,11 +12,10 @@ import ( ) const ( - augmentAgentKey = "augment" - augmentGuardScript = "guard.js" - augmentAuditScript = "hook.js" - augmentHookTimeout = float64(10_000) - augmentPOSIXApostrophe = `'"'"'` + augmentAgentKey = "augment" + augmentGuardScript = "guard.js" + augmentAuditScript = "hook.js" + augmentHookTimeout = float64(10_000) ) var ( @@ -68,14 +67,6 @@ type augmentRuntimeContract struct { auditWrapper string } -func cloneAugmentObject(value map[string]any) map[string]any { - clone := make(map[string]any, len(value)) - for key, item := range value { - clone[key] = item - } - return clone -} - func validateAugmentHandler( value any, event string, @@ -114,7 +105,7 @@ func validateAugmentHandler( return nil, fmt.Errorf("%s timeout must be a positive finite number", label) } } - return cloneAugmentObject(handler), nil + return cloneJSONObject(handler), nil } func validateAugmentMetadata(value any, label string) error { @@ -169,7 +160,7 @@ func validateAugmentGroup(value any, event string, groupIndex int) (augmentGroup } handlers = append(handlers, handler) } - return augmentGroup{object: cloneAugmentObject(object), handlers: handlers}, nil + return augmentGroup{object: cloneJSONObject(object), handlers: handlers}, nil } func readAugmentHooks(root map[string]any) (augmentHooks, error) { @@ -248,7 +239,7 @@ func renderAugmentDocument( if !document.exists && len(hooks) == 0 { return &augmentRenderedDocument{document: document}, nil } - root := cloneAugmentObject(document.root) + root := cloneJSONObject(document.root) if len(hooks) == 0 { delete(root, "hooks") } else { @@ -281,7 +272,7 @@ func renderAugmentHooks(hooks augmentHooks) map[string]any { for event, groups := range hooks { values := make([]any, 0, len(groups)) for _, group := range groups { - object := cloneAugmentObject(group.object) + object := cloneJSONObject(group.object) handlers := make([]any, 0, len(group.handlers)) for _, handler := range group.handlers { handlers = append(handlers, handler) @@ -314,23 +305,8 @@ func readAugmentWindowsArgument(command string) (string, bool) { } func readAugmentPOSIXArgument(command string) (string, bool) { - if len(command) < 2 || command[0] != '\'' { - return "", false - } - var value strings.Builder - for index := 1; index < len(command); { - if strings.HasPrefix(command[index:], augmentPOSIXApostrophe) { - value.WriteByte('\'') - index += len(augmentPOSIXApostrophe) - continue - } - if command[index] == '\'' { - return value.String(), index == len(command)-1 && value.Len() > 0 - } - value.WriteByte(command[index]) - index++ - } - return "", false + value, end, ok := readPOSIXArgument(command, 0) + return value, ok && end == len(command) && value != "" } func parseAugmentCommand(command string) (string, bool) { @@ -340,36 +316,6 @@ func parseAugmentCommand(command string) (string, bool) { return readAugmentPOSIXArgument(command) } -func normalizeAugmentPath(value string) string { - absolute, err := filepath.Abs(value) - if err == nil { - value = absolute - } - value = filepath.ToSlash(filepath.Clean(value)) - if runtime.GOOS == "windows" { - return strings.ToLower(value) - } - return value -} - -func sameAugmentPath(left, right string) bool { - return normalizeAugmentPath(left) == normalizeAugmentPath(right) -} - -func sameAugmentAgentID(left, right string) bool { - if runtime.GOOS == "windows" { - return strings.EqualFold(left, right) - } - return left == right -} - -func sameAugmentFileName(left, right string) bool { - if runtime.GOOS == "windows" { - return strings.EqualFold(left, right) - } - return left == right -} - func managedAugmentAgentID( handler map[string]any, wrapperName, runtimeRoot string, @@ -385,11 +331,11 @@ func managedAugmentAgentID( return "", false } wrapperPath, ok := parseAugmentCommand(command) - if !ok || !sameAugmentFileName(filepath.Base(wrapperPath), wrapperName) { + if !ok || !sameManagedName(filepath.Base(wrapperPath), wrapperName) { return "", false } agentDirectory := filepath.Dir(wrapperPath) - if !sameAugmentPath(filepath.Dir(agentDirectory), runtimeRoot) { + if !sameManagedPath(filepath.Dir(agentDirectory), runtimeRoot) { return "", false } agentID := filepath.Base(agentDirectory) @@ -407,7 +353,7 @@ func removeManagedAugmentGroups( groupChanged := false for _, handler := range group.handlers { managedID, managed := managedAugmentAgentID(handler, wrapperName, runtimeRoot) - remove := managed && (agentID == "" || sameAugmentAgentID(managedID, agentID)) + remove := managed && (agentID == "" || sameManagedAgentID(managedID, agentID)) if remove { changed = true groupChanged = true diff --git a/sdks/go/cmd/elydora/plugins/augment_installation.go b/sdks/go/cmd/elydora/plugins/augment_installation.go index 43e030a..c35437c 100644 --- a/sdks/go/cmd/elydora/plugins/augment_installation.go +++ b/sdks/go/cmd/elydora/plugins/augment_installation.go @@ -1,96 +1,42 @@ package plugins -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "strings" -) +import "fmt" type augmentRuntimePaths struct { - runtimeRoot string - agentDirectory string - configPath string - keyPath string - guardPath string - auditPath string + managedRuntimePaths guardWrapperPath string auditWrapperPath string } -func validateAugmentInstallConfig(config InstallConfig) error { - for _, field := range []struct{ name, value string }{ - {"agent name", config.AgentName}, - {"organization ID", config.OrgID}, - {"agent ID", config.AgentID}, - {"key ID", config.KID}, - {"private key", config.PrivateKey}, - {"base URL", config.BaseURL}, - {"guard script path", config.GuardScriptPath}, - } { - if field.value == "" { - return fmt.Errorf("%s is required", field.name) - } +func augmentWrapperArtifacts(agentDirectory string) []managedArtifact { + wrappers := resolveAugmentWrapperPaths(agentDirectory) + return []managedArtifact{ + {wrappers.guard, "Auggie guard wrapper", maxManagedSourceBytes}, + {wrappers.audit, "Auggie audit wrapper", maxManagedSourceBytes}, } - if config.AgentName != augmentAgentKey { - return fmt.Errorf( - "Augment Code CLI installation requires agent name %s", - augmentAgentKey, - ) - } - if strings.TrimSpace(config.OrgID) == "" { - return fmt.Errorf("organization ID is required") - } - if strings.TrimSpace(config.KID) == "" { - return fmt.Errorf("key ID is required") - } - if config.Token != "" && strings.TrimSpace(config.Token) == "" { - return fmt.Errorf("token must contain a non-whitespace value when provided") - } - if err := validateManagedPrivateKey(config.PrivateKey); err != nil { - return err - } - return validateManagedBaseURL(config.BaseURL) } func augmentAgentPaths(config InstallConfig) (*augmentRuntimePaths, error) { - if err := validateAugmentInstallConfig(config); err != nil { + if err := validateManagedInstallConfig(config, augmentAgentKey, "Augment Code CLI"); err != nil { return nil, err } - runtimeRoot, err := AgentRuntimeRoot() + paths, err := resolveManagedRuntimePaths(config, augmentGuardScript, augmentAuditScript) if err != nil { return nil, err } - agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID) - if err != nil { - return nil, err - } - wrappers := resolveAugmentWrapperPaths(agentDirectory) - paths := &augmentRuntimePaths{ - runtimeRoot: runtimeRoot, agentDirectory: agentDirectory, - configPath: filepath.Join(agentDirectory, "config.json"), - keyPath: filepath.Join(agentDirectory, "private.key"), - guardPath: filepath.Join(agentDirectory, augmentGuardScript), - auditPath: filepath.Join(agentDirectory, augmentAuditScript), - guardWrapperPath: wrappers.guard, - auditWrapperPath: wrappers.audit, - } - if !filepath.IsAbs(config.GuardScriptPath) || - !sameAugmentPath(config.GuardScriptPath, paths.guardPath) { - return nil, fmt.Errorf( - "Elydora guard runtime must use the managed agent directory: %s", - paths.guardPath, - ) - } - if config.HookScript != "" && (!filepath.IsAbs(config.HookScript) || - !sameAugmentPath(config.HookScript, paths.auditPath)) { - return nil, fmt.Errorf( - "Elydora audit runtime must use the managed agent directory: %s", - paths.auditPath, - ) - } - return paths, nil + wrappers := resolveAugmentWrapperPaths(paths.agentDirectory) + return &augmentRuntimePaths{ + managedRuntimePaths: *paths, + guardWrapperPath: wrappers.guard, + auditWrapperPath: wrappers.audit, + }, nil +} + +func validateAugmentRuntimeIdentity(agentDirectory, agentID string) error { + return validateManagedRuntimeIdentity( + agentDirectory, agentID, augmentAgentKey, "Auggie", + augmentWrapperArtifacts(agentDirectory)..., + ) } func preflightAugmentInstallation( @@ -98,55 +44,25 @@ func preflightAugmentInstallation( document *augmentDocument, ) (*augmentRuntimePaths, string, error) { if document == nil || document.configPath == "" { - return nil, "", fmt.Errorf( - "Augment Code CLI installation requires a user settings document", - ) + return nil, "", fmt.Errorf("Augment Code CLI installation requires a user settings document") } paths, err := augmentAgentPaths(config) if err != nil { return nil, "", err } - if err := validateAugmentRuntimeIdentity( - paths.agentDirectory, - config.AgentID, - ); err != nil { + if err := validateAugmentRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil { return nil, "", err } - nodePath, err := resolveNodeRuntime() + nodePath, err := resolveAbsoluteNodeRuntime("Auggie") if err != nil { return nil, "", err } - if err := requireAugmentAbsoluteNode(nodePath); err != nil { - return nil, "", err - } if err := validateAugmentMatchers(document.hooks, nodePath); err != nil { return nil, "", err } return paths, nodePath, nil } -func buildAugmentRuntimeConfig(config InstallConfig) ([]byte, error) { - value := map[string]any{ - "org_id": config.OrgID, "agent_id": config.AgentID, "kid": config.KID, - "base_url": config.BaseURL, "agent_name": augmentAgentKey, - } - if config.Token != "" { - value["token"] = config.Token - } - encoded, err := json.MarshalIndent(value, "", " ") - if err != nil { - return nil, fmt.Errorf("encode Elydora runtime config: %w", err) - } - encoded = append(encoded, '\n') - if len(encoded) > maxRuntimeConfigBytes { - return nil, fmt.Errorf( - "Elydora runtime config exceeds %d bytes after JSON encoding", - maxRuntimeConfigBytes, - ) - } - return encoded, nil -} - func prepareAugmentInstallationChanges( config InstallConfig, paths *augmentRuntimePaths, @@ -156,63 +72,27 @@ func prepareAugmentInstallationChanges( if paths == nil || rendered == nil { return nil, fmt.Errorf("prepared Auggie installation is required") } - if err := validateAugmentRuntimeIdentity( - paths.agentDirectory, - config.AgentID, - ); err != nil { + if err := validateAugmentRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil { return nil, err } - runtimeConfig, err := buildAugmentRuntimeConfig(config) + changes, err := managedRuntimeFileChanges( + config, + &paths.managedRuntimePaths, + augmentAgentKey, + generateGuardScript(augmentAgentKey, config.AgentID, "", false, ""), + buildHookScriptWithOutput(augmentAgentKey, config.AgentID, "", false, true), + ) if err != nil { return nil, err } - items := []struct { + for _, item := range []struct { path, label string content []byte - mode os.FileMode }{ - { - paths.guardPath, - "Elydora guard runtime", - []byte(generateGuardScript( - augmentAgentKey, - config.AgentID, - "", - false, - "", - )), - 0700, - }, - {paths.configPath, "Elydora runtime config", runtimeConfig, 0600}, - {paths.keyPath, "Elydora private key", []byte(config.PrivateKey), 0600}, - { - paths.auditPath, - "Elydora audit runtime", - []byte(buildHookScriptWithOutput( - augmentAgentKey, - config.AgentID, - "", - false, - true, - )), - 0700, - }, - { - paths.guardWrapperPath, - "Auggie guard wrapper", - buildAugmentWrapper(nodePath, paths.guardPath), - 0700, - }, - { - paths.auditWrapperPath, - "Auggie audit wrapper", - buildAugmentWrapper(nodePath, paths.auditPath), - 0700, - }, - } - changes := make([]*fileChange, 0, len(items)+1) - for _, item := range items { - change, err := prepareFileChange(item.path, item.label, item.content, item.mode) + {paths.guardWrapperPath, "Auggie guard wrapper", buildAugmentWrapper(nodePath, paths.guardPath)}, + {paths.auditWrapperPath, "Auggie audit wrapper", buildAugmentWrapper(nodePath, paths.auditPath)}, + } { + change, err := prepareFileChange(item.path, item.label, item.content, 0700) if err != nil { return nil, err } diff --git a/sdks/go/cmd/elydora/plugins/augment_io.go b/sdks/go/cmd/elydora/plugins/augment_io.go index 40aeff9..a6365b7 100644 --- a/sdks/go/cmd/elydora/plugins/augment_io.go +++ b/sdks/go/cmd/elydora/plugins/augment_io.go @@ -83,43 +83,6 @@ func prepareRenderedAugmentChange( ) } -func writeAugmentChanges( - changes []*fileChange, - label string, - rename renameFunc, - runtimeRoot string, - agentDirectory string, - settingsDirectory string, -) error { - hasChanges := false - for _, change := range changes { - if change != nil { - hasChanges = true - break - } - } - if !hasChanges { - return nil - } - if agentDirectory != "" { - if err := EnsurePrivateDirectory(runtimeRoot); err != nil { - return err - } - if err := EnsurePrivateDirectory(agentDirectory); err != nil { - return err - } - } - if settingsDirectory != "" { - if err := ensureManagedDirectory( - settingsDirectory, - "Auggie configuration directory", - ); err != nil { - return err - } - } - return writeChanges(changes, label, rename) -} - func validateAugmentMatchers(hooks augmentHooks, nodePath string) error { events := make([]string, 0, len(hooks)) for event := range hooks { @@ -209,10 +172,3 @@ func resolveAugmentWrapperPaths(agentDirectory string) augmentWrapperPaths { audit: filepath.Join(agentDirectory, augmentAuditWrapperName()), } } - -func requireAugmentAbsoluteNode(nodePath string) error { - if !filepath.IsAbs(nodePath) || !isClaudeNodeExecutable(nodePath) { - return fmt.Errorf("Auggie hooks require an absolute Node.js executable path") - } - return nil -} diff --git a/sdks/go/cmd/elydora/plugins/augment_status.go b/sdks/go/cmd/elydora/plugins/augment_status.go index c99b4cd..3e15636 100644 --- a/sdks/go/cmd/elydora/plugins/augment_status.go +++ b/sdks/go/cmd/elydora/plugins/augment_status.go @@ -2,11 +2,8 @@ package plugins import ( "bytes" - "fmt" "path/filepath" - "runtime" "sort" - "strings" ) func managedAugmentIDs( @@ -17,19 +14,10 @@ func managedAugmentIDs( result := map[string]string{} for _, group := range groups { for _, handler := range group.handlers { - agentID, managed := managedAugmentAgentID( - handler, - wrapperName, - runtimeRoot, - ) - if !managed { - continue + agentID, managed := managedAugmentAgentID(handler, wrapperName, runtimeRoot) + if managed { + result[managedReferenceKey(agentID)] = agentID } - key := agentID - if runtime.GOOS == "windows" { - key = strings.ToLower(key) - } - result[key] = agentID } } return result @@ -39,16 +27,8 @@ func augmentRuntimeContracts( hooks augmentHooks, runtimeRoot string, ) []augmentRuntimeContract { - guards := managedAugmentIDs( - hooks["PreToolUse"], - augmentGuardWrapperName(), - runtimeRoot, - ) - audits := managedAugmentIDs( - hooks["PostToolUse"], - augmentAuditWrapperName(), - runtimeRoot, - ) + guards := managedAugmentIDs(hooks["PreToolUse"], augmentGuardWrapperName(), runtimeRoot) + audits := managedAugmentIDs(hooks["PostToolUse"], augmentAuditWrapperName(), runtimeRoot) keys := make([]string, 0, len(guards)) for key := range guards { if _, exists := audits[key]; exists { @@ -72,282 +52,47 @@ func augmentRuntimeContracts( return contracts } -func readAugmentRuntimeConfig(path string) (map[string]any, bool, error) { - snapshot, err := readManagedFile( - path, - "Elydora runtime config", - maxRuntimeConfigBytes, - ) - if err != nil || snapshot == nil { - return nil, snapshot != nil, err +func augmentWrappersMatch(contract augmentRuntimeContract, nodePath string) (bool, error) { + wrappers := resolveAugmentWrapperPaths(filepath.Dir(contract.guardPath)) + if !sameManagedPath(contract.guardWrapper, wrappers.guard) || + !sameManagedPath(contract.auditWrapper, wrappers.audit) { + return false, nil } - label := fmt.Sprintf("Elydora runtime config at %s", path) - config, err := decodeStrictJSONObject(snapshot.contents, label) - return config, true, err -} - -func requireAugmentRuntimeString( - config map[string]any, - field string, - configPath string, -) (string, error) { - value, ok := config[field].(string) - if !ok || strings.TrimSpace(value) == "" { - return "", fmt.Errorf( - "Elydora runtime config %s is invalid: %s", - field, - configPath, - ) - } - return value, nil -} - -func validateAugmentRuntimeConfig( - config map[string]any, - expectedAgentID string, - configPath string, -) error { - supported := stringSet( - "org_id", - "agent_id", - "kid", - "base_url", - "token", - "agent_name", - ) - fields := make([]string, 0, len(config)) - for field := range config { - fields = append(fields, field) - } - sort.Strings(fields) - for _, field := range fields { - if _, ok := supported[field]; !ok { - return fmt.Errorf( - `Elydora runtime config has unsupported field %q: %s`, - field, - configPath, - ) - } - } - if _, err := requireAugmentRuntimeString(config, "org_id", configPath); err != nil { - return err - } - if _, err := requireAugmentRuntimeString(config, "kid", configPath); err != nil { - return err - } - agentID, err := requireAugmentRuntimeString(config, "agent_id", configPath) - if err != nil { - return err - } - if !sameAugmentAgentID(agentID, expectedAgentID) || - config["agent_name"] != augmentAgentKey { - return fmt.Errorf( - "Elydora runtime identity does not match Auggie hooks: %s", - configPath, - ) - } - if _, exists := config["token"]; exists { - if _, err := requireAugmentRuntimeString(config, "token", configPath); err != nil { - return err - } - } - baseURL, err := requireAugmentRuntimeString(config, "base_url", configPath) - if err != nil { - return err - } - if err := validateManagedBaseURL(baseURL); err != nil { - return fmt.Errorf( - "Elydora runtime config base URL is invalid at %s: %w", - configPath, - err, - ) - } - return nil -} - -func validateAugmentRuntimeIdentity(agentDirectory, agentID string) error { - runtimeRoot := filepath.Dir(agentDirectory) - rootExists, err := managedPhysicalDirectoryExists( - runtimeRoot, - "Elydora runtime directory", - ) - if err != nil || !rootExists { - return err - } - directoryExists, err := managedPhysicalDirectoryExists( - agentDirectory, - "Elydora agent runtime directory", - ) - if err != nil || !directoryExists { - return err - } - configPath := filepath.Join(agentDirectory, "config.json") - config, configExists, err := readAugmentRuntimeConfig(configPath) - if err != nil { - return err - } - artifactExists := false for _, item := range []struct { - path string - label string - limit int64 + path, label string + expected []byte }{ - {filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes}, - {filepath.Join(agentDirectory, augmentGuardScript), "Elydora guard runtime", maxManagedSourceBytes}, - {filepath.Join(agentDirectory, augmentAuditScript), "Elydora audit runtime", maxManagedSourceBytes}, - {filepath.Join(agentDirectory, augmentGuardWrapperName()), "Auggie guard wrapper", maxManagedSourceBytes}, - {filepath.Join(agentDirectory, augmentAuditWrapperName()), "Auggie audit wrapper", maxManagedSourceBytes}, - {filepath.Join(agentDirectory, "chain-state.json"), "Elydora chain state", maxRuntimeConfigBytes}, - {filepath.Join(agentDirectory, "status-cache.json"), "Elydora status cache", maxRuntimeConfigBytes}, - {filepath.Join(agentDirectory, "error.log"), "Elydora error log", maxManagedSourceBytes}, + {contract.guardWrapper, "Auggie guard wrapper", buildAugmentWrapper(nodePath, contract.guardPath)}, + {contract.auditWrapper, "Auggie audit wrapper", buildAugmentWrapper(nodePath, contract.auditPath)}, } { - exists, inspectErr := managedPhysicalFileExists( - item.path, - item.label, - item.limit, - ) - if inspectErr != nil { - return inspectErr + snapshot, err := readManagedFile(item.path, item.label, maxManagedSourceBytes) + if err != nil || snapshot == nil || !bytes.Equal(snapshot.contents, item.expected) { + return false, err } - artifactExists = artifactExists || exists } - if !configExists { - if artifactExists { - return fmt.Errorf( - "Elydora runtime identity cannot be verified without config.json: %s", - agentDirectory, - ) - } - return nil - } - configuredID, ok := config["agent_id"].(string) - if !ok || config["agent_name"] != augmentAgentKey || - !sameAugmentAgentID(configuredID, agentID) { - return fmt.Errorf( - "Elydora runtime config identity does not match Auggie agent %s: %s", - agentID, - configPath, - ) - } - return nil -} - -func augmentRuntimeContractExists( - contract augmentRuntimeContract, - runtimeRoot string, - nodePath string, -) (bool, error) { - agentDirectory := filepath.Dir(contract.guardPath) - wrappers := resolveAugmentWrapperPaths(agentDirectory) - if !sameAugmentPath(filepath.Dir(agentDirectory), runtimeRoot) || - !sameAugmentPath( - contract.auditPath, - filepath.Join(agentDirectory, augmentAuditScript), - ) || - !sameAugmentPath(contract.guardWrapper, wrappers.guard) || - !sameAugmentPath(contract.auditWrapper, wrappers.audit) { - return false, nil - } - rootExists, err := managedPhysicalDirectoryExists( - runtimeRoot, - "Elydora runtime directory", - ) - if err != nil || !rootExists { - return false, err - } - directoryExists, err := managedPhysicalDirectoryExists( - agentDirectory, - "Elydora agent runtime directory", - ) - if err != nil || !directoryExists { - return false, err - } - configPath := filepath.Join(agentDirectory, "config.json") - keyPath := filepath.Join(agentDirectory, "private.key") - config, configExists, err := readAugmentRuntimeConfig(configPath) - if err != nil { - return false, err - } - key, err := readManagedFile(keyPath, "Elydora private key", maxProtectedSecretBytes) - if err != nil { - return false, err - } - guard, err := readManagedFile( - contract.guardPath, - "Elydora guard runtime", - maxManagedSourceBytes, - ) - if err != nil { - return false, err - } - audit, err := readManagedFile( - contract.auditPath, - "Elydora audit runtime", - maxManagedSourceBytes, - ) - if err != nil { - return false, err - } - guardWrapper, err := readManagedFile( - contract.guardWrapper, - "Auggie guard wrapper", - maxManagedSourceBytes, - ) - if err != nil { - return false, err - } - auditWrapper, err := readManagedFile( - contract.auditWrapper, - "Auggie audit wrapper", - maxManagedSourceBytes, - ) - if err != nil { - return false, err - } - if !configExists || key == nil || guard == nil || audit == nil || - guardWrapper == nil || auditWrapper == nil { - return false, nil - } - if err := validateAugmentRuntimeConfig( - config, - contract.agentID, - configPath, - ); err != nil { - return false, err - } - if err := validateManagedPrivateKey(string(key.contents)); err != nil { - return false, fmt.Errorf("Elydora private key at %s: %w", keyPath, err) - } - return len(guard.contents) > 0 && - len(audit.contents) > 0 && - bytes.Equal( - guardWrapper.contents, - buildAugmentWrapper(nodePath, contract.guardPath), - ) && - bytes.Equal( - auditWrapper.contents, - buildAugmentWrapper(nodePath, contract.auditPath), - ), nil + return true, nil } func augmentRuntimeFilesExist( contracts []augmentRuntimeContract, runtimeRoot string, ) (bool, error) { - nodePath, err := resolveNodeRuntime() + nodePath, err := resolveAbsoluteNodeRuntime("Auggie") if err != nil { return false, err } - if err := requireAugmentAbsoluteNode(nodePath); err != nil { - return false, err - } for _, contract := range contracts { - exists, err := augmentRuntimeContractExists(contract, runtimeRoot, nodePath) - if err != nil { - return false, err + runtime := managedRuntimeContract{contract.agentID, contract.guardPath, contract.auditPath} + exists, err := managedRuntimeContractExists(runtime, augmentAgentKey, "Auggie", nil) + if err != nil || !exists { + if err != nil { + return false, err + } + continue } - if exists { - return true, nil + matches, err := augmentWrappersMatch(contract, nodePath) + if err != nil || matches { + return matches, err } } return false, nil diff --git a/sdks/go/cmd/elydora/plugins/augment_transaction_test.go b/sdks/go/cmd/elydora/plugins/augment_transaction_test.go index 85daf7d..09559c2 100644 --- a/sdks/go/cmd/elydora/plugins/augment_transaction_test.go +++ b/sdks/go/cmd/elydora/plugins/augment_transaction_test.go @@ -54,7 +54,7 @@ func TestAugmentInstallRollsBackAllSevenFiles(t *testing.T) { ) failed := false fixture.plugin.rename = func(source, destination string) error { - if sameAugmentPath(destination, fixture.configPath) && + if sameManagedPath(destination, fixture.configPath) && strings.HasSuffix(source, ".tmp") && !failed { failed = true @@ -87,13 +87,14 @@ func TestPreparedAugmentInstallRejectsConcurrentSettingsChange(t *testing.T) { if err := os.WriteFile(fixture.configPath, concurrent, 0600); err != nil { t.Fatalf("write concurrent settings: %v", err) } - err := writeAugmentChanges( + err := writeManagedChanges( changes, "Install Augment Code CLI hooks", nil, paths.runtimeRoot, paths.agentDirectory, filepath.Dir(document.configPath), + "Auggie configuration directory", ) if err == nil || !strings.Contains(err.Error(), "changed during installation") { t.Fatalf("prepared install error = %v", err) @@ -375,7 +376,7 @@ func TestAugmentUninstallPreservesSettingsAfterCommitFailure(t *testing.T) { t.Fatalf("read installed settings: %v", err) } fixture.plugin.rename = func(source, destination string) error { - if sameAugmentPath(destination, fixture.configPath) && + if sameManagedPath(destination, fixture.configPath) && strings.HasSuffix(source, ".tmp") { return errors.New("injected Auggie uninstall failure") } diff --git a/sdks/go/cmd/elydora/plugins/claudecode.go b/sdks/go/cmd/elydora/plugins/claudecode.go index cbbe5a2..4ef9ad1 100644 --- a/sdks/go/cmd/elydora/plugins/claudecode.go +++ b/sdks/go/cmd/elydora/plugins/claudecode.go @@ -10,8 +10,7 @@ type ClaudeCodePlugin struct { rename renameFunc } -// ManagesGuardRuntime reports that Claude Code commits its provider guard with -// the audit runtime and user settings document. +// ManagesGuardRuntime reports that the guard is committed with the audit runtime. func (p *ClaudeCodePlugin) ManagesGuardRuntime() bool { return true } @@ -73,13 +72,14 @@ func (p *ClaudeCodePlugin) Install(config InstallConfig) error { if err != nil { return err } - if err := writeClaudeChanges( + if err := writeManagedChanges( changes, "Install Claude Code hooks", p.rename, paths.runtimeRoot, paths.agentDirectory, filepath.Dir(document.filePath), + "Claude Code configuration directory", ); err != nil { return err } @@ -105,13 +105,14 @@ func (p *ClaudeCodePlugin) Uninstall(agentID string) error { if err != nil { return err } - return writeClaudeChanges( + return writeManagedChanges( []*fileChange{change}, "Uninstall Claude Code hooks", p.rename, "", "", filepath.Dir(document.filePath), + "Claude Code configuration directory", ) } diff --git a/sdks/go/cmd/elydora/plugins/claudecode_contract.go b/sdks/go/cmd/elydora/plugins/claudecode_contract.go index 4994cd3..5aa454b 100644 --- a/sdks/go/cmd/elydora/plugins/claudecode_contract.go +++ b/sdks/go/cmd/elydora/plugins/claudecode_contract.go @@ -77,19 +77,7 @@ type claudeRenderedDocument struct { remove bool } -type claudeRuntimeContract struct { - agentID string - guardPath string - auditPath string -} - -func cloneClaudeObject(value map[string]any) map[string]any { - clone := make(map[string]any, len(value)) - for key, item := range value { - clone[key] = item - } - return clone -} +type claudeRuntimeContract = managedRuntimeContract func cloneClaudeHooks(source claudeHooks) claudeHooks { clone := make(claudeHooks, len(source)) @@ -299,7 +287,7 @@ func validateClaudeHandler( } } } - return cloneClaudeObject(handler), nil + return cloneJSONObject(handler), nil } func validateClaudeGroup(value any, event string, groupIndex int) (claudeGroup, error) { @@ -332,7 +320,7 @@ func validateClaudeGroup(value any, event string, groupIndex int) (claudeGroup, } handlers = append(handlers, handler) } - return claudeGroup{object: cloneClaudeObject(object), handlers: handlers}, nil + return claudeGroup{object: cloneJSONObject(object), handlers: handlers}, nil } func readClaudeHooks(root map[string]any) (claudeHooks, error) { @@ -411,7 +399,7 @@ func renderClaudeHooks(hooks claudeHooks) map[string]any { for event, groups := range hooks { values := make([]any, 0, len(groups)) for _, group := range groups { - object := cloneClaudeObject(group.object) + object := cloneJSONObject(group.object) handlers := make([]any, 0, len(group.handlers)) for _, handler := range group.handlers { handlers = append(handlers, handler) @@ -439,7 +427,7 @@ func renderClaudeDocument( document: document, changed: true, remove: true, }, nil } - root := cloneClaudeObject(document.root) + root := cloneJSONObject(document.root) if len(hooks) == 0 { delete(root, "hooks") } else { diff --git a/sdks/go/cmd/elydora/plugins/claudecode_installation.go b/sdks/go/cmd/elydora/plugins/claudecode_installation.go index 27f5a5e..7a3ee21 100644 --- a/sdks/go/cmd/elydora/plugins/claudecode_installation.go +++ b/sdks/go/cmd/elydora/plugins/claudecode_installation.go @@ -1,182 +1,48 @@ package plugins -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "strings" -) - -type claudeRuntimePaths struct { - runtimeRoot string - agentDirectory string - configPath string - keyPath string - guardPath string - auditPath string -} - -func validateClaudeInstallConfig(config InstallConfig) error { - for _, field := range []struct{ name, value string }{ - {"agent name", config.AgentName}, - {"organization ID", config.OrgID}, - {"agent ID", config.AgentID}, - {"key ID", config.KID}, - {"private key", config.PrivateKey}, - {"base URL", config.BaseURL}, - {"guard script path", config.GuardScriptPath}, - } { - if field.value == "" { - return fmt.Errorf("%s is required", field.name) - } - } - if config.AgentName != claudeAgentKey { - return fmt.Errorf( - "Claude Code installation requires agent name %s", - claudeAgentKey, - ) - } - if strings.TrimSpace(config.OrgID) == "" { - return fmt.Errorf("organization ID is required") - } - if strings.TrimSpace(config.KID) == "" { - return fmt.Errorf("key ID is required") - } - if config.Token != "" && strings.TrimSpace(config.Token) == "" { - return fmt.Errorf("token must contain a non-whitespace value when provided") - } - if err := validateManagedPrivateKey(config.PrivateKey); err != nil { - return err - } - return validateManagedBaseURL(config.BaseURL) -} - -func claudeAgentPaths(config InstallConfig) (*claudeRuntimePaths, error) { - if err := validateClaudeInstallConfig(config); err != nil { - return nil, err - } - runtimeRoot, err := AgentRuntimeRoot() - if err != nil { - return nil, err - } - agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID) - if err != nil { - return nil, err - } - paths := &claudeRuntimePaths{ - runtimeRoot: runtimeRoot, agentDirectory: agentDirectory, - configPath: filepath.Join(agentDirectory, "config.json"), - keyPath: filepath.Join(agentDirectory, "private.key"), - guardPath: filepath.Join(agentDirectory, claudeGuardScript), - auditPath: filepath.Join(agentDirectory, claudeAuditScript), - } - if !filepath.IsAbs(config.GuardScriptPath) || - !sameClaudePath(config.GuardScriptPath, paths.guardPath) { - return nil, fmt.Errorf( - "guard runtime must use the managed Elydora agent directory: %s", - paths.guardPath, - ) - } - if config.HookScript != "" && (!filepath.IsAbs(config.HookScript) || - !sameClaudePath(config.HookScript, paths.auditPath)) { - return nil, fmt.Errorf( - "audit runtime must use the managed Elydora agent directory: %s", - paths.auditPath, - ) - } - return paths, nil -} +import "fmt" func preflightClaudeInstallation( config InstallConfig, document *claudeDocument, -) (*claudeRuntimePaths, string, error) { +) (*managedRuntimePaths, string, error) { if document == nil || document.filePath == "" { - return nil, "", fmt.Errorf( - "Claude Code installation requires a user settings document", - ) + return nil, "", fmt.Errorf("Claude Code installation requires a user settings document") } - paths, err := claudeAgentPaths(config) + if err := validateManagedInstallConfig(config, claudeAgentKey, "Claude Code"); err != nil { + return nil, "", err + } + paths, err := resolveManagedRuntimePaths(config, claudeGuardScript, claudeAuditScript) if err != nil { return nil, "", err } - if err := validateClaudeRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil { + if err := validateManagedRuntimeIdentity( + paths.agentDirectory, config.AgentID, claudeAgentKey, "Claude Code", + ); err != nil { return nil, "", err } - nodePath, err := resolveNodeRuntime() + nodePath, err := resolveAbsoluteNodeRuntime("Claude Code") if err != nil { return nil, "", err } - if err := requireClaudeAbsoluteNode(nodePath); err != nil { - return nil, "", err - } return paths, nodePath, nil } -func buildClaudeRuntimeConfig(config InstallConfig) ([]byte, error) { - value := map[string]any{ - "org_id": config.OrgID, "agent_id": config.AgentID, "kid": config.KID, - "base_url": config.BaseURL, "agent_name": claudeAgentKey, - } - if config.Token != "" { - value["token"] = config.Token - } - encoded, err := json.MarshalIndent(value, "", " ") - if err != nil { - return nil, fmt.Errorf("encode Elydora runtime config: %w", err) - } - encoded = append(encoded, '\n') - if len(encoded) > maxRuntimeConfigBytes { - return nil, fmt.Errorf( - "elydora runtime config exceeds %d bytes after JSON encoding", - maxRuntimeConfigBytes, - ) - } - return encoded, nil -} - func prepareClaudeInstallationChanges( config InstallConfig, - paths *claudeRuntimePaths, + paths *managedRuntimePaths, rendered *claudeRenderedDocument, ) ([]*fileChange, error) { - runtimeConfig, err := buildClaudeRuntimeConfig(config) + changes, err := managedRuntimeFileChanges( + config, + paths, + claudeAgentKey, + generateGuardScript(claudeAgentKey, config.AgentID, "", false, ""), + buildHookScriptWithOutput(claudeAgentKey, config.AgentID, "", false, true), + ) if err != nil { return nil, err } - items := []struct { - path, label string - content []byte - mode os.FileMode - }{ - { - paths.guardPath, "Elydora guard runtime", - []byte(generateGuardScript(claudeAgentKey, config.AgentID, "", false, "")), - 0700, - }, - {paths.configPath, "Elydora runtime config", runtimeConfig, 0600}, - {paths.keyPath, "Elydora private key", []byte(config.PrivateKey), 0600}, - { - paths.auditPath, "Elydora audit runtime", - []byte(buildHookScriptWithOutput( - claudeAgentKey, - config.AgentID, - "", - false, - true, - )), - 0700, - }, - } - changes := make([]*fileChange, 0, len(items)+1) - for _, item := range items { - change, err := prepareFileChange(item.path, item.label, item.content, item.mode) - if err != nil { - return nil, err - } - changes = append(changes, change) - } documentChange, err := prepareRenderedClaudeChange(rendered) if err != nil { return nil, err diff --git a/sdks/go/cmd/elydora/plugins/claudecode_io.go b/sdks/go/cmd/elydora/plugins/claudecode_io.go index 996ec83..596fe36 100644 --- a/sdks/go/cmd/elydora/plugins/claudecode_io.go +++ b/sdks/go/cmd/elydora/plugins/claudecode_io.go @@ -71,38 +71,3 @@ func prepareRenderedClaudeChange( rendered.remove, ) } - -func writeClaudeChanges( - changes []*fileChange, - label string, - rename renameFunc, - runtimeRoot string, - agentDirectory string, - settingsDirectory string, -) error { - hasChanges := false - for _, change := range changes { - if change != nil { - hasChanges = true - break - } - } - if !hasChanges { - return nil - } - if agentDirectory != "" { - if err := EnsurePrivateDirectory(runtimeRoot); err != nil { - return err - } - if err := EnsurePrivateDirectory(agentDirectory); err != nil { - return err - } - } - if err := ensureManagedDirectory( - settingsDirectory, - "Claude Code configuration directory", - ); err != nil { - return err - } - return writeChanges(changes, label, rename) -} diff --git a/sdks/go/cmd/elydora/plugins/claudecode_ownership.go b/sdks/go/cmd/elydora/plugins/claudecode_ownership.go index f3c6aa4..ce4a21a 100644 --- a/sdks/go/cmd/elydora/plugins/claudecode_ownership.go +++ b/sdks/go/cmd/elydora/plugins/claudecode_ownership.go @@ -1,73 +1,18 @@ package plugins import ( - "fmt" "path/filepath" - "runtime" "sort" "strings" ) -type claudeRuntimeReference struct { - agentID string - scriptPath string -} - -func sameClaudePath(left, right string) bool { - left = filepath.Clean(left) - right = filepath.Clean(right) - if absolute, err := filepath.Abs(left); err == nil { - left = absolute - } - if absolute, err := filepath.Abs(right); err == nil { - right = absolute - } - if runtime.GOOS == "windows" { - return strings.EqualFold(left, right) - } - return left == right -} - -func sameClaudeAgentID(left, right string) bool { - if runtime.GOOS == "windows" { - return strings.EqualFold(left, right) - } - return left == right -} - -func isClaudeNodeExecutable(path string) bool { - name := filepath.Base(path) - return name == "node" || strings.EqualFold(name, "node.exe") -} - -func sameClaudeFileName(left, right string) bool { - if runtime.GOOS == "windows" { - return strings.EqualFold(left, right) - } - return left == right -} +type claudeRuntimeReference = managedScriptReference func claudeRuntimeReferenceForScript( scriptPath string, scriptName string, ) (*claudeRuntimeReference, error) { - if !filepath.IsAbs(scriptPath) || - !sameClaudeFileName(filepath.Base(scriptPath), scriptName) { - return nil, nil - } - agentDirectory := filepath.Dir(scriptPath) - runtimeRoot, err := AgentRuntimeRoot() - if err != nil { - return nil, err - } - if !sameClaudePath(filepath.Dir(agentDirectory), runtimeRoot) { - return nil, nil - } - agentID := filepath.Base(agentDirectory) - if agentID == "" || agentID == "." || agentID == ".." { - return nil, nil - } - return &claudeRuntimeReference{agentID: agentID, scriptPath: scriptPath}, nil + return resolveManagedScript(scriptPath, scriptName) } func claudeLegacyReference( @@ -95,7 +40,7 @@ func managedClaudeReference( handler["statusMessage"] == statusMessage { nodePath, nodeOK := handler["command"].(string) args, argsOK := handler["args"].([]any) - if nodeOK && filepath.IsAbs(nodePath) && isClaudeNodeExecutable(nodePath) && + if nodeOK && filepath.IsAbs(nodePath) && isNodeExecutable(nodePath) && argsOK && len(args) == 1 { if scriptPath, ok := args[0].(string); ok { return claudeRuntimeReferenceForScript(scriptPath, scriptName) @@ -146,7 +91,7 @@ func removeManagedClaudeGroups( return nil, false, err } owned := reference != nil && - (agentID == "" || sameClaudeAgentID(reference.agentID, agentID)) + (agentID == "" || sameManagedAgentID(reference.agentID, agentID)) if owned { removed = true continue @@ -154,7 +99,7 @@ func removeManagedClaudeGroups( kept = append(kept, handler) } if len(kept) > 0 || !exactManagedClaudeGroup(group) { - object := cloneClaudeObject(group.object) + object := cloneJSONObject(group.object) object["hooks"] = group.object["hooks"] result = append(result, claudeGroup{object: object, handlers: kept}) } @@ -235,13 +180,6 @@ func entirelyManagedClaudeDocument(document *claudeDocument) bool { return handlerCount > 0 } -func claudeReferenceKey(agentID string) string { - if runtime.GOOS == "windows" { - return strings.ToLower(agentID) - } - return agentID -} - func claudeReferencesForEvent( groups []claudeGroup, scriptName string, @@ -263,7 +201,7 @@ func claudeReferencesForEvent( return nil, err } if reference != nil { - key := claudeReferenceKey(reference.agentID) + key := managedReferenceKey(reference.agentID) result[key] = append(result[key], *reference) } } @@ -307,7 +245,7 @@ func claudeRuntimeContracts(hooks claudeHooks) ([]claudeRuntimeContract, error) success := successes[key] failure := failures[key] if len(guard) != 1 || len(success) != 1 || len(failure) != 1 || - !sameClaudePath(success[0].scriptPath, failure[0].scriptPath) { + !sameManagedPath(success[0].scriptPath, failure[0].scriptPath) { continue } contracts = append(contracts, claudeRuntimeContract{ @@ -317,10 +255,3 @@ func claudeRuntimeContracts(hooks claudeHooks) ([]claudeRuntimeContract, error) } return contracts, nil } - -func requireClaudeAbsoluteNode(nodePath string) error { - if !filepath.IsAbs(nodePath) || !isClaudeNodeExecutable(nodePath) { - return fmt.Errorf("Claude Code hooks require an absolute Node.js executable path") - } - return nil -} diff --git a/sdks/go/cmd/elydora/plugins/claudecode_security_test.go b/sdks/go/cmd/elydora/plugins/claudecode_security_test.go index 3753f24..f248e7d 100644 --- a/sdks/go/cmd/elydora/plugins/claudecode_security_test.go +++ b/sdks/go/cmd/elydora/plugins/claudecode_security_test.go @@ -150,10 +150,10 @@ func TestClaudeValidatesManagedRuntimeInputsBeforeWrites(t *testing.T) { }, "query parameters"}, {"guard path", func(f *claudeFixture) { f.config.GuardScriptPath = filepath.Join(f.homeDir, "outside", "guard.js") - }, "managed Elydora agent directory"}, + }, "managed agent directory"}, {"audit path", func(f *claudeFixture) { f.config.HookScript = filepath.Join(f.homeDir, "outside", "hook.js") - }, "managed Elydora agent directory"}, + }, "managed agent directory"}, } for _, testCase := range tests { t.Run(testCase.name, func(t *testing.T) { diff --git a/sdks/go/cmd/elydora/plugins/claudecode_status.go b/sdks/go/cmd/elydora/plugins/claudecode_status.go index 781cb21..7c8ea88 100644 --- a/sdks/go/cmd/elydora/plugins/claudecode_status.go +++ b/sdks/go/cmd/elydora/plugins/claudecode_status.go @@ -1,231 +1,5 @@ package plugins -import ( - "fmt" - "path/filepath" - "sort" - "strings" -) - -func readClaudeRuntimeConfig(path string) (map[string]any, bool, error) { - snapshot, err := readManagedFile(path, "Elydora runtime config", maxRuntimeConfigBytes) - if err != nil || snapshot == nil { - return nil, snapshot != nil, err - } - label := fmt.Sprintf("Elydora runtime config at %s", path) - config, err := decodeStrictJSONObject(snapshot.contents, label) - return config, true, err -} - -func requireClaudeRuntimeString( - config map[string]any, - field string, - configPath string, -) (string, error) { - value, ok := config[field].(string) - if !ok || strings.TrimSpace(value) == "" { - return "", fmt.Errorf( - "elydora runtime config %s is invalid: %s", - field, - configPath, - ) - } - return value, nil -} - -func validateClaudeRuntimeConfig( - config map[string]any, - expectedAgentID string, - configPath string, -) error { - supported := stringSet( - "org_id", "agent_id", "kid", "base_url", "token", "agent_name", - ) - fields := make([]string, 0, len(config)) - for field := range config { - fields = append(fields, field) - } - sort.Strings(fields) - for _, field := range fields { - if _, ok := supported[field]; !ok { - return fmt.Errorf( - `elydora runtime config has unsupported field %q: %s`, - field, - configPath, - ) - } - } - if _, err := requireClaudeRuntimeString(config, "org_id", configPath); err != nil { - return err - } - if _, err := requireClaudeRuntimeString(config, "kid", configPath); err != nil { - return err - } - agentID, err := requireClaudeRuntimeString(config, "agent_id", configPath) - if err != nil { - return err - } - if !sameClaudeAgentID(agentID, expectedAgentID) || - config["agent_name"] != claudeAgentKey { - return fmt.Errorf( - "elydora runtime identity does not match Claude Code hooks: %s", - configPath, - ) - } - if _, exists := config["token"]; exists { - if _, err := requireClaudeRuntimeString(config, "token", configPath); err != nil { - return err - } - } - baseURL, err := requireClaudeRuntimeString(config, "base_url", configPath) - if err != nil { - return err - } - if err := validateManagedBaseURL(baseURL); err != nil { - return fmt.Errorf( - "elydora runtime config base URL is invalid at %s: %w", - configPath, - err, - ) - } - return nil -} - -func validateClaudeRuntimeIdentity(agentDirectory, agentID string) error { - runtimeRoot := filepath.Dir(agentDirectory) - rootExists, err := managedPhysicalDirectoryExists( - runtimeRoot, - "Elydora runtime directory", - ) - if err != nil || !rootExists { - return err - } - directoryExists, err := managedPhysicalDirectoryExists( - agentDirectory, - "Elydora agent runtime directory", - ) - if err != nil || !directoryExists { - return err - } - configPath := filepath.Join(agentDirectory, "config.json") - config, configExists, err := readClaudeRuntimeConfig(configPath) - if err != nil { - return err - } - artifactExists := false - for _, item := range []struct { - path string - label string - limit int64 - }{ - {filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes}, - {filepath.Join(agentDirectory, claudeGuardScript), "Elydora guard runtime", maxManagedSourceBytes}, - {filepath.Join(agentDirectory, claudeAuditScript), "Elydora audit runtime", maxManagedSourceBytes}, - {filepath.Join(agentDirectory, "chain-state.json"), "Elydora chain state", maxRuntimeConfigBytes}, - {filepath.Join(agentDirectory, "status-cache.json"), "Elydora status cache", maxRuntimeConfigBytes}, - {filepath.Join(agentDirectory, "error.log"), "Elydora error log", maxManagedSourceBytes}, - } { - exists, inspectErr := managedPhysicalFileExists(item.path, item.label, item.limit) - if inspectErr != nil { - return inspectErr - } - artifactExists = artifactExists || exists - } - if !configExists { - if artifactExists { - return fmt.Errorf( - "elydora runtime identity cannot be verified without config.json: %s", - agentDirectory, - ) - } - return nil - } - configuredID, ok := config["agent_id"].(string) - if !ok || config["agent_name"] != claudeAgentKey || - !sameClaudeAgentID(configuredID, agentID) { - return fmt.Errorf( - "elydora runtime config identity does not match Claude Code agent %s: %s", - agentID, - configPath, - ) - } - return nil -} - -func claudeRuntimeContractExists(contract claudeRuntimeContract) (bool, error) { - runtimeRoot, err := AgentRuntimeRoot() - if err != nil { - return false, err - } - agentDirectory := filepath.Dir(contract.guardPath) - if !sameClaudePath(filepath.Dir(agentDirectory), runtimeRoot) || - !sameClaudePath( - contract.auditPath, - filepath.Join(agentDirectory, claudeAuditScript), - ) { - return false, nil - } - rootExists, err := managedPhysicalDirectoryExists( - runtimeRoot, - "Elydora runtime directory", - ) - if err != nil || !rootExists { - return false, err - } - directoryExists, err := managedPhysicalDirectoryExists( - agentDirectory, - "Elydora agent runtime directory", - ) - if err != nil || !directoryExists { - return false, err - } - configPath := filepath.Join(agentDirectory, "config.json") - keyPath := filepath.Join(agentDirectory, "private.key") - config, configExists, err := readClaudeRuntimeConfig(configPath) - if err != nil { - return false, err - } - key, err := readManagedFile(keyPath, "Elydora private key", maxProtectedSecretBytes) - if err != nil { - return false, err - } - guard, err := readManagedFile( - contract.guardPath, - "Elydora guard runtime", - maxManagedSourceBytes, - ) - if err != nil { - return false, err - } - audit, err := readManagedFile( - contract.auditPath, - "Elydora audit runtime", - maxManagedSourceBytes, - ) - if err != nil { - return false, err - } - if !configExists || key == nil || guard == nil || audit == nil { - return false, nil - } - if err := validateClaudeRuntimeConfig(config, contract.agentID, configPath); err != nil { - return false, err - } - if err := validateManagedPrivateKey(string(key.contents)); err != nil { - return false, fmt.Errorf("elydora private key at %s: %w", keyPath, err) - } - return len(guard.contents) > 0 && len(audit.contents) > 0, nil -} - -func claudeRuntimeFilesExist(contracts []claudeRuntimeContract) (bool, error) { - for _, contract := range contracts { - exists, err := claudeRuntimeContractExists(contract) - if err != nil { - return false, err - } - if exists { - return true, nil - } - } - return false, nil +func claudeRuntimeFilesExist(contracts []managedRuntimeContract) (bool, error) { + return managedRuntimeFilesExist(contracts, claudeAgentKey, "Claude Code", nil) } diff --git a/sdks/go/cmd/elydora/plugins/claudecode_transaction_test.go b/sdks/go/cmd/elydora/plugins/claudecode_transaction_test.go index b46991b..8ec3073 100644 --- a/sdks/go/cmd/elydora/plugins/claudecode_transaction_test.go +++ b/sdks/go/cmd/elydora/plugins/claudecode_transaction_test.go @@ -60,7 +60,7 @@ func TestClaudeInstallRollsBackAllRuntimesAfterSettingsFailure(t *testing.T) { } before := snapshotClaudeFiles(t, paths...) fixture.plugin.rename = func(source, destination string) error { - if sameClaudePath(destination, fixture.configPath) && + if sameManagedPath(destination, fixture.configPath) && strings.HasSuffix(source, ".tmp") { return errors.New("injected Claude settings failure") } @@ -114,13 +114,14 @@ func TestPreparedClaudeInstallRejectsConcurrentSettingsChange(t *testing.T) { if err := os.WriteFile(fixture.configPath, concurrent, 0600); err != nil { t.Fatalf("write concurrent settings: %v", err) } - err = writeClaudeChanges( + err = writeManagedChanges( changes, "Install Claude Code hooks", nil, paths.runtimeRoot, paths.agentDirectory, filepath.Dir(document.filePath), + "Claude Code configuration directory", ) if err == nil || !strings.Contains(err.Error(), "changed during installation") { t.Fatalf("prepared install error = %v", err) @@ -176,7 +177,7 @@ func TestClaudeUninstallPreservesSettingsAfterCommitFailure(t *testing.T) { t.Fatalf("read installed settings: %v", err) } fixture.plugin.rename = func(source, destination string) error { - if sameClaudePath(destination, fixture.configPath) && + if sameManagedPath(destination, fixture.configPath) && strings.HasSuffix(source, ".tmp") { return errors.New("injected Claude uninstall failure") } diff --git a/sdks/go/cmd/elydora/plugins/cline.go b/sdks/go/cmd/elydora/plugins/cline.go index 0db5396..acfbb28 100644 --- a/sdks/go/cmd/elydora/plugins/cline.go +++ b/sdks/go/cmd/elydora/plugins/cline.go @@ -7,8 +7,7 @@ type ClinePlugin struct { rename renameFunc } -// ManagesGuardRuntime reports that Cline commits both generated runtimes and -// both native hook files in one transaction. +// ManagesGuardRuntime reports that the guard is committed with the audit runtime. func (p *ClinePlugin) ManagesGuardRuntime() bool { return true } diff --git a/sdks/go/cmd/elydora/plugins/cline_contract.go b/sdks/go/cmd/elydora/plugins/cline_contract.go index 5d2f0d1..5cdde35 100644 --- a/sdks/go/cmd/elydora/plugins/cline_contract.go +++ b/sdks/go/cmd/elydora/plugins/cline_contract.go @@ -6,7 +6,6 @@ import ( "fmt" "os" "path/filepath" - "runtime" "strings" ) @@ -80,37 +79,6 @@ func resolveClineHookFiles() (clineHookPaths, error) { }, nil } -func sameClineAgentID(left, right string) bool { - if runtime.GOOS == "windows" { - return strings.EqualFold(left, right) - } - return left == right -} - -func normalizeClinePath(value string) (string, error) { - absolute, err := filepath.Abs(value) - if err != nil { - return "", err - } - normalized := filepath.Clean(absolute) - if runtime.GOOS == "windows" { - normalized = strings.ToLower(normalized) - } - return normalized, nil -} - -func sameClinePath(left, right string) (bool, error) { - normalizedLeft, err := normalizeClinePath(left) - if err != nil { - return false, err - } - normalizedRight, err := normalizeClinePath(right) - if err != nil { - return false, err - } - return normalizedLeft == normalizedRight, nil -} - func buildClineMetadata(kind, agentID, runtimePath string) (clineHookMetadata, error) { metadata := clineHookMetadata{ Version: clineMetadataVersion, @@ -314,7 +282,7 @@ func clineContractForFiles( if guard.Kind != "guard" || audit.Kind != "audit" { return nil, fmt.Errorf("Elydora Cline hook files contain mismatched event metadata") } - if !sameClineAgentID(guard.AgentID, audit.AgentID) { + if !sameManagedAgentID(guard.AgentID, audit.AgentID) { return nil, fmt.Errorf("Elydora Cline hook files reference different agents") } agentDirectory, err := ResolveAgentRuntimeDirectory(guard.AgentID) @@ -323,15 +291,8 @@ func clineContractForFiles( } expectedGuard := filepath.Join(agentDirectory, clineGuardScript) expectedAudit := filepath.Join(agentDirectory, clineAuditScript) - guardMatches, err := sameClinePath(guard.RuntimePath, expectedGuard) - if err != nil { - return nil, fmt.Errorf("compare Cline guard runtime path: %w", err) - } - auditMatches, err := sameClinePath(audit.RuntimePath, expectedAudit) - if err != nil { - return nil, fmt.Errorf("compare Cline audit runtime path: %w", err) - } - if !guardMatches || !auditMatches { + if !sameManagedPath(guard.RuntimePath, expectedGuard) || + !sameManagedPath(audit.RuntimePath, expectedAudit) { return nil, fmt.Errorf("Elydora Cline hook metadata references an unexpected runtime path") } return &clineRuntimeContract{ diff --git a/sdks/go/cmd/elydora/plugins/cline_installation.go b/sdks/go/cmd/elydora/plugins/cline_installation.go index dff29e8..d6c3056 100644 --- a/sdks/go/cmd/elydora/plugins/cline_installation.go +++ b/sdks/go/cmd/elydora/plugins/cline_installation.go @@ -1,97 +1,21 @@ package plugins -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "strings" -) +import "fmt" type clineRuntimePaths struct { - runtimeRoot string - agentDirectory string - configPath string - keyPath string - guardPath string - auditPath string - hooks clineHookPaths + managedRuntimePaths + hooks clineHookPaths } -func validateClineInstallConfig(config InstallConfig) error { - for _, field := range []struct{ name, value string }{ - {"agent name", config.AgentName}, - {"organization ID", config.OrgID}, - {"agent ID", config.AgentID}, - {"key ID", config.KID}, - {"private key", config.PrivateKey}, - {"base URL", config.BaseURL}, - {"guard script path", config.GuardScriptPath}, - } { - if field.value == "" { - return fmt.Errorf("%s is required", field.name) - } - } - if config.AgentName != clineAgentKey { - return fmt.Errorf("Cline installation requires agent name %s", clineAgentKey) - } - if strings.TrimSpace(config.OrgID) == "" { - return fmt.Errorf("organization ID is required") - } - if strings.TrimSpace(config.KID) == "" { - return fmt.Errorf("key ID is required") - } - if config.Token != "" && strings.TrimSpace(config.Token) == "" { - return fmt.Errorf("token must contain a non-whitespace value when provided") - } - if err := validateManagedPrivateKey(config.PrivateKey); err != nil { - return err - } - return validateManagedBaseURL(config.BaseURL) -} - -func clineAgentPaths( - config InstallConfig, - hooks clineHookPaths, -) (*clineRuntimePaths, error) { - if err := validateClineInstallConfig(config); err != nil { +func clineAgentPaths(config InstallConfig, hooks clineHookPaths) (*clineRuntimePaths, error) { + if err := validateManagedInstallConfig(config, clineAgentKey, "Cline"); err != nil { return nil, err } - runtimeRoot, err := AgentRuntimeRoot() + paths, err := resolveManagedRuntimePaths(config, clineGuardScript, clineAuditScript) if err != nil { return nil, err } - agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID) - if err != nil { - return nil, err - } - paths := &clineRuntimePaths{ - runtimeRoot: runtimeRoot, agentDirectory: agentDirectory, hooks: hooks, - configPath: filepath.Join(agentDirectory, "config.json"), - keyPath: filepath.Join(agentDirectory, "private.key"), - guardPath: filepath.Join(agentDirectory, clineGuardScript), - auditPath: filepath.Join(agentDirectory, clineAuditScript), - } - if !filepath.IsAbs(config.GuardScriptPath) || - !sameClinePathValue(config.GuardScriptPath, paths.guardPath) { - return nil, fmt.Errorf( - "Elydora guard runtime must use the managed agent directory: %s", - paths.guardPath, - ) - } - if config.HookScript != "" && (!filepath.IsAbs(config.HookScript) || - !sameClinePathValue(config.HookScript, paths.auditPath)) { - return nil, fmt.Errorf( - "Elydora audit runtime must use the managed agent directory: %s", - paths.auditPath, - ) - } - return paths, nil -} - -func sameClinePathValue(left, right string) bool { - matches, err := sameClinePath(left, right) - return err == nil && matches + return &clineRuntimePaths{managedRuntimePaths: *paths, hooks: hooks}, nil } func preflightClineInstallation( @@ -102,39 +26,20 @@ func preflightClineInstallation( if err != nil { return nil, err } - if err := validateClineRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil { + if err := validateManagedRuntimeIdentity( + paths.agentDirectory, config.AgentID, clineAgentKey, "Cline", + ); err != nil { return nil, err } - nodePath, err := resolveNodeRuntime() - if err != nil { + if _, err := resolveAbsoluteNodeRuntime("Cline"); err != nil { return nil, err } - if !filepath.IsAbs(nodePath) || !isClaudeNodeExecutable(nodePath) { - return nil, fmt.Errorf("Cline hooks require an absolute Node.js executable path") - } return paths, nil } -func buildClineRuntimeConfig(config InstallConfig) ([]byte, error) { - value := map[string]any{ - "org_id": config.OrgID, "agent_id": config.AgentID, "kid": config.KID, - "base_url": config.BaseURL, "agent_name": clineAgentKey, - } - if config.Token != "" { - value["token"] = config.Token - } - encoded, err := json.MarshalIndent(value, "", " ") - if err != nil { - return nil, fmt.Errorf("encode Elydora runtime config: %w", err) - } - encoded = append(encoded, '\n') - if len(encoded) > maxRuntimeConfigBytes { - return nil, fmt.Errorf( - "Elydora runtime config exceeds %d bytes after JSON encoding", - maxRuntimeConfigBytes, - ) - } - return encoded, nil +func clineExpectedScripts(agentID string, _ map[string]any) ([]byte, []byte) { + return []byte(generateGuardScript(clineAgentKey, agentID, "", false, "")), + []byte(buildHookScriptWithOutput(clineAgentKey, agentID, "", false, true)) } func prepareClineInstallationChanges( @@ -146,11 +51,9 @@ func prepareClineInstallationChanges( if paths == nil { return nil, fmt.Errorf("prepared Cline installation is required") } - if err := validateClineRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil { - return nil, err - } - runtimeConfig, err := buildClineRuntimeConfig(config) - if err != nil { + if err := validateManagedRuntimeIdentity( + paths.agentDirectory, config.AgentID, clineAgentKey, "Cline", + ); err != nil { return nil, err } guardMetadata, err := buildClineMetadata("guard", config.AgentID, paths.guardPath) @@ -175,23 +78,12 @@ func prepareClineInstallationChanges( ); err != nil { return nil, err } - runtimeItems := []struct { - path, label string - content []byte - mode os.FileMode - }{ - {paths.guardPath, "Elydora guard runtime", []byte(generateGuardScript(clineAgentKey, config.AgentID, "", false, "")), 0700}, - {paths.configPath, "Elydora runtime config", runtimeConfig, 0600}, - {paths.keyPath, "Elydora private key", []byte(config.PrivateKey), 0600}, - {paths.auditPath, "Elydora audit runtime", []byte(buildHookScriptWithOutput(clineAgentKey, config.AgentID, "", false, true)), 0700}, - } - changes := make([]*fileChange, 0, len(runtimeItems)+2) - for _, item := range runtimeItems { - change, err := prepareFileChange(item.path, item.label, item.content, item.mode) - if err != nil { - return nil, err - } - changes = append(changes, change) + guardScript, auditScript := clineExpectedScripts(config.AgentID, nil) + changes, err := managedRuntimeFileChanges( + config, &paths.managedRuntimePaths, clineAgentKey, string(guardScript), string(auditScript), + ) + if err != nil { + return nil, err } for _, item := range []struct { file clineHookFile diff --git a/sdks/go/cmd/elydora/plugins/cline_io.go b/sdks/go/cmd/elydora/plugins/cline_io.go index bc8d549..522702d 100644 --- a/sdks/go/cmd/elydora/plugins/cline_io.go +++ b/sdks/go/cmd/elydora/plugins/cline_io.go @@ -74,7 +74,7 @@ func writeClineHookChanges( rename renameFunc, paths clineHookPaths, ) error { - if !hasClineChanges(changes) { + if !hasFileChanges(changes) { return nil } if err := ensureManagedDirectory( @@ -95,7 +95,7 @@ func writeClineChanges( rename renameFunc, paths *clineRuntimePaths, ) error { - if !hasClineChanges(changes) { + if !hasFileChanges(changes) { return nil } if err := EnsurePrivateDirectory(paths.runtimeRoot); err != nil { @@ -107,15 +107,6 @@ func writeClineChanges( return writeClineHookChanges(changes, label, rename, paths.hooks) } -func hasClineChanges(changes []*fileChange) bool { - for _, change := range changes { - if change != nil { - return true - } - } - return false -} - func prepareClineUninstallChanges( files []clineHookFile, agentID string, @@ -123,7 +114,7 @@ func prepareClineUninstallChanges( changes := make([]*fileChange, 0, len(files)) for _, file := range files { if file.metadata == nil || - (agentID != "" && !sameClineAgentID(file.metadata.AgentID, agentID)) { + (agentID != "" && !sameManagedAgentID(file.metadata.AgentID, agentID)) { continue } if err := assertClineWrapperIntegrity(file); err != nil { diff --git a/sdks/go/cmd/elydora/plugins/cline_status.go b/sdks/go/cmd/elydora/plugins/cline_status.go index daeef04..5ec6896 100644 --- a/sdks/go/cmd/elydora/plugins/cline_status.go +++ b/sdks/go/cmd/elydora/plugins/cline_status.go @@ -1,206 +1,15 @@ package plugins -import ( - "bytes" - "fmt" - "path/filepath" - "sort" - "strings" -) - -func readClineRuntimeConfig(path string) (map[string]any, bool, error) { - snapshot, err := readManagedFile(path, "Elydora runtime config", maxRuntimeConfigBytes) - if err != nil || snapshot == nil { - return nil, snapshot != nil, err - } - label := fmt.Sprintf("Elydora runtime config at %s", path) - config, err := decodeStrictJSONObject(snapshot.contents, label) - return config, true, err -} - -func requireClineRuntimeString( - config map[string]any, - field string, - configPath string, -) (string, error) { - value, ok := config[field].(string) - if !ok || strings.TrimSpace(value) == "" { - return "", fmt.Errorf( - "Elydora runtime config %s is invalid: %s", - field, - configPath, - ) - } - return value, nil -} - -func validateClineRuntimeConfig( - config map[string]any, - expectedAgentID string, - configPath string, -) error { - supported := stringSet( - "org_id", "agent_id", "kid", "base_url", "token", "agent_name", - ) - fields := make([]string, 0, len(config)) - for field := range config { - fields = append(fields, field) - } - sort.Strings(fields) - for _, field := range fields { - if _, ok := supported[field]; !ok { - return fmt.Errorf( - `Elydora runtime config has unsupported field %q: %s`, - field, - configPath, - ) - } - } - if _, err := requireClineRuntimeString(config, "org_id", configPath); err != nil { - return err - } - if _, err := requireClineRuntimeString(config, "kid", configPath); err != nil { - return err - } - agentID, err := requireClineRuntimeString(config, "agent_id", configPath) - if err != nil { - return err - } - if !sameClineAgentID(agentID, expectedAgentID) || config["agent_name"] != clineAgentKey { - return fmt.Errorf( - "Elydora runtime identity does not match Cline hooks: %s", - configPath, - ) - } - if _, exists := config["token"]; exists { - if _, err := requireClineRuntimeString(config, "token", configPath); err != nil { - return err - } - } - baseURL, err := requireClineRuntimeString(config, "base_url", configPath) - if err != nil { - return err - } - if err := validateManagedBaseURL(baseURL); err != nil { - return fmt.Errorf( - "Elydora runtime config base URL is invalid at %s: %w", - configPath, - err, - ) - } - return nil -} - -func validateClineRuntimeIdentity(agentDirectory, agentID string) error { - runtimeRoot := filepath.Dir(agentDirectory) - rootExists, err := managedPhysicalDirectoryExists( - runtimeRoot, - "Elydora runtime directory", - ) - if err != nil || !rootExists { - return err - } - directoryExists, err := managedPhysicalDirectoryExists( - agentDirectory, - "Elydora agent runtime directory", - ) - if err != nil || !directoryExists { - return err - } - configPath := filepath.Join(agentDirectory, "config.json") - config, configExists, err := readClineRuntimeConfig(configPath) - if err != nil { - return err - } - artifactExists := false - for _, item := range []struct { - path string - label string - limit int64 - }{ - {filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes}, - {filepath.Join(agentDirectory, clineGuardScript), "Elydora guard runtime", maxManagedSourceBytes}, - {filepath.Join(agentDirectory, clineAuditScript), "Elydora audit runtime", maxManagedSourceBytes}, - {filepath.Join(agentDirectory, "chain-state.json"), "Elydora chain state", maxRuntimeConfigBytes}, - {filepath.Join(agentDirectory, "status-cache.json"), "Elydora status cache", maxRuntimeConfigBytes}, - {filepath.Join(agentDirectory, "error.log"), "Elydora error log", maxManagedSourceBytes}, - } { - exists, inspectErr := managedPhysicalFileExists(item.path, item.label, item.limit) - if inspectErr != nil { - return inspectErr - } - artifactExists = artifactExists || exists - } - if !configExists { - if artifactExists { - return fmt.Errorf( - "Elydora runtime identity cannot be verified without config.json: %s", - agentDirectory, - ) - } - return nil - } - configuredID, ok := config["agent_id"].(string) - if !ok || config["agent_name"] != clineAgentKey || - !sameClineAgentID(configuredID, agentID) { - return fmt.Errorf( - "Elydora runtime config identity does not match Cline agent %s: %s", - agentID, - configPath, - ) - } - return nil -} +import "path/filepath" func clineRuntimeFilesExist(contract *clineRuntimeContract) (bool, error) { - runtimeRoot, err := AgentRuntimeRoot() - if err != nil { - return false, err - } - if !sameClinePathValue(filepath.Dir(contract.agentDirectory), runtimeRoot) || - !sameClinePathValue(contract.guardPath, filepath.Join(contract.agentDirectory, clineGuardScript)) || - !sameClinePathValue(contract.auditPath, filepath.Join(contract.agentDirectory, clineAuditScript)) { + if !sameManagedPath(contract.guardPath, filepath.Join(contract.agentDirectory, clineGuardScript)) { return false, nil } - rootExists, err := managedPhysicalDirectoryExists(runtimeRoot, "Elydora runtime directory") - if err != nil || !rootExists { - return false, err - } - directoryExists, err := managedPhysicalDirectoryExists( - contract.agentDirectory, - "Elydora agent runtime directory", + return managedRuntimeContractExists( + managedRuntimeContract{contract.agentID, contract.guardPath, contract.auditPath}, + clineAgentKey, + "Cline", + clineExpectedScripts, ) - if err != nil || !directoryExists { - return false, err - } - configPath := filepath.Join(contract.agentDirectory, "config.json") - keyPath := filepath.Join(contract.agentDirectory, "private.key") - config, configExists, err := readClineRuntimeConfig(configPath) - if err != nil { - return false, err - } - key, err := readManagedFile(keyPath, "Elydora private key", maxProtectedSecretBytes) - if err != nil { - return false, err - } - guard, err := readManagedFile(contract.guardPath, "Elydora guard runtime", maxManagedSourceBytes) - if err != nil { - return false, err - } - audit, err := readManagedFile(contract.auditPath, "Elydora audit runtime", maxManagedSourceBytes) - if err != nil { - return false, err - } - if !configExists || key == nil || guard == nil || audit == nil { - return false, nil - } - if err := validateClineRuntimeConfig(config, contract.agentID, configPath); err != nil { - return false, err - } - if err := validateManagedPrivateKey(string(key.contents)); err != nil { - return false, fmt.Errorf("Elydora private key at %s: %w", keyPath, err) - } - expectedGuard := []byte(generateGuardScript(clineAgentKey, contract.agentID, "", false, "")) - expectedAudit := []byte(buildHookScriptWithOutput(clineAgentKey, contract.agentID, "", false, true)) - return bytes.Equal(guard.contents, expectedGuard) && bytes.Equal(audit.contents, expectedAudit), nil } diff --git a/sdks/go/cmd/elydora/plugins/codex.go b/sdks/go/cmd/elydora/plugins/codex.go index 2471184..686c862 100644 --- a/sdks/go/cmd/elydora/plugins/codex.go +++ b/sdks/go/cmd/elydora/plugins/codex.go @@ -10,14 +10,12 @@ type CodexPlugin struct { rename renameFunc } -// ManagesGuardRuntime reports that Codex commits its provider guard with the -// audit runtime and user hook document. +// ManagesGuardRuntime reports that the guard is committed with the audit runtime. func (p *CodexPlugin) ManagesGuardRuntime() bool { return true } -// PreflightInstall validates every existing source before the CLI creates -// runtime state. +// PreflightInstall validates every source before the CLI creates runtime state. func (p *CodexPlugin) PreflightInstall(config InstallConfig) error { document, err := readCodexDocument() if err != nil { @@ -61,13 +59,14 @@ func (p *CodexPlugin) Install(config InstallConfig) error { if err != nil { return err } - if err := writeCodexChanges( + if err := writeManagedChanges( changes, "Install Codex hooks", p.rename, paths.runtimeRoot, paths.agentDirectory, filepath.Dir(document.filePath), + "Codex hooks directory", ); err != nil { return err } @@ -90,13 +89,14 @@ func (p *CodexPlugin) Uninstall(agentID string) error { if err != nil { return err } - return writeCodexChanges( + return writeManagedChanges( []*fileChange{change}, "Uninstall Codex hooks", p.rename, "", "", filepath.Dir(document.filePath), + "Codex hooks directory", ) } diff --git a/sdks/go/cmd/elydora/plugins/codex_command.go b/sdks/go/cmd/elydora/plugins/codex_command.go index dd42168..95b086e 100644 --- a/sdks/go/cmd/elydora/plugins/codex_command.go +++ b/sdks/go/cmd/elydora/plugins/codex_command.go @@ -1,53 +1,12 @@ package plugins -import ( - "encoding/base64" - "encoding/binary" - "os" - "path/filepath" - "runtime" - "strings" - "unicode/utf16" -) - -func quoteCodexPowerShell(value string) string { - return "'" + strings.ReplaceAll(value, "'", "''") + "'" -} - -func codexPowerShellPath() string { - root := "C:\\Windows" - if runtime.GOOS == "windows" { - configured := os.Getenv("SystemRoot") - if filepath.IsAbs(configured) && !strings.ContainsAny(configured, "\"%\r\n") { - root = configured - } - } - return strings.TrimRight(root, `\/`) + - `\System32\WindowsPowerShell\v1.0\powershell.exe` -} - -func encodeCodexPowerShell(source string) string { - runes := utf16.Encode([]rune(source)) - raw := make([]byte, len(runes)*2) - for index, value := range runes { - binary.LittleEndian.PutUint16(raw[index*2:], value) - } - return base64.StdEncoding.EncodeToString(raw) -} - -func codexWindowsCommand(runtimePath, scriptPath string) string { - source := "& " + quoteCodexPowerShell(runtimePath) + " " + - quoteCodexPowerShell(scriptPath) + "; exit $LASTEXITCODE" - return `"` + codexPowerShellPath() + - `" -NoLogo -NoProfile -NonInteractive -EncodedCommand ` + - encodeCodexPowerShell(source) -} +import "path/filepath" func codexHandler(runtimePath, scriptPath, statusMessage string) map[string]any { return map[string]any{ "type": "command", - "command": quotePOSIXArgument(runtimePath) + " " + quotePOSIXArgument(scriptPath), - "commandWindows": codexWindowsCommand(runtimePath, scriptPath), + "command": posixSource(runtimePath, scriptPath), + "commandWindows": encodedWindowsCommand(runtimePath, scriptPath), "timeout": codexHookTimeout, "statusMessage": statusMessage, } @@ -57,124 +16,6 @@ func codexMatcherGroup(handler map[string]any) map[string]any { return map[string]any{"matcher": "*", "hooks": []any{handler}} } -func readCodexPOSIXArgument(command string, start int) (string, int, bool) { - if start >= len(command) || command[start] != '\'' { - return "", start, false - } - var value strings.Builder - for index := start + 1; index < len(command); { - if strings.HasPrefix(command[index:], codexPOSIXApostrophe) { - value.WriteByte('\'') - index += len(codexPOSIXApostrophe) - continue - } - if command[index] == '\'' { - return value.String(), index + 1, true - } - value.WriteByte(command[index]) - index++ - } - return "", start, false -} - -func parseCodexPOSIXCommand(value any) (string, string, bool) { - command, ok := value.(string) - if !ok { - return "", "", false - } - runtimePath, next, ok := readCodexPOSIXArgument(command, 0) - if !ok || next >= len(command) || command[next] != ' ' { - return "", "", false - } - scriptPath, end, ok := readCodexPOSIXArgument(command, next+1) - return runtimePath, scriptPath, ok && end == len(command) -} - -func readCodexPowerShellArgument(command string, start int) (string, int, bool) { - if start >= len(command) || command[start] != '\'' { - return "", start, false - } - var value strings.Builder - for index := start + 1; index < len(command); index++ { - if command[index] != '\'' { - value.WriteByte(command[index]) - continue - } - if index+1 < len(command) && command[index+1] == '\'' { - value.WriteByte('\'') - index++ - continue - } - return value.String(), index + 1, true - } - return "", start, false -} - -func parseCodexPowerShellSource(source string) (string, string, bool) { - if !strings.HasPrefix(source, "& ") { - return "", "", false - } - runtimePath, next, ok := readCodexPowerShellArgument(source, 2) - if !ok || next >= len(source) || source[next] != ' ' { - return "", "", false - } - scriptPath, end, ok := readCodexPowerShellArgument(source, next+1) - return runtimePath, scriptPath, ok && source[end:] == "; exit $LASTEXITCODE" -} - -func windowsPathBase(value string) string { - index := strings.LastIndexAny(value, `\/`) - return value[index+1:] -} - -func isAbsoluteWindowsPath(value string) bool { - return len(value) >= 3 && ((value[0] >= 'A' && value[0] <= 'Z') || - (value[0] >= 'a' && value[0] <= 'z')) && value[1] == ':' && - (value[2] == '\\' || value[2] == '/') || strings.HasPrefix(value, `\\`) -} - -func decodeCodexPowerShell(encoded string) (string, bool) { - raw, err := base64.StdEncoding.DecodeString(encoded) - if err != nil || base64.StdEncoding.EncodeToString(raw) != encoded || len(raw)%2 != 0 { - return "", false - } - units := make([]uint16, len(raw)/2) - for index := range units { - units[index] = uint16(raw[index*2]) | uint16(raw[index*2+1])<<8 - } - return string(utf16.Decode(units)), true -} - -func parseCodexWindowsCommand(value any) (string, string, bool) { - command, ok := value.(string) - if !ok || !strings.HasPrefix(command, `"`) { - return "", "", false - } - closing := strings.IndexByte(command[1:], '"') - if closing < 0 { - return "", "", false - } - closing++ - powerShell := command[1:closing] - prefix := `" -NoLogo -NoProfile -NonInteractive -EncodedCommand ` - if !isAbsoluteWindowsPath(powerShell) || - !strings.EqualFold(windowsPathBase(powerShell), "powershell.exe") || - !strings.HasPrefix(command[closing:], prefix) { - return "", "", false - } - encoded := command[closing+len(prefix):] - source, ok := decodeCodexPowerShell(encoded) - if !ok { - return "", "", false - } - return parseCodexPowerShellSource(source) -} - -func isCodexNodeExecutable(path string) bool { - name := filepath.Base(path) - return name == "node" || strings.EqualFold(name, "node.exe") -} - func exactCodexHandlerKeys(handler map[string]any) bool { if len(handler) != 5 { return false @@ -192,20 +33,21 @@ func codexManagedScriptPath(handler map[string]any, status string) (string, bool handler["timeout"] != codexHookTimeout || handler["statusMessage"] != status { return "", false } - posixRuntime, posixScript, posixOK := parseCodexPOSIXCommand(handler["command"]) - windowsRuntime, windowsScript, windowsOK := parseCodexWindowsCommand(handler["commandWindows"]) + command, _ := handler["command"].(string) + commandWindows, _ := handler["commandWindows"].(string) + posixRuntime, posixScript, posixOK := parsePOSIXCommand(command) + windowsRuntime, windowsScript, windowsOK := parseEncodedWindowsCommand(commandWindows) if !windowsOK && posixOK { - legacy, ok := handler["commandWindows"].(string) expected := quoteWindowsArgument(posixRuntime) + " " + quoteWindowsArgument(posixScript) - if ok && legacy == expected { + if commandWindows == expected { windowsRuntime, windowsScript, windowsOK = posixRuntime, posixScript, true } } if !posixOK || !windowsOK || !filepath.IsAbs(posixRuntime) || - !filepath.IsAbs(posixScript) || !isCodexNodeExecutable(posixRuntime) || - !isCodexNodeExecutable(windowsRuntime) || - !sameCodexPath(posixRuntime, windowsRuntime) || - !sameCodexPath(posixScript, windowsScript) { + !filepath.IsAbs(posixScript) || !isNodeExecutable(posixRuntime) || + !isNodeExecutable(windowsRuntime) || + !sameManagedPath(posixRuntime, windowsRuntime) || + !sameManagedPath(posixScript, windowsScript) { return "", false } return posixScript, true @@ -217,14 +59,12 @@ func codexManagedAgentID( status string, ) (string, bool) { scriptPath, managed := codexManagedScriptPath(handler, status) - if !managed || filepath.Base(scriptPath) != scriptName { + if !managed { return "", false } - agentDirectory := filepath.Dir(scriptPath) - runtimeRoot, err := AgentRuntimeRoot() - if err != nil || !sameCodexPath(filepath.Dir(agentDirectory), runtimeRoot) { + reference, err := resolveManagedScript(scriptPath, scriptName) + if err != nil || reference == nil { return "", false } - agentID := filepath.Base(agentDirectory) - return agentID, agentID != "" && agentID != "." && agentID != ".." + return reference.agentID, true } diff --git a/sdks/go/cmd/elydora/plugins/codex_contract.go b/sdks/go/cmd/elydora/plugins/codex_contract.go index b8534bf..b285527 100644 --- a/sdks/go/cmd/elydora/plugins/codex_contract.go +++ b/sdks/go/cmd/elydora/plugins/codex_contract.go @@ -6,9 +6,7 @@ import ( "fmt" "path/filepath" "reflect" - "runtime" "sort" - "strings" ) const ( @@ -20,7 +18,6 @@ const ( codexOwnedDescription = "Elydora audit and freeze enforcement" codexGuardStatusMessage = "Checking Elydora agent state" codexAuditStatusMessage = "Recording Elydora tool use" - codexPOSIXApostrophe = `'"'"'` ) type codexHooks map[string][]map[string]any @@ -40,19 +37,7 @@ type codexRenderedDocument struct { remove bool } -type codexRuntimeContract struct { - agentID string - guardPath string - auditPath string -} - -func cloneCodexObject(value map[string]any) map[string]any { - clone := make(map[string]any, len(value)) - for key, item := range value { - clone[key] = item - } - return clone -} +type codexRuntimeContract = managedRuntimeContract func cloneCodexHooks(source codexHooks) codexHooks { clone := make(codexHooks, len(source)) @@ -62,28 +47,6 @@ func cloneCodexHooks(source codexHooks) codexHooks { return clone } -func sameCodexPath(left, right string) bool { - left = filepath.Clean(left) - right = filepath.Clean(right) - if absolute, err := filepath.Abs(left); err == nil { - left = absolute - } - if absolute, err := filepath.Abs(right); err == nil { - right = absolute - } - if runtime.GOOS == "windows" { - return strings.EqualFold(left, right) - } - return left == right -} - -func sameCodexAgentID(left, right string) bool { - if runtime.GOOS == "windows" { - return strings.EqualFold(left, right) - } - return left == right -} - func readCodexHooks(value any, label string) (codexHooks, error) { object, ok := value.(map[string]any) if !ok { @@ -126,16 +89,6 @@ func readCodexHooks(value any, label string) (codexHooks, error) { return hooks, nil } -func decodeStrictJSONObject(source []byte, label string) (map[string]any, error) { - if !json.Valid(source) { - var value any - if err := json.Unmarshal(source, &value); err != nil { - return nil, fmt.Errorf("parse %s: %w", label, err) - } - } - return decodeJSONCObject(source, label, false) -} - func parseCodexDocument(filePath string, raw []byte) (*codexDocument, error) { label := fmt.Sprintf("Codex user hooks at %s", filePath) root, err := decodeStrictJSONObject(raw, label) @@ -180,13 +133,13 @@ func removeCodexGroups( for _, value := range handlers { handler := value.(map[string]any) managedID, managed := codexManagedAgentID(handler, scriptName, status) - if managed && (agentID == "" || sameCodexAgentID(managedID, agentID)) { + if managed && (agentID == "" || sameManagedAgentID(managedID, agentID)) { continue } kept = append(kept, handler) } if len(kept) > 0 || !exactCodexMatcherGroup(group) { - next := cloneCodexObject(group) + next := cloneJSONObject(group) next["hooks"] = kept result = append(result, next) } @@ -262,7 +215,7 @@ func renderCodexDocument( if len(hooks) == 0 && entirelyManagedCodexDocument(document) { return &codexRenderedDocument{document: document, changed: true, remove: true}, nil } - root := cloneCodexObject(document.root) + root := cloneJSONObject(document.root) if len(hooks) == 0 { delete(root, "hooks") } else { @@ -295,11 +248,7 @@ func managedCodexIDs( if !managed { continue } - key := agentID - if runtime.GOOS == "windows" { - key = strings.ToLower(agentID) - } - result[key] = agentID + result[managedReferenceKey(agentID)] = agentID } } return result diff --git a/sdks/go/cmd/elydora/plugins/codex_installation.go b/sdks/go/cmd/elydora/plugins/codex_installation.go index 31ea6f0..f74a5f9 100644 --- a/sdks/go/cmd/elydora/plugins/codex_installation.go +++ b/sdks/go/cmd/elydora/plugins/codex_installation.go @@ -1,94 +1,26 @@ package plugins -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" -) - -type codexRuntimePaths struct { - runtimeRoot string - agentDirectory string - configPath string - keyPath string - guardPath string - auditPath string -} - -func validateCodexInstallConfig(config InstallConfig) error { - for _, field := range []struct{ name, value string }{ - {"agent name", config.AgentName}, - {"organization ID", config.OrgID}, - {"agent ID", config.AgentID}, - {"key ID", config.KID}, - {"private key", config.PrivateKey}, - {"base URL", config.BaseURL}, - {"guard script path", config.GuardScriptPath}, - } { - if field.value == "" { - return fmt.Errorf("%s is required", field.name) - } - } - if config.AgentName != codexAgentKey { - return fmt.Errorf("codex installation requires agent name %s", codexAgentKey) - } - if err := validateManagedPrivateKey(config.PrivateKey); err != nil { - return err - } - return validateManagedBaseURL(config.BaseURL) -} - -func codexAgentPaths(config InstallConfig) (*codexRuntimePaths, error) { - if err := validateCodexInstallConfig(config); err != nil { - return nil, err - } - runtimeRoot, err := AgentRuntimeRoot() - if err != nil { - return nil, err - } - agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID) - if err != nil { - return nil, err - } - paths := &codexRuntimePaths{ - runtimeRoot: runtimeRoot, - agentDirectory: agentDirectory, - configPath: filepath.Join(agentDirectory, "config.json"), - keyPath: filepath.Join(agentDirectory, "private.key"), - guardPath: filepath.Join(agentDirectory, codexGuardScript), - auditPath: filepath.Join(agentDirectory, codexAuditScript), - } - if !sameCodexPath(config.GuardScriptPath, paths.guardPath) { - return nil, fmt.Errorf( - "guard runtime must use the managed Elydora agent directory: %s", - paths.guardPath, - ) - } - if config.HookScript != "" && !sameCodexPath(config.HookScript, paths.auditPath) { - return nil, fmt.Errorf( - "audit runtime must use the managed Elydora agent directory: %s", - paths.auditPath, - ) - } - return paths, nil -} +import "path/filepath" func preflightCodexInstallation( config InstallConfig, hooksPath string, -) (*codexRuntimePaths, string, error) { - paths, err := codexAgentPaths(config) +) (*managedRuntimePaths, string, error) { + if err := validateManagedInstallConfig(config, codexAgentKey, "Codex"); err != nil { + return nil, "", err + } + paths, err := resolveManagedRuntimePaths(config, codexGuardScript, codexAuditScript) if err != nil { return nil, "", err } if _, err := managedPhysicalDirectoryExists( - filepath.Dir(hooksPath), - "Codex hooks directory", + filepath.Dir(hooksPath), "Codex hooks directory", ); err != nil { return nil, "", err } - if err := validateCodexRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil { + if err := validateManagedRuntimeIdentity( + paths.agentDirectory, config.AgentID, codexAgentKey, "Codex", + ); err != nil { return nil, "", err } nodePath, err := resolveNodeRuntime() @@ -98,64 +30,21 @@ func preflightCodexInstallation( return paths, nodePath, nil } -func buildCodexRuntimeConfig(config InstallConfig) ([]byte, error) { - encoded, err := json.MarshalIndent(agentRuntimeConfig{ - OrgID: config.OrgID, AgentID: config.AgentID, KID: config.KID, - BaseURL: config.BaseURL, Token: config.Token, AgentName: codexAgentKey, - }, "", " ") - if err != nil { - return nil, fmt.Errorf("encode Elydora runtime config: %w", err) - } - encoded = append(encoded, '\n') - if len(encoded) > maxRuntimeConfigBytes { - return nil, fmt.Errorf( - "elydora runtime config exceeds %d bytes after JSON encoding", - maxRuntimeConfigBytes, - ) - } - return encoded, nil -} - func prepareCodexInstallationChanges( config InstallConfig, - paths *codexRuntimePaths, + paths *managedRuntimePaths, rendered *codexRenderedDocument, ) ([]*fileChange, error) { - runtimeConfig, err := buildCodexRuntimeConfig(config) + changes, err := managedRuntimeFileChanges( + config, + paths, + codexAgentKey, + generateGuardScript(codexAgentKey, config.AgentID, "", false, ""), + buildHookScriptWithOutput(codexAgentKey, config.AgentID, "", false, true), + ) if err != nil { return nil, err } - items := []struct { - path, label string - content []byte - mode os.FileMode - }{ - { - paths.guardPath, "Elydora guard runtime", - []byte(generateGuardScript(codexAgentKey, config.AgentID, "", false, "")), 0700, - }, - {paths.configPath, "Elydora runtime config", runtimeConfig, 0600}, - {paths.keyPath, "Elydora private key", []byte(config.PrivateKey), 0600}, - { - paths.auditPath, "Elydora audit runtime", - []byte(buildHookScriptWithOutput( - codexAgentKey, config.AgentID, "", false, true, - )), 0700, - }, - } - changes := make([]*fileChange, 0, len(items)+1) - for _, item := range items { - change, err := prepareFileChange( - item.path, - item.label, - item.content, - item.mode, - ) - if err != nil { - return nil, err - } - changes = append(changes, change) - } documentChange, err := prepareRenderedCodexChange(rendered) if err != nil { return nil, err diff --git a/sdks/go/cmd/elydora/plugins/codex_io.go b/sdks/go/cmd/elydora/plugins/codex_io.go index ff63921..f388aa6 100644 --- a/sdks/go/cmd/elydora/plugins/codex_io.go +++ b/sdks/go/cmd/elydora/plugins/codex_io.go @@ -82,152 +82,6 @@ func prepareRenderedCodexChange(rendered *codexRenderedDocument) (*fileChange, e ) } -func writeCodexChanges( - changes []*fileChange, - label string, - rename renameFunc, - runtimeRoot string, - agentDirectory string, - hooksDirectory string, -) error { - hasChanges := false - for _, change := range changes { - if change != nil { - hasChanges = true - break - } - } - if !hasChanges { - return nil - } - if agentDirectory != "" { - if err := EnsurePrivateDirectory(runtimeRoot); err != nil { - return err - } - if err := EnsurePrivateDirectory(agentDirectory); err != nil { - return err - } - } - if err := ensureManagedDirectory(hooksDirectory, "Codex hooks directory"); err != nil { - return err - } - return writeChanges(changes, label, rename) -} - -func readCodexRuntimeConfig(path string) (map[string]any, bool, error) { - snapshot, err := readManagedFile(path, "Elydora runtime config", maxRuntimeConfigBytes) - if err != nil || snapshot == nil { - return nil, snapshot != nil, err - } - label := fmt.Sprintf("Elydora runtime config at %s", path) - config, err := decodeStrictJSONObject(snapshot.contents, label) - return config, true, err -} - -func validateCodexRuntimeIdentity(agentDirectory, agentID string) error { - runtimeRoot := filepath.Dir(agentDirectory) - rootExists, err := managedPhysicalDirectoryExists( - runtimeRoot, - "Elydora runtime directory", - ) - if err != nil || !rootExists { - return err - } - directoryExists, err := managedPhysicalDirectoryExists( - agentDirectory, - "Elydora agent runtime directory", - ) - if err != nil || !directoryExists { - return err - } - configPath := filepath.Join(agentDirectory, "config.json") - config, configExists, err := readCodexRuntimeConfig(configPath) - if err != nil { - return err - } - artifactExists := false - for _, item := range []struct { - path string - label string - limit int64 - }{ - {filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes}, - {filepath.Join(agentDirectory, codexGuardScript), "Elydora guard runtime", maxManagedSourceBytes}, - {filepath.Join(agentDirectory, codexAuditScript), "Elydora audit runtime", maxManagedSourceBytes}, - {filepath.Join(agentDirectory, "chain-state.json"), "Elydora chain state", maxRuntimeConfigBytes}, - {filepath.Join(agentDirectory, "status-cache.json"), "Elydora status cache", maxRuntimeConfigBytes}, - {filepath.Join(agentDirectory, "error.log"), "Elydora error log", maxManagedSourceBytes}, - } { - exists, inspectErr := managedPhysicalFileExists(item.path, item.label, item.limit) - if inspectErr != nil { - return inspectErr - } - artifactExists = artifactExists || exists - } - if !configExists { - if artifactExists { - return fmt.Errorf( - "elydora runtime identity cannot be verified without config.json: %s", - agentDirectory, - ) - } - return nil - } - configuredID, ok := config["agent_id"].(string) - if !ok || config["agent_name"] != codexAgentKey || - !sameCodexAgentID(configuredID, agentID) { - return fmt.Errorf( - "elydora runtime config identity does not match Codex agent %s: %s", - agentID, - configPath, - ) - } - return nil -} - -func codexRuntimeFilesExist(contracts []codexRuntimeContract) (bool, error) { - for _, contract := range contracts { - agentDirectory := filepath.Dir(contract.guardPath) - directoryExists, err := managedPhysicalDirectoryExists( - agentDirectory, - "Elydora agent runtime directory", - ) - if err != nil { - return false, err - } - if !directoryExists { - continue - } - config, exists, err := readCodexRuntimeConfig( - filepath.Join(agentDirectory, "config.json"), - ) - if err != nil { - return false, err - } - configuredID, idOK := config["agent_id"].(string) - if !exists || !idOK || config["agent_name"] != codexAgentKey || - !sameCodexAgentID(configuredID, contract.agentID) { - continue - } - complete := true - for _, item := range []struct { - path string - label string - limit int64 - }{ - {contract.guardPath, "Elydora guard runtime", maxManagedSourceBytes}, - {contract.auditPath, "Elydora audit runtime", maxManagedSourceBytes}, - {filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes}, - } { - exists, inspectErr := managedPhysicalFileExists(item.path, item.label, item.limit) - if inspectErr != nil { - return false, inspectErr - } - complete = complete && exists - } - if complete { - return true, nil - } - } - return false, nil +func codexRuntimeFilesExist(contracts []managedRuntimeContract) (bool, error) { + return managedRuntimeFilesPresent(contracts, codexAgentKey) } diff --git a/sdks/go/cmd/elydora/plugins/codex_recovery_test.go b/sdks/go/cmd/elydora/plugins/codex_recovery_test.go index d7ea358..83794f0 100644 --- a/sdks/go/cmd/elydora/plugins/codex_recovery_test.go +++ b/sdks/go/cmd/elydora/plugins/codex_recovery_test.go @@ -249,7 +249,7 @@ func TestCodexInstallRollsBackAllFilesOnHooksCommitFailure(t *testing.T) { source := []byte(`{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"keep"}]}]}}`) fixture := prepareCodexFixture(t, string(source)) fixture.plugin.rename = func(source, destination string) error { - if sameCodexPath(destination, fixture.configPath) { + if sameManagedPath(destination, fixture.configPath) { return errors.New("injected Codex hooks commit failure") } return os.Rename(source, destination) diff --git a/sdks/go/cmd/elydora/plugins/codex_test.go b/sdks/go/cmd/elydora/plugins/codex_test.go index cb8381b..9fa3004 100644 --- a/sdks/go/cmd/elydora/plugins/codex_test.go +++ b/sdks/go/cmd/elydora/plugins/codex_test.go @@ -355,10 +355,10 @@ func TestCodexUninstallPreservesUnrelatedHooksAndExactAgent(t *testing.T) { if len(requireArray(t, hooks["PreToolUse"])) != 2 || len(requireArray(t, hooks["PostToolUse"])) != 1 { t.Fatalf("unexpected remaining hooks: %#v", hooks) } - _, remainingScript, parsed := parseCodexPOSIXCommand( - requireCodexHandler(t, remaining, "PostToolUse", codexAuditStatus)["command"], + _, remainingScript, parsed := parsePOSIXCommand( + requireCodexHandler(t, remaining, "PostToolUse", codexAuditStatus)["command"].(string), ) - if !parsed || !sameCodexPath(remainingScript, filepath.Join(otherDir, codexAuditScript)) { + if !parsed || !sameManagedPath(remainingScript, filepath.Join(otherDir, codexAuditScript)) { t.Fatalf("other agent handler was removed: %#v", hooks) } } diff --git a/sdks/go/cmd/elydora/plugins/copilot.go b/sdks/go/cmd/elydora/plugins/copilot.go index 44a3bb0..ef90ccb 100644 --- a/sdks/go/cmd/elydora/plugins/copilot.go +++ b/sdks/go/cmd/elydora/plugins/copilot.go @@ -10,14 +10,12 @@ type CopilotPlugin struct { rename renameFunc } -// ManagesGuardRuntime reports that Copilot commits its provider guard with the -// audit runtime and hook documents. +// ManagesGuardRuntime reports that the guard is committed with the audit runtime. func (p *CopilotPlugin) ManagesGuardRuntime() bool { return true } -// PreflightInstall validates hook sources, settings, matchers, credentials, -// and runtime identity before any write. +// PreflightInstall validates every source before the CLI creates runtime state. func (p *CopilotPlugin) PreflightInstall(config InstallConfig) error { sources, _, err := readCopilotSources() if err != nil { @@ -165,7 +163,7 @@ func mergedCopilotContracts( } unique := map[string]copilotRuntimeContract{} for _, contract := range contracts { - unique[copilotEntryKey(contract.agentID)] = contract + unique[managedReferenceKey(contract.agentID)] = contract } result := make([]copilotRuntimeContract, 0, len(unique)) for _, contract := range unique { diff --git a/sdks/go/cmd/elydora/plugins/copilot_contract.go b/sdks/go/cmd/elydora/plugins/copilot_contract.go index fa6a74a..d1e842e 100644 --- a/sdks/go/cmd/elydora/plugins/copilot_contract.go +++ b/sdks/go/cmd/elydora/plugins/copilot_contract.go @@ -5,19 +5,17 @@ import ( "encoding/json" "fmt" "path/filepath" - "runtime" "sort" "strings" ) const ( - copilotAgentKey = "copilot" - copilotGuardScript = "guard.js" - copilotAuditScript = "hook.js" - copilotConfigFile = "elydora-audit.json" - copilotHookTimeout = float64(10) - copilotLegacyTimeout = float64(5) - copilotPOSIXApostrophe = `'"'"'` + copilotAgentKey = "copilot" + copilotGuardScript = "guard.js" + copilotAuditScript = "hook.js" + copilotConfigFile = "elydora-audit.json" + copilotHookTimeout = float64(10) + copilotLegacyTimeout = float64(5) ) var copilotManagedEvents = []struct { @@ -61,23 +59,19 @@ type copilotRenderedDocument struct { remove bool } -type copilotRuntimeContract struct { - agentID string - guardPath string - auditPath string -} +type copilotRuntimeContract = managedRuntimeContract -type copilotManagedEntry struct { - agentID string - scriptPath string -} +type copilotManagedEntry = managedScriptReference -func cloneCopilotObject(value map[string]any) map[string]any { - clone := make(map[string]any, len(value)) - for key, item := range value { - clone[key] = item +func copilotManagedEntryForHandler( + handler map[string]any, + scriptName string, +) (*copilotManagedEntry, error) { + scriptPath, managed := copilotManagedScriptPath(handler) + if !managed { + return nil, nil } - return clone + return resolveManagedScript(scriptPath, scriptName) } func cloneCopilotHooks(source copilotHooks) copilotHooks { @@ -90,109 +84,15 @@ func cloneCopilotHooks(source copilotHooks) copilotHooks { return clone } -func sameCopilotPath(left, right string) bool { - left = filepath.Clean(left) - right = filepath.Clean(right) - if absolute, err := filepath.Abs(left); err == nil { - left = absolute - } - if absolute, err := filepath.Abs(right); err == nil { - right = absolute - } - if runtime.GOOS == "windows" { - return strings.EqualFold(left, right) - } - return left == right -} - -func sameCopilotAgentID(left, right string) bool { - if runtime.GOOS == "windows" { - return strings.EqualFold(left, right) - } - return left == right -} - -func sameCopilotFileName(left, right string) bool { - if runtime.GOOS == "windows" { - return strings.EqualFold(left, right) - } - return left == right -} - -func quoteCopilotPowerShell(value string) string { - return "'" + strings.ReplaceAll(value, "'", "''") + "'" -} - func buildCopilotHandler(nodePath, scriptPath string) map[string]any { return map[string]any{ "type": "command", - "bash": quotePOSIXArgument(nodePath) + " " + quotePOSIXArgument(scriptPath), - "powershell": "& " + quoteCopilotPowerShell(nodePath) + " " + quoteCopilotPowerShell(scriptPath) + "; exit $LASTEXITCODE", + "bash": posixSource(nodePath, scriptPath), + "powershell": powerShellSource(nodePath, scriptPath), "timeoutSec": copilotHookTimeout, } } -func readCopilotPOSIXArgument(command string, start int) (string, int, bool) { - if start >= len(command) || command[start] != '\'' { - return "", start, false - } - var value strings.Builder - for index := start + 1; index < len(command); { - if strings.HasPrefix(command[index:], copilotPOSIXApostrophe) { - value.WriteByte('\'') - index += len(copilotPOSIXApostrophe) - continue - } - if command[index] == '\'' { - return value.String(), index + 1, true - } - value.WriteByte(command[index]) - index++ - } - return "", start, false -} - -func readCopilotPowerShellArgument(command string, start int) (string, int, bool) { - if start >= len(command) || command[start] != '\'' { - return "", start, false - } - var value strings.Builder - for index := start + 1; index < len(command); index++ { - if command[index] != '\'' { - value.WriteByte(command[index]) - continue - } - if index+1 < len(command) && command[index+1] == '\'' { - value.WriteByte('\'') - index++ - continue - } - return value.String(), index + 1, true - } - return "", start, false -} - -func parseCopilotBash(command string) (string, string, bool) { - executable, next, ok := readCopilotPOSIXArgument(command, 0) - if !ok || next >= len(command) || command[next] != ' ' { - return "", "", false - } - script, end, ok := readCopilotPOSIXArgument(command, next+1) - return executable, script, ok && end == len(command) -} - -func parseCopilotPowerShell(command string) (string, string, bool) { - if !strings.HasPrefix(command, "& ") { - return "", "", false - } - executable, next, ok := readCopilotPowerShellArgument(command, 2) - if !ok || next >= len(command) || command[next] != ' ' { - return "", "", false - } - script, end, ok := readCopilotPowerShellArgument(command, next+1) - return executable, script, ok && command[end:] == "; exit $LASTEXITCODE" -} - func parseCopilotLegacyCommand(command string) (string, bool) { command = strings.TrimSpace(command) separator := strings.IndexAny(command, " \t") @@ -211,11 +111,6 @@ func parseCopilotLegacyCommand(command string) (string, bool) { return script, script != "" } -func isCopilotNodeExecutable(path string) bool { - name := filepath.Base(path) - return name == "node" || strings.EqualFold(name, "node.exe") -} - func copilotManagedScriptPath(handler map[string]any) (string, bool) { if len(handler) != 4 || handler["type"] != "command" { return "", false @@ -226,11 +121,11 @@ func copilotManagedScriptPath(handler map[string]any) (string, bool) { return "", false } if handler["timeoutSec"] == copilotHookTimeout { - bashNode, bashScript, bashParsed := parseCopilotBash(bash) - psNode, psScript, psParsed := parseCopilotPowerShell(powershell) + bashNode, bashScript, bashParsed := parsePOSIXCommand(bash) + psNode, psScript, psParsed := parsePowerShellSource(powershell) if bashParsed && psParsed && filepath.IsAbs(bashNode) && filepath.IsAbs(bashScript) && - isCopilotNodeExecutable(bashNode) && sameCopilotPath(bashNode, psNode) && - sameCopilotPath(bashScript, psScript) { + isNodeExecutable(bashNode) && sameManagedPath(bashNode, psNode) && + sameManagedPath(bashScript, psScript) { return bashScript, true } } @@ -240,30 +135,6 @@ func copilotManagedScriptPath(handler map[string]any) (string, bool) { return parseCopilotLegacyCommand(bash) } -func copilotManagedEntryForHandler( - handler map[string]any, - scriptName string, -) (*copilotManagedEntry, error) { - scriptPath, managed := copilotManagedScriptPath(handler) - if !managed || !filepath.IsAbs(scriptPath) || - !sameCopilotFileName(filepath.Base(scriptPath), scriptName) { - return nil, nil - } - runtimeRoot, err := AgentRuntimeRoot() - if err != nil { - return nil, err - } - agentDirectory := filepath.Dir(scriptPath) - if !sameCopilotPath(filepath.Dir(agentDirectory), runtimeRoot) { - return nil, nil - } - agentID := filepath.Base(agentDirectory) - if agentID == "" || agentID == "." || agentID == ".." { - return nil, nil - } - return &copilotManagedEntry{agentID: agentID, scriptPath: scriptPath}, nil -} - func removeManagedCopilotHooks( hooks copilotHooks, agentID string, @@ -277,7 +148,7 @@ func removeManagedCopilotHooks( return nil, err } remove := entry != nil && - (agentID == "" || sameCopilotAgentID(entry.agentID, agentID)) + (agentID == "" || sameManagedAgentID(entry.agentID, agentID)) if !remove { kept = append(kept, handler) } @@ -361,7 +232,7 @@ func renderCopilotDocument( document: document, changed: true, remove: true, }, nil } - root := cloneCopilotObject(document.root) + root := cloneJSONObject(document.root) root["version"] = float64(1) if len(hooks) == 0 { delete(root, "hooks") @@ -387,13 +258,6 @@ func renderCopilotDocument( }, nil } -func copilotEntryKey(agentID string) string { - if runtime.GOOS == "windows" { - return strings.ToLower(agentID) - } - return agentID -} - func copilotManagedEntries( handlers []map[string]any, scriptName string, @@ -405,7 +269,7 @@ func copilotManagedEntries( return nil, err } if entry != nil { - key := copilotEntryKey(entry.agentID) + key := managedReferenceKey(entry.agentID) result[key] = append(result[key], *entry) } } @@ -438,7 +302,7 @@ func copilotRuntimeContracts(hooks copilotHooks) ([]copilotRuntimeContract, erro success := successes[key] failure := failures[key] if len(guard) != 1 || len(success) != 1 || len(failure) != 1 || - !sameCopilotPath(success[0].scriptPath, failure[0].scriptPath) { + !sameManagedPath(success[0].scriptPath, failure[0].scriptPath) { continue } contracts = append(contracts, copilotRuntimeContract{ diff --git a/sdks/go/cmd/elydora/plugins/copilot_installation.go b/sdks/go/cmd/elydora/plugins/copilot_installation.go index 34f6b41..632d4a1 100644 --- a/sdks/go/cmd/elydora/plugins/copilot_installation.go +++ b/sdks/go/cmd/elydora/plugins/copilot_installation.go @@ -1,103 +1,19 @@ package plugins import ( - "encoding/json" "fmt" - "os" - "path/filepath" - "strings" ) -type copilotRuntimePaths struct { - runtimeRoot string - agentDirectory string - configPath string - keyPath string - guardPath string - auditPath string -} - type preparedCopilotInstallation struct { changes []*fileChange preconditions []filePrecondition - paths *copilotRuntimePaths -} - -func validateCopilotInstallConfig(config InstallConfig) error { - for _, field := range []struct{ name, value string }{ - {"agent name", config.AgentName}, - {"organization ID", config.OrgID}, - {"agent ID", config.AgentID}, - {"key ID", config.KID}, - {"private key", config.PrivateKey}, - {"base URL", config.BaseURL}, - {"guard script path", config.GuardScriptPath}, - } { - if field.value == "" { - return fmt.Errorf("%s is required", field.name) - } - } - if config.AgentName != copilotAgentKey { - return fmt.Errorf( - "GitHub Copilot CLI installation requires agent name %s", - copilotAgentKey, - ) - } - if strings.TrimSpace(config.OrgID) == "" { - return fmt.Errorf("organization ID is required") - } - if strings.TrimSpace(config.KID) == "" { - return fmt.Errorf("key ID is required") - } - if config.Token != "" && strings.TrimSpace(config.Token) == "" { - return fmt.Errorf("token must contain a non-whitespace value when provided") - } - if err := validateManagedPrivateKey(config.PrivateKey); err != nil { - return err - } - return validateManagedBaseURL(config.BaseURL) -} - -func copilotAgentPaths(config InstallConfig) (*copilotRuntimePaths, error) { - if err := validateCopilotInstallConfig(config); err != nil { - return nil, err - } - runtimeRoot, err := AgentRuntimeRoot() - if err != nil { - return nil, err - } - agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID) - if err != nil { - return nil, err - } - paths := &copilotRuntimePaths{ - runtimeRoot: runtimeRoot, agentDirectory: agentDirectory, - configPath: filepath.Join(agentDirectory, "config.json"), - keyPath: filepath.Join(agentDirectory, "private.key"), - guardPath: filepath.Join(agentDirectory, copilotGuardScript), - auditPath: filepath.Join(agentDirectory, copilotAuditScript), - } - if !filepath.IsAbs(config.GuardScriptPath) || - !sameCopilotPath(config.GuardScriptPath, paths.guardPath) { - return nil, fmt.Errorf( - "Elydora guard runtime must use the managed agent directory: %s", - paths.guardPath, - ) - } - if config.HookScript != "" && (!filepath.IsAbs(config.HookScript) || - !sameCopilotPath(config.HookScript, paths.auditPath)) { - return nil, fmt.Errorf( - "Elydora audit runtime must use the managed agent directory: %s", - paths.auditPath, - ) - } - return paths, nil + paths *managedRuntimePaths } func preflightCopilotInstallation( config InstallConfig, sources *copilotSources, -) (*copilotRuntimePaths, string, error) { +) (*managedRuntimePaths, string, error) { if sources == nil || sources.user == nil || sources.user.filePath == "" { return nil, "", fmt.Errorf( "GitHub Copilot CLI installation requires a user hook path", @@ -106,22 +22,22 @@ func preflightCopilotInstallation( if err := requireCopilotHooksEnabled(sources); err != nil { return nil, "", err } - paths, err := copilotAgentPaths(config) + if err := validateManagedInstallConfig(config, copilotAgentKey, "GitHub Copilot CLI"); err != nil { + return nil, "", err + } + paths, err := resolveManagedRuntimePaths(config, copilotGuardScript, copilotAuditScript) if err != nil { return nil, "", err } - if err := validateCopilotRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil { + if err := validateManagedRuntimeIdentity( + paths.agentDirectory, config.AgentID, copilotAgentKey, "Copilot", + ); err != nil { return nil, "", err } - nodePath, err := resolveNodeRuntime() + nodePath, err := resolveAbsoluteNodeRuntime("GitHub Copilot CLI") if err != nil { return nil, "", err } - if !filepath.IsAbs(nodePath) || !isCopilotNodeExecutable(nodePath) { - return nil, "", fmt.Errorf( - "GitHub Copilot CLI hooks require an absolute Node.js executable path", - ) - } hookSources := []copilotHooks{sources.user.hooks} if sources.legacy != nil { hookSources = append(hookSources, sources.legacy.hooks) @@ -132,28 +48,6 @@ func preflightCopilotInstallation( return paths, nodePath, nil } -func buildCopilotRuntimeConfig(config InstallConfig) ([]byte, error) { - value := map[string]any{ - "org_id": config.OrgID, "agent_id": config.AgentID, "kid": config.KID, - "base_url": config.BaseURL, "agent_name": copilotAgentKey, - } - if config.Token != "" { - value["token"] = config.Token - } - encoded, err := json.MarshalIndent(value, "", " ") - if err != nil { - return nil, fmt.Errorf("encode Elydora runtime config: %w", err) - } - encoded = append(encoded, '\n') - if len(encoded) > maxRuntimeConfigBytes { - return nil, fmt.Errorf( - "Elydora runtime config exceeds %d bytes after JSON encoding", - maxRuntimeConfigBytes, - ) - } - return encoded, nil -} - func validateCopilotRenderedSet( sources *copilotSources, rendered []*copilotRenderedDocument, @@ -169,7 +63,7 @@ func validateCopilotRenderedSet( matches := 0 for _, item := range rendered { if item != nil && item.document != nil && - sameCopilotPath(item.document.filePath, document.filePath) { + sameManagedPath(item.document.filePath, document.filePath) { matches++ } } @@ -218,34 +112,13 @@ func prepareCopilotInstallation( if err := validateCopilotRenderedSet(sources, rendered); err != nil { return nil, err } - runtimeConfig, err := buildCopilotRuntimeConfig(config) + guardScript, auditScript := copilotExpectedScripts(config.AgentID, nil) + changes, err := managedRuntimeFileChanges( + config, paths, copilotAgentKey, string(guardScript), string(auditScript), + ) if err != nil { return nil, err } - items := []struct { - path, label string - content []byte - mode os.FileMode - }{ - { - paths.guardPath, "Elydora guard runtime", - []byte(generateGuardScript(copilotAgentKey, config.AgentID, "", false, "")), 0700, - }, - {paths.configPath, "Elydora runtime config", runtimeConfig, 0600}, - {paths.keyPath, "Elydora private key", []byte(config.PrivateKey), 0600}, - { - paths.auditPath, "Elydora audit runtime", - []byte(buildHookScriptWithOutput(copilotAgentKey, config.AgentID, "", false, true)), 0700, - }, - } - changes := make([]*fileChange, 0, len(items)+len(rendered)) - for _, item := range items { - change, changeErr := prepareFileChange(item.path, item.label, item.content, item.mode) - if changeErr != nil { - return nil, changeErr - } - changes = append(changes, change) - } for _, document := range rendered { change, changeErr := prepareRenderedCopilotChange(document) if changeErr != nil { @@ -275,3 +148,8 @@ func commitCopilotInstallation( prepared.preconditions, ) } + +func copilotExpectedScripts(agentID string, _ map[string]any) ([]byte, []byte) { + return []byte(generateGuardScript(copilotAgentKey, agentID, "", false, "")), + []byte(buildHookScriptWithOutput(copilotAgentKey, agentID, "", false, true)) +} diff --git a/sdks/go/cmd/elydora/plugins/copilot_io.go b/sdks/go/cmd/elydora/plugins/copilot_io.go index fd6f8a7..1327fa2 100644 --- a/sdks/go/cmd/elydora/plugins/copilot_io.go +++ b/sdks/go/cmd/elydora/plugins/copilot_io.go @@ -240,14 +240,7 @@ func writeCopilotChanges( agentDirectory string, preconditions []filePrecondition, ) error { - hasChanges := false - for _, change := range changes { - if change != nil { - hasChanges = true - break - } - } - if !hasChanges { + if !hasFileChanges(changes) { return writeChanges(changes, label, rename, preconditions...) } if agentDirectory != "" { diff --git a/sdks/go/cmd/elydora/plugins/copilot_schema.go b/sdks/go/cmd/elydora/plugins/copilot_schema.go index 944d6fe..122db5b 100644 --- a/sdks/go/cmd/elydora/plugins/copilot_schema.go +++ b/sdks/go/cmd/elydora/plugins/copilot_schema.go @@ -274,7 +274,7 @@ func validateCopilotHooks(value any, label string) (copilotHooks, error) { if err := validateCopilotHandler(handler, event, itemLabel); err != nil { return nil, err } - handlers = append(handlers, cloneCopilotObject(handler)) + handlers = append(handlers, cloneJSONObject(handler)) } hooks[event] = handlers } diff --git a/sdks/go/cmd/elydora/plugins/copilot_status.go b/sdks/go/cmd/elydora/plugins/copilot_status.go index 734173c..a423e37 100644 --- a/sdks/go/cmd/elydora/plugins/copilot_status.go +++ b/sdks/go/cmd/elydora/plugins/copilot_status.go @@ -1,261 +1,5 @@ package plugins -import ( - "bytes" - "fmt" - "path/filepath" - "sort" - "strings" -) - -func readCopilotRuntimeConfig(path string) (map[string]any, bool, error) { - snapshot, err := readManagedFile(path, "Elydora runtime config", maxRuntimeConfigBytes) - if err != nil || snapshot == nil { - return nil, snapshot != nil, err - } - label := fmt.Sprintf("Elydora runtime config at %s", path) - config, err := decodeStrictJSONObject(snapshot.contents, label) - return config, true, err -} - -func requireCopilotRuntimeString( - config map[string]any, - field string, - configPath string, -) (string, error) { - value, ok := config[field].(string) - if !ok || strings.TrimSpace(value) == "" { - return "", fmt.Errorf( - "Elydora runtime config %s is invalid: %s", - field, - configPath, - ) - } - return value, nil -} - -func validateCopilotRuntimeConfig( - config map[string]any, - expectedAgentID string, - configPath string, -) error { - supported := stringSet( - "org_id", "agent_id", "kid", "base_url", "token", "agent_name", - ) - fields := make([]string, 0, len(config)) - for field := range config { - fields = append(fields, field) - } - sort.Strings(fields) - for _, field := range fields { - if _, ok := supported[field]; !ok { - return fmt.Errorf( - `Elydora runtime config has unsupported field %q: %s`, - field, - configPath, - ) - } - } - if _, err := requireCopilotRuntimeString(config, "org_id", configPath); err != nil { - return err - } - if _, err := requireCopilotRuntimeString(config, "kid", configPath); err != nil { - return err - } - agentID, err := requireCopilotRuntimeString(config, "agent_id", configPath) - if err != nil { - return err - } - if !sameCopilotAgentID(agentID, expectedAgentID) || - config["agent_name"] != copilotAgentKey { - return fmt.Errorf( - "Elydora runtime identity does not match Copilot hooks: %s", - configPath, - ) - } - if _, exists := config["token"]; exists { - if _, err := requireCopilotRuntimeString(config, "token", configPath); err != nil { - return err - } - } - baseURL, err := requireCopilotRuntimeString(config, "base_url", configPath) - if err != nil { - return err - } - if err := validateManagedBaseURL(baseURL); err != nil { - return fmt.Errorf( - "Elydora runtime config base URL is invalid at %s: %w", - configPath, - err, - ) - } - return nil -} - -func validateCopilotRuntimeIdentity(agentDirectory, agentID string) error { - runtimeRoot := filepath.Dir(agentDirectory) - rootExists, err := managedPhysicalDirectoryExists( - runtimeRoot, - "Elydora runtime directory", - ) - if err != nil || !rootExists { - return err - } - directoryExists, err := managedPhysicalDirectoryExists( - agentDirectory, - "Elydora agent runtime directory", - ) - if err != nil || !directoryExists { - return err - } - configPath := filepath.Join(agentDirectory, "config.json") - config, configExists, err := readCopilotRuntimeConfig(configPath) - if err != nil { - return err - } - artifactExists := false - for _, item := range []struct { - path string - label string - limit int64 - }{ - {filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes}, - {filepath.Join(agentDirectory, copilotGuardScript), "Elydora guard runtime", maxManagedSourceBytes}, - {filepath.Join(agentDirectory, copilotAuditScript), "Elydora audit runtime", maxManagedSourceBytes}, - {filepath.Join(agentDirectory, "chain-state.json"), "Elydora chain state", maxRuntimeConfigBytes}, - {filepath.Join(agentDirectory, "status-cache.json"), "Elydora status cache", maxRuntimeConfigBytes}, - {filepath.Join(agentDirectory, "error.log"), "Elydora error log", maxManagedSourceBytes}, - } { - exists, inspectErr := managedPhysicalFileExists(item.path, item.label, item.limit) - if inspectErr != nil { - return inspectErr - } - artifactExists = artifactExists || exists - } - if !configExists { - if artifactExists { - return fmt.Errorf( - "Elydora runtime identity cannot be verified without config.json: %s", - agentDirectory, - ) - } - return nil - } - configuredID, ok := config["agent_id"].(string) - if !ok || config["agent_name"] != copilotAgentKey || - !sameCopilotAgentID(configuredID, agentID) { - return fmt.Errorf( - "Elydora runtime config identity does not match Copilot agent %s: %s", - agentID, - configPath, - ) - } - return nil -} - -func validCopilotContractPaths(contract copilotRuntimeContract) (bool, error) { - runtimeRoot, err := AgentRuntimeRoot() - if err != nil { - return false, err - } - agentDirectory := filepath.Dir(contract.guardPath) - return sameCopilotPath(filepath.Dir(agentDirectory), runtimeRoot) && - sameCopilotPath( - contract.guardPath, - filepath.Join(agentDirectory, copilotGuardScript), - ) && - sameCopilotPath( - contract.auditPath, - filepath.Join(agentDirectory, copilotAuditScript), - ), nil -} - -func copilotRuntimeContractExists(contract copilotRuntimeContract) (bool, error) { - validPaths, err := validCopilotContractPaths(contract) - if err != nil || !validPaths { - return false, err - } - runtimeRoot, err := AgentRuntimeRoot() - if err != nil { - return false, err - } - agentDirectory := filepath.Dir(contract.guardPath) - rootExists, err := managedPhysicalDirectoryExists( - runtimeRoot, - "Elydora runtime directory", - ) - if err != nil || !rootExists { - return false, err - } - directoryExists, err := managedPhysicalDirectoryExists( - agentDirectory, - "Elydora agent runtime directory", - ) - if err != nil || !directoryExists { - return false, err - } - configPath := filepath.Join(agentDirectory, "config.json") - keyPath := filepath.Join(agentDirectory, "private.key") - config, configExists, err := readCopilotRuntimeConfig(configPath) - if err != nil { - return false, err - } - key, err := readManagedFile(keyPath, "Elydora private key", maxProtectedSecretBytes) - if err != nil { - return false, err - } - guard, err := readManagedFile( - contract.guardPath, - "Elydora guard runtime", - maxManagedSourceBytes, - ) - if err != nil { - return false, err - } - audit, err := readManagedFile( - contract.auditPath, - "Elydora audit runtime", - maxManagedSourceBytes, - ) - if err != nil { - return false, err - } - if !configExists || key == nil || guard == nil || audit == nil { - return false, nil - } - if err := validateCopilotRuntimeConfig(config, contract.agentID, configPath); err != nil { - return false, err - } - if err := validateManagedPrivateKey(string(key.contents)); err != nil { - return false, fmt.Errorf("Elydora private key at %s: %w", keyPath, err) - } - expectedGuard := []byte(generateGuardScript( - copilotAgentKey, - contract.agentID, - "", - false, - "", - )) - expectedAudit := []byte(buildHookScriptWithOutput( - copilotAgentKey, - contract.agentID, - "", - false, - true, - )) - return bytes.Equal(guard.contents, expectedGuard) && - bytes.Equal(audit.contents, expectedAudit), nil -} - -func copilotRuntimeFilesExist(contracts []copilotRuntimeContract) (bool, error) { - for _, contract := range contracts { - exists, err := copilotRuntimeContractExists(contract) - if err != nil { - return false, err - } - if exists { - return true, nil - } - } - return false, nil +func copilotRuntimeFilesExist(contracts []managedRuntimeContract) (bool, error) { + return managedRuntimeFilesExist(contracts, copilotAgentKey, "Copilot", copilotExpectedScripts) } diff --git a/sdks/go/cmd/elydora/plugins/cursor.go b/sdks/go/cmd/elydora/plugins/cursor.go index f709ea8..bb403e3 100644 --- a/sdks/go/cmd/elydora/plugins/cursor.go +++ b/sdks/go/cmd/elydora/plugins/cursor.go @@ -10,55 +10,27 @@ type CursorPlugin struct { rename renameFunc } -// ManagesGuardRuntime reports that Cursor commits its provider-specific guard -// in the same transaction as the rest of the installation. +// ManagesGuardRuntime reports that the guard is committed with the audit runtime. func (p *CursorPlugin) ManagesGuardRuntime() bool { return true } -func cursorAgentPaths(config InstallConfig) ( - runtimeRoot, agentDirectory, guardPath, auditPath string, - err error, -) { - if config.AgentID == "" { - return "", "", "", "", fmt.Errorf("agent ID is required") - } - runtimeRoot, err = AgentRuntimeRoot() - if err != nil { - return "", "", "", "", err - } - agentDirectory, err = ResolveAgentRuntimeDirectory(config.AgentID) - if err != nil { - return "", "", "", "", err - } - guardPath = filepath.Join(agentDirectory, cursorGuardScript) - if !sameCursorPath(config.GuardScriptPath, guardPath) { - return "", "", "", "", fmt.Errorf( - "Elydora guard runtime must use the managed agent directory: %s", - guardPath, - ) - } - auditPath = filepath.Join(agentDirectory, cursorAuditScript) - if config.HookScript != "" && !sameCursorPath(config.HookScript, auditPath) { - return "", "", "", "", fmt.Errorf( - "Elydora audit runtime must use the managed agent directory: %s", - auditPath, - ) - } - return runtimeRoot, agentDirectory, guardPath, auditPath, nil +func cursorAgentPaths(config InstallConfig) (*managedRuntimePaths, error) { + return resolveManagedRuntimePaths(config, cursorGuardScript, cursorAuditScript) } -// PreflightInstall validates every existing source before the CLI creates a -// runtime directory or guard file. +// PreflightInstall validates every source before the CLI creates runtime state. func (p *CursorPlugin) PreflightInstall(config InstallConfig) error { if _, err := readCursorDocument(); err != nil { return err } - _, agentDirectory, _, _, err := cursorAgentPaths(config) + paths, err := cursorAgentPaths(config) if err != nil { return err } - if err := preflightCursorRuntime(agentDirectory, config.AgentID); err != nil { + if err := validateManagedRuntimeIdentity( + paths.agentDirectory, config.AgentID, cursorAgentKey, "Cursor", + ); err != nil { return err } _, err = resolveNodeRuntime() @@ -70,44 +42,42 @@ func (p *CursorPlugin) Install(config InstallConfig) error { if err != nil { return err } - runtimeRoot, agentDirectory, guardPath, auditPath, err := cursorAgentPaths(config) + paths, err := cursorAgentPaths(config) if err != nil { return err } - if err := preflightCursorRuntime(agentDirectory, config.AgentID); err != nil { + if err := validateManagedRuntimeIdentity( + paths.agentDirectory, config.AgentID, cursorAgentKey, "Cursor", + ); err != nil { return err } nodePath, err := resolveNodeRuntime() if err != nil { return err } - hooks := removeManagedCursorHooks(document.hooks, runtimeRoot, "") - hooks["preToolUse"] = append( - hooks["preToolUse"], - buildCursorHandler(nodePath, guardPath), - ) - hooks["postToolUse"] = append( - hooks["postToolUse"], - buildCursorHandler(nodePath, auditPath), - ) + hooks := removeManagedCursorHooks(document.hooks, paths.runtimeRoot, "") + hooks["preToolUse"] = append(hooks["preToolUse"], buildCursorHandler(nodePath, paths.guardPath)) + hooks["postToolUse"] = append(hooks["postToolUse"], buildCursorHandler(nodePath, paths.auditPath)) hooks["postToolUseFailure"] = append( hooks["postToolUseFailure"], - buildCursorHandler(nodePath, auditPath), + buildCursorHandler(nodePath, paths.auditPath), ) - rendered, err := renderCursorDocument(document, hooks, runtimeRoot) + rendered, err := renderCursorDocument(document, hooks, paths.runtimeRoot) if err != nil { return fmt.Errorf("render Cursor user hooks: %w", err) } - changes, err := prepareCursorInstallationChanges(config, agentDirectory, rendered) + changes, err := prepareCursorInstallationChanges(config, paths, rendered) if err != nil { return err } - if err := writeCursorChanges( + if err := writeManagedChanges( changes, "Install Cursor hooks", p.rename, - runtimeRoot, - agentDirectory, + paths.runtimeRoot, + paths.agentDirectory, + filepath.Dir(document.filePath), + "Cursor hooks directory", ); err != nil { return err } @@ -133,12 +103,14 @@ func (p *CursorPlugin) Uninstall(agentID string) error { if err != nil { return err } - return writeCursorChanges( + return writeManagedChanges( []*fileChange{change}, "Uninstall Cursor hooks", p.rename, "", "", + filepath.Dir(document.filePath), + "Cursor hooks directory", ) } diff --git a/sdks/go/cmd/elydora/plugins/cursor_contract.go b/sdks/go/cmd/elydora/plugins/cursor_contract.go index 6b02783..c9ade4a 100644 --- a/sdks/go/cmd/elydora/plugins/cursor_contract.go +++ b/sdks/go/cmd/elydora/plugins/cursor_contract.go @@ -11,12 +11,11 @@ import ( ) const ( - cursorAgentKey = "cursor" - cursorConfigFile = "hooks.json" - cursorGuardScript = "guard.js" - cursorAuditScript = "hook.js" - cursorHookTimeout = float64(10) - cursorPOSIXApostrophe = `'"'"'` + cursorAgentKey = "cursor" + cursorConfigFile = "hooks.json" + cursorGuardScript = "guard.js" + cursorAuditScript = "hook.js" + cursorHookTimeout = float64(10) ) type cursorHooks map[string][]map[string]any @@ -36,11 +35,7 @@ type cursorRenderedDocument struct { remove bool } -type cursorRuntimeContract struct { - agentID string - guardPath string - auditPath string -} +type cursorRuntimeContract = managedRuntimeContract func cloneCursorHooks(source cursorHooks) cursorHooks { clone := make(cursorHooks, len(source)) @@ -50,104 +45,16 @@ func cloneCursorHooks(source cursorHooks) cursorHooks { return clone } -func sameCursorPath(left, right string) bool { - left = filepath.Clean(left) - right = filepath.Clean(right) - if absolute, err := filepath.Abs(left); err == nil { - left = absolute - } - if absolute, err := filepath.Abs(right); err == nil { - right = absolute - } - if runtime.GOOS == "windows" { - return strings.EqualFold(left, right) - } - return left == right -} - -func sameCursorAgentID(left, right string) bool { - if runtime.GOOS == "windows" { - return strings.EqualFold(left, right) - } - return left == right -} - -func quoteCursorPowerShell(value string) string { - return "'" + strings.ReplaceAll(value, "'", "''") + "'" -} - func buildCursorHandler(nodePath, scriptPath string) map[string]any { - command := quotePOSIXArgument(nodePath) + " " + quotePOSIXArgument(scriptPath) + command := posixSource(nodePath, scriptPath) if runtime.GOOS == "windows" { - command = "& " + quoteCursorPowerShell(nodePath) + " " + - quoteCursorPowerShell(scriptPath) + "; exit $LASTEXITCODE" + command = powerShellSource(nodePath, scriptPath) } return map[string]any{ "command": command, "timeout": cursorHookTimeout, "failClosed": true, } } -func readCursorPOSIXArgument(command string, start int) (string, int, bool) { - if start >= len(command) || command[start] != '\'' { - return "", start, false - } - var value strings.Builder - for index := start + 1; index < len(command); { - if strings.HasPrefix(command[index:], cursorPOSIXApostrophe) { - value.WriteByte('\'') - index += len(cursorPOSIXApostrophe) - continue - } - if command[index] == '\'' { - return value.String(), index + 1, true - } - value.WriteByte(command[index]) - index++ - } - return "", start, false -} - -func readCursorPowerShellArgument(command string, start int) (string, int, bool) { - if start >= len(command) || command[start] != '\'' { - return "", start, false - } - var value strings.Builder - for index := start + 1; index < len(command); index++ { - if command[index] != '\'' { - value.WriteByte(command[index]) - continue - } - if index+1 < len(command) && command[index+1] == '\'' { - value.WriteByte('\'') - index++ - continue - } - return value.String(), index + 1, true - } - return "", start, false -} - -func parseCursorPOSIXCommand(command string) (string, string, bool) { - executable, next, ok := readCursorPOSIXArgument(command, 0) - if !ok || next >= len(command) || command[next] != ' ' { - return "", "", false - } - script, end, ok := readCursorPOSIXArgument(command, next+1) - return executable, script, ok && end == len(command) -} - -func parseCursorPowerShellCommand(command string) (string, string, bool) { - if !strings.HasPrefix(command, "& ") { - return "", "", false - } - executable, next, ok := readCursorPowerShellArgument(command, 2) - if !ok || next >= len(command) || command[next] != ' ' { - return "", "", false - } - script, end, ok := readCursorPowerShellArgument(command, next+1) - return executable, script, ok && command[end:] == "; exit $LASTEXITCODE" -} - func parseCursorLegacyCommand(command string) (string, bool) { separator := strings.IndexByte(command, ' ') if separator < 0 { @@ -164,11 +71,6 @@ func parseCursorLegacyCommand(command string) (string, bool) { return script, script != "" && !strings.ContainsAny(script, "\r\n") } -func isCursorNodeExecutable(path string) bool { - name := filepath.Base(path) - return name == "node" || strings.EqualFold(name, "node.exe") -} - func cursorManagedScriptPath(handler map[string]any) (string, bool) { if len(handler) == 3 && handler["timeout"] == cursorHookTimeout && handler["failClosed"] == true { command, ok := handler["command"].(string) @@ -178,12 +80,12 @@ func cursorManagedScriptPath(handler map[string]any) (string, bool) { var nodePath, scriptPath string var parsed bool if runtime.GOOS == "windows" { - nodePath, scriptPath, parsed = parseCursorPowerShellCommand(command) + nodePath, scriptPath, parsed = parsePowerShellSource(command) } else { - nodePath, scriptPath, parsed = parseCursorPOSIXCommand(command) + nodePath, scriptPath, parsed = parsePOSIXCommand(command) } if parsed && filepath.IsAbs(nodePath) && filepath.IsAbs(scriptPath) && - isCursorNodeExecutable(nodePath) { + isNodeExecutable(nodePath) { return scriptPath, true } } @@ -207,7 +109,7 @@ func cursorManagedAgentID( return "", false } agentDirectory := filepath.Dir(scriptPath) - if !sameCursorPath(filepath.Dir(agentDirectory), runtimeRoot) { + if !sameManagedPath(filepath.Dir(agentDirectory), runtimeRoot) { return "", false } agentID := filepath.Base(agentDirectory) @@ -297,7 +199,7 @@ func removeManagedCursorHooks(hooks cursorHooks, runtimeRoot, agentID string) cu kept := make([]map[string]any, 0, len(result[contract.event])) for _, handler := range result[contract.event] { managedID, managed := cursorManagedAgentID(handler, contract.script, runtimeRoot) - if managed && (agentID == "" || sameCursorAgentID(managedID, agentID)) { + if managed && (agentID == "" || sameManagedAgentID(managedID, agentID)) { continue } kept = append(kept, handler) @@ -384,11 +286,7 @@ func managedCursorIDs( if !managed { continue } - key := agentID - if runtime.GOOS == "windows" { - key = strings.ToLower(agentID) - } - result[key] = agentID + result[managedReferenceKey(agentID)] = agentID } return result } diff --git a/sdks/go/cmd/elydora/plugins/cursor_io.go b/sdks/go/cmd/elydora/plugins/cursor_io.go index a943573..d2f57d5 100644 --- a/sdks/go/cmd/elydora/plugins/cursor_io.go +++ b/sdks/go/cmd/elydora/plugins/cursor_io.go @@ -1,13 +1,9 @@ package plugins import ( - "encoding/json" - "errors" "fmt" "os" "path/filepath" - "runtime" - "strings" ) func cursorConfigPath() (string, error) { @@ -27,21 +23,14 @@ func readCursorDocument() (*cursorDocument, error) { if err != nil { return nil, err } - info, err := os.Lstat(filePath) - if errors.Is(err, os.ErrNotExist) { + snapshot, err := readManagedFile(filePath, "Cursor user hooks", maxManagedSourceBytes) + if err != nil { + return nil, err + } + if snapshot == nil { return createCursorDocument(filePath), nil } - if err != nil { - return nil, fmt.Errorf("inspect Cursor user hooks at %s: %w", filePath, err) - } - if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { - return nil, fmt.Errorf("Cursor user hooks path is not a physical file: %s", filePath) - } - raw, err := os.ReadFile(filePath) // #nosec G304 -- filePath is the fixed Cursor user configuration path. - if err != nil { - return nil, fmt.Errorf("read Cursor user hooks at %s: %w", filePath, err) - } - return parseCursorDocument(filePath, raw, runtimeRoot) + return parseCursorDocument(filePath, snapshot.contents, runtimeRoot) } func prepareRenderedCursorChange(rendered *cursorRenderedDocument) (*fileChange, error) { @@ -59,167 +48,25 @@ func prepareRenderedCursorChange(rendered *cursorRenderedDocument) (*fileChange, ) } -func cursorPhysicalFileExists(path, label string) (bool, error) { - info, err := os.Lstat(path) - if errors.Is(err, os.ErrNotExist) { - return false, nil - } - if err != nil { - return false, fmt.Errorf("inspect %s at %s: %w", label, path, err) - } - if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { - return false, fmt.Errorf("%s path is not a physical file: %s", label, path) - } - return true, nil -} - -func cursorPhysicalDirectoryExists(path string) (bool, error) { - info, err := os.Lstat(path) - if errors.Is(err, os.ErrNotExist) { - return false, nil - } - if err != nil { - return false, fmt.Errorf("inspect Elydora runtime directory at %s: %w", path, err) - } - if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { - return false, fmt.Errorf("Elydora runtime path is not a physical directory: %s", path) - } - return true, nil -} - -func readCursorRuntimeConfig(path string) (map[string]any, bool, error) { - exists, err := cursorPhysicalFileExists(path, "Elydora runtime config") - if err != nil || !exists { - return nil, exists, err - } - raw, err := os.ReadFile(path) // #nosec G304 -- path is confined to one validated Elydora agent directory. - if err != nil { - return nil, true, fmt.Errorf("read Elydora runtime config at %s: %w", path, err) - } - label := fmt.Sprintf("Elydora runtime config at %s", path) - if !json.Valid(raw) { - var value any - if err := json.Unmarshal(raw, &value); err != nil { - return nil, true, fmt.Errorf("parse %s: %w", label, err) - } - } - config, err := decodeJSONCObject(raw, label, false) - if err != nil { - return nil, true, err - } - return config, true, nil -} - -func validateCursorRuntimeIdentity(path, agentID string) (bool, error) { - config, exists, err := readCursorRuntimeConfig(path) - if err != nil || !exists { - return exists, err - } - configuredID, ok := config["agent_id"].(string) - if !ok || config["agent_name"] != cursorAgentKey || - !sameCursorAgentID(configuredID, agentID) { - return true, fmt.Errorf( - "Elydora runtime config identity does not match Cursor agent %s: %s", - agentID, - path, - ) - } - return true, nil -} - -func preflightCursorRuntime(agentDirectory, agentID string) error { - exists, err := cursorPhysicalDirectoryExists(agentDirectory) - if err != nil || !exists { - return err - } - identityExists, err := validateCursorRuntimeIdentity( - filepath.Join(agentDirectory, "config.json"), agentID, - ) - if err != nil { - return err - } - runtimeExists := false - for _, item := range []struct{ path, label string }{ - {filepath.Join(agentDirectory, cursorGuardScript), "Elydora guard runtime"}, - {filepath.Join(agentDirectory, cursorAuditScript), "Elydora audit runtime"}, - {filepath.Join(agentDirectory, "private.key"), "Elydora private key"}, - } { - exists, err := cursorPhysicalFileExists(item.path, item.label) - if err != nil { - return err - } - runtimeExists = runtimeExists || exists - } - if runtimeExists && !identityExists { - return fmt.Errorf( - "Elydora runtime identity cannot be verified without config.json: %s", - agentDirectory, - ) - } - return nil -} - func prepareCursorInstallationChanges( config InstallConfig, - agentDirectory string, + paths *managedRuntimePaths, rendered *cursorRenderedDocument, ) ([]*fileChange, error) { - baseURL := config.BaseURL - if baseURL == "" { - baseURL = "https://api.elydora.com" + if config.BaseURL == "" { + config.BaseURL = "https://api.elydora.com" } - runtimeConfig, err := json.MarshalIndent(agentRuntimeConfig{ - OrgID: config.OrgID, AgentID: config.AgentID, KID: config.KID, - BaseURL: baseURL, Token: config.Token, AgentName: cursorAgentKey, - }, "", " ") + changes, err := managedRuntimeFileChanges( + config, + paths, + cursorAgentKey, + generateGuardScript( + cursorAgentKey, config.AgentID, `{"permission":"allow"}`+"\n", true, "cursor", + ), + buildHookScriptWithOutput(cursorAgentKey, config.AgentID, "{}\n", true, true), + ) if err != nil { - return nil, fmt.Errorf("encode Elydora runtime config: %w", err) - } - runtimeConfig = append(runtimeConfig, '\n') - if len(runtimeConfig) > maxRuntimeConfigBytes { - return nil, fmt.Errorf( - "Elydora runtime config exceeds %d bytes after JSON encoding", - maxRuntimeConfigBytes, - ) - } - items := []struct { - path, label string - content []byte - mode os.FileMode - }{ - { - filepath.Join(agentDirectory, cursorGuardScript), "Elydora guard runtime", - []byte(generateGuardScript( - cursorAgentKey, - config.AgentID, - `{"permission":"allow"}`+"\n", - true, - "cursor", - )), 0700, - }, - {filepath.Join(agentDirectory, "config.json"), "Elydora runtime config", runtimeConfig, 0600}, - {filepath.Join(agentDirectory, "private.key"), "Elydora private key", []byte(config.PrivateKey), 0600}, - { - filepath.Join(agentDirectory, cursorAuditScript), "Elydora audit runtime", - []byte(buildHookScriptWithOutput( - cursorAgentKey, - config.AgentID, - "{}\n", - true, - true, - )), 0700, - }, - } - changes := make([]*fileChange, 0, len(items)+1) - for _, item := range items { - if err := validateRuntimeFileTarget(item.path, item.label); err != nil { - return nil, err - } - change, err := prepareFileChange(item.path, item.label, item.content, item.mode) - if err != nil { - return nil, err - } - changes = append(changes, change) + return nil, err } documentChange, err := prepareRenderedCursorChange(rendered) if err != nil { @@ -228,98 +75,6 @@ func prepareCursorInstallationChanges( return append(changes, documentChange), nil } -func ensureCursorDirectory(path, label string) error { - if err := os.MkdirAll(path, 0700); err != nil { - return fmt.Errorf("create %s directory at %s: %w", label, path, err) - } - info, err := os.Lstat(path) - if err != nil { - return fmt.Errorf("inspect %s directory at %s: %w", label, path, err) - } - if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { - return fmt.Errorf("%s directory is not a physical directory: %s", label, path) - } - return nil -} - -func writeCursorChanges( - changes []*fileChange, - label string, - rename renameFunc, - runtimeRoot, agentDirectory string, -) error { - hasChanges := false - for _, change := range changes { - if change != nil { - hasChanges = true - break - } - } - if !hasChanges { - return nil - } - if agentDirectory != "" { - if err := EnsurePrivateDirectory(runtimeRoot); err != nil { - return err - } - if err := EnsurePrivateDirectory(agentDirectory); err != nil { - return err - } - } - directories := map[string]string{} - for _, change := range changes { - if change == nil { - continue - } - directory := filepath.Dir(change.filePath) - key := filepath.Clean(directory) - if runtime.GOOS == "windows" { - key = strings.ToLower(key) - } - directories[key] = directory - } - for _, directory := range directories { - if err := ensureCursorDirectory(directory, label); err != nil { - return err - } - } - return writeChanges(changes, label, rename) -} - -func cursorRuntimeFilesExist(contracts []cursorRuntimeContract) (bool, error) { - for _, contract := range contracts { - agentDirectory := filepath.Dir(contract.guardPath) - directoryExists, err := cursorPhysicalDirectoryExists(agentDirectory) - if err != nil { - return false, err - } - if !directoryExists { - continue - } - config, exists, err := readCursorRuntimeConfig(filepath.Join(agentDirectory, "config.json")) - if err != nil { - return false, err - } - configuredID, idOK := config["agent_id"].(string) - if !exists || !idOK || config["agent_name"] != cursorAgentKey || - !sameCursorAgentID(configuredID, contract.agentID) { - continue - } - complete := true - for _, item := range []struct{ path, label string }{ - {contract.guardPath, "Elydora guard runtime"}, - {contract.auditPath, "Elydora audit runtime"}, - {filepath.Join(agentDirectory, "private.key"), "Elydora private key"}, - } { - exists, err := cursorPhysicalFileExists(item.path, item.label) - if err != nil { - return false, err - } - complete = complete && exists - } - if complete { - return true, nil - } - } - return false, nil +func cursorRuntimeFilesExist(contracts []managedRuntimeContract) (bool, error) { + return managedRuntimeFilesPresent(contracts, cursorAgentKey) } diff --git a/sdks/go/cmd/elydora/plugins/cursor_recovery_test.go b/sdks/go/cmd/elydora/plugins/cursor_recovery_test.go index a267bb5..1067dec 100644 --- a/sdks/go/cmd/elydora/plugins/cursor_recovery_test.go +++ b/sdks/go/cmd/elydora/plugins/cursor_recovery_test.go @@ -183,7 +183,7 @@ func TestCursorInstallCreatesRuntimeDirectoryTransactionally(t *testing.T) { fixture.guardPath, fixture.hookPath, } { - if exists, err := cursorPhysicalFileExists(path, "Cursor runtime"); err != nil || !exists { + if exists, err := managedPhysicalFileExists(path, "Cursor runtime", maxManagedSourceBytes); err != nil || !exists { t.Fatalf("runtime file %s exists = %v, %v", path, exists, err) } } @@ -200,7 +200,7 @@ func TestCursorInstallRollsBackEveryFileOnConfigCommitFailure(t *testing.T) { } failed := false fixture.plugin.rename = func(source, destination string) error { - if !failed && sameCursorPath(destination, fixture.configPath) { + if !failed && sameManagedPath(destination, fixture.configPath) { failed = true return errors.New("injected Cursor config commit failure") } diff --git a/sdks/go/cmd/elydora/plugins/droid.go b/sdks/go/cmd/elydora/plugins/droid.go index 97fcee9..962c451 100644 --- a/sdks/go/cmd/elydora/plugins/droid.go +++ b/sdks/go/cmd/elydora/plugins/droid.go @@ -7,14 +7,12 @@ type DroidPlugin struct { rename renameFunc } -// ManagesGuardRuntime reports that Droid commits its guard, audit runtime, -// credentials, and hook documents in one transaction. +// ManagesGuardRuntime reports that the guard is committed with the audit runtime. func (p *DroidPlugin) ManagesGuardRuntime() bool { return true } -// PreflightInstall validates every hook source, policy layer, credential, -// matcher, and runtime identity before any write. +// PreflightInstall validates every source before the CLI creates runtime state. func (p *DroidPlugin) PreflightInstall(config InstallConfig) error { sources, err := readDroidSources() if err != nil { @@ -107,7 +105,7 @@ func (p *DroidPlugin) Uninstall(agentID string) error { if err != nil { return err } - runtimeRoot, err := droidRuntimeRoot() + runtimeRoot, err := AgentRuntimeRoot() if err != nil { return err } @@ -133,7 +131,7 @@ func (p *DroidPlugin) Status() (PluginStatus, error) { if droidHookBlocked(sources) != nil { return status, nil } - runtimeRoot, err := droidRuntimeRoot() + runtimeRoot, err := AgentRuntimeRoot() if err != nil { return status, err } diff --git a/sdks/go/cmd/elydora/plugins/droid_config.go b/sdks/go/cmd/elydora/plugins/droid_config.go index 1b5f13f..17a79a3 100644 --- a/sdks/go/cmd/elydora/plugins/droid_config.go +++ b/sdks/go/cmd/elydora/plugins/droid_config.go @@ -239,7 +239,7 @@ func droidAdditionsFor( document, target *droidDocument, groups map[string]map[string]any, ) map[string]map[string]any { - if sameDroidPath(document.filePath, target.filePath) { + if sameManagedPath(document.filePath, target.filePath) { return groups } return map[string]map[string]any{} diff --git a/sdks/go/cmd/elydora/plugins/droid_contract.go b/sdks/go/cmd/elydora/plugins/droid_contract.go index c493065..4474ae1 100644 --- a/sdks/go/cmd/elydora/plugins/droid_contract.go +++ b/sdks/go/cmd/elydora/plugins/droid_contract.go @@ -15,13 +15,11 @@ import ( ) const ( - droidAgentKey = "droid" - droidGuardScript = "guard.js" - droidAuditScript = "hook.js" - droidHookTimeout = float64(10) - droidOwnedFileMarker = "// Managed by Elydora" - droidPOSIXApostrophe = `'"'"'` - droidWindowsExitSuffix = "; exit $LASTEXITCODE" + droidAgentKey = "droid" + droidGuardScript = "guard.js" + droidAuditScript = "hook.js" + droidHookTimeout = float64(10) + droidOwnedFileMarker = "// Managed by Elydora" ) var droidToolEvents = [...]string{"PreToolUse", "PostToolUse"} @@ -35,11 +33,7 @@ type droidManagedRemoval struct { removeGroup bool } -type droidRuntimeContract struct { - agentID string - guardPath string - auditPath string -} +type droidRuntimeContract = managedRuntimeContract type droidRegexEntry struct { Label string `json:"label"` @@ -178,145 +172,21 @@ for (const entry of entries) { return nil } -func buildDroidCommand(nodePath, scriptPath string) string { - if runtime.GOOS == "windows" { - return "& " + quoteDroidPowerShellArgument(nodePath) + " " + - quoteDroidPowerShellArgument(scriptPath) + droidWindowsExitSuffix - } - return quotePOSIXArgument(nodePath) + " " + quotePOSIXArgument(scriptPath) -} - -func quoteDroidPowerShellArgument(value string) string { - return "'" + strings.ReplaceAll(value, "'", "''") + "'" -} - func buildDroidGroup(nodePath, scriptPath string) map[string]any { return map[string]any{ "matcher": "*", "hooks": []any{map[string]any{ - "type": "command", "command": buildDroidCommand(nodePath, scriptPath), "timeout": droidHookTimeout, + "type": "command", "command": buildShellCommand(nodePath, scriptPath), "timeout": droidHookTimeout, }}, } } func parseDroidCommand(command string, includeLegacy ...bool) (string, string, bool) { - if runtime.GOOS != "windows" { - return parseDroidPOSIXCommand(command) - } - executable, script, ok := parseDroidPowerShellCommand(command) - if ok || len(includeLegacy) == 0 || !includeLegacy[0] { + executable, script, ok := parseShellCommand(command) + if ok || runtime.GOOS != "windows" || len(includeLegacy) == 0 || !includeLegacy[0] { return executable, script, ok } - return parseDroidLegacyWindowsCommand(command) -} - -func parseDroidPOSIXCommand(command string) (string, string, bool) { - executable, next, ok := readDroidPOSIXArgument(command, 0) - if !ok || next >= len(command) || command[next] != ' ' { - return "", "", false - } - script, end, ok := readDroidPOSIXArgument(command, next+1) - return executable, script, ok && end == len(command) && executable != "" && script != "" -} - -func parseDroidPowerShellCommand(command string) (string, string, bool) { - if !strings.HasPrefix(command, "& ") { - return "", "", false - } - executable, next, ok := readDroidPowerShellArgument(command, 2) - if !ok || next >= len(command) || command[next] != ' ' { - return "", "", false - } - script, end, ok := readDroidPowerShellArgument(command, next+1) - return executable, script, ok && command[end:] == droidWindowsExitSuffix && executable != "" && script != "" -} - -func parseDroidLegacyWindowsCommand(command string) (string, string, bool) { - executable, next, ok := readDroidLegacyWindowsArgument(command, 0) - if !ok || next >= len(command) || command[next] != ' ' { - return "", "", false - } - script, end, ok := readDroidLegacyWindowsArgument(command, next+1) - return executable, script, ok && end == len(command) && executable != "" && script != "" -} - -func readDroidPowerShellArgument(command string, start int) (string, int, bool) { - if start >= len(command) || command[start] != '\'' { - return "", start, false - } - var value strings.Builder - for index := start + 1; index < len(command); index++ { - if command[index] != '\'' { - value.WriteByte(command[index]) - continue - } - if index+1 < len(command) && command[index+1] == '\'' { - value.WriteByte('\'') - index++ - continue - } - return value.String(), index + 1, true - } - return "", start, false -} - -func readDroidLegacyWindowsArgument(command string, start int) (string, int, bool) { - if start >= len(command) || command[start] != '"' { - return "", start, false - } - end := strings.IndexByte(command[start+1:], '"') - if end < 0 { - return "", start, false - } - end += start + 1 - value := command[start+1 : end] - return value, end + 1, value != "" && !strings.ContainsAny(value, "\r\n") -} - -func readDroidPOSIXArgument(command string, start int) (string, int, bool) { - if start >= len(command) || command[start] != '\'' { - return "", start, false - } - var value strings.Builder - for index := start + 1; index < len(command); { - if strings.HasPrefix(command[index:], droidPOSIXApostrophe) { - value.WriteByte('\'') - index += len(droidPOSIXApostrophe) - continue - } - if command[index] == '\'' { - return value.String(), index + 1, true - } - value.WriteByte(command[index]) - index++ - } - return "", start, false -} - -func sameDroidPath(left, right string) bool { - left = filepath.Clean(left) - right = filepath.Clean(right) - if absolute, err := filepath.Abs(left); err == nil { - left = absolute - } - if absolute, err := filepath.Abs(right); err == nil { - right = absolute - } - if runtime.GOOS == "windows" { - return strings.EqualFold(left, right) - } - return left == right -} - -func sameDroidAgentID(left, right string) bool { - if runtime.GOOS == "windows" { - return strings.EqualFold(left, right) - } - return left == right -} - -func droidRuntimeRoot() (string, error) { - return AgentRuntimeRoot() + return parseQuotedWindowsCommand(command) } func managedDroidAgentID( @@ -333,29 +203,17 @@ func managedDroidAgentID( } executable, scriptPath, ok := parseDroidCommand(command, includeLegacy...) if !ok || !filepath.IsAbs(executable) || !filepath.IsAbs(scriptPath) || - !isDroidNodeExecutable(executable) || !sameDroidFileName(filepath.Base(scriptPath), scriptName) { + !isNodeExecutable(executable) || !sameManagedName(filepath.Base(scriptPath), scriptName) { return "", false } agentDirectory := filepath.Dir(scriptPath) - if !sameDroidPath(filepath.Dir(agentDirectory), runtimeRoot) { + if !sameManagedPath(filepath.Dir(agentDirectory), runtimeRoot) { return "", false } agentID := filepath.Base(agentDirectory) return agentID, agentID != "" && agentID != "." && agentID != ".." } -func isDroidNodeExecutable(path string) bool { - name := filepath.Base(path) - return name == "node" || strings.EqualFold(name, "node.exe") -} - -func sameDroidFileName(left, right string) bool { - if runtime.GOOS == "windows" { - return strings.EqualFold(left, right) - } - return left == right -} - func managedDroidRemovals(settings droidHookSettings, agentID, runtimeRoot string) []droidManagedRemoval { removals := make([]droidManagedRemoval, 0) for _, contract := range []struct{ event, script string }{ @@ -370,7 +228,7 @@ func managedDroidRemovals(settings droidHookSettings, agentID, runtimeRoot strin managedID, managed := managedDroidAgentID( handlerValue.(map[string]any), contract.script, runtimeRoot, true, ) - if managed && (agentID == "" || sameDroidAgentID(managedID, agentID)) { + if managed && (agentID == "" || sameManagedAgentID(managedID, agentID)) { indexes = append(indexes, handlerIndex) } } @@ -400,7 +258,7 @@ func droidRuntimeContracts(settings droidHookSettings, runtimeRoot string) []dro contracts := make([]droidRuntimeContract, 0, len(guardIDs)) for _, guardID := range guardIDs { for auditID := range audits { - if !sameDroidAgentID(guardID, auditID) { + if !sameManagedAgentID(guardID, auditID) { continue } root := filepath.Join(runtimeRoot, guardID) diff --git a/sdks/go/cmd/elydora/plugins/droid_installation.go b/sdks/go/cmd/elydora/plugins/droid_installation.go index 4730cbf..1d27833 100644 --- a/sdks/go/cmd/elydora/plugins/droid_installation.go +++ b/sdks/go/cmd/elydora/plugins/droid_installation.go @@ -1,24 +1,13 @@ package plugins import ( - "encoding/json" "fmt" - "os" - "path/filepath" - "strings" ) -type droidRuntimePaths struct { - runtimeRoot string - agentDirectory string - guardPath string - auditPath string -} - type preparedDroidInstallation struct { changes []*fileChange preconditions []filePrecondition - paths *droidRuntimePaths + paths *managedRuntimePaths } type preparedDroidUninstall struct { @@ -39,80 +28,10 @@ func droidSourceLabel(document *droidDocument) string { } } -func validateDroidInstallConfig(config InstallConfig) error { - for _, field := range []struct{ name, value string }{ - {"agent name", config.AgentName}, - {"organization ID", config.OrgID}, - {"agent ID", config.AgentID}, - {"key ID", config.KID}, - {"private key", config.PrivateKey}, - {"base URL", config.BaseURL}, - {"guard script path", config.GuardScriptPath}, - } { - if field.value == "" { - return fmt.Errorf("%s is required", field.name) - } - } - if config.AgentName != droidAgentKey { - return fmt.Errorf( - "Factory Droid installation requires agent name %s", - droidAgentKey, - ) - } - if strings.TrimSpace(config.OrgID) == "" { - return fmt.Errorf("organization ID is required") - } - if strings.TrimSpace(config.KID) == "" { - return fmt.Errorf("key ID is required") - } - if config.Token != "" && strings.TrimSpace(config.Token) == "" { - return fmt.Errorf("token must contain a non-whitespace value when provided") - } - if err := validateManagedPrivateKey(config.PrivateKey); err != nil { - return err - } - return validateManagedBaseURL(config.BaseURL) -} - -func droidAgentPaths(config InstallConfig) (*droidRuntimePaths, error) { - if err := validateDroidInstallConfig(config); err != nil { - return nil, err - } - runtimeRoot, err := AgentRuntimeRoot() - if err != nil { - return nil, err - } - agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID) - if err != nil { - return nil, err - } - paths := &droidRuntimePaths{ - runtimeRoot: runtimeRoot, - agentDirectory: agentDirectory, - guardPath: filepath.Join(agentDirectory, droidGuardScript), - auditPath: filepath.Join(agentDirectory, droidAuditScript), - } - if !filepath.IsAbs(config.GuardScriptPath) || - !sameDroidPath(config.GuardScriptPath, paths.guardPath) { - return nil, fmt.Errorf( - "Elydora guard runtime must use the managed agent directory: %s", - paths.guardPath, - ) - } - if config.HookScript != "" && (!filepath.IsAbs(config.HookScript) || - !sameDroidPath(config.HookScript, paths.auditPath)) { - return nil, fmt.Errorf( - "Elydora audit runtime must use the managed agent directory: %s", - paths.auditPath, - ) - } - return paths, nil -} - func preflightDroidInstallation( config InstallConfig, sources *droidSources, -) (*droidRuntimePaths, string, error) { +) (*managedRuntimePaths, string, error) { if sources == nil || sources.root == nil || sources.policy == nil { return nil, "", fmt.Errorf("Factory Droid installation sources are required") } @@ -126,50 +45,25 @@ func preflightDroidInstallation( if err := validateDroidRegexes(hooks...); err != nil { return nil, "", err } - paths, err := droidAgentPaths(config) + if err := validateManagedInstallConfig(config, droidAgentKey, "Factory Droid"); err != nil { + return nil, "", err + } + paths, err := resolveManagedRuntimePaths(config, droidGuardScript, droidAuditScript) if err != nil { return nil, "", err } - if err := validateDroidRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil { + if err := validateManagedRuntimeIdentity( + paths.agentDirectory, config.AgentID, droidAgentKey, "Factory Droid", + ); err != nil { return nil, "", err } - nodePath, err := resolveNodeRuntime() + nodePath, err := resolveAbsoluteNodeRuntime("Factory Droid") if err != nil { return nil, "", err } - if !filepath.IsAbs(nodePath) || !isDroidNodeExecutable(nodePath) { - return nil, "", fmt.Errorf( - "Factory Droid hooks require an absolute Node.js executable path", - ) - } return paths, nodePath, nil } -func buildDroidRuntimeConfig(config InstallConfig) ([]byte, error) { - value := map[string]any{ - "org_id": config.OrgID, - "agent_id": config.AgentID, - "kid": config.KID, - "base_url": config.BaseURL, - "agent_name": droidAgentKey, - } - if config.Token != "" { - value["token"] = config.Token - } - encoded, err := json.MarshalIndent(value, "", " ") - if err != nil { - return nil, fmt.Errorf("encode Elydora runtime config: %w", err) - } - encoded = append(encoded, '\n') - if len(encoded) > maxRuntimeConfigBytes { - return nil, fmt.Errorf( - "Elydora runtime config exceeds %d bytes after JSON encoding", - maxRuntimeConfigBytes, - ) - } - return encoded, nil -} - func prepareRenderedDroidChange( rendered *droidRenderedDocument, ) (*fileChange, error) { @@ -198,7 +92,7 @@ func validateDroidRenderedSet( matches := 0 for _, item := range rendered { if item != nil && item.document != nil && - sameDroidPath(item.document.filePath, document.filePath) { + sameManagedPath(item.document.filePath, document.filePath) { matches++ } } @@ -217,7 +111,7 @@ func droidSourcePreconditions( for _, document := range droidSourceDocuments(sources) { changed := false for _, path := range changedPaths { - if sameDroidPath(document.filePath, path) { + if sameManagedPath(document.filePath, path) { changed = true break } @@ -246,38 +140,13 @@ func prepareDroidInstallation( if err := validateDroidRenderedSet(sources, rendered); err != nil { return nil, err } - runtimeConfig, err := buildDroidRuntimeConfig(config) + guardScript, auditScript := droidExpectedScripts(config.AgentID, nil) + changes, err := managedRuntimeFileChanges( + config, paths, droidAgentKey, string(guardScript), string(auditScript), + ) if err != nil { return nil, err } - items := []struct { - path, label string - content []byte - mode os.FileMode - }{ - { - paths.guardPath, - "Elydora guard runtime", - []byte(generateGuardScript(droidAgentKey, config.AgentID, "", false, "")), - 0700, - }, - {filepath.Join(paths.agentDirectory, "config.json"), "Elydora runtime config", runtimeConfig, 0600}, - {filepath.Join(paths.agentDirectory, "private.key"), "Elydora private key", []byte(config.PrivateKey), 0600}, - { - paths.auditPath, - "Elydora audit runtime", - []byte(buildHookScriptWithOutput(droidAgentKey, config.AgentID, "", false, true)), - 0700, - }, - } - changes := make([]*fileChange, 0, len(items)+len(rendered)) - for _, item := range items { - change, changeErr := prepareFileChange(item.path, item.label, item.content, item.mode) - if changeErr != nil { - return nil, changeErr - } - changes = append(changes, change) - } changedPaths := make([]string, 0, len(rendered)) for _, document := range rendered { change, changeErr := prepareRenderedDroidChange(document) @@ -305,14 +174,7 @@ func writeDroidChanges( runtimeRoot, agentDirectory string, preconditions []filePrecondition, ) error { - hasChanges := false - for _, change := range changes { - if change != nil { - hasChanges = true - break - } - } - if !hasChanges { + if !hasFileChanges(changes) { return writeChanges(changes, label, rename, preconditions...) } if err := assertFilePreconditions(preconditions, label); err != nil { @@ -365,7 +227,7 @@ func prepareDroidUninstall( for _, item := range rendered { changed := false for _, path := range changedPaths { - if sameDroidPath(item.document.filePath, path) { + if sameManagedPath(item.document.filePath, path) { changed = true break } @@ -398,3 +260,8 @@ func commitDroidUninstall( prepared.preconditions, ) } + +func droidExpectedScripts(agentID string, _ map[string]any) ([]byte, []byte) { + return []byte(generateGuardScript(droidAgentKey, agentID, "", false, "")), + []byte(buildHookScriptWithOutput(droidAgentKey, agentID, "", false, true)) +} diff --git a/sdks/go/cmd/elydora/plugins/droid_status.go b/sdks/go/cmd/elydora/plugins/droid_status.go index 2f9ed24..71259d5 100644 --- a/sdks/go/cmd/elydora/plugins/droid_status.go +++ b/sdks/go/cmd/elydora/plugins/droid_status.go @@ -1,251 +1,5 @@ package plugins -import ( - "bytes" - "fmt" - "path/filepath" - "sort" - "strings" -) - -func readDroidRuntimeConfig(path string) (map[string]any, bool, error) { - snapshot, err := readManagedFile(path, "Elydora runtime config", maxRuntimeConfigBytes) - if err != nil || snapshot == nil { - return nil, snapshot != nil, err - } - config, err := decodeStrictJSONObject( - snapshot.contents, - fmt.Sprintf("Elydora runtime config at %s", path), - ) - return config, true, err -} - -func requireDroidRuntimeString( - config map[string]any, - field, configPath string, -) (string, error) { - value, ok := config[field].(string) - if !ok || strings.TrimSpace(value) == "" { - return "", fmt.Errorf( - "Elydora runtime config %s is invalid: %s", - field, - configPath, - ) - } - return value, nil -} - -func validateDroidRuntimeConfig( - config map[string]any, - expectedAgentID, configPath string, -) error { - supported := stringSet( - "org_id", - "agent_id", - "kid", - "base_url", - "token", - "agent_name", - ) - fields := make([]string, 0, len(config)) - for field := range config { - fields = append(fields, field) - } - sort.Strings(fields) - for _, field := range fields { - if _, ok := supported[field]; !ok { - return fmt.Errorf( - `Elydora runtime config has unsupported field %q: %s`, - field, - configPath, - ) - } - } - if _, err := requireDroidRuntimeString(config, "org_id", configPath); err != nil { - return err - } - if _, err := requireDroidRuntimeString(config, "kid", configPath); err != nil { - return err - } - agentID, err := requireDroidRuntimeString(config, "agent_id", configPath) - if err != nil { - return err - } - if !sameDroidAgentID(agentID, expectedAgentID) || config["agent_name"] != droidAgentKey { - return fmt.Errorf( - "Elydora runtime identity does not match Factory Droid hooks: %s", - configPath, - ) - } - if _, exists := config["token"]; exists { - if _, err := requireDroidRuntimeString(config, "token", configPath); err != nil { - return err - } - } - baseURL, err := requireDroidRuntimeString(config, "base_url", configPath) - if err != nil { - return err - } - if err := validateManagedBaseURL(baseURL); err != nil { - return fmt.Errorf( - "Elydora runtime config base URL is invalid at %s: %w", - configPath, - err, - ) - } - return nil -} - -func validateDroidRuntimeIdentity(agentDirectory, agentID string) error { - runtimeRoot := filepath.Dir(agentDirectory) - rootExists, err := managedPhysicalDirectoryExists( - runtimeRoot, - "Elydora runtime directory", - ) - if err != nil || !rootExists { - return err - } - directoryExists, err := managedPhysicalDirectoryExists( - agentDirectory, - "Elydora agent runtime directory", - ) - if err != nil || !directoryExists { - return err - } - configPath := filepath.Join(agentDirectory, "config.json") - config, configExists, err := readDroidRuntimeConfig(configPath) - if err != nil { - return err - } - artifactExists := false - for _, item := range []struct { - path string - label string - limit int64 - }{ - {filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes}, - {filepath.Join(agentDirectory, droidGuardScript), "Elydora guard runtime", maxManagedSourceBytes}, - {filepath.Join(agentDirectory, droidAuditScript), "Elydora audit runtime", maxManagedSourceBytes}, - {filepath.Join(agentDirectory, "chain-state.json"), "Elydora chain state", maxRuntimeConfigBytes}, - {filepath.Join(agentDirectory, "status-cache.json"), "Elydora status cache", maxRuntimeConfigBytes}, - {filepath.Join(agentDirectory, "error.log"), "Elydora error log", maxManagedSourceBytes}, - } { - exists, inspectErr := managedPhysicalFileExists(item.path, item.label, item.limit) - if inspectErr != nil { - return inspectErr - } - artifactExists = artifactExists || exists - } - if !configExists { - if artifactExists { - return fmt.Errorf( - "Elydora runtime identity cannot be verified without config.json: %s", - agentDirectory, - ) - } - return nil - } - configuredID, ok := config["agent_id"].(string) - if !ok || config["agent_name"] != droidAgentKey || - !sameDroidAgentID(configuredID, agentID) { - return fmt.Errorf( - "Elydora runtime config identity does not match Factory Droid agent %s: %s", - agentID, - configPath, - ) - } - return nil -} - -func validDroidContractPaths(contract droidRuntimeContract) (bool, error) { - runtimeRoot, err := AgentRuntimeRoot() - if err != nil { - return false, err - } - agentDirectory := filepath.Dir(contract.guardPath) - return sameDroidPath(filepath.Dir(agentDirectory), runtimeRoot) && - sameDroidPath( - contract.guardPath, - filepath.Join(agentDirectory, droidGuardScript), - ) && - sameDroidPath( - contract.auditPath, - filepath.Join(agentDirectory, droidAuditScript), - ), nil -} - -func droidRuntimeContractExists(contract droidRuntimeContract) (bool, error) { - validPaths, err := validDroidContractPaths(contract) - if err != nil || !validPaths { - return false, err - } - runtimeRoot, err := AgentRuntimeRoot() - if err != nil { - return false, err - } - agentDirectory := filepath.Dir(contract.guardPath) - rootExists, err := managedPhysicalDirectoryExists( - runtimeRoot, - "Elydora runtime directory", - ) - if err != nil || !rootExists { - return false, err - } - directoryExists, err := managedPhysicalDirectoryExists( - agentDirectory, - "Elydora agent runtime directory", - ) - if err != nil || !directoryExists { - return false, err - } - configPath := filepath.Join(agentDirectory, "config.json") - keyPath := filepath.Join(agentDirectory, "private.key") - config, configExists, err := readDroidRuntimeConfig(configPath) - if err != nil { - return false, err - } - key, err := readManagedFile(keyPath, "Elydora private key", maxProtectedSecretBytes) - if err != nil { - return false, err - } - guard, err := readManagedFile(contract.guardPath, "Elydora guard runtime", maxManagedSourceBytes) - if err != nil { - return false, err - } - audit, err := readManagedFile(contract.auditPath, "Elydora audit runtime", maxManagedSourceBytes) - if err != nil { - return false, err - } - if !configExists || key == nil || guard == nil || audit == nil { - return false, nil - } - if err := validateDroidRuntimeConfig(config, contract.agentID, configPath); err != nil { - return false, err - } - if err := validateManagedPrivateKey(string(key.contents)); err != nil { - return false, fmt.Errorf("Elydora private key at %s: %w", keyPath, err) - } - expectedGuard := []byte(generateGuardScript(droidAgentKey, contract.agentID, "", false, "")) - expectedAudit := []byte(buildHookScriptWithOutput( - droidAgentKey, - contract.agentID, - "", - false, - true, - )) - return bytes.Equal(guard.contents, expectedGuard) && - bytes.Equal(audit.contents, expectedAudit), nil -} - -func droidRuntimeFilesExist(contracts []droidRuntimeContract) (bool, error) { - for _, contract := range contracts { - exists, err := droidRuntimeContractExists(contract) - if err != nil { - return false, err - } - if exists { - return true, nil - } - } - return false, nil +func droidRuntimeFilesExist(contracts []managedRuntimeContract) (bool, error) { + return managedRuntimeFilesExist(contracts, droidAgentKey, "Factory Droid", droidExpectedScripts) } diff --git a/sdks/go/cmd/elydora/plugins/droid_test.go b/sdks/go/cmd/elydora/plugins/droid_test.go index 0c7df37..8d27dfa 100644 --- a/sdks/go/cmd/elydora/plugins/droid_test.go +++ b/sdks/go/cmd/elydora/plugins/droid_test.go @@ -57,7 +57,7 @@ func TestDroidInstallWritesCurrentContainerAndCompleteRuntime(t *testing.T) { requireDroidNativeGroup(t, auditGroup) guardCommand := droidManagedHandler(t, hooks["PreToolUse"], fixture.guardPath)["command"].(string) if runtime.GOOS == "windows" && - (!strings.HasPrefix(guardCommand, "& '") || !strings.HasSuffix(guardCommand, droidWindowsExitSuffix)) { + (!strings.HasPrefix(guardCommand, "& '") || !strings.HasSuffix(guardCommand, powerShellExitSuffix)) { t.Fatalf("Windows Factory command = %q", guardCommand) } runtimeConfig := readDroidTestObject(t, fixture.runtimeConfig) diff --git a/sdks/go/cmd/elydora/plugins/droid_test_helpers_test.go b/sdks/go/cmd/elydora/plugins/droid_test_helpers_test.go index b6c55d4..2fcdb43 100644 --- a/sdks/go/cmd/elydora/plugins/droid_test_helpers_test.go +++ b/sdks/go/cmd/elydora/plugins/droid_test_helpers_test.go @@ -237,7 +237,7 @@ func droidManagedGroup(t *testing.T, groupsValue any, scriptPath string) map[str handler := requireDroidObject(t, handlerValue) command, _ := handler["command"].(string) _, configuredPath, managed := parseDroidCommand(command, true) - if managed && sameDroidPath(configuredPath, scriptPath) { + if managed && sameManagedPath(configuredPath, scriptPath) { return group } } @@ -253,7 +253,7 @@ func droidManagedHandler(t *testing.T, groupsValue any, scriptPath string) map[s handler := requireDroidObject(t, handlerValue) command, _ := handler["command"].(string) _, configuredPath, managed := parseDroidCommand(command, true) - if managed && sameDroidPath(configuredPath, scriptPath) { + if managed && sameManagedPath(configuredPath, scriptPath) { return handler } } @@ -344,3 +344,12 @@ func requireNoDroidStagingFiles(t *testing.T, root string) { t.Fatalf("walk staging files: %v", err) } } + +func standardizeJSONC(source []byte, label string, allowTrailingCommas bool) ([]byte, error) { + value, err := parseJSONC(source, label, allowTrailingCommas) + if err != nil { + return nil, err + } + value.Standardize() + return value.Pack(), nil +} diff --git a/sdks/go/cmd/elydora/plugins/droid_transaction_test.go b/sdks/go/cmd/elydora/plugins/droid_transaction_test.go index 136ed4d..3074da8 100644 --- a/sdks/go/cmd/elydora/plugins/droid_transaction_test.go +++ b/sdks/go/cmd/elydora/plugins/droid_transaction_test.go @@ -53,7 +53,7 @@ func TestDroidInstallRollsBackRuntimeAfterLateHookCommitFailure(t *testing.T) { prepared := prepareDroidTestInstallation(t, fixture, fixture.config) failed := false rename := func(source, destination string) error { - if !failed && sameDroidPath(destination, fixture.configPath) { + if !failed && sameManagedPath(destination, fixture.configPath) { failed = true return errors.New("injected Droid hook commit failure") } @@ -85,7 +85,7 @@ func TestDroidConcurrentActiveHookReplacementPreventsRuntimeCommit(t *testing.T) t.Fatalf("concurrent source error = %v", err) } for path, source := range before { - if sameDroidPath(path, fixture.configPath) { + if sameManagedPath(path, fixture.configPath) { continue } if readDroidTestFile(t, path) != source { @@ -205,7 +205,7 @@ func TestDroidSameContentReplacementIsRejectedByPhysicalIdentity(t *testing.T) { t.Fatal("same-content replacement changed") } for path, source := range before { - if !sameDroidPath(path, fixture.configPath) && readDroidTestFile(t, path) != source { + if !sameManagedPath(path, fixture.configPath) && readDroidTestFile(t, path) != source { t.Fatalf("runtime changed after inode replacement: %s", path) } } @@ -226,7 +226,7 @@ func TestDroidUninstallRollsBackAllHookDocuments(t *testing.T) { if err != nil { t.Fatalf("read uninstall sources: %v", err) } - runtimeRoot, err := droidRuntimeRoot() + runtimeRoot, err := AgentRuntimeRoot() if err != nil { t.Fatalf("resolve runtime root: %v", err) } @@ -240,7 +240,7 @@ func TestDroidUninstallRollsBackAllHookDocuments(t *testing.T) { } failed := false rename := func(source, destination string) error { - if !failed && sameDroidPath(destination, fixture.settingsPath) { + if !failed && sameManagedPath(destination, fixture.settingsPath) { failed = true return errors.New("injected Droid uninstall failure") } diff --git a/sdks/go/cmd/elydora/plugins/gemini.go b/sdks/go/cmd/elydora/plugins/gemini.go index 860a1da..e4e100d 100644 --- a/sdks/go/cmd/elydora/plugins/gemini.go +++ b/sdks/go/cmd/elydora/plugins/gemini.go @@ -10,8 +10,7 @@ type GeminiPlugin struct { rename renameFunc } -// ManagesGuardRuntime reports that Gemini commits both generated runtimes and -// the user settings document through one transaction. +// ManagesGuardRuntime reports that the guard is committed with the audit runtime. func (p *GeminiPlugin) ManagesGuardRuntime() bool { return true } @@ -92,13 +91,14 @@ func (p *GeminiPlugin) Install(config InstallConfig) error { if err != nil { return err } - if err := writeGeminiChanges( + if err := writeManagedChanges( changes, "Install Gemini CLI hooks", p.rename, paths.runtimeRoot, paths.agentDirectory, filepath.Dir(document.filePath), + "Gemini CLI configuration directory", ); err != nil { return err } @@ -120,13 +120,14 @@ func (p *GeminiPlugin) Uninstall(agentID string) error { if err != nil { return err } - return writeGeminiChanges( + return writeManagedChanges( []*fileChange{change}, "Uninstall Gemini CLI hooks", p.rename, "", "", filepath.Dir(document.filePath), + "Gemini CLI configuration directory", ) } diff --git a/sdks/go/cmd/elydora/plugins/gemini_command.go b/sdks/go/cmd/elydora/plugins/gemini_command.go index d7f4b62..2d86af5 100644 --- a/sdks/go/cmd/elydora/plugins/gemini_command.go +++ b/sdks/go/cmd/elydora/plugins/gemini_command.go @@ -7,83 +7,37 @@ import ( "strings" ) -type geminiRuntimeReference struct { - agentID string - scriptPath string -} - -func sameGeminiPath(left, right string) bool { - left = filepath.Clean(left) - right = filepath.Clean(right) - if absolute, err := filepath.Abs(left); err == nil { - left = absolute - } - if absolute, err := filepath.Abs(right); err == nil { - right = absolute - } - if runtime.GOOS == "windows" { - return strings.EqualFold(left, right) - } - return left == right -} - -func sameGeminiAgentID(left, right string) bool { - if runtime.GOOS == "windows" { - return strings.EqualFold(left, right) - } - return left == right -} - -func sameGeminiFileName(left, right string) bool { - if runtime.GOOS == "windows" { - return strings.EqualFold(left, right) - } - return left == right -} - -func isGeminiNodeExecutable(path string) bool { - name := filepath.Base(path) - return name == "node" || strings.EqualFold(name, "node.exe") -} +type geminiRuntimeReference = managedScriptReference func buildGeminiCommand(runtimePath, scriptPath string) (string, error) { if !filepath.IsAbs(runtimePath) || !filepath.IsAbs(scriptPath) { - return "", fmt.Errorf( - "Gemini CLI hook commands require absolute runtime and script paths", - ) + return "", fmt.Errorf("Gemini CLI hook commands require absolute runtime and script paths") } if runtime.GOOS != "windows" { - return quotePOSIXArgument(runtimePath) + " " + quotePOSIXArgument(scriptPath), nil + return posixSource(runtimePath, scriptPath), nil } - source := "& " + quoteCodexPowerShell(runtimePath) + " " + - quoteCodexPowerShell(scriptPath) + "; exit $LASTEXITCODE" - return "& " + quoteCodexPowerShell(codexPowerShellPath()) + - " -NoLogo -NoProfile -NonInteractive -EncodedCommand " + - encodeCodexPowerShell(source), nil + return "& " + quotePowerShellArgument(windowsPowerShellPath()) + powerShellEncodedFlags + + encodePowerShellSource(powerShellSource(runtimePath, scriptPath)), nil } func parseGeminiWindowsCommand(command string) (string, string, bool) { if !strings.HasPrefix(command, "& ") { return "", "", false } - powerShell, next, ok := readCodexPowerShellArgument(command, 2) - if !ok || !isAbsoluteWindowsPath(powerShell) || - !strings.EqualFold(windowsPathBase(powerShell), "powershell.exe") { + powerShell, next, ok := readPowerShellArgument(command, 2) + if !ok || !isPowerShellExecutable(powerShell) || + !strings.HasPrefix(command[next:], powerShellEncodedFlags) { return "", "", false } - prefix := " -NoLogo -NoProfile -NonInteractive -EncodedCommand " - if !strings.HasPrefix(command[next:], prefix) { - return "", "", false - } - source, ok := decodeCodexPowerShell(command[next+len(prefix):]) + source, ok := decodePowerShellSource(command[next+len(powerShellEncodedFlags):]) if !ok { return "", "", false } - return parseCodexPowerShellSource(source) + return parsePowerShellSource(source) } func parseGeminiCommand(command string) (string, string, bool) { - if runtimePath, scriptPath, ok := parseCodexPOSIXCommand(command); ok { + if runtimePath, scriptPath, ok := parsePOSIXCommand(command); ok { return runtimePath, scriptPath, true } return parseGeminiWindowsCommand(command) @@ -110,23 +64,10 @@ func geminiRuntimeReferenceForCommand( if !current && includeLegacy { runtimePath, scriptPath, legacy = parseLegacyGeminiCommand(command) } - validRuntime := (current && filepath.IsAbs(runtimePath) && - isGeminiNodeExecutable(runtimePath)) || (legacy && runtimePath == "node") - if !validRuntime || !filepath.IsAbs(scriptPath) || - !sameGeminiFileName(filepath.Base(scriptPath), scriptName) { + validRuntime := (current && filepath.IsAbs(runtimePath) && isNodeExecutable(runtimePath)) || + (legacy && runtimePath == "node") + if !validRuntime { return nil, nil } - agentDirectory := filepath.Dir(scriptPath) - runtimeRoot, err := AgentRuntimeRoot() - if err != nil { - return nil, err - } - if !sameGeminiPath(filepath.Dir(agentDirectory), runtimeRoot) { - return nil, nil - } - agentID := filepath.Base(agentDirectory) - if agentID == "" || agentID == "." || agentID == ".." { - return nil, nil - } - return &geminiRuntimeReference{agentID: agentID, scriptPath: scriptPath}, nil + return resolveManagedScript(scriptPath, scriptName) } diff --git a/sdks/go/cmd/elydora/plugins/gemini_contract.go b/sdks/go/cmd/elydora/plugins/gemini_contract.go index 74e9da7..62a4c0d 100644 --- a/sdks/go/cmd/elydora/plugins/gemini_contract.go +++ b/sdks/go/cmd/elydora/plugins/gemini_contract.go @@ -1,10 +1,7 @@ package plugins import ( - "path/filepath" - "runtime" "sort" - "strings" ) const ( @@ -27,11 +24,7 @@ type geminiManagedRemoval struct { removeGroup bool } -type geminiRuntimeContract struct { - agentID string - guardPath string - auditPath string -} +type geminiRuntimeContract = managedRuntimeContract func buildGeminiGroup(runtimePath, scriptPath, name string) (map[string]any, error) { command, err := buildGeminiCommand(runtimePath, scriptPath) @@ -151,7 +144,7 @@ func managedGeminiRemovals( return nil, err } if reference != nil && - (agentID == "" || sameGeminiAgentID(reference.agentID, agentID)) { + (agentID == "" || sameManagedAgentID(reference.agentID, agentID)) { indexes = append(indexes, handlerIndex) } } @@ -168,13 +161,6 @@ func managedGeminiRemovals( return removals, nil } -func geminiReferenceKey(agentID string) string { - if runtime.GOOS == "windows" { - return strings.ToLower(agentID) - } - return agentID -} - func geminiReferencesForEvent( groups []any, scriptName string, @@ -196,7 +182,7 @@ func geminiReferencesForEvent( return nil, err } if reference != nil { - key := geminiReferenceKey(reference.agentID) + key := managedReferenceKey(reference.agentID) result[key] = append(result[key], *reference) } } @@ -243,11 +229,3 @@ func geminiRuntimeContracts( } return contracts, nil } - -func geminiRuntimeRoot() (string, error) { - root, err := AgentRuntimeRoot() - if err != nil { - return "", err - } - return filepath.Clean(root), nil -} diff --git a/sdks/go/cmd/elydora/plugins/gemini_installation.go b/sdks/go/cmd/elydora/plugins/gemini_installation.go index 2b99479..f519740 100644 --- a/sdks/go/cmd/elydora/plugins/gemini_installation.go +++ b/sdks/go/cmd/elydora/plugins/gemini_installation.go @@ -1,190 +1,48 @@ package plugins -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "strings" -) - -type geminiRuntimePaths struct { - runtimeRoot string - agentDirectory string - configPath string - keyPath string - guardPath string - auditPath string -} - -func validateGeminiInstallConfig(config InstallConfig) error { - for _, field := range []struct{ name, value string }{ - {"agent name", config.AgentName}, - {"organization ID", config.OrgID}, - {"agent ID", config.AgentID}, - {"key ID", config.KID}, - {"private key", config.PrivateKey}, - {"base URL", config.BaseURL}, - {"guard script path", config.GuardScriptPath}, - } { - if field.value == "" { - return fmt.Errorf("%s is required", field.name) - } - } - if config.AgentName != geminiAgentKey { - return fmt.Errorf( - "Gemini CLI installation requires agent name %s", - geminiAgentKey, - ) - } - if strings.TrimSpace(config.OrgID) == "" { - return fmt.Errorf("organization ID is required") - } - if strings.TrimSpace(config.KID) == "" { - return fmt.Errorf("key ID is required") - } - if config.Token != "" && strings.TrimSpace(config.Token) == "" { - return fmt.Errorf("token must contain a non-whitespace value when provided") - } - if err := validateManagedPrivateKey(config.PrivateKey); err != nil { - return err - } - return validateManagedBaseURL(config.BaseURL) -} - -func geminiAgentPaths(config InstallConfig) (*geminiRuntimePaths, error) { - if err := validateGeminiInstallConfig(config); err != nil { - return nil, err - } - runtimeRoot, err := geminiRuntimeRoot() - if err != nil { - return nil, err - } - agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID) - if err != nil { - return nil, err - } - paths := &geminiRuntimePaths{ - runtimeRoot: runtimeRoot, agentDirectory: agentDirectory, - configPath: filepath.Join(agentDirectory, "config.json"), - keyPath: filepath.Join(agentDirectory, "private.key"), - guardPath: filepath.Join(agentDirectory, geminiGuardScript), - auditPath: filepath.Join(agentDirectory, geminiAuditScript), - } - if !filepath.IsAbs(config.GuardScriptPath) || - !sameGeminiPath(config.GuardScriptPath, paths.guardPath) { - return nil, fmt.Errorf( - "guard runtime must use the managed Elydora agent directory: %s", - paths.guardPath, - ) - } - if config.HookScript != "" && (!filepath.IsAbs(config.HookScript) || - !sameGeminiPath(config.HookScript, paths.auditPath)) { - return nil, fmt.Errorf( - "audit runtime must use the managed Elydora agent directory: %s", - paths.auditPath, - ) - } - return paths, nil -} +import "fmt" func preflightGeminiInstallation( config InstallConfig, document *geminiDocument, -) (*geminiRuntimePaths, string, error) { +) (*managedRuntimePaths, string, error) { if document == nil || document.filePath == "" { - return nil, "", fmt.Errorf( - "Gemini CLI installation requires a user settings document", - ) + return nil, "", fmt.Errorf("Gemini CLI installation requires a user settings document") } - paths, err := geminiAgentPaths(config) + if err := validateManagedInstallConfig(config, geminiAgentKey, "Gemini CLI"); err != nil { + return nil, "", err + } + paths, err := resolveManagedRuntimePaths(config, geminiGuardScript, geminiAuditScript) if err != nil { return nil, "", err } - if err := validateGeminiRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil { + if err := validateManagedRuntimeIdentity( + paths.agentDirectory, config.AgentID, geminiAgentKey, "Gemini CLI", + ); err != nil { return nil, "", err } - nodePath, err := resolveNodeRuntime() + nodePath, err := resolveAbsoluteNodeRuntime("Gemini CLI") if err != nil { return nil, "", err } - if !filepath.IsAbs(nodePath) || !isGeminiNodeExecutable(nodePath) { - return nil, "", fmt.Errorf( - "Gemini CLI hooks require an absolute Node.js executable path", - ) - } return paths, nodePath, nil } -func buildGeminiRuntimeConfig(config InstallConfig) ([]byte, error) { - value := map[string]any{ - "org_id": config.OrgID, "agent_id": config.AgentID, "kid": config.KID, - "base_url": config.BaseURL, "agent_name": geminiAgentKey, - } - if config.Token != "" { - value["token"] = config.Token - } - encoded, err := json.MarshalIndent(value, "", " ") - if err != nil { - return nil, fmt.Errorf("encode Elydora runtime config: %w", err) - } - encoded = append(encoded, '\n') - if len(encoded) > maxRuntimeConfigBytes { - return nil, fmt.Errorf( - "elydora runtime config exceeds %d bytes after JSON encoding", - maxRuntimeConfigBytes, - ) - } - return encoded, nil -} - func prepareGeminiInstallationChanges( config InstallConfig, - paths *geminiRuntimePaths, + paths *managedRuntimePaths, rendered *geminiRenderedDocument, ) ([]*fileChange, error) { - runtimeConfig, err := buildGeminiRuntimeConfig(config) + changes, err := managedRuntimeFileChanges( + config, + paths, + geminiAgentKey, + generateGuardScript(geminiAgentKey, config.AgentID, "{}\n", false, ""), + buildHookScriptWithOutput(geminiAgentKey, config.AgentID, "{}\n", false, true), + ) if err != nil { return nil, err } - items := []struct { - path, label string - content []byte - mode os.FileMode - }{ - { - paths.guardPath, "Elydora guard runtime", - []byte(generateGuardScript( - geminiAgentKey, - config.AgentID, - "{}\n", - false, - "", - )), - 0700, - }, - {paths.configPath, "Elydora runtime config", runtimeConfig, 0600}, - {paths.keyPath, "Elydora private key", []byte(config.PrivateKey), 0600}, - { - paths.auditPath, "Elydora audit runtime", - []byte(buildHookScriptWithOutput( - geminiAgentKey, - config.AgentID, - "{}\n", - false, - true, - )), - 0700, - }, - } - changes := make([]*fileChange, 0, len(items)+1) - for _, item := range items { - change, err := prepareFileChange(item.path, item.label, item.content, item.mode) - if err != nil { - return nil, err - } - changes = append(changes, change) - } documentChange, err := prepareRenderedGeminiChange(rendered) if err != nil { return nil, err diff --git a/sdks/go/cmd/elydora/plugins/gemini_io.go b/sdks/go/cmd/elydora/plugins/gemini_io.go index 422d514..b5a70d8 100644 --- a/sdks/go/cmd/elydora/plugins/gemini_io.go +++ b/sdks/go/cmd/elydora/plugins/gemini_io.go @@ -67,38 +67,3 @@ func prepareRenderedGeminiChange( rendered.remove, ) } - -func writeGeminiChanges( - changes []*fileChange, - label string, - rename renameFunc, - runtimeRoot string, - agentDirectory string, - settingsDirectory string, -) error { - hasChanges := false - for _, change := range changes { - if change != nil { - hasChanges = true - break - } - } - if !hasChanges { - return nil - } - if agentDirectory != "" { - if err := EnsurePrivateDirectory(runtimeRoot); err != nil { - return err - } - if err := EnsurePrivateDirectory(agentDirectory); err != nil { - return err - } - } - if err := ensureManagedDirectory( - settingsDirectory, - "Gemini CLI configuration directory", - ); err != nil { - return err - } - return writeChanges(changes, label, rename) -} diff --git a/sdks/go/cmd/elydora/plugins/gemini_security_test.go b/sdks/go/cmd/elydora/plugins/gemini_security_test.go index d740c79..0c5736a 100644 --- a/sdks/go/cmd/elydora/plugins/gemini_security_test.go +++ b/sdks/go/cmd/elydora/plugins/gemini_security_test.go @@ -164,10 +164,10 @@ func TestGeminiValidatesManagedRuntimeInputsBeforeWrites(t *testing.T) { }, "query parameters"}, {"guard path", func(f *geminiFixture) { f.config.GuardScriptPath = filepath.Join(f.homeDir, "outside", "guard.js") - }, "managed Elydora agent directory"}, + }, "managed agent directory"}, {"audit path", func(f *geminiFixture) { f.config.HookScript = filepath.Join(f.homeDir, "outside", "hook.js") - }, "managed Elydora agent directory"}, + }, "managed agent directory"}, } { t.Run(testCase.name, func(t *testing.T) { fixture := prepareGeminiFixture(t, geminiFixtureOptions{}) diff --git a/sdks/go/cmd/elydora/plugins/gemini_status.go b/sdks/go/cmd/elydora/plugins/gemini_status.go index 1650cf6..db5808b 100644 --- a/sdks/go/cmd/elydora/plugins/gemini_status.go +++ b/sdks/go/cmd/elydora/plugins/gemini_status.go @@ -1,231 +1,5 @@ package plugins -import ( - "fmt" - "path/filepath" - "sort" - "strings" -) - -func readGeminiRuntimeConfig(path string) (map[string]any, bool, error) { - snapshot, err := readManagedFile(path, "Elydora runtime config", maxRuntimeConfigBytes) - if err != nil || snapshot == nil { - return nil, snapshot != nil, err - } - label := fmt.Sprintf("Elydora runtime config at %s", path) - config, err := decodeStrictJSONObject(snapshot.contents, label) - return config, true, err -} - -func requireGeminiRuntimeString( - config map[string]any, - field string, - configPath string, -) (string, error) { - value, ok := config[field].(string) - if !ok || strings.TrimSpace(value) == "" { - return "", fmt.Errorf( - "elydora runtime config %s is invalid: %s", - field, - configPath, - ) - } - return value, nil -} - -func validateGeminiRuntimeConfig( - config map[string]any, - expectedAgentID string, - configPath string, -) error { - supported := stringSet( - "org_id", "agent_id", "kid", "base_url", "token", "agent_name", - ) - fields := make([]string, 0, len(config)) - for field := range config { - fields = append(fields, field) - } - sort.Strings(fields) - for _, field := range fields { - if _, ok := supported[field]; !ok { - return fmt.Errorf( - `elydora runtime config has unsupported field %q: %s`, - field, - configPath, - ) - } - } - if _, err := requireGeminiRuntimeString(config, "org_id", configPath); err != nil { - return err - } - if _, err := requireGeminiRuntimeString(config, "kid", configPath); err != nil { - return err - } - agentID, err := requireGeminiRuntimeString(config, "agent_id", configPath) - if err != nil { - return err - } - if !sameGeminiAgentID(agentID, expectedAgentID) || - config["agent_name"] != geminiAgentKey { - return fmt.Errorf( - "elydora runtime identity does not match Gemini CLI hooks: %s", - configPath, - ) - } - if _, exists := config["token"]; exists { - if _, err := requireGeminiRuntimeString(config, "token", configPath); err != nil { - return err - } - } - baseURL, err := requireGeminiRuntimeString(config, "base_url", configPath) - if err != nil { - return err - } - if err := validateManagedBaseURL(baseURL); err != nil { - return fmt.Errorf( - "elydora runtime config base URL is invalid at %s: %w", - configPath, - err, - ) - } - return nil -} - -func validateGeminiRuntimeIdentity(agentDirectory, agentID string) error { - runtimeRoot := filepath.Dir(agentDirectory) - rootExists, err := managedPhysicalDirectoryExists( - runtimeRoot, - "Elydora runtime directory", - ) - if err != nil || !rootExists { - return err - } - directoryExists, err := managedPhysicalDirectoryExists( - agentDirectory, - "Elydora agent runtime directory", - ) - if err != nil || !directoryExists { - return err - } - configPath := filepath.Join(agentDirectory, "config.json") - config, configExists, err := readGeminiRuntimeConfig(configPath) - if err != nil { - return err - } - artifactExists := false - for _, item := range []struct { - path string - label string - limit int64 - }{ - {filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes}, - {filepath.Join(agentDirectory, geminiGuardScript), "Elydora guard runtime", maxManagedSourceBytes}, - {filepath.Join(agentDirectory, geminiAuditScript), "Elydora audit runtime", maxManagedSourceBytes}, - {filepath.Join(agentDirectory, "chain-state.json"), "Elydora chain state", maxRuntimeConfigBytes}, - {filepath.Join(agentDirectory, "status-cache.json"), "Elydora status cache", maxRuntimeConfigBytes}, - {filepath.Join(agentDirectory, "error.log"), "Elydora error log", maxManagedSourceBytes}, - } { - exists, inspectErr := managedPhysicalFileExists(item.path, item.label, item.limit) - if inspectErr != nil { - return inspectErr - } - artifactExists = artifactExists || exists - } - if !configExists { - if artifactExists { - return fmt.Errorf( - "elydora runtime identity cannot be verified without config.json: %s", - agentDirectory, - ) - } - return nil - } - configuredID, ok := config["agent_id"].(string) - if !ok || config["agent_name"] != geminiAgentKey || - !sameGeminiAgentID(configuredID, agentID) { - return fmt.Errorf( - "elydora runtime config identity does not match Gemini CLI agent %s: %s", - agentID, - configPath, - ) - } - return nil -} - -func geminiRuntimeContractExists(contract geminiRuntimeContract) (bool, error) { - runtimeRoot, err := geminiRuntimeRoot() - if err != nil { - return false, err - } - agentDirectory := filepath.Dir(contract.guardPath) - if !sameGeminiPath(filepath.Dir(agentDirectory), runtimeRoot) || - !sameGeminiPath( - contract.auditPath, - filepath.Join(agentDirectory, geminiAuditScript), - ) { - return false, nil - } - rootExists, err := managedPhysicalDirectoryExists( - runtimeRoot, - "Elydora runtime directory", - ) - if err != nil || !rootExists { - return false, err - } - directoryExists, err := managedPhysicalDirectoryExists( - agentDirectory, - "Elydora agent runtime directory", - ) - if err != nil || !directoryExists { - return false, err - } - configPath := filepath.Join(agentDirectory, "config.json") - keyPath := filepath.Join(agentDirectory, "private.key") - config, configExists, err := readGeminiRuntimeConfig(configPath) - if err != nil { - return false, err - } - key, err := readManagedFile(keyPath, "Elydora private key", maxProtectedSecretBytes) - if err != nil { - return false, err - } - guard, err := readManagedFile( - contract.guardPath, - "Elydora guard runtime", - maxManagedSourceBytes, - ) - if err != nil { - return false, err - } - audit, err := readManagedFile( - contract.auditPath, - "Elydora audit runtime", - maxManagedSourceBytes, - ) - if err != nil { - return false, err - } - if !configExists || key == nil || guard == nil || audit == nil { - return false, nil - } - if err := validateGeminiRuntimeConfig(config, contract.agentID, configPath); err != nil { - return false, err - } - if err := validateManagedPrivateKey(string(key.contents)); err != nil { - return false, fmt.Errorf("elydora private key at %s: %w", keyPath, err) - } - return len(guard.contents) > 0 && len(audit.contents) > 0, nil -} - -func geminiRuntimeFilesExist(contracts []geminiRuntimeContract) (bool, error) { - for _, contract := range contracts { - exists, err := geminiRuntimeContractExists(contract) - if err != nil { - return false, err - } - if exists { - return true, nil - } - } - return false, nil +func geminiRuntimeFilesExist(contracts []managedRuntimeContract) (bool, error) { + return managedRuntimeFilesExist(contracts, geminiAgentKey, "Gemini CLI", nil) } diff --git a/sdks/go/cmd/elydora/plugins/gemini_test.go b/sdks/go/cmd/elydora/plugins/gemini_test.go index 919e48b..addedf1 100644 --- a/sdks/go/cmd/elydora/plugins/gemini_test.go +++ b/sdks/go/cmd/elydora/plugins/gemini_test.go @@ -33,8 +33,8 @@ func TestGeminiCommandRoundTripAndRejectsInjection(t *testing.T) { t.Fatalf("build Gemini command: %v", err) } executable, script, ok := parseGeminiCommand(command) - if !ok || !sameGeminiPath(executable, nodePath) || - !sameGeminiPath(script, fixture.guardPath) { + if !ok || !sameManagedPath(executable, nodePath) || + !sameManagedPath(script, fixture.guardPath) { t.Fatalf("parsed Gemini command = %q, %q, %v", executable, script, ok) } if runtime.GOOS == "windows" && (strings.Contains(command, "%GEMINI_CWD%") || @@ -425,12 +425,12 @@ func TestGeminiRuntimeConfigOmitsEmptyOptionalToken(t *testing.T) { func TestGeminiAgentIDMatchingIsPlatformCorrect(t *testing.T) { if runtime.GOOS == "windows" { - if !sameGeminiAgentID("AGENT-1", "agent-1") { + if !sameManagedAgentID("AGENT-1", "agent-1") { t.Fatal("Windows Gemini agent IDs should compare case-insensitively") } return } - if sameGeminiAgentID("AGENT-1", "agent-1") { + if sameManagedAgentID("AGENT-1", "agent-1") { t.Fatal("POSIX Gemini agent IDs should preserve case") } } diff --git a/sdks/go/cmd/elydora/plugins/gemini_test_helpers_test.go b/sdks/go/cmd/elydora/plugins/gemini_test_helpers_test.go index e0dd1ee..c525bb3 100644 --- a/sdks/go/cmd/elydora/plugins/gemini_test_helpers_test.go +++ b/sdks/go/cmd/elydora/plugins/gemini_test_helpers_test.go @@ -251,7 +251,7 @@ func runGeminiRawCommand( var process *exec.Cmd if runtime.GOOS == "windows" { process = exec.Command( - codexPowerShellPath(), + windowsPowerShellPath(), "-NoLogo", "-NoProfile", "-NonInteractive", diff --git a/sdks/go/cmd/elydora/plugins/gemini_transaction_test.go b/sdks/go/cmd/elydora/plugins/gemini_transaction_test.go index 362f654..9bb55b2 100644 --- a/sdks/go/cmd/elydora/plugins/gemini_transaction_test.go +++ b/sdks/go/cmd/elydora/plugins/gemini_transaction_test.go @@ -35,7 +35,7 @@ func prepareGeminiTestChanges( t *testing.T, fixture *geminiFixture, document *geminiDocument, -) ([]*fileChange, *geminiRuntimePaths) { +) ([]*fileChange, *managedRuntimePaths) { t.Helper() paths, nodePath, err := preflightGeminiInstallation(fixture.config, document) if err != nil { @@ -110,7 +110,7 @@ func TestGeminiInstallRollsBackAllFiveFilesAfterSettingsFailure(t *testing.T) { fixture.config.OrgID = "org-updated" fixture.config.Token = "token-updated" fixture.plugin.rename = func(source, destination string) error { - if sameGeminiPath(destination, fixture.settingsPath) && + if sameManagedPath(destination, fixture.settingsPath) && strings.HasSuffix(source, ".tmp") { return errors.New("injected Gemini settings failure") } @@ -139,13 +139,14 @@ func TestPreparedGeminiInstallRejectsConcurrentSettingsChange(t *testing.T) { if err := os.WriteFile(fixture.settingsPath, concurrent, 0600); err != nil { t.Fatalf("write concurrent settings: %v", err) } - err = writeGeminiChanges( + err = writeManagedChanges( changes, "Install Gemini CLI hooks", nil, paths.runtimeRoot, paths.agentDirectory, filepath.Dir(document.filePath), + "Gemini CLI configuration directory", ) if err == nil || !strings.Contains(err.Error(), "changed during installation") { t.Fatalf("prepared install error = %v", err) @@ -204,7 +205,7 @@ func TestGeminiUninstallPreservesSettingsAfterCommitFailure(t *testing.T) { t.Fatalf("read installed Gemini settings: %v", err) } fixture.plugin.rename = func(source, destination string) error { - if sameGeminiPath(destination, fixture.settingsPath) && + if sameManagedPath(destination, fixture.settingsPath) && strings.HasSuffix(source, ".tmp") { return errors.New("injected Gemini uninstall failure") } diff --git a/sdks/go/cmd/elydora/plugins/grok.go b/sdks/go/cmd/elydora/plugins/grok.go index 6c4a6ae..093ab1b 100644 --- a/sdks/go/cmd/elydora/plugins/grok.go +++ b/sdks/go/cmd/elydora/plugins/grok.go @@ -36,11 +36,11 @@ func (p *GrokPlugin) Install(config InstallConfig) error { if err != nil { return err } - guardCommand, err := buildGrokCommand(nodePath, paths.guardPath) + guardCommand, err := buildEncodedCommand("Grok", nodePath, paths.guardPath) if err != nil { return err } - auditCommand, err := buildGrokCommand(nodePath, paths.auditPath) + auditCommand, err := buildEncodedCommand("Grok", nodePath, paths.auditPath) if err != nil { return err } diff --git a/sdks/go/cmd/elydora/plugins/grok_command.go b/sdks/go/cmd/elydora/plugins/grok_command.go index 80f8a70..b28c48d 100644 --- a/sdks/go/cmd/elydora/plugins/grok_command.go +++ b/sdks/go/cmd/elydora/plugins/grok_command.go @@ -1,132 +1,21 @@ package plugins -import ( - "fmt" - "path/filepath" - "runtime" - "strings" -) - -type grokRuntimeReference struct { - agentID string - scriptPath string -} - -func sameGrokPath(left, right string) bool { - left = filepath.Clean(left) - right = filepath.Clean(right) - if absolute, err := filepath.Abs(left); err == nil { - left = absolute - } - if absolute, err := filepath.Abs(right); err == nil { - right = absolute - } - if runtime.GOOS == "windows" { - return strings.EqualFold(left, right) - } - return left == right -} - -func sameGrokAgentID(left, right string) bool { - if runtime.GOOS == "windows" { - return strings.EqualFold(left, right) - } - return left == right -} - -func buildGrokCommand(runtimePath, scriptPath string) (string, error) { - if !filepath.IsAbs(runtimePath) || !filepath.IsAbs(scriptPath) { - return "", fmt.Errorf( - "Grok hook commands require absolute runtime and script paths", - ) - } - if runtime.GOOS == "windows" { - return codexWindowsCommand(runtimePath, scriptPath), nil - } - return quotePOSIXArgument(runtimePath) + " " + quotePOSIXArgument(scriptPath), nil -} - -func quoteGrokLegacyWindowsArgument(value string) string { - return `"` + strings.ReplaceAll(value, `"`, `\"`) + `"` -} - -func readGrokLegacyWindowsArgument( - command string, - start int, -) (string, int, bool) { - if start >= len(command) || command[start] != '"' { - return "", start, false - } - var value strings.Builder - for index := start + 1; index < len(command); index++ { - if command[index] == '"' { - return value.String(), index + 1, true - } - if command[index] == '\r' || command[index] == '\n' { - return "", start, false - } - value.WriteByte(command[index]) - } - return "", start, false -} - -func parseGrokLegacyWindowsCommand(command string) (string, string, bool) { - runtimePath, next, ok := readGrokLegacyWindowsArgument(command, 0) - if !ok || next >= len(command) || command[next] != ' ' { - return "", "", false - } - scriptPath, end, ok := readGrokLegacyWindowsArgument(command, next+1) - if !ok || end != len(command) { - return "", "", false - } - expected := quoteGrokLegacyWindowsArgument(runtimePath) + " " + - quoteGrokLegacyWindowsArgument(scriptPath) - return runtimePath, scriptPath, command == expected -} +import "path/filepath" func parseGrokCommand(command string) (string, string, bool) { - if runtimePath, scriptPath, ok := parseCodexPOSIXCommand(command); ok { + if runtimePath, scriptPath, ok := parseEncodedCommand(command); ok { return runtimePath, scriptPath, true } - if runtimePath, scriptPath, ok := parseCodexWindowsCommand(command); ok { - return runtimePath, scriptPath, true - } - return parseGrokLegacyWindowsCommand(command) -} - -func isGrokNodeExecutable(path string) bool { - name := filepath.Base(path) - return name == "node" || strings.EqualFold(name, "node.exe") + return parseQuotedWindowsCommand(command) } func grokRuntimeReferenceForCommand( command string, scriptName string, -) (*grokRuntimeReference, error) { +) (*managedScriptReference, error) { runtimePath, scriptPath, ok := parseGrokCommand(command) - if !ok || !filepath.IsAbs(runtimePath) || !filepath.IsAbs(scriptPath) || - !isGrokNodeExecutable(runtimePath) || - !sameGrokFileName(filepath.Base(scriptPath), scriptName) { + if !ok || !filepath.IsAbs(runtimePath) || !isNodeExecutable(runtimePath) { return nil, nil } - agentDirectory := filepath.Dir(scriptPath) - runtimeRoot, err := AgentRuntimeRoot() - if err != nil { - return nil, err - } - if !sameGrokPath(filepath.Dir(agentDirectory), runtimeRoot) { - return nil, nil - } - agentID := filepath.Base(agentDirectory) - if agentID == "" || agentID == "." || agentID == ".." { - return nil, nil - } - return &grokRuntimeReference{agentID: agentID, scriptPath: scriptPath}, nil -} - -func sameGrokFileName(left, right string) bool { - if runtime.GOOS == "windows" { - return strings.EqualFold(left, right) - } - return left == right + return resolveManagedScript(scriptPath, scriptName) } diff --git a/sdks/go/cmd/elydora/plugins/grok_contract.go b/sdks/go/cmd/elydora/plugins/grok_contract.go index dc5e145..0cb35f1 100644 --- a/sdks/go/cmd/elydora/plugins/grok_contract.go +++ b/sdks/go/cmd/elydora/plugins/grok_contract.go @@ -6,9 +6,7 @@ import ( "fmt" "math" "reflect" - "runtime" "sort" - "strings" ) const ( @@ -48,20 +46,6 @@ type grokRenderedDocument struct { remove bool } -type grokRuntimeContract struct { - agentID string - guardPath string - auditPath string -} - -func cloneGrokObject(value map[string]any) map[string]any { - clone := make(map[string]any, len(value)) - for key, item := range value { - clone[key] = item - } - return clone -} - func cloneGrokHooks(source grokHooks) grokHooks { clone := make(grokHooks, len(source)) for event, groups := range source { @@ -128,7 +112,7 @@ func validateGrokHandler( } } } - return cloneGrokObject(handler), nil + return cloneJSONObject(handler), nil } func validateGrokGroup(value any, event string, groupIndex int) (grokGroup, error) { @@ -166,7 +150,7 @@ func validateGrokGroup(value any, event string, groupIndex int) (grokGroup, erro } handlers = append(handlers, handler) } - return grokGroup{object: cloneGrokObject(object), handlers: handlers}, nil + return grokGroup{object: cloneJSONObject(object), handlers: handlers}, nil } func readGrokHooks(root map[string]any) (grokHooks, error) { @@ -229,7 +213,7 @@ func renderGrokHooks(hooks grokHooks) map[string]any { for event, groups := range hooks { values := make([]any, 0, len(groups)) for _, group := range groups { - object := cloneGrokObject(group.object) + object := cloneJSONObject(group.object) handlers := make([]any, 0, len(group.handlers)) for _, handler := range group.handlers { handlers = append(handlers, handler) @@ -262,7 +246,7 @@ func exactManagedGrokGroup(group grokGroup) bool { func managedGrokReference( handler map[string]any, scriptName string, -) (*grokRuntimeReference, error) { +) (*managedScriptReference, error) { if len(handler) != 3 || handler["type"] != "command" || handler["timeout"] != grokHookTimeout { return nil, nil @@ -303,7 +287,7 @@ func removeManagedGrokGroups( return nil, err } remove := reference != nil && - (agentID == "" || sameGrokAgentID(reference.agentID, agentID)) + (agentID == "" || sameManagedAgentID(reference.agentID, agentID)) if !remove { kept = append(kept, handler) } @@ -386,7 +370,7 @@ func renderGrokDocument( document: document, changed: true, remove: true, }, nil } - root := cloneGrokObject(document.root) + root := cloneJSONObject(document.root) if len(hooks) == 0 { delete(root, "hooks") } else { @@ -407,18 +391,11 @@ func renderGrokDocument( }, nil } -func grokReferenceKey(agentID string) string { - if runtime.GOOS == "windows" { - return strings.ToLower(agentID) - } - return agentID -} - func grokReferencesForEvent( groups []grokGroup, scriptName string, -) (map[string][]grokRuntimeReference, error) { - result := map[string][]grokRuntimeReference{} +) (map[string][]managedScriptReference, error) { + result := map[string][]managedScriptReference{} for _, group := range groups { if !exactManagedGrokGroup(group) { continue @@ -431,14 +408,14 @@ func grokReferencesForEvent( if reference == nil { continue } - key := grokReferenceKey(reference.agentID) + key := managedReferenceKey(reference.agentID) result[key] = append(result[key], *reference) } } return result, nil } -func grokRuntimeContracts(hooks grokHooks) ([]grokRuntimeContract, error) { +func grokRuntimeContracts(hooks grokHooks) ([]managedRuntimeContract, error) { guards, err := grokReferencesForEvent(hooks["PreToolUse"], grokGuardScript) if err != nil { return nil, err @@ -459,16 +436,16 @@ func grokRuntimeContracts(hooks grokHooks) ([]grokRuntimeContract, error) { keys = append(keys, key) } sort.Strings(keys) - contracts := make([]grokRuntimeContract, 0, len(keys)) + contracts := make([]managedRuntimeContract, 0, len(keys)) for _, key := range keys { guard := guards[key] success := successes[key] failure := failures[key] if len(guard) != 1 || len(success) != 1 || len(failure) != 1 || - !sameGrokPath(success[0].scriptPath, failure[0].scriptPath) { + !sameManagedPath(success[0].scriptPath, failure[0].scriptPath) { continue } - contracts = append(contracts, grokRuntimeContract{ + contracts = append(contracts, managedRuntimeContract{ agentID: guard[0].agentID, guardPath: guard[0].scriptPath, auditPath: success[0].scriptPath, }) diff --git a/sdks/go/cmd/elydora/plugins/grok_installation.go b/sdks/go/cmd/elydora/plugins/grok_installation.go index 01bb50a..c4f37f4 100644 --- a/sdks/go/cmd/elydora/plugins/grok_installation.go +++ b/sdks/go/cmd/elydora/plugins/grok_installation.go @@ -1,102 +1,24 @@ package plugins -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "strings" -) - -type grokRuntimePaths struct { - runtimeRoot string - agentDirectory string - configPath string - keyPath string - guardPath string - auditPath string -} - -func validateGrokInstallConfig(config InstallConfig) error { - for _, field := range []struct{ name, value string }{ - {"agent name", config.AgentName}, - {"organization ID", config.OrgID}, - {"agent ID", config.AgentID}, - {"key ID", config.KID}, - {"private key", config.PrivateKey}, - {"base URL", config.BaseURL}, - {"guard script path", config.GuardScriptPath}, - } { - if field.value == "" { - return fmt.Errorf("%s is required", field.name) - } - } - if config.AgentName != grokAgentKey { - return fmt.Errorf("grok installation requires agent name %s", grokAgentKey) - } - if strings.TrimSpace(config.OrgID) == "" { - return fmt.Errorf("organization ID is required") - } - if strings.TrimSpace(config.KID) == "" { - return fmt.Errorf("key ID is required") - } - if config.Token != "" && strings.TrimSpace(config.Token) == "" { - return fmt.Errorf("token must contain a non-whitespace value when provided") - } - if err := validateManagedPrivateKey(config.PrivateKey); err != nil { - return err - } - return validateManagedBaseURL(config.BaseURL) -} - -func grokAgentPaths(config InstallConfig) (*grokRuntimePaths, error) { - if err := validateGrokInstallConfig(config); err != nil { - return nil, err - } - runtimeRoot, err := AgentRuntimeRoot() - if err != nil { - return nil, err - } - agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID) - if err != nil { - return nil, err - } - paths := &grokRuntimePaths{ - runtimeRoot: runtimeRoot, agentDirectory: agentDirectory, - configPath: filepath.Join(agentDirectory, "config.json"), - keyPath: filepath.Join(agentDirectory, "private.key"), - guardPath: filepath.Join(agentDirectory, grokGuardScript), - auditPath: filepath.Join(agentDirectory, grokAuditScript), - } - if !filepath.IsAbs(config.GuardScriptPath) || - !sameGrokPath(config.GuardScriptPath, paths.guardPath) { - return nil, fmt.Errorf( - "guard runtime must use the managed Elydora agent directory: %s", - paths.guardPath, - ) - } - if config.HookScript != "" && (!filepath.IsAbs(config.HookScript) || - !sameGrokPath(config.HookScript, paths.auditPath)) { - return nil, fmt.Errorf( - "audit runtime must use the managed Elydora agent directory: %s", - paths.auditPath, - ) - } - return paths, nil -} +import "fmt" func preflightGrokInstallation( config InstallConfig, document *grokDocument, -) (*grokRuntimePaths, string, error) { +) (*managedRuntimePaths, string, error) { if document == nil { return nil, "", fmt.Errorf("grok installation requires a hook document") } - paths, err := grokAgentPaths(config) + if err := validateManagedInstallConfig(config, grokAgentKey, "grok"); err != nil { + return nil, "", err + } + paths, err := resolveManagedRuntimePaths(config, grokGuardScript, grokAuditScript) if err != nil { return nil, "", err } - if err := validateGrokRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil { + if err := validateManagedRuntimeIdentity( + paths.agentDirectory, config.AgentID, grokAgentKey, "Grok", + ); err != nil { return nil, "", err } nodePath, err := resolveNodeRuntime() @@ -106,63 +28,23 @@ func preflightGrokInstallation( return paths, nodePath, nil } -func buildGrokRuntimeConfig(config InstallConfig) ([]byte, error) { - value := map[string]any{ - "org_id": config.OrgID, "agent_id": config.AgentID, "kid": config.KID, - "base_url": config.BaseURL, "agent_name": grokAgentKey, - } - if config.Token != "" { - value["token"] = config.Token - } - encoded, err := json.MarshalIndent(value, "", " ") - if err != nil { - return nil, fmt.Errorf("encode Elydora runtime config: %w", err) - } - encoded = append(encoded, '\n') - if len(encoded) > maxRuntimeConfigBytes { - return nil, fmt.Errorf( - "elydora runtime config exceeds %d bytes after JSON encoding", - maxRuntimeConfigBytes, - ) - } - return encoded, nil +func grokExpectedScripts(agentID string, _ map[string]any) ([]byte, []byte) { + return []byte(generateGuardScript(grokAgentKey, agentID, "", false, "grok")), + []byte(buildHookScriptWithOutput(grokAgentKey, agentID, "", false, true)) } func prepareGrokInstallationChanges( config InstallConfig, - paths *grokRuntimePaths, + paths *managedRuntimePaths, rendered *grokRenderedDocument, ) ([]*fileChange, error) { - runtimeConfig, err := buildGrokRuntimeConfig(config) + guardScript, auditScript := grokExpectedScripts(config.AgentID, nil) + changes, err := managedRuntimeFileChanges( + config, paths, grokAgentKey, string(guardScript), string(auditScript), + ) if err != nil { return nil, err } - items := []struct { - path, label string - content []byte - mode os.FileMode - }{ - { - paths.guardPath, "Elydora guard runtime", - []byte(generateGuardScript(grokAgentKey, config.AgentID, "", false, "grok")), 0700, - }, - {paths.configPath, "Elydora runtime config", runtimeConfig, 0600}, - {paths.keyPath, "Elydora private key", []byte(config.PrivateKey), 0600}, - { - paths.auditPath, "Elydora audit runtime", - []byte(buildHookScriptWithOutput( - grokAgentKey, config.AgentID, "", false, true, - )), 0700, - }, - } - changes := make([]*fileChange, 0, len(items)+1) - for _, item := range items { - change, err := prepareFileChange(item.path, item.label, item.content, item.mode) - if err != nil { - return nil, err - } - changes = append(changes, change) - } documentChange, err := prepareRenderedGrokChange(rendered) if err != nil { return nil, err diff --git a/sdks/go/cmd/elydora/plugins/grok_io.go b/sdks/go/cmd/elydora/plugins/grok_io.go index 0ae9323..9987b6a 100644 --- a/sdks/go/cmd/elydora/plugins/grok_io.go +++ b/sdks/go/cmd/elydora/plugins/grok_io.go @@ -85,32 +85,17 @@ func writeGrokChanges( agentDirectory string, hooksDirectory string, ) error { - hasChanges := false - for _, change := range changes { - if change != nil { - hasChanges = true - break - } - } - if !hasChanges { + if !hasFileChanges(changes) { return nil } - if agentDirectory != "" { - if err := EnsurePrivateDirectory(runtimeRoot); err != nil { - return err - } - if err := EnsurePrivateDirectory(agentDirectory); err != nil { - return err - } - } if err := ensureManagedDirectory( filepath.Dir(hooksDirectory), "Grok home directory", ); err != nil { return err } - if err := ensureManagedDirectory(hooksDirectory, "Grok hooks directory"); err != nil { - return err - } - return writeChanges(changes, label, rename) + return writeManagedChanges( + changes, label, rename, runtimeRoot, agentDirectory, + hooksDirectory, "Grok hooks directory", + ) } diff --git a/sdks/go/cmd/elydora/plugins/grok_security_test.go b/sdks/go/cmd/elydora/plugins/grok_security_test.go index 7f8b912..1ef7723 100644 --- a/sdks/go/cmd/elydora/plugins/grok_security_test.go +++ b/sdks/go/cmd/elydora/plugins/grok_security_test.go @@ -164,10 +164,10 @@ func TestGrokValidatesManagedRuntimeInputsBeforeWrites(t *testing.T) { }, "query parameters"}, {"guard path", func(f *grokFixture) { f.config.GuardScriptPath = filepath.Join(f.homeDir, "outside", "guard.js") - }, "managed Elydora agent directory"}, + }, "managed agent directory"}, {"audit path", func(f *grokFixture) { f.config.HookScript = filepath.Join(f.homeDir, "outside", "hook.js") - }, "managed Elydora agent directory"}, + }, "managed agent directory"}, } { t.Run(testCase.name, func(t *testing.T) { fixture := prepareGrokFixture(t, grokFixtureOptions{}) diff --git a/sdks/go/cmd/elydora/plugins/grok_status.go b/sdks/go/cmd/elydora/plugins/grok_status.go index abb696e..3c6cc19 100644 --- a/sdks/go/cmd/elydora/plugins/grok_status.go +++ b/sdks/go/cmd/elydora/plugins/grok_status.go @@ -1,232 +1,5 @@ package plugins -import ( - "fmt" - "path/filepath" - "sort" - "strings" -) - -func readGrokRuntimeConfig(path string) (map[string]any, bool, error) { - snapshot, err := readManagedFile(path, "Elydora runtime config", maxRuntimeConfigBytes) - if err != nil || snapshot == nil { - return nil, snapshot != nil, err - } - label := fmt.Sprintf("Elydora runtime config at %s", path) - config, err := decodeStrictJSONObject(snapshot.contents, label) - return config, true, err -} - -func requireGrokRuntimeString( - config map[string]any, - field string, - configPath string, -) (string, error) { - value, ok := config[field].(string) - if !ok || strings.TrimSpace(value) == "" { - return "", fmt.Errorf( - "elydora runtime config %s is invalid: %s", - field, - configPath, - ) - } - return value, nil -} - -func validateGrokRuntimeConfig( - config map[string]any, - expectedAgentID string, - configPath string, -) error { - supported := stringSet( - "org_id", "agent_id", "kid", "base_url", "token", "agent_name", - ) - fields := make([]string, 0, len(config)) - for field := range config { - fields = append(fields, field) - } - sort.Strings(fields) - for _, field := range fields { - if _, ok := supported[field]; !ok { - return fmt.Errorf( - `elydora runtime config has unsupported field %q: %s`, - field, - configPath, - ) - } - } - if _, err := requireGrokRuntimeString(config, "org_id", configPath); err != nil { - return err - } - if _, err := requireGrokRuntimeString(config, "kid", configPath); err != nil { - return err - } - agentID, err := requireGrokRuntimeString(config, "agent_id", configPath) - if err != nil { - return err - } - if !sameGrokAgentID(agentID, expectedAgentID) || - config["agent_name"] != grokAgentKey { - return fmt.Errorf( - "elydora runtime identity does not match Grok hooks: %s", - configPath, - ) - } - if _, exists := config["token"]; exists { - if _, err := requireGrokRuntimeString(config, "token", configPath); err != nil { - return err - } - } - baseURL, err := requireGrokRuntimeString(config, "base_url", configPath) - if err != nil { - return err - } - if err := validateManagedBaseURL(baseURL); err != nil { - return fmt.Errorf( - "elydora runtime config base URL is invalid at %s: %w", - configPath, - err, - ) - } - return nil -} - -func validateGrokRuntimeIdentity(agentDirectory, agentID string) error { - runtimeRoot := filepath.Dir(agentDirectory) - rootExists, err := managedPhysicalDirectoryExists( - runtimeRoot, - "Elydora runtime directory", - ) - if err != nil || !rootExists { - return err - } - directoryExists, err := managedPhysicalDirectoryExists( - agentDirectory, - "Elydora agent runtime directory", - ) - if err != nil || !directoryExists { - return err - } - configPath := filepath.Join(agentDirectory, "config.json") - config, configExists, err := readGrokRuntimeConfig(configPath) - if err != nil { - return err - } - artifactExists := false - for _, item := range []struct { - path string - label string - limit int64 - }{ - {filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes}, - {filepath.Join(agentDirectory, grokGuardScript), "Elydora guard runtime", maxManagedSourceBytes}, - {filepath.Join(agentDirectory, grokAuditScript), "Elydora audit runtime", maxManagedSourceBytes}, - {filepath.Join(agentDirectory, "chain-state.json"), "Elydora chain state", maxRuntimeConfigBytes}, - {filepath.Join(agentDirectory, "status-cache.json"), "Elydora status cache", maxRuntimeConfigBytes}, - {filepath.Join(agentDirectory, "error.log"), "Elydora error log", maxManagedSourceBytes}, - } { - exists, inspectErr := managedPhysicalFileExists(item.path, item.label, item.limit) - if inspectErr != nil { - return inspectErr - } - artifactExists = artifactExists || exists - } - if !configExists { - if artifactExists { - return fmt.Errorf( - "elydora runtime identity cannot be verified without config.json: %s", - agentDirectory, - ) - } - return nil - } - configuredID, ok := config["agent_id"].(string) - if !ok || config["agent_name"] != grokAgentKey || - !sameGrokAgentID(configuredID, agentID) { - return fmt.Errorf( - "elydora runtime config identity does not match Grok agent %s: %s", - agentID, - configPath, - ) - } - return nil -} - -func grokRuntimeContractExists(contract grokRuntimeContract) (bool, error) { - runtimeRoot, err := AgentRuntimeRoot() - if err != nil { - return false, err - } - agentDirectory := filepath.Dir(contract.guardPath) - if !sameGrokPath(filepath.Dir(agentDirectory), runtimeRoot) || - !sameGrokPath( - contract.auditPath, - filepath.Join(agentDirectory, grokAuditScript), - ) { - return false, nil - } - rootExists, err := managedPhysicalDirectoryExists( - runtimeRoot, - "Elydora runtime directory", - ) - if err != nil || !rootExists { - return false, err - } - directoryExists, err := managedPhysicalDirectoryExists( - agentDirectory, - "Elydora agent runtime directory", - ) - if err != nil || !directoryExists { - return false, err - } - - configPath := filepath.Join(agentDirectory, "config.json") - keyPath := filepath.Join(agentDirectory, "private.key") - config, configExists, err := readGrokRuntimeConfig(configPath) - if err != nil { - return false, err - } - key, err := readManagedFile(keyPath, "Elydora private key", maxProtectedSecretBytes) - if err != nil { - return false, err - } - guard, err := readManagedFile( - contract.guardPath, - "Elydora guard runtime", - maxManagedSourceBytes, - ) - if err != nil { - return false, err - } - audit, err := readManagedFile( - contract.auditPath, - "Elydora audit runtime", - maxManagedSourceBytes, - ) - if err != nil { - return false, err - } - if !configExists || key == nil || guard == nil || audit == nil { - return false, nil - } - if err := validateGrokRuntimeConfig(config, contract.agentID, configPath); err != nil { - return false, err - } - if err := validateManagedPrivateKey(string(key.contents)); err != nil { - return false, fmt.Errorf("elydora private key at %s: %w", keyPath, err) - } - return len(guard.contents) > 0 && len(audit.contents) > 0, nil -} - -func grokRuntimeFilesExist(contracts []grokRuntimeContract) (bool, error) { - for _, contract := range contracts { - exists, err := grokRuntimeContractExists(contract) - if err != nil { - return false, err - } - if exists { - return true, nil - } - } - return false, nil +func grokRuntimeFilesExist(contracts []managedRuntimeContract) (bool, error) { + return managedRuntimeFilesExist(contracts, grokAgentKey, "Grok", nil) } diff --git a/sdks/go/cmd/elydora/plugins/grok_test.go b/sdks/go/cmd/elydora/plugins/grok_test.go index 613e20d..c7b595e 100644 --- a/sdks/go/cmd/elydora/plugins/grok_test.go +++ b/sdks/go/cmd/elydora/plugins/grok_test.go @@ -27,22 +27,22 @@ func TestGrokCommandRoundTripAndRejectsInjection(t *testing.T) { if err != nil { t.Fatalf("resolve Node.js runtime: %v", err) } - command, err := buildGrokCommand(nodePath, fixture.guardPath) + command, err := buildEncodedCommand("Grok", nodePath, fixture.guardPath) if err != nil { t.Fatalf("build Grok command: %v", err) } executable, script, ok := parseGrokCommand(command) - if !ok || !sameGrokPath(executable, nodePath) || - !sameGrokPath(script, fixture.guardPath) { + if !ok || !sameManagedPath(executable, nodePath) || + !sameManagedPath(script, fixture.guardPath) { t.Fatalf("parsed Grok command = %q, %q, %v", executable, script, ok) } - if strings.Contains(command, "%ELYDORA_HOOK_PATH%") { + if runtime.GOOS == "windows" && strings.Contains(command, "%ELYDORA_HOOK_PATH%") { t.Fatalf("Grok command exposes an expandable path: %q", command) } - if _, err := buildGrokCommand("node", fixture.guardPath); err == nil { + if _, err := buildEncodedCommand("Grok", "node", fixture.guardPath); err == nil { t.Fatal("accepted a relative Grok runtime path") } - if _, err := buildGrokCommand(nodePath, "guard.js"); err == nil { + if _, err := buildEncodedCommand("Grok", nodePath, "guard.js"); err == nil { t.Fatal("accepted a relative Grok script path") } for _, invalid := range []string{ @@ -337,12 +337,12 @@ func TestGrokRuntimeConfigOmitsEmptyOptionalToken(t *testing.T) { func TestGrokWindowsAgentIDMatchingIsPlatformCorrect(t *testing.T) { if runtime.GOOS == "windows" { - if !sameGrokAgentID("AGENT-1", "agent-1") { + if !sameManagedAgentID("AGENT-1", "agent-1") { t.Fatal("Windows Grok agent IDs should compare case-insensitively") } return } - if sameGrokAgentID("AGENT-1", "agent-1") { + if sameManagedAgentID("AGENT-1", "agent-1") { t.Fatal("POSIX Grok agent IDs should preserve case") } } diff --git a/sdks/go/cmd/elydora/plugins/grok_test_helpers_test.go b/sdks/go/cmd/elydora/plugins/grok_test_helpers_test.go index 7b74cb8..2eb4dc4 100644 --- a/sdks/go/cmd/elydora/plugins/grok_test_helpers_test.go +++ b/sdks/go/cmd/elydora/plugins/grok_test_helpers_test.go @@ -275,8 +275,7 @@ func legacyGrokCommand(t *testing.T, scriptPath string) string { t.Fatalf("resolve Node.js runtime: %v", err) } if runtime.GOOS == "windows" { - return quoteGrokLegacyWindowsArgument(nodePath) + " " + - quoteGrokLegacyWindowsArgument(scriptPath) + return `"` + nodePath + `" "` + scriptPath + `"` } return quotePOSIXArgument(nodePath) + " " + quotePOSIXArgument(scriptPath) } diff --git a/sdks/go/cmd/elydora/plugins/grok_transaction_test.go b/sdks/go/cmd/elydora/plugins/grok_transaction_test.go index 7c600fa..cc9025f 100644 --- a/sdks/go/cmd/elydora/plugins/grok_transaction_test.go +++ b/sdks/go/cmd/elydora/plugins/grok_transaction_test.go @@ -59,7 +59,7 @@ func TestGrokInstallRollsBackFourRuntimesAfterConfigCommitFailure(t *testing.T) } before := snapshotGrokFiles(t, paths...) fixture.plugin.rename = func(source, destination string) error { - if sameGrokPath(destination, fixture.configPath) && + if sameManagedPath(destination, fixture.configPath) && strings.HasSuffix(source, ".tmp") { return errors.New("injected Grok config failure") } @@ -85,11 +85,11 @@ func TestPreparedGrokInstallRejectsConcurrentConfigChange(t *testing.T) { if err != nil { t.Fatalf("preflight Grok installation: %v", err) } - guardCommand, err := buildGrokCommand(nodePath, paths.guardPath) + guardCommand, err := buildEncodedCommand("Grok", nodePath, paths.guardPath) if err != nil { t.Fatalf("build guard command: %v", err) } - auditCommand, err := buildGrokCommand(nodePath, paths.auditPath) + auditCommand, err := buildEncodedCommand("Grok", nodePath, paths.auditPath) if err != nil { t.Fatalf("build audit command: %v", err) } @@ -179,7 +179,7 @@ func TestGrokUninstallPreservesConfigAfterCommitFailure(t *testing.T) { t.Fatalf("read installed Grok config: %v", err) } fixture.plugin.rename = func(source, destination string) error { - if sameGrokPath(destination, fixture.configPath) && + if sameManagedPath(destination, fixture.configPath) && strings.HasSuffix(source, ".tmp") { return errors.New("injected Grok uninstall failure") } diff --git a/sdks/go/cmd/elydora/plugins/guardtemplate.go b/sdks/go/cmd/elydora/plugins/guardtemplate.go index 9bc9349..8ad6e92 100644 --- a/sdks/go/cmd/elydora/plugins/guardtemplate.go +++ b/sdks/go/cmd/elydora/plugins/guardtemplate.go @@ -146,30 +146,8 @@ function writeStatusCache(status) { }); } -function resolveBaseURL(config) { - const value = config.base_url || 'https://api.elydora.com'; - if (typeof value !== 'string' || /[\\\u0000-\u0020]/.test(value)) { - throw new Error('Agent config base_url must be an absolute HTTP or HTTPS URL'); - } - let parsed; - try { - parsed = new URL(value); - } catch (error) { - throw new Error('Agent config base_url must be an absolute HTTP or HTTPS URL'); - } - if (!['http:', 'https:'].includes(parsed.protocol) || !parsed.hostname) { - throw new Error('Agent config base_url must be an absolute HTTP or HTTPS URL'); - } - if (parsed.username || parsed.password || parsed.search || parsed.hash) { - throw new Error( - 'Agent config base_url must exclude credentials, query parameters, and fragments', - ); - } - return value.replace(/\/+$/, ''); -} - async function resolveRemoteStatus(config) { - const baseURL = resolveBaseURL(config); + const baseURL = resolveConfigBaseURL(config); const token = config.token || ''; if (typeof token !== 'string') throw new Error('Agent config token must be a string'); const controller = new AbortController(); diff --git a/sdks/go/cmd/elydora/plugins/hooktemplate.go b/sdks/go/cmd/elydora/plugins/hooktemplate.go index ff82f21..33b34c9 100644 --- a/sdks/go/cmd/elydora/plugins/hooktemplate.go +++ b/sdks/go/cmd/elydora/plugins/hooktemplate.go @@ -13,10 +13,7 @@ func buildHookScriptWithOutput( return fmt.Sprintf(`#!/usr/bin/env node 'use strict'; -// --------------------------------------------------------------------------- -// Self-contained Elydora audit hook for: %s -// Generated by @elydora/sdk — DO NOT EDIT -// --------------------------------------------------------------------------- +// Elydora audit hook for %s. Generated by Elydora SDK. const crypto = require('node:crypto'); const fs = require('node:fs'); @@ -53,10 +50,6 @@ function parsePrivateKey(raw) { return value; } -// --------------------------------------------------------------------------- -// Embedded crypto (from @elydora/sdk src/crypto.ts + src/utils.ts) -// --------------------------------------------------------------------------- - function base64urlEncode(data) { const buf = Buffer.isBuffer(data) ? data : Buffer.from(data); return buf.toString('base64url'); @@ -158,10 +151,6 @@ function generateNonce() { return base64urlEncode(crypto.randomBytes(16)); } -// --------------------------------------------------------------------------- -// Chain state persistence (atomic writes) -// --------------------------------------------------------------------------- - function readChainState() { let raw; try { @@ -265,10 +254,6 @@ function advanceChainState(fromHash, toHash) { }); } -// --------------------------------------------------------------------------- -// Error logging -// --------------------------------------------------------------------------- - function logError(err) { try { const ts = new Date().toISOString(); @@ -281,32 +266,6 @@ function logError(err) { } } -function resolveAuditBaseURL(config) { - const value = config.base_url || 'https://api.elydora.com'; - if (typeof value !== 'string' || /[\\\u0000-\u0020]/.test(value)) { - throw new Error('Agent config base_url must be an absolute HTTP or HTTPS URL'); - } - let parsed; - try { - parsed = new URL(value); - } catch (error) { - throw new Error('Agent config base_url must be an absolute HTTP or HTTPS URL'); - } - if (!['http:', 'https:'].includes(parsed.protocol) || !parsed.hostname) { - throw new Error('Agent config base_url must be an absolute HTTP or HTTPS URL'); - } - if (parsed.username || parsed.password || parsed.search || parsed.hash) { - throw new Error( - 'Agent config base_url must exclude credentials, query parameters, and fragments', - ); - } - return value.replace(/\/+$/, ''); -} - -// --------------------------------------------------------------------------- -// Main hook logic -// --------------------------------------------------------------------------- - async function postOperation(baseUrl, token, eor, deadline) { const remaining = deadline - performance.now(); if (remaining <= 0) throw new Error('Audit API retry budget exhausted'); @@ -344,7 +303,6 @@ async function postOperation(baseUrl, token, eor, deadline) { async function main() { try { - // Read JSON from stdin const chunks = []; for await (const chunk of process.stdin) { chunks.push(chunk); @@ -380,7 +338,6 @@ async function main() { hookData.conversation_id || hookData.session_id || hookData.sessionId || hookData.session || hookData.taskId || 'unknown'; - // Read agent config and private key let config; let privateKey; try { @@ -408,7 +365,7 @@ async function main() { const orgId = config.org_id; const agentId = config.agent_id; const kid = config.kid; - const baseUrl = resolveAuditBaseURL(config); + const baseUrl = resolveConfigBaseURL(config); const token = config.token || ''; if (typeof orgId !== 'string' || !orgId || typeof agentId !== 'string' || !agentId || typeof kid !== 'string' || !kid) { @@ -416,7 +373,7 @@ async function main() { } if (typeof token !== 'string') throw new Error('Agent config token must be a string'); - // Build and submit, retrying from the server chain head on PREV_HASH_MISMATCH + // Retry from the server chain head on PREV_HASH_MISMATCH. const payload = NATIVE_PAYLOAD ? hookData : { tool_name: toolName, tool_input: toolInput, diff --git a/sdks/go/cmd/elydora/plugins/jsonc.go b/sdks/go/cmd/elydora/plugins/jsonc.go index 2931616..931f9ca 100644 --- a/sdks/go/cmd/elydora/plugins/jsonc.go +++ b/sdks/go/cmd/elydora/plugins/jsonc.go @@ -17,15 +17,6 @@ type jsoncEditor struct { label string } -func standardizeJSONC(source []byte, label string, allowTrailingCommas bool) ([]byte, error) { - value, err := parseJSONC(source, label, allowTrailingCommas) - if err != nil { - return nil, err - } - value.Standardize() - return value.Pack(), nil -} - func parseJSONC(source []byte, label string, allowTrailingCommas bool) (hujson.Value, error) { value, err := hujson.Parse(source) if err != nil { @@ -97,6 +88,16 @@ func rejectDuplicateJSONCKeys(value *hujson.Value, label, location string) error return nil } +func decodeStrictJSONObject(source []byte, label string) (map[string]any, error) { + if !json.Valid(source) { + var value any + if err := json.Unmarshal(source, &value); err != nil { + return nil, fmt.Errorf("parse %s: %w", label, err) + } + } + return decodeJSONCObject(source, label, false) +} + func decodeJSONCObject(source []byte, label string, allowTrailingCommas bool) (map[string]any, error) { value, err := parseJSONC(source, label, allowTrailingCommas) if err != nil { @@ -292,3 +293,11 @@ func jsonPointer(path []any) string { } return pointer.String() } + +func cloneJSONObject(value map[string]any) map[string]any { + clone := make(map[string]any, len(value)) + for key, item := range value { + clone[key] = item + } + return clone +} diff --git a/sdks/go/cmd/elydora/plugins/jsonutil.go b/sdks/go/cmd/elydora/plugins/jsonutil.go index 729b391..e83bde1 100644 --- a/sdks/go/cmd/elydora/plugins/jsonutil.go +++ b/sdks/go/cmd/elydora/plugins/jsonutil.go @@ -2,25 +2,13 @@ package plugins import ( "encoding/json" + "errors" "fmt" "os" "path/filepath" - "strings" ) -// expandHome replaces a leading ~ with the user's home directory. -func expandHome(p string) (string, error) { - if !strings.HasPrefix(p, "~") { - return p, nil - } - home, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("resolve home directory: %w", err) - } - return filepath.Join(home, p[1:]), nil -} - -// readJSONFile reads a JSON file into a map. Returns an empty map if the file does not exist. +// readJSONFile reads a JSON object; a missing file yields an empty map. func readJSONFile(path string) (map[string]interface{}, error) { data, err := os.ReadFile(path) if err != nil { @@ -53,7 +41,7 @@ func writeJSONFile(path string, data map[string]interface{}) error { return nil } -// hookScriptPath returns the default path for the hook script inside ~/.elydora//. +// hookScriptPath returns ~/.elydora//hook.js. func hookScriptPath(agentId string) (string, error) { agentDirectory, err := ResolveAgentRuntimeDirectory(agentId) if err != nil { @@ -62,7 +50,21 @@ func hookScriptPath(agentId string) (string, error) { return filepath.Join(agentDirectory, "hook.js"), nil } -// guardScriptPath returns the default path for the guard script inside ~/.elydora//. +// removeAgentScripts deletes hook.js and guard.js; missing files are fine. +func removeAgentScripts(agentID string) error { + for _, resolve := range []func(string) (string, error){hookScriptPath, guardScriptPath} { + path, err := resolve(agentID) + if err != nil { + return err + } + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("remove %s: %w", path, err) + } + } + return nil +} + +// guardScriptPath returns ~/.elydora//guard.js. func guardScriptPath(agentId string) (string, error) { agentDirectory, err := ResolveAgentRuntimeDirectory(agentId) if err != nil { diff --git a/sdks/go/cmd/elydora/plugins/kimi.go b/sdks/go/cmd/elydora/plugins/kimi.go index 2cf4f6c..6da1afe 100644 --- a/sdks/go/cmd/elydora/plugins/kimi.go +++ b/sdks/go/cmd/elydora/plugins/kimi.go @@ -34,11 +34,11 @@ func (p *KimiPlugin) Install(config InstallConfig) error { if err != nil { return err } - guardCommand, err := buildKimiCommand(nodePath, paths.guardPath) + guardCommand, err := buildEncodedCommand("kimi", nodePath, paths.guardPath) if err != nil { return err } - auditCommand, err := buildKimiCommand(nodePath, paths.auditPath) + auditCommand, err := buildEncodedCommand("kimi", nodePath, paths.auditPath) if err != nil { return err } @@ -70,12 +70,9 @@ func (p *KimiPlugin) Install(config InstallConfig) error { if err != nil { return err } - if err := writeKimiChanges( - changes, - "Install Kimi hooks", - p.rename, - paths.runtimeRoot, - paths.agentDirectory, + if err := writeManagedChanges( + changes, "Install Kimi hooks", p.rename, + paths.runtimeRoot, paths.agentDirectory, "", "", ); err != nil { return err } @@ -107,13 +104,7 @@ func (p *KimiPlugin) Uninstall(agentID string) error { if err != nil { return err } - return writeKimiChanges( - changes, - "Uninstall Kimi hooks", - p.rename, - "", - "", - ) + return writeManagedChanges(changes, "Uninstall Kimi hooks", p.rename, "", "", "", "") } func (p *KimiPlugin) Status() (PluginStatus, error) { diff --git a/sdks/go/cmd/elydora/plugins/kimi_command.go b/sdks/go/cmd/elydora/plugins/kimi_command.go index f894d59..eced803 100644 --- a/sdks/go/cmd/elydora/plugins/kimi_command.go +++ b/sdks/go/cmd/elydora/plugins/kimi_command.go @@ -1,49 +1,10 @@ package plugins import ( - "fmt" "path/filepath" - "runtime" "strings" ) -type kimiRuntimeReference struct { - agentID string - scriptPath string -} - -func sameKimiPath(left, right string) bool { - left = filepath.Clean(left) - right = filepath.Clean(right) - if absolute, err := filepath.Abs(left); err == nil { - left = absolute - } - if absolute, err := filepath.Abs(right); err == nil { - right = absolute - } - if runtime.GOOS == "windows" { - return strings.EqualFold(left, right) - } - return left == right -} - -func sameKimiAgentID(left, right string) bool { - if runtime.GOOS == "windows" { - return strings.EqualFold(left, right) - } - return left == right -} - -func buildKimiCommand(runtimePath, scriptPath string) (string, error) { - if !filepath.IsAbs(runtimePath) || !filepath.IsAbs(scriptPath) { - return "", fmt.Errorf("kimi hook commands require absolute runtime and script paths") - } - if runtime.GOOS == "windows" { - return codexWindowsCommand(runtimePath, scriptPath), nil - } - return quotePOSIXArgument(runtimePath) + " " + quotePOSIXArgument(scriptPath), nil -} - func readKimiLegacyWindowsArgument(command string, start int) (string, int, bool) { if start >= len(command) { return "", start, false @@ -66,6 +27,7 @@ func readKimiLegacyWindowsArgument(command string, start int) (string, int, bool return command[start:end], end, end > start } +// parseKimiLegacyWindowsCommand reads the pre-2.1 quoteWindowsArgument form. func parseKimiLegacyWindowsCommand(command string) (string, string, bool) { runtimePath, next, ok := readKimiLegacyWindowsArgument(command, 0) if !ok || next >= len(command) || command[next] != ' ' { @@ -80,40 +42,19 @@ func parseKimiLegacyWindowsCommand(command string) (string, string, bool) { } func parseKimiCommand(command string) (string, string, bool) { - if runtimePath, scriptPath, ok := parseCodexPOSIXCommand(command); ok { - return runtimePath, scriptPath, true - } - if runtimePath, scriptPath, ok := parseCodexWindowsCommand(command); ok { + if runtimePath, scriptPath, ok := parseEncodedCommand(command); ok { return runtimePath, scriptPath, true } return parseKimiLegacyWindowsCommand(command) } -func isKimiNodeExecutable(path string) bool { - name := filepath.Base(path) - return name == "node" || strings.EqualFold(name, "node.exe") -} - func kimiRuntimeReferenceForCommand( command string, scriptName string, -) (*kimiRuntimeReference, error) { +) (*managedScriptReference, error) { runtimePath, scriptPath, ok := parseKimiCommand(command) - if !ok || !filepath.IsAbs(runtimePath) || !filepath.IsAbs(scriptPath) || - !isKimiNodeExecutable(runtimePath) || filepath.Base(scriptPath) != scriptName { + if !ok || !filepath.IsAbs(runtimePath) || !isNodeExecutable(runtimePath) { return nil, nil } - agentDirectory := filepath.Dir(scriptPath) - runtimeRoot, err := AgentRuntimeRoot() - if err != nil { - return nil, err - } - if !sameKimiPath(filepath.Dir(agentDirectory), runtimeRoot) { - return nil, nil - } - agentID := filepath.Base(agentDirectory) - if agentID == "" || agentID == "." || agentID == ".." { - return nil, nil - } - return &kimiRuntimeReference{agentID: agentID, scriptPath: scriptPath}, nil + return resolveManagedScript(scriptPath, scriptName) } diff --git a/sdks/go/cmd/elydora/plugins/kimi_contract.go b/sdks/go/cmd/elydora/plugins/kimi_contract.go index 61e4883..ac699b0 100644 --- a/sdks/go/cmd/elydora/plugins/kimi_contract.go +++ b/sdks/go/cmd/elydora/plugins/kimi_contract.go @@ -2,9 +2,7 @@ package plugins import ( "fmt" - "runtime" "sort" - "strings" ) const ( @@ -54,20 +52,10 @@ type kimiHook struct { } type kimiRuntimeContract struct { - agentID string - guardPath string - auditPath string + managedRuntimeContract configPath string } -func stringSet(values ...string) map[string]struct{} { - result := make(map[string]struct{}, len(values)) - for _, value := range values { - result[value] = struct{}{} - } - return result -} - func mergeStringSets(sets ...map[string]struct{}) map[string]struct{} { result := map[string]struct{}{} for _, set := range sets { @@ -173,7 +161,7 @@ func managedKimiReference( hook kimiHook, event string, scriptName string, -) (*kimiRuntimeReference, error) { +) (*managedScriptReference, error) { if hook.fieldCount != 3 || hook.event != event || hook.matcher != nil || hook.timeout == nil || *hook.timeout != kimiHookTimeoutSeconds { return nil, nil @@ -196,7 +184,7 @@ func keptKimiHookIndices(hooks []kimiHook, agentID string) ([]int, error) { indices := make([]int, 0, len(hooks)) for index, hook := range hooks { event, scriptName, managedEvent := managedKimiEvent(hook.event) - var reference *kimiRuntimeReference + var reference *managedScriptReference var err error if managedEvent { reference, err = managedKimiReference(hook, event, scriptName) @@ -205,7 +193,7 @@ func keptKimiHookIndices(hooks []kimiHook, agentID string) ([]int, error) { } } remove := reference != nil && - (agentID == "" || sameKimiAgentID(reference.agentID, agentID)) + (agentID == "" || sameManagedAgentID(reference.agentID, agentID)) if !remove { indices = append(indices, index) } @@ -213,19 +201,12 @@ func keptKimiHookIndices(hooks []kimiHook, agentID string) ([]int, error) { return indices, nil } -func kimiReferenceKey(agentID string) string { - if runtime.GOOS == "windows" { - return strings.ToLower(agentID) - } - return agentID -} - func kimiReferencesForEvent( hooks []kimiHook, event string, scriptName string, -) (map[string][]kimiRuntimeReference, error) { - result := map[string][]kimiRuntimeReference{} +) (map[string][]managedScriptReference, error) { + result := map[string][]managedScriptReference{} for _, hook := range hooks { reference, err := managedKimiReference(hook, event, scriptName) if err != nil { @@ -234,7 +215,7 @@ func kimiReferencesForEvent( if reference == nil { continue } - key := kimiReferenceKey(reference.agentID) + key := managedReferenceKey(reference.agentID) result[key] = append(result[key], *reference) } return result, nil @@ -267,12 +248,15 @@ func kimiRuntimeContracts(documents []kimiDocument) ([]kimiRuntimeContract, erro success := successes[key] failure := failures[key] if len(guard) != 1 || len(success) != 1 || len(failure) != 1 || - !sameKimiPath(success[0].scriptPath, failure[0].scriptPath) { + !sameManagedPath(success[0].scriptPath, failure[0].scriptPath) { continue } contracts = append(contracts, kimiRuntimeContract{ - agentID: guard[0].agentID, guardPath: guard[0].scriptPath, - auditPath: success[0].scriptPath, configPath: document.contract.configPath, + managedRuntimeContract: managedRuntimeContract{ + agentID: guard[0].agentID, guardPath: guard[0].scriptPath, + auditPath: success[0].scriptPath, + }, + configPath: document.contract.configPath, }) } } diff --git a/sdks/go/cmd/elydora/plugins/kimi_installation.go b/sdks/go/cmd/elydora/plugins/kimi_installation.go index 0d6cbd6..a7ccf73 100644 --- a/sdks/go/cmd/elydora/plugins/kimi_installation.go +++ b/sdks/go/cmd/elydora/plugins/kimi_installation.go @@ -2,107 +2,32 @@ package plugins import ( "bytes" - "encoding/json" "fmt" - "os" - "path/filepath" "strings" ) -type kimiRuntimePaths struct { - runtimeRoot string - agentDirectory string - configPath string - keyPath string - guardPath string - auditPath string -} - type kimiRenderedDocument struct { document kimiDocument next []byte } -func validateKimiInstallConfig(config InstallConfig) error { - for _, field := range []struct{ name, value string }{ - {"agent name", config.AgentName}, - {"organization ID", config.OrgID}, - {"agent ID", config.AgentID}, - {"key ID", config.KID}, - {"private key", config.PrivateKey}, - {"base URL", config.BaseURL}, - {"guard script path", config.GuardScriptPath}, - } { - if field.value == "" { - return fmt.Errorf("%s is required", field.name) - } - } - if config.AgentName != kimiAgentKey { - return fmt.Errorf("kimi installation requires agent name %s", kimiAgentKey) - } - if strings.TrimSpace(config.OrgID) == "" { - return fmt.Errorf("organization ID is required") - } - if strings.TrimSpace(config.KID) == "" { - return fmt.Errorf("key ID is required") - } - if config.Token != "" && strings.TrimSpace(config.Token) == "" { - return fmt.Errorf("token must contain a non-whitespace value when provided") - } - if err := validateManagedPrivateKey(config.PrivateKey); err != nil { - return err - } - return validateManagedBaseURL(config.BaseURL) -} - -func kimiAgentPaths(config InstallConfig) (*kimiRuntimePaths, error) { - if err := validateKimiInstallConfig(config); err != nil { - return nil, err - } - runtimeRoot, err := AgentRuntimeRoot() - if err != nil { - return nil, err - } - agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID) - if err != nil { - return nil, err - } - paths := &kimiRuntimePaths{ - runtimeRoot: runtimeRoot, agentDirectory: agentDirectory, - configPath: filepath.Join(agentDirectory, "config.json"), - keyPath: filepath.Join(agentDirectory, "private.key"), - guardPath: filepath.Join(agentDirectory, kimiGuardScript), - auditPath: filepath.Join(agentDirectory, kimiAuditScript), - } - if !filepath.IsAbs(config.GuardScriptPath) || - !sameKimiPath(config.GuardScriptPath, paths.guardPath) { - return nil, fmt.Errorf( - "guard runtime must use the managed Elydora agent directory: %s", - paths.guardPath, - ) - } - if config.HookScript != "" && (!filepath.IsAbs(config.HookScript) || - !sameKimiPath(config.HookScript, paths.auditPath)) { - return nil, fmt.Errorf( - "audit runtime must use the managed Elydora agent directory: %s", - paths.auditPath, - ) - } - return paths, nil -} - func preflightKimiInstallation( config InstallConfig, documents []kimiDocument, -) (*kimiRuntimePaths, string, error) { +) (*managedRuntimePaths, string, error) { if len(documents) == 0 { return nil, "", fmt.Errorf("kimi installation requires at least one hook contract") } - paths, err := kimiAgentPaths(config) + if err := validateManagedInstallConfig(config, kimiAgentKey, "kimi"); err != nil { + return nil, "", err + } + paths, err := resolveManagedRuntimePaths(config, kimiGuardScript, kimiAuditScript) if err != nil { return nil, "", err } - if err := validateKimiRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil { + if err := validateManagedRuntimeIdentity( + paths.agentDirectory, config.AgentID, kimiAgentKey, "Kimi", + ); err != nil { return nil, "", err } nodePath, err := resolveNodeRuntime() @@ -112,26 +37,9 @@ func preflightKimiInstallation( return paths, nodePath, nil } -func buildKimiRuntimeConfig(config InstallConfig) ([]byte, error) { - value := map[string]any{ - "org_id": config.OrgID, "agent_id": config.AgentID, "kid": config.KID, - "base_url": config.BaseURL, "agent_name": kimiAgentKey, - } - if config.Token != "" { - value["token"] = config.Token - } - encoded, err := json.MarshalIndent(value, "", " ") - if err != nil { - return nil, fmt.Errorf("encode Elydora runtime config: %w", err) - } - encoded = append(encoded, '\n') - if len(encoded) > maxRuntimeConfigBytes { - return nil, fmt.Errorf( - "elydora runtime config exceeds %d bytes after JSON encoding", - maxRuntimeConfigBytes, - ) - } - return encoded, nil +func kimiExpectedScripts(agentID string, _ map[string]any) ([]byte, []byte) { + return []byte(generateGuardScript(kimiAgentKey, agentID, "", false, "")), + []byte(buildHookScriptWithOutput(kimiAgentKey, agentID, "", false, true)) } func renderKimiChange( @@ -172,47 +80,21 @@ func prepareRenderedKimiChange(rendered kimiRenderedDocument) (*fileChange, erro func prepareKimiInstallationChanges( config InstallConfig, - paths *kimiRuntimePaths, + paths *managedRuntimePaths, rendered []kimiRenderedDocument, ) ([]*fileChange, error) { - runtimeConfig, err := buildKimiRuntimeConfig(config) + guardScript, auditScript := kimiExpectedScripts(config.AgentID, nil) + changes, err := managedRuntimeFileChanges( + config, paths, kimiAgentKey, string(guardScript), string(auditScript), + ) if err != nil { return nil, err } - items := []struct { - path, label string - content []byte - mode os.FileMode - }{ - { - paths.guardPath, "Elydora guard runtime", - []byte(generateGuardScript(kimiAgentKey, config.AgentID, "", false, "")), 0700, - }, - {paths.configPath, "Elydora runtime config", runtimeConfig, 0600}, - {paths.keyPath, "Elydora private key", []byte(config.PrivateKey), 0600}, - { - paths.auditPath, "Elydora audit runtime", - []byte(buildHookScriptWithOutput( - kimiAgentKey, config.AgentID, "", false, true, - )), 0700, - }, + documentChanges, err := prepareKimiUninstallChanges(rendered) + if err != nil { + return nil, err } - changes := make([]*fileChange, 0, len(items)+len(rendered)) - for _, item := range items { - change, err := prepareFileChange(item.path, item.label, item.content, item.mode) - if err != nil { - return nil, err - } - changes = append(changes, change) - } - for _, document := range rendered { - change, err := prepareRenderedKimiChange(document) - if err != nil { - return nil, err - } - changes = append(changes, change) - } - return changes, nil + return append(changes, documentChanges...), nil } func prepareKimiUninstallChanges( @@ -228,31 +110,3 @@ func prepareKimiUninstallChanges( } return changes, nil } - -func writeKimiChanges( - changes []*fileChange, - label string, - rename renameFunc, - runtimeRoot string, - agentDirectory string, -) error { - hasChanges := false - for _, change := range changes { - if change != nil { - hasChanges = true - break - } - } - if !hasChanges { - return nil - } - if agentDirectory != "" { - if err := EnsurePrivateDirectory(runtimeRoot); err != nil { - return err - } - if err := EnsurePrivateDirectory(agentDirectory); err != nil { - return err - } - } - return writeChanges(changes, label, rename) -} diff --git a/sdks/go/cmd/elydora/plugins/kimi_io.go b/sdks/go/cmd/elydora/plugins/kimi_io.go index 77a308f..452ae23 100644 --- a/sdks/go/cmd/elydora/plugins/kimi_io.go +++ b/sdks/go/cmd/elydora/plugins/kimi_io.go @@ -66,7 +66,7 @@ func resolveKimiContracts() ([]kimiContract, error) { legacyHome := filepath.Join(home, ".kimi") stable := stableKimiContract(filepath.Join(stableHome, "config.toml")) legacy := legacyKimiContract(filepath.Join(legacyHome, "config.toml")) - if sameKimiPath(stable.configPath, legacy.configPath) { + if sameManagedPath(stable.configPath, legacy.configPath) { return []kimiContract{stable}, nil } diff --git a/sdks/go/cmd/elydora/plugins/kimi_status.go b/sdks/go/cmd/elydora/plugins/kimi_status.go index 626440e..404f081 100644 --- a/sdks/go/cmd/elydora/plugins/kimi_status.go +++ b/sdks/go/cmd/elydora/plugins/kimi_status.go @@ -1,194 +1,12 @@ package plugins -import ( - "fmt" - "path/filepath" - "sort" - "strings" -) - -func readKimiRuntimeConfig(path string) (map[string]any, bool, error) { - snapshot, err := readManagedFile(path, "Elydora runtime config", maxRuntimeConfigBytes) - if err != nil || snapshot == nil { - return nil, snapshot != nil, err - } - label := fmt.Sprintf("Elydora runtime config at %s", path) - config, err := decodeStrictJSONObject(snapshot.contents, label) - return config, true, err -} - -func requireKimiRuntimeString(config map[string]any, field, configPath string) (string, error) { - value, ok := config[field].(string) - if !ok || strings.TrimSpace(value) == "" { - return "", fmt.Errorf( - "elydora runtime config %s is invalid: %s", field, configPath, - ) - } - return value, nil -} - -func validateKimiRuntimeConfig( - config map[string]any, - expectedAgentID string, - configPath string, -) error { - supported := stringSet("org_id", "agent_id", "kid", "base_url", "token", "agent_name") - fields := make([]string, 0, len(config)) - for field := range config { - fields = append(fields, field) - } - sort.Strings(fields) - for _, field := range fields { - if _, ok := supported[field]; !ok { - return fmt.Errorf( - `elydora runtime config has unsupported field %q: %s`, field, configPath, - ) - } - } - if _, err := requireKimiRuntimeString(config, "org_id", configPath); err != nil { - return err - } - if _, err := requireKimiRuntimeString(config, "kid", configPath); err != nil { - return err - } - agentID, err := requireKimiRuntimeString(config, "agent_id", configPath) - if err != nil { - return err - } - if !sameKimiAgentID(agentID, expectedAgentID) || config["agent_name"] != kimiAgentKey { - return fmt.Errorf("elydora runtime identity does not match Kimi hooks: %s", configPath) - } - if _, exists := config["token"]; exists { - if _, err := requireKimiRuntimeString(config, "token", configPath); err != nil { - return err - } - } - baseURL, err := requireKimiRuntimeString(config, "base_url", configPath) - if err != nil { - return err - } - if err := validateManagedBaseURL(baseURL); err != nil { - return fmt.Errorf("elydora runtime config base URL is invalid at %s: %w", configPath, err) - } - return nil -} - -func validateKimiRuntimeIdentity(agentDirectory, agentID string) error { - runtimeRoot := filepath.Dir(agentDirectory) - rootExists, err := managedPhysicalDirectoryExists(runtimeRoot, "Elydora runtime directory") - if err != nil || !rootExists { - return err - } - directoryExists, err := managedPhysicalDirectoryExists( - agentDirectory, "Elydora agent runtime directory", - ) - if err != nil || !directoryExists { - return err - } - configPath := filepath.Join(agentDirectory, "config.json") - config, configExists, err := readKimiRuntimeConfig(configPath) - if err != nil { - return err - } - artifactExists := false - for _, item := range []struct { - path string - label string - limit int64 - }{ - {filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes}, - {filepath.Join(agentDirectory, kimiGuardScript), "Elydora guard runtime", maxManagedSourceBytes}, - {filepath.Join(agentDirectory, kimiAuditScript), "Elydora audit runtime", maxManagedSourceBytes}, - {filepath.Join(agentDirectory, "chain-state.json"), "Elydora chain state", maxRuntimeConfigBytes}, - {filepath.Join(agentDirectory, "status-cache.json"), "Elydora status cache", maxRuntimeConfigBytes}, - {filepath.Join(agentDirectory, "error.log"), "Elydora error log", maxManagedSourceBytes}, - } { - exists, inspectErr := managedPhysicalFileExists(item.path, item.label, item.limit) - if inspectErr != nil { - return inspectErr - } - artifactExists = artifactExists || exists - } - if !configExists { - if artifactExists { - return fmt.Errorf( - "elydora runtime identity cannot be verified without config.json: %s", - agentDirectory, - ) - } - return nil - } - configuredID, ok := config["agent_id"].(string) - if !ok || config["agent_name"] != kimiAgentKey || - !sameKimiAgentID(configuredID, agentID) { - return fmt.Errorf( - "elydora runtime config identity does not match Kimi agent %s: %s", - agentID, - configPath, - ) - } - return nil -} - -func kimiRuntimeContractExists(contract kimiRuntimeContract) (bool, error) { - runtimeRoot, err := AgentRuntimeRoot() - if err != nil { - return false, err - } - agentDirectory := filepath.Dir(contract.guardPath) - if !sameKimiPath(filepath.Dir(agentDirectory), runtimeRoot) || - !sameKimiPath(contract.auditPath, filepath.Join(agentDirectory, kimiAuditScript)) { - return false, nil - } - rootExists, err := managedPhysicalDirectoryExists(runtimeRoot, "Elydora runtime directory") - if err != nil || !rootExists { - return false, err - } - directoryExists, err := managedPhysicalDirectoryExists( - agentDirectory, "Elydora agent runtime directory", - ) - if err != nil || !directoryExists { - return false, err - } - - configPath := filepath.Join(agentDirectory, "config.json") - keyPath := filepath.Join(agentDirectory, "private.key") - config, configExists, err := readKimiRuntimeConfig(configPath) - if err != nil { - return false, err - } - key, err := readManagedFile(keyPath, "Elydora private key", maxProtectedSecretBytes) - if err != nil { - return false, err - } - guard, err := readManagedFile(contract.guardPath, "Elydora guard runtime", maxManagedSourceBytes) - if err != nil { - return false, err - } - audit, err := readManagedFile(contract.auditPath, "Elydora audit runtime", maxManagedSourceBytes) - if err != nil { - return false, err - } - if !configExists || key == nil || guard == nil || audit == nil { - return false, nil - } - if err := validateKimiRuntimeConfig(config, contract.agentID, configPath); err != nil { - return false, err - } - if err := validateManagedPrivateKey(string(key.contents)); err != nil { - return false, fmt.Errorf("elydora private key at %s: %w", keyPath, err) - } - return len(guard.contents) > 0 && len(audit.contents) > 0, nil -} - func kimiRuntimeFilesExist(contracts []kimiRuntimeContract) (bool, error) { for _, contract := range contracts { - exists, err := kimiRuntimeContractExists(contract) - if err != nil { - return false, err - } - if exists { - return true, nil + exists, err := managedRuntimeContractExists( + contract.managedRuntimeContract, kimiAgentKey, "Kimi", nil, + ) + if err != nil || exists { + return exists, err } } return false, nil diff --git a/sdks/go/cmd/elydora/plugins/kimi_transaction_test.go b/sdks/go/cmd/elydora/plugins/kimi_transaction_test.go index 22c999e..10c70f9 100644 --- a/sdks/go/cmd/elydora/plugins/kimi_transaction_test.go +++ b/sdks/go/cmd/elydora/plugins/kimi_transaction_test.go @@ -38,7 +38,7 @@ func TestKimiInstallRollsBackRuntimeAndBothConfigs(t *testing.T) { modernConfig: kimiString(string(stable)), legacyConfig: kimiString(string(legacy)), }) fixture.plugin.rename = func(source, destination string) error { - if sameKimiPath(destination, fixture.legacyPath) { + if sameManagedPath(destination, fixture.legacyPath) { return errors.New("injected legacy config failure") } return os.Rename(source, destination) @@ -160,11 +160,11 @@ func TestPreparedKimiInstallDetectsSourceChangeBeforeCommit(t *testing.T) { if err != nil { t.Fatalf("preflight Kimi installation: %v", err) } - guardCommand, err := buildKimiCommand(nodePath, paths.guardPath) + guardCommand, err := buildEncodedCommand("kimi", nodePath, paths.guardPath) if err != nil { t.Fatalf("build guard command: %v", err) } - auditCommand, err := buildKimiCommand(nodePath, paths.auditPath) + auditCommand, err := buildEncodedCommand("kimi", nodePath, paths.auditPath) if err != nil { t.Fatalf("build audit command: %v", err) } @@ -204,12 +204,9 @@ func TestPreparedKimiInstallDetectsSourceChangeBeforeCommit(t *testing.T) { t.Fatalf("write concurrent source: %v", err) } - err = writeKimiChanges( - changes, - "Install Kimi hooks", - nil, - paths.runtimeRoot, - paths.agentDirectory, + err = writeManagedChanges( + changes, "Install Kimi hooks", nil, + paths.runtimeRoot, paths.agentDirectory, "", "", ) if err == nil || !strings.Contains(err.Error(), "changed during installation") { t.Fatalf("prepared install error = %v", err) diff --git a/sdks/go/cmd/elydora/plugins/kirocli.go b/sdks/go/cmd/elydora/plugins/kirocli.go index d2c619e..2302ae3 100644 --- a/sdks/go/cmd/elydora/plugins/kirocli.go +++ b/sdks/go/cmd/elydora/plugins/kirocli.go @@ -79,7 +79,7 @@ func (p *KiroCliPlugin) Install(config InstallConfig) error { copyKiroObject(nextV2, v2Settings) nextV2["hooks"] = v2Hooks - nextV3 := cloneKiroObject(v3Settings) + nextV3 := cloneJSONObject(v3Settings) nextV3["version"] = "v1" v3Hooks := make([]any, 0, len(currentV3Hooks)+2) for _, hook := range currentV3Hooks { diff --git a/sdks/go/cmd/elydora/plugins/kirocli_contract.go b/sdks/go/cmd/elydora/plugins/kirocli_contract.go index 8dffe11..543b992 100644 --- a/sdks/go/cmd/elydora/plugins/kirocli_contract.go +++ b/sdks/go/cmd/elydora/plugins/kirocli_contract.go @@ -28,12 +28,6 @@ type kiroConfigMutation struct { remove bool } -func cloneKiroObject(value map[string]any) map[string]any { - clone := make(map[string]any, len(value)) - copyKiroObject(clone, value) - return clone -} - func copyKiroObject(destination, source map[string]any) { for key, value := range source { destination[key] = value @@ -49,7 +43,7 @@ func kiroHooksObject(settings map[string]any, label string) (map[string]any, err if !ok || hooks == nil { return nil, fmt.Errorf(`%s field "hooks" must be an object`, label) } - return cloneKiroObject(hooks), nil + return cloneJSONObject(hooks), nil } func kiroHookEntries(hooks map[string]any, event, label string) ([]map[string]any, error) { @@ -193,7 +187,7 @@ func prepareKiroV2Uninstall( } hooks["preToolUse"] = filteredPre hooks["postToolUse"] = filteredPost - next := cloneKiroObject(settings) + next := cloneJSONObject(settings) next["hooks"] = hooks mutation.value = next mutation.changed = true @@ -250,7 +244,7 @@ func prepareKiroV3Uninstall( if len(filtered) == len(hooks) { return mutation, nil } - next := cloneKiroObject(settings) + next := cloneJSONObject(settings) next["hooks"] = filtered owned := true for key := range settings { diff --git a/sdks/go/cmd/elydora/plugins/kirocli_io.go b/sdks/go/cmd/elydora/plugins/kirocli_io.go index 4133857..01422c3 100644 --- a/sdks/go/cmd/elydora/plugins/kirocli_io.go +++ b/sdks/go/cmd/elydora/plugins/kirocli_io.go @@ -3,7 +3,6 @@ package plugins import ( "fmt" "os" - "os/exec" "path/filepath" "runtime" "strings" @@ -18,18 +17,6 @@ func kiroConfigPaths() (string, string, error) { filepath.Join(home, ".kiro", "hooks", "elydora-audit.json"), nil } -func resolveNodeRuntime() (string, error) { - nodePath, err := exec.LookPath("node") - if err != nil { - return "", fmt.Errorf("resolve Node.js runtime: %w", err) - } - absolute, err := filepath.Abs(nodePath) - if err != nil { - return "", fmt.Errorf("resolve Node.js runtime path: %w", err) - } - return absolute, nil -} - func buildKiroCommand(runtimePath, scriptPath string) string { if runtime.GOOS == "windows" { return quoteWindowsArgument(runtimePath) + " " + quoteWindowsArgument(scriptPath) @@ -37,10 +24,6 @@ func buildKiroCommand(runtimePath, scriptPath string) string { return quotePOSIXArgument(runtimePath) + " " + quotePOSIXArgument(scriptPath) } -func quotePOSIXArgument(value string) string { - return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'" -} - func quoteWindowsArgument(value string) string { if value != "" && !strings.ContainsAny(value, " \t\n\v\"") { return value diff --git a/sdks/go/cmd/elydora/plugins/kiroide_command.go b/sdks/go/cmd/elydora/plugins/kiroide_command.go index b19dc64..a5acf15 100644 --- a/sdks/go/cmd/elydora/plugins/kiroide_command.go +++ b/sdks/go/cmd/elydora/plugins/kiroide_command.go @@ -1,101 +1,32 @@ package plugins import ( - "fmt" "path/filepath" - "runtime" "strings" ) -type kiroIdeRuntimeReference struct { - agentID string - scriptPath string -} - -func sameKiroIdePath(left, right string) bool { - left = filepath.Clean(left) - right = filepath.Clean(right) - if absolute, err := filepath.Abs(left); err == nil { - left = absolute - } - if absolute, err := filepath.Abs(right); err == nil { - right = absolute - } - if runtime.GOOS == "windows" { - return strings.EqualFold(left, right) - } - return left == right -} - -func sameKiroIdeAgentID(left, right string) bool { - if runtime.GOOS == "windows" { - return strings.EqualFold(left, right) - } - return left == right -} - -func sameKiroIdeFileName(left, right string) bool { - if runtime.GOOS == "windows" { - return strings.EqualFold(left, right) - } - return left == right -} - -func buildKiroIdeCommand(runtimePath, scriptPath string) (string, error) { - if !filepath.IsAbs(runtimePath) || !filepath.IsAbs(scriptPath) { - return "", fmt.Errorf( - "Kiro IDE hook commands require absolute runtime and script paths", - ) - } - if runtime.GOOS == "windows" { - return codexWindowsCommand(runtimePath, scriptPath), nil - } - return quotePOSIXArgument(runtimePath) + " " + quotePOSIXArgument(scriptPath), nil -} - -func parseKiroIdeCommand(command string) (string, string, bool) { - if runtime.GOOS == "windows" { - return parseCodexWindowsCommand(command) - } - return parseCodexPOSIXCommand(command) -} - func kiroIdeRuntimeReferenceForCommand( command string, scriptName string, -) (*kiroIdeRuntimeReference, error) { - runtimePath, scriptPath, ok := parseKiroIdeCommand(command) - if !ok || !filepath.IsAbs(runtimePath) || !filepath.IsAbs(scriptPath) || - !isCodexNodeExecutable(runtimePath) || - !sameKiroIdeFileName(filepath.Base(scriptPath), scriptName) { +) (*managedScriptReference, error) { + runtimePath, scriptPath, ok := parseEncodedCommand(command) + if !ok || !filepath.IsAbs(runtimePath) || !isNodeExecutable(runtimePath) { return nil, nil } - agentDirectory := filepath.Dir(scriptPath) - runtimeRoot, err := AgentRuntimeRoot() - if err != nil { - return nil, err - } - if !sameKiroIdePath(filepath.Dir(agentDirectory), runtimeRoot) { - return nil, nil - } - agentID := filepath.Base(agentDirectory) - if agentID == "" || agentID == "." || agentID == ".." { - return nil, nil - } - return &kiroIdeRuntimeReference{agentID: agentID, scriptPath: scriptPath}, nil + return resolveManagedScript(scriptPath, scriptName) } func currentKiroIdeReference( hook map[string]any, nodePath string, -) (*kiroIdeRuntimeReference, error) { +) (*managedScriptReference, error) { reference, err := managedKiroIdeReference(hook) if err != nil || reference == nil { return reference, err } action := hook["action"].(map[string]any) command := action["command"].(string) - expected, err := buildKiroIdeCommand(nodePath, reference.scriptPath) + expected, err := buildEncodedCommand("Kiro IDE", nodePath, reference.scriptPath) if err != nil { return nil, err } @@ -105,30 +36,14 @@ func currentKiroIdeReference( return reference, nil } +// legacyKiroIdeGoReference reads the pre-2.1 `node