中文 English

一个 operator.write 把网关干翻:OpenClaw fallback 模型为什么突然全体认证失败?

发布时间: 2026-07-10
OpenClaw NewAPI AI Gateway 权限 排障 fallback operator.write

这次问题表面上很像“模型供应商挂了”:OpenClaw 刚加完 NewAPI 的几个 fallback 模型,命令一跑,直接报 Provider authentication failed;进一步看网关调用,真正的错误是:

missing scope: operator.write

如果只看第一层现象,很容易把锅甩给模型密钥、NewAPI 地址、模型名、网络连通性。但这次真正的问题不在模型,也不在 NewAPI,而在 OpenClaw 走网关调用 fallback 模型时,某个 model override 分支把“设备身份”省掉了。结果服务端虽然看到了一个被授权的请求,却没有把它绑定到那台已经配对、已经拥有 operator.write 的设备上,最终在执行 agent RPC 时被权限系统拦下。

先说结论:这是一个典型的“令牌有了,钥匙串没带上”的问题。 模型直连能成功,网关调用失败;设备列表里明明能看到 operator.write,但真正发起网关请求时没有使用那份已配对设备身份,于是权限检查按未绑定请求处理,报出 missing scope: operator.write

AI 生成封面:一个网关前的双锁权限系统

1. 问题背景:一次“只是加 fallback”的小改动

当时的目标很简单:给 OpenClaw 和 HermesAgent 使用的模型配置都加上几个来自 NewAPI 的 fallback 模型,避免主模型不可用时整个机器人接入链路中断。配置完成后,常规的本地模型调用可以跑通,newapi/glm-latest 之类的模型也能返回结果。

但是只要通过 OpenClaw gateway 走模型调用,就出现了认证失败。尤其是在已经修过供应商地址和密钥之后,这个错误就更迷惑:供应商本身能通,模型本身也能跑,为什么 gateway 一介入就说没有 operator.write

第一张证据就是失败输出。注意截图已经脱敏,不包含真实内网地址、真实设备 ID、真实主机名或密钥。

真实终端截图:gateway 调用失败,报 missing scope: operator.write

这类错误最容易误导人。Provider authentication failed 听起来像模型供应商鉴权失败;missing scope 又像设备没有权限。两个方向都能查很久,但都不是最后的根因。

2. 先把“模型供应商问题”排除掉

排障第一步不是改代码,而是把事实链拉直。这里我做了三个对照:

  1. 直接调用 NewAPI 来源的模型,确认模型名、地址、密钥可用。
  2. 使用 OpenClaw 本地路径调用同一个模型,确认不是模型配置格式错误。
  3. 使用 OpenClaw gateway 路径调用同一个模型,复现 missing scope: operator.write

这个对照很关键。它把问题从“模型供应商认证失败”缩小到“OpenClaw gateway 模式下的权限上下文丢失”。如果本地和 gateway 都失败,那优先查 NewAPI 配置;如果本地成功、gateway 失败,就要查 gateway 的认证链路和 scope 传递。

可以把它想象成进公司大楼:你本人有工牌,会议室也存在,门禁系统也通电;但你走了一个特殊入口,入口机器没有读取你的工牌,于是会议室门口还是提示“你没有开门权限”。这不代表你没有权限,而是这次通行没有携带能证明权限的身份。

3. scope 到底是什么?为什么它能拦住一次模型调用?

scope 不是 OpenClaw 独有概念。OAuth 2.0 里就把 scope 当作访问令牌的权限范围,客户端可以请求某些 scope,授权服务器也可以决定最终给哪些 scope。RFC 6749 对 scope 的定义可以参考:https://www.rfc-editor.org/rfc/rfc6749#section-3.3。Bearer Token 规范里也有 insufficient_scope 这类错误语义,参考:https://www.rfc-editor.org/rfc/rfc6750#section-3.1

OpenClaw 这里的 operator.write 可以粗略理解为“允许操作型写入动作”的权限。模型 gateway 调用表面上只是“问模型一句话”,但在系统内部可能需要通过 agent RPC 执行一次带状态、带操作语义的请求。只读权限不够,普通用户权限也不够,必须有 operator 级别写权限。

安全设计上,这其实是好事。OWASP 的授权建议也强调最小权限和默认拒绝:https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html。问题不在于它拦得太严,而在于我们要搞清楚:明明设备有权限,为什么这次请求没有用上这份权限?

权限双锁示意:供应商认证和 operator scope 是两把不同的锁

图里这两把锁非常重要:

  1. 第一把锁是模型供应商认证,例如 NewAPI 的 base URL、API key、模型名。
  2. 第二把锁是 OpenClaw 自己的 operator scope,例如 operator.write

很多人遇到 Provider authentication failed 会只盯第一把锁。但这次真正没打开的是第二把锁。

4. 第二个反常点:设备明明有 operator.write

按直觉,如果报 missing scope: operator.write,下一步应该检查设备授权。于是我查看已配对设备列表,结果发现对应 operator 设备的 scope 是完整的,里面包括:

也就是说,设备本身没有缺权限。

真实终端截图:设备列表里可以看到 operator.write 已存在

这就是整个排障里最关键的转折点。因为如果设备表里没有 operator.write,那问题很简单:重新授权或重新配对即可。但现在设备有权限,调用仍然缺权限,说明权限不是“没发”,而是“请求没有绑定到这台设备”。

继续顺着代码查,OpenClaw 的 gateway 调用里有一个逻辑会判断是否省略设备身份。它本来的设计意图可以理解:当后端或 gateway-client 使用 loopback 和共享 token/password 发起请求时,某些情况下不必携带设备身份。但在 model override 的分支上,这个优化踩到了权限边界。

model override 是什么?简单说,就是你在命令里明确指定 --model newapi/glm-latest 这类模型时,CLI 会走一个和默认模型不同的调用路径。这个路径把请求标记成 gateway-client/backend 模式,同时只带了 admin scope 请求,却没有强制使用已存储的设备认证。服务端拿到请求后发现它没有绑定设备,于是清掉未绑定 scope,最终 agent RPC 阶段缺少 operator.write

这就像你拿着临时通行条进了楼,但没有刷自己的员工卡。临时条能让你进大门,却不能证明你有会议室写权限。

5. 根因定位:不是缺权限,而是没有使用 stored device auth

代码层面的根因可以概括成一句话:

model override 的 gateway 调用分支使用了 backend/gateway-client 模式,却没有显式要求使用已存储设备认证,导致设备身份被省略,operator scope 没有绑定到请求上。

修复点也很小:在这个分支里保留原来的 admin scope,同时要求使用 stored device auth,并声明这个 stored device auth 至少要具备 admin scope。

核心补丁形态如下,路径和文件名请以自己的安装环境为准:

 ...hasModelOverride ? {
-    scopes: [ADMIN_SCOPE]
+    scopes: [ADMIN_SCOPE],
+    useStoredDeviceAuth: true,
+    requiredStoredDeviceAuthScopes: [ADMIN_SCOPE]
 } : {}

修复路径示意:从失败复现到补丁验证

补丁后还需要做语法检查,避免直接改编译产物时留下低级错误。

真实终端截图:补丁检查和语法检查通过

6. 手工修复方法

如果你也遇到类似问题,建议按下面顺序操作。不要一上来就改代码。

第一步,确认是不是同一个症状:

openclaw infer model run --local --model newapi/glm-latest --prompt "只回复 OK"
openclaw infer model run --gateway --model newapi/glm-latest --prompt "只回复 OK"

如果 local 成功、gateway 失败,并且失败里出现 missing scope: operator.write,再继续。

第二步,确认设备确实有权限:

openclaw devices list

你要看到对应 operator 设备包含 operator.write。如果设备没有这个 scope,请先重新配对或重新授权,不要直接套补丁。

第三步,备份 OpenClaw 编译产物:

OPENCLAW_DIST="<openclaw-install>/dist"
BACKUP_DIR="$HOME/.openclaw/backups/operator-write-scope-$(date +%Y%m%d-%H%M%S)"
mkdir -p "$BACKUP_DIR"
cp "$OPENCLAW_DIST"/capability-cli-*.js "$BACKUP_DIR"/

第四步,在 capability-cli-*.js 里找到 model override 调用 callGateway 的分支,将 scopes: [ADMIN_SCOPE] 扩展为:

scopes: [ADMIN_SCOPE],
useStoredDeviceAuth: true,
requiredStoredDeviceAuthScopes: [ADMIN_SCOPE]

第五步,检查语法并验证 gateway:

node --check "$OPENCLAW_DIST"/capability-cli-*.js
openclaw infer model run --gateway --model newapi/glm-latest --prompt "只回复 OK"

修复后,我这里的 gateway 调用已经能稳定返回 OK

真实终端截图:gateway 调用修复后返回 OK

7. 一键自动修复脚本:Windows 11

下面脚本只使用系统自带 PowerShell 和 Node,不依赖第三方服务。它会自动查找常见 npm 全局安装目录,备份目标文件,再做文本补丁。建议先在测试环境或快照环境执行。

$ErrorActionPreference = "Stop"

$candidates = @(
  "$env:APPDATA\npm\node_modules\openclaw\dist",
  "$env:ProgramFiles\nodejs\node_modules\openclaw\dist",
  "$env:ProgramFiles\node_modules\openclaw\dist"
) | Where-Object { Test-Path $_ }

if ($candidates.Count -eq 0) {
  throw "OpenClaw dist directory not found. Set OPENCLAW_DIST and rerun."
}

$dist = $env:OPENCLAW_DIST
if (-not $dist) { $dist = $candidates[0] }

$file = Get-ChildItem $dist -Filter "capability-cli-*.js" | Select-Object -First 1
if (-not $file) { throw "capability-cli bundle not found in $dist" }

$stamp = Get-Date -Format "yyyyMMdd-HHmmss"
$backup = Join-Path $env:USERPROFILE ".openclaw\backups\operator-write-scope-$stamp"
New-Item -ItemType Directory -Force -Path $backup | Out-Null
Copy-Item $file.FullName $backup

$text = Get-Content $file.FullName -Raw
$old = "scopes: [ADMIN_SCOPE]"
$new = "scopes: [ADMIN_SCOPE], useStoredDeviceAuth: true, requiredStoredDeviceAuthScopes: [ADMIN_SCOPE]"

if ($text -notlike "*$old*") {
  throw "Patch anchor not found. OpenClaw bundle may have changed."
}
if ($text -notlike "*useStoredDeviceAuth*") {
  $text = $text.Replace($old, $new)
  Set-Content -Path $file.FullName -Value $text -Encoding UTF8
}

node --check $file.FullName
openclaw infer model run --gateway --model newapi/glm-latest --prompt "只回复 OK"

8. 一键自动修复脚本:Ubuntu 26.04

#!/usr/bin/env bash
set -euo pipefail

DIST="${OPENCLAW_DIST:-}"
if [[ -z "$DIST" ]]; then
  for p in \
    /usr/local/lib/node_modules/openclaw/dist \
    /usr/lib/node_modules/openclaw/dist \
    /opt/openclaw/dist; do
    [[ -d "$p" ]] && DIST="$p" && break
  done
fi

[[ -n "$DIST" && -d "$DIST" ]] || {
  echo "OpenClaw dist directory not found. Set OPENCLAW_DIST." >&2
  exit 1
}

FILE="$(find "$DIST" -maxdepth 1 -name 'capability-cli-*.js' | head -n 1)"
[[ -n "$FILE" ]] || { echo "capability-cli bundle not found" >&2; exit 1; }

BACKUP="$HOME/.openclaw/backups/operator-write-scope-$(date +%Y%m%d-%H%M%S)"
mkdir -p "$BACKUP"
cp "$FILE" "$BACKUP/"

python3 - "$FILE" <<'PY'
from pathlib import Path
import sys

p = Path(sys.argv[1])
text = p.read_text(encoding="utf-8")
old = "scopes: [ADMIN_SCOPE]"
new = "scopes: [ADMIN_SCOPE], useStoredDeviceAuth: true, requiredStoredDeviceAuthScopes: [ADMIN_SCOPE]"
if "useStoredDeviceAuth" not in text:
    if old not in text:
        raise SystemExit("Patch anchor not found. OpenClaw bundle may have changed.")
    text = text.replace(old, new, 1)
    p.write_text(text, encoding="utf-8")
PY

node --check "$FILE"
openclaw infer model run --gateway --model newapi/glm-latest --prompt "只回复 OK"

9. 一键自动修复脚本:macOS 26

#!/usr/bin/env bash
set -euo pipefail

DIST="${OPENCLAW_DIST:-}"
if [[ -z "$DIST" ]]; then
  NPM_PREFIX="$(npm prefix -g 2>/dev/null || true)"
  for p in \
    "$NPM_PREFIX/lib/node_modules/openclaw/dist" \
    /opt/homebrew/lib/node_modules/openclaw/dist \
    /usr/local/lib/node_modules/openclaw/dist; do
    [[ -d "$p" ]] && DIST="$p" && break
  done
fi

[[ -n "$DIST" && -d "$DIST" ]] || {
  echo "OpenClaw dist directory not found. Set OPENCLAW_DIST." >&2
  exit 1
}

FILE="$(find "$DIST" -maxdepth 1 -name 'capability-cli-*.js' | head -n 1)"
[[ -n "$FILE" ]] || { echo "capability-cli bundle not found" >&2; exit 1; }

BACKUP="$HOME/.openclaw/backups/operator-write-scope-$(date +%Y%m%d-%H%M%S)"
mkdir -p "$BACKUP"
cp "$FILE" "$BACKUP/"

perl -0pi -e 's/scopes: \[ADMIN_SCOPE\]/scopes: [ADMIN_SCOPE], useStoredDeviceAuth: true, requiredStoredDeviceAuthScopes: [ADMIN_SCOPE]/ if index($_, "useStoredDeviceAuth") < 0' "$FILE"

node --check "$FILE"
openclaw infer model run --gateway --model newapi/glm-latest --prompt "只回复 OK"

10. Agent 自动配置方法

如果你是让 Agent 自动处理,建议不要只给一句“修一下 OpenClaw”。更稳的提示词可以这样写:

请在不泄露任何密钥、内网地址、主机名的前提下,排查 OpenClaw gateway 调用模型时报 missing scope: operator.write 的问题。
要求:
1. 先验证 local 模型调用和 gateway 模型调用的差异;
2. 检查 openclaw devices list 中 operator 设备是否包含 operator.write;
3. 在修改任何文件前备份目标文件;
4. 如果确认是 model override gateway 分支没有使用 stored device auth,请只修改该分支;
5. 修改后运行 node --check;
6. 用 gateway 模式分别验证 newapi/MiniMax-fallback、newapi/glm-latest、newapi/glm-fallback;
7. 输出最终变更、备份路径和验证结果,禁止输出完整 IP、完整主机名、密钥和设备 ID。

Agent 的价值不是“自动乱改”,而是严格按证据链做事:复现、缩小范围、备份、最小改动、验证、脱敏总结。

11. fallback 模型验证

补丁后,我继续验证了三个 NewAPI 来源的 fallback 模型:

状态里可以看到 fallback 列表已经生效,gateway 连通性也恢复正常。

真实终端截图:fallback 模型列表和 gateway 状态检查

这里有一个经验:fallback 不只是“配置几行模型名”。真正可用的 fallback 要同时满足三件事:

  1. 模型供应商能通。
  2. 模型名能被当前网关识别。
  3. 调用路径上的本地权限、设备身份、operator scope 都能通过。

缺任何一项,最终都可能表现成“模型不可用”。

12. Q&A

Q:看到 Provider authentication failed,是不是一定要先换 API key?

不一定。它可能是供应商 key 错,也可能是上层 gateway 把内部权限错误包装成供应商认证失败。先做 local/gateway 对照,不要直接重置密钥。

Q:设备列表里有 operator.write,为什么还会报缺 scope?

因为设备“拥有权限”和请求“携带这份设备身份”是两件事。就像你家里有钥匙,但出门没带,门锁不会因为你理论上拥有钥匙就自动打开。

Q:这个补丁是不是适合所有 OpenClaw 版本?

不保证。它针对的是这次看到的编译产物结构:model override 分支走 gateway-client/backend 模式,但没有显式启用 stored device auth。新版本如果源码结构变化,应以实际代码为准。

Q:为什么不直接给共享 token 更多权限?

因为那会扩大权限面。更合理的做法是让需要设备权限的请求使用已经配对的设备身份,而不是把共享 token 变成万能钥匙。

Q:这算安全漏洞吗?

从结果看,它更像是一个权限上下文传递缺陷:权限系统在拦截非法上下文时工作正常,但合法设备身份没有被正确带入特定调用路径。是否定义为安全漏洞,要看项目的威胁模型和版本边界。

13. 最后复盘

这次排障最有价值的地方,不是那几行补丁,而是排障顺序:

  1. 不被 Provider authentication failed 的外层文案带偏。
  2. 用 local/gateway 对照把问题定位到 gateway 路径。
  3. 用设备列表证明 operator.write 本身存在。
  4. 沿着 model override 调用路径查设备身份何时被省略。
  5. 用最小补丁让请求重新使用 stored device auth。
  6. 用三个 fallback 模型逐一验证,而不是只看一个成功案例。

很多权限问题都像这样:不是“没有权限”,而是“权限没有跟着请求走到最后一公里”。如果把 scope 当成门禁权限,stored device auth 就是那张真正能证明你是谁的工牌。模型网关这条路上,工牌没刷,会议室门当然不会开。