Skip to content

Commit d1b2d81

Browse files
committed
fix: send OpenClaw attribution to OpenAI
1 parent 9881a80 commit d1b2d81

27 files changed

Lines changed: 597 additions & 68 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ Docs: https://docs.openclaw.ai
2626
- Channels/Discord: treat bare numeric outbound targets that match the effective Discord DM allowlist as user DMs while preserving account-specific legacy `dm.allowFrom` precedence over inherited root `allowFrom`. (#74303) Thanks @Squirbie.
2727
- Control UI: make the chat sidebar split divider focusable, keyboard-resizable, ARIA-described, and pointer-event based so sidebar resizing works without a mouse. Thanks @BunsDev.
2828
- Agents/usage: keep PI embedded-run telemetry attributed to the resolved model provider instead of the PI harness label, so OpenRouter and other provider-backed turns report the right provider in session usage and traces. Thanks @vincentkoc.
29+
- Agents/attribution: send OpenClaw attribution headers on native OpenAI and Codex traffic, including SDK transports, realtime voice and TTS, device-code auth, WHAM usage, and remote embeddings, so PI-origin defaults no longer leak into provider requests. Thanks @vincentkoc.
2930
- Agents/auth: keep OAuth auth profiles inherited from the main agent read-through instead of copying refresh tokens into secondary agents, and refresh Codex app-server tokens against the owning store so multi-agent swarms avoid reused refresh-token failures. Fixes #74055. Thanks @ClarityInvest.
3031
- Channels/Telegram: honor `ALL_PROXY` / `all_proxy` and service-level `OPENCLAW_PROXY_URL` when constructing the HTTP/1-only Telegram Bot API transport, so Windows and service installs that rely on those proxy settings no longer fall back to direct egress. Fixes #74014; refs #74086. Thanks @SymbolStar.
3132
- Channels/Telegram: continue polling when `deleteWebhook` hits a transient network failure but `getWebhookInfo` confirms no webhook is configured, so startup does not retry cleanup forever after the webhook was already removed. Refs #74086; carries forward #47384. Thanks @clovericbot.
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
c14ed336d7add0044299560f2fb2fa9272f23aae335799313f32c63521edc24e plugin-sdk-api-baseline.json
2-
e096b25bd16bf1b0562a783609e9f7d945b6e29560ef8ad3fb433145fe084a5d plugin-sdk-api-baseline.jsonl
1+
597577966dfee329740d7b0a331263afc26db518fe778f0fad95e2a01da88d83 plugin-sdk-api-baseline.json
2+
65fb1cad5e5ec1764e3ccfcfd3fbb2e5cfb938ad34b45e6416bba0c00a1d735a plugin-sdk-api-baseline.jsonl

extensions/openai/openai-codex-device-code.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ function createJsonResponse(body: unknown, init?: { status?: number }) {
2020
describe("loginOpenAICodexDeviceCode", () => {
2121
it("requests a device code, polls for authorization, and exchanges OAuth tokens", async () => {
2222
vi.useFakeTimers();
23+
vi.stubEnv("OPENCLAW_VERSION", "2026.3.22");
2324
try {
2425
const fetchMock = vi
2526
.fn()
@@ -78,6 +79,38 @@ describe("loginOpenAICodexDeviceCode", () => {
7879
"https://auth.openai.com/api/accounts/deviceauth/usercode",
7980
expect.objectContaining({
8081
method: "POST",
82+
headers: {
83+
"Content-Type": "application/json",
84+
originator: "openclaw",
85+
version: "2026.3.22",
86+
"User-Agent": "openclaw/2026.3.22",
87+
},
88+
}),
89+
);
90+
expect(fetchMock).toHaveBeenNthCalledWith(
91+
2,
92+
"https://auth.openai.com/api/accounts/deviceauth/token",
93+
expect.objectContaining({
94+
method: "POST",
95+
headers: {
96+
"Content-Type": "application/json",
97+
originator: "openclaw",
98+
version: "2026.3.22",
99+
"User-Agent": "openclaw/2026.3.22",
100+
},
101+
}),
102+
);
103+
expect(fetchMock).toHaveBeenNthCalledWith(
104+
4,
105+
"https://auth.openai.com/oauth/token",
106+
expect.objectContaining({
107+
method: "POST",
108+
headers: {
109+
"Content-Type": "application/x-www-form-urlencoded",
110+
originator: "openclaw",
111+
version: "2026.3.22",
112+
"User-Agent": "openclaw/2026.3.22",
113+
},
81114
}),
82115
);
83116
expect(onVerification).toHaveBeenCalledWith({
@@ -96,6 +129,7 @@ describe("loginOpenAICodexDeviceCode", () => {
96129
expect(credentials.expires).toBeGreaterThan(Date.now());
97130
} finally {
98131
vi.useRealTimers();
132+
vi.unstubAllEnvs();
99133
}
100134
});
101135

extensions/openai/openai-codex-device-code.ts

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,16 @@ const OPENAI_CODEX_DEVICE_CODE_DEFAULT_INTERVAL_MS = 5_000;
88
const OPENAI_CODEX_DEVICE_CODE_MIN_INTERVAL_MS = 1_000;
99
const OPENAI_CODEX_DEVICE_CALLBACK_URL = `${OPENAI_AUTH_BASE_URL}/deviceauth/callback`;
1010

11+
function resolveOpenAICodexDeviceCodeHeaders(contentType: string): Record<string, string> {
12+
const version = process.env.OPENCLAW_VERSION?.trim();
13+
return {
14+
"Content-Type": contentType,
15+
originator: "openclaw",
16+
...(version ? { version } : {}),
17+
"User-Agent": version ? `openclaw/${version}` : "openclaw",
18+
};
19+
}
20+
1121
type OpenAICodexDeviceCodePrompt = {
1222
verificationUrl: string;
1323
userCode: string;
@@ -129,9 +139,7 @@ function formatDeviceCodeError(params: {
129139
async function requestOpenAICodexDeviceCode(fetchFn: typeof fetch): Promise<RequestedDeviceCode> {
130140
const response = await fetchFn(`${OPENAI_AUTH_BASE_URL}/api/accounts/deviceauth/usercode`, {
131141
method: "POST",
132-
headers: {
133-
"Content-Type": "application/json",
134-
},
142+
headers: resolveOpenAICodexDeviceCodeHeaders("application/json"),
135143
body: JSON.stringify({
136144
client_id: OPENAI_CODEX_CLIENT_ID,
137145
}),
@@ -180,9 +188,7 @@ async function pollOpenAICodexDeviceCode(params: {
180188
while (Date.now() < deadline) {
181189
const response = await params.fetchFn(`${OPENAI_AUTH_BASE_URL}/api/accounts/deviceauth/token`, {
182190
method: "POST",
183-
headers: {
184-
"Content-Type": "application/json",
185-
},
191+
headers: resolveOpenAICodexDeviceCodeHeaders("application/json"),
186192
body: JSON.stringify({
187193
device_auth_id: params.deviceAuthId,
188194
user_code: params.userCode,
@@ -229,9 +235,7 @@ async function exchangeOpenAICodexDeviceCode(params: {
229235
}): Promise<OpenAICodexDeviceCodeCredentials> {
230236
const response = await params.fetchFn(`${OPENAI_AUTH_BASE_URL}/oauth/token`, {
231237
method: "POST",
232-
headers: {
233-
"Content-Type": "application/x-www-form-urlencoded",
234-
},
238+
headers: resolveOpenAICodexDeviceCodeHeaders("application/x-www-form-urlencoded"),
235239
body: new URLSearchParams({
236240
grant_type: "authorization_code",
237241
code: params.authorizationCode,

extensions/openai/realtime-transcription-provider.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { resolveProviderRequestHeaders } from "openclaw/plugin-sdk/provider-http";
12
import {
23
createRealtimeTranscriptionWebSocketSession,
34
type RealtimeTranscriptionProviderConfig,
@@ -107,7 +108,16 @@ function createOpenAIRealtimeTranscriptionSession(
107108
providerId: "openai",
108109
callbacks: config,
109110
url: OPENAI_REALTIME_TRANSCRIPTION_URL,
110-
headers: {
111+
headers: resolveProviderRequestHeaders({
112+
provider: "openai",
113+
baseUrl: OPENAI_REALTIME_TRANSCRIPTION_URL,
114+
capability: "audio",
115+
transport: "websocket",
116+
defaultHeaders: {
117+
Authorization: `Bearer ${config.apiKey}`,
118+
"OpenAI-Beta": "realtime=v1",
119+
},
120+
}) ?? {
111121
Authorization: `Bearer ${config.apiKey}`,
112122
"OpenAI-Beta": "realtime=v1",
113123
},

extensions/openai/realtime-voice-provider.test.ts

Lines changed: 94 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { REALTIME_VOICE_AUDIO_FORMAT_PCM16_24KHZ } from "openclaw/plugin-sdk/realtime-voice";
2-
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
33
import { buildOpenAIRealtimeVoiceProvider } from "./realtime-voice-provider.js";
44

5-
const { FakeWebSocket } = vi.hoisted(() => {
5+
const { FakeWebSocket, fetchWithSsrFGuardMock } = vi.hoisted(() => {
66
type Listener = (...args: unknown[]) => void;
77

88
class MockWebSocket {
@@ -15,8 +15,10 @@ const { FakeWebSocket } = vi.hoisted(() => {
1515
sent: string[] = [];
1616
closed = false;
1717
terminated = false;
18+
args: unknown[];
1819

19-
constructor() {
20+
constructor(...args: unknown[]) {
21+
this.args = args;
2022
MockWebSocket.instances.push(this);
2123
}
2224

@@ -49,13 +51,17 @@ const { FakeWebSocket } = vi.hoisted(() => {
4951
}
5052
}
5153

52-
return { FakeWebSocket: MockWebSocket };
54+
return { FakeWebSocket: MockWebSocket, fetchWithSsrFGuardMock: vi.fn() };
5355
});
5456

5557
vi.mock("ws", () => ({
5658
default: FakeWebSocket,
5759
}));
5860

61+
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
62+
fetchWithSsrFGuard: fetchWithSsrFGuardMock,
63+
}));
64+
5965
type FakeWebSocketInstance = InstanceType<typeof FakeWebSocket>;
6066
type SentRealtimeEvent = {
6167
type: string;
@@ -70,9 +76,93 @@ function parseSent(socket: FakeWebSocketInstance): SentRealtimeEvent[] {
7076
return socket.sent.map((payload: string) => JSON.parse(payload) as SentRealtimeEvent);
7177
}
7278

79+
function createJsonResponse(body: unknown, init?: { status?: number }): Response {
80+
return new Response(JSON.stringify(body), {
81+
status: init?.status ?? 200,
82+
headers: {
83+
"Content-Type": "application/json",
84+
},
85+
});
86+
}
87+
7388
describe("buildOpenAIRealtimeVoiceProvider", () => {
7489
beforeEach(() => {
7590
FakeWebSocket.instances = [];
91+
fetchWithSsrFGuardMock.mockReset();
92+
});
93+
94+
afterEach(() => {
95+
vi.unstubAllEnvs();
96+
});
97+
98+
it("adds OpenClaw attribution headers to native realtime websocket requests", () => {
99+
vi.stubEnv("OPENCLAW_VERSION", "2026.3.22");
100+
const provider = buildOpenAIRealtimeVoiceProvider();
101+
const bridge = provider.createBridge({
102+
providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret
103+
onAudio: vi.fn(),
104+
onClearAudio: vi.fn(),
105+
});
106+
107+
void bridge.connect();
108+
bridge.close();
109+
110+
const socket = FakeWebSocket.instances[0];
111+
const options = socket?.args[1] as { headers?: Record<string, string> } | undefined;
112+
expect(options?.headers).toMatchObject({
113+
originator: "openclaw",
114+
version: "2026.3.22",
115+
"User-Agent": "openclaw/2026.3.22",
116+
});
117+
});
118+
119+
it("returns browser-safe OpenClaw attribution headers for native WebRTC offers", async () => {
120+
vi.stubEnv("OPENCLAW_VERSION", "2026.3.22");
121+
fetchWithSsrFGuardMock.mockResolvedValueOnce({
122+
response: createJsonResponse({
123+
client_secret: { value: "client-secret-123" },
124+
expires_at: 1_765_000_000,
125+
}),
126+
release: vi.fn(async () => undefined),
127+
});
128+
const provider = buildOpenAIRealtimeVoiceProvider();
129+
if (!provider.createBrowserSession) {
130+
throw new Error("expected OpenAI realtime provider to support browser sessions");
131+
}
132+
133+
const session = await provider.createBrowserSession({
134+
providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret
135+
instructions: "Be concise.",
136+
});
137+
138+
expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith(
139+
expect.objectContaining({
140+
url: "https://api.openai.com/v1/realtime/client_secrets",
141+
init: expect.objectContaining({
142+
method: "POST",
143+
headers: expect.objectContaining({
144+
Authorization: "Bearer sk-test", // pragma: allowlist secret
145+
"Content-Type": "application/json",
146+
originator: "openclaw",
147+
version: "2026.3.22",
148+
"User-Agent": "openclaw/2026.3.22",
149+
}),
150+
}),
151+
}),
152+
);
153+
expect(session).toMatchObject({
154+
provider: "openai",
155+
transport: "webrtc-sdp",
156+
clientSecret: "client-secret-123",
157+
offerUrl: "https://api.openai.com/v1/realtime/calls",
158+
offerHeaders: {
159+
originator: "openclaw",
160+
version: "2026.3.22",
161+
},
162+
});
163+
expect((session as { offerHeaders?: Record<string, string> }).offerHeaders).not.toHaveProperty(
164+
"User-Agent",
165+
);
76166
});
77167

78168
it("normalizes provider-owned voice settings from raw provider config", () => {

0 commit comments

Comments
 (0)