Skip to content

Fix NyxID relay callback authentication#368

Merged
eanzhao merged 5 commits into
devfrom
fix/2026-04-24_nyxid-relay-callback-jwt
Apr 24, 2026
Merged

Fix NyxID relay callback authentication#368
eanzhao merged 5 commits into
devfrom
fix/2026-04-24_nyxid-relay-callback-jwt

Conversation

@eanzhao
Copy link
Copy Markdown
Contributor

@eanzhao eanzhao commented Apr 24, 2026

Summary

Fixes #366.

This replaces the NyxID relay callback HMAC/local-secret authentication path with a dedicated callback JWT verified through JWKS. The callback token validates aud, expiry with clock skew, jti, api_key_id, message_id, platform, and byte-exact body_sha256 binding before the relay payload is accepted.

The relay reply token is not persisted in ChatActivity, actor state, read models, or outbound protobuf contracts. It is carried only on the transient actor-inbox relay envelope, captured into ConversationGAgent runtime memory for the current turn, and cleaned on success, failure, duplicate delivery, token expiry, or actor deactivation. Outbound delivery persists only reply_message_id and correlation_id; the old reply_access_token protobuf field is reserved.

Rollout Note

This PR intentionally does not restore HMAC fallback or IAevatarSecretsStore usage in production paths. Aevatar should be deployed after NyxID callback JWT forwarding is available; callbacks without X-NyxID-Callback-Token are rejected as callback_jwt_missing.

Root Cause

The previous relay HMAC flow depended on IAevatarSecretsStore, which stores secrets locally on each node. In multi-pod deployment, provisioning can write the relay secret on one pod while callbacks land on another pod, causing signature verification to use a missing key and fail as invalid_signature.

Changes

  • Add callback JWT validation for NyxID relay callbacks, including kid miss JWKS refresh cooldown and single-flight behavior.
  • Read the raw request body for byte-exact body_sha256 validation.
  • Add correlation_id to the relay callback payload and require it to match JWT jti.
  • Register the relay replay guard in production DI and reject replayed callback jti / message_id values.
  • Add callback_jwt_validation_failures_total{reason} metrics.
  • Move reply token handling into actor-owned runtime state and schedule expiry cleanup.
  • Reserve old relay secret / reply-token protobuf fields (credential_ref, reply_access_token) and remove dead credential-ref plumbing.
  • Remove relay HMAC credential resolver and ChannelRuntime production IAevatarSecretsStore registration.
  • Add an architecture guard preventing service/agent DI from default-registering IAevatarSecretsStore.
  • Update endpoint, relay transport/outbound delivery, provisioning, and regression tests.

Validation

  • dotnet build test/Aevatar.AI.Tests/Aevatar.AI.Tests.csproj --nologo --no-restore
  • dotnet build test/Aevatar.GAgents.ChannelRuntime.Tests/Aevatar.GAgents.ChannelRuntime.Tests.csproj --nologo --no-restore
  • dotnet build test/Aevatar.GAgents.Channel.Protocol.Tests/Aevatar.GAgents.Channel.Protocol.Tests.csproj --nologo --no-restore
  • dotnet test test/Aevatar.AI.Tests/Aevatar.AI.Tests.csproj --nologo
  • dotnet test test/Aevatar.GAgents.ChannelRuntime.Tests/Aevatar.GAgents.ChannelRuntime.Tests.csproj --nologo --no-build
  • dotnet test test/Aevatar.GAgents.Channel.Protocol.Tests/Aevatar.GAgents.Channel.Protocol.Tests.csproj --nologo --no-build
  • bash tools/ci/test_stability_guards.sh
  • git diff --check

bash tools/ci/architecture_guards.sh was run locally and passed the earlier sub-guards, but stopped at proto lint because buf is not installed in the local environment. The added secrets-store DI scan was also run directly and returned no hits.

@eanzhao eanzhao marked this pull request as ready for review April 24, 2026 05:06
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b5f51393b4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@eanzhao
Copy link
Copy Markdown
Contributor Author

eanzhao commented Apr 24, 2026

Review 对照 issue #366 — 不能 close #366

整体方向对,validator 重写得很干净(错误码与 issue §1 表格 12 项逐项对齐、JWKS kid miss cooldown + single-flight via SemaphoreSlim、body_sha256 走 raw bytes、correlation_id == jti 绑定都到位),provisioning / IAevatarSecretsStore / NyxIdRelayRegistrationCredentialResolver 清理彻底,新加的 architecture_guards.sh 也兜住了 host 与 agents ServiceCollectionExtensions.cs 的 secrets-store 注册。但下面这些点要么直接违背 issue 的验收标准,要么会让上线即故障,所以现在还不能 close #366

🔴 阻塞项(必须修复)

1. NyxID 侧的 callback JWT 还没有发布,PR 是硬切 + 无 fallback。

  • ChronoAIProject/NyxID#500 当前仍是 OPEN。生产上 NyxID forward 出去的还是 X-NyxID-User-Token + X-NyxID-Signature 这一对,没有 X-NyxID-Callback-Token
  • 本 PR 把 Aevatar callback 主认证改成"必须有 X-NyxID-Callback-Token,否则 callback_jwt_missing 401",并完全删掉了 HMAC 路径。merge + deploy 的瞬间,所有 Lark→NyxID→Aevatar 回调都会 401。
  • 这与 issue §6 明文要求的顺序直接冲突:
    1. NyxID 先上线 callback JWT…
    2. Aevatar 先上线 JWT 验证路径,并通过 per-registration feature flag 控制历史注册的 HMAC fallback。
    3. 灰度期间优先 JWT;JWT 缺失时短期允许 HMAC fallback…
    4. 最后删除 Aevatar HMAC secret 存储…
  • 现在的实现把第 5 步的删除提前到了第 2 步,并且没有 per-registration feature flag、没有任何 fallback、没有全局 emergency kill switch。建议:要么等 NyxID#500 先 merge 并部署到所有 NyxID pod 之后再 merge 本 PR,要么在本 PR 内补一条 per-registration JWT-only flag + HMAC fallback 路径作为过渡。

2. Replay guard 在生产 DI 里没注册,replay 防护实际上被关掉了。

  • agents/Aevatar.GAgents.ChannelRuntime/ServiceCollectionExtensions.cs 这次把原来对 INyxIdRelayReplayGuardTryAddSingleton(...) 注册整段删掉了。
  • 全仓 grep 之后,INyxIdRelayReplayGuard 在生产代码里没有任何替代注册(只有测试构造函数里手动 new)。
  • NyxIdRelayAuthValidator 构造函数里 INyxIdRelayReplayGuard? replayGuard = null,Validate 流程里又是 if (_replayGuard is not null) { ... },所以生产路径里 jti / message_id 的 replay 校验完全被跳过。
  • 这违背 issue §7:"inbound callback 幂等由 Aevatar 负责,至少按 message_id / callback JWT jti 去重"。
  • 修复方向:在 ChannelRuntimeNyxidChat 的 DI 里重新 TryAddSingleton<INyxIdRelayReplayGuard>(...)(用合适的 TTL window,比 callback JWT TTL 大一个 skew 即可),并加一个验证生产 DI 已注册 replay guard 的测试。

🟠 与 issue 验收标准的明显偏差

3. ChannelTransportBinding.credential_ref proto 字段没 reserve,留了空壳。

  • agents/Aevatar.GAgents.Channel.Abstractions/protos/channel_contracts.proto:87 仍然是 string credential_ref = 2;,PR 只是在所有写入点把它塞成 string.Empty
  • issue §4 明文:"ChannelBotRegisterCommand / ChannelBotRegistrationEntry / ChannelBotRegistrationDocument 里的 credential_ref 如果只剩历史 HMAC 用途,应删除并 reserve proto field;不要保留空壳兼容。"
  • 这正是 issue 禁止的"空壳兼容"。建议按 issue 要求改成 reserved 2; reserved \"credential_ref\";,同步删除所有调用点的 credentialRef: string.Empty 形参。

4. reply_token 进入了 proto 传输契约 NyxRelayInboundActivity

  • agents/Aevatar.GAgents.Channel.Runtime/protos/conversation_events.proto 新增的 NyxRelayInboundActivity { string reply_token = 2; int64 reply_token_expires_at_unix_ms = 3; ... } 这一段,把 reply_token 写进了 proto transport contract。
  • issue §4 + 验收标准:"reply_token 不进入任何持久化 state / event / read model / proto transport contract"。
  • 实现上你确实只把它当作 actor inbox 内的瞬时 envelope,actor 收到之后立即捕获到内存 dict、不再持久化,工程意图是对的;但字面上这条验收没满足。两种收法:
    • 把 reply_token 不通过 proto envelope 传递,改成 endpoint 直接调用 actor runtime 的内存 API 写入 token(更贴近 issue 字面要求,但耦合一层运行时直调);
    • 或者明确在 issue 里把这条改成"不进入任何持久化 state / event / read model / 持久化 proto transport contract",承认 inbox 内瞬时 envelope 不算违例。我倾向后者,但需要在 issue 上 explicitly 改一下措辞,否则 close 时说不清。

5. callback_jwt_validation_failures_total{reason=<error_code>} 指标没加。

  • issue §7 + 验收标准明文要求"灰度阶段比只看日志更容易判断失败分布"。这次 PR 只补了 logging,没加 counter / metric。建议补一个最小实现(counter + reason 标签),否则灰度过程没法量化错误分布。

🟡 次级问题

6. Token 清理只覆盖了 success / non-retryable failure。

  • _nyxRelayReplyTokens dict 在重试性失败上不会被清掉,靠 BuildNyxRelayRuntimeContext 读取时 lazy 检查 ExpiresAtUtc 才删。actor 长期存活 + 大量 transient failure 时会有 stale entries 累积。
  • issue §3 明文要求"turn 完成、发送成功、token consumed、超时、deactivate 时清理","超时"这一条目前只是 lazy;deactivate 自然清理可以接受(actor 重新激活 dict 就空了)。建议至少加一个周期性清理或在 transient failure 路径上也 RemoveNyxRelayReplyToken。

7. 架构守卫的 ripgrep 模式过宽。

  • tools/ci/architecture_guards.sh 新加的 rg "AddSingleton<IAevatarSecretsStore|TryAddSingleton<IAevatarSecretsStore|IAevatarSecretsStore" 第三个 alt 会命中任何对 IAevatarSecretsStore 的引用,不只是 DI 注册。如果这是有意把所有引用都禁掉(连 using/类型引用都不允许出现在 host / agents ServiceCollectionExtensions.cs),可以保留;如果只想禁注册,建议收窄到 (Add|TryAdd)Singleton<IAevatarSecretsStore

8. Projection OCC 端到端测试未覆盖。

✅ 做得好的部分

  • NyxIdRelayAuthValidator 重写得很整齐:12 个 error code 与 issue §1 表格逐项对齐;SemaphoreSlim + _lastForcedRefreshUtc 实现 single-flight + cooldown;ComputeBodySha256Hex(bodyBytes) 走 raw bytes;payload.correlation_id == token.jti 绑定明确;JWT 中没有重复 correlation_id claim。
  • Endpoint 在反序列化前先 MemoryStream.CopyToAsync 拿到 raw bodyBytes 再传给 validator,body hash 校验链路是真的端到端。
  • Provisioning service 把 IAevatarSecretsStoreBuildRelayCredentialRefResolveExistingCredentialRefComputeApiKeyHash 都干干净净拔掉了;NyxIdRelayRegistrationCredentialResolver 实现 + 接口同步删除。
  • OutboundDeliveryContext.reply_access_token 正确 reserve(field 2 + name),新加 correlation_id = 3
  • Reply token 落到 actor in-memory dict,actor-owned,符合架构要求。
  • 新增 architecture_guards.sh 的 secrets-store 注册扫描,门禁前置,思路对。
  • 测试覆盖大部分 issue §1 的 reason code 分支(callback_jwt_missing/audience/issuer/signature/lifetime/api_key_id/message_id/platform/correlation_id/body_hash/replay/kid_throttle)。

建议下一步

  • 立刻:要么补 per-registration HMAC fallback + JWT-only flag,要么把本 PR rebase 到 NyxID#500 部署完成之后再 merge,避免上线即 401。
  • 必修:把 INyxIdRelayReplayGuard 重新挂回生产 DI,并加一个 DI 注册 assertion 测试,防止以后再被无声删掉。
  • 必修:补 callback_jwt_validation_failures_total{reason} counter。
  • 应修:ChannelTransportBinding.credential_ref proto 改 reserved,删空壳形参。
  • 应修:澄清 NyxRelayInboundActivity.reply_token 与 issue §4 验收措辞之间的取舍,留个 PR comment 或更新 issue。
  • 可后续:token cleanup 的 transient failure / 周期清理;OCC 端到端测试在 Propagate event-store OCC so projection scope can retry #365 路径上补齐。

这些处理完之后 #366 才适合 close。当前状态可以作为主干推进,但不建议直接 close issue。

@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 24, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 70.04%. Comparing base (1e6dce9) to head (111dade).
⚠️ Report is 6 commits behind head on dev.

@@           Coverage Diff           @@
##              dev     #368   +/-   ##
=======================================
  Coverage   70.04%   70.04%           
=======================================
  Files        1155     1155           
  Lines       82584    82584           
  Branches    10868    10868           
=======================================
  Hits        57844    57844           
  Misses      20562    20562           
  Partials     4178     4178           
Flag Coverage Δ
ci 70.04% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

eanzhao added 2 commits April 24, 2026 13:41
Document NyxRelayInboundActivity as an actor-inbox-only envelope that
must never enter persisted state, then add a regression test that runs a
relay inbound + LLM reply turn with a sentinel reply_token and asserts
the byte sequence never appears in any persisted event payload.
@eanzhao
Copy link
Copy Markdown
Contributor Author

eanzhao commented Apr 24, 2026

Re-review after 279e4d46 + 4d9ebea5

逐项核对:

Review 项 状态 备注
🔴 Replay guard 生产 DI 缺失 ✅ 已修 INyxIdRelayReplayGuardAevatar.GAgents.ChannelRuntime/ServiceCollectionExtensions.csAevatar.GAgents.NyxidChat/ServiceCollectionExtensions.cs 都重新 TryAddSingletonCallbackReplayWindowSeconds 默认 6 分钟,并加了 NyxIdChatServiceCollectionExtensionsTests + ServiceCollectionExtensionsTests 两处 DI 注册断言。validator Fail() 也走同一通道发指标。
🟠 credential_ref proto 留空壳 ✅ 已修 ChannelTransportBinding / ChannelBotRegistrationEntry / ChannelBotRegisterCommand / ChannelBotRegistrationDocumentreserved 2/9/12/13; + reserved "credential_ref",全调用链(projector / query port / GAgent / tool / provisioning / mirror repair request / prompt 文案)一并删除。新增 proto compatibility 与 surface 测试断言 descriptor 找不到该字段。
🟠 callback_jwt_validation_failures_total{reason} 指标 ✅ 已修 新增 NyxIdRelayMetrics (MeterAevatar.Channel.NyxIdRelay,counter callback_jwt_validation_failures_total);validator 用 Fail() 集中点统一 emit reason,并加了 MeterListener 断言测试。
🟠 NyxRelayInboundActivity.reply_token 与 issue §4 字面冲突 ✅ 收口 我在这次 push (4d9ebea5) 加了两层保险:proto 上明确注释为 actor-inbox-only transient envelope,禁止持久化;并在 ConversationGAgentDedupTests 增加端到端 invariant 测试 — 用 sentinel reply_token 跑完 relay inbound → LLM reply ready → completed 全流程,扫描所有 IEventStore payload bytes,确认 token 字节序列从未落盘。
🟡 Token cleanup 仅覆盖 success / non-retryable ✅ 已修 RemoveNyxRelayReplyToken 现已扩展到 duplicate detect、inbound transient/permanent failure、LLM reply success、permanent failure;新增 ScheduleNyxRelayReplyTokenCleanupAsync + NyxRelayReplyTokenCleanupRequestedEvent durable timeout 兜底 transient retry 链路;capture 与 read 路径都做了 SweepExpiredNyxRelayReplyTokens() lazy GC。
🟡 架构守卫 ripgrep 模式过宽 ✅ 已修 收窄到 (AddSingleton|TryAddSingleton)<IAevatarSecretsStore,只禁 DI 注册不再误伤普通引用。
Projection OCC 端到端测试 ⏸️ 仍延后 #365 配对处理,本 PR 不涉及。
🔴 NyxID 切换顺序(NyxID#500) ⏸️ 暂搁置 按要求等 NyxID 那边 callback JWT feature 上线 + 我这边二次复核之后再 merge。

本地验证:

  • dotnet test test/Aevatar.AI.Tests 462 通过
  • dotnet test test/Aevatar.GAgents.ChannelRuntime.Tests 281 通过
  • dotnet test test/Aevatar.GAgents.Channel.Protocol.Tests 102 通过(包含新加的 HandleNyxRelayInboundActivityAsync_NeverPersistsReplyTokenIntoEventStore
  • bash tools/ci/architecture_guards.sh 通过到 buf lint(本地无 buf,与 PR 描述一致)
  • bash tools/ci/test_stability_guards.sh 通过

架构上剩余唯一的开放项就是 NyxID#500 上线 + 灰度顺序确认,按指示等回头来 verify。其余 issue #366 验收标准都收敛了。

eanzhao added 2 commits April 24, 2026 14:01
NyxID's relay callback JWT does not emit a sub claim, and Aevatar never
consumes the resulting ScopeId downstream. Remove the dead ScopeId field
from the auth result records along with the unused sub read.

Also fill in token_type="relay_callback" on the endpoint coverage test's
JWT helper so the validator's new token_type check accepts the synthetic
callbacks.
@eanzhao
Copy link
Copy Markdown
Contributor Author

eanzhao commented Apr 24, 2026

Re-review after 839d805f + 111dade6

839d805f 把契约对齐 NyxID#504 完整收口,并且顺手修了我之前 review 漏掉的一个真 bug

修了我漏的 bug — replay guard 之前同时按 jtimessage_id 去重,但 NyxID 的 retry 是 fresh jti + 同 message_id,会被当成 replay 拒绝。新代码只按 jti 去重(auth-replay 语义),message_id 级别的 idempotency 留给 ConversationGAgent.ProcessedMessageIds(业务 idempotency 语义)。新测试 ValidateAsync_ShouldAcceptRetryWithSameMessageIdAndFreshJti 正好覆盖这条路径。✅

补齐 token_type 校验 — 新增 RelayCallbackTokenType = "relay_callback" 强校验,对齐 NyxID 发的 token_type = "relay_callback",错误码 callback_jwt_token_type_mismatch。✅

我刚 push 的 111dade6 收尾两件事:

  1. sub claim 的死代码:NyxID 的 RelayCallbackClaims 不发 sub,Aevatar 取 principal.FindFirstValue("sub") 之后只塞进 NyxIdRelayAuthenticationResult.ScopeId,全仓没有任何下游消费者(grep 过 endpoint / runner / tool 都没有)。删掉 ScopeId 字段(两处 record + 一处赋值 + 一处读取 + 测试断言),免得将来有人误以为生产里 ScopeId 会有值。
  2. CreateRelayJwt helper 的 token_type839d805f 把 token_type 改成必填,但 NyxIdChatEndpointsCoverageTests.cs 里那个 CreateRelayJwt helper 没同步加 token_type claim,导致 5 个 endpoint coverage 测试 401 红了(HandleRelayWebhookAsync_ShouldAcceptAndDispatchChatActivity_WhenRelayIsValid 等)。补上 new Claim("token_type", "relay_callback"),并把 helper 链路上现在彻底无用的 subject / ScopeId 参数一并清理。

本地验证:

  • dotnet test test/Aevatar.AI.Tests 464 通过(之前作者 push 后实际是 459 通过 + 5 红,但他可能没全跑)
  • dotnet test test/Aevatar.GAgents.ChannelRuntime.Tests 281 通过
  • dotnet test test/Aevatar.GAgents.Channel.Protocol.Tests 102 通过
  • bash tools/ci/architecture_guards.sh 通过到 buf lint

NyxID#504 已 merge。Aevatar 这边契约层全绿、测试全绿,等 NyxID 部署到生产之后,这个 PR 就可以走灰度了。

@eanzhao eanzhao merged commit 0a15750 into dev Apr 24, 2026
11 checks passed
eanzhao added a commit that referenced this pull request Apr 24, 2026
Drives streaming edit-in-place of an LLM reply so Lark users see a
placeholder within ~1s and incremental updates instead of a 5-30s
silent wait. Built on top of the NyxID relay edit endpoint shipped in
ChronoAIProject/NyxID#480 / #483.

This redesign keeps the reply token inside the conversation actor (per
PR #368's security boundary): the inbox runtime only accumulates deltas
and signals them to the actor; the actor is the sole caller of the
outbound port, holding both the reply token and the placeholder
platform_message_id in in-memory runtime state.

## Flow

    Inbox runtime (async LLM, no outbound port access):
        LLM stream delta -> TurnStreamingReplySink.OnDeltaAsync (throttle 750ms)
            -> actor.HandleEventAsync(LlmReplyStreamChunkEvent)
        LLM stream ends -> TurnStreamingReplySink.FinalizeAsync (bypass throttle)
            -> actor.HandleEventAsync(LlmReplyStreamChunkEvent) final flush
            -> actor.HandleEventAsync(LlmReplyReadyEvent)

    ConversationGAgent (single-threaded turn, owns reply token + streaming state):
        HandleLlmReplyStreamChunkAsync:
            - resolve runtimeContext with reply token (same path as HandleLlmReplyReadyAsync)
            - read per-correlation streaming state (PlatformMessageId, Disabled, EditCount)
            - if first chunk: runner.RunStreamChunkAsync(chunk, null PMID) -> placeholder send
            - if subsequent: runner.RunStreamChunkAsync(chunk, PMID) -> edit
            - on failure: mark state.Disabled and drop further chunks for this turn
        HandleLlmReplyReadyAsync:
            - if streaming state is healthy and LLM completed:
                force final update if final text differs from last flushed,
                then persist ConversationTurnCompletedEvent directly (no re-send)
            - otherwise: existing RunLlmReplyAsync fallback path
            - cleanup streaming state + reply token in both paths

## Architectural rules respected

- Reply token never leaves the actor. Inbox runtime can't call the
  outbound port; it can only signal via EventEnvelope. Actor's
  _nyxRelayReplyTokens dict remains the single authoritative store.
- No middle-layer ID map. Sink per-turn state (throttle timestamp,
  last emitted text) lives in instance fields on a per-invocation
  sink; the actor's _nyxRelayStreamingStates dict is actor-owned
  runtime state, same lifecycle as _nyxRelayReplyTokens.
- No generic actor query/reply. Chunk dispatch is fire-and-(awaited)-
  order-preserving signaling; no back-channel read.
- New proto event LlmReplyStreamChunkEvent is a runtime-only signal
  documented as never-persist; HandleEventAsync dispatches to handler
  without PersistDomainEventAsync, matching existing event flow.
- Strong-typed event evolution: new event instead of overloading
  LlmReplyReadyEvent with a boolean, keeping semantics single-purpose.

## Degradation policy (v1)

Any placeholder or update failure permanently disables streaming for
the turn and falls back to the legacy single-shot reply path. Partial
stale placeholder may remain visible to the user; the final send
arrives as a second message. Transient-failure retry is tracked
separately in #371 as backlog.

## Feature flag

NyxIdRelayOptions.StreamingRepliesEnabled defaults to true;
StreamingFlushIntervalMs defaults to 750ms per the issue spec.

## Verification

- dotnet build aevatar.slnx
- dotnet test test/Aevatar.GAgents.ChannelRuntime.Tests (298 passed, +17 new)
- dotnet test test/Aevatar.AI.Tests (469 passed, +5 new)
- dotnet test test/Aevatar.GAgents.Channel.Protocol.Tests (108 passed, +6 new)
- bash tools/ci/architecture_guards.sh
- bash tools/ci/test_stability_guards.sh

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Fix NyxID Lark relay authentication and keep relay tokens out of persisted state

1 participant