Skip to content

Commit 16e5d66

Browse files
authored
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
1 parent eebdbab commit 16e5d66

5 files changed

Lines changed: 101 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ Docs: https://docs.openclaw.ai
2020
- Agents/sessions: preserve fresh post-compaction token snapshots across stale usage updates, preventing repeated auto-compaction after every message. Fixes #82576. (#82578) Thanks @njuboy11.
2121
- Gateway/sessions: discard stale metadata when recreating dead main session rows, so replacement sessions do not inherit old labels or transcript paths.
2222
- 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.
23+
- 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.
24+
- GitHub Copilot: route device-login requests through the plugin SSRF guard with a GitHub-only policy.
2325
- 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.
2426
- WebChat: show progress while manual `/compact` is running by streaming a session operation event to subscribed Control UI clients. Fixes #82407. Thanks @Conan-Scott.
2527
- 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.

extensions/github-copilot/login.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,13 @@ import {
77
upsertAuthProfileWithLock,
88
} from "openclaw/plugin-sdk/provider-auth";
99
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
10-
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
10+
import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
1111

1212
const CLIENT_ID = "Iv1.b507a08c87ecfe98";
1313
const DEVICE_CODE_URL = "https://github.com/login/device/code";
1414
const ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token";
1515
const GITHUB_DEVICE_VERIFICATION_URL = "https://github.com/login/device";
16+
const GITHUB_AUTH_SSRF_POLICY: SsrFPolicy = { hostnameAllowlist: ["github.com"] };
1617

1718
type DeviceCodeResponse = {
1819
device_code: string;
@@ -96,6 +97,7 @@ async function postGitHubDeviceFlowForm(params: {
9697
body: params.body,
9798
},
9899
requireHttps: true,
100+
policy: GITHUB_AUTH_SSRF_POLICY,
99101
auditContext: "github-copilot-device-flow",
100102
});
101103
try {

src/gateway/server-channels.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -733,12 +733,74 @@ describe("server-channels auto restart", () => {
733733
const manager = createManager({ startupTrace });
734734

735735
await manager.startChannels();
736+
expect(startAccount).not.toHaveBeenCalled();
737+
738+
await vi.advanceTimersByTimeAsync(0);
739+
await flushMicrotasks();
736740

737741
const names = measureMock.mock.calls.map(([name]) => name);
738742
expect(names).toContain("channels.discord.start");
739743
expect(names).toContain("channels.discord.list-accounts");
740744
expect(names).toContain("channels.discord.runtime");
741745
expect(names).toContain("channels.discord.approval-bootstrap");
746+
expect(names).toContain("channels.discord.start-account-handoff");
747+
expect(startAccount).toHaveBeenCalledTimes(1);
748+
});
749+
750+
it("ends startup trace spans before long-lived channel account tasks settle", async () => {
751+
const activeNames = new Set<string>();
752+
const measuredNames: string[] = [];
753+
const startupTrace = {
754+
measure: async <T>(name: string, run: () => T | Promise<T>) => {
755+
activeNames.add(name);
756+
measuredNames.push(name);
757+
try {
758+
return await run();
759+
} finally {
760+
activeNames.delete(name);
761+
}
762+
},
763+
};
764+
const channelTask = createDeferred();
765+
const startAccount = vi.fn(() => channelTask.promise);
766+
767+
installTestRegistry(createTestPlugin({ startAccount }));
768+
const manager = createManager({ startupTrace });
769+
770+
await manager.startChannels();
771+
await vi.advanceTimersByTimeAsync(0);
772+
await flushMicrotasks();
773+
774+
expect(startAccount).toHaveBeenCalledTimes(1);
775+
expect(measuredNames).toContain("channels.discord.start-account-handoff");
776+
expect(activeNames.has("channels.discord.start-account-handoff")).toBe(false);
777+
expect(
778+
manager.getRuntimeSnapshot().channelAccounts.discord?.[DEFAULT_ACCOUNT_ID]?.running,
779+
).toBe(true);
780+
781+
channelTask.resolve();
782+
await flushMicrotasks();
783+
});
784+
785+
it("does not start traced channel accounts after stop wins the handoff", async () => {
786+
const startupTrace = {
787+
measure: async <T>(_name: string, run: () => T | Promise<T>) => await run(),
788+
};
789+
const startAccount = vi.fn(async () => {});
790+
791+
installTestRegistry(createTestPlugin({ startAccount }));
792+
const manager = createManager({ startupTrace });
793+
794+
await manager.startChannel("discord", DEFAULT_ACCOUNT_ID);
795+
const stopTask = manager.stopChannel("discord", DEFAULT_ACCOUNT_ID);
796+
await vi.advanceTimersByTimeAsync(0);
797+
await stopTask;
798+
await flushMicrotasks();
799+
800+
expect(startAccount).not.toHaveBeenCalled();
801+
expect(
802+
manager.getRuntimeSnapshot().channelAccounts.discord?.[DEFAULT_ACCOUNT_ID]?.running,
803+
).toBe(false);
742804
});
743805

744806
it("limits whole-channel account startup fanout to four", async () => {

src/gateway/server-channels.ts

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,13 @@ const MAX_RESTART_ATTEMPTS = 10;
3939
const CHANNEL_STOP_ABORT_TIMEOUT_MS = 5_000;
4040
const CHANNEL_STARTUP_CONCURRENCY = 4;
4141

42+
function waitForChannelStartupHandoff(): Promise<void> {
43+
return new Promise((resolve) => {
44+
const handle = setImmediate(resolve);
45+
handle.unref?.();
46+
});
47+
}
48+
4249
type ChannelRuntimeStore = {
4350
aborts: Map<string, AbortController>;
4451
starting: Map<string, Promise<void>>;
@@ -512,9 +519,16 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage
512519
lastError: null,
513520
reconnectAttempts: preserveRestartAttempts ? (restartAttempts.get(rKey) ?? 0) : 0,
514521
});
515-
const task = Promise.resolve().then(() =>
516-
measureStartup(`channels.${channelId}.start-account`, () =>
517-
startAccount({
522+
const task = Promise.resolve().then(async () => {
523+
if (startupTrace) {
524+
await waitForChannelStartupHandoff();
525+
}
526+
if (abort.signal.aborted || manuallyStopped.has(rKey)) {
527+
return;
528+
}
529+
let startAccountTask: ReturnType<typeof startAccount> | undefined;
530+
await measureStartup(`channels.${channelId}.start-account-handoff`, () => {
531+
startAccountTask = startAccount({
518532
cfg,
519533
accountId: id,
520534
account,
@@ -524,9 +538,10 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage
524538
getStatus: () => getRuntime(channelId, id),
525539
setStatus: (next) => setRuntime(channelId, id, next),
526540
...(channelRuntimeForTask ? { channelRuntime: channelRuntimeForTask } : {}),
527-
}),
528-
),
529-
);
541+
});
542+
});
543+
await startAccountTask;
544+
});
530545
const trackedPromise = task
531546
.then(() => {
532547
if (abort.signal.aborted || manuallyStopped.has(rKey)) {

src/infra/net/fetch-guard.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,7 @@ export async function fetchWithSsrFGuard(params: GuardedFetchOptions): Promise<G
347347
if (!defaultFetch) {
348348
throw new Error("fetch is not available");
349349
}
350+
const isUsingMockedFetch = isMockedFetch(defaultFetch);
350351

351352
const maxRedirects =
352353
typeof params.maxRedirects === "number" && Number.isFinite(params.maxRedirects)
@@ -413,6 +414,13 @@ export async function fetchWithSsrFGuard(params: GuardedFetchOptions): Promise<G
413414
shouldUseEnvHttpProxyForUrl(parsedUrl.toString());
414415
const canUseManagedProxy =
415416
mode === GUARDED_FETCH_MODE.STRICT && isManagedProxyActive() && hasProxyEnvConfigured();
417+
const canUseMockedFetchWithoutDns =
418+
isUsingMockedFetch &&
419+
params.lookupFn === undefined &&
420+
!canUseTrustedEnvProxy &&
421+
!canUseManagedProxy &&
422+
!usesTrustedExplicitProxyMode &&
423+
params.pinDns !== false;
416424
const timeoutMs = resolveDispatcherTimeoutMs(params.timeoutMs);
417425

418426
// Trusted env-proxy and pinDns=false can skip local DNS pinning, so keep
@@ -434,6 +442,10 @@ export async function fetchWithSsrFGuard(params: GuardedFetchOptions): Promise<G
434442
// policy, but the proxy does the DNS resolution for the final target.
435443
assertHostnameAllowedWithPolicy(parsedUrl.hostname, policyForUrl);
436444
dispatcher = createPolicyDispatcherWithoutPinnedDns(params.dispatcherPolicy, timeoutMs);
445+
} else if (canUseMockedFetchWithoutDns) {
446+
// Test-installed fetch mocks should stay hermetic. Host/IP policy still runs;
447+
// real fetches continue through pinned DNS below.
448+
assertHostnameAllowedWithPolicy(parsedUrl.hostname, policyForUrl);
437449
} else if (params.pinDns === false) {
438450
await resolvePinnedHostnameWithPolicy(parsedUrl.hostname, {
439451
lookupFn: params.lookupFn,
@@ -466,7 +478,7 @@ export async function fetchWithSsrFGuard(params: GuardedFetchOptions): Promise<G
466478
fetchImpl: params.fetchImpl,
467479
globalFetch: globalThis.fetch,
468480
})) ||
469-
isMockedFetch(defaultFetch);
481+
isUsingMockedFetch;
470482
// Explicit caller stubs and test-installed fetch mocks should win.
471483
// Otherwise, fall back to undici's fetch whenever we attach a dispatcher,
472484
// because the default global fetch path will not honor per-request

0 commit comments

Comments
 (0)