许可与审计
Nova 能动手,但动手这件事必须看得见、可撤销、留得下痕迹。这套机制分三层:工具自身的只读/写、许可层开关、可审计操作日志。
第一层:工具的只读与写
每个工具自己带 ReadOnly 标志,划的是「查询」与「改动」的界。这层是硬边界,不依赖任何运行时开关——ReadOnly: false 的工具在 Agent 循环里永远不会被直接执行,只会被攒进确认清单。见工具集。
第二层:许可层开关
有些能力不在「工具能不能调」的范畴,而在「用户是否授权」——典型是录屏:游戏运行时从显卡缓冲区采集画面并硬件编码落盘。
public sealed record NovaPermission(
[property: JsonPropertyName("key")] string Key,
[property: JsonPropertyName("on")] bool Enabled,
[property: JsonPropertyName("granted_at")] DateTime? GrantedAt = null);存在 %APPDATA%/starcloud-launcher/nova.permissions.json。当前只有一档:
public const string Recording = "recording";默认全部关闭,开启即显式授权。UI 侧必须先弹确认清单再调 SetAsync,不允许直接改状态文件。
每档开关都带一句边界说明,界面直接显示给用户:
public static (string Title, string Boundary) Describe(string key) => key switch
{
Recording => ("录屏", "只在游戏运行时从显卡缓冲区采集画面并硬件编码,录到本地磁盘;不碰非游戏窗口"),
_ => (key, ""),
};写「不碰非游戏窗口」不是为了免责声明,是因为录屏是隐私敏感度最高的操作,用户有权知道边界精确到什么程度。
第三层:可审计操作日志
每条日志是一个 NovaActionEntry,六个字段:谁(id)、何时(time)、什么类别(kind)、做了什么(title)、结果(state)、细节(detail)。
{
"id": "a3f9c1e20b7d",
"time": "2026-09-14T20:31:08.412+08:00",
"kind": "plan",
"title": "停用重复的旧版本模组",
"state": "已确认执行",
"detail": "已按清单停用 3 个旧版本模组:\n• sodium-1.20.jar → .disabled\n…"
}kind 有三种:plan(确认清单)、permission(许可层状态变更)、fallback(降级)。state 覆盖「已确认执行 / 已取消 / 失败 / 已授权 / 已撤销」。
落在 %APPDATA%/starcloud-launcher/nova.actions.json,保留最近 200 条,多的从头部丢。
审计落盘失败不阻断
try
{
File.WriteAllText(LauncherPaths.NovaActionsFile, JsonSerializer.Serialize(_entries, JsonOpts));
}
catch { /* 审计落盘失败不阻断 */ }审计不可用时不能把正常流程卡死——但这条 catch 只吞掉写盘异常,内存里的 _entries 已经追加了,所以日志本身没丢,只是没持久化。下次写入会一并带上。
确认清单的执行与调度中心联动
清单被点头之后,执行走 NovaActionLog.ExecuteConfirmedAsync:
public async Task<string> ExecuteConfirmedAsync(NovaPlan plan, CancellationToken ct = default)
{
var task = TaskCenterService.Instance.Begin($"Nova · {plan.Title}");
// 调度中心的「取消」得能真的掐掉这次执行 → 把任务自己的 Cts 与调用方的 ct 串起来
using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, task.Cts.Token);
try
{
var result = await plan.ExecuteAsync(linked.Token);
Append(new NovaActionEntry(plan.Id, DateTime.Now, "plan", plan.Title, "已确认执行", result));
TaskCenterService.Instance.Complete(task);
return result;
}
catch (OperationCanceledException)
{
Append(new NovaActionEntry(plan.Id, DateTime.Now, "plan", plan.Title, "已取消", "用户在调度中心取消了这次执行"));
TaskCenterService.Instance.Cancel(task);
throw;
}
...
}三件事值得说:
一、粒度是「一条计划一条任务」。 压缩存档、装整合包这类长计划跑到一半,用户切走页面就完全看不到进度。逐步登记会让调度中心抽屉被 Nova 的琐碎步骤淹没。
二、取消必须能真的掐断。 光在 UI 上把按钮置灰没用——执行体(压缩 zip、下载、移动目录)必须真的中止。所以任务自己的 Cts.Token 和调用方传进来的 ct 被 CreateLinkedTokenSource 串成一条链,取消任一端都会让 plan.ExecuteAsync 抛出 OperationCanceledException。执行体内部也要老实检查 ct.ThrowIfCancellationRequested(),否则取消只是外层不再等结果,文件还在后台继续写。
三、取消也要记日志。 用户在确认清单上点取消走 LogCancelled,记的是「已取消 / 用户在确认清单上点了取消」;在调度中心取消走异常分支,记的是「已取消 / 用户在调度中心取消了这次执行」。两种取消在审计上分得开——这在排查「文件怎么被改的」时有用。
合规:凭据不下发
模型凭据不下发到客户端。上游合约由 Starcloud ID 服务端持有,客户端只拿本用户的 Key(星云 ID 登录时由后端下发的那一枚),拿它调自家网关 https://starcloudfur.cn/v1。自填 Key 的场景则完全是用户自己的地址和自己的 Key,不经过我们。
额度(注册赠送 100 万 token,每日签到 10 万)只在客户端显示 + 讲清楚 + 降级,不做本地计数。两个理由:多端同时用时本地计数必然不一致;本地计数也挡不住刷——真正能管住额度的只有服务端。
额度用尽或未授权是「用户需要知道」的状态,Nova 会明说而不是静默冒充兜底:
if (ex.IsQuotaExceeded)
{
return ex.Message + "\n本地能力(崩溃排查 / 游戏体检 / 模组冲突 / 资源查找)不受影响,可以直接继续用;"
+ "也可以在 Nova 页接入自己的模型接着问。";
}降级是产品承诺,不是道歉。