Chores: fix chutes oauth build
This commit is contained in:
committed by
Peter Steinberger
parent
0aba911912
commit
0efcfc0864
@@ -3,16 +3,15 @@ import os from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
type AuthProfileStore,
|
||||||
|
ensureAuthProfileStore,
|
||||||
|
resolveApiKeyForProfile,
|
||||||
|
} from "./auth-profiles.js";
|
||||||
import {
|
import {
|
||||||
CHUTES_TOKEN_ENDPOINT,
|
CHUTES_TOKEN_ENDPOINT,
|
||||||
type ChutesStoredOAuth,
|
type ChutesStoredOAuth,
|
||||||
} from "./chutes-oauth.js";
|
} from "./chutes-oauth.js";
|
||||||
import {
|
|
||||||
ensureAuthProfileStore,
|
|
||||||
resolveApiKeyForProfile,
|
|
||||||
type AuthProfileStore,
|
|
||||||
} from "./auth-profiles.js";
|
|
||||||
|
|
||||||
describe("auth-profiles (chutes)", () => {
|
describe("auth-profiles (chutes)", () => {
|
||||||
const previousStateDir = process.env.CLAWDBOT_STATE_DIR;
|
const previousStateDir = process.env.CLAWDBOT_STATE_DIR;
|
||||||
@@ -31,16 +30,23 @@ describe("auth-profiles (chutes)", () => {
|
|||||||
else process.env.CLAWDBOT_STATE_DIR = previousStateDir;
|
else process.env.CLAWDBOT_STATE_DIR = previousStateDir;
|
||||||
if (previousAgentDir === undefined) delete process.env.CLAWDBOT_AGENT_DIR;
|
if (previousAgentDir === undefined) delete process.env.CLAWDBOT_AGENT_DIR;
|
||||||
else process.env.CLAWDBOT_AGENT_DIR = previousAgentDir;
|
else process.env.CLAWDBOT_AGENT_DIR = previousAgentDir;
|
||||||
if (previousPiAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR;
|
if (previousPiAgentDir === undefined)
|
||||||
|
delete process.env.PI_CODING_AGENT_DIR;
|
||||||
else process.env.PI_CODING_AGENT_DIR = previousPiAgentDir;
|
else process.env.PI_CODING_AGENT_DIR = previousPiAgentDir;
|
||||||
if (previousChutesClientId === undefined) delete process.env.CHUTES_CLIENT_ID;
|
if (previousChutesClientId === undefined)
|
||||||
|
delete process.env.CHUTES_CLIENT_ID;
|
||||||
else process.env.CHUTES_CLIENT_ID = previousChutesClientId;
|
else process.env.CHUTES_CLIENT_ID = previousChutesClientId;
|
||||||
});
|
});
|
||||||
|
|
||||||
it("refreshes expired Chutes OAuth credentials", async () => {
|
it("refreshes expired Chutes OAuth credentials", async () => {
|
||||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-chutes-"));
|
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-chutes-"));
|
||||||
process.env.CLAWDBOT_STATE_DIR = tempDir;
|
process.env.CLAWDBOT_STATE_DIR = tempDir;
|
||||||
process.env.CLAWDBOT_AGENT_DIR = path.join(tempDir, "agents", "main", "agent");
|
process.env.CLAWDBOT_AGENT_DIR = path.join(
|
||||||
|
tempDir,
|
||||||
|
"agents",
|
||||||
|
"main",
|
||||||
|
"agent",
|
||||||
|
);
|
||||||
process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR;
|
process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR;
|
||||||
|
|
||||||
const authProfilePath = path.join(
|
const authProfilePath = path.join(
|
||||||
@@ -69,7 +75,8 @@ describe("auth-profiles (chutes)", () => {
|
|||||||
|
|
||||||
const fetchSpy = vi.fn(async (input: RequestInfo | URL) => {
|
const fetchSpy = vi.fn(async (input: RequestInfo | URL) => {
|
||||||
const url = String(input);
|
const url = String(input);
|
||||||
if (url !== CHUTES_TOKEN_ENDPOINT) return new Response("not found", { status: 404 });
|
if (url !== CHUTES_TOKEN_ENDPOINT)
|
||||||
|
return new Response("not found", { status: 404 });
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
access_token: "at_new",
|
access_token: "at_new",
|
||||||
@@ -89,7 +96,9 @@ describe("auth-profiles (chutes)", () => {
|
|||||||
expect(resolved?.apiKey).toBe("at_new");
|
expect(resolved?.apiKey).toBe("at_new");
|
||||||
expect(fetchSpy).toHaveBeenCalled();
|
expect(fetchSpy).toHaveBeenCalled();
|
||||||
|
|
||||||
const persisted = JSON.parse(await fs.readFile(authProfilePath, "utf8")) as {
|
const persisted = JSON.parse(
|
||||||
|
await fs.readFile(authProfilePath, "utf8"),
|
||||||
|
) as {
|
||||||
profiles?: Record<string, { access?: string }>;
|
profiles?: Record<string, { access?: string }>;
|
||||||
};
|
};
|
||||||
expect(persisted.profiles?.["chutes:default"]?.access).toBe("at_new");
|
expect(persisted.profiles?.["chutes:default"]?.access).toBe("at_new");
|
||||||
|
|||||||
@@ -15,12 +15,12 @@ import { loadJsonFile, saveJsonFile } from "../infra/json-file.js";
|
|||||||
import { createSubsystemLogger } from "../logging.js";
|
import { createSubsystemLogger } from "../logging.js";
|
||||||
import { resolveUserPath } from "../utils.js";
|
import { resolveUserPath } from "../utils.js";
|
||||||
import { resolveClawdbotAgentDir } from "./agent-paths.js";
|
import { resolveClawdbotAgentDir } from "./agent-paths.js";
|
||||||
|
import { type ChutesStoredOAuth, refreshChutesTokens } from "./chutes-oauth.js";
|
||||||
import {
|
import {
|
||||||
readClaudeCliCredentialsCached,
|
readClaudeCliCredentialsCached,
|
||||||
readCodexCliCredentialsCached,
|
readCodexCliCredentialsCached,
|
||||||
writeClaudeCliCredentials,
|
writeClaudeCliCredentials,
|
||||||
} from "./cli-credentials.js";
|
} from "./cli-credentials.js";
|
||||||
import { refreshChutesTokens, type ChutesStoredOAuth } from "./chutes-oauth.js";
|
|
||||||
import { normalizeProviderId } from "./model-selection.js";
|
import { normalizeProviderId } from "./model-selection.js";
|
||||||
|
|
||||||
const AUTH_STORE_VERSION = 1;
|
const AUTH_STORE_VERSION = 1;
|
||||||
|
|||||||
@@ -13,9 +13,12 @@ describe("chutes-oauth", () => {
|
|||||||
const url = String(input);
|
const url = String(input);
|
||||||
if (url === CHUTES_TOKEN_ENDPOINT) {
|
if (url === CHUTES_TOKEN_ENDPOINT) {
|
||||||
expect(init?.method).toBe("POST");
|
expect(init?.method).toBe("POST");
|
||||||
expect(String(init?.headers && (init.headers as Record<string, string>)["Content-Type"])).toContain(
|
expect(
|
||||||
"application/x-www-form-urlencoded",
|
String(
|
||||||
);
|
init?.headers &&
|
||||||
|
(init.headers as Record<string, string>)["Content-Type"],
|
||||||
|
),
|
||||||
|
).toContain("application/x-www-form-urlencoded");
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
access_token: "at_123",
|
access_token: "at_123",
|
||||||
@@ -28,13 +31,17 @@ describe("chutes-oauth", () => {
|
|||||||
if (url === CHUTES_USERINFO_ENDPOINT) {
|
if (url === CHUTES_USERINFO_ENDPOINT) {
|
||||||
expect(
|
expect(
|
||||||
String(
|
String(
|
||||||
init?.headers && (init.headers as Record<string, string>).Authorization,
|
init?.headers &&
|
||||||
|
(init.headers as Record<string, string>).Authorization,
|
||||||
),
|
),
|
||||||
).toBe("Bearer at_123");
|
).toBe("Bearer at_123");
|
||||||
return new Response(JSON.stringify({ username: "fred", sub: "sub_1" }), {
|
return new Response(
|
||||||
status: 200,
|
JSON.stringify({ username: "fred", sub: "sub_1" }),
|
||||||
headers: { "Content-Type": "application/json" },
|
{
|
||||||
});
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return new Response("not found", { status: 404 });
|
return new Response("not found", { status: 404 });
|
||||||
};
|
};
|
||||||
@@ -55,15 +62,20 @@ describe("chutes-oauth", () => {
|
|||||||
expect(creds.access).toBe("at_123");
|
expect(creds.access).toBe("at_123");
|
||||||
expect(creds.refresh).toBe("rt_123");
|
expect(creds.refresh).toBe("rt_123");
|
||||||
expect(creds.email).toBe("fred");
|
expect(creds.email).toBe("fred");
|
||||||
expect((creds as unknown as { accountId?: string }).accountId).toBe("sub_1");
|
expect((creds as unknown as { accountId?: string }).accountId).toBe(
|
||||||
expect((creds as unknown as { clientId?: string }).clientId).toBe("cid_test");
|
"sub_1",
|
||||||
|
);
|
||||||
|
expect((creds as unknown as { clientId?: string }).clientId).toBe(
|
||||||
|
"cid_test",
|
||||||
|
);
|
||||||
expect(creds.expires).toBe(now + 3600 * 1000 - 5 * 60 * 1000);
|
expect(creds.expires).toBe(now + 3600 * 1000 - 5 * 60 * 1000);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("refreshes tokens using stored client id and falls back to old refresh token", async () => {
|
it("refreshes tokens using stored client id and falls back to old refresh token", async () => {
|
||||||
const fetchFn: typeof fetch = async (input, init) => {
|
const fetchFn: typeof fetch = async (input, init) => {
|
||||||
const url = String(input);
|
const url = String(input);
|
||||||
if (url !== CHUTES_TOKEN_ENDPOINT) return new Response("not found", { status: 404 });
|
if (url !== CHUTES_TOKEN_ENDPOINT)
|
||||||
|
return new Response("not found", { status: 404 });
|
||||||
expect(init?.method).toBe("POST");
|
expect(init?.method).toBe("POST");
|
||||||
const body = init?.body as URLSearchParams;
|
const body = init?.body as URLSearchParams;
|
||||||
expect(String(body.get("grant_type"))).toBe("refresh_token");
|
expect(String(body.get("grant_type"))).toBe("refresh_token");
|
||||||
@@ -96,4 +108,3 @@ describe("chutes-oauth", () => {
|
|||||||
expect(refreshed.expires).toBe(now + 1800 * 1000 - 5 * 60 * 1000);
|
expect(refreshed.expires).toBe(now + 1800 * 1000 - 5 * 60 * 1000);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,9 @@ export function parseOAuthCallbackInput(
|
|||||||
|
|
||||||
function coerceExpiresAt(expiresInSeconds: number, now: number): number {
|
function coerceExpiresAt(expiresInSeconds: number, now: number): number {
|
||||||
const value =
|
const value =
|
||||||
now + Math.max(0, Math.floor(expiresInSeconds)) * 1000 - DEFAULT_EXPIRES_BUFFER_MS;
|
now +
|
||||||
|
Math.max(0, Math.floor(expiresInSeconds)) * 1000 -
|
||||||
|
DEFAULT_EXPIRES_BUFFER_MS;
|
||||||
return Math.max(value, now + 30_000);
|
return Math.max(value, now + 30_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,7 +123,8 @@ export async function exchangeChutesCodeForTokens(params: {
|
|||||||
const refresh = data.refresh_token?.trim();
|
const refresh = data.refresh_token?.trim();
|
||||||
const expiresIn = data.expires_in ?? 0;
|
const expiresIn = data.expires_in ?? 0;
|
||||||
|
|
||||||
if (!access) throw new Error("Chutes token exchange returned no access_token");
|
if (!access)
|
||||||
|
throw new Error("Chutes token exchange returned no access_token");
|
||||||
if (!refresh) {
|
if (!refresh) {
|
||||||
throw new Error("Chutes token exchange returned no refresh_token");
|
throw new Error("Chutes token exchange returned no refresh_token");
|
||||||
}
|
}
|
||||||
@@ -201,4 +204,3 @@ export async function refreshChutesTokens(params: {
|
|||||||
clientSecret,
|
clientSecret,
|
||||||
} as unknown as ChutesStoredOAuth;
|
} as unknown as ChutesStoredOAuth;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
buildTokenProfileId,
|
buildTokenProfileId,
|
||||||
validateAnthropicSetupToken,
|
validateAnthropicSetupToken,
|
||||||
} from "./auth-token.js";
|
} from "./auth-token.js";
|
||||||
|
import { loginChutes } from "./chutes-oauth.js";
|
||||||
import {
|
import {
|
||||||
applyGoogleGeminiModelDefault,
|
applyGoogleGeminiModelDefault,
|
||||||
GOOGLE_GEMINI_DEFAULT_MODEL,
|
GOOGLE_GEMINI_DEFAULT_MODEL,
|
||||||
@@ -68,7 +69,6 @@ import {
|
|||||||
} from "./onboard-auth.js";
|
} from "./onboard-auth.js";
|
||||||
import { openUrl } from "./onboard-helpers.js";
|
import { openUrl } from "./onboard-helpers.js";
|
||||||
import type { AuthChoice } from "./onboard-types.js";
|
import type { AuthChoice } from "./onboard-types.js";
|
||||||
import { loginChutes } from "./chutes-oauth.js";
|
|
||||||
import {
|
import {
|
||||||
applyOpenAICodexModelDefault,
|
applyOpenAICodexModelDefault,
|
||||||
OPENAI_CODEX_DEFAULT_MODEL,
|
OPENAI_CODEX_DEFAULT_MODEL,
|
||||||
|
|||||||
@@ -49,78 +49,74 @@ async function waitForLocalCallback(params: {
|
|||||||
const port = redirectUrl.port ? Number.parseInt(redirectUrl.port, 10) : 80;
|
const port = redirectUrl.port ? Number.parseInt(redirectUrl.port, 10) : 80;
|
||||||
const expectedPath = redirectUrl.pathname || "/";
|
const expectedPath = redirectUrl.pathname || "/";
|
||||||
|
|
||||||
let server: ReturnType<typeof createServer> | null = null;
|
return await new Promise<{ code: string; state: string }>(
|
||||||
let timeout: NodeJS.Timeout | null = null;
|
(resolve, reject) => {
|
||||||
|
let timeout: NodeJS.Timeout | null = null;
|
||||||
try {
|
const server = createServer((req, res) => {
|
||||||
const resultPromise = new Promise<{ code: string; state: string }>(
|
try {
|
||||||
(resolve, reject) => {
|
const requestUrl = new URL(req.url ?? "/", redirectUrl.origin);
|
||||||
server = createServer((req, res) => {
|
if (requestUrl.pathname !== expectedPath) {
|
||||||
try {
|
res.statusCode = 404;
|
||||||
const requestUrl = new URL(req.url ?? "/", redirectUrl.origin);
|
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||||
if (requestUrl.pathname !== expectedPath) {
|
res.end("Not found");
|
||||||
res.statusCode = 404;
|
return;
|
||||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
||||||
res.end("Not found");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const code = requestUrl.searchParams.get("code")?.trim();
|
|
||||||
const state = requestUrl.searchParams.get("state")?.trim();
|
|
||||||
|
|
||||||
if (!code) {
|
|
||||||
res.statusCode = 400;
|
|
||||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
||||||
res.end("Missing code");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!state || state !== params.expectedState) {
|
|
||||||
res.statusCode = 400;
|
|
||||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
||||||
res.end("Invalid state");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.statusCode = 200;
|
|
||||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
||||||
res.end(
|
|
||||||
[
|
|
||||||
"<!doctype html>",
|
|
||||||
"<html><head><meta charset='utf-8' /></head>",
|
|
||||||
"<body><h2>Chutes OAuth complete</h2>",
|
|
||||||
"<p>You can close this window and return to clawdbot.</p></body></html>",
|
|
||||||
].join(""),
|
|
||||||
);
|
|
||||||
resolve({ code, state });
|
|
||||||
} catch (err) {
|
|
||||||
reject(err);
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
server.once("error", reject);
|
const code = requestUrl.searchParams.get("code")?.trim();
|
||||||
server.listen(port, hostname, () => {
|
const state = requestUrl.searchParams.get("state")?.trim();
|
||||||
params.onProgress?.(
|
|
||||||
`Waiting for OAuth callback on ${redirectUrl.origin}${expectedPath}…`,
|
if (!code) {
|
||||||
|
res.statusCode = 400;
|
||||||
|
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||||
|
res.end("Missing code");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!state || state !== params.expectedState) {
|
||||||
|
res.statusCode = 400;
|
||||||
|
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||||
|
res.end("Invalid state");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.statusCode = 200;
|
||||||
|
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||||
|
res.end(
|
||||||
|
[
|
||||||
|
"<!doctype html>",
|
||||||
|
"<html><head><meta charset='utf-8' /></head>",
|
||||||
|
"<body><h2>Chutes OAuth complete</h2>",
|
||||||
|
"<p>You can close this window and return to clawdbot.</p></body></html>",
|
||||||
|
].join(""),
|
||||||
);
|
);
|
||||||
});
|
if (timeout) clearTimeout(timeout);
|
||||||
},
|
server.close();
|
||||||
);
|
resolve({ code, state });
|
||||||
|
} catch (err) {
|
||||||
|
if (timeout) clearTimeout(timeout);
|
||||||
|
server.close();
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
timeout = setTimeout(() => {
|
server.once("error", (err) => {
|
||||||
try {
|
if (timeout) clearTimeout(timeout);
|
||||||
server?.close();
|
|
||||||
} catch {}
|
|
||||||
}, params.timeoutMs);
|
|
||||||
|
|
||||||
return await resultPromise;
|
|
||||||
} finally {
|
|
||||||
if (timeout) clearTimeout(timeout);
|
|
||||||
if (server) {
|
|
||||||
try {
|
|
||||||
server.close();
|
server.close();
|
||||||
} catch {}
|
reject(err);
|
||||||
}
|
});
|
||||||
}
|
server.listen(port, hostname, () => {
|
||||||
|
params.onProgress?.(
|
||||||
|
`Waiting for OAuth callback on ${redirectUrl.origin}${expectedPath}…`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
try {
|
||||||
|
server.close();
|
||||||
|
} catch {}
|
||||||
|
reject(new Error("OAuth callback timeout"));
|
||||||
|
}, params.timeoutMs);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loginChutes(params: {
|
export async function loginChutes(params: {
|
||||||
|
|||||||
Reference in New Issue
Block a user