Files
Agentswarm/scripts/test-key-injection-contract.py
T
Songhaoz666andClaude Opus 4.8 0cbab3f750 模型 key 注入对接:定死 HM #60 参数(KV value JSON + per-user 吊销握手)(Refs #16 #60)
回应 HM「#60 落地前需 Swarm 定死的参数清单」。Swarm 侧逐条定死并落实现:

- A.2 KV secret value 格式:JSON {"openai_api_key":"sk-..."}(对齐 callback 签名密钥
  约定),解析字段 openai_api_key;裸 sk- 串兼容;解析不到/字段缺失不伪造。实现
  agent_launcher._extract_model_key + _resolve_secret_ref。
- A.5 吊销信号(事件驱动):sk- per-user 长存;stop 为唯一终态(completed/failed 经
  …/input 可重开故保 key)。某用户全部 run 被 stop(retained 集清空)时,运行时发
  恰好一次 swarm.pool_terminated{user_id, secret_ref},HM 据此吊销 sk- + 清 KV。
  单 run swarm.stopped 不触发吊销。实现 swarm_runtime.retain_run_for_user /
  release_run_and_maybe_terminate_pool(per-user retained 集 + 一次性 flag)。
- A.1 粒度:每用户一把、跨 run 复用;KV 命名 swarm-model-key-<user_id>(文档)。
- A.4 OPENAI_API_BASE:Swarm 部署常量(已实现),不经 create 下发(文档确认)。
- A.3 KV 读 RBAC:⚠ 待定(联调阻塞前置)——如实标注归属未敲定,不伪造已就绪。
- B.1:CLIENT_GUIDE §9 /events 游标改 after=<next_after>(HM 不透明游标,非 sequence)。

swarm.pool_terminated 为 HM 控制面生命周期事件,不入 FROZEN_CLIENT_EVENT_TYPES、
不渲染驾驶舱;payload secret_ref 为 azkv:// 引用(非明文 key)。

文档:runtime-contract §3.3.1(参数表)、event-schema(注册 + 说明)、
security-boundary §6(吊销握手 + RBAC 待定)。测试 scripts/test-key-injection-contract.py
(KV 格式解析 + 吊销握手:单 run/多 run 保留/不重复吊销/teardown 后重新武装)+ CI 步。

不动 Manager 面接口、HMAC 回调、审批链、计费/审计字段语义。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 17:57:15 +08:00

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-kv/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-kv/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())