Seed settings with an auto-minted temporary key when auth is on
Why: seed_settings PUTs /api/settings/user without credentials, so under the PG tier's always-on auth the seed always 401s and model config silently never lands (reproduced on the dev instance 2026-10-11; settings had to be written by hand). Evidence: make test-governance && make test exit=0 (receipt 8ffc3a89); tests/test_svc.py adds three stub-based cases - auth-on seeds with a Bearer header and create/revoke around the PUT, --seed-owner skips account lookup, auth-off stays credential-free; 14/14 svc tests green. Impact: deployments that pass SVC_MODEL_*/SVC_TAVILY_KEYS now seed reliably regardless of auth mode; a temporary apikey (name=svc-seed) is minted for the first admin (or --seed-owner/SVC_SEED_OWNER) and revoked right after; auth-off path unchanged. Refs: task card feature-svc-seed-auth.
This commit is contained in:
File diff suppressed because one or more lines are too long
+41
-2
@@ -46,6 +46,7 @@
|
||||
# <name> config <file.json> [--no-restart] agent 更换使用的配置文件(schema 校验通过才切换,
|
||||
# 运行中自动重启即时生效;--clear 移除;无参查看当前配置与生效值)
|
||||
# --model-base/--model-key/--model-name 与 --light-*/--tavily-keys 启动后播种设置
|
||||
# (认证开启时自动铸临时密钥完成写入,--seed-owner 指定目标账号)
|
||||
# -- <剩余参数> 原样透传给 agent-system serve(escape hatch)
|
||||
# db create:--user U(默认 agent)--password P(缺省自动生成)--db NAME --auth-mode scram-sha-256|md5
|
||||
# db backup:--db X --out F db restore:--file F --db X --force
|
||||
@@ -410,7 +411,10 @@ dotenv 格式:KEY=VALUE,支持 # 注释、成对引号、export 前缀;只
|
||||
SVC_MODEL_BASE/KEY/NAME 主模型(agent 启动健康后播种)
|
||||
SVC_LIGHT_BASE/KEY/NAME 轻量模型
|
||||
SVC_TAVILY_KEYS Tavily 密钥池
|
||||
SVC_SEED_OWNER 播种目标账号(缺省取库内首个管理员)
|
||||
播种幂等:设置持久化在 agent 数据目录,播过一次后续启动无需再传。
|
||||
认证开启(PG 档默认)时播种自动铸临时密钥(name=svc-seed,用后即回收),
|
||||
不依赖手动到设置页配置。
|
||||
|
||||
持久化语义:
|
||||
- deploy/start 成功(健康探测通过)后把当前态写入 svc.env(600,临时文件原子替换);
|
||||
@@ -531,6 +535,7 @@ CLI_ADDR=""; CLI_HOST=""; CLI_PORT=""; CLI_DATA=""; CLI_DSN=""
|
||||
CLI_AUTH=""; CLI_MOCK=0; CLI_PLUGINS=""; CLI_EXTRA=""; CLI_LOGS_N=""
|
||||
CLI_MBASE=""; CLI_MKEY=""; CLI_MNAME=""
|
||||
CLI_LBASE=""; CLI_LKEY=""; CLI_LNAME=""; CLI_TAVILY=""
|
||||
CLI_SEED_OWNER=""
|
||||
CLI_FROM=""; CLI_PICK=""; CLI_DRYRUN=0
|
||||
CLI_KIND=""; CLI_DIR=""; CLI_PURGE=0; CLI_REVEAL=0
|
||||
CLI_APPLY=0; CLI_FORCE=0; CLI_ONLINE=0; CLI_FILE=""; CLI_OUT=""
|
||||
@@ -558,6 +563,7 @@ parse_args() {
|
||||
--light-key) need_value "$@"; CLI_LKEY="$2"; shift 2 ;;
|
||||
--light-name) need_value "$@"; CLI_LNAME="$2"; shift 2 ;;
|
||||
--tavily-keys) need_value "$@"; CLI_TAVILY="$2"; shift 2 ;;
|
||||
--seed-owner) need_value "$@"; CLI_SEED_OWNER="$2"; shift 2 ;;
|
||||
--from) need_value "$@"; CLI_FROM="$2"; shift 2 ;;
|
||||
--pick) CLI_PICK="dev"
|
||||
case "${2:-}" in dev|release) CLI_PICK="$2"; shift 2 ;; *) shift ;; esac ;;
|
||||
@@ -1193,6 +1199,9 @@ print_agent_plan() {
|
||||
}
|
||||
|
||||
# seed_settings:把 CLI/环境变量给出的模型与搜索配置写入 settings(幂等)。
|
||||
# 认证开启(PG 档默认)时 PUT /api/settings/user 同样要求凭证:自动铸一把临时
|
||||
# API key(name=svc-seed)完成写入后立即回收;owner 缺省取库内首个管理员(回落
|
||||
# 首个账号),--seed-owner / SVC_SEED_OWNER 可指定。铸 key 失败维持无头 PUT(旧行为)。
|
||||
seed_settings() {
|
||||
local host="$1" port="$2"
|
||||
local cb="${CLI_MBASE:-${SVC_MODEL_BASE:-}}" ck="${CLI_MKEY:-${SVC_MODEL_KEY:-}}" cn="${CLI_MNAME:-${SVC_MODEL_NAME:-}}"
|
||||
@@ -1220,8 +1229,38 @@ if tk: out["tools"] = {"tavily": {"apiKeys": tk}}
|
||||
print(json.dumps(out))
|
||||
PY
|
||||
)"
|
||||
# 认证开启时的播种凭证:临时 key,明文只存在于本函数局部变量,不回显、不落盘。
|
||||
# apikey 子命令不读环境 DSN,只能经 CLI 传递(进程列表短暂可见;serve 走
|
||||
# AGENT_SYSTEM_DSN 环境注入,此路对 CLI 子命令不可用)。
|
||||
local seed_key="" seed_owner="${CLI_SEED_OWNER:-${SVC_SEED_OWNER:-}}"
|
||||
local -a seed_hdr=() seed_dbf=()
|
||||
if db_auth_enabled; then
|
||||
if [ "$DB_MODE" = pg ]; then seed_dbf=(--dsn "$DB_DSN"); else seed_dbf=(--data "$ENV_DATA"); fi
|
||||
if [ -z "$seed_owner" ]; then
|
||||
seed_owner="$("$ENV_BIN" user list ${seed_dbf[@]+"${seed_dbf[@]}"} 2>/dev/null | python3 -c 'import json, sys
|
||||
try:
|
||||
users = json.load(sys.stdin)
|
||||
except Exception:
|
||||
sys.exit(0)
|
||||
for u in users:
|
||||
if u.get("isAdmin"):
|
||||
print(u["owner"]); break
|
||||
else:
|
||||
for u in users:
|
||||
print(u["owner"]); break' 2>/dev/null || true)"
|
||||
fi
|
||||
if [ -n "$seed_owner" ]; then
|
||||
seed_key="$("$ENV_BIN" apikey create ${seed_dbf[@]+"${seed_dbf[@]}"} --owner "$seed_owner" --name svc-seed 2>/dev/null || true)"
|
||||
[ -n "$seed_key" ] && seed_hdr=(-H "Authorization: Bearer $seed_key")
|
||||
fi
|
||||
fi
|
||||
local resp
|
||||
resp="$(curl -s -m 5 -X PUT "http://$host:$port/api/settings/user" -H 'Content-Type: application/json' -d "$payload")"
|
||||
resp="$(curl -s -m 5 -X PUT "http://$host:$port/api/settings/user" -H 'Content-Type: application/json' ${seed_hdr[@]+"${seed_hdr[@]}"} -d "$payload")"
|
||||
# 用后即回收;失败只提示吊销途径,不回显任何密钥材料。
|
||||
if [ -n "$seed_key" ]; then
|
||||
"$ENV_BIN" apikey revoke ${seed_dbf[@]+"${seed_dbf[@]}"} --owner "$seed_owner" --name svc-seed >/dev/null 2>&1 \
|
||||
|| c_warn "[$ENV_NAME] 临时播种密钥回收失败:请手动吊销 owner=$seed_owner name=svc-seed"
|
||||
fi
|
||||
if echo "$resp" | grep -q '"ok": *true'; then
|
||||
local did=""
|
||||
[ -n "$cb$ck$cn" ] && did="主模型"
|
||||
@@ -1229,7 +1268,7 @@ PY
|
||||
[ -n "$tk" ] && did="${did:+$did、}Tavily"
|
||||
c_ok "[$ENV_NAME] 已播种设置:$did(持久化于 $ENV_DATA,后续启动无需再传)"
|
||||
else
|
||||
c_warn "[$ENV_NAME] 设置播种未成功:${resp:-<无响应>}(若开启了 API key 认证,需在设置页手动配置)"
|
||||
c_warn "[$ENV_NAME] 设置播种未成功:${resp:-<无响应>}(认证开启时需库内已有账号以铸临时密钥,或用 --seed-owner 指定;也可在设置页手动配置)"
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
+107
-1
@@ -58,10 +58,49 @@ case "${1:-}" in
|
||||
done
|
||||
port="${addr##*:}"; host="${addr%:*}"
|
||||
trap 'kill "$child" 2>/dev/null; exit 0' TERM INT
|
||||
python3 -m http.server "$port" --bind "$host" --directory "$trace_dir" >/dev/null 2>&1 &
|
||||
# GET/HEAD 一律 200(健康探测语义与 http.server 等价);PUT /api/* 捕获头与
|
||||
# 请求体到 trace/put.txt 供播种断言(settings 播种验证,2026-10-11)。
|
||||
python3 - "$port" "$host" "$trace_dir" <<'PYSRV' >/dev/null 2>&1 &
|
||||
import sys
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
port, host, trace = int(sys.argv[1]), sys.argv[2], sys.argv[3]
|
||||
class H(BaseHTTPRequestHandler):
|
||||
def _ok(self, body=b'{"ok":true}'):
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
self.send_header('Content-Length', str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
def do_GET(self):
|
||||
self._ok()
|
||||
def do_HEAD(self):
|
||||
self._ok(b'')
|
||||
def do_PUT(self):
|
||||
n = int(self.headers.get('Content-Length') or 0)
|
||||
body = self.rfile.read(n).decode('utf-8', 'replace')
|
||||
with open(trace + '/put.txt', 'a') as f:
|
||||
f.write('AUTH=%s\nBODY=%s\n--\n' % (self.headers.get('Authorization') or '-', body))
|
||||
self._ok()
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
HTTPServer((host, port), H).serve_forever()
|
||||
PYSRV
|
||||
child=$!
|
||||
wait "$child"
|
||||
;;
|
||||
user)
|
||||
printf 'user %s\n' "$*" >> "$trace_dir/cli.txt"
|
||||
case "${2:-}" in
|
||||
list) echo '[{"owner":"op","name":"op","isAdmin":true,"mustChangePassword":false},{"owner":"plain","name":"plain","isAdmin":false,"mustChangePassword":false}]' ;;
|
||||
esac
|
||||
exit 0 ;;
|
||||
apikey)
|
||||
printf 'apikey %s\n' "$*" >> "$trace_dir/cli.txt"
|
||||
case "${2:-}" in
|
||||
create) echo "ask_stub_seed_key" ;;
|
||||
revoke) echo "revoked" ;;
|
||||
esac
|
||||
exit 0 ;;
|
||||
*)
|
||||
exit 0 ;;
|
||||
esac
|
||||
@@ -563,5 +602,72 @@ class SvcDbRecoveryTests(unittest.TestCase):
|
||||
self.assertIn("AGENT_SYSTEM_DSN=%s" % dsn, self.serve_record())
|
||||
|
||||
|
||||
# ---- 12. 播种认证(2026-10-11 修复):认证开启自动铸临时 key,用后回收 ----
|
||||
|
||||
def put_record(self):
|
||||
f = self.trace / "put.txt"
|
||||
return f.read_text() if f.exists() else ""
|
||||
|
||||
def cli_record(self):
|
||||
f = self.trace / "cli.txt"
|
||||
return f.read_text() if f.exists() else ""
|
||||
|
||||
def model_env(self, **extra):
|
||||
env = dict(self.env)
|
||||
env.update({
|
||||
"SVC_MODEL_BASE": "https://stub.example/v1",
|
||||
"SVC_MODEL_KEY": "sk-stub-model",
|
||||
"SVC_MODEL_NAME": "stub-model",
|
||||
})
|
||||
env.update(extra)
|
||||
return env
|
||||
|
||||
def test_seed_puts_with_temporary_bearer_key_when_auth_on(self):
|
||||
self.register_pg("pg1", 18831, password="seed-pw-1")
|
||||
dsn = "postgresql://agent:seed-pw-1@127.0.0.1:18831/agent"
|
||||
d = self.runtime("dev")
|
||||
self.seed_agent("dev", 18832,
|
||||
"--addr 127.0.0.1:18832 --data %s --plugins %s --auth auto --dsn %s"
|
||||
% (d / "data", d / "plugins", dsn))
|
||||
result = self.svc("dev", "start", env=self.model_env())
|
||||
self.assertIn("已播种设置:主模型", result.stdout)
|
||||
put = self.put_record()
|
||||
self.assertIn("AUTH=Bearer ask_stub_seed_key", put)
|
||||
body_part = put.split("BODY=", 1)[1]
|
||||
self.assertIn('"model": "stub-model"', body_part)
|
||||
self.assertNotIn("ask_stub_seed_key", body_part) # 临时 key 不进请求体
|
||||
cli = self.cli_record()
|
||||
# owner 解析:user list 首个管理员(op)→ 铸 key → 播种后回收。
|
||||
self.assertIn("user list --dsn %s" % dsn, cli)
|
||||
self.assertIn("apikey create --dsn %s --owner op --name svc-seed" % dsn, cli)
|
||||
self.assertIn("apikey revoke --dsn %s --owner op --name svc-seed" % dsn, cli)
|
||||
|
||||
def test_seed_owner_override_skips_account_lookup(self):
|
||||
self.register_pg("pg1", 18833, password="seed-pw-2")
|
||||
dsn = "postgresql://agent:seed-pw-2@127.0.0.1:18833/agent"
|
||||
d = self.runtime("dev")
|
||||
self.seed_agent("dev", 18834,
|
||||
"--addr 127.0.0.1:18834 --data %s --plugins %s --auth auto --dsn %s"
|
||||
% (d / "data", d / "plugins", dsn))
|
||||
result = self.svc("dev", "start", env=self.model_env(SVC_SEED_OWNER="boss"))
|
||||
self.assertIn("已播种设置:主模型", result.stdout)
|
||||
self.assertIn("AUTH=Bearer ask_stub_seed_key", self.put_record())
|
||||
cli = self.cli_record()
|
||||
self.assertIn("--owner boss --name svc-seed", cli)
|
||||
self.assertNotIn("user list", cli) # 显式指定即跳过账号解析
|
||||
|
||||
def test_seed_puts_without_credentials_when_auth_off(self):
|
||||
d = self.runtime("dev")
|
||||
self.seed_agent("dev", 18835,
|
||||
"--addr 127.0.0.1:18835 --data %s --plugins %s --auth auto"
|
||||
% (d / "data", d / "plugins"))
|
||||
result = self.svc("dev", "start", env=self.model_env())
|
||||
self.assertIn("已播种设置:主模型", result.stdout)
|
||||
put = self.put_record()
|
||||
self.assertIn("AUTH=-", put)
|
||||
self.assertIn('"model": "stub-model"', put.split("BODY=", 1)[1])
|
||||
self.assertNotIn("apikey", self.cli_record()) # 认证关闭不铸 key
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user