事情发生在周四晚上,我本来只想用 deepseek harness 跑一个拖了两天的 code review 任务,结果 skill 一加载就崩,日志里翻来覆去只有一行诡异的报错:setnamedsecurityinfow failed (win32)。我一开始觉得这只是个小问题,结果重启、清缓存、换目录、重装插件全试了一遍,它就像长在日志里一样纹丝不动。等我把根因真正挖出来、补丁合进去再跑通全量回归,已经是凌晨两点。这期间踩的坑和最后找到的答案,我觉得值得单独写一篇出来,给同样在折腾 harness 权限问题的朋友当个参考。
先说清楚这篇东西适合谁:你在用 deepseek harness 做本地 coding 开发,或者打算把它部署到内网服务器、离线局域网,又或者正在为 skill 加载、插件安装各种玄学报错头疼,那这篇就是给你准备的。我会把 bug 的完整排查链路、Windows 权限模型里几个关键概念、以及修复时的配置和代码都摊开讲。
1. 先说结论:deepseek harness 是个什么东西
1.1 模型之外的那层“外壳”
很多人以为拿了 DeepSeek 的 API key、写两行代码调用一下,就算接入大模型了。但真要把模型用成“能干活”的 agent——自动读代码、改文件、跑命令、汇总结果——中间还隔着一层东西,就是 harness。
你完全可以把它理解成“发动机和整车的区别”。模型是发动机,负责生成文字、推理意图;harness 是底盘、转向、刹车和仪表盘,负责决定模型下一步到底能不能执行、用什么权限执行、执行结果怎么回到对话上下文里。没有 harness 的模型是裸的 API 调用,有了 harness,它才变成一个受控的、可复用的智能体工具链。
当时我搭的这套 deepseek harness,本质上就是一个围绕 DeepSeek 模型的 agent 执行框架:它管理多轮对话的上下文窗口,定义工具调用协议,控制文件读写权限,还负责把执行结果反馈给模型继续推理。这些说起来不复杂,但真正堆到一起就很容易出幺蛾子——尤其当你让它跑在 Windows 上,并且是通过计划任务或者后台服务方式启动的时候。
1.2 我实际怎么用它:skill、插件和模型后端
我的主战场有两个。一个是 coding 方向的日常任务,比如给存量项目做 code review、按 issue 整理修改点、批量重构小函数;另一个是研究场景,比如让它帮我读一堆 PDF 然后按综述结构输出笔记。这两个场景都依赖 harness 的 skill 机制。
skill 在 harness 里就是“预置的系统提示词 + 一组可执行工具脚本”,相当于给模型预装了一套岗位 SOP。比如我常用的 code_review skill,里面定义好了审查的检查项、输出格式,以及一组 git diff 解析脚本。这套 skill 在正常手动启动时没问题,但这次偏偏挂在“skill 加载”这个环节之前的一个安全初始化阶段。
插件则更像“外挂工具箱”,主要给工具层做扩展。deepseek harness 社区的插件从提示词优化、git 操作增强,到知识库检索都有。当时我机器上装了一个 prompt 优化插件和一个 code review 增强插件,后面排查时我一度怀疑是它们打架,后来证明它们是无辜的。
模型后端我也有两套:一套直连 DeepSeek 官方 API,负责日常任务;另一套是本地用 vllm 部署的开源模型,负责离线跑批和隐私敏感的数据。这次的 bug 与模型后端无关,因为它发生在“模型还没开始推理”之前的 skill 环境初始化阶段,但也正是因为卡得够早,我才能快速把模型因素排除掉。
2. 事故现场与完整排查链路
2.1 报错长什么样,触发条件有哪些
故障现象是这样的:在 PowerShell 里启动 harness,然后执行skill load code_review,几秒之后控制台输出一段日志:
[skill-manager][INFO] mounting skill: code_review [skill-manager][INFO] pre-mount security check... [skill-security][ERROR] setnamedsecurityinfow failed (win32) [code=5] [skill-manager][ERROR] skill_mount aborted: security init error [skill-manager][INFO] fallback to degraded mode, skill reads denied报错代码 5 对应的是ERROR_ACCESS_DENIED,被拒绝访问了。日志虽然短,但信息量其实不少:它是在“pre-mount security check”这个环节挂的,也就是 harness 想在 skill 加载前对 skill 目录做一次安全加固,结果这个加固动作本身被系统拒绝了。
关键触发条件我记了一下:
- 手动双击启动 harness 时,同样配置不报错。
- 通过计划任务“不管用户是否登录都运行”的方式启动,必现。
- skill 装到 C 盘、D 盘、E 盘都试过,与磁盘位置无关。
- 与插件数量无关,禁用全部插件也一样。
- 与模型后端无关,切到本地 vllm 也一样。
这组条件特别重要,因为它直接指向了“进程运行上下文”而不是“配置内容”。我后来复盘时也意识到,凌晨那会儿如果我早一点注意到“手动跑没事、计划任务跑必现”这条线索,至少能少折腾半个多小时。
2.2 按时间线一路查到凌晨
晚上九点:复现 bug,心态还行,以为是缓存问题。
九点二十分:重启 harness、清理整个 skill 缓存目录、删掉 skill 重新 clone,再跑,报错原封不动。这时候我意识到不是简单的脏缓存。
九点四十分:打开 harness 的 debug 日志,看到报错来自skill_security模块的一个叫_lockdown_skill_dir的函数。这个函数名字暗示它是要给 skill 目录做“锁定”操作,而锁定的手段大概率就是改 ACL。
十点十分:怀疑插件冲突,把所有插件禁用掉,依然报错。排除插件因素。
十点四十分:把 skill 目录从带空格的路径挪到E:\agent_workspace\skills\这种简单路径下,还是在同一个地方失败。排除长路径和空格问题。
十一点:切换到 WSL 里的 Linux 环境,用同一套 harness 配置跑相同的 skill,完全正常。到这里就非常明确了:这是 Windows 平台某个环节的专属问题。
十一点半:直接去看skill_security.py源码,果然在_lockdown_skill_dir里发现调用了SetNamedSecurityInfoW,也就是 Windows 的“设置命名对象安全信息”API。这时候我心里其实已经有预感,问题大概率出在调用这个 API 的前置条件上。
凌晨十二点多:用 Process Monitor(Sysinternals 工具)抓了一次调用过程,结果里清晰显示操作类型是SetSecurityFile,结果是ACCESS DENIED,调用方进程就是 harness 自己。实锤了,不是杀毒软件拦的,是系统层的权限拒绝。
凌晨一点:花时间把当前进程的令牌和特权打出来,发现它缺少SeTakeOwnershipPrivilege和SeRestorePrivilege。那一刻我基本就知道根因了。
凌晨一点半:写了最小复现脚本,确认“有特权就能过、没特权就必现”。两点整:把修复补丁合进本地安装环境,跑完一轮完整的 skill 加载和 code review 回归,一切正常。
回头再看这条时间线,真正耗时间的不是“找不到”,而是“没往权限上想”。因为平时手动跑都是好好的,谁能想到换成计划任务就变“半个管理员”了呢。
2.3 为什么我一开始没往权限上想
这也是我想专门拿出来说的一点:Windows 下权限相关的问题特别容易迷惑人,因为它跟直觉差距很大。
我当时用的账户本身就在 Administrators 组里,PowerShell 执行whoami也显示是管理员。而且我在同一个 skill 目录下手工创建文件、删除文件都正常,说明这个目录的 NTFS 权限是允许当前用户读写的。既然文件和目录本身都能访问,为什么系统还返回拒绝?
问题出在“能读写文件”和“能修改文件的安全描述符”是两件事。前者走的是 DACL 对当前用户授予的读取/写入权限,后者需要的是WRITE_DAC和WRITE_OWNER这样的特殊权限,以及对应的特权(Privilege)。平时我用记事本或者普通 shell 在这个目录干活,根本不需要碰安全描述符;而 harness 的 skill 安全模块要做的恰恰是改 ACL、设置 owner,于是立刻就撞上权限墙了。
更阴险的是,Windows 的 UAC 特权过滤机制决定了:就算你的账户是 Administrators 组成员,当你以非提升方式运行进程时,系统会把令牌里大部分高权限给它“过滤”掉。很多调试工具也会在界面上显示“以管理员身份运行”,导致我默认认为当前上下文里特权是全的。这也是为什么我一直到凌晨才想起来去翻进程令牌里的特权列表。
3. root cause 深挖:一个 win32 API 引发的血案
3.1 SetNamedSecurityInfoW 到底在干什么
简单来说,SetNamedSecurityInfoW是 Windows 提供给开发者修改文件、目录、注册表键等“命名对象”安全属性的标准 API。它能够设置对象的属主(Owner)、所属组(Group)、DACL 和 SACL,也就是把一个对象的安全配置整体重写一遍。
它在 harness 里被用来做目录“锁定”:skill 加载前,harness 会把这个目录的 owner 改成当前用户,然后通过 DACL 把访问权限收紧到“只有当前用户和 SYSTEM 能访问”,确保 skill 里内置的脚本不会在未经授权的情况下被其他进程读取或篡改。思路是好的,但调用这个 API 是有前置条件的,而且文档里写得很清楚:
- 要设置 owner,进程必须持有
SeTakeOwnershipPrivilege特权。 - 要修改不是自己创建对象的 DACL,要么对该对象拥有
WRITE_DAC权限,要么持有SeRestorePrivilege或SeBackupPrivilege这类高权限。 - 要设置 SACL,还必须持有
SeSecurityPrivilege。
你把这套规则跟 UAC 的特权过滤机制放在一起看,就知道为什么这个 bug 会跟“进程怎么启动的”强相关了:手动双击启动时,如果 harness 的主程序包含requireAdministrator的 manifest,或者用户右键管理员运行,那么进程令牌就是“提升后”的完整令牌,上述特权都在,API 调用自然成功;但通过计划任务以“不管用户是否登录都运行”启动时,系统给进程分发的是过滤后的普通令牌,特权列表里缺了一大截,调用SetNamedSecurityInfoW自然就失败。
3.2 我这边真正的坑:计划任务把我跑成了“半个管理员”
我之所以踩到这个坑,是因为当天想把一个长时间运行的代码分析任务挂到后台跑,就顺手用 Windows 计划任务启动 harness,配置勾的是“不管用户是否登录都运行”加“使用最高权限运行”没勾。这个场景本来很常见,谁能想到安全模型的差异会导致一个必现 bug。
来看具体现象。同一个 harness 安装目录,同一个 skill 包,手动启动时:
whoami /all | findstr "SeTakeOwnershipPrivilege SeRestorePrivilege" SeTakeOwnershipPrivilege SeTakeOwnershipPrivilege 禁用 SeRestorePrivilege SeRestorePrivilege 禁用注意,即使是提升后的令牌,这两个特权默认也可能是未启用的,但它们存在于令牌里、随时可以用 enable 函数打开。而通过计划任务启动时:
whoami /all | findstr "SeTakeOwnershipPrivilege SeRestorePrivilege"什么都不显示,说明这两个特权压根不在令牌里。这种情况下,就算你想在代码里调用AdjustTokenPrivileges去启用它,也会因为令牌里不存在该特权而失败。
所以,问题链路其实是这样的:
- 计划任务以过滤令牌启动 harness,令牌中缺少关键特权。
- harness 的
skill_security模块按代码路径无条件调用SetNamedSecurityInfoW,想把 skill 目录 owner 改为当前用户、并收紧 DACL。 - Windows 安全引用监视器检查调用方令牌,发现没有设置 owner 所需特权,直接返回
ERROR_ACCESS_DENIED。 - harness 内部的异常包装逻辑只把系统错误码透传出来,没有附带说明“这里需要哪些特权、当前缺少哪些特权”,导致排查困难。
第 4 点其实很关键,如果你也在写同类工具,我强烈建议:所有调用 Windows 安全 API 的地方,失败时一定要同时打印GetLastError、当前是否持有对应特权、以及目标对象的路径。这能帮你自己省下无数凌晨时光。
3.3 源码里一行不起眼的判断
我之前为了确认根因,写过一段最小复现脚本,大概长这样:
import ctypes import os import tempfile # 调 SetNamedSecurityInfoW 前先尝试启用 SeTakeOwnershipPrivilege / SeRestorePrivilege # 正常情况下,令牌里有这些特权但未启用,启用后调用成功; # 计划任务场景下,令牌里根本没这些特权,启用也救不回来 path = tempfile.mkdtemp(prefix="harness_sec_") SDDL_OWNER = "D:(A;;FA;;;OW)" rc = ctypes.windll.advapi32.SetNamedSecurityInfoW( path, 1, # SE_FILE_OBJECT 1 | 4, # OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION None, None, ctypes.c_wchar_p(SDDL_OWNER), None, ) print("rc =", rc)这个脚本在“普通管理员手动运行”时返回值是 0,表示成功;在“计划任务过滤令牌”环境下返回 5,完美复现了 harness 里的报错。到这里,根因已经板上钉钉。
4. 修复方法与 Windows 权限问题避坑指南
4.1 最快的绕行方案:关掉 skill 目录加固
如果你的场景是个人开发机、内网环境,并且当前没有明显恶意软件威胁,最快的办法是直接关掉 harness 的 skill 目录 ACL 加固逻辑。这一步通常不用改代码,在 harness 的配置文件里把安全等级调低就行。
拿我当时用的版本举例,配置里大概是这样:
security: enable_skill_lockdown: false sandbox: policy: minimal改完重启 harness,skill 加载不再触发SetNamedSecurityInfoW调用,问题立刻消失。代价是 skill 目录不再做“只允许当前用户访问”的强制收紧,多用户共用一台机器时需要注意。这个方案适合临时绕过,不适合当长期方案。
不过有一点要提醒:不少版本的 harness 即使你在配置里关了enable_skill_lockdown,在skill pre-mount security check阶段仍然会去检查目录 ACL,如果 ACL 处于“异常开放”状态还会额外打一条 warning。这不算 bug,只是提示你目录权限没有按预期收紧。如果不想被这条 warning 烦,可以手动把 skill 目录的 ACL 收紧一下,或者接受这个提示直接无视。
4.2 真正的根治:在进程里把特权申请回来
如果 harness 是你自己搭的、或者你愿意给开源版本提一个修复补丁,那正确的做法是在调用SetNamedSecurityInfoW之前先把需要的特权启用一遍。
先说清楚一个概念:UAC 提升后的令牌里,SeTakeOwnershipPrivilege、SeRestorePrivilege这些特权通常是存在的,只是默认处于“禁用”状态,你需要在进程令牌里把它启用。启用不代表提权到系统,只是告诉 Windows“我确实需要用到这个特权,请允许我在授权范围内使用它”。
下面这个函数就是干这事的,基于ctypes直接调advapi32,不依赖第三方库:
import ctypes import ctypes.wintypes ERROR_NOT_ALL_ASSIGNED = 1300 SE_PRIVILEGE_ENABLED = 0x00000002 TOKEN_QUERY = 0x0008 TOKEN_ADJUST_PRIVILEGES = 0x0020 class LUID(ctypes.Structure): _fields_ = [ ("LowPart", ctypes.wintypes.DWORD), ("HighPart", ctypes.wintypes.DWORD), ] class LUID_AND_ATTRIBUTES(ctypes.Structure): _fields_ = [ ("Luid", LUID), ("Attributes", ctypes.wintypes.DWORD), ] class TOKEN_PRIVILEGES(ctypes.Structure): _fields_ = [ ("PrivilegeCount", ctypes.wintypes.DWORD), ("Privileges", LUID_AND_ATTRIBUTES * 1), ] def enable_privilege(privilege_name: str) -> bool: handle = ctypes.wintypes.HANDLE() if not ctypes.windll.advapi32.OpenProcessToken( ctypes.windll.kernel32.GetCurrentProcess(), TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY, ctypes.byref(handle), ): return False luid = LUID() if not ctypes.windll.advapi32.LookupPrivilegeValueW( None, privilege_name, ctypes.byref(luid) ): ctypes.windll.kernel32.CloseHandle(handle) return False tp = TOKEN_PRIVILEGES() tp.PrivilegeCount = 1 tp.Privileges[0].Luid = luid tp.Privileges[0].Attributes = SE_PRIVILEGE_ENABLED ok = ctypes.windll.advapi32.AdjustTokenPrivileges( handle, False, ctypes.byref(tp), 0, None, None, ) err = ctypes.windll.kernel32.GetLastError() ctypes.windll.kernel32.CloseHandle(handle) # AdjustTokenPrivileges 即使失败也可能返回非零,必须检查 GetLastError return bool(ok) and err != ERROR_NOT_ALL_ASSIGNED然后在自己进程初始化时,把它们都启用一遍:
if sys.platform == "win32": for priv in ( "SeTakeOwnershipPrivilege", "SeRestorePrivilege", "SeSecurityPrivilege", ): enable_privilege(priv)这里要特别强调:如果当前进程令牌里根本没有某个特权,LookupPrivilegeValueW可能查得到名字,但AdjustTokenPrivileges会返回错误码 1300,也就是“所有特权未分配”。这种情况下,代码层面的 enable 救不了你,你必须从进程启动方式上解决问题,比如改用提升过的完整令牌启动 harness。我这次的情况就是典型的“计划任务启动导致特权缺失”,如果从一开始就用“使用最高权限运行”选项,整晚的折腾都不会发生。
如果你不方便改源码,还有一个靠谱的兜底手段:在 harness 调用SetNamedSecurityInfoW之前,先用 Windows 自带的icacls命令把 skill 目录的 owner 和 DACL 设置好。icacls是独立进程,会由系统以你当前登录会话的上下文去访问文件,很多时候比在自己进程里手工调安全 API 更省事。命令大致是:
icacls "E:\agent_workspace\skills\code_review" /setowner "$env:USERNAME" /t /c icacls "E:\agent_workspace\skills\code_review" /grant:r "$($env:USERDOMAIN)\$($env:USERNAME):(OI)(CI)F" /t /c注意,/setowner本身也需要特权,所以务必在管理员权限的 PowerShell 里执行。设置完之后,harness 的_lockdown_skill_dir再跑一次安全检查,发现 owner 和 DACL 已经符合预期,就不会再触发写安全描述符的操作了。这个方案很适合“我代码里不想动、但想赶紧跑起来”的场景。
4.3 怎么验证真的修好了
修完之后不能只看 skill 有没有正常加载,还得确认三件事:skill 目录的 ACL 真的收紧到了预期、后续再新建 skill 时不会再次触发失败、以及手动模式和计划任务模式行为一致。
我的验证步骤如下:
- 清理 skill 缓存目录并重新加载一个新 skill,确认
setnamedsecurityinfow failed不再出现。 - 用 PowerShell 检查目标目录的 owner 是否变成了当前用户:
Get-Acl "E:\agent_workspace\skills\code_review" | Select-Object Owner。 - 检查 DACL 里是否存在“其他用户完全控制”这类危险条目:
Get-Acl "E:\agent_workspace\skills\code_review" | Format-List。 - 再切换回手动启动模式跑一轮同款 code review 任务,确认功能没有回退。
- 连续加载多个 skill 并热切换,观察是否触发竞态条件。
我修完跑完整轮回归时是凌晨两点多一点,看到所有 skill 都能正常挂载、code review 的最终结果正常输出,整个人才踏实下来。
5. 折腾一夜换来的实战清单
5.1 Windows 下跑 agent 框架的权限问题速查表
这次教训让我整理了一张小表,后边凡是碰见类似权限报错,我都会先对一遍。
| 报错特征 | 可能原因 | 快速检查 | 处理方向 |
|---|---|---|---|
ERROR_ACCESS_DENIED+ 安全 API | 进程令牌缺少指定特权 | whoami /all查特权、Process Monitor 看SetSecurityFile | 调整启动方式、AdjustTokenPrivileges启用特权 |
setnamedsecurityinfow failed (win32) | 设置 owner / DACL 时权限不足 | 检查启动方式是不是计划任务、服务、远程会话 | 改用提升令牌启动,或先icacls手工收紧 |
ERROR_INVALID_NAME/ 路径不存在 | 路径前缀\\?\与 API 不兼容 | 打印实际传入路径 | 去掉长路径前缀,或换os.path.abspath归一化 |
| 仅在特定 build 复现 | UAC 令牌策略差异 | 记录 build 号,对比两台机器行为 | 优先建议升级 harness 版本,再查系统更新 |
| skill 直接读取文件报权限问题 | 加固失败后 fallback 为“禁止读取” | 看 harness 日志里的 fallback 标记 | 修复加固逻辑,或按 4.2 手工预置 ACL |
这张表不止适用于 harness,凡是在 Windows 上做 agent 类工具、涉及沙箱目录、临时文件隔离、插件加载的,大概率都能用上。
5.2 下次再遇到离奇 bug,我建议这么查
这次 Debug 最大的收获不是那个 API 本身,而是一套“离奇 bug 排查心态”。我把几条对我最有用的经验写在这里。
第一,改动隔离。把所有非必要因素先摘掉。插件禁用、模型切换、路径简化,目的就是让变量越少越好。这次如果我一直带着插件去查,可能到现在都查不到安全模块。
第二,换一个底层的对照实验。Windows 上解决不了的,拿到 Linux 容器里跑一遍同类配置,立刻就能判断是不是平台相关。很多 agent 框架都在 Linux 上开发调试,Windows 只是兼容目标,所以平台差异导致的 bug 比例相当高。
第三,特权类问题别信“我是管理员”。Windows 的管理员身份在实际进程运行层面有很多层,UAC 过滤、计划任务令牌差异、服务会话差异,都会让你“看起来是管理员,实际被限制”。遇到访问被拒,第一件事就是把当前进程的令牌特权完整打出来看。
第四,遇到 Windows 安全 API 报错,直接上 Process Monitor。它能精确捕获到是哪一次SetSecurityFile操作被拒绝、返回什么结果、由哪个进程发起。比你在日志里猜百倍高效。
5.3 内网部署和多模型的几个提醒
既然这次也是奔着“把 harness 部署到内网服务器”去的,我顺手整理几条相对常见的注意点。
内网离线部署时,skill 语义照常,但要注意让它不要依赖外网来拉取插件或模型元数据。如果完全离线,建议先把所有 skill 和插件源码缓存到本地仓库,然后设置环境变量指向本地镜像源。模型后端这一层,推荐用 vllm 在局域网内部署一个与 OpenAI 协议兼容的服务,harness 配置里只需要改base_url和api_key占位符,就能把模型从官方 API 切换到本地模型。
另外,Windows 下用计划任务让 harness 常驻时,记得勾选“使用最高权限运行”,否则你可能一觉醒来发现它在凌晨某个任务节点触发了 ACL 失败——这正好是这次 bug 的另一种打开方式。Linux 下就没这个问题,所以我个人推荐,生产环境类部署直接放 Linux 服务器上,省心很多。
最后顺手给个插件建议:如果你主要用 harness 做 coding 开发,优先装 git 操作增强、prompt 优化和代码结构分析这三类,别贪多。插件社区的热门不等于你的任务需要,插件越多,工具调用链就越长,出这种权限级 bug 的概率也越高。
凌晨两点把补丁打好的那一刻,我特地在笔记里写了一句话:凡是涉及 Windows 安全 API 的模块,必须把调用前置条件检查做足,失败时别只扔一个ERROR_ACCESS_DENIED,要把“缺哪个特权、当前底细是什么、怎么解决”一起告诉用户。这个习惯救了我后来的很多个晚上。现在如果谁再遇到 harness 报setnamedsecurityinfow failed,我希望他能直接照着这篇文章先查进程令牌,而不是像我一样把凌晨一点到两点那段路再走一遍。