From 16e5d6692dcc688f4a4ac90a77b7939bbed05051 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 16 May 2026 15:15:57 +0100 Subject: [PATCH] fix(gateway): bound traced channel startup handoff (#82592) * fix(gateway): bound traced channel startup handoff * fix(github-copilot): guard device login fetches * fix(gateway): skip stopped traced channel handoffs * test(net): keep guarded fetch mocks hermetic --- CHANGELOG.md | 2 + extensions/github-copilot/login.ts | 4 +- src/gateway/server-channels.test.ts | 62 +++++++++++++++++++++++++++++ src/gateway/server-channels.ts | 27 ++++++++++--- src/infra/net/fetch-guard.ts | 14 ++++++- 5 files changed, 101 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a91a7e44b6..f715191c74f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,8 @@ Docs: https://docs.openclaw.ai - Agents/sessions: preserve fresh post-compaction token snapshots across stale usage updates, preventing repeated auto-compaction after every message. Fixes #82576. (#82578) Thanks @njuboy11. - Gateway/sessions: discard stale metadata when recreating dead main session rows, so replacement sessions do not inherit old labels or transcript paths. - Codex app-server: mark native context compaction completion events as successful, preventing false "Compaction incomplete" notices after successful Codex-managed compaction. Fixes #82470. (#81593) Thanks @Kyzcreig. +- Gateway/channels: hand off traced channel account startup outside the startup diagnostic phase so long-lived channel tasks do not keep liveness warnings pinned to channel startup. Refs #82398. +- GitHub Copilot: route device-login requests through the plugin SSRF guard with a GitHub-only policy. - Gateway/WebChat: route image attachments through a configured vision-capable `imageModel` plan before inlining images, and carry that image-model fallback chain through runtime retries. (#82524) Thanks @frankekn. - WebChat: show progress while manual `/compact` is running by streaming a session operation event to subscribed Control UI clients. Fixes #82407. Thanks @Conan-Scott. - Codex app-server: limit canonical OpenAI Codex app-server attribution rewrites to local transcript and trajectory records, leaving runtime/tool routing on the selected OpenAI model metadata so OpenAI API-key backup profiles keep their billing path. diff --git a/extensions/github-copilot/login.ts b/extensions/github-copilot/login.ts index b8b38a4f7cd..888247bf039 100644 --- a/extensions/github-copilot/login.ts +++ b/extensions/github-copilot/login.ts @@ -7,12 +7,13 @@ import { upsertAuthProfileWithLock, } from "openclaw/plugin-sdk/provider-auth"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime"; -import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; +import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime"; const CLIENT_ID = "Iv1.b507a08c87ecfe98"; const DEVICE_CODE_URL = "https://github.com/login/device/code"; const ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token"; const GITHUB_DEVICE_VERIFICATION_URL = "https://github.com/login/device"; +const GITHUB_AUTH_SSRF_POLICY: SsrFPolicy = { hostnameAllowlist: ["github.com"] }; type DeviceCodeResponse = { device_code: string; @@ -96,6 +97,7 @@ async function postGitHubDeviceFlowForm(params: { body: params.body, }, requireHttps: true, + policy: GITHUB_AUTH_SSRF_POLICY, auditContext: "github-copilot-device-flow", }); try { diff --git a/src/gateway/server-channels.test.ts b/src/gateway/server-channels.test.ts index ac7ffc42850..c7d3603a8ac 100644 --- a/src/gateway/server-channels.test.ts +++ b/src/gateway/server-channels.test.ts @@ -733,12 +733,74 @@ describe("server-channels auto restart", () => { const manager = createManager({ startupTrace }); await manager.startChannels(); + expect(startAccount).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(0); + await flushMicrotasks(); const names = measureMock.mock.calls.map(([name]) => name); expect(names).toContain("channels.discord.start"); expect(names).toContain("channels.discord.list-accounts"); expect(names).toContain("channels.discord.runtime"); expect(names).toContain("channels.discord.approval-bootstrap"); + expect(names).toContain("channels.discord.start-account-handoff"); + expect(startAccount).toHaveBeenCalledTimes(1); + }); + + it("ends startup trace spans before long-lived channel account tasks settle", async () => { + const activeNames = new Set(); + const measuredNames: string[] = []; + const startupTrace = { + measure: async (name: string, run: () => T | Promise) => { + activeNames.add(name); + measuredNames.push(name); + try { + return await run(); + } finally { + activeNames.delete(name); + } + }, + }; + const channelTask = createDeferred(); + const startAccount = vi.fn(() => channelTask.promise); + + installTestRegistry(createTestPlugin({ startAccount })); + const manager = createManager({ startupTrace }); + + await manager.startChannels(); + await vi.advanceTimersByTimeAsync(0); + await flushMicrotasks(); + + expect(startAccount).toHaveBeenCalledTimes(1); + expect(measuredNames).toContain("channels.discord.start-account-handoff"); + expect(activeNames.has("channels.discord.start-account-handoff")).toBe(false); + expect( + manager.getRuntimeSnapshot().channelAccounts.discord?.[DEFAULT_ACCOUNT_ID]?.running, + ).toBe(true); + + channelTask.resolve(); + await flushMicrotasks(); + }); + + it("does not start traced channel accounts after stop wins the handoff", async () => { + const startupTrace = { + measure: async (_name: string, run: () => T | Promise) => await run(), + }; + const startAccount = vi.fn(async () => {}); + + installTestRegistry(createTestPlugin({ startAccount })); + const manager = createManager({ startupTrace }); + + await manager.startChannel("discord", DEFAULT_ACCOUNT_ID); + const stopTask = manager.stopChannel("discord", DEFAULT_ACCOUNT_ID); + await vi.advanceTimersByTimeAsync(0); + await stopTask; + await flushMicrotasks(); + + expect(startAccount).not.toHaveBeenCalled(); + expect( + manager.getRuntimeSnapshot().channelAccounts.discord?.[DEFAULT_ACCOUNT_ID]?.running, + ).toBe(false); }); it("limits whole-channel account startup fanout to four", async () => { diff --git a/src/gateway/server-channels.ts b/src/gateway/server-channels.ts index 2d858cc619b..56755a2d6c3 100644 --- a/src/gateway/server-channels.ts +++ b/src/gateway/server-channels.ts @@ -39,6 +39,13 @@ const MAX_RESTART_ATTEMPTS = 10; const CHANNEL_STOP_ABORT_TIMEOUT_MS = 5_000; const CHANNEL_STARTUP_CONCURRENCY = 4; +function waitForChannelStartupHandoff(): Promise { + return new Promise((resolve) => { + const handle = setImmediate(resolve); + handle.unref?.(); + }); +} + type ChannelRuntimeStore = { aborts: Map; starting: Map>; @@ -512,9 +519,16 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage lastError: null, reconnectAttempts: preserveRestartAttempts ? (restartAttempts.get(rKey) ?? 0) : 0, }); - const task = Promise.resolve().then(() => - measureStartup(`channels.${channelId}.start-account`, () => - startAccount({ + const task = Promise.resolve().then(async () => { + if (startupTrace) { + await waitForChannelStartupHandoff(); + } + if (abort.signal.aborted || manuallyStopped.has(rKey)) { + return; + } + let startAccountTask: ReturnType | undefined; + await measureStartup(`channels.${channelId}.start-account-handoff`, () => { + startAccountTask = startAccount({ cfg, accountId: id, account, @@ -524,9 +538,10 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage getStatus: () => getRuntime(channelId, id), setStatus: (next) => setRuntime(channelId, id, next), ...(channelRuntimeForTask ? { channelRuntime: channelRuntimeForTask } : {}), - }), - ), - ); + }); + }); + await startAccountTask; + }); const trackedPromise = task .then(() => { if (abort.signal.aborted || manuallyStopped.has(rKey)) { diff --git a/src/infra/net/fetch-guard.ts b/src/infra/net/fetch-guard.ts index 9ecb6cca9e0..f167a8519cf 100644 --- a/src/infra/net/fetch-guard.ts +++ b/src/infra/net/fetch-guard.ts @@ -347,6 +347,7 @@ export async function fetchWithSsrFGuard(params: GuardedFetchOptions): Promise