Remember the super-admin credential file path in svc
Auth-on agent services need super-admin credentials at every launch, but svc only forwarded them from the current environment, and recover/watchdog relaunch with a cleared environment, so a bare restart or recovery failed closed. svc now records only the absolute path of AGENT_SYSTEM_ENV_FILE as superadmin_envfile= after checking it is a regular, owner-only (0600) file before stopping anything, and forwards that path when no explicit credentials are set. An empty AGENT_SYSTEM_ENV_FILE clears the record. The file content is never read, copied or printed. Why: prod restarts and recovery required re-exporting AGENT_SYSTEM_ENV_FILE. Evidence: agent-exec unit dev-svc-superadmin-envfile-persist (cursor, attempt 1, terminal=success); unittest test_svc_superadmin_envfile + test_svc_recover + test_svc_identity: 51 OK (1 skipped). Impact: svc.sh agent launch and svc.env gain superadmin_envfile; docs/svc.md 4.2 note. Refs: Dev task fix/svc-superadmin-envfile-surface-conv. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
ddbf9aec90
commit
1edf326448
@@ -189,6 +189,12 @@ scripts/svc.sh <name> status # 单服务详情
|
||||
- 显式给任何运行参数(或 `--config` 文件、`SVC_*` 变量)时**整体重建**参数;
|
||||
- agent/static 换端口:`restart --port N`(进程与登记同步更新);
|
||||
- postgres 换端口:`set --apply`(见 §7.4)或 `restart --port N` 同样有效。
|
||||
- **超级管理员凭据文件(agent,`superadmin_envfile=`)**:在 deploy/start/restart 时传一次
|
||||
`AGENT_SYSTEM_ENV_FILE=<文件>`,svc 先校验(普通文件、属当前用户、权限 0600 即无组/其他位,
|
||||
不合格则在停旧服务前拒绝),再只把**绝对路径**记入 svc.env(从不读取、复制或打印文件内容);
|
||||
此后裸 `restart`、`recover`/watchdog 拉起都复用该路径。本次环境显式设置的
|
||||
`SUPER_ADMIN_OWNER`/`SUPER_ADMIN_PASSWORD`/`AGENT_SYSTEM_ENV_FILE` 优先于登记;
|
||||
`AGENT_SYSTEM_ENV_FILE=`(空值)清除登记;登记文件已不存在时告警且不转发(serve 按缺凭据拒启)。
|
||||
|
||||
`status` 输出示例(postgres):
|
||||
|
||||
|
||||
@@ -942,6 +942,7 @@ svc_merge_tags() {
|
||||
# (托管绑定不重复存 DSN,外部直连存受保护 dsn=);未走解析的调用保持旧值。
|
||||
record_state() {
|
||||
local pid prev_src prev_dbref prev_conffile prev_db prev_dsn prev_envfile prev_plugins plugins_out merged_tags
|
||||
local prev_saenv
|
||||
pid="$(read_pid)"
|
||||
prev_src=""
|
||||
[ -f "$ENV_DIR/svc.env" ] && prev_src="$(svc_get source)"
|
||||
@@ -950,6 +951,7 @@ record_state() {
|
||||
prev_db="$(svc_get db)"
|
||||
prev_dsn="$(svc_get dsn)"
|
||||
prev_envfile="$(svc_get envfile)"
|
||||
prev_saenv="$(svc_get superadmin_envfile)"
|
||||
prev_plugins="$(svc_get plugins)"
|
||||
plugins_out="$prev_plugins"
|
||||
# 只有激活校验和 UI 探活都通过之后才提交新的 plugins=。中途 record_state
|
||||
@@ -999,6 +1001,11 @@ record_state() {
|
||||
elif [ -n "$prev_envfile" ]; then
|
||||
printf 'envfile=%s\n' "$prev_envfile"
|
||||
fi
|
||||
if [ -n "${SUPERADMIN_ENVFILE_NEW:-}" ]; then
|
||||
printf 'superadmin_envfile=%s\n' "$SUPERADMIN_ENVFILE_NEW"
|
||||
elif [ "${SUPERADMIN_ENVFILE_CLEAR:-0}" != 1 ] && [ -n "$prev_saenv" ]; then
|
||||
printf 'superadmin_envfile=%s\n' "$prev_saenv"
|
||||
fi
|
||||
[ -n "$plugins_out" ] && printf 'plugins=%s\n' "$plugins_out"
|
||||
[ $# -gt 0 ] && printf '%s\n' "$@"
|
||||
} > "$tmp"
|
||||
@@ -1529,6 +1536,38 @@ svc_write_serve_env() {
|
||||
chmod 0600 "$f" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# svc_superadmin_envfile_check:agent 的 start/restart/deploy 在停旧服务前校验本次
|
||||
# AGENT_SYSTEM_ENV_FILE(只看路径与元数据,绝不读内容)。非空须为当前用户所有、
|
||||
# 无组/其他权限位的普通文件,通过后由 record_state 记为 superadmin_envfile=;
|
||||
# 已设置但为空则清除登记。
|
||||
SUPERADMIN_ENVFILE_NEW=""
|
||||
SUPERADMIN_ENVFILE_CLEAR=0
|
||||
svc_superadmin_envfile_check() {
|
||||
[ -n "${AGENT_SYSTEM_ENV_FILE+x}" ] || return 0
|
||||
if [ -z "$AGENT_SYSTEM_ENV_FILE" ]; then
|
||||
SUPERADMIN_ENVFILE_CLEAR=1
|
||||
return 0
|
||||
fi
|
||||
local f="$AGENT_SYSTEM_ENV_FILE" dir meta owner mode
|
||||
dir="$(cd "$(dirname "$f")" 2>/dev/null && pwd)" || dir=""
|
||||
[ -n "$dir" ] && f="${dir%/}/$(basename "$f")"
|
||||
if [ -z "$dir" ] || [ ! -f "$f" ]; then
|
||||
c_err "[$ENV_NAME] 超级管理员凭据文件不存在或不是普通文件:$f(运行中的服务未改动)"
|
||||
return 1
|
||||
fi
|
||||
meta="$(stat -L -c '%u %a' "$f" 2>/dev/null)" || meta=""
|
||||
owner="${meta%% *}"; mode="${meta##* }"
|
||||
if [ -z "$meta" ] || [ "$owner" != "$(id -u)" ]; then
|
||||
c_err "[$ENV_NAME] 超级管理员凭据文件不属于当前用户:$f(运行中的服务未改动)"
|
||||
return 1
|
||||
fi
|
||||
if [ $(( 8#$mode & 8#077 )) -ne 0 ]; then
|
||||
c_err "[$ENV_NAME] 超级管理员凭据文件权限过宽($mode):$f,须 chmod 600(运行中的服务未改动)"
|
||||
return 1
|
||||
fi
|
||||
SUPERADMIN_ENVFILE_NEW="$f"
|
||||
}
|
||||
|
||||
# svc_launch_serve ARGS:env -i 只带 serve.env 与本次构造的 guard_env。
|
||||
svc_launch_serve() {
|
||||
local exec_args="$1"
|
||||
@@ -1558,11 +1597,25 @@ svc_launch_serve() {
|
||||
args+=("RDM_DISABLE_EXEC_TOOLS=$RDM_DISABLE_EXEC_TOOLS")
|
||||
fi
|
||||
# 超级管理员凭据只从本次环境转发,不写入 serve.env;缺失时由 serve 自行 fail closed。
|
||||
# 本次环境三者皆未设置时回落到 svc.env 登记的 superadmin_envfile=(只转发路径),
|
||||
# recover/watchdog 的清空环境子进程因此也能拉起认证实例。
|
||||
local sa_explicit=0 sa_file
|
||||
for k in SUPER_ADMIN_OWNER SUPER_ADMIN_PASSWORD AGENT_SYSTEM_ENV_FILE; do
|
||||
if [ -n "${!k+x}" ]; then
|
||||
args+=("$k=${!k}")
|
||||
sa_explicit=1
|
||||
fi
|
||||
done
|
||||
if [ "$sa_explicit" = 0 ]; then
|
||||
sa_file="$(svc_get superadmin_envfile)"
|
||||
if [ -n "$sa_file" ]; then
|
||||
if [ -f "$sa_file" ]; then
|
||||
args+=("AGENT_SYSTEM_ENV_FILE=$sa_file")
|
||||
else
|
||||
c_warn "[$ENV_NAME] 登记的超级管理员凭据文件已不存在:$sa_file(不转发,serve 将按缺凭据拒启)"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
# shellcheck disable=SC2086
|
||||
nohup "${args[@]}" "$ENV_BIN" serve $exec_args >> "$ENV_DIR/svc.log" 2>&1 9>&- &
|
||||
}
|
||||
@@ -5576,6 +5629,8 @@ env_status_detail() {
|
||||
cfile="$(svc_get conffile)"
|
||||
[ -z "$cfile" ] && cfile="$(printf '%s' "$(svc_get exec)" | sed -n 's/.*--config \([^ ]*\).*/\1/p')"
|
||||
printf ' 配置文件 %s\n' "${cfile:-无(命令行参数形态;<name> config <file> 绑定)}"
|
||||
local saf; saf="$(svc_get superadmin_envfile)"
|
||||
[ -n "$saf" ] && printf ' 超管凭据文件 %s(仅登记路径)\n' "$saf"
|
||||
printf ' 监听守卫 insecureListen=%s allowedHosts=%s\n' \
|
||||
"$(env_var "$ENV_NAME" INSECURE_LISTEN)" \
|
||||
"$(env_var "$ENV_NAME" ALLOWED_HOSTS)"
|
||||
@@ -6452,6 +6507,12 @@ main() {
|
||||
exit 2
|
||||
fi
|
||||
env_config "$ENV_NAME"
|
||||
case "$action" in
|
||||
start|restart|deploy)
|
||||
if [ "$SVC_KIND" = agent ]; then
|
||||
svc_superadmin_envfile_check || exit 1
|
||||
fi ;;
|
||||
esac
|
||||
# 生命周期动作持 .start.lock(wait 模式;进程退出自动释放)——deploy 全程含
|
||||
# 构建窗口,防 recover/并发 start 在停机-换产物间隙抢跑。只读动作不加锁。
|
||||
case "$action" in
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
"""svc.sh 超级管理员凭据文件路径登记回归(superadmin_envfile=)。
|
||||
|
||||
deploy/start/restart 时传一次 AGENT_SYSTEM_ENV_FILE:svc 校验(普通文件、属当前用户、
|
||||
无组/其他权限位)后只登记路径;裸 restart 与 recover(清空环境的生命周期子进程)
|
||||
复用该路径转发给 serve;权限过宽在停旧服务前拒绝;空值清除登记;svc.env/svc.log
|
||||
绝不出现文件内容。桩二进制与夹具复用 tests/test_svc_recover.py。
|
||||
"""
|
||||
import importlib.util
|
||||
import os
|
||||
import shutil
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
SOURCE = Path(__file__).resolve().parents[1]
|
||||
|
||||
_spec = importlib.util.spec_from_file_location("tsr", SOURCE / "tests" / "test_svc_recover.py")
|
||||
tsr = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(tsr)
|
||||
|
||||
# 桩 serve 额外记录实际收到的 AGENT_SYSTEM_ENV_FILE(未设置记 <unset>)。
|
||||
_ANCHOR = '> "$trace_dir/serve.txt"\n'
|
||||
assert _ANCHOR in tsr.STUB
|
||||
STUB = tsr.STUB.replace(
|
||||
_ANCHOR,
|
||||
_ANCHOR + r""" printf 'AGENT_SYSTEM_ENV_FILE=%s\n' "${AGENT_SYSTEM_ENV_FILE-<unset>}" >> "$trace_dir/serve.txt"
|
||||
""", 1)
|
||||
|
||||
PASSWORD = "Dummy-SA-Pw-9f4c2e71-do-not-leak"
|
||||
UNSET = "<unset>"
|
||||
|
||||
|
||||
class SuperAdminEnvfileTests(tsr.SvcAutoRecoverTests):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
shutil.copy2(SOURCE / "scripts" / "svc_manifest.py",
|
||||
self.root / "scripts" / "svc_manifest.py")
|
||||
self.stub.write_text(STUB)
|
||||
for key in ("AGENT_SYSTEM_ENV_FILE", "SUPER_ADMIN_OWNER", "SUPER_ADMIN_PASSWORD"):
|
||||
self.env.pop(key, None)
|
||||
self.secrets = self.root / "secrets"
|
||||
self.secrets.mkdir(mode=0o700)
|
||||
self.cred = self.make_cred("sa.env", 0o600)
|
||||
|
||||
# ---- 夹具 ----
|
||||
|
||||
def make_cred(self, name, mode):
|
||||
p = self.secrets / name
|
||||
p.write_text("SUPER_ADMIN_OWNER=op\nSUPER_ADMIN_PASSWORD=%s\n" % PASSWORD)
|
||||
p.chmod(mode)
|
||||
return p
|
||||
|
||||
def env_with(self, **extra):
|
||||
env = dict(self.env)
|
||||
env.update(extra)
|
||||
return env
|
||||
|
||||
def served_envfile(self):
|
||||
for line in (self.trace / "serve.txt").read_text().splitlines():
|
||||
if line.startswith("AGENT_SYSTEM_ENV_FILE="):
|
||||
return line.split("=", 1)[1]
|
||||
return None
|
||||
|
||||
def fresh_trace(self):
|
||||
f = self.trace / "serve.txt"
|
||||
if f.exists():
|
||||
f.unlink()
|
||||
|
||||
def assert_same_path(self, recorded, expected):
|
||||
self.assertIsNotNone(recorded)
|
||||
self.assertTrue(os.path.isabs(recorded), recorded)
|
||||
self.assertEqual(os.path.realpath(recorded), os.path.realpath(expected))
|
||||
|
||||
def assert_no_secret(self, name, *outputs):
|
||||
for fname in ("svc.env", "svc.log", "manifest.json", "serve.env", "history.jsonl"):
|
||||
p = self.runtime(name) / fname
|
||||
if p.exists():
|
||||
self.assertNotIn(PASSWORD, p.read_text(errors="replace"), fname)
|
||||
for out in outputs:
|
||||
self.assertNotIn(PASSWORD, out)
|
||||
|
||||
def start_with_cred(self, name, cred=None):
|
||||
result = self.svc(name, "start",
|
||||
env=self.env_with(AGENT_SYSTEM_ENV_FILE=str(cred or self.cred)))
|
||||
self.assertIn("已启动", result.stdout)
|
||||
return result
|
||||
|
||||
# ---- 用例 ----
|
||||
|
||||
def test_sa_start_records_path_and_bare_restart_reuses_it(self):
|
||||
self.seed_agent("dev", tsr.free_fixture_port())
|
||||
r1 = self.start_with_cred("dev")
|
||||
self.assert_same_path(self.kv("dev", "superadmin_envfile"), self.cred)
|
||||
self.assert_same_path(self.served_envfile(), self.cred)
|
||||
status = self.svc("dev", "status")
|
||||
self.assertIn("超管凭据文件", status.stdout)
|
||||
self.fresh_trace()
|
||||
r2 = self.svc("dev", "restart") # 环境里不再有 AGENT_SYSTEM_ENV_FILE
|
||||
self.assertIn("已启动", r2.stdout)
|
||||
self.assert_same_path(self.served_envfile(), self.cred)
|
||||
self.assert_same_path(self.kv("dev", "superadmin_envfile"), self.cred)
|
||||
self.assert_no_secret("dev", r1.stdout, r2.stdout, status.stdout)
|
||||
|
||||
def test_sa_relative_path_recorded_absolute(self):
|
||||
self.seed_agent("dev", tsr.free_fixture_port())
|
||||
result = self.svc("dev", "start",
|
||||
env=self.env_with(AGENT_SYSTEM_ENV_FILE="secrets/sa.env"))
|
||||
self.assertIn("已启动", result.stdout)
|
||||
self.assert_same_path(self.kv("dev", "superadmin_envfile"), self.cred)
|
||||
|
||||
def test_sa_recover_forwards_recorded_path(self):
|
||||
self.seed_agent("dev", tsr.free_fixture_port())
|
||||
self.start_with_cred("dev")
|
||||
old_pid = self.kill9("dev")
|
||||
self.fresh_trace()
|
||||
result = self.svc("recover", "--quiet")
|
||||
self.assertIn("recover 汇总:拉回 1", result.stdout)
|
||||
new_pid = self.pid_of("dev")
|
||||
self.assertTrue(self.pid_alive(new_pid))
|
||||
self.assertNotEqual(new_pid, old_pid)
|
||||
self.assert_same_path(self.served_envfile(), self.cred)
|
||||
self.assert_same_path(self.kv("dev", "superadmin_envfile"), self.cred)
|
||||
self.assert_no_secret("dev", result.stdout)
|
||||
|
||||
def test_sa_loose_permissions_refused_before_stop(self):
|
||||
self.seed_agent("dev", tsr.free_fixture_port())
|
||||
self.start_ok("dev")
|
||||
pid = self.pid_of("dev")
|
||||
before = (self.runtime("dev") / "svc.env").read_bytes()
|
||||
loose = self.make_cred("loose.env", 0o644)
|
||||
result = self.svc("dev", "restart", check=False,
|
||||
env=self.env_with(AGENT_SYSTEM_ENV_FILE=str(loose)))
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertIn("权限过宽", result.stdout)
|
||||
self.assertIn(str(loose), result.stdout)
|
||||
self.assertEqual(self.pid_of("dev"), pid)
|
||||
self.assertTrue(self.pid_alive(pid), "拒绝时不得停掉运行中的服务")
|
||||
self.assertEqual((self.runtime("dev") / "svc.env").read_bytes(), before)
|
||||
self.assert_no_secret("dev", result.stdout)
|
||||
|
||||
def test_sa_missing_file_refused_before_stop(self):
|
||||
self.seed_agent("dev", tsr.free_fixture_port())
|
||||
self.start_ok("dev")
|
||||
pid = self.pid_of("dev")
|
||||
result = self.svc("dev", "restart", check=False,
|
||||
env=self.env_with(AGENT_SYSTEM_ENV_FILE=str(self.secrets / "nope.env")))
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertIn("nope.env", result.stdout)
|
||||
self.assertTrue(self.pid_alive(pid))
|
||||
self.assertEqual(self.pid_of("dev"), pid)
|
||||
|
||||
def test_sa_empty_value_clears_key(self):
|
||||
self.seed_agent("dev", tsr.free_fixture_port())
|
||||
self.start_with_cred("dev")
|
||||
self.assertIsNotNone(self.kv("dev", "superadmin_envfile"))
|
||||
self.fresh_trace()
|
||||
self.svc("dev", "restart", env=self.env_with(AGENT_SYSTEM_ENV_FILE=""))
|
||||
self.assertIsNone(self.kv("dev", "superadmin_envfile"))
|
||||
self.assertEqual(self.served_envfile(), "")
|
||||
self.fresh_trace()
|
||||
self.svc("dev", "restart")
|
||||
self.assertEqual(self.served_envfile(), UNSET)
|
||||
|
||||
def test_sa_explicit_environment_overrides_record(self):
|
||||
self.seed_agent("dev", tsr.free_fixture_port())
|
||||
self.start_with_cred("dev")
|
||||
self.fresh_trace()
|
||||
r = self.svc("dev", "restart", env=self.env_with(SUPER_ADMIN_OWNER="op"))
|
||||
self.assertEqual(self.served_envfile(), UNSET, "显式凭据环境优先,不回落登记路径")
|
||||
self.assert_same_path(self.kv("dev", "superadmin_envfile"), self.cred)
|
||||
other = self.make_cred("other.env", 0o600)
|
||||
self.fresh_trace()
|
||||
self.svc("dev", "restart", env=self.env_with(AGENT_SYSTEM_ENV_FILE=str(other)))
|
||||
self.assert_same_path(self.served_envfile(), other)
|
||||
self.assert_same_path(self.kv("dev", "superadmin_envfile"), other)
|
||||
self.assert_no_secret("dev", r.stdout)
|
||||
|
||||
def test_sa_recorded_file_gone_warns_and_skips(self):
|
||||
self.seed_agent("dev", tsr.free_fixture_port())
|
||||
self.start_with_cred("dev")
|
||||
self.cred.unlink()
|
||||
self.fresh_trace()
|
||||
result = self.svc("dev", "restart")
|
||||
self.assertIn("已不存在", result.stdout)
|
||||
self.assertEqual(self.served_envfile(), UNSET)
|
||||
|
||||
|
||||
# 只跑本文件用例;继承来的自恢复用例由 tests/test_svc_recover.py 负责。
|
||||
for _name in dir(tsr.SvcAutoRecoverTests):
|
||||
if _name.startswith("test_") and _name not in SuperAdminEnvfileTests.__dict__:
|
||||
setattr(SuperAdminEnvfileTests, _name, None)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user