From 4f996ed38a3603ac116cf06c33646cdd051d6afa Mon Sep 17 00:00:00 2001 From: cc-mac-mini Date: Wed, 13 May 2026 00:29:47 +0800 Subject: [PATCH] Harden staging E2E credential loading --- docs/bugfix/staging-e2e.md | 329 ++++++++++++++++++ dotnet/examples/e2e/Program.cs | 9 +- go/examples/e2e/main.go | 2 +- go/examples/free_service_smoke/main.go | 11 +- .../sdk/examples/ExampleSupport.java | 6 +- .../ai/synapsenetwork/sdk/SynapseClient.java | 5 + scripts/e2e/sdk_parity_e2e.sh | 72 ++-- scripts/e2e/sdk_wave1_local.sh | 62 +++- scripts/e2e/staging_env_loader.sh | 104 ++++++ typescript/examples/_shared.ts | 24 +- typescript/src/client.ts | 2 +- typescript/src/types.ts | 3 + typescript/tests/unit/client.test.ts | 53 +++ 13 files changed, 629 insertions(+), 53 deletions(-) create mode 100644 docs/bugfix/staging-e2e.md create mode 100644 scripts/e2e/staging_env_loader.sh diff --git a/docs/bugfix/staging-e2e.md b/docs/bugfix/staging-e2e.md new file mode 100644 index 0000000..5f4ad6d --- /dev/null +++ b/docs/bugfix/staging-e2e.md @@ -0,0 +1,329 @@ +--- +created_at: 2026-05-12 +updated_at: 2026-05-13 +doc_status: active +--- + +# Staging E2E Bug Log + +This log tracks staging SDK E2E readiness issues. Do not include private keys, +agent keys, JWTs, provider secrets, or full authorization headers. + +## BUG-STAGING-E2E-001 - Python E2E runner used system Python without SDK dependencies + +**Status:** FIXED +**Severity:** blocking staging runtime E2E +**SDK:** Python +**Scenario:** `fixed-price` startup + +### Command + +```bash +bash scripts/e2e/sdk_wave1_local.sh --languages python,typescript,go,java,dotnet --skip-install +``` + +### Symptom + +The Python example failed before calling staging: + +```text +ModuleNotFoundError: No module named 'requests' +``` + +### Root Cause + +`scripts/ci/python_checks.sh` installs SDK dependencies into `python/.venv`, but +`scripts/e2e/sdk_wave1_local.sh` launched Python examples with system `python3`. +On machines where system Python does not have the SDK dependencies, staging E2E +fails before exercising the Gateway. + +### Fix + +Updated the staging E2E shell harness to prefer `python/.venv/bin/python` +when launching Python examples and owner-auth helper snippets. This keeps live +examples on the same interpreter that `scripts/ci/python_checks.sh` prepares. + +### Verification + +```bash +bash scripts/e2e/sdk_wave1_local.sh --languages python --skip-install +``` + +Run id: `sdk-staging-python-fix-20260512-234710` + +The Python example advanced past imports and reached staging. It then failed on +credential validation, tracked separately below. + +--- + +## BUG-STAGING-E2E-002 - Staging Agent Key rejected by Gateway + +**Status:** FIXED +**Severity:** blocking staging runtime E2E +**SDK:** Python first, all runtime SDKs blocked by the same shared credential +**Scenario:** `fixed-price` + +### Command + +```bash +bash scripts/e2e/sdk_wave1_local.sh --languages python --skip-install +``` + +### Symptom + +The Python runtime E2E reached staging and passed early checks: + +```text +{"language":"python","scenario":"local-negative","status":"ok"} +{"language":"python","scenario":"health","status":"ok"} +{"language":"python","scenario":"auth-negative","status":"ok"} +``` + +The fixed-price invocation failed with: + +```text +AuthenticationError: Credential is invalid +``` + +### Root Cause + +The current `SYNAPSE_AGENT_KEY` in the execution environment is not accepted by +the staging Gateway. This is an environment credential blocker, not an SDK code +bug. No key material was logged. + +### Fix + +Added Secret Manager loading for staging E2E and changed the staging parity +flow to issue or rotate a short-lived runtime credential from +`SYNAPSE_OWNER_PRIVATE_KEY` by default. The stored +`synapse-staging-e2e-agent-credential` remains a fallback, but stale static +agent credentials no longer block the primary readiness path. + +### Verification + +```bash +bash scripts/e2e/sdk_wave1_local.sh --languages python,typescript,go,java,dotnet --free-only --skip-install +``` + +Run id: `sdk-staging-sm-free-all-20260513-001646` + +All five SDKs completed staging health, auth-negative, fixed-price invoke, and +receipt checks. + +--- + +## BUG-STAGING-E2E-003 - Owner/provider parity cannot run without owner key + +**Status:** FIXED +**Severity:** blocking staging owner/provider parity +**SDK:** Python, TypeScript, Go, Java, .NET +**Scenario:** `owner-provider-parity` + +### Command + +```bash +bash scripts/e2e/sdk_parity_e2e.sh --env staging --languages python,typescript,go,java,dotnet --skip-install +``` + +### Symptom + +The parity entrypoint exits before live owner auth: + +```text +[e2e:sdk-parity] SYNAPSE_OWNER_PRIVATE_KEY is required +``` + +Run id: `sdk-staging-parity-blocker-20260512-234741` + +### Root Cause + +`SYNAPSE_OWNER_PRIVATE_KEY` is not set in the execution environment. The +staging owner/provider readiness path requires owner wallet auth for challenge +signing, balance reads, usage logs, provider registration guide, and optional +temporary credential issuance. + +### Fix + +Added staging Secret Manager lookup for: + +- `synapse-staging-e2e-consumer-private-key` +- `synapse-staging-e2e-provider-private-key` +- `synapse-staging-e2e-agent-credential` + +The scripts only log secret names and load status, never secret values. + +### Verification + +```bash +bash scripts/e2e/sdk_parity_e2e.sh --env staging --languages python --skip-install +``` + +Run id: `sdk-staging-sm-python-full-20260513-001739` + +Owner/provider parity passed and returned typed balance, usage, and provider +guide objects. Runtime fixed-price also passed before the LLM provider blocker +tracked below. + +--- + +## BUG-STAGING-E2E-004 - Staging Secret Manager helper hid credential issuance failures + +**Status:** FIXED +**Severity:** staging E2E reliability +**SDK:** all harnesses +**Scenario:** credential preparation + +### Symptom + +When a shell had `SYNAPSE_ENV=local` set, the staging runtime harness still +used the staging gateway but the credential helper attempted to issue against +the removed `local` environment. A failed command substitution was also masked +by shell assignment behavior. + +### Root Cause + +`sdk_wave1_local.sh` did not force `SYNAPSE_ENV=staging` for its default +staging path. The Secret Manager helper assigned command substitution output +directly to `SYNAPSE_AGENT_KEY`, which can hide a failing nested command. + +### Fix + +The default staging runtime path now exports `SYNAPSE_ENV=staging`, and the +helper captures issued credentials into a temporary variable so failures and +empty values stop the run. + +### Verification + +```bash +SYNAPSE_ENV=local bash scripts/e2e/sdk_wave1_local.sh --languages python --free-only --skip-install +``` + +Run id: `sdk-staging-sm-free-20260513-000631` + +Python staging fixed-price E2E passed despite the caller shell setting +`SYNAPSE_ENV=local`. + +--- + +## BUG-STAGING-E2E-005 - Non-Python examples missed staging pricing shape + +**Status:** FIXED +**Severity:** blocking multi-SDK staging fixed-price E2E +**SDK:** TypeScript, Go, Java, .NET +**Scenario:** fixed-price service discovery + +### Symptom + +Python found and invoked `svc_synapse_echo`, but TypeScript, Go, Java, and .NET +examples failed with: + +```text +no free fixed-price API service found +``` + +### Root Cause + +Staging discovery returns `priceModel` inside the `pricing` object for +`svc_synapse_echo`. Non-Python examples only checked the top-level +`priceModel`, so they rejected a valid free fixed-price service. + +### Fix + +Updated TypeScript, Go, Java, and .NET examples to accept nested +`pricing.priceModel`. TypeScript also accepts snake_case discovery fields in +the public type and rediscovery helper. + +### Verification + +```bash +bash scripts/e2e/sdk_wave1_local.sh --languages python,typescript,go,java,dotnet --free-only --skip-install +``` + +Run id: `sdk-staging-sm-free-all-20260513-001646` + +All five SDKs passed fixed-price staging E2E with `svc_synapse_echo`. + +--- + +## BUG-STAGING-E2E-006 - Staging LLM provider route returns 404 + +**Status:** BLOCKED - staging service/provider configuration +**Severity:** blocking token-metered LLM release readiness +**SDK:** Python first, all SDK LLM runtime checks share the same service +**Scenario:** `llm` + +### Command + +```bash +bash scripts/e2e/sdk_parity_e2e.sh --env staging --languages python --skip-install +``` + +### Symptom + +Owner/provider parity and fixed-price invoke pass, but +`svc_deepseek_chat` fails during LLM invoke: + +```text +InvokeError: Upstream provider returned HTTP 404 +``` + +### Root Cause + +The staging Gateway accepts the request and reports an upstream provider 404. +Discovery currently exposes `svc_deepseek_chat`, but the upstream service route +is not healthy for the E2E payload. This is a staging service/provider +configuration blocker rather than a credential or SDK harness bug. + +### Fix + +Pending staging provider/service repair or a replacement healthy +token-metered LLM service ID. + +### Verification + +Pending. Re-run the full command after the staging LLM provider is repaired. + +--- + +## Staging Readiness Report - 2026-05-12 + +**Overall status:** PARTIAL PASS - fixed-price SDK readiness passed; LLM blocked by staging provider 404. + +### Commands Run + +```bash +curl -fsS --max-time 15 https://api-staging.synapse-network.ai/health +bash scripts/ci/pr_checks.sh +bash scripts/e2e/sdk_wave1_local.sh --languages python,typescript,go,java,dotnet --skip-install +bash scripts/e2e/sdk_wave1_local.sh --languages python --skip-install +bash scripts/e2e/sdk_parity_e2e.sh --env staging --languages python,typescript,go,java,dotnet --skip-install +``` + +### Results + +| Check | Result | Notes | +|---|---|---| +| Staging health | PASS | `/health` returned `{"status":"ok","version":"2.0.0"}` | +| PR quality gates | PASS | Full `scripts/ci/pr_checks.sh` passed | +| Python runtime harness | FIXED | E2E now uses repo venv Python | +| Secret Manager credential load | PASS | Staging E2E loads owner/provider keys and agent fallback without logging secret values | +| Owner/provider parity | PASS | Python owner/provider parity passed with Secret Manager owner key | +| Runtime fixed-price | PASS | Python, TypeScript, Go, Java, and .NET all invoked `svc_synapse_echo` on staging | +| Runtime LLM | BLOCKED | `svc_deepseek_chat` returns upstream provider HTTP 404 | + +### Latest Evidence + +| Run id | Result | Notes | +|---|---|---| +| `sdk-staging-sm-free-all-20260513-001646` | PASS | Five SDK fixed-price staging E2E | +| `sdk-staging-sm-python-full-20260513-001739` | BLOCKED | Python full parity reaches LLM and gets upstream 404 | + +### Next Required Environment Fix + +Repair or replace the staging token-metered LLM service behind +`svc_deepseek_chat`, then rerun: + +```bash +export E2E_RUN_ID="sdk-staging-$(date +%Y%m%d-%H%M%S)" +bash scripts/e2e/sdk_parity_e2e.sh --env staging --languages python,typescript,go,java,dotnet --skip-install +``` diff --git a/dotnet/examples/e2e/Program.cs b/dotnet/examples/e2e/Program.cs index 2834014..d3279f0 100644 --- a/dotnet/examples/e2e/Program.cs +++ b/dotnet/examples/e2e/Program.cs @@ -139,7 +139,7 @@ static bool IsFreeFixedApiService(ServiceRecord service, string amount) { return !string.IsNullOrWhiteSpace(service.ServiceId) && string.Equals(service.ServiceKind, "api", StringComparison.OrdinalIgnoreCase) - && string.Equals(service.PriceModel, "fixed", StringComparison.OrdinalIgnoreCase) + && string.Equals(FirstNonBlank(service.PriceModel, PricingPriceModel(service)), "fixed", StringComparison.OrdinalIgnoreCase) && DecimalEquals(amount, "0"); } @@ -184,6 +184,13 @@ static string PricingAmount(ServiceRecord service) : ""; } +static string PricingPriceModel(ServiceRecord service) +{ + return service.Pricing.HasValue && service.Pricing.Value.TryGetProperty("priceModel", out var priceModel) + ? priceModel.GetString() ?? "" + : ""; +} + static bool Terminal(string? status) { return string.Equals(status, "SUCCEEDED", StringComparison.OrdinalIgnoreCase) diff --git a/go/examples/e2e/main.go b/go/examples/e2e/main.go index 5f97ed1..d8639b2 100644 --- a/go/examples/e2e/main.go +++ b/go/examples/e2e/main.go @@ -163,7 +163,7 @@ func fixedService(ctx context.Context, client *synapse.Client) (string, string, func isFreeFixedAPIService(service synapse.ServiceRecord, amount string) bool { return strings.TrimSpace(service.ServiceID) != "" && strings.EqualFold(service.ServiceKind, "api") && - strings.EqualFold(service.PriceModel, "fixed") && + strings.EqualFold(firstNonEmpty(service.PriceModel, moneyString(service.Pricing["priceModel"])), "fixed") && decimalEqual(amount, "0") } diff --git a/go/examples/free_service_smoke/main.go b/go/examples/free_service_smoke/main.go index de92845..3f9dfc7 100644 --- a/go/examples/free_service_smoke/main.go +++ b/go/examples/free_service_smoke/main.go @@ -69,6 +69,15 @@ func isFreeFixedAPIService(service synapse.ServiceRecord) bool { } return strings.TrimSpace(service.ServiceID) != "" && strings.EqualFold(service.ServiceKind, "api") && - strings.EqualFold(service.PriceModel, "fixed") && + strings.EqualFold(firstNonEmpty(service.PriceModel, fmt.Sprint(service.Pricing["priceModel"])), "fixed") && left.Sign() == 0 } + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" && strings.TrimSpace(value) != "" { + return value + } + } + return "" +} diff --git a/java/examples/src/main/java/ai/synapsenetwork/sdk/examples/ExampleSupport.java b/java/examples/src/main/java/ai/synapsenetwork/sdk/examples/ExampleSupport.java index 92cf8d9..902f208 100644 --- a/java/examples/src/main/java/ai/synapsenetwork/sdk/examples/ExampleSupport.java +++ b/java/examples/src/main/java/ai/synapsenetwork/sdk/examples/ExampleSupport.java @@ -74,7 +74,7 @@ static FixedTarget fixedTarget(SynapseClient client) { static boolean isFreeFixedApiService(SynapseClient.ServiceRecord service) { return service.serviceId() != null && "api".equalsIgnoreCase(service.serviceKind()) - && "fixed".equalsIgnoreCase(service.priceModel()) + && "fixed".equalsIgnoreCase(firstNonBlank(service.priceModel(), pricingPriceModel(service))) && decimalEquals(pricingAmount(service), "0"); } @@ -219,6 +219,10 @@ private static String pricingAmount(SynapseClient.ServiceRecord service) { return service.pricing() == null ? "" : service.pricing().path("amount").asText(""); } + private static String pricingPriceModel(SynapseClient.ServiceRecord service) { + return service.pricing() == null ? "" : service.pricing().path("priceModel").asText(""); + } + private static void putIfPresent(Map target, String key, String value) { if (value != null && !value.isBlank()) { target.put(key, value); diff --git a/java/src/main/java/ai/synapsenetwork/sdk/SynapseClient.java b/java/src/main/java/ai/synapsenetwork/sdk/SynapseClient.java index dac71e3..9081fe8 100644 --- a/java/src/main/java/ai/synapsenetwork/sdk/SynapseClient.java +++ b/java/src/main/java/ai/synapsenetwork/sdk/SynapseClient.java @@ -1,6 +1,7 @@ package ai.synapsenetwork.sdk; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonAlias; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -244,11 +245,15 @@ public static final class LlmInvokeOptions { @JsonIgnoreProperties(ignoreUnknown = true) public record ServiceRecord( + @JsonAlias("service_id") @JsonProperty("serviceId") String serviceId, String id, + @JsonAlias("service_name") @JsonProperty("serviceName") String serviceName, String status, + @JsonAlias("service_kind") @JsonProperty("serviceKind") String serviceKind, + @JsonAlias("price_model") @JsonProperty("priceModel") String priceModel, JsonNode pricing, String summary, diff --git a/scripts/e2e/sdk_parity_e2e.sh b/scripts/e2e/sdk_parity_e2e.sh index 47ca225..00215a9 100755 --- a/scripts/e2e/sdk_parity_e2e.sh +++ b/scripts/e2e/sdk_parity_e2e.sh @@ -4,6 +4,8 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" cd "$ROOT_DIR" +source "$ROOT_DIR/scripts/e2e/staging_env_loader.sh" + LANGUAGES="python,typescript,go,java,dotnet" TARGET_ENV="staging" RUN_RUNTIME=true @@ -27,8 +29,10 @@ Required: SYNAPSE_OWNER_PRIVATE_KEY Owner wallet private key for auth challenge signing Runtime invoke requirements: - SYNAPSE_AGENT_KEY Optional. If missing, the script issues a short-lived - staging/local credential from SYNAPSE_OWNER_PRIVATE_KEY. + SYNAPSE_AGENT_KEY Optional fallback. For staging, the script issues a + short-lived credential from SYNAPSE_OWNER_PRIVATE_KEY + by default so stale stored credentials do not block + release readiness checks. Environment rules: --env staging Uses SDK staging preset unless SYNAPSE_GATEWAY_URL overrides it @@ -42,6 +46,13 @@ Optional runtime service overrides: SYNAPSE_E2E_LLM_SERVICE_ID SYNAPSE_E2E_LLM_MAX_COST_USDC SYNAPSE_E2E_LLM_PAYLOAD_JSON + SYNAPSE_E2E_LOAD_SECRET_MANAGER + Set to 0 to disable staging Secret Manager lookup. + SYNAPSE_E2E_SECRET_MANAGER_OVERRIDE + Defaults to 1. Set to 0 to keep explicit env values. + SYNAPSE_E2E_ISSUE_AGENT_KEY Defaults to 1 for staging. Set to 0 to use the + loaded or explicit SYNAPSE_AGENT_KEY directly. + SYNAPSE_GCP_PROJECT Optional Google Cloud project override. Optional full side-effecting checks: RUN_SDK_PARITY_FULL_E2E=1 @@ -88,6 +99,8 @@ done case "$TARGET_ENV" in staging) export SYNAPSE_ENV=staging + load_staging_e2e_secrets + export SYNAPSE_E2E_ISSUE_AGENT_KEY="${SYNAPSE_E2E_ISSUE_AGENT_KEY:-1}" ;; local) if [[ -z "${SYNAPSE_GATEWAY_URL:-}" ]]; then @@ -143,6 +156,15 @@ ensure_python3() { command -v python3 >/dev/null 2>&1 || brew_install python } +python_e2e_bin() { + local venv_python="$ROOT_DIR/python/.venv/bin/python" + if [[ -x "$venv_python" ]]; then + echo "$venv_python" + else + command -v python3 + fi +} + ensure_node() { command -v npm >/dev/null 2>&1 || brew_install node if [[ ! -d "$ROOT_DIR/typescript/node_modules" ]]; then @@ -185,10 +207,17 @@ ensure_dotnet() { if has_dotnet_8; then return fi + local dotnet_dir="${SYNAPSE_E2E_DOTNET_DIR:-$HOME/.synapse-network-sdk-e2e/dotnet}" + if [[ -x "$dotnet_dir/dotnet" ]]; then + export DOTNET_ROOT="$dotnet_dir" + export PATH="$DOTNET_ROOT:$PATH" + if has_dotnet_8; then + return + fi + fi if [[ "$INSTALL_MISSING" != "true" ]]; then fail_missing_tool ".NET SDK 8.0" fi - local dotnet_dir="${SYNAPSE_E2E_DOTNET_DIR:-$HOME/.synapse-network-sdk-e2e/dotnet}" mkdir -p "$dotnet_dir" echo "[e2e:sdk-parity] installing .NET SDK 8.0 into $dotnet_dir" curl -fsSL https://dot.net/v1/dotnet-install.sh -o "$dotnet_dir/dotnet-install.sh" @@ -198,44 +227,19 @@ ensure_dotnet() { } ensure_agent_key() { - if [[ -n "${SYNAPSE_AGENT_KEY:-}" || "$RUN_RUNTIME" != "true" ]]; then + if [[ "$RUN_RUNTIME" != "true" ]]; then + return + fi + if [[ "${SYNAPSE_E2E_ISSUE_AGENT_KEY:-0}" != "1" && -n "${SYNAPSE_AGENT_KEY:-}" ]]; then return fi ensure_python3 - echo "[e2e:sdk-parity] SYNAPSE_AGENT_KEY missing; issuing a temporary credential" - SYNAPSE_AGENT_KEY="$( - PYTHONPATH="$ROOT_DIR/python" python3 - <<'PY' -import os - -from synapse_client import SynapseAuth -from synapse_client.exceptions import AuthenticationError - -auth = SynapseAuth.from_private_key( - os.environ["SYNAPSE_OWNER_PRIVATE_KEY"], - environment=os.environ.get("SYNAPSE_ENV", "staging"), - gateway_url=os.environ.get("SYNAPSE_GATEWAY_URL") or None, -) -try: - result = auth.issue_credential( - name=f"{os.environ.get('E2E_RUN_ID', 'sdk-parity')}-agent", - max_calls=20, - rpm=60, - expires_in_sec=3600, - ) - print(result.token) -except AuthenticationError: - credentials = auth.list_active_credentials() - if not credentials: - raise - print(auth._usable_token_for_credential(credentials[0])) -PY - )" - export SYNAPSE_AGENT_KEY + issue_e2e_agent_key_from_owner "$ROOT_DIR" "$(python_e2e_bin)" } emit_owner_event_python() { ensure_python3 - PYTHONPATH="$ROOT_DIR/python" python3 - <<'PY' + PYTHONPATH="$ROOT_DIR/python" "$(python_e2e_bin)" - <<'PY' import json import os diff --git a/scripts/e2e/sdk_wave1_local.sh b/scripts/e2e/sdk_wave1_local.sh index ad5f5a9..a8fd2c2 100755 --- a/scripts/e2e/sdk_wave1_local.sh +++ b/scripts/e2e/sdk_wave1_local.sh @@ -4,6 +4,8 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" cd "$ROOT_DIR" +source "$ROOT_DIR/scripts/e2e/staging_env_loader.sh" + LANGUAGES="python,typescript,go,java,dotnet" FREE_ONLY=false INSTALL_MISSING=true @@ -22,7 +24,9 @@ Options: -h, --help Show this help Required: - SYNAPSE_AGENT_KEY Staging Agent Key, e.g. agt_xxx + SYNAPSE_AGENT_KEY Staging Agent Key, e.g. agt_xxx. Optional when + SYNAPSE_OWNER_PRIVATE_KEY is available because the + script can issue a short-lived runtime credential. Optional: SYNAPSE_GATEWAY_URL Explicit Gateway URL; defaults to SDK staging @@ -32,6 +36,14 @@ Optional: SYNAPSE_E2E_LLM_SERVICE_ID Default: svc_deepseek_chat SYNAPSE_E2E_LLM_MAX_COST_USDC Default: 0.010000 SYNAPSE_E2E_LLM_PAYLOAD_JSON + SYNAPSE_E2E_LOAD_SECRET_MANAGER + Set to 0 to disable staging Secret Manager lookup. + SYNAPSE_E2E_SECRET_MANAGER_OVERRIDE + Defaults to 1. Set to 0 to keep explicit env values. + SYNAPSE_E2E_ISSUE_AGENT_KEY Defaults to 1 when SYNAPSE_OWNER_PRIVATE_KEY is set + and no explicit Gateway URL is used. Set to 0 to use + the loaded or explicit SYNAPSE_AGENT_KEY directly. + SYNAPSE_GCP_PROJECT Optional Google Cloud project override. EOF } @@ -65,11 +77,6 @@ while [[ $# -gt 0 ]]; do esac done -if [[ -z "${SYNAPSE_AGENT_KEY:-}" ]]; then - echo "[e2e:sdk-wave1] SYNAPSE_AGENT_KEY is required for real staging E2E" >&2 - exit 2 -fi - export SYNAPSE_E2E_FREE_ONLY="$FREE_ONLY" export SYNAPSE_E2E_SKIP_AUTH_NEGATIVE="$SKIP_AUTH_NEGATIVE" @@ -148,11 +155,18 @@ ensure_dotnet() { if has_dotnet_8; then return fi + local dotnet_dir="${SYNAPSE_E2E_DOTNET_DIR:-$HOME/.synapse-network-sdk-e2e/dotnet}" + if [[ -x "$dotnet_dir/dotnet" ]]; then + export DOTNET_ROOT="$dotnet_dir" + export PATH="$DOTNET_ROOT:$PATH" + if has_dotnet_8; then + return + fi + fi if [[ "$INSTALL_MISSING" != "true" ]]; then fail_missing_tool ".NET SDK 8.0" fi - local dotnet_dir="${SYNAPSE_E2E_DOTNET_DIR:-$HOME/.synapse-network-sdk-e2e/dotnet}" mkdir -p "$dotnet_dir" echo "[e2e:sdk-wave1] installing .NET SDK 8.0 into $dotnet_dir" curl -fsSL https://dot.net/v1/dotnet-install.sh -o "$dotnet_dir/dotnet-install.sh" @@ -168,6 +182,36 @@ ensure_python3() { brew_install python } +python_e2e_bin() { + local venv_python="$ROOT_DIR/python/.venv/bin/python" + if [[ -x "$venv_python" ]]; then + echo "$venv_python" + else + command -v python3 + fi +} + +prepare_runtime_credentials() { + if [[ -z "${SYNAPSE_GATEWAY_URL:-}" ]]; then + export SYNAPSE_ENV=staging + load_staging_e2e_secrets + if [[ -n "${SYNAPSE_OWNER_PRIVATE_KEY:-}" ]]; then + export SYNAPSE_E2E_ISSUE_AGENT_KEY="${SYNAPSE_E2E_ISSUE_AGENT_KEY:-1}" + fi + fi + + if [[ "${SYNAPSE_E2E_ISSUE_AGENT_KEY:-0}" == "1" ]]; then + ensure_python3 + issue_e2e_agent_key_from_owner "$ROOT_DIR" "$(python_e2e_bin)" + return + fi + + if [[ -z "${SYNAPSE_AGENT_KEY:-}" ]]; then + echo "[e2e:sdk-wave1] SYNAPSE_AGENT_KEY is required for real staging E2E" >&2 + exit 2 + fi +} + ensure_node() { if command -v npm >/dev/null 2>&1; then return @@ -225,7 +269,7 @@ run_language() { python) ensure_python3 bash scripts/ci/python_checks.sh - run_and_validate python env PYTHONPATH="$ROOT_DIR/python" python3 python/examples/e2e.py + run_and_validate python env PYTHONPATH="$ROOT_DIR/python" "$(python_e2e_bin)" python/examples/e2e.py ;; typescript|ts) ensure_node @@ -258,6 +302,8 @@ run_language() { ensure_python3 +prepare_runtime_credentials + IFS=',' read -r -a SELECTED_LANGUAGES <<< "$LANGUAGES" for language in "${SELECTED_LANGUAGES[@]}"; do language="$(echo "$language" | tr -d '[:space:]')" diff --git a/scripts/e2e/staging_env_loader.sh b/scripts/e2e/staging_env_loader.sh new file mode 100644 index 0000000..0fd5c36 --- /dev/null +++ b/scripts/e2e/staging_env_loader.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash + +load_staging_e2e_secrets() { + if [[ "${SYNAPSE_E2E_LOAD_SECRET_MANAGER:-1}" == "0" ]]; then + echo "[e2e:secrets] Secret Manager loading disabled" + return 0 + fi + + if ! command -v gcloud >/dev/null 2>&1; then + echo "[e2e:secrets] gcloud not found; using existing environment variables only" + return 0 + fi + + load_secret_if_missing \ + "SYNAPSE_AGENT_KEY" \ + "${SYNAPSE_STAGING_E2E_AGENT_CREDENTIAL_SECRET:-synapse-staging-e2e-agent-credential}" + load_secret_if_missing \ + "SYNAPSE_OWNER_PRIVATE_KEY" \ + "${SYNAPSE_STAGING_E2E_CONSUMER_PRIVATE_KEY_SECRET:-synapse-staging-e2e-consumer-private-key}" + load_secret_if_missing \ + "SYNAPSE_PROVIDER_PRIVATE_KEY" \ + "${SYNAPSE_STAGING_E2E_PROVIDER_PRIVATE_KEY_SECRET:-synapse-staging-e2e-provider-private-key}" +} + +load_secret_if_missing() { + local env_name="$1" + local secret_name="$2" + local override="${SYNAPSE_E2E_SECRET_MANAGER_OVERRIDE:-1}" + if [[ -n "${!env_name:-}" && "$override" != "1" ]]; then + echo "[e2e:secrets] $env_name already set; keeping explicit environment value" + return 0 + fi + + local command_args=(secrets versions access latest --secret="$secret_name") + local project="${SYNAPSE_GCP_PROJECT:-${GOOGLE_CLOUD_PROJECT:-${GCLOUD_PROJECT:-}}}" + if [[ -n "$project" ]]; then + command_args+=(--project "$project") + fi + + local value + if ! value="$(gcloud "${command_args[@]}" 2>/dev/null)"; then + echo "[e2e:secrets] unable to load $env_name from Secret Manager secret $secret_name" >&2 + return 1 + fi + if [[ -z "$value" ]]; then + echo "[e2e:secrets] Secret Manager secret $secret_name is empty" >&2 + return 1 + fi + + export "$env_name=$value" + echo "[e2e:secrets] loaded $env_name from Secret Manager secret $secret_name" +} + +issue_e2e_agent_key_from_owner() { + local root_dir="$1" + local python_bin="$2" + if [[ -z "${SYNAPSE_OWNER_PRIVATE_KEY:-}" ]]; then + echo "[e2e:secrets] SYNAPSE_OWNER_PRIVATE_KEY is required to issue a runtime credential" >&2 + return 1 + fi + + echo "[e2e:secrets] issuing a temporary runtime credential" + local issued_agent_key + if ! issued_agent_key="$( + PYTHONPATH="$root_dir/python" "$python_bin" - <<'PY' +import os + +from synapse_client import SynapseAuth +from synapse_client.exceptions import AuthenticationError + +auth = SynapseAuth.from_private_key( + os.environ["SYNAPSE_OWNER_PRIVATE_KEY"], + environment=os.environ.get("SYNAPSE_ENV", "staging"), + gateway_url=os.environ.get("SYNAPSE_GATEWAY_URL") or None, +) +try: + result = auth.issue_credential( + name=f"{os.environ.get('E2E_RUN_ID', 'sdk-e2e')}-agent", + max_calls=100, + rpm=60, + expires_in_sec=3600, + ) + print(result.token) +except AuthenticationError: + credentials = auth.list_active_credentials() + if not credentials: + raise + credential = credentials[0] + auth.update_credential_quota(credential.credential_id, max_calls=100, rpm=60) + print(auth._usable_token_for_credential(credential)) +PY + )"; then + echo "[e2e:secrets] unable to issue or reuse a runtime credential" >&2 + return 1 + fi + if [[ -z "$issued_agent_key" ]]; then + echo "[e2e:secrets] issued runtime credential was empty" >&2 + return 1 + fi + + SYNAPSE_AGENT_KEY="$issued_agent_key" + export SYNAPSE_AGENT_KEY + export SYNAPSE_E2E_SECRET_MANAGER_OVERRIDE=0 +} diff --git a/typescript/examples/_shared.ts b/typescript/examples/_shared.ts index 48c9864..182d91b 100644 --- a/typescript/examples/_shared.ts +++ b/typescript/examples/_shared.ts @@ -79,11 +79,11 @@ export async function fixedTarget( const echoServices = await synapse.search(SYNAPSE_ECHO_SERVICE_ID, { limit: 10 }); const echoService = echoServices.find( - (service) => (service.serviceId ?? service.id) === SYNAPSE_ECHO_SERVICE_ID && isFreeFixedApiService(service) + (service) => serviceId(service) === SYNAPSE_ECHO_SERVICE_ID && isFreeFixedApiService(service) ); if (echoService) { return { - serviceId: echoService.serviceId ?? echoService.id ?? "", + serviceId: serviceId(echoService) ?? "", costUsdc: pricingAmount(echoService), payload, }; @@ -98,12 +98,16 @@ export async function fixedTarget( ); } return { - serviceId: service.serviceId ?? service.id ?? "", + serviceId: serviceId(service) ?? "", costUsdc: pricingAmount(service), payload, }; } +export function serviceId(service: ServiceRecord): string | undefined { + return service.serviceId ?? service.service_id ?? service.id; +} + export function pricingAmount(service: ServiceRecord): string { const pricing = service.pricing; if (typeof pricing === "string") return pricing; @@ -115,13 +119,21 @@ export function pricingAmount(service: ServiceRecord): string { export function isFreeFixedApiService(service: ServiceRecord): boolean { return ( - Boolean(service.serviceId ?? service.id) && - String(service.serviceKind ?? "").toLowerCase() === "api" && - String(service.priceModel ?? "").toLowerCase() === "fixed" && + Boolean(serviceId(service)) && + String(service.serviceKind ?? service.service_kind ?? "").toLowerCase() === "api" && + String(service.priceModel ?? service.price_model ?? pricingPriceModel(service) ?? "").toLowerCase() === "fixed" && decimalEquals(pricingAmount(service), "0") ); } +function pricingPriceModel(service: ServiceRecord): string | undefined { + const pricing = service.pricing; + if (pricing && typeof pricing === "object" && "priceModel" in pricing) { + return String(pricing.priceModel ?? ""); + } + return undefined; +} + export async function awaitReceipt(synapse: SynapseClient, invocationId: string) { if (!invocationId.trim()) throw new Error("invoke returned empty invocationId"); const deadline = Date.now() + envInt("SYNAPSE_E2E_RECEIPT_TIMEOUT_S", 60) * 1000; diff --git a/typescript/src/client.ts b/typescript/src/client.ts index dca0914..b200fbd 100644 --- a/typescript/src/client.ts +++ b/typescript/src/client.ts @@ -345,7 +345,7 @@ function invokeError(err: unknown): Error { } function serviceKey(service: ServiceRecord): string | undefined { - return service.serviceId ?? service.id; + return service.serviceId ?? service.service_id ?? service.id; } function extractServicePrice(service: ServiceRecord): number | null { diff --git a/typescript/src/types.ts b/typescript/src/types.ts index ad68f36..db36111 100644 --- a/typescript/src/types.ts +++ b/typescript/src/types.ts @@ -220,12 +220,15 @@ export interface LlmPricing { export interface ServiceRecord { serviceId?: string; + service_id?: string; id?: string; agentToolName?: string; serviceName?: string; status?: string; serviceKind?: ServiceKind | string; + service_kind?: ServiceKind | string; priceModel?: PriceModel | string; + price_model?: PriceModel | string; pricing?: FixedPricing | LlmPricing | string; inputPricePer1MTokensUsdc?: string; outputPricePer1MTokensUsdc?: string; diff --git a/typescript/tests/unit/client.test.ts b/typescript/tests/unit/client.test.ts index 6b7d7a8..18c23a8 100644 --- a/typescript/tests/unit/client.test.ts +++ b/typescript/tests/unit/client.test.ts @@ -614,6 +614,59 @@ test("invokeWithRediscovery handles string prices and missing discovered prices" expect(calls[2].body?.costUsdc).toBe(0.14); }); +test("invokeWithRediscovery accepts snake_case staging discovery records", async () => { + const calls: Array<{ url: string; body?: Record }> = []; + let invokeCount = 0; + (globalThis as unknown as Record).fetch = jest.fn(async (url: string, init?: RequestInit) => { + const body = init?.body ? (JSON.parse(init.body as string) as Record) : undefined; + calls.push({ url, body }); + if (url.includes("/api/v1/agent/invoke")) { + invokeCount += 1; + if (invokeCount === 1) { + return { + ok: false, + status: 422, + text: async () => + JSON.stringify({ + detail: { + code: "PRICE_MISMATCH", + message: "Price changed", + expectedPriceUsdc: 0.05, + currentPriceUsdc: 0.12, + }, + }), + } as Response; + } + return { + ok: true, + status: 200, + text: async () => JSON.stringify({ invocationId: "inv_snake_case_price", status: "SUCCEEDED" }), + } as Response; + } + return { + ok: true, + status: 200, + text: async () => + JSON.stringify({ + services: [ + { + service_id: "svc_1", + service_kind: "api", + price_model: "fixed", + pricing: { amount: "0.12", currency: "USDC" }, + }, + ], + }), + } as Response; + }); + + const client = new SynapseClient({ credential: "agt_test" }); + const result = await client.invokeWithRediscovery("svc_1", {}, { costUsdc: 0.05 }); + + expect(result.invocationId).toBe("inv_snake_case_price"); + expect(calls[2].body?.costUsdc).toBe(0.12); +}); + test("invokeWithRediscovery fails clearly when rediscovery cannot provide a price", async () => { let invokeCount = 0; (globalThis as unknown as Record).fetch = jest.fn(async (url: string) => {