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
61 changes: 59 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,43 @@ concurrency:
cancel-in-progress: true

jobs:
secrets:
name: Secrets scan
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0

- name: Gitleaks
uses: docker://ghcr.io/gitleaks/gitleaks:v8.30.1
with:
args: git --redact --no-banner

dependency-audit:
name: Dependency audit
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v6

- name: Scan lockfiles with OSV-Scanner
continue-on-error: true
uses: google/osv-scanner-action/osv-scanner-action@v2.3.8
with:
scan-args: |-
scan
source
--recursive
--all-packages
--format=json
--output-file=/github/workspace/osv-results.json
./

- name: Enforce high and critical severity gate
run: node scripts/check-osv-severity.mjs osv-results.json bun.lock src-tauri/Cargo.lock

check:
name: Check and build
runs-on: ubuntu-24.04
Expand Down Expand Up @@ -92,12 +129,15 @@ jobs:
run: bun run build

- name: Rust tests
run: cargo test --manifest-path src-tauri/Cargo.toml --locked --all-targets
run: cargo test --manifest-path src-tauri/Cargo.toml --workspace --locked --all-targets

- name: Rust clippy
run: cargo clippy --manifest-path src-tauri/Cargo.toml --workspace --locked --all-targets -- -D warnings

- name: Rust coverage
run: |
mkdir -p src-tauri/target/llvm-cov
cargo llvm-cov --manifest-path src-tauri/Cargo.toml --locked --all-targets --fail-under-lines 80 --html --output-dir src-tauri/target/llvm-cov/html
cargo llvm-cov --manifest-path src-tauri/Cargo.toml --workspace --locked --all-targets --fail-under-lines 80 --html --output-dir src-tauri/target/llvm-cov/html
cargo llvm-cov report --manifest-path src-tauri/Cargo.toml --lcov --output-path src-tauri/target/llvm-cov/lcov.info

- name: Summarize Rust coverage
Expand Down Expand Up @@ -126,3 +166,20 @@ jobs:
path: |
src-tauri/target/llvm-cov/lcov.info
src-tauri/target/llvm-cov/html/

gate:
name: CI gate
if: always()
needs: [check, dependency-audit, secrets]
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Require every blocking job
env:
CHECK_RESULT: ${{ needs.check.result }}
DEPENDENCY_AUDIT_RESULT: ${{ needs.dependency-audit.result }}
SECRETS_RESULT: ${{ needs.secrets.result }}
run: |
test "$CHECK_RESULT" = success
test "$DEPENDENCY_AUDIT_RESULT" = success
test "$SECRETS_RESULT" = success
244 changes: 123 additions & 121 deletions bun.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions clippy.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
cognitive-complexity-threshold = 15
2 changes: 2 additions & 0 deletions docs/releases/UNRELEASED.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ reset this file.

## Internal/release changes

- Added blocking CI gates for Rust and frontend complexity, coverage, secrets,
and high/critical dependency advisories, with one required aggregate status.
- Headless `usage --json` refreshes independent providers concurrently
(join-all, emit in fixed service order) so an offline credentialed install
pays ~max per-provider timeout instead of the sequential sum.
Expand Down
16 changes: 16 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,25 @@ export default defineConfig(
},
{
rules: {
complexity: ["error", 15],
"max-depth": ["error", 4],
"max-lines-per-function": [
"error",
{ max: 100, skipBlankLines: true, skipComments: true },
],
"no-empty": ["error", { allowEmptyCatch: true }],
},
},
{
files: [
"**/*.{test,spec}.{js,mjs,ts,svelte}",
"**/*.node-test.mjs",
"tests/**/*.{js,mjs,ts,svelte}",
],
rules: {
"max-lines-per-function": "off",
},
},
{
files: ["**/*.svelte"],
languageOptions: {
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"prepare:sidecar": "node scripts/prepare-playwright-sidecar.mjs",
"test:installer": "node tests/install-script-smoke.mjs",
"test:headless-appimage-validator": "node tests/headless-appimage-validator-smoke.mjs",
"test": "node tests/install-script-smoke.mjs && node tests/headless-appimage-validator-smoke.mjs && vitest run && node --test sidecars/playwright/*.node-test.mjs && node scripts/prepare-playwright-sidecar.mjs --check && node scripts/validate-playwright-sidecar-package.mjs",
"test": "node tests/install-script-smoke.mjs && node tests/headless-appimage-validator-smoke.mjs && node --test tests/*.node-test.mjs && vitest run && node --test sidecars/playwright/*.node-test.mjs && node scripts/prepare-playwright-sidecar.mjs --check && node scripts/validate-playwright-sidecar-package.mjs",
"test:coverage": "vitest run --coverage",
"test:browser-preview": "node scripts/validate-browser-preview.mjs",
"test:auth-profile-helper": "node scripts/validate-playwright-auth-profile-helper.mjs",
Expand Down Expand Up @@ -49,6 +49,6 @@
"typescript": "^5.9.3",
"typescript-eslint": "^8.60.1",
"vite": "^7.2.4",
"vitest": "^4.1.8"
"vitest": "4.1.8"
}
}
91 changes: 91 additions & 0 deletions scripts/check-osv-severity.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
#!/usr/bin/env node

import { readFileSync } from "node:fs";
import { resolve } from "node:path";

const [reportPath, ...expectedLockfiles] = process.argv.slice(2);

if (!reportPath || expectedLockfiles.length === 0) {
throw new Error("Usage: check-osv-severity <report.json> <lockfile>...");
}

const report = JSON.parse(readFileSync(resolve(reportPath), "utf8"));
if (!Array.isArray(report.results)) {
throw new Error("OSV report is missing its results array");
}

const scannedPaths = report.results.map((result) => result.source?.path).filter(Boolean);
for (const lockfile of expectedLockfiles) {
const suffix = `/${lockfile}`;
if (!scannedPaths.some((path) => path === lockfile || path.endsWith(suffix))) {
throw new Error(`OSV report is missing lockfile: ${lockfile}`);
}
}

const vulnerabilitiesById = new Map();
for (const result of report.results) {
for (const entry of result.packages ?? []) {
for (const vulnerability of entry.vulnerabilities ?? []) {
if (vulnerability.id) {
vulnerabilitiesById.set(vulnerability.id, vulnerability);
}
}
}
}

const findings = [];
const informationalFindings = [];
for (const result of report.results) {
for (const entry of result.packages ?? []) {
for (const group of entry.groups ?? []) {
const rawSeverity = group.max_severity;
const severityText = String(rawSeverity ?? "").trim();
const severity = Number(severityText);
const ids = Array.isArray(group.ids) ? group.ids : [];
const isUnscored = ids.length > 0 && (severityText === "" || !Number.isFinite(severity));
const isInformational =
isUnscored &&
ids.every((id) => {
const vulnerability = vulnerabilitiesById.get(id);
return Boolean(
vulnerability &&
(vulnerability.database_specific?.informational ||
vulnerability.affected?.some(
(affected) => affected.database_specific?.informational,
) ||
vulnerability.withdrawn),
);
});
const finding = {
ids: ids.join(", ") || "unknown advisory",
package: `${entry.package?.name ?? "unknown"}@${entry.package?.version ?? "unknown"}`,
rawSeverity,
source: result.source?.path ?? "unknown source",
};

if (isInformational) {
informationalFindings.push(finding);
} else if (isUnscored || (Number.isFinite(severity) && severity >= 7)) {
findings.push(finding);
}
}
}
}

console.log(
`OSV scanned ${expectedLockfiles.length} lockfiles; high/critical findings: ${findings.length}`,
);
for (const finding of informationalFindings) {
console.log(
`Skipped informational ${finding.ids}: ${finding.package} in ${finding.source}`,
);
}
for (const finding of findings) {
console.error(
`${finding.ids}: ${finding.package} (raw max_severity ${JSON.stringify(finding.rawSeverity)}) in ${finding.source}`,
);
}

if (findings.length > 0) {
process.exitCode = 1;
}
2 changes: 2 additions & 0 deletions scripts/validate-playwright-authenticated-profile.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,8 @@ function assertProfileStorageSafety(
}
}

// TODO(#69): split option parsing and validation.
// eslint-disable-next-line complexity -- Legacy CLI parser exceeds the enforced cap.
function parseOptions(args) {
const profileRoots = new Map();
const options = {
Expand Down
2 changes: 2 additions & 0 deletions scripts/validate-playwright-official-fail-closed.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,8 @@ if (validationRootCleanupFailed) {
throw new Error("Temporary official fail-closed validation root must be removed");
}

// TODO(#69): split scenario setup from response validation.
// eslint-disable-next-line complexity -- Legacy smoke helper exceeds the enforced cap.
async function validateHeadlessRefresh({
args = launchArgs,
expectedPageState = null,
Expand Down
2 changes: 2 additions & 0 deletions sidecars/playwright/pickgauge-playwright-sidecar.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ const allowedServices = new Set(["codex", "claude"]);
const allowedActions = new Set(["launchLogin", "refreshUsage"]);
const navigationTimeoutMs = 30_000;

// TODO(#69): split request validation by protocol action.
// eslint-disable-next-line complexity -- Legacy protocol validator exceeds the enforced cap.
export function validateLaunchRequest(input) {
if (!input || typeof input !== "object" || Array.isArray(input)) {
return rejected("invalid_request");
Expand Down
16 changes: 8 additions & 8 deletions src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -53,5 +53,9 @@ windows-sys = { version = "0.61", features = [
"Win32_System_Threading",
] }

[lints.clippy]
cognitive_complexity = "deny"
too_many_lines = "deny"

[profile.release]
debug = "line-tables-only"
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ const allowedServices = new Set(["codex", "claude"]);
const allowedActions = new Set(["launchLogin", "refreshUsage"]);
const navigationTimeoutMs = 30_000;

// TODO(#69): split request validation by protocol action.
// eslint-disable-next-line complexity -- Legacy protocol validator exceeds the enforced cap.
export function validateLaunchRequest(input) {
if (!input || typeof input !== "object" || Array.isArray(input)) {
return rejected("invalid_request");
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/src/browser_profile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ fn canonicalize_existing_path(path: &Path) -> Result<PathBuf, String> {
fn reject_overlapping_profile_paths(paths: &BrowserProfilePaths) -> Result<(), String> {
let service_paths = [&paths.codex, &paths.claude];

if service_paths.iter().any(|path| &paths.root == *path) {
if service_paths.contains(&&paths.root) {
return Err("Browser profile root must not be a service profile path".to_string());
}

Expand Down
4 changes: 4 additions & 0 deletions src-tauri/src/browser_session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1859,6 +1859,8 @@ mod tests {
}

#[test]
// TODO(#69): split protocol-shape assertions into focused helpers.
#[allow(clippy::cognitive_complexity)]
fn playwright_sidecar_launch_request_serializes_to_protocol_shape() {
let profile_path = "/tmp/pickgauge/browser-profiles/codex";
let plan = chromium_launch_plan(Service::Codex, profile_path);
Expand Down Expand Up @@ -1918,6 +1920,8 @@ mod tests {
}

#[test]
// TODO(#69): split protocol-shape assertions into focused helpers.
#[allow(clippy::cognitive_complexity)]
fn playwright_sidecar_refresh_request_serializes_to_headless_protocol_shape() {
let profile_path = "/tmp/pickgauge/browser-profiles/claude";
let plan = chromium_launch_plan(Service::Claude, profile_path);
Expand Down
14 changes: 8 additions & 6 deletions src-tauri/src/cli_provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@
//! call.
//!
//! Endpoints/clients discovered from the shipped CLI binaries:
//! - Codex refresh: POST https://auth.openai.com/oauth/token (client app_EMoamEEZ73f0CkXaXp7hrann)
//! usage: GET https://chatgpt.com/backend-api/codex/usage
//! - Claude refresh: POST https://platform.claude.com/v1/oauth/token (client 9d1c250a-…)
//! usage: GET https://api.anthropic.com/api/oauth/usage
//! - Grok usage: GET https://grok.com/rest/subscriptions
//! (bearer from ~/.grok/auth.json; never refreshed or written back)
//! - Codex refresh: POST https://auth.openai.com/oauth/token (client app_EMoamEEZ73f0CkXaXp7hrann)
//! usage: GET https://chatgpt.com/backend-api/codex/usage
//! - Claude refresh: POST https://platform.claude.com/v1/oauth/token (client 9d1c250a-…)
//! usage: GET https://api.anthropic.com/api/oauth/usage
//! - Grok usage: GET https://grok.com/rest/subscriptions
//! (bearer from ~/.grok/auth.json; never refreshed or written back)
use std::{
collections::HashMap,
path::{Path, PathBuf},
Expand Down Expand Up @@ -917,6 +917,8 @@ mod tests {
}

#[test]
// TODO(#69): split subscription selection assertions by source.
#[allow(clippy::cognitive_complexity)]
fn prefers_active_grok_subscription_over_active_x_tier() {
let body: Value = serde_json::from_str(GROK_SUBSCRIPTIONS_FIXTURE).unwrap();
let snapshot = parse_grok_body(&body, "2026-07-09T20:00:00Z").unwrap();
Expand Down
Loading