Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -144,4 +144,4 @@ vite.config.ts.timestamp-*

# Local-only working storage (plans, scratch notes, etc.)
tmp/

security-audit/
13 changes: 3 additions & 10 deletions src/commands/tools-dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,11 +187,7 @@ export class ToolsDashboardComponent implements Component {
const entry = this.options.config.providers[name];
const key = entry?.apiKey;
const keyState =
key === undefined
? "unset"
: classifyCredential(key) === "env"
? `env: ${key}`
: "set";
key === undefined ? "unset" : classifyCredential(key) === "env" ? `env: ${key}` : "set";
const isSelected = index === this.providerIndex;
const prefix = renderRowPrefix(isSelected, this.options.theme);
const paddedName = padVisible(truncateVisible(name, 20), 20);
Expand Down Expand Up @@ -227,8 +223,7 @@ export class ToolsDashboardComponent implements Component {
if (!result) return "";
// Non-search providers never show a successful test cell, but they can
// still display a deterministic failure if `t` was pressed on them.
const isSearchProvider =
this.options.registry.selectSearchCandidates(name).length > 0;
const isSearchProvider = this.options.registry.selectSearchCandidates(name).length > 0;
if (!isSearchProvider) {
// Render the deterministic failure for non-search providers.
return `FAIL • ${result.message}`;
Expand Down Expand Up @@ -318,9 +313,7 @@ export class ToolsDashboardComponent implements Component {
if (matchesKey(data, Key.shift("t"))) {
const searchNames = this.options.registry.getSearchProviderNames();
if (searchNames.length === 0) return;
this.beginTest((signal) =>
runProviderTests(this.options.registry, searchNames, signal),
);
this.beginTest((signal) => runProviderTests(this.options.registry, searchNames, signal));
return;
}

Expand Down
2 changes: 1 addition & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -524,7 +524,7 @@ export function resolveProviderKey(providerName: string, configKey?: string): st

// --- Trust Gating ---

const SENSITIVE_KEYS = new Set(["apiKey", "apiSecret", "token"]);
const SENSITIVE_KEYS = new Set(["apiKey", "apiSecret", "token", "baseUrl", "instanceUrl"]);
const SENSITIVE_PATHS = new Set([
"ssrf.allowRanges",
"gemini.cloudflareApiKey",
Expand Down
36 changes: 10 additions & 26 deletions src/extract/chrome-cookies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,10 +142,7 @@ export async function getGoogleCookies(options?: {
if (!ALL_COOKIE_NAMES.has(name)) continue;
if (cookies[name]) continue; // keep first (freshest, sorted by expires_utc DESC)

let value =
typeof row.value === "string" && row.value.length > 0
? row.value
: null;
let value = typeof row.value === "string" && row.value.length > 0 ? row.value : null;
if (!value) {
const encrypted = row.encrypted_value;
if (encrypted instanceof Uint8Array) {
Expand Down Expand Up @@ -182,11 +179,7 @@ export async function getGoogleCookies(options?: {
* - Removes PKCS7 padding
* - If stripHash is true (Chrome DB v24+), removes first 32 bytes of plaintext
*/
function decryptCookieValue(
encrypted: Uint8Array,
key: Buffer,
stripHash: boolean,
): string | null {
function decryptCookieValue(encrypted: Uint8Array, key: Buffer, stripHash: boolean): string | null {
const buf = Buffer.from(encrypted);
if (buf.length < 3) return null;

Expand All @@ -200,13 +193,9 @@ function decryptCookieValue(
const iv = Buffer.alloc(16, 0x20);
const decipher = createDecipheriv("aes-128-cbc", key, iv);
decipher.setAutoPadding(false);
const plaintext = Buffer.concat([
decipher.update(ciphertext),
decipher.final(),
]);
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
const unpadded = removePkcs7Padding(plaintext);
const bytes =
stripHash && unpadded.length >= 32 ? unpadded.subarray(32) : unpadded;
const bytes = stripHash && unpadded.length >= 32 ? unpadded.subarray(32) : unpadded;
const decoded = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
// Strip leading control characters (< 0x20)
let i = 0;
Expand All @@ -233,8 +222,7 @@ function readBrowserPassword(
currentPlatform: string,
): Promise<string | null> {
if (currentPlatform === "darwin") {
if (!config.keychainAccount || !config.keychainService)
return Promise.resolve(null);
if (!config.keychainAccount || !config.keychainService) return Promise.resolve(null);
return readKeychainPassword(config.keychainAccount, config.keychainService);
}
if (currentPlatform === "linux") {
Expand All @@ -243,10 +231,7 @@ function readBrowserPassword(
return Promise.resolve(null);
}

function readKeychainPassword(
account: string,
service: string,
): Promise<string | null> {
function readKeychainPassword(account: string, service: string): Promise<string | null> {
return new Promise((resolve) => {
execFile(
"security",
Expand Down Expand Up @@ -300,8 +285,7 @@ async function importSqlite(): Promise<typeof import("node:sqlite") | null> {
// Suppress the ExperimentalWarning for SQLite
const orig = process.emitWarning.bind(process);
process.emitWarning = ((warning: string | Error, ...args: unknown[]) => {
const msg =
typeof warning === "string" ? warning : (warning?.message ?? "");
const msg = typeof warning === "string" ? warning : (warning?.message ?? "");
if (msg.includes("SQLite is an experimental feature")) return;
return (orig as (...a: unknown[]) => void)(warning, ...args);
}) as typeof process.emitWarning;
Expand Down Expand Up @@ -341,9 +325,9 @@ async function readMetaVersion(dbPath: string): Promise<number> {
const db = new sqlite.DatabaseSync(dbPath, opts);

try {
const rows = db
.prepare("SELECT value FROM meta WHERE key = 'version'")
.all() as Array<Record<string, unknown>>;
const rows = db.prepare("SELECT value FROM meta WHERE key = 'version'").all() as Array<
Record<string, unknown>
>;
const val = rows[0]?.value;
if (typeof val === "number") return Math.floor(val);
if (typeof val === "bigint") return Number(val);
Expand Down
45 changes: 30 additions & 15 deletions src/extract/frames.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ function isTimeoutError(err: unknown): boolean {
const name = (err as { name?: string }).name;
const code = (err as { code?: string }).code;
const message = (err as { message?: string }).message ?? "";
return name === "AbortError" || code === "ETIMEDOUT" || message.toLowerCase().includes("timed out");
return (
name === "AbortError" || code === "ETIMEDOUT" || message.toLowerCase().includes("timed out")
);
}

function trimErrorText(text: string, maxLen = 200): string {
Expand Down Expand Up @@ -189,9 +191,8 @@ export async function getYouTubeStreamInfo(
const rawDuration = lines[0]?.trim();
const streamUrl = lines[1]?.trim();
if (!streamUrl) return { error: "yt-dlp failed: missing stream URL" };
const parsedDuration = rawDuration && rawDuration !== "NA"
? Number.parseFloat(rawDuration)
: Number.NaN;
const parsedDuration =
rawDuration && rawDuration !== "NA" ? Number.parseFloat(rawDuration) : Number.NaN;
const duration = Number.isFinite(parsedDuration) ? parsedDuration : null;
return { streamUrl, duration };
} catch (err) {
Expand All @@ -203,9 +204,7 @@ export async function getYouTubeStreamInfo(
* Get the duration of a local video file using ffprobe.
* Runs: ffprobe -v quiet -show_entries format=duration -of csv=p=0 <filePath>
*/
export async function getLocalVideoDuration(
filePath: string,
): Promise<number | { error: string }> {
export async function getLocalVideoDuration(filePath: string): Promise<number | { error: string }> {
try {
const output = execFileSync(
"ffprobe",
Expand Down Expand Up @@ -235,11 +234,16 @@ function extractSingleFrame(source: string, timestampSec: number): Buffer | stri
const buffer = execFileSync(
"ffmpeg",
[
"-ss", String(timestampSec),
"-i", source,
"-frames:v", "1",
"-f", "image2pipe",
"-vcodec", "mjpeg",
"-ss",
String(timestampSec),
"-i",
source,
"-frames:v",
"1",
"-f",
"image2pipe",
"-vcodec",
"mjpeg",
"pipe:1",
],
{
Expand All @@ -258,20 +262,31 @@ function extractSingleFrame(source: string, timestampSec: number): Buffer | stri

/** Shared loop: extract frames from a source, return partial results on failure. */
function collectFrames(
source: string, timestamps: number[], duration: number | null, signal?: AbortSignal,
source: string,
timestamps: number[],
duration: number | null,
signal?: AbortSignal,
): { frames: VideoFrame[]; duration: number | null; error: string | null } {
const frames: VideoFrame[] = [];
let firstError: string | null = null;
for (const t of timestamps) {
if (signal?.aborted) break;
const result = extractSingleFrame(source, t);
if (Buffer.isBuffer(result)) {
frames.push({ data: result.toString("base64"), mimeType: "image/jpeg", timestamp: formatSeconds(t) });
frames.push({
data: result.toString("base64"),
mimeType: "image/jpeg",
timestamp: formatSeconds(t),
});
} else if (!firstError) {
firstError = result;
}
}
return { frames, duration, error: frames.length === 0 ? (firstError ?? "All frames failed to extract") : null };
return {
frames,
duration,
error: frames.length === 0 ? (firstError ?? "All frames failed to extract") : null,
};
}

/**
Expand Down
13 changes: 3 additions & 10 deletions src/extract/gemini-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,7 @@ function isCloudflareGateway(): boolean {
}
}

function withTimeout(
signal: AbortSignal | undefined,
timeoutMs: number,
): AbortSignal {
function withTimeout(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal {
const timeout = AbortSignal.timeout(timeoutMs);
return signal ? AbortSignal.any([signal, timeout]) : timeout;
}
Expand Down Expand Up @@ -150,9 +147,7 @@ export function isGeminiApiAvailable(): boolean {
function buildAuthHeaders(): Record<string, string> {
if (!isCloudflareGateway()) return {};
const cloudflareApiKey = getCloudflareApiKey();
return cloudflareApiKey
? { "cf-aig-authorization": `Bearer ${cloudflareApiKey}` }
: {};
return cloudflareApiKey ? { "cf-aig-authorization": `Bearer ${cloudflareApiKey}` } : {};
}

/**
Expand Down Expand Up @@ -215,9 +210,7 @@ export async function queryGeminiApi(
if (!res.ok) {
activityMonitor.logError(entryId, `HTTP ${res.status}`);
const errorText = await res.text();
throw new Error(
`Gemini API error ${res.status}: ${errorText.slice(0, 300)}`,
);
throw new Error(`Gemini API error ${res.status}: ${errorText.slice(0, 300)}`);
} else {
activityMonitor.logComplete(entryId, res.status);
}
Expand Down
9 changes: 5 additions & 4 deletions src/extract/gemini-url-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,11 @@ export async function extractWithUrlContext(
}
}

const text = data.candidates?.[0]?.content?.parts
?.map((p) => p.text)
.filter(Boolean)
.join("\n") ?? "";
const text =
data.candidates?.[0]?.content?.parts
?.map((p) => p.text)
.filter(Boolean)
.join("\n") ?? "";

if (!text || text.length < MIN_CONTENT_LENGTH) return null;

Expand Down
41 changes: 9 additions & 32 deletions src/extract/gemini-web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,7 @@ function isBrowserCookieAccessAllowed(): boolean {
* and extracting valid Google cookies from a local browser.
* Returns the cookie map if available, null otherwise.
*/
export async function isGeminiWebAvailable(
chromeProfile?: string,
): Promise<CookieMap | null> {
export async function isGeminiWebAvailable(chromeProfile?: string): Promise<CookieMap | null> {
if (!isBrowserCookieAccessAllowed()) return null;

let profile = chromeProfile?.trim() || undefined;
Expand Down Expand Up @@ -103,10 +101,7 @@ export async function queryWithCookies(
cookieMap: CookieMap,
options: GeminiWebOptions = {},
): Promise<string> {
const model =
options.model && MODEL_HEADERS[options.model]
? options.model
: DEFAULT_MODEL;
const model = options.model && MODEL_HEADERS[options.model] ? options.model : DEFAULT_MODEL;
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;

let fullPrompt = prompt;
Expand Down Expand Up @@ -134,8 +129,7 @@ export async function queryWithCookies(
options.signal,
);
if (fallback.errorMessage) throw new Error(fallback.errorMessage);
if (!fallback.text)
throw new Error("Gemini Web returned empty response (fallback model)");
if (!fallback.text) throw new Error("Gemini Web returned empty response (fallback model)");
return fallback.text;
}

Expand Down Expand Up @@ -226,16 +220,8 @@ async function runGeminiWebOnce(
* Fetch the access token (SNlM0e or thykhd) from the Gemini app page.
* Manually follows redirects to preserve cookies across auth bounces.
*/
async function fetchAccessToken(
cookieHeader: string,
signal: AbortSignal,
): Promise<string> {
const html = await fetchWithCookieRedirects(
GEMINI_APP_URL,
cookieHeader,
MAX_REDIRECTS,
signal,
);
async function fetchAccessToken(cookieHeader: string, signal: AbortSignal): Promise<string> {
const html = await fetchWithCookieRedirects(GEMINI_APP_URL, cookieHeader, MAX_REDIRECTS, signal);

for (const key of ["SNlM0e", "thykhd"]) {
const match = html.match(new RegExp(`"${key}":"(.*?)"`));
Expand Down Expand Up @@ -314,9 +300,7 @@ async function uploadFile(

if (!res.ok) {
const text = await res.text();
throw new Error(
`File upload failed: ${res.status} (${text.slice(0, 200)})`,
);
throw new Error(`File upload failed: ${res.status} (${text.slice(0, 200)})`);
}

return { id: await res.text(), name: fileName };
Expand All @@ -330,14 +314,9 @@ async function uploadFile(
* Build the fReq payload for BardChatUi StreamGenerate.
* Format: JSON.stringify([null, JSON.stringify(innerList)])
*/
function buildFReqPayload(
prompt: string,
uploaded: Array<{ id: string; name: string }>,
): string {
function buildFReqPayload(prompt: string, uploaded: Array<{ id: string; name: string }>): string {
const promptPayload =
uploaded.length > 0
? [prompt, 0, null, uploaded.map((file) => [[file.id, 1]])]
: [prompt];
uploaded.length > 0 ? [prompt, 0, null, uploaded.map((file) => [[file.id, 1]])] : [prompt];
const innerList = [promptPayload, null, null];
return JSON.stringify([null, JSON.stringify(innerList)]);
}
Expand Down Expand Up @@ -376,9 +355,7 @@ function parseStreamGenerateResponse(rawText: string): GeminiWebResult {
}

const text =
latestNonEmptyText.length > 0
? latestNonEmptyText
: extractCandidateText(firstCandidateSeen);
latestNonEmptyText.length > 0 ? latestNonEmptyText : extractCandidateText(firstCandidateSeen);

return { text, errorCode };
}
Expand Down
Loading