Skip to content
Open
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
15 changes: 15 additions & 0 deletions crates/aft/src/commands/configure.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use std::fs;
use std::panic::{catch_unwind, AssertUnwindSafe};
use std::path::{Component, Path, PathBuf};
use std::process::Command;
Expand Down Expand Up @@ -1207,6 +1208,7 @@ pub fn handle_configure(req: &RawRequest, ctx: &AppContext) -> Response {
Ok(path) => Some(path),
Err(error) => return Response::error(&req.id, "invalid_request", error),
};

}
if let Some(raw) = params.get("url_fetch_allow_private") {
let Some(value) = raw.as_bool() else {
Expand Down Expand Up @@ -1356,6 +1358,19 @@ pub fn handle_configure(req: &RawRequest, ctx: &AppContext) -> Response {
&canonical_cache_root,
));
if let Some(storage_dir) = next_config.storage_dir.clone() {
// Ensure the storage root directory exists so subsystems (trust,
// backups, checkpoints, DB, persistence) can create their sub-trees
// without a separate create_dir_all per subsystem. On fresh installs
// this directory hasn't been created yet, and every subsystem
// currently creates its own subdirectory lazily — but the root must
// exist for status/diagnostics to report a valid path.
if let Err(err) = fs::create_dir_all(&storage_dir) {
slog_warn!(
"failed to create storage directory {}: {}",
storage_dir.display(),
err
);
}
ctx.backup().borrow_mut().set_storage_dir_for_harness(
storage_dir,
harness,
Expand Down
42 changes: 39 additions & 3 deletions crates/aft/src/semantic_index.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ const F32_BYTES: usize = std::mem::size_of::<f32>();
const HEADER_BYTES_V1: usize = 9;
const HEADER_BYTES_V2: usize = 13;
const ONNX_RUNTIME_INSTALL_HINT: &str =
"ONNX Runtime not found. Install via: brew install onnxruntime (macOS) or apt install libonnxruntime (Linux).";
"ONNX Runtime not found. Install via: brew install onnxruntime (macOS), \
apt install libonnxruntime (Linux), or place onnxruntime.dll in your PATH (Windows). \
AFT can auto-download ONNX Runtime — run `npx @cortexkit/aft doctor` to diagnose.";

const SEMANTIC_INDEX_VERSION_V1: u8 = 1;
const SEMANTIC_INDEX_VERSION_V2: u8 = 2;
Expand Down Expand Up @@ -788,8 +790,42 @@ pub fn pre_validate_onnx_runtime() -> Result<(), String> {

#[cfg(target_os = "windows")]
{
// On Windows, skip pre-validation — let ort handle LoadLibrary
let _ = dylib_path;
// Validate ONNX Runtime availability on Windows by loading the DLL
// via LoadLibraryExW before the ort crate attempts its own LoadLibrary.
// This way we can produce a friendly error (with installation hints)
// instead of a raw LoadLibrary failure from deep inside fastembed.
let lib_name = dylib_path.as_deref().unwrap_or("onnxruntime.dll");

// Use kernel32 LoadLibraryExW for the validation — built-in, no
// crate dependency required.
#[link(name = "kernel32")]
extern "system" {
fn LoadLibraryExW(
lpLibFileName: *const u16,
hFile: *mut std::ffi::c_void,
dwFlags: u32,
) -> *mut std::ffi::c_void;
fn FreeLibrary(hLibModule: *mut std::ffi::c_void) -> i32;
}

unsafe {
use std::os::windows::ffi::OsStrExt;
let wide: Vec<u16> = std::ffi::OsStr::new(lib_name)
.encode_wide()
.chain(std::iter::once(0))
.collect();

let handle = LoadLibraryExW(wide.as_ptr(), std::ptr::null_mut(), 0);
if handle.is_null() {
let err = std::io::Error::last_os_error();
return Err(format!(
"ONNX Runtime not found. LoadLibraryExW('{}') failed: {}. \
Run `npx @cortexkit/aft doctor` to diagnose.",
lib_name, err
));
}
FreeLibrary(handle);
}
}

Ok(())
Expand Down
9 changes: 8 additions & 1 deletion packages/aft-bridge/src/downloader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,14 @@ export async function downloadBinary(version?: string): Promise<string | null> {
chmodSync(tmpPath, 0o755);
}

// Atomic rename
// Atomic rename — unlink first on Windows where renameSync fails if target exists
if (process.platform === "win32" && existsSync(binaryPath)) {
try {
unlinkSync(binaryPath);
} catch {
// best-effort; renameSync will surface the error if unlink fails
}
}
renameSync(tmpPath, binaryPath);

log(`AFT binary ready at ${binaryPath}`);
Expand Down
89 changes: 77 additions & 12 deletions packages/aft-bridge/src/onnx-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -430,22 +430,87 @@ function findSystemOnnxRuntime(libName?: string): string | null {
"/usr/lib/aarch64-linux-gnu",
"/usr/local/lib",
);
} else if (process.platform === "win32") {
// Common Windows install locations for ONNX Runtime
const programFiles = process.env.ProgramFiles ?? "C:\\Program Files";
const programFilesX86 = process.env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)";
searchPaths.push(
join(programFiles, "onnxruntime", "lib"),
join(programFiles, "Microsoft ONNX Runtime", "lib"),
join(programFiles, "Microsoft Machine Learning", "lib"),
join(programFilesX86, "onnxruntime", "lib"),
// Windows NuGet package layout:
// <user>\.nuget\packages\microsoft.ml.onnxruntime\<version>\runtimes\win-{x64,arm64}\native\
// Scan all installed versions since we don't know which one is present.
...(() => {
const nugetPaths: string[] = [];
const userProfile = process.env.USERPROFILE ?? "";
if (!userProfile) return nugetPaths;
const nugetPackageDir = join(userProfile, ".nuget", "packages", "microsoft.ml.onnxruntime");
if (!existsSync(nugetPackageDir)) return nugetPaths;
try {
for (const entry of readdirSync(nugetPackageDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
// Skip well-known non-version entries
if (entry.name === "__globalPackagesFolder" || entry.name.startsWith(".")) continue;
nugetPaths.push(
join(nugetPackageDir, entry.name, "runtimes", "win-x64", "native"),
join(nugetPackageDir, entry.name, "runtimes", "win-arm64", "native"),
);
}
} catch {
// best-effort scan
}
return nugetPaths;
})(),
);
// Also search PATH directories for onnxruntime.dll
const pathEnv = process.env.PATH ?? "";
for (const dir of pathEnv.split(";")) {
const trimmed = dir.trim();
if (!trimmed) continue;
searchPaths.push(trimmed);
}
}

for (const dir of searchPaths) {
// Deduplicate paths while preserving order.
// On case-insensitive filesystems (Windows, macOS) normalize casing for
// comparison; on Linux the raw path casing is the authority.
const normalizeCase = process.platform === "win32" || process.platform === "darwin";
const seen = new Set<string>();
const uniquePaths = searchPaths.filter((p) => {
let key = resolve(p).replace(/[/\\]+$/, "");
if (normalizeCase) key = key.toLowerCase();
if (seen.has(key)) return false;
seen.add(key);
return true;
});

for (const dir of uniquePaths) {
if (!existsSync(join(dir, libName))) continue;

// Reject system installs that the Rust pre-validator will refuse. Without
// this filter, a stale distro package (e.g. libonnxruntime1.9 on Ubuntu
// 22.04) shadows our auto-downloaded v1.24 forever and semantic search
// stays "failed" until the user hand-deletes the system library.
const version = detectOnnxVersion(dir, libName);
if (version && !isOnnxVersionCompatible(version)) {
warn(
`Skipping system ONNX Runtime at ${dir} (v${version}); AFT requires ` +
`v${REQUIRED_ORT_MAJOR}.${REQUIRED_ORT_MIN_MINOR}+. Falling through to AFT-managed download.`,
);
continue;
// Skip the version check for PATH entries — version-suffixed filenames
// are less common on Windows and we want PATH discovery to succeed.
let skipVersionCheck = false;
if (process.platform === "win32") {
// Only do version check for common install paths, not PATH entries
const isCommonPath = dir.includes("Program Files") || dir.includes("onnxruntime");
if (!isCommonPath) skipVersionCheck = true;
}

if (!skipVersionCheck) {
// Reject system installs that the Rust pre-validator will refuse. Without
// this filter, a stale distro package (e.g. libonnxruntime1.9 on Ubuntu
// 22.04) shadows our auto-downloaded v1.24 forever and semantic search
// stays "failed" until the user hand-deletes the system library.
const version = detectOnnxVersion(dir, libName);
if (version && !isOnnxVersionCompatible(version)) {
warn(
`Skipping system ONNX Runtime at ${dir} (v${version}); AFT requires ` +
`v${REQUIRED_ORT_MAJOR}.${REQUIRED_ORT_MIN_MINOR}+. Falling through to AFT-managed download.`,
);
continue;
}
}

return dir;
Expand Down
120 changes: 112 additions & 8 deletions packages/aft-bridge/src/resolver.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { execSync } from "node:child_process";
import { chmodSync, copyFileSync, existsSync, mkdirSync, renameSync } from "node:fs";
import { execSync, spawnSync } from "node:child_process";
import { chmodSync, copyFileSync, existsSync, mkdirSync, readdirSync, renameSync, unlinkSync } from "node:fs";
import { createRequire } from "node:module";
import { homedir } from "node:os";
import { join } from "node:path";
Expand Down Expand Up @@ -55,6 +55,14 @@ function copyToVersionedCache(npmBinaryPath: string, knownVersion?: string): str
if (process.platform !== "win32") {
chmodSync(tmpPath, 0o755);
}
// Atomic rename — unlink first on Windows where renameSync fails if target exists
if (process.platform === "win32" && existsSync(cachedPath)) {
try {
unlinkSync(cachedPath);
} catch {
// best-effort; renameSync will surface the error if unlink fails
}
}
renameSync(tmpPath, cachedPath);
log(`Copied npm binary to versioned cache: ${cachedPath}`);
return cachedPath;
Expand All @@ -68,6 +76,22 @@ function normalizeBareVersion(version: string): string {
return version.startsWith("v") ? version.slice(1) : version;
}

/**
* Compare two semver strings. Returns >0 if a > b, <0 if a < b, 0 if equal.
* Both versions must be bare (no leading "v").
*/
function compareSemver(a: string, b: string): number {
const aParts = a.split(".").map(Number);
const bParts = b.split(".").map(Number);
for (let i = 0; i < 3; i++) {
const av = aParts[i] ?? 0;
const bv = bParts[i] ?? 0;
if (av > bv) return 1;
if (av < bv) return -1;
}
return 0;
}

function homeDirFromEnv(env: ResolverEnv): string {
return (process.platform === "win32" ? env.USERPROFILE || env.HOME : env.HOME) || homedir();
}
Expand Down Expand Up @@ -123,6 +147,60 @@ function parsePathLookupOutput(output: string): string[] {
.filter(Boolean);
}

/**
* Scan the versioned cache directory for the highest available binary version.
*
* Iterates over versioned cache entries (e.g. `<cacheDir>/v0.30.0/aft`), reads each binary's version,
* and returns the one with the highest semantic version. This is the fallback
* when the exact plugin version isn't cached but a newer version was downloaded
* by `aft doctor --fix` or a previous `ensureBinary` call.
*
* @returns The path and version of the highest-versioned binary, or null.
*/
function findHighestCachedVersion(
cacheDir: string,
ext: string,
): { path: string; version: string } | null {
try {
const entries = readdirSync(cacheDir, { withFileTypes: true });
let best: { path: string; version: string } | null = null;

for (const entry of entries) {
if (!entry.isDirectory()) continue;
const tag = entry.name;
if (!tag.startsWith("v")) continue;

const binaryPath = join(cacheDir, tag, `aft${ext}`);
if (!existsSync(binaryPath)) continue;

const binaryVersion = readBinaryVersion(binaryPath);
if (binaryVersion === null) continue;

if (best === null) {
best = { path: binaryPath, version: binaryVersion };
} else {
// Compare semver strings
const current = best.version.split(".").map(Number);
const candidate = binaryVersion.split(".").map(Number);
let isHigher = false;
for (let i = 0; i < 3; i++) {
const a = current[i] ?? 0;
const b = candidate[i] ?? 0;
if (b > a) { isHigher = true; break; }
if (b < a) break;
}
if (isHigher) {
best = { path: binaryPath, version: binaryVersion };
}
}
}

return best;
} catch {
return null;
}
}

/**
* Map the current `process.platform` and `process.arch` to the npm platform
* package suffix (e.g. `"darwin-arm64"`, `"linux-x64"`).
Expand Down Expand Up @@ -157,6 +235,10 @@ export function platformKey(
/**
* Locate the `aft` binary synchronously by checking (in order):
* 1. Cached binary from previous auto-download (~/.cache/aft/bin/)
* a. First, check for the **exact** requested version in cache.
* b. Then, scan ALL cached versions and return the highest one (handles
* the case where `aft doctor --fix` downloaded a newer version but the
* plugin still resolves by its own older version).
* 2. npm platform package via `require.resolve(@cortexkit/aft-<platform>/bin/aft)`
* 3. PATH lookup via `which aft` (or `where aft` on Windows)
* 4. ~/.cargo/bin/aft (Rust cargo install location)
Expand All @@ -171,9 +253,6 @@ export function findBinarySync(expectedVersion?: string): string | null {
const ext = process.platform === "win32" ? ".exe" : "";
const env = { ...process.env };

// 1. Check versioned cache for the requested version (or this package's own
// version as a fallback so direct callers without a host still benefit from
// the cache).
const pluginVersion =
expectedVersion ??
(() => {
Expand All @@ -184,10 +263,35 @@ export function findBinarySync(expectedVersion?: string): string | null {
return null;
}
})();

// 1. Check versioned cache — first exact version, then highest fallback.
if (pluginVersion) {
const tag = pluginVersion.startsWith("v") ? pluginVersion : `v${pluginVersion}`;
const versionCached = cachedBinaryPathFromEnv(tag, env, ext);
if (versionCached && isExpectedCachedBinary(versionCached, pluginVersion)) return versionCached;

// 1a. Exact version in cache.
const exactCached = cachedBinaryPathFromEnv(tag, env, ext);
if (exactCached && isExpectedCachedBinary(exactCached, pluginVersion)) return exactCached;

// 1b. Scan ALL cached versions for the highest available one. This handles
// the case where `aft doctor --fix` downloaded v0.30.3 but the plugin
// resolves by its own older version (v0.29.1). Preferring the newer binary
// is safe because the protocol is backwards compatible.
const cacheDir = cacheDirFromEnv(env);
const highestCached = findHighestCachedVersion(cacheDir, ext);
if (highestCached) {
const highestBare = normalizeBareVersion(highestCached.version);
// Only use the highest cached version if it's actually newer than the
// plugin version. A simple inequality check is not enough — the highest
// version in the cache could still be lower than the plugin's version
// (e.g. cache has v0.28.0 but plugin is v0.30.0). We never downgrade.
const pluginBare = normalizeBareVersion(pluginVersion);
if (compareSemver(highestBare, pluginBare) > 0) {
log(
`Using highest cached version ${highestCached.version} (plugin requested ${pluginVersion}) at ${highestCached.path}`,
);
return highestCached.path;
}
}
}

// 2. Check npm platform package — copy to versioned cache to avoid
Expand Down Expand Up @@ -257,7 +361,7 @@ export const __test__ = {
* Locate the `aft` binary, with auto-download as a last resort.
*
* Resolution order:
* 1. Cached binary (~/.cache/aft/bin/)
* 1. Cached binary (~/.cache/aft/bin/) — exact version, then highest available
* 2. npm platform package (@cortexkit/aft-<platform>)
* 3. PATH lookup (which aft)
* 4. ~/.cargo/bin/aft
Expand Down
13 changes: 11 additions & 2 deletions packages/aft-cli/src/adapters/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,8 +219,17 @@ export class OpenCodeAdapter implements HarnessAdapter {
}

getStorageDir(): string {
const xdg = process.env.XDG_DATA_HOME || join(homedir(), ".local", "share");
return join(xdg, "opencode", "storage", "plugin", "aft");
// Use the same CortexKit storage root as the AFT binary and migration code.
// The legacy path (~/.local/share/opencode/storage/plugin/aft on Unix)
// is only relevant for pre-v0.27 upgrades — after migration, data lives
// under the CortexKit root regardless of platform.
if (process.platform === "win32") {
const localAppData =
process.env.LOCALAPPDATA || join(homedir(), "AppData", "Local");
return join(localAppData, "cortexkit", "aft");
}
const xdgData = process.env.XDG_DATA_HOME || join(homedir(), ".local", "share");
return join(xdgData, "cortexkit", "aft");
}

getLogFile(): string {
Expand Down
Loading
Loading