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:
agent-exec-transport
2026-10-21 14:58:38 +00:00
committed by weichen
co-authored by Cursor
parent ddbf9aec90
commit 1edf326448
3 changed files with 261 additions and 0 deletions
+194
View File
@@ -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()