Merge branch 'main' into fix/token-usage-input-output-breakdown
This commit is contained in:
commit
806598ea68
@ -77,12 +77,9 @@ vi.mock("./client.js", () => ({
|
||||
createFeishuClient: mockCreateFeishuClient,
|
||||
}));
|
||||
|
||||
vi.mock("../../../src/acp/persistent-bindings.route.js", () => ({
|
||||
vi.mock("openclaw/plugin-sdk/conversation-runtime", () => ({
|
||||
resolveConfiguredAcpRoute: (params: unknown) => mockResolveConfiguredAcpRoute(params),
|
||||
ensureConfiguredAcpRouteReady: (params: unknown) => mockEnsureConfiguredAcpRouteReady(params),
|
||||
}));
|
||||
|
||||
vi.mock("../../../src/infra/outbound/session-binding-service.js", () => ({
|
||||
getSessionBindingService: () => ({
|
||||
resolveByConversation: mockResolveBoundConversation,
|
||||
touch: mockTouchBinding,
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { FeishuConfig, ResolvedFeishuAccount } from "./types.js";
|
||||
|
||||
const clientCtorMock = vi.hoisted(() => vi.fn());
|
||||
const wsClientCtorMock = vi.hoisted(() =>
|
||||
vi.fn(function wsClientCtor() {
|
||||
return { connected: true };
|
||||
@ -22,22 +23,6 @@ const mockBaseHttpInstance = vi.hoisted(() => ({
|
||||
head: vi.fn().mockResolvedValue({}),
|
||||
options: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
vi.mock("@larksuiteoapi/node-sdk", () => ({
|
||||
AppType: { SelfBuild: "self" },
|
||||
Domain: { Feishu: "https://open.feishu.cn", Lark: "https://open.larksuite.com" },
|
||||
LoggerLevel: { info: "info" },
|
||||
Client: vi.fn(),
|
||||
WSClient: wsClientCtorMock,
|
||||
EventDispatcher: vi.fn(),
|
||||
defaultHttpInstance: mockBaseHttpInstance,
|
||||
}));
|
||||
|
||||
vi.mock("https-proxy-agent", () => ({
|
||||
HttpsProxyAgent: httpsProxyAgentCtorMock,
|
||||
}));
|
||||
|
||||
import { Client as LarkClient } from "@larksuiteoapi/node-sdk";
|
||||
import {
|
||||
createFeishuClient,
|
||||
createFeishuWSClient,
|
||||
@ -45,6 +30,7 @@ import {
|
||||
FEISHU_HTTP_TIMEOUT_MS,
|
||||
FEISHU_HTTP_TIMEOUT_MAX_MS,
|
||||
FEISHU_HTTP_TIMEOUT_ENV_VAR,
|
||||
setFeishuClientRuntimeForTest,
|
||||
} from "./client.js";
|
||||
|
||||
const proxyEnvKeys = ["https_proxy", "HTTPS_PROXY", "http_proxy", "HTTP_PROXY"] as const;
|
||||
@ -78,6 +64,21 @@ beforeEach(() => {
|
||||
delete process.env[key];
|
||||
}
|
||||
vi.clearAllMocks();
|
||||
setFeishuClientRuntimeForTest({
|
||||
sdk: {
|
||||
AppType: { SelfBuild: "self" } as never,
|
||||
Domain: {
|
||||
Feishu: "https://open.feishu.cn",
|
||||
Lark: "https://open.larksuite.com",
|
||||
} as never,
|
||||
LoggerLevel: { info: "info" } as never,
|
||||
Client: clientCtorMock as never,
|
||||
WSClient: wsClientCtorMock as never,
|
||||
EventDispatcher: vi.fn() as never,
|
||||
defaultHttpInstance: mockBaseHttpInstance as never,
|
||||
},
|
||||
HttpsProxyAgent: httpsProxyAgentCtorMock as never,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@ -94,6 +95,7 @@ afterEach(() => {
|
||||
} else {
|
||||
process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR] = priorFeishuTimeoutEnv;
|
||||
}
|
||||
setFeishuClientRuntimeForTest();
|
||||
});
|
||||
|
||||
describe("createFeishuClient HTTP timeout", () => {
|
||||
@ -102,7 +104,7 @@ describe("createFeishuClient HTTP timeout", () => {
|
||||
});
|
||||
|
||||
const getLastClientHttpInstance = () => {
|
||||
const calls = (LarkClient as unknown as ReturnType<typeof vi.fn>).mock.calls;
|
||||
const calls = clientCtorMock.mock.calls;
|
||||
const lastCall = calls[calls.length - 1]?.[0] as
|
||||
| { httpInstance?: { get: (...args: unknown[]) => Promise<unknown> } }
|
||||
| undefined;
|
||||
@ -122,7 +124,7 @@ describe("createFeishuClient HTTP timeout", () => {
|
||||
it("passes a custom httpInstance with default timeout to Lark.Client", () => {
|
||||
createFeishuClient({ appId: "app_1", appSecret: "secret_1", accountId: "timeout-test" }); // pragma: allowlist secret
|
||||
|
||||
const calls = (LarkClient as unknown as ReturnType<typeof vi.fn>).mock.calls;
|
||||
const calls = clientCtorMock.mock.calls;
|
||||
const lastCall = calls[calls.length - 1][0] as { httpInstance?: unknown };
|
||||
expect(lastCall.httpInstance).toBeDefined();
|
||||
});
|
||||
@ -130,7 +132,7 @@ describe("createFeishuClient HTTP timeout", () => {
|
||||
it("injects default timeout into HTTP request options", async () => {
|
||||
createFeishuClient({ appId: "app_2", appSecret: "secret_2", accountId: "timeout-inject" }); // pragma: allowlist secret
|
||||
|
||||
const calls = (LarkClient as unknown as ReturnType<typeof vi.fn>).mock.calls;
|
||||
const calls = clientCtorMock.mock.calls;
|
||||
const lastCall = calls[calls.length - 1][0] as {
|
||||
httpInstance: { post: (...args: unknown[]) => Promise<unknown> };
|
||||
};
|
||||
@ -152,7 +154,7 @@ describe("createFeishuClient HTTP timeout", () => {
|
||||
it("allows explicit timeout override per-request", async () => {
|
||||
createFeishuClient({ appId: "app_3", appSecret: "secret_3", accountId: "timeout-override" }); // pragma: allowlist secret
|
||||
|
||||
const calls = (LarkClient as unknown as ReturnType<typeof vi.fn>).mock.calls;
|
||||
const calls = clientCtorMock.mock.calls;
|
||||
const lastCall = calls[calls.length - 1][0] as {
|
||||
httpInstance: { get: (...args: unknown[]) => Promise<unknown> };
|
||||
};
|
||||
@ -241,7 +243,7 @@ describe("createFeishuClient HTTP timeout", () => {
|
||||
config: { httpTimeoutMs: 45_000 },
|
||||
});
|
||||
|
||||
const calls = (LarkClient as unknown as ReturnType<typeof vi.fn>).mock.calls;
|
||||
const calls = clientCtorMock.mock.calls;
|
||||
expect(calls.length).toBe(2);
|
||||
|
||||
const lastCall = calls[calls.length - 1][0] as {
|
||||
|
||||
@ -2,6 +2,30 @@ import * as Lark from "@larksuiteoapi/node-sdk";
|
||||
import { HttpsProxyAgent } from "https-proxy-agent";
|
||||
import type { FeishuConfig, FeishuDomain, ResolvedFeishuAccount } from "./types.js";
|
||||
|
||||
type FeishuClientSdk = Pick<
|
||||
typeof Lark,
|
||||
| "AppType"
|
||||
| "Client"
|
||||
| "defaultHttpInstance"
|
||||
| "Domain"
|
||||
| "EventDispatcher"
|
||||
| "LoggerLevel"
|
||||
| "WSClient"
|
||||
>;
|
||||
|
||||
const defaultFeishuClientSdk: FeishuClientSdk = {
|
||||
AppType: Lark.AppType,
|
||||
Client: Lark.Client,
|
||||
defaultHttpInstance: Lark.defaultHttpInstance,
|
||||
Domain: Lark.Domain,
|
||||
EventDispatcher: Lark.EventDispatcher,
|
||||
LoggerLevel: Lark.LoggerLevel,
|
||||
WSClient: Lark.WSClient,
|
||||
};
|
||||
|
||||
let feishuClientSdk: FeishuClientSdk = defaultFeishuClientSdk;
|
||||
let httpsProxyAgentCtor: typeof HttpsProxyAgent = HttpsProxyAgent;
|
||||
|
||||
/** Default HTTP timeout for Feishu API requests (30 seconds). */
|
||||
export const FEISHU_HTTP_TIMEOUT_MS = 30_000;
|
||||
export const FEISHU_HTTP_TIMEOUT_MAX_MS = 300_000;
|
||||
@ -14,7 +38,7 @@ function getWsProxyAgent(): HttpsProxyAgent<string> | undefined {
|
||||
process.env.http_proxy ||
|
||||
process.env.HTTP_PROXY;
|
||||
if (!proxyUrl) return undefined;
|
||||
return new HttpsProxyAgent(proxyUrl);
|
||||
return new httpsProxyAgentCtor(proxyUrl);
|
||||
}
|
||||
|
||||
// Multi-account client cache
|
||||
@ -28,10 +52,10 @@ const clientCache = new Map<
|
||||
|
||||
function resolveDomain(domain: FeishuDomain | undefined): Lark.Domain | string {
|
||||
if (domain === "lark") {
|
||||
return Lark.Domain.Lark;
|
||||
return feishuClientSdk.Domain.Lark;
|
||||
}
|
||||
if (domain === "feishu" || !domain) {
|
||||
return Lark.Domain.Feishu;
|
||||
return feishuClientSdk.Domain.Feishu;
|
||||
}
|
||||
return domain.replace(/\/+$/, ""); // Custom URL for private deployment
|
||||
}
|
||||
@ -42,7 +66,8 @@ function resolveDomain(domain: FeishuDomain | undefined): Lark.Domain | string {
|
||||
* (e.g. when the Feishu API is slow, causing per-chat queue deadlocks).
|
||||
*/
|
||||
function createTimeoutHttpInstance(defaultTimeoutMs: number): Lark.HttpInstance {
|
||||
const base: Lark.HttpInstance = Lark.defaultHttpInstance as unknown as Lark.HttpInstance;
|
||||
const base: Lark.HttpInstance =
|
||||
feishuClientSdk.defaultHttpInstance as unknown as Lark.HttpInstance;
|
||||
|
||||
function injectTimeout<D>(opts?: Lark.HttpRequestOptions<D>): Lark.HttpRequestOptions<D> {
|
||||
return { timeout: defaultTimeoutMs, ...opts } as Lark.HttpRequestOptions<D>;
|
||||
@ -129,10 +154,10 @@ export function createFeishuClient(creds: FeishuClientCredentials): Lark.Client
|
||||
}
|
||||
|
||||
// Create new client with timeout-aware HTTP instance
|
||||
const client = new Lark.Client({
|
||||
const client = new feishuClientSdk.Client({
|
||||
appId,
|
||||
appSecret,
|
||||
appType: Lark.AppType.SelfBuild,
|
||||
appType: feishuClientSdk.AppType.SelfBuild,
|
||||
domain: resolveDomain(domain),
|
||||
httpInstance: createTimeoutHttpInstance(defaultHttpTimeoutMs),
|
||||
});
|
||||
@ -158,11 +183,11 @@ export function createFeishuWSClient(account: ResolvedFeishuAccount): Lark.WSCli
|
||||
}
|
||||
|
||||
const agent = getWsProxyAgent();
|
||||
return new Lark.WSClient({
|
||||
return new feishuClientSdk.WSClient({
|
||||
appId,
|
||||
appSecret,
|
||||
domain: resolveDomain(domain),
|
||||
loggerLevel: Lark.LoggerLevel.info,
|
||||
loggerLevel: feishuClientSdk.LoggerLevel.info,
|
||||
...(agent ? { agent } : {}),
|
||||
});
|
||||
}
|
||||
@ -171,7 +196,7 @@ export function createFeishuWSClient(account: ResolvedFeishuAccount): Lark.WSCli
|
||||
* Create an event dispatcher for an account.
|
||||
*/
|
||||
export function createEventDispatcher(account: ResolvedFeishuAccount): Lark.EventDispatcher {
|
||||
return new Lark.EventDispatcher({
|
||||
return new feishuClientSdk.EventDispatcher({
|
||||
encryptKey: account.encryptKey,
|
||||
verificationToken: account.verificationToken,
|
||||
});
|
||||
@ -194,3 +219,13 @@ export function clearClientCache(accountId?: string): void {
|
||||
clientCache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export function setFeishuClientRuntimeForTest(overrides?: {
|
||||
sdk?: Partial<FeishuClientSdk>;
|
||||
HttpsProxyAgent?: typeof HttpsProxyAgent;
|
||||
}): void {
|
||||
feishuClientSdk = overrides?.sdk
|
||||
? { ...defaultFeishuClientSdk, ...overrides.sdk }
|
||||
: defaultFeishuClientSdk;
|
||||
httpsProxyAgentCtor = overrides?.HttpsProxyAgent ?? HttpsProxyAgent;
|
||||
}
|
||||
|
||||
@ -1,11 +1,18 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const createFeishuClientMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./client.js", () => ({
|
||||
createFeishuClient: createFeishuClientMock,
|
||||
const clientCtorMock = vi.hoisted(() => vi.fn());
|
||||
const mockBaseHttpInstance = vi.hoisted(() => ({
|
||||
request: vi.fn().mockResolvedValue({}),
|
||||
get: vi.fn().mockResolvedValue({}),
|
||||
post: vi.fn().mockResolvedValue({}),
|
||||
put: vi.fn().mockResolvedValue({}),
|
||||
patch: vi.fn().mockResolvedValue({}),
|
||||
delete: vi.fn().mockResolvedValue({}),
|
||||
head: vi.fn().mockResolvedValue({}),
|
||||
options: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
import { clearClientCache, setFeishuClientRuntimeForTest } from "./client.js";
|
||||
import { FEISHU_PROBE_REQUEST_TIMEOUT_MS, probeFeishu, clearProbeCache } from "./probe.js";
|
||||
|
||||
const DEFAULT_CREDS = { appId: "cli_123", appSecret: "secret" } as const; // pragma: allowlist secret
|
||||
@ -28,9 +35,15 @@ function makeRequestFn(response: Record<string, unknown>) {
|
||||
return vi.fn().mockResolvedValue(response);
|
||||
}
|
||||
|
||||
function installClientCtor(requestFn: unknown) {
|
||||
clientCtorMock.mockImplementation(function MockFeishuClient(this: { request: unknown }) {
|
||||
this.request = requestFn;
|
||||
} as never);
|
||||
}
|
||||
|
||||
function setupClient(response: Record<string, unknown>) {
|
||||
const requestFn = makeRequestFn(response);
|
||||
createFeishuClientMock.mockReturnValue({ request: requestFn });
|
||||
installClientCtor(requestFn);
|
||||
return requestFn;
|
||||
}
|
||||
|
||||
@ -60,7 +73,7 @@ async function expectErrorResultCached(params: {
|
||||
expectedError: string;
|
||||
ttlMs: number;
|
||||
}) {
|
||||
createFeishuClientMock.mockReturnValue({ request: params.requestFn });
|
||||
installClientCtor(params.requestFn);
|
||||
|
||||
const first = await probeFeishu(DEFAULT_CREDS);
|
||||
const second = await probeFeishu(DEFAULT_CREDS);
|
||||
@ -95,11 +108,25 @@ async function readSequentialDefaultProbePair() {
|
||||
describe("probeFeishu", () => {
|
||||
beforeEach(() => {
|
||||
clearProbeCache();
|
||||
vi.restoreAllMocks();
|
||||
clearClientCache();
|
||||
vi.clearAllMocks();
|
||||
setFeishuClientRuntimeForTest({
|
||||
sdk: {
|
||||
AppType: { SelfBuild: "self" } as never,
|
||||
Domain: {
|
||||
Feishu: "https://open.feishu.cn",
|
||||
Lark: "https://open.larksuite.com",
|
||||
} as never,
|
||||
Client: clientCtorMock as never,
|
||||
defaultHttpInstance: mockBaseHttpInstance as never,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearProbeCache();
|
||||
clearClientCache();
|
||||
setFeishuClientRuntimeForTest();
|
||||
});
|
||||
|
||||
it("returns error when credentials are missing", async () => {
|
||||
@ -141,7 +168,7 @@ describe("probeFeishu", () => {
|
||||
it("returns timeout error when request exceeds timeout", async () => {
|
||||
await withFakeTimers(async () => {
|
||||
const requestFn = vi.fn().mockImplementation(() => new Promise(() => {}));
|
||||
createFeishuClientMock.mockReturnValue({ request: requestFn });
|
||||
installClientCtor(requestFn);
|
||||
|
||||
const promise = probeFeishu(DEFAULT_CREDS, { timeoutMs: 1_000 });
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
@ -152,7 +179,6 @@ describe("probeFeishu", () => {
|
||||
});
|
||||
|
||||
it("returns aborted when abort signal is already aborted", async () => {
|
||||
createFeishuClientMock.mockClear();
|
||||
const abortController = new AbortController();
|
||||
abortController.abort();
|
||||
|
||||
@ -162,7 +188,7 @@ describe("probeFeishu", () => {
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ ok: false, error: "probe aborted" });
|
||||
expect(createFeishuClientMock).not.toHaveBeenCalled();
|
||||
expect(clientCtorMock).not.toHaveBeenCalled();
|
||||
});
|
||||
it("returns cached result on subsequent calls within TTL", async () => {
|
||||
const requestFn = setupSuccessClient();
|
||||
|
||||
@ -1,7 +1,27 @@
|
||||
import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs";
|
||||
import type { Dirent } from "node:fs";
|
||||
import { delimiter, dirname, join } from "node:path";
|
||||
import { CLIENT_ID_KEYS, CLIENT_SECRET_KEYS } from "./oauth.shared.js";
|
||||
|
||||
type CredentialFs = {
|
||||
existsSync: (path: Parameters<typeof existsSync>[0]) => ReturnType<typeof existsSync>;
|
||||
readFileSync: (path: Parameters<typeof readFileSync>[0], encoding: "utf8") => string;
|
||||
realpathSync: (path: Parameters<typeof realpathSync>[0]) => string;
|
||||
readdirSync: (
|
||||
path: Parameters<typeof readdirSync>[0],
|
||||
options: { withFileTypes: true },
|
||||
) => Dirent[];
|
||||
};
|
||||
|
||||
const defaultFs: CredentialFs = {
|
||||
existsSync,
|
||||
readFileSync,
|
||||
realpathSync,
|
||||
readdirSync,
|
||||
};
|
||||
|
||||
let credentialFs: CredentialFs = defaultFs;
|
||||
|
||||
function resolveEnv(keys: string[]): string | undefined {
|
||||
for (const key of keys) {
|
||||
const value = process.env[key]?.trim();
|
||||
@ -18,6 +38,10 @@ export function clearCredentialsCache(): void {
|
||||
cachedGeminiCliCredentials = null;
|
||||
}
|
||||
|
||||
export function setOAuthCredentialsFsForTest(overrides?: Partial<CredentialFs>): void {
|
||||
credentialFs = overrides ? { ...defaultFs, ...overrides } : defaultFs;
|
||||
}
|
||||
|
||||
export function extractGeminiCliCredentials(): { clientId: string; clientSecret: string } | null {
|
||||
if (cachedGeminiCliCredentials) {
|
||||
return cachedGeminiCliCredentials;
|
||||
@ -29,7 +53,7 @@ export function extractGeminiCliCredentials(): { clientId: string; clientSecret:
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolvedPath = realpathSync(geminiPath);
|
||||
const resolvedPath = credentialFs.realpathSync(geminiPath);
|
||||
const geminiCliDirs = resolveGeminiCliDirs(geminiPath, resolvedPath);
|
||||
|
||||
let content: string | null = null;
|
||||
@ -55,10 +79,9 @@ export function extractGeminiCliCredentials(): { clientId: string; clientSecret:
|
||||
"oauth2.js",
|
||||
),
|
||||
];
|
||||
|
||||
for (const path of searchPaths) {
|
||||
if (existsSync(path)) {
|
||||
content = readFileSync(path, "utf8");
|
||||
if (credentialFs.existsSync(path)) {
|
||||
content = credentialFs.readFileSync(path, "utf8");
|
||||
break;
|
||||
}
|
||||
}
|
||||
@ -67,7 +90,7 @@ export function extractGeminiCliCredentials(): { clientId: string; clientSecret:
|
||||
}
|
||||
const found = findFile(geminiCliDir, "oauth2.js", 10);
|
||||
if (found) {
|
||||
content = readFileSync(found, "utf8");
|
||||
content = credentialFs.readFileSync(found, "utf8");
|
||||
break;
|
||||
}
|
||||
}
|
||||
@ -116,7 +139,7 @@ function findInPath(name: string): string | null {
|
||||
for (const dir of (process.env.PATH ?? "").split(delimiter)) {
|
||||
for (const ext of exts) {
|
||||
const path = join(dir, name + ext);
|
||||
if (existsSync(path)) {
|
||||
if (credentialFs.existsSync(path)) {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
@ -129,7 +152,7 @@ function findFile(dir: string, name: string, depth: number): string | null {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
for (const entry of credentialFs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const path = join(dir, entry.name);
|
||||
if (entry.isFile() && entry.name === name) {
|
||||
return path;
|
||||
|
||||
@ -21,23 +21,11 @@ vi.mock("../../src/infra/net/fetch-guard.js", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock fs module before importing the module under test
|
||||
const mockExistsSync = vi.fn();
|
||||
const mockReadFileSync = vi.fn();
|
||||
const mockRealpathSync = vi.fn();
|
||||
const mockReaddirSync = vi.fn();
|
||||
|
||||
vi.mock("node:fs", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:fs")>();
|
||||
return {
|
||||
...actual,
|
||||
existsSync: (...args: Parameters<typeof actual.existsSync>) => mockExistsSync(...args),
|
||||
readFileSync: (...args: Parameters<typeof actual.readFileSync>) => mockReadFileSync(...args),
|
||||
realpathSync: (...args: Parameters<typeof actual.realpathSync>) => mockRealpathSync(...args),
|
||||
readdirSync: (...args: Parameters<typeof actual.readdirSync>) => mockReaddirSync(...args),
|
||||
};
|
||||
});
|
||||
|
||||
describe("extractGeminiCliCredentials", () => {
|
||||
const normalizePath = (value: string) =>
|
||||
value.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
|
||||
@ -51,6 +39,20 @@ describe("extractGeminiCliCredentials", () => {
|
||||
|
||||
let originalPath: string | undefined;
|
||||
|
||||
async function loadCredentialsModule() {
|
||||
return await import("./oauth.credentials.js");
|
||||
}
|
||||
|
||||
async function installMockFs() {
|
||||
const { setOAuthCredentialsFsForTest } = await loadCredentialsModule();
|
||||
setOAuthCredentialsFsForTest({
|
||||
existsSync: (...args) => mockExistsSync(...args),
|
||||
readFileSync: (...args) => mockReadFileSync(...args),
|
||||
realpathSync: (...args) => mockRealpathSync(...args),
|
||||
readdirSync: (...args) => mockReaddirSync(...args),
|
||||
});
|
||||
}
|
||||
|
||||
function makeFakeLayout() {
|
||||
const binDir = join(rootDir, "fake", "bin");
|
||||
const geminiPath = join(binDir, "gemini");
|
||||
@ -157,17 +159,20 @@ describe("extractGeminiCliCredentials", () => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
originalPath = process.env.PATH;
|
||||
await installMockFs();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
afterEach(async () => {
|
||||
process.env.PATH = originalPath;
|
||||
const { setOAuthCredentialsFsForTest } = await loadCredentialsModule();
|
||||
setOAuthCredentialsFsForTest();
|
||||
});
|
||||
|
||||
it("returns null when gemini binary is not in PATH", async () => {
|
||||
process.env.PATH = "/nonexistent";
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
|
||||
const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js");
|
||||
const { extractGeminiCliCredentials, clearCredentialsCache } = await loadCredentialsModule();
|
||||
clearCredentialsCache();
|
||||
expect(extractGeminiCliCredentials()).toBeNull();
|
||||
});
|
||||
@ -175,7 +180,7 @@ describe("extractGeminiCliCredentials", () => {
|
||||
it("extracts credentials from oauth2.js in known path", async () => {
|
||||
installGeminiLayout({ oauth2Exists: true, oauth2Content: FAKE_OAUTH2_CONTENT });
|
||||
|
||||
const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js");
|
||||
const { extractGeminiCliCredentials, clearCredentialsCache } = await loadCredentialsModule();
|
||||
clearCredentialsCache();
|
||||
const result = extractGeminiCliCredentials();
|
||||
|
||||
@ -185,7 +190,7 @@ describe("extractGeminiCliCredentials", () => {
|
||||
it("extracts credentials when PATH entry is an npm global shim", async () => {
|
||||
installNpmShimLayout({ oauth2Exists: true, oauth2Content: FAKE_OAUTH2_CONTENT });
|
||||
|
||||
const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js");
|
||||
const { extractGeminiCliCredentials, clearCredentialsCache } = await loadCredentialsModule();
|
||||
clearCredentialsCache();
|
||||
const result = extractGeminiCliCredentials();
|
||||
|
||||
@ -195,7 +200,7 @@ describe("extractGeminiCliCredentials", () => {
|
||||
it("returns null when oauth2.js cannot be found", async () => {
|
||||
installGeminiLayout({ oauth2Exists: false, readdir: [] });
|
||||
|
||||
const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js");
|
||||
const { extractGeminiCliCredentials, clearCredentialsCache } = await loadCredentialsModule();
|
||||
clearCredentialsCache();
|
||||
expect(extractGeminiCliCredentials()).toBeNull();
|
||||
});
|
||||
@ -203,7 +208,7 @@ describe("extractGeminiCliCredentials", () => {
|
||||
it("returns null when oauth2.js lacks credentials", async () => {
|
||||
installGeminiLayout({ oauth2Exists: true, oauth2Content: "// no credentials here" });
|
||||
|
||||
const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js");
|
||||
const { extractGeminiCliCredentials, clearCredentialsCache } = await loadCredentialsModule();
|
||||
clearCredentialsCache();
|
||||
expect(extractGeminiCliCredentials()).toBeNull();
|
||||
});
|
||||
@ -211,7 +216,7 @@ describe("extractGeminiCliCredentials", () => {
|
||||
it("caches credentials after first extraction", async () => {
|
||||
installGeminiLayout({ oauth2Exists: true, oauth2Content: FAKE_OAUTH2_CONTENT });
|
||||
|
||||
const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js");
|
||||
const { extractGeminiCliCredentials, clearCredentialsCache } = await loadCredentialsModule();
|
||||
clearCredentialsCache();
|
||||
|
||||
// First call
|
||||
|
||||
@ -4,10 +4,14 @@ import { describe, expect, it, vi } from "vitest";
|
||||
const uploadGoogleChatAttachmentMock = vi.hoisted(() => vi.fn());
|
||||
const sendGoogleChatMessageMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./api.js", () => ({
|
||||
sendGoogleChatMessage: sendGoogleChatMessageMock,
|
||||
uploadGoogleChatAttachment: uploadGoogleChatAttachmentMock,
|
||||
}));
|
||||
vi.mock("./api.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./api.js")>();
|
||||
return {
|
||||
...actual,
|
||||
sendGoogleChatMessage: sendGoogleChatMessageMock,
|
||||
uploadGoogleChatAttachment: uploadGoogleChatAttachmentMock,
|
||||
};
|
||||
});
|
||||
|
||||
import { googlechatPlugin } from "./channel.js";
|
||||
import { setGoogleChatRuntime } from "./runtime.js";
|
||||
|
||||
@ -39,10 +39,52 @@ describe("sandbox ssh helpers", () => {
|
||||
expect(config).toContain("UpdateHostKeys no");
|
||||
|
||||
const configDir = session.configPath.slice(0, session.configPath.lastIndexOf("/"));
|
||||
expect(await fs.readFile(`${configDir}/identity`, "utf8")).toBe("PRIVATE KEY");
|
||||
expect(await fs.readFile(`${configDir}/certificate.pub`, "utf8")).toBe("SSH CERT");
|
||||
expect(await fs.readFile(`${configDir}/identity`, "utf8")).toBe("PRIVATE KEY\n");
|
||||
expect(await fs.readFile(`${configDir}/certificate.pub`, "utf8")).toBe("SSH CERT\n");
|
||||
expect(await fs.readFile(`${configDir}/known_hosts`, "utf8")).toBe(
|
||||
"example.com ssh-ed25519 AAAATEST",
|
||||
"example.com ssh-ed25519 AAAATEST\n",
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes CRLF and escaped-newline private keys before writing temp files", async () => {
|
||||
const session = await createSshSandboxSessionFromSettings({
|
||||
command: "ssh",
|
||||
target: "peter@example.com:2222",
|
||||
strictHostKeyChecking: true,
|
||||
updateHostKeys: false,
|
||||
identityData:
|
||||
"-----BEGIN OPENSSH PRIVATE KEY-----\\nbGluZTE=\\r\\nbGluZTI=\\r\\n-----END OPENSSH PRIVATE KEY-----",
|
||||
knownHostsData: "example.com ssh-ed25519 AAAATEST",
|
||||
});
|
||||
sessions.push(session);
|
||||
|
||||
const configDir = session.configPath.slice(0, session.configPath.lastIndexOf("/"));
|
||||
expect(await fs.readFile(`${configDir}/identity`, "utf8")).toBe(
|
||||
"-----BEGIN OPENSSH PRIVATE KEY-----\n" +
|
||||
"bGluZTE=\n" +
|
||||
"bGluZTI=\n" +
|
||||
"-----END OPENSSH PRIVATE KEY-----\n",
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes mixed real and escaped newlines in private keys", async () => {
|
||||
const session = await createSshSandboxSessionFromSettings({
|
||||
command: "ssh",
|
||||
target: "peter@example.com:2222",
|
||||
strictHostKeyChecking: true,
|
||||
updateHostKeys: false,
|
||||
identityData:
|
||||
"-----BEGIN OPENSSH PRIVATE KEY-----\nline-1\\nline-2\n-----END OPENSSH PRIVATE KEY-----",
|
||||
knownHostsData: "example.com ssh-ed25519 AAAATEST",
|
||||
});
|
||||
sessions.push(session);
|
||||
|
||||
const configDir = session.configPath.slice(0, session.configPath.lastIndexOf("/"));
|
||||
expect(await fs.readFile(`${configDir}/identity`, "utf8")).toBe(
|
||||
"-----BEGIN OPENSSH PRIVATE KEY-----\n" +
|
||||
"line-1\n" +
|
||||
"line-2\n" +
|
||||
"-----END OPENSSH PRIVATE KEY-----\n",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@ -35,6 +35,35 @@ export type RunSshSandboxCommandParams = {
|
||||
tty?: boolean;
|
||||
};
|
||||
|
||||
function normalizeInlineSshMaterial(contents: string, filename: string): string {
|
||||
const withoutBom = contents.replace(/^\uFEFF/, "");
|
||||
const normalizedNewlines = withoutBom.replace(/\r\n?/g, "\n");
|
||||
const normalizedEscapedNewlines = normalizedNewlines
|
||||
.replace(/\\r\\n/g, "\\n")
|
||||
.replace(/\\r/g, "\\n");
|
||||
const expanded =
|
||||
filename === "identity" || filename === "certificate.pub"
|
||||
? normalizedEscapedNewlines.replace(/\\n/g, "\n")
|
||||
: normalizedEscapedNewlines;
|
||||
return expanded.endsWith("\n") ? expanded : `${expanded}\n`;
|
||||
}
|
||||
|
||||
function buildSshFailureMessage(stderr: string, exitCode?: number): string {
|
||||
const trimmed = stderr.trim();
|
||||
if (
|
||||
trimmed.includes("error in libcrypto") &&
|
||||
(trimmed.includes('Load key "') || trimmed.includes("Permission denied (publickey)"))
|
||||
) {
|
||||
return `${trimmed}\nSSH sandbox failed to load the configured identity. The private key contents may be malformed (for example CRLF or escaped newlines). Prefer identityFile when possible.`;
|
||||
}
|
||||
return (
|
||||
trimmed ||
|
||||
(exitCode !== undefined
|
||||
? `ssh exited with code ${exitCode}`
|
||||
: "ssh exited with a non-zero status")
|
||||
);
|
||||
}
|
||||
|
||||
export function shellEscape(value: string): string {
|
||||
return `'${value.replaceAll("'", `'"'"'`)}'`;
|
||||
}
|
||||
@ -201,14 +230,11 @@ export async function runSshSandboxCommand(
|
||||
const exitCode = code ?? 0;
|
||||
if (exitCode !== 0 && !params.allowFailure) {
|
||||
reject(
|
||||
Object.assign(
|
||||
new Error(stderr.toString("utf8").trim() || `ssh exited with code ${exitCode}`),
|
||||
{
|
||||
code: exitCode,
|
||||
stdout,
|
||||
stderr,
|
||||
},
|
||||
),
|
||||
Object.assign(new Error(buildSshFailureMessage(stderr.toString("utf8"), exitCode)), {
|
||||
code: exitCode,
|
||||
stdout,
|
||||
stderr,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@ -328,7 +354,10 @@ async function writeSecretMaterial(
|
||||
contents: string,
|
||||
): Promise<string> {
|
||||
const pathname = path.join(dir, filename);
|
||||
await fs.writeFile(pathname, contents, { encoding: "utf8", mode: 0o600 });
|
||||
await fs.writeFile(pathname, normalizeInlineSshMaterial(contents, filename), {
|
||||
encoding: "utf8",
|
||||
mode: 0o600,
|
||||
});
|
||||
await fs.chmod(pathname, 0o600);
|
||||
return pathname;
|
||||
}
|
||||
|
||||
@ -5,36 +5,57 @@ import {
|
||||
expectCodexMissingAuthHint,
|
||||
} from "../provider-runtime.test-support.js";
|
||||
import {
|
||||
providerContractPluginIds,
|
||||
resolveProviderContractPluginIdsForProvider,
|
||||
resolveProviderContractProvidersForPluginIds,
|
||||
uniqueProviderContractProviders,
|
||||
} from "./registry.js";
|
||||
|
||||
const resolvePluginProvidersMock = vi.fn();
|
||||
const resolveOwningPluginIdsForProviderMock = vi.fn();
|
||||
const resolveNonBundledProviderPluginIdsMock = vi.fn();
|
||||
type ResolvePluginProviders = typeof import("../providers.js").resolvePluginProviders;
|
||||
type ResolveOwningPluginIdsForProvider =
|
||||
typeof import("../providers.js").resolveOwningPluginIdsForProvider;
|
||||
type ResolveNonBundledProviderPluginIds =
|
||||
typeof import("../providers.js").resolveNonBundledProviderPluginIds;
|
||||
|
||||
const resolvePluginProvidersMock = vi.hoisted(() =>
|
||||
vi.fn<ResolvePluginProviders>((_) => uniqueProviderContractProviders),
|
||||
);
|
||||
const resolveOwningPluginIdsForProviderMock = vi.hoisted(() =>
|
||||
vi.fn<ResolveOwningPluginIdsForProvider>((params) =>
|
||||
resolveProviderContractPluginIdsForProvider(params.provider),
|
||||
),
|
||||
);
|
||||
const resolveNonBundledProviderPluginIdsMock = vi.hoisted(() =>
|
||||
vi.fn<ResolveNonBundledProviderPluginIds>((_) => [] as string[]),
|
||||
);
|
||||
|
||||
vi.mock("../providers.js", () => ({
|
||||
resolvePluginProviders: (...args: unknown[]) => resolvePluginProvidersMock(...args),
|
||||
resolveOwningPluginIdsForProvider: (...args: unknown[]) =>
|
||||
resolveOwningPluginIdsForProviderMock(...args),
|
||||
resolveNonBundledProviderPluginIds: (...args: unknown[]) =>
|
||||
resolveNonBundledProviderPluginIdsMock(...args),
|
||||
resolvePluginProviders: (params: unknown) => resolvePluginProvidersMock(params as never),
|
||||
resolveOwningPluginIdsForProvider: (params: unknown) =>
|
||||
resolveOwningPluginIdsForProviderMock(params as never),
|
||||
resolveNonBundledProviderPluginIds: (params: unknown) =>
|
||||
resolveNonBundledProviderPluginIdsMock(params as never),
|
||||
}));
|
||||
|
||||
const {
|
||||
augmentModelCatalogWithProviderPlugins,
|
||||
buildProviderMissingAuthMessageWithPlugin,
|
||||
resetProviderRuntimeHookCacheForTest,
|
||||
resolveProviderBuiltInModelSuppression,
|
||||
} = await import("../provider-runtime.js");
|
||||
let augmentModelCatalogWithProviderPlugins: typeof import("../provider-runtime.js").augmentModelCatalogWithProviderPlugins;
|
||||
let buildProviderMissingAuthMessageWithPlugin: typeof import("../provider-runtime.js").buildProviderMissingAuthMessageWithPlugin;
|
||||
let resetProviderRuntimeHookCacheForTest: typeof import("../provider-runtime.js").resetProviderRuntimeHookCacheForTest;
|
||||
let resolveProviderBuiltInModelSuppression: typeof import("../provider-runtime.js").resolveProviderBuiltInModelSuppression;
|
||||
|
||||
describe("provider catalog contract", () => {
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
({
|
||||
augmentModelCatalogWithProviderPlugins,
|
||||
buildProviderMissingAuthMessageWithPlugin,
|
||||
resetProviderRuntimeHookCacheForTest,
|
||||
resolveProviderBuiltInModelSuppression,
|
||||
} = await import("../provider-runtime.js"));
|
||||
resetProviderRuntimeHookCacheForTest();
|
||||
|
||||
resolveOwningPluginIdsForProviderMock.mockReset();
|
||||
resolveOwningPluginIdsForProviderMock.mockReturnValue(providerContractPluginIds);
|
||||
resolveOwningPluginIdsForProviderMock.mockImplementation((params) =>
|
||||
resolveProviderContractPluginIdsForProvider(params.provider),
|
||||
);
|
||||
|
||||
resolveNonBundledProviderPluginIdsMock.mockReset();
|
||||
resolveNonBundledProviderPluginIdsMock.mockReturnValue([]);
|
||||
|
||||
@ -177,6 +177,19 @@ export function requireProviderContractProvider(providerId: string): ProviderPlu
|
||||
return provider;
|
||||
}
|
||||
|
||||
export function resolveProviderContractPluginIdsForProvider(
|
||||
providerId: string,
|
||||
): string[] | undefined {
|
||||
const pluginIds = [
|
||||
...new Set(
|
||||
providerContractRegistry
|
||||
.filter((entry) => entry.provider.id === providerId)
|
||||
.map((entry) => entry.pluginId),
|
||||
),
|
||||
];
|
||||
return pluginIds.length > 0 ? pluginIds : undefined;
|
||||
}
|
||||
|
||||
export function resolveProviderContractProvidersForPluginIds(
|
||||
pluginIds: readonly string[],
|
||||
): ProviderPlugin[] {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user