diff --git a/.github/workflows/ci-core.yml b/.github/workflows/ci-core.yml index 0bf35759b..8c3f0234e 100644 --- a/.github/workflows/ci-core.yml +++ b/.github/workflows/ci-core.yml @@ -62,12 +62,16 @@ jobs: global-json-file: global.json - name: Verify real cancellation transports without skips run: bash scripts/gates/run-acp-cancellation-transport-gates.sh Release - - name: Upload cancellation evidence + - name: Verify bound credentials on real transports without skips + run: bash scripts/gates/run-acp-credential-transport-gates.sh Release + - name: Upload transport evidence if: always() uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: acp-cancellation-transports - path: artifacts/acp-cancellation/ + path: | + artifacts/acp-cancellation/ + artifacts/acp-credentials/ build-and-test: name: Build and Test diff --git a/AGENTS.md b/AGENTS.md index 04ecb623a..a8df753c4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -96,7 +96,7 @@ - Transcript 视口事实源:当消息 transcript 需要在用户脱底、内容流式更新、会话热切回、冷进入或 overlay 暂停/恢复之间保持阅读位置时,follow/detached、per-conversation restore token、scroll request generation 和 restore lifecycle 必须由单一 Core controller 拥有;View 只采集原生 ListView viewport 事实并执行 native scroll/restore 意图;禁止 no-op 兼容 API、View-local overlay shadow flag、容器 realized 状态驱动 loading、或用 projection/list layout tick 推断业务状态;验证覆盖 following 内容追加、pinned 内容更新、warm return、cold enter、overlay 同/异会话恢复、Desktop/WASM 构建和真实 Skia Desktop GUI smoke。 - 后台恢复完成的权威晋升:当前台激活可被更新意图 supersede、而后台会话恢复(`session/load` / `resume`)在与激活解耦的 request token 上继续跑完时,被 supersede 的完成只要 binding、profile 与 connection instance identity 仍与该 conversation 匹配,就必须落 projection 并把 runtime 晋升为 authoritative Warm(`SessionLoadCompleted` / `SessionResumeCompleted`),使回切成为零往返 warm reuse 而非再次慢恢复;晋升工作必须按 conversationId 隔离,禁止触碰前台(`HydratedConversationId` / overlay / 选中态由最新激活拥有)。唯一例外:若**同一** conversation 已有更新的在途激活(runtime 已被重置为 `Selecting` / `Selected` / `RemoteConnectionReady` 等更早 pending 阶段),旧的后台完成不得晋升 Warm 或落正文,须让更新激活驱动自己的权威恢复。禁止做法:superseded 完成分支只清 buffering 而不晋升(会让掠过的会话永远停在 `RemoteHydrating`,每次回切都 `RuntimeStateNotWarm` 重跑慢恢复);也禁止无条件晋升而忽略同会话更新激活。验证覆盖:不同会话 supersede 后旧会话晋升 Warm 且前台不被夺、同会话更新激活在途时旧完成不晋升且新激活重跑恢复、binding/connection identity 不匹配时不晋升。 - 测试缝隙与风险归属:在判定一个逻辑类型“无法被干净测试”或“不值得测”之前,必须先排查既有测试缝隙(projector / factory / `.Empty` / 带默认值的 record / adapter / 测试工程已有 mock 模式),不得仅凭“构造参数多”“看起来是框架胶水”就下结论并推迟覆盖;测试对象按风险归属划分,框架与运行时保证的能力(如 `.WaitAsync` 计时、record 结构 `Equals` 等语言契约)不重复测,应用层接线、封装、诊断文案、null 缝、适配层、状态机顺序与门控才是测试对象,断言须复用被测类的契约常量(如 policy 常量)而非魔法数,且不重复其下层单元测试已覆盖的功劳;耦合若是有意设计(如全字段结构相等以避免“字段变了也抑制”的行为退化),测试须基于真实实例构造而非缩小相等面或改生产契约;验证覆盖构造路径确认(`.Empty` / 默认值 / mock seam 是否能造出真实实例)、状态机各分支与门控、null/空边界、诊断渲染与内层异常保留,以及新测试用例数与实际执行数一致。 - - 应用启动副作用所有权:当 singleton 运行态需要加载配置、恢复 workspace、重建导航树或自动连接时,必须由 application-scoped startup workflow 统一触发并共享在途任务;Page / Window 的 `Loaded` 只附着原生视图、焦点、viewport 与事件,不得直接加载 profile、恢复 conversation、另起 fire-and-forget 初始化或成为失败重试 owner。原生期望行为是 shell 首帧先完成挂载,运行态初始化随后由同一 owner 推进;失败只重试失败的子任务,不重复已完成恢复。验证覆盖冷启动、shell reload、Mini Window 首开、多个页面并发挂载、空配置目录、初始化失败重试与每个副作用调用次数。 + - 应用启动副作用所有权:当 singleton 运行态需要加载配置、恢复 workspace、重建导航树或自动连接时,必须由 application-scoped startup workflow 统一触发并共享在途任务;Page / Window 的 `Loaded` 只附着原生视图、焦点、viewport 与事件,不得直接加载 profile、恢复 conversation、另起 fire-and-forget 初始化或成为失败重试 owner。原生期望行为是 shell 首帧先完成挂载,运行态初始化随后由同一 owner 推进;失败只重试失败的子任务,不重复已完成恢复。排队的初始聚焦必须在创建时快照已有导航 token、原生焦点元素/状态/CorrelationId 与 root,并在实际执行前确认仍有效;用户已进入内容区或产生新焦点/导航意图时放弃,禁止晚到的初始聚焦抢走输入,禁止另建 focus generation owner。验证覆盖冷启动、shell reload、Mini Window 首开、多个页面并发挂载、空配置目录、初始化失败重试与每个副作用调用次数;焦点门禁覆盖输入途中回调、离开后回到同一元素、root 卸载和无用户操作的初始聚焦,并以真实按键完整值及撤掉保护时失败的反向证据验收。 - 回收型容器的 per-container 状态与集合抖动:当控件把选中/焦点/展开等状态存在**被回收复用的容器**上(`ItemsRepeater` 家族,含 `NavigationView`、`ListView`、`ItemsView` 的虚拟化宿主)时,应用层在该状态存活期间不得搬动已渲染行。触发条件:绑定集合会因 recency/活跃度等易变排序键在用户交互或加载风暴期间重排。原生期望行为:把「已渲染行的位置」当作控件借出的资源——插入/删除安全(宿主只对存活元素重编索引,仅回收被删数据自身的容器),搬动不安全(`Move` 被 `ItemsRepeater` 拆成 Remove+Add,容器进回收池且池不复位 per-container 状态,父控件「取消上一个选中」在容器已非 realized 时会静默 no-op),故未静默期只保持既有顺序并追加新行,静默后再收敛到目标顺序。禁止做法:用 code-behind 遍历 realized 容器强清 `IsSelected` 等控件自有视觉状态(与控件自身对账竞态,且若真凶是 pointer 态则完全无效);仅改集合事件形状(`Remove`+`Insert` 换成 `Move`)就认为保住了状态——WinUI 文档只承诺 `INotifyCollectionChanged` 能投递真 `Move`,不承诺宿主不拆解它;为保住某一行而重排其余所有行(总变更次数才是主因子,附带 churn 会把一个脏容器扩散成多行残留)。上游已修但未进当前依赖线时,必须在代码注释与提交信息中记录 issue/PR 号与「上游落地后删除本缓解」的条件。验证覆盖:快速连续切换叠加高频集合重排、加载期与静默期两种路径、收敛不饿死,以及在**真实控件树**上枚举全部 realized 容器断言该状态数量不超过控件契约上限。 - 对端违反协议时的归责与呈现:当规范把某条约束落在**对端**(例:"The agent MUST NOT write anything to its `stdout` that is not a valid ACP message",诊断输出应走 `stderr`),而对端仍然违反时,client 不得把解析器/校验器的原始报错当作用户可见错误呈现——那既非用户所为、也无可操作性,落在会「保持到下一次成功操作」的故障面上还会形成滞留提示。触发条件:规范对该流/字段有 MUST 级约束且明确指定了合法去处,而收到的输入根本不属于协议消息。原生期望行为:先把**判据**与**归因**分开——"这是不是一条协议消息"是协议层事实,必须收敛为单一定义供所有传输共用;"这属于对端写错了流"才是某一层独有的归因(如 stdout 与 stderr 之分只有 stdio 传输层看得见,WebSocket/HTTP 无此二分)。**把判据也收进那一层是本条最易犯的错**:一旦存在中转(stdio→WS 桥原样转发对端 stdout),未设判据的那条路会把同一条非协议输入照原样送达并复现缺陷,而"某传输已过滤"的注释会掩盖它。非协议输入按已有的"对端诊断"通路处理(记日志后早退,与既有 stderr 处理同形),并把内容与前若干字节 hex 记到**默认可见的级别**——同一条解析器消息可能对应 BOM、`U+FFFD`、私有区字形、纯日志等多种成因,只有前导字节能区分,仅记长度等于无法定位。已成帧但解析失败的才是真协议错误,按 JSON-RPC 2.0 回 `-32700` 且 id 必须为**显式 null**(若信封序列化设了 `WhenWritingNull`,null id 会被整个丢掉从而降级成 Notification,须对该属性做属性级覆盖并用线上文本断言);从来不像帧的行不回 `-32700`——它没有请求可回应,且实测会触发"回复自身发送失败"的二次错误。宽容仅限规范明确许可处(如 RFC 8259 §8.1 允许解析方 "ignore the presence of a byte order mark rather than treating it as an error",故前导 BOM 应剥除而非拒绝;注意 `U+FEFF` 不被 `IsNullOrWhiteSpace` 视为空白,纯 BOM 行会穿过空行守卫)。禁止做法:把对端违规呈现为 client 故障;仅记长度不记内容与 hex;用 Debug 级记违规(默认级别看不见);对非帧行回 `-32700`;把宽容扩大到规范未许可处(如放宽必填字段或类型契约)。验证覆盖:**每一种在用传输**各自跑一遍(含经桥中转的那条,否则半个修复会被另一半的注释掩盖)、真实子进程在真实管道上发出违规字节(注意 `dash` 的 `printf %b` 不认 `\xEF` 十六进制转义、须用八进制,且特殊字符经源文件编码可能丢失,宜在脚本侧生成)、合规对端零违规且帧原样通行、跨传输回归(共享层改动对 WebSocket/HTTP 同样生效)、以及从传输到 client 事件的**整条链**断言不向用户抛错。 - 视觉树级缺陷的运行时门禁与反向验证:当缺陷只在真实控件树上可观测(容器回收、视觉状态、焦点/选中投影、布局回流)时,单元测试不构成安全网,必须建立运行时门禁:由**视图层**只读采集原生事实(枚举 realized 容器及其状态)并输出可断言标记,由**独立诊断组件**自持依赖、按环境变量自我门控地施加负载,门禁脚本对不变式做硬断言而非仅打印数值。禁止做法:让页面 code-behind 直接驱动下层服务制造负载(分层异味);门禁只 `echo` 指标由人肉判读;断言 mock 调用或集合事件形状而非用户可观测的原生状态(会给出假信心);把不可靠的观测字段(跨行同名模板部件、模板作用域视觉状态组)当证据。新增或修改此类门禁后必须做**反向验证**:临时移除修复并确认门禁失败(记录失败输出),否则不得认为门禁有效。验证覆盖:修复在位时多轮稳定通过、修复移除时必然失败、采样数下限校验以防空跑绿。 diff --git a/README.en.md b/README.en.md index 31e927c84..04a4b658e 100644 --- a/README.en.md +++ b/README.en.md @@ -116,6 +116,29 @@ printf '%s\n' "$AGENT_TOKEN" | salmon-egg set-credential --token-std printf '%s\n' "$AGENT_TOKEN" | salmon-egg --allow-insecure-storage set-credential --token-stdin ``` +#### Credential injection + +Stored credentials need an explicit injection binding. Use the environment variable or header name +documented by the Agent. A binding approves the current stdio command and arguments, or the complete +network endpoint; editing that destination requires binding it again. + +```bash +# Inject an API key only into the configured stdio child process. +salmon-egg config server update --credential-source api_key --credential-env AGENT_API_KEY + +# Send Authorization: Bearer ... to the configured HTTP or desktop WebSocket endpoint. +salmon-egg config server update --credential-source token --credential-header Authorization --credential-scheme Bearer + +# Remove injection while retaining the stored credential. +salmon-egg config server update --clear-credential-binding +``` + +The app's profile editor exposes the same binding. Empty credential fields preserve the stored value; +the explicit clear option removes it. Saved changes take effect on reconnect. A binding with a missing +credential prevents connection until the credential is set or the binding is removed. Browser WebSocket +cannot set request headers; use an HTTP endpoint supported by the Agent. Bound network requests do not +follow redirects to prevent forwarding credentials to another destination. + #### Running from source The examples below use `dotnet run` so they work in a checkout without installing anything. Replace the `dotnet run --project ... --` prefix with `salmon-egg` when using an installed build. diff --git a/README.md b/README.md index 1de5daa5c..07abd0d77 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,21 @@ printf '%s\n' "$AGENT_TOKEN" | salmon-egg --allow-insecure-storage set-credentia 凭据值只从 stdin 读取,不会进入进程参数、YAML 或 `has-credential` 输出。非凭据配置操作不受该策略影响。该策略针对 Linux Secret Service 与 macOS Keychain;Windows DPAPI 始终可用,该 flag 在 Windows 上无实际作用。完整命令示例见 [README.en.md](README.en.md#cli-configuration-management),发布与安装细节见 [发布指南](docs/release-guide.md#cli-发布)。 +保存凭据后,还需按 Agent 文档指定发送位置。绑定只对当前启动命令或完整网络地址生效;更改目标后必须重新绑定。示例: + +```bash +# 本地 Agent:仅注入其子进程环境 +salmon-egg config server update --credential-source api_key --credential-env AGENT_API_KEY + +# HTTP / 桌面 WebSocket:向当前地址发送 Authorization: Bearer ... +salmon-egg config server update --credential-source token --credential-header Authorization --credential-scheme Bearer + +# 停止注入,保留已保存的凭据 +salmon-egg config server update --clear-credential-binding +``` + +应用内的配置编辑页也可设置“凭据发送方式”。凭据输入留空会保留旧值,勾选清除才会删除;保存后的更改在重新连接时生效。绑定仍在但凭据已清除时,连接会提示补充凭据或移除绑定。浏览器 WebSocket 无法设置请求头,须使用 Agent 支持的 HTTP 接口。绑定的网络请求不跟随重定向,避免凭据转发到另一个目标。 + ## 文档 - [文档导航](docs/README.md) diff --git a/SalmonEgg/SalmonEgg/MainPage.xaml.cs b/SalmonEgg/SalmonEgg/MainPage.xaml.cs index 18baf4495..6a5f3c11b 100644 --- a/SalmonEgg/SalmonEgg/MainPage.xaml.cs +++ b/SalmonEgg/SalmonEgg/MainPage.xaml.cs @@ -77,9 +77,11 @@ public sealed partial class MainPage : Page, INavigationIntentConsumer, IGamepad private readonly IGamepadShortcutDispatcher _gamepadShortcutDispatcher; private readonly IGamepadContextIntentDispatcher _gamepadContextIntentDispatcher; private readonly IApplicationStartupWorkflow _startupWorkflow; + private readonly IShellNavigationRuntimeState _shellNavigationRuntimeState; private readonly ContentFrameNavigationAdapter _contentNavigation; private bool _isGamepadInputAttached; private long _contentFrameNavigationVersion; + private Guid _nativeFocusCorrelationId; public MainPage() { @@ -102,6 +104,7 @@ public MainPage() _gamepadShortcutDispatcher = App.ServiceProvider.GetRequiredService(); _gamepadContextIntentDispatcher = App.ServiceProvider.GetRequiredService(); _startupWorkflow = App.ServiceProvider.GetRequiredService(); + _shellNavigationRuntimeState = App.ServiceProvider.GetRequiredService(); IsGuiAutomationMode = string.Equals( Environment.GetEnvironmentVariable("SALMONEGG_GUI"), "1", @@ -192,6 +195,7 @@ private void OnMainPageUnloaded(object sender, RoutedEventArgs e) // Navigation-scoped unsubscriptions live in OnNavigatedFrom. DetachGamepadInput(); DetachDebugKeyLogging(); + Microsoft.UI.Xaml.Input.FocusManager.GettingFocus -= OnMainPageGettingFocus; _metricsProvider.Detach(); _titleBarAdapter.Detach(); DetachAppWindowClosing(); @@ -716,6 +720,10 @@ private void EndRightPanelResize(Pointer pointer) private async void OnMainPageLoaded(object sender, RoutedEventArgs e) { + // Observe native movements before startup can yield. The correlation id also changes + // when focus leaves and returns to the same element; element equality alone misses it. + Microsoft.UI.Xaml.Input.FocusManager.GettingFocus -= OnMainPageGettingFocus; + Microsoft.UI.Xaml.Input.FocusManager.GettingFocus += OnMainPageGettingFocus; AttachGamepadInput(); AttachDebugKeyLogging(); AttachAppWindowClosing(); @@ -726,12 +734,39 @@ private async void OnMainPageLoaded(object sender, RoutedEventArgs e) UpdateMainNavAutomationSelectionState(); await _startupWorkflow.ActivateShellAsync().ConfigureAwait(true); BootLogDebug("MainPage: initial shell content activated"); - _ = DispatcherQueue.TryEnqueue( - Microsoft.UI.Dispatching.DispatcherQueuePriority.Low, - () => - { - _ = TryMoveFocusFromCurrentContentIntoMainNavigation(); - }); + var initialFocusRoot = XamlRoot; + var initialFocusedElement = initialFocusRoot is null + ? null + : Microsoft.UI.Xaml.Input.FocusManager.GetFocusedElement(initialFocusRoot) as DependencyObject; + if (initialFocusRoot is not null && !IsDescendantOf(initialFocusedElement, ContentFrame)) + { + var initialFocusState = (initialFocusedElement as Control)?.FocusState; + var initialFocusCorrelationId = _nativeFocusCorrelationId; + var initialActivationToken = _shellNavigationRuntimeState.LatestActivationToken; + var initialContentNavigationVersion = Interlocked.Read(ref _contentFrameNavigationVersion); + // Low-priority work can run after the user has already navigated or begun typing. + // Seed only the original focus intent; native focus and navigation remain the owners. + _ = DispatcherQueue.TryEnqueue( + Microsoft.UI.Dispatching.DispatcherQueuePriority.Low, + () => + { + var currentFocusedElement = XamlRoot is null + ? null + : Microsoft.UI.Xaml.Input.FocusManager.GetFocusedElement(XamlRoot) as DependencyObject; + if (!IsLoaded + || !ReferenceEquals(XamlRoot, initialFocusRoot) + || _shellNavigationRuntimeState.LatestActivationToken != initialActivationToken + || Interlocked.Read(ref _contentFrameNavigationVersion) != initialContentNavigationVersion + || _nativeFocusCorrelationId != initialFocusCorrelationId + || !ReferenceEquals(currentFocusedElement, initialFocusedElement) + || (currentFocusedElement as Control)?.FocusState != initialFocusState) + { + return; + } + + _ = TryMoveFocusFromCurrentContentIntoMainNavigation(); + }); + } InitializeTray(); await _startupWorkflow.InitializeRuntimeAsync().ConfigureAwait(true); @@ -745,6 +780,9 @@ private async void OnMainPageLoaded(object sender, RoutedEventArgs e) NumberBoxThemeProbeDriver.TryStart(App.ServiceProvider, this); } + private void OnMainPageGettingFocus(object? sender, GettingFocusEventArgs e) + => _nativeFocusCorrelationId = e.CorrelationId; + private void AttachGamepadInput() { if (_isGamepadInputAttached) diff --git a/SalmonEgg/SalmonEgg/Presentation/Views/ConfigurationEditorDialog.xaml b/SalmonEgg/SalmonEgg/Presentation/Views/ConfigurationEditorDialog.xaml index 281cd767b..2d1656a0d 100644 --- a/SalmonEgg/SalmonEgg/Presentation/Views/ConfigurationEditorDialog.xaml +++ b/SalmonEgg/SalmonEgg/Presentation/Views/ConfigurationEditorDialog.xaml @@ -81,6 +81,49 @@ + + + + + + + + + + + + + + + + +