|
3 | 3 | Tests the credential pipeline end-to-end: |
4 | 4 | 1. Tools fail when credentials are missing |
5 | 5 | 2. Env vars are NOT read (security boundary) |
6 | | - 3. Credentials added via CLI are resolved at execution time |
| 6 | + 3. Credentials added to the server store are resolved at execution time |
7 | 7 | 4. Credential updates propagate to subsequent runs |
8 | 8 |
|
9 | 9 | Single sequential test with try/finally cleanup. |
10 | | -No mocks. Real server, real CLI, real LLM. |
| 10 | +No mocks. Real server, real LLM. |
11 | 11 | """ |
12 | 12 |
|
13 | 13 | import os |
|
28 | 28 | CRED_B = "E2E_CRED_B" |
29 | 29 | TIMEOUT = 300 # 5 min per agent run — CI runners are slower |
30 | 30 |
|
| 31 | +API = os.environ.get("CONDUCTOR_SERVER_URL", "http://localhost:8080/api").rstrip("/") |
| 32 | + |
| 33 | + |
| 34 | +# ── Credential store (server API — no CLI) ────────────────────────────── |
| 35 | + |
| 36 | + |
| 37 | +def _put_secret(name: str, value: str) -> None: |
| 38 | + """Store a credential, skipping the suite when the store is read-only. |
| 39 | +
|
| 40 | + Unlike a suite that only *consumes* a credential, this one sets specific |
| 41 | + values and then updates them, so it needs a writable store: conductor-oss |
| 42 | + serves secrets from the server process env and rejects writes with 501. |
| 43 | + """ |
| 44 | + r = requests.put( |
| 45 | + f"{API}/secrets/{name}", |
| 46 | + data=value, |
| 47 | + headers={"Content-Type": "text/plain"}, |
| 48 | + timeout=10, |
| 49 | + ) |
| 50 | + if not r.ok: |
| 51 | + pytest.skip( |
| 52 | + f"server credential store rejected a write (HTTP {r.status_code}) — " |
| 53 | + f"this suite needs a writable store to set and update credentials" |
| 54 | + ) |
| 55 | + |
| 56 | + |
| 57 | +def _delete_secret(name: str) -> None: |
| 58 | + try: |
| 59 | + requests.delete(f"{API}/secrets/{name}", timeout=10) |
| 60 | + except Exception: |
| 61 | + pass # best-effort cleanup |
| 62 | + |
31 | 63 |
|
32 | 64 | # ── Tools ─────────────────────────────────────────────────────────────── |
33 | 65 |
|
@@ -204,9 +236,9 @@ def _credential_audit(agent: Agent) -> str: |
204 | 236 |
|
205 | 237 | # Fetch stored credentials from server |
206 | 238 | try: |
207 | | - resp = requests.get(f"{base_url}/api/credentials", timeout=5) |
| 239 | + resp = requests.get(f"{base_url}/api/secrets", timeout=5) |
208 | 240 | resp.raise_for_status() |
209 | | - stored = {c["name"] for c in resp.json()} |
| 241 | + stored = {c if isinstance(c, str) else c.get("name") for c in resp.json()} |
210 | 242 | except Exception as e: |
211 | 243 | return f"(could not fetch credentials from server: {e})" |
212 | 244 |
|
@@ -291,6 +323,215 @@ def _get_output_text(result) -> str: |
291 | 323 | # ── Test ──────────────────────────────────────────────────────────────── |
292 | 324 |
|
293 | 325 |
|
| 326 | +@pytest.mark.timeout(300) |
| 327 | +class TestSuite2ToolCalling: |
| 328 | + """Credential lifecycle: missing -> env ignored -> add -> update.""" |
| 329 | + |
| 330 | + @pytest.mark.usefixtures("requires_runtime_metadata") |
| 331 | + def test_credential_lifecycle(self, runtime, model): |
| 332 | + """Full credential lifecycle test — sequential steps with cleanup.""" |
| 333 | + try: |
| 334 | + self._run_lifecycle(runtime, model) |
| 335 | + finally: |
| 336 | + # Always clean up credentials |
| 337 | + _delete_secret(CRED_A) |
| 338 | + _delete_secret(CRED_B) |
| 339 | + # Clean env vars if they leaked |
| 340 | + os.environ.pop(CRED_A, None) |
| 341 | + os.environ.pop(CRED_B, None) |
| 342 | + |
| 343 | + def _run_lifecycle(self, runtime, model): |
| 344 | + agent = _make_agent(model) |
| 345 | + owned_runtimes: list[AgentRuntime] = [] |
| 346 | + |
| 347 | + def restart_runtime(current: AgentRuntime) -> AgentRuntime: |
| 348 | + current.shutdown() |
| 349 | + # Let old poll loops drain before new workers start with fresh |
| 350 | + # execution tokens for the updated credential state. |
| 351 | + time.sleep(2) |
| 352 | + fresh = AgentRuntime() |
| 353 | + owned_runtimes.append(fresh) |
| 354 | + return fresh |
| 355 | + |
| 356 | + try: |
| 357 | + # ── Step 1: Clean slate ───────────────────────────────────── |
| 358 | + _delete_secret(CRED_A) |
| 359 | + _delete_secret(CRED_B) |
| 360 | + |
| 361 | + # ── Step 2: No credentials — paid tools should fail ───────── |
| 362 | + result = runtime.run(agent, "Call all three tools.", timeout=TIMEOUT) |
| 363 | + |
| 364 | + assert result.execution_id, ( |
| 365 | + f"[Step 2: No credentials] No execution_id returned. " |
| 366 | + f"{_run_diagnostic(result)}" |
| 367 | + ) |
| 368 | + |
| 369 | + # The run should reach a terminal state (COMPLETED or FAILED). |
| 370 | + # Paid tools should raise RuntimeError because credentials are missing. |
| 371 | + assert result.status in ("COMPLETED", "FAILED", "TERMINATED"), ( |
| 372 | + f"[Step 2: No credentials] Expected terminal status, " |
| 373 | + f"got '{result.status}'. The agent should either complete " |
| 374 | + f"(reporting tool errors) or fail outright when credentials " |
| 375 | + f"are missing.\n" |
| 376 | + f" {_run_diagnostic(result)}\n" |
| 377 | + f" {_tool_diagnostics(result.execution_id)}" |
| 378 | + ) |
| 379 | + |
| 380 | + # Verify via workflow tasks: paid tools must be terminal (not retryable). |
| 381 | + # Conductor maps TaskResult.FAILED_WITH_TERMINAL_ERROR → Task.COMPLETED_WITH_ERRORS |
| 382 | + tool_tasks_s2 = _find_tool_tasks_for(result.execution_id) |
| 383 | + terminal_statuses = {"FAILED_WITH_TERMINAL_ERROR", "COMPLETED_WITH_ERRORS"} |
| 384 | + for paid in ("paid_tool_a", "paid_tool_b"): |
| 385 | + if paid in tool_tasks_s2: |
| 386 | + task_info = tool_tasks_s2[paid] |
| 387 | + assert task_info["status"] in terminal_statuses, ( |
| 388 | + f"[Step 2: No credentials] {paid} should be terminal " |
| 389 | + f"(not retryable), got '{task_info['status']}'. Missing " |
| 390 | + f"credentials are a config issue — retries are pointless.\n" |
| 391 | + f" task={task_info}" |
| 392 | + ) |
| 393 | + |
| 394 | + # ── Step 3: Env vars should NOT be read ───────────────────── |
| 395 | + os.environ[CRED_A] = "from-env-aaa" |
| 396 | + os.environ[CRED_B] = "from-env-bbb" |
| 397 | + try: |
| 398 | + result_env = runtime.run( |
| 399 | + agent, "Call all three tools.", timeout=TIMEOUT |
| 400 | + ) |
| 401 | + |
| 402 | + # The paid tools should STILL fail despite env vars being set. |
| 403 | + # The SDK resolves credentials from the server, not env. |
| 404 | + output_env = _get_output_text(result_env) |
| 405 | + |
| 406 | + # Check for "from-env" (unique prefix of our test env values). |
| 407 | + # Using "fro" caused false positives when LLM prose contained |
| 408 | + # "from" in normal words. |
| 409 | + assert "from-env" not in output_env, ( |
| 410 | + "SECURITY VIOLATION: env vars were read for credential " |
| 411 | + "resolution! The SDK MUST NOT resolve credentials from " |
| 412 | + "environment variables — only from the server.\n" |
| 413 | + f" {_run_diagnostic(result_env)}\n" |
| 414 | + f" output_text={output_env[:300]}" |
| 415 | + ) |
| 416 | + finally: |
| 417 | + os.environ.pop(CRED_A, None) |
| 418 | + os.environ.pop(CRED_B, None) |
| 419 | + |
| 420 | + # ── Step 4: Add credentials ───────────────────────────────── |
| 421 | + runtime = restart_runtime(runtime) |
| 422 | + _put_secret(CRED_A, "secret-aaa-value") |
| 423 | + _put_secret(CRED_B, "secret-bbb-value") |
| 424 | + |
| 425 | + result_with_creds = runtime.run( |
| 426 | + agent, "Call all three tools.", timeout=TIMEOUT |
| 427 | + ) |
| 428 | + _assert_run_completed(result_with_creds, "Step 4: With credentials", agent) |
| 429 | + |
| 430 | + # Primary: validate via workflow task data |
| 431 | + tool_tasks_s4 = _find_tool_tasks_for(result_with_creds.execution_id) |
| 432 | + |
| 433 | + assert "free_tool" in tool_tasks_s4, ( |
| 434 | + f"[Step 4] free_tool task not found in workflow.\n" |
| 435 | + f" found_tasks={list(tool_tasks_s4.keys())}" |
| 436 | + ) |
| 437 | + assert tool_tasks_s4["free_tool"]["status"] == "COMPLETED", ( |
| 438 | + f"[Step 4] free_tool not COMPLETED.\n" |
| 439 | + f" task={tool_tasks_s4['free_tool']}" |
| 440 | + ) |
| 441 | + |
| 442 | + assert "paid_tool_a" in tool_tasks_s4, ( |
| 443 | + f"[Step 4] paid_tool_a task not found in workflow.\n" |
| 444 | + f" found_tasks={list(tool_tasks_s4.keys())}" |
| 445 | + ) |
| 446 | + assert tool_tasks_s4["paid_tool_a"]["status"] == "COMPLETED", ( |
| 447 | + f"[Step 4] paid_tool_a not COMPLETED.\n" |
| 448 | + f" task={tool_tasks_s4['paid_tool_a']}" |
| 449 | + ) |
| 450 | + s4_paid_a_output = str(tool_tasks_s4["paid_tool_a"]["output"]) |
| 451 | + assert "sec" in s4_paid_a_output, ( |
| 452 | + f"[Step 4] paid_tool_a output should contain 'sec' " |
| 453 | + f"(first 3 chars of 'secret-aaa-value').\n" |
| 454 | + f" task_output={s4_paid_a_output}" |
| 455 | + ) |
| 456 | + |
| 457 | + assert "paid_tool_b" in tool_tasks_s4, ( |
| 458 | + f"[Step 4] paid_tool_b task not found in workflow.\n" |
| 459 | + f" found_tasks={list(tool_tasks_s4.keys())}" |
| 460 | + ) |
| 461 | + assert tool_tasks_s4["paid_tool_b"]["status"] == "COMPLETED", ( |
| 462 | + f"[Step 4] paid_tool_b not COMPLETED.\n" |
| 463 | + f" task={tool_tasks_s4['paid_tool_b']}" |
| 464 | + ) |
| 465 | + s4_paid_b_output = str(tool_tasks_s4["paid_tool_b"]["output"]) |
| 466 | + assert "sec" in s4_paid_b_output, ( |
| 467 | + f"[Step 4] paid_tool_b output should contain 'sec' " |
| 468 | + f"(first 3 chars of 'secret-bbb-value').\n" |
| 469 | + f" task_output={s4_paid_b_output}" |
| 470 | + ) |
| 471 | + |
| 472 | + # Secondary: also check LLM output text |
| 473 | + output_creds = _get_output_text(result_with_creds) |
| 474 | + |
| 475 | + assert "free" in output_creds.lower(), ( |
| 476 | + f"[Step 4: With credentials] free_tool output not found in " |
| 477 | + f"agent response. free_tool always returns 'free:ok' — if " |
| 478 | + f"missing, the agent may not have called it.\n" |
| 479 | + f" {_run_diagnostic(result_with_creds)}\n" |
| 480 | + f" output_text={output_creds[:300]}\n" |
| 481 | + f" {_tool_diagnostics(result_with_creds.execution_id)}" |
| 482 | + ) |
| 483 | + assert "sec" in output_creds, ( |
| 484 | + f"[Step 4: With credentials] paid_tool_a should return 'sec' " |
| 485 | + f"(first 3 chars of 'secret-aaa-value'). If missing, credential " |
| 486 | + f"'{CRED_A}' may not have been resolved correctly.\n" |
| 487 | + f" {_run_diagnostic(result_with_creds)}\n" |
| 488 | + f" output_text={output_creds[:300]}\n" |
| 489 | + f" {_tool_diagnostics(result_with_creds.execution_id)}" |
| 490 | + ) |
| 491 | + |
| 492 | + # ── Step 5: Update credentials ────────────────────────────── |
| 493 | + runtime = restart_runtime(runtime) |
| 494 | + _put_secret(CRED_A, "newval-xxx-updated") |
| 495 | + _put_secret(CRED_B, "newval-yyy-updated") |
| 496 | + |
| 497 | + result_updated = runtime.run( |
| 498 | + agent, "Call all three tools.", timeout=TIMEOUT |
| 499 | + ) |
| 500 | + _assert_run_completed(result_updated, "Step 5: Updated credentials", agent) |
| 501 | + |
| 502 | + # Primary: validate via workflow task data |
| 503 | + tool_tasks_s5 = _find_tool_tasks_for(result_updated.execution_id) |
| 504 | + |
| 505 | + assert "paid_tool_a" in tool_tasks_s5, ( |
| 506 | + f"[Step 5] paid_tool_a task not found in workflow.\n" |
| 507 | + f" found_tasks={list(tool_tasks_s5.keys())}" |
| 508 | + ) |
| 509 | + assert tool_tasks_s5["paid_tool_a"]["status"] == "COMPLETED", ( |
| 510 | + f"[Step 5] paid_tool_a not COMPLETED.\n" |
| 511 | + f" task={tool_tasks_s5['paid_tool_a']}" |
| 512 | + ) |
| 513 | + s5_paid_a_output = str(tool_tasks_s5["paid_tool_a"]["output"]) |
| 514 | + assert "new" in s5_paid_a_output, ( |
| 515 | + f"[Step 5] paid_tool_a output should contain 'new' " |
| 516 | + f"(first 3 chars of 'newval-xxx-updated').\n" |
| 517 | + f" task_output={s5_paid_a_output}" |
| 518 | + ) |
| 519 | + |
| 520 | + # Secondary: also check LLM output text |
| 521 | + output_updated = _get_output_text(result_updated) |
| 522 | + |
| 523 | + assert "new" in output_updated, ( |
| 524 | + f"[Step 5: Updated credentials] paid_tool_a should return 'new' " |
| 525 | + f"(first 3 chars of 'newval-xxx-updated'). If missing, the " |
| 526 | + f"credential update may not have propagated.\n" |
| 527 | + f" {_run_diagnostic(result_updated)}\n" |
| 528 | + f" output_text={output_updated[:300]}\n" |
| 529 | + f" {_tool_diagnostics(result_updated.execution_id)}" |
| 530 | + ) |
| 531 | + finally: |
| 532 | + for owned in reversed(owned_runtimes): |
| 533 | + owned.shutdown() |
| 534 | + |
294 | 535 |
|
295 | 536 | # Output masking (Audit gap D) is covered deterministically by the server's |
296 | 537 | # SecretMaskingIntegrationTest (MockMvc + @MockBean AgentService). An e2e |
|
0 commit comments