Fix OpenClaw auth propagation and debug visibility
This commit is contained in:
@@ -8,6 +8,12 @@ function nonEmpty(value: unknown): string | null {
|
|||||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toAuthorizationHeaderValue(rawToken: string): string {
|
||||||
|
const trimmed = rawToken.trim();
|
||||||
|
if (!trimmed) return trimmed;
|
||||||
|
return /^bearer\s+/i.test(trimmed) ? trimmed : `Bearer ${trimmed}`;
|
||||||
|
}
|
||||||
|
|
||||||
function resolvePaperclipApiUrlOverride(value: unknown): string | null {
|
function resolvePaperclipApiUrlOverride(value: unknown): string | null {
|
||||||
const raw = nonEmpty(value);
|
const raw = nonEmpty(value);
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
@@ -499,6 +505,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
headers[key] = value;
|
headers[key] = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const openClawAuthHeader = nonEmpty(headers["x-openclaw-auth"] ?? headers["X-OpenClaw-Auth"]);
|
||||||
|
if (openClawAuthHeader && !headers.authorization && !headers.Authorization) {
|
||||||
|
headers.authorization = toAuthorizationHeaderValue(openClawAuthHeader);
|
||||||
|
}
|
||||||
if (webhookAuthHeader && !headers.authorization && !headers.Authorization) {
|
if (webhookAuthHeader && !headers.authorization && !headers.Authorization) {
|
||||||
headers.authorization = webhookAuthHeader;
|
headers.authorization = webhookAuthHeader;
|
||||||
}
|
}
|
||||||
@@ -599,6 +609,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const outboundHeaderKeys = Array.from(new Set([...Object.keys(headers), "accept"])).sort();
|
||||||
|
await onLog("stdout", `[openclaw] outbound header keys: ${outboundHeaderKeys.join(", ")}\n`);
|
||||||
await onLog("stdout", `[openclaw] invoking ${method} ${url} (transport=sse)\n`);
|
await onLog("stdout", `[openclaw] invoking ${method} ${url} (transport=sse)\n`);
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
|||||||
@@ -14,13 +14,14 @@ describe("buildJoinDefaultsPayloadForAccept", () => {
|
|||||||
expect(result).toMatchObject({
|
expect(result).toMatchObject({
|
||||||
url: "http://localhost:18789/v1/responses",
|
url: "http://localhost:18789/v1/responses",
|
||||||
paperclipApiUrl: "http://host.docker.internal:3100",
|
paperclipApiUrl: "http://host.docker.internal:3100",
|
||||||
|
webhookAuthHeader: "Bearer gateway-token",
|
||||||
headers: {
|
headers: {
|
||||||
"x-openclaw-auth": "gateway-token",
|
"x-openclaw-auth": "gateway-token",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not overwrite explicit OpenClaw defaults when already provided", () => {
|
it("does not overwrite explicit OpenClaw endpoint defaults when already provided", () => {
|
||||||
const result = buildJoinDefaultsPayloadForAccept({
|
const result = buildJoinDefaultsPayloadForAccept({
|
||||||
adapterType: "openclaw",
|
adapterType: "openclaw",
|
||||||
defaultsPayload: {
|
defaultsPayload: {
|
||||||
@@ -41,6 +42,28 @@ describe("buildJoinDefaultsPayloadForAccept", () => {
|
|||||||
url: "https://example.com/v1/responses",
|
url: "https://example.com/v1/responses",
|
||||||
method: "POST",
|
method: "POST",
|
||||||
paperclipApiUrl: "https://paperclip.example.com",
|
paperclipApiUrl: "https://paperclip.example.com",
|
||||||
|
webhookAuthHeader: "Bearer existing-token",
|
||||||
|
headers: {
|
||||||
|
"x-openclaw-auth": "existing-token",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves explicit webhookAuthHeader when configured", () => {
|
||||||
|
const result = buildJoinDefaultsPayloadForAccept({
|
||||||
|
adapterType: "openclaw",
|
||||||
|
defaultsPayload: {
|
||||||
|
url: "https://example.com/v1/responses",
|
||||||
|
webhookAuthHeader: "Bearer explicit-token",
|
||||||
|
headers: {
|
||||||
|
"x-openclaw-auth": "existing-token",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
inboundOpenClawAuthHeader: "legacy-token",
|
||||||
|
}) as Record<string, unknown>;
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
webhookAuthHeader: "Bearer explicit-token",
|
||||||
headers: {
|
headers: {
|
||||||
"x-openclaw-auth": "existing-token",
|
"x-openclaw-auth": "existing-token",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -223,6 +223,64 @@ describe("openclaw adapter execute", () => {
|
|||||||
expect(String(body.text ?? "")).toContain("PAPERCLIP_API_URL=http://dotta-macbook-pro:3100/");
|
expect(String(body.text ?? "")).toContain("PAPERCLIP_API_URL=http://dotta-macbook-pro:3100/");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("logs outbound header keys for auth debugging", async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue(
|
||||||
|
sseResponse([
|
||||||
|
"event: response.completed\n",
|
||||||
|
'data: {"type":"response.completed","status":"completed"}\n\n',
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
const logs: string[] = [];
|
||||||
|
const result = await execute(
|
||||||
|
buildContext(
|
||||||
|
{
|
||||||
|
url: "https://agent.example/sse",
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"x-openclaw-auth": "gateway-token",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onLog: async (_stream, chunk) => {
|
||||||
|
logs.push(chunk);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.exitCode).toBe(0);
|
||||||
|
expect(
|
||||||
|
logs.some((line) => line.includes("[openclaw] outbound header keys:") && line.includes("x-openclaw-auth")),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives Authorization header from x-openclaw-auth when webhookAuthHeader is unset", async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue(
|
||||||
|
sseResponse([
|
||||||
|
"event: response.completed\n",
|
||||||
|
'data: {"type":"response.completed","status":"completed"}\n\n',
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
const result = await execute(
|
||||||
|
buildContext({
|
||||||
|
url: "https://agent.example/sse",
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"x-openclaw-auth": "gateway-token",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.exitCode).toBe(0);
|
||||||
|
const headers = (fetchMock.mock.calls[0]?.[1]?.headers ?? {}) as Record<string, string>;
|
||||||
|
expect(headers["x-openclaw-auth"]).toBe("gateway-token");
|
||||||
|
expect(headers.authorization).toBe("Bearer gateway-token");
|
||||||
|
});
|
||||||
|
|
||||||
it("derives issue session keys when configured", async () => {
|
it("derives issue session keys when configured", async () => {
|
||||||
const fetchMock = vi.fn().mockResolvedValue(
|
const fetchMock = vi.fn().mockResolvedValue(
|
||||||
sseResponse([
|
sseResponse([
|
||||||
|
|||||||
@@ -152,6 +152,20 @@ function headerMapHasKeyIgnoreCase(headers: Record<string, string>, targetKey: s
|
|||||||
return Object.keys(headers).some((key) => key.trim().toLowerCase() === normalizedTarget);
|
return Object.keys(headers).some((key) => key.trim().toLowerCase() === normalizedTarget);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function headerMapGetIgnoreCase(headers: Record<string, string>, targetKey: string): string | null {
|
||||||
|
const normalizedTarget = targetKey.trim().toLowerCase();
|
||||||
|
const key = Object.keys(headers).find((candidate) => candidate.trim().toLowerCase() === normalizedTarget);
|
||||||
|
if (!key) return null;
|
||||||
|
const value = headers[key];
|
||||||
|
return typeof value === "string" ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toAuthorizationHeaderValue(rawToken: string): string {
|
||||||
|
const trimmed = rawToken.trim();
|
||||||
|
if (!trimmed) return trimmed;
|
||||||
|
return /^bearer\s+/i.test(trimmed) ? trimmed : `Bearer ${trimmed}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function buildJoinDefaultsPayloadForAccept(input: {
|
export function buildJoinDefaultsPayloadForAccept(input: {
|
||||||
adapterType: string | null;
|
adapterType: string | null;
|
||||||
defaultsPayload: unknown;
|
defaultsPayload: unknown;
|
||||||
@@ -211,6 +225,15 @@ export function buildJoinDefaultsPayloadForAccept(input: {
|
|||||||
delete merged.headers;
|
delete merged.headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasAuthorizationHeader = headerMapHasKeyIgnoreCase(mergedHeaders, "authorization");
|
||||||
|
const hasWebhookAuthHeader = Boolean(nonEmptyTrimmedString(merged.webhookAuthHeader));
|
||||||
|
if (!hasAuthorizationHeader && !hasWebhookAuthHeader) {
|
||||||
|
const openClawAuthToken = headerMapGetIgnoreCase(mergedHeaders, "x-openclaw-auth");
|
||||||
|
if (openClawAuthToken) {
|
||||||
|
merged.webhookAuthHeader = toAuthorizationHeaderValue(openClawAuthToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return Object.keys(merged).length > 0 ? merged : null;
|
return Object.keys(merged).length > 0 ? merged : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1395,6 +1418,54 @@ export function accessRoutes(
|
|||||||
return row;
|
return row;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (requestType === "agent" && (req.body.adapterType ?? null) === "openclaw") {
|
||||||
|
const expectedDefaults = summarizeOpenClawDefaultsForLog(joinDefaults.normalized);
|
||||||
|
const persistedDefaults = summarizeOpenClawDefaultsForLog(created.agentDefaultsPayload);
|
||||||
|
const missingPersistedFields: string[] = [];
|
||||||
|
|
||||||
|
if (expectedDefaults.url && !persistedDefaults.url) missingPersistedFields.push("url");
|
||||||
|
if (expectedDefaults.paperclipApiUrl && !persistedDefaults.paperclipApiUrl) {
|
||||||
|
missingPersistedFields.push("paperclipApiUrl");
|
||||||
|
}
|
||||||
|
if (expectedDefaults.webhookAuthHeader && !persistedDefaults.webhookAuthHeader) {
|
||||||
|
missingPersistedFields.push("webhookAuthHeader");
|
||||||
|
}
|
||||||
|
if (expectedDefaults.openClawAuthHeader && !persistedDefaults.openClawAuthHeader) {
|
||||||
|
missingPersistedFields.push("headers.x-openclaw-auth");
|
||||||
|
}
|
||||||
|
if (expectedDefaults.headerKeys.length > 0 && persistedDefaults.headerKeys.length === 0) {
|
||||||
|
missingPersistedFields.push("headers");
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
inviteId: invite.id,
|
||||||
|
joinRequestId: created.id,
|
||||||
|
joinRequestStatus: created.status,
|
||||||
|
expectedDefaults,
|
||||||
|
persistedDefaults,
|
||||||
|
diagnostics: joinDefaults.diagnostics.map((diag) => ({
|
||||||
|
code: diag.code,
|
||||||
|
level: diag.level,
|
||||||
|
message: diag.message,
|
||||||
|
hint: diag.hint ?? null,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
"invite accept persisted OpenClaw join request",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (missingPersistedFields.length > 0) {
|
||||||
|
logger.warn(
|
||||||
|
{
|
||||||
|
inviteId: invite.id,
|
||||||
|
joinRequestId: created.id,
|
||||||
|
missingPersistedFields,
|
||||||
|
},
|
||||||
|
"invite accept detected missing persisted OpenClaw defaults",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await logActivity(db, {
|
await logActivity(db, {
|
||||||
companyId,
|
companyId,
|
||||||
actorType: req.actor.type === "agent" ? "agent" : "user",
|
actorType: req.actor.type === "agent" ? "agent" : "user",
|
||||||
|
|||||||
@@ -16,6 +16,27 @@ export function OpenClawConfigFields({
|
|||||||
eff,
|
eff,
|
||||||
mark,
|
mark,
|
||||||
}: AdapterConfigFieldsProps) {
|
}: AdapterConfigFieldsProps) {
|
||||||
|
const configuredHeaders =
|
||||||
|
config.headers && typeof config.headers === "object" && !Array.isArray(config.headers)
|
||||||
|
? (config.headers as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
const effectiveHeaders =
|
||||||
|
(eff("adapterConfig", "headers", configuredHeaders) as Record<string, unknown>) ?? {};
|
||||||
|
const effectiveGatewayAuthHeader = typeof effectiveHeaders["x-openclaw-auth"] === "string"
|
||||||
|
? String(effectiveHeaders["x-openclaw-auth"])
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const commitGatewayAuthHeader = (rawValue: string) => {
|
||||||
|
const nextValue = rawValue.trim();
|
||||||
|
const nextHeaders: Record<string, unknown> = { ...effectiveHeaders };
|
||||||
|
if (nextValue) {
|
||||||
|
nextHeaders["x-openclaw-auth"] = nextValue;
|
||||||
|
} else {
|
||||||
|
delete nextHeaders["x-openclaw-auth"];
|
||||||
|
}
|
||||||
|
mark("adapterConfig", "headers", Object.keys(nextHeaders).length > 0 ? nextHeaders : undefined);
|
||||||
|
};
|
||||||
|
|
||||||
const transport = eff(
|
const transport = eff(
|
||||||
"adapterConfig",
|
"adapterConfig",
|
||||||
"streamTransport",
|
"streamTransport",
|
||||||
@@ -110,6 +131,16 @@ export function OpenClawConfigFields({
|
|||||||
placeholder="Bearer <token>"
|
placeholder="Bearer <token>"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Gateway auth token (x-openclaw-auth)">
|
||||||
|
<DraftInput
|
||||||
|
value={effectiveGatewayAuthHeader}
|
||||||
|
onCommit={commitGatewayAuthHeader}
|
||||||
|
immediate
|
||||||
|
className={inputClass}
|
||||||
|
placeholder="OpenClaw gateway token"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in New Issue
Block a user