agent_swarm#56 评论:模型 key 的真实 Key Vault 库名是 `heicode-vault` (`https://heicode-vault.vault.azure.net`),早期契约文档误写为 `heicode-kv`; 且生产 `SECRET_RESOLVER` 须指向 `heicode-vault`。 - runtime-contract.md §3.3.1 A.3:库名更正 + 标注 SECRET_RESOLVER 指向 heicode-vault + 明确 Swarm 需提供 Pod 身份的 clientId+objectId 给 HM 授权(只读、限 swarm-model-key-*)。 - security-boundary.md:secret_ref 示例 host 同步更正。 - test-key-injection-contract.py:模型 key fixture host 同步更正(resolver 仅取末段名, 功能不变;测试仍全绿)。 纯文档/fixture 更名,无事件 schema/契约字段改动。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
158 lines
7.2 KiB
Python
158 lines
7.2 KiB
Python
"""Model-key injection + revocation handshake contract tests (agent_swarm#16 / HM #60).
|
|
|
|
Pins the two Swarm-side parameters HM locked for #60 (calling-key injection) before HM writes its
|
|
mint→KV→revoke side:
|
|
|
|
A.2 — KV secret VALUE format: Swarm resolves the per-user `sk-` from the HM #60 JSON
|
|
``{"openai_api_key": "sk-..."}`` (mirrors the callback-secret convention); a bare `sk-`
|
|
string is still accepted; malformed / missing field never fabricates a key.
|
|
A.5 — revocation signal: a `stop` is the only *final* state (completed/failed are reopenable via
|
|
POST …/input, so they keep the key). When a stop drains a user's retained-run set, the
|
|
runtime emits exactly one `swarm.pool_terminated{user_id, secret_ref}` so HM revokes the
|
|
`sk-` + clears the KV secret. No double-revoke; still-live runs keep the key.
|
|
|
|
Hermetic: REDIS_FAKE, no model key, no real Manager callback (callback url empty).
|
|
|
|
Run from agent_swarm_v6 (install deps first — needs fakeredis):
|
|
pip install -r orchestrator/requirements.txt
|
|
REDIS_FAKE=1 python scripts/test-key-injection-contract.py
|
|
"""
|
|
import asyncio
|
|
import json
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
os.environ["REDIS_FAKE"] = "1"
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
|
|
from orchestrator.redis_client import redis_client
|
|
from orchestrator import agent_launcher
|
|
from orchestrator.swarm_runtime import swarm_runtime, FROZEN_CLIENT_EVENT_TYPES
|
|
|
|
failures = []
|
|
|
|
|
|
def check(name, cond):
|
|
print(("PASS" if cond else "FAIL"), "-", name)
|
|
if not cond:
|
|
failures.append(name)
|
|
|
|
|
|
async def stored_events(swarm_id):
|
|
raw = await redis_client.lrange(f"{swarm_runtime.EVENT_KEY_PREFIX}{swarm_id}", 0, -1)
|
|
return [json.loads(r) for r in raw]
|
|
|
|
|
|
async def new_run(user_id, secret_ref="azkv://heicode-vault/secrets/swarm-model-key-u1"):
|
|
body = {
|
|
"mode": "swarm",
|
|
"orchestration_plan": {"objective": "key-contract test"},
|
|
"billing_context": {"secret_ref": secret_ref},
|
|
"callback": {"url": "", "subscribed_events": []},
|
|
"metadata": {"manager_deployment_id": "m-key", "runtime_headers": {"x_user_id": user_id}},
|
|
}
|
|
run, _ = await swarm_runtime.get_or_create_run(body=body, idempotency_key=None, correlation_id="k")
|
|
return run
|
|
|
|
|
|
# ---------------------------------------------------------------------------------------------
|
|
# A.2 — KV secret value format
|
|
# ---------------------------------------------------------------------------------------------
|
|
def test_kv_value_format():
|
|
check("JSON {openai_api_key} → key extracted",
|
|
agent_launcher._extract_model_key('{"openai_api_key": "sk-CONTRACT"}') == "sk-CONTRACT")
|
|
check("JSON with sibling fields → still extracts the key",
|
|
agent_launcher._extract_model_key('{"openai_api_key":"sk-X","note":"per-user"}') == "sk-X")
|
|
check("bare sk- string value still accepted (back-compat)",
|
|
agent_launcher._extract_model_key("sk-BARE") == "sk-BARE")
|
|
check("malformed JSON → None (never fabricated)",
|
|
agent_launcher._extract_model_key("{not json") is None)
|
|
check("JSON missing the key field → None",
|
|
agent_launcher._extract_model_key('{"callback_signing_secret":"x"}') is None)
|
|
check("empty value → None", agent_launcher._extract_model_key("") is None)
|
|
|
|
# End-to-end via resolve_model_key: azkv ref → dev env map → JSON value.
|
|
os.environ.pop("AGENT_LAUNCH_MODEL_KEY", None)
|
|
os.environ.pop("OPENAI_API_KEY", None)
|
|
os.environ["HEICODE_SECRET_swarm-model-key-u1"] = '{"openai_api_key": "sk-RESOLVED"}'
|
|
body = {"billing_context": {"secret_ref": "azkv://heicode-vault/secrets/swarm-model-key-u1"}}
|
|
check("resolve_model_key resolves the azkv JSON secret to the bare sk-",
|
|
agent_launcher.resolve_model_key(body) == "sk-RESOLVED")
|
|
os.environ.pop("HEICODE_SECRET_swarm-model-key-u1", None)
|
|
|
|
|
|
# ---------------------------------------------------------------------------------------------
|
|
# A.5 — revocation handshake (swarm.pool_terminated on retained-set drain)
|
|
# ---------------------------------------------------------------------------------------------
|
|
async def pool_terminated_events(swarm_id):
|
|
return [e for e in await stored_events(swarm_id) if e["event_type"] == "swarm.pool_terminated"]
|
|
|
|
|
|
async def test_single_run_stop_signals():
|
|
run = await new_run("user-A")
|
|
# not signalled before stop
|
|
check("no pool_terminated before stop", not await pool_terminated_events(run.swarm_id))
|
|
await swarm_runtime.stop_run(run.deployment_id, reason="manager stop")
|
|
evs = await pool_terminated_events(run.swarm_id)
|
|
check("pool_terminated emitted when user's only run is stopped", len(evs) == 1)
|
|
if evs:
|
|
p = evs[0]["payload"]
|
|
check("pool_terminated carries user_id", p.get("user_id") == "user-A")
|
|
check("pool_terminated carries azkv secret_ref (not a plaintext key)",
|
|
str(p.get("secret_ref", "")).startswith("azkv://"))
|
|
# It is an HM control-plane lifecycle signal, NOT a client task-cockpit event.
|
|
check("pool_terminated is NOT in the frozen client event set",
|
|
"swarm.pool_terminated" not in FROZEN_CLIENT_EVENT_TYPES)
|
|
|
|
|
|
async def test_multi_run_keeps_key_until_last_stop():
|
|
a = await new_run("user-B")
|
|
b = await new_run("user-B")
|
|
await swarm_runtime.stop_run(a.deployment_id, reason="stop a")
|
|
# B still retained → no signal on either run's stream yet
|
|
sig_a = await pool_terminated_events(a.swarm_id)
|
|
sig_b = await pool_terminated_events(b.swarm_id)
|
|
check("stopping one of two runs does NOT revoke (other still live)", not sig_a and not sig_b)
|
|
await swarm_runtime.stop_run(b.deployment_id, reason="stop b")
|
|
check("pool_terminated fires only when the user's LAST run is stopped",
|
|
len(await pool_terminated_events(b.swarm_id)) == 1)
|
|
|
|
|
|
async def test_no_double_revoke():
|
|
run = await new_run("user-C")
|
|
await swarm_runtime.stop_run(run.deployment_id, reason="stop 1")
|
|
# a redundant second stop of the same (already drained) user must not signal again
|
|
await swarm_runtime.stop_run(run.deployment_id, reason="stop 2")
|
|
check("no double pool_terminated on a repeated stop",
|
|
len(await pool_terminated_events(run.swarm_id)) == 1)
|
|
|
|
|
|
async def test_relife_after_new_run():
|
|
# After a full teardown, a brand-new run re-arms the signal (clears the terminated flag).
|
|
run1 = await new_run("user-D")
|
|
await swarm_runtime.stop_run(run1.deployment_id, reason="stop d1")
|
|
check("first teardown signals", len(await pool_terminated_events(run1.swarm_id)) == 1)
|
|
run2 = await new_run("user-D")
|
|
await swarm_runtime.stop_run(run2.deployment_id, reason="stop d2")
|
|
check("a new run after teardown re-arms a second pool_terminated",
|
|
len(await pool_terminated_events(run2.swarm_id)) == 1)
|
|
|
|
|
|
async def main():
|
|
await redis_client.connect()
|
|
test_kv_value_format()
|
|
await test_single_run_stop_signals()
|
|
await test_multi_run_keeps_key_until_last_stop()
|
|
await test_no_double_revoke()
|
|
await test_relife_after_new_run()
|
|
print()
|
|
if failures:
|
|
print(f"{len(failures)} key-injection-contract check(s) FAILED: {failures}")
|
|
sys.exit(1)
|
|
print("all key-injection-contract checks passed")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
asyncio.run(main())
|