Skip to content

Commit 50045a9

Browse files
Restore the e2e test bodies removed in #441
#441 deleted four test functions along with the cli_credentials conftest fixture, leaving suites 2 and 3 collecting nothing and suites 4 and 5 unable to run. Restored verbatim, with one substantive change: credentials now go through the server's /api/secrets REST API instead of shelling out to the agentspan CLI. The CLI targeted /api/credentials, which only Orkes serves (404 on conductor-oss) — that was the sole reason these suites could not run there. They now run on both, and need no conftest fixture. suite collected Orkes conductor-oss 2 tool_calling 0 -> 1 pass skip (store read-only) 3 cli_tools 0 -> 1 pass skip at the credential write 4 mcp_tools 1 -> 2 pass pass 5 http_tools 1 -> 2 pass pass Suites 4 and 5 adopt a pre-provisioned credential when the store is read-only, so their authenticated phases run on conductor-oss too. Suites 2 and 3 set and then update values, which requires a writable store, so they skip there with a message naming the cause. Also: suite 3's whitelist checks moved ahead of the credential write (they need no store, and were otherwise stranded behind the skip), and suite 4 gains an assertion that a tool result the model cannot invent appears in the answer. test_suite16_cli_skills.py was also removed by #441 — intentionally, so not restored.
1 parent d5db0e0 commit 50045a9

4 files changed

Lines changed: 812 additions & 9 deletions

File tree

e2e/test_suite2_tool_calling.py

Lines changed: 245 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@
33
Tests the credential pipeline end-to-end:
44
1. Tools fail when credentials are missing
55
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
77
4. Credential updates propagate to subsequent runs
88
99
Single sequential test with try/finally cleanup.
10-
No mocks. Real server, real CLI, real LLM.
10+
No mocks. Real server, real LLM.
1111
"""
1212

1313
import os
@@ -28,6 +28,38 @@
2828
CRED_B = "E2E_CRED_B"
2929
TIMEOUT = 300 # 5 min per agent run — CI runners are slower
3030

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+
3163

3264
# ── Tools ───────────────────────────────────────────────────────────────
3365

@@ -204,9 +236,9 @@ def _credential_audit(agent: Agent) -> str:
204236

205237
# Fetch stored credentials from server
206238
try:
207-
resp = requests.get(f"{base_url}/api/credentials", timeout=5)
239+
resp = requests.get(f"{base_url}/api/secrets", timeout=5)
208240
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()}
210242
except Exception as e:
211243
return f"(could not fetch credentials from server: {e})"
212244

@@ -291,6 +323,215 @@ def _get_output_text(result) -> str:
291323
# ── Test ────────────────────────────────────────────────────────────────
292324

293325

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+
294535

295536
# Output masking (Audit gap D) is covered deterministically by the server's
296537
# SecretMaskingIntegrationTest (MockMvc + @MockBean AgentService). An e2e

0 commit comments

Comments
 (0)