diff --git a/AGENTS.md b/AGENTS.md index d8e5ffeca..e29cce89a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -161,7 +161,7 @@ The `#/` alias is context-sensitive: a custom Vite plugin (`createPathAliasPlugi - Secrets: use `.env` (see `.env.example`); never commit keys. - Toolchains: Bun 1.4.0. Windows: enable Developer Mode for symlinks. - Build: Vite 8 with Rolldown; `vite-plugin-electron` multi-env for main/preload/renderer. -- Runtimes: bundled Bun, ripgrep, uv, rtk in `runtime/` — installed via `bun run installRuntime`. +- Runtimes: uv and ripgrep seeds in `runtime/` — installed via `bun run installRuntime`. Node, uv, and ripgrep resolve at runtime through the daemon's managed toolchain service (see `docs/features/managed-toolchains`). ## Specification-Driven Development diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6b193a3f4..a925e73dd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -119,7 +119,7 @@ Argos is a Turborepo monorepo. The desktop app is an Electron **shell** that loa - `packages/shared/` (`@argos/shared`): Shared types and utilities (web-safe). - `packages/backend-core/`, `packages/{acp,mcp,skills,memory,remote-control}-runtime/`, `packages/agent-runtime/`, `packages/pi-orchestrator-extension/`: Shared backend logic and host-port-injected runtimes. - `apps/landing/`: Marketing site + GitHub OAuth relay (Cloudflare Worker). -- `runtime/`: Bundled runtimes used by MCP and agent tooling (Bun/uv/ripgrep/rtk) — installed via `bun run installRuntime`. +- `runtime/`: Bundled runtime seeds used by MCP and agent tooling (uv/ripgrep) - installed via `bun run installRuntime`. Node/uv/ripgrep used by the daemon resolve through the managed toolchain service (`apps/daemon/src/host/toolchains/`). - `scripts/`, `resources/`, `build/`: Build, packaging, and asset pipelines. - `dist/`, `out/`: Build outputs (do not edit manually). - `docs/`: Design docs, guides, and the SDD spec/plan/task records. diff --git a/apps/daemon/src/dispatch/daemonDispatcher.ts b/apps/daemon/src/dispatch/daemonDispatcher.ts index 2551fafd5..78522a839 100644 --- a/apps/daemon/src/dispatch/daemonDispatcher.ts +++ b/apps/daemon/src/dispatch/daemonDispatcher.ts @@ -31,6 +31,7 @@ import { resolveDaemonVersion } from "../version"; import type { DaemonTerminalRuntime } from "../terminal/daemonTerminalRuntime"; import { diagnoseDaemonSchema, repairDaemonSchema } from "../host/daemonSchemaDiagnostics"; import { settleSessionForOwnershipChange, type SettleSessionHost } from "../host/sessionSettlement"; +import type { ToolchainService } from "../host/toolchains/service"; import { getPiToolDefinitions } from "../host/piToolCatalog"; import { aggregateUsageStats, resolveBuiltinModelPrice } from "../host/usageStatsAggregator"; import { resolveModelCost } from "../host/modelCost"; @@ -48,6 +49,11 @@ import { onboardingSetStepStatusRoute, onboardingCompleteRoute, onboardingResetRoute, + toolchainsListRoute, + toolchainsSetSourceRoute, + toolchainsRemoveSourceRoute, + toolchainsInstallRoute, + toolchainsCancelInstallRoute, settingsGetSnapshotRoute, settingsUpdateRoute, settingsActivityListRoute, @@ -935,6 +941,7 @@ export function createDaemonDispatcher( }, knowledgeRuntime?: DaemonKnowledgeRuntimePort, terminalRuntime?: DaemonTerminalRuntime, + toolchains?: ToolchainService, ): RouteDispatcher { const settingsHandler = new SettingsRouteHandler(createSettingsRouteAdapter(configPresenter)); const runtime: { @@ -2061,6 +2068,46 @@ export function createDaemonDispatcher( return settingsListSystemFontsRoute.output.parse({ fonts: [] }); } + if (route === toolchainsListRoute.name) { + if (!toolchains) throw new Error("Toolchain service is not available in this runtime."); + toolchainsListRoute.input.parse(rawInput); + return toolchainsListRoute.output.parse({ tools: await toolchains.list() }); + } + + if (route === toolchainsSetSourceRoute.name) { + if (!toolchains) throw new Error("Toolchain service is not available in this runtime."); + const input = toolchainsSetSourceRoute.input.parse(rawInput); + return toolchainsSetSourceRoute.output.parse({ + status: await toolchains.setSource(input.tool, input.source, input.path), + }); + } + + if (route === toolchainsRemoveSourceRoute.name) { + if (!toolchains) throw new Error("Toolchain service is not available in this runtime."); + const input = toolchainsRemoveSourceRoute.input.parse(rawInput); + return toolchainsRemoveSourceRoute.output.parse({ status: await toolchains.removeSource(input.tool) }); + } + + if (route === toolchainsInstallRoute.name) { + if (!toolchains) throw new Error("Toolchain service is not available in this runtime."); + const input = toolchainsInstallRoute.input.parse(rawInput); + const started = toolchains.install(input.tool); + return toolchainsInstallRoute.output.parse({ + started: started.started, + status: await toolchains.status(input.tool), + }); + } + + if (route === toolchainsCancelInstallRoute.name) { + if (!toolchains) throw new Error("Toolchain service is not available in this runtime."); + const input = toolchainsCancelInstallRoute.input.parse(rawInput); + toolchains.cancelInstall(input.tool); + return toolchainsCancelInstallRoute.output.parse({ + cancelled: true, + status: await toolchains.status(input.tool), + }); + } + if (isDesktopOnlyRoute(route)) { // Routes that are truly desktop-only (open windows, file dialogs) throw. throw new Error(`Route not available in headless mode: ${route}`); diff --git a/apps/daemon/src/host/acp-provider-execution.ts b/apps/daemon/src/host/acp-provider-execution.ts index e821747db..09d44edf0 100644 --- a/apps/daemon/src/host/acp-provider-execution.ts +++ b/apps/daemon/src/host/acp-provider-execution.ts @@ -25,6 +25,7 @@ import type { BunSessionRepository } from "./bun-session-repository"; import { usageDateKey } from "./bun-session-repository"; import { createDaemonAcpPorts } from "./acpPorts"; import { createDaemonAcpSqlitePresenter } from "./daemonAcpSqlite"; +import type { ToolchainService } from "./toolchains/service"; import { sessionsStatusChangedEvent } from "@argos/shared-contracts"; import { methods as acpMethods, PROTOCOL_VERSION } from "@agentclientprotocol/sdk"; import type { AcpConfigState, AcpAgentDiagnostics, AcpDebugRequest, AcpDebugRunResult } from "@argos/shared/presenter"; @@ -51,8 +52,8 @@ type PendingAcpPermission = { * clients through the daemon `BunEventPublisher`. * * Sessions persist to the daemon's SQLite `acp_sessions` table (resume across - * daemon restarts). The daemon resolves agent runtimes from `$PATH` (no bundled - * runtime). + * daemon restarts). Agent runtimes (`npx`/`uvx`/`node`) resolve through the + * managed toolchain service, falling through to `$PATH` when unconfigured. */ export class AcpProviderExecutionPort implements ProviderExecutionPort { private runtimePromise: Promise | null = null; @@ -84,6 +85,7 @@ export class AcpProviderExecutionPort implements ProviderExecutionPort { private readonly deps: { dataDir: string; appVersion: string; + toolchains: ToolchainService; db: { prepare(sql: string): { get(...p: unknown[]): unknown; @@ -101,6 +103,7 @@ export class AcpProviderExecutionPort implements ProviderExecutionPort { dataDir: this.deps.dataDir, appVersion: this.deps.appVersion, eventPublisher: this.eventPublisher, + toolchains: this.deps.toolchains, }); const sessionPersistence = new AcpSessionPersistence(createDaemonAcpSqlitePresenter(this.deps.db), () => ports.paths.homeDir(), diff --git a/apps/daemon/src/host/acpPorts.ts b/apps/daemon/src/host/acpPorts.ts index 09173db7d..4d39e6d53 100644 --- a/apps/daemon/src/host/acpPorts.ts +++ b/apps/daemon/src/host/acpPorts.ts @@ -3,17 +3,19 @@ import path from "node:path"; import type { AcpHostPorts } from "@argos/acp-runtime"; import type { IEventPublisher } from "@argos/backend-core"; import { shouldRejectAcpTextRead, buildBinaryReadGuidance } from "./acpBinaryGuard"; - +import type { ToolchainService } from "./toolchains/service"; /** * Daemon implementation of the ACP host ports. Resolves paths from the OS and - * daemon data dir, uses a no-op runtime (agents resolve `npx`/`uvx`/`node` from - * `$PATH`), bridges events to the daemon `IEventPublisher`, and wires lifecycle - * to process signals. + * daemon data dir, resolves `npx`/`uvx`/`node`/`uv` through the managed + * toolchain service (falling through to `$PATH` when unconfigured), bridges + * events to the daemon `IEventPublisher`, and wires lifecycle to process + * signals. */ export function createDaemonAcpPorts(deps: { dataDir: string; appVersion: string; eventPublisher: IEventPublisher; + toolchains: ToolchainService; }): AcpHostPorts { return { paths: { @@ -23,10 +25,27 @@ export function createDaemonAcpPorts(deps: { appVersion: () => deps.appVersion, }, runtime: { - // v1 daemon ships no bundled runtime; agents use $PATH-resolved tools. expandPath: (target) => target, resolveCommand: (command) => command, - buildSpawnEnv: (base) => base, + resolveCommandWithArgs: async ({ command, args }) => { + const resolved = await deps.toolchains.resolveCommand(command, args); + if (resolved.command === command) { + return null; + } + return resolved; + }, + buildSpawnEnv: (base) => { + const dirs = deps.toolchains.binDirsSync(); + if (dirs.length === 0) { + return base; + } + const existingKey = Object.keys(base).find((key) => key.toLowerCase() === "path"); + const key = existingKey ?? (process.platform === "win32" ? "Path" : "PATH"); + return { + ...base, + [key]: [...dirs, base[key] ?? ""].filter(Boolean).join(path.delimiter), + }; + }, }, events: { broadcast: (name, payload) => deps.eventPublisher.publish(name, payload), diff --git a/apps/daemon/src/host/daemonMcpPorts.ts b/apps/daemon/src/host/daemonMcpPorts.ts index d3f23cac5..6a5bb5578 100644 --- a/apps/daemon/src/host/daemonMcpPorts.ts +++ b/apps/daemon/src/host/daemonMcpPorts.ts @@ -1,4 +1,5 @@ import { homedir } from "node:os"; +import { delimiter } from "node:path"; import { createJsonStoreFactory } from "./jsonStoreFactory"; import { ArtifactsServer, @@ -15,6 +16,7 @@ import { import { BuiltinKnowledgeServer } from "@argos/backend-core"; import type { IEventPublisher } from "@argos/backend-core"; import type { DaemonConfigPresenter } from "./daemonConfigPresenter"; +import type { ToolchainService } from "./toolchains/service"; import type { PluginToolPolicyDecision } from "@argos/shared/types/plugin"; /** Knowledge capabilities exposed by the daemon knowledge runtime. */ @@ -44,6 +46,7 @@ export function createDaemonMcpPorts(deps: { eventPublisher: IEventPublisher; configPresenter: DaemonConfigPresenter; configDir: string; + toolchains?: ToolchainService; knowledge?: DaemonKnowledgePort; db: { prepare(sql: string): { @@ -93,11 +96,16 @@ export function createDaemonMcpPorts(deps: { runtime: { initializeRuntimes: () => {}, expandPath: (target) => target, - processCommandWithArgs: (command, args) => ({ command, args }), - normalizePathEnv: (paths) => ({ key: "PATH", value: paths.join(":") }), - getDefaultPaths: () => [], + processCommandWithArgs: (command, args) => + deps.toolchains ? deps.toolchains.resolveCommandSync(command, args) : { command, args }, + /** Coalesce concurrent identical Ollama lookups into one upstream request. */ + normalizePathEnv: (paths: string[]) => ({ + key: process.platform === "win32" ? "Path" : "PATH", + value: paths.join(delimiter), + }), + getDefaultPaths: () => deps.toolchains?.binDirsSync() ?? [], getBunRuntimePath: () => null, - getUvRuntimePath: () => null, + getUvRuntimePath: () => deps.toolchains?.binDirForToolSync("uv") ?? null, setBunRuntimePath: () => {}, setUvRuntimePath: () => {}, }, diff --git a/apps/daemon/src/host/toolchains/catalog.ts b/apps/daemon/src/host/toolchains/catalog.ts new file mode 100644 index 000000000..d6d74ad4e --- /dev/null +++ b/apps/daemon/src/host/toolchains/catalog.ts @@ -0,0 +1,106 @@ +import type { ToolchainArchive } from "./types"; + +/** + * Managed-install catalog. Pins carry real SHA-256 digests captured from the + * official release artifacts: + * - Node from https://nodejs.org/dist//SHASUMS256.txt + * - uv from the GitHub release artifacts for the pin. + * + * uv archive filenames do not embed the version, so a pin bump without fresh + * hashes would pass compile-time checks and fail at first install — the + * catalog tests therefore assert every pin has complete non-empty hashes. + * ripgrep has no managed pin: the bundled seed plus system installs cover it. + */ + +export const NODE_PIN = "v24.18.0"; +export const UV_PIN = "0.9.18"; + +export const NODE_DIST_BASE = "https://nodejs.org/dist"; +export const UV_RELEASE_BASE = "https://github.com/astral-sh/uv/releases/download"; + +type PlatformKey = string; + +const NODE_ARCHIVES: Record> = { + [NODE_PIN]: { + "win32-x64": { + filename: `node-${NODE_PIN}-win-x64.zip`, + url: `${NODE_DIST_BASE}/${NODE_PIN}/node-${NODE_PIN}-win-x64.zip`, + sha256: "0ae68406b42d7725661da979b1403ec9926da205c6770827f33aac9d8f26e821", + }, + "win32-arm64": { + filename: `node-${NODE_PIN}-win-arm64.zip`, + url: `${NODE_DIST_BASE}/${NODE_PIN}/node-${NODE_PIN}-win-arm64.zip`, + sha256: "f274669adb93b1fd0fbf8f21fd078609e9dcc84333d4f2718d2dde3f9a161a01", + }, + "darwin-arm64": { + filename: `node-${NODE_PIN}-darwin-arm64.tar.gz`, + url: `${NODE_DIST_BASE}/${NODE_PIN}/node-${NODE_PIN}-darwin-arm64.tar.gz`, + sha256: "e1a97e14c99c803e96c7339403282ea05a499c32f8d83defe9ef5ec66f979ed1", + }, + "darwin-x64": { + filename: `node-${NODE_PIN}-darwin-x64.tar.gz`, + url: `${NODE_DIST_BASE}/${NODE_PIN}/node-${NODE_PIN}-darwin-x64.tar.gz`, + sha256: "dfd0dbd3e721503434df7b7205e719f61b3a3a31b2bcf9729b8b91fea240f080", + }, + "linux-x64": { + filename: `node-${NODE_PIN}-linux-x64.tar.gz`, + url: `${NODE_DIST_BASE}/${NODE_PIN}/node-${NODE_PIN}-linux-x64.tar.gz`, + sha256: "783130984963db7ba9cbd01089eaf2c2efb055c7c1693c943174b967b3050cb8", + }, + "linux-arm64": { + filename: `node-${NODE_PIN}-linux-arm64.tar.gz`, + url: `${NODE_DIST_BASE}/${NODE_PIN}/node-${NODE_PIN}-linux-arm64.tar.gz`, + sha256: "6b4484c2190274175df9aa8f28e2d758a819cb1c1fe6ab481e2f95b463ab8508", + }, + }, +}; + +const UV_ARCHIVES: Record> = { + [UV_PIN]: { + "win32-x64": { + filename: "uv-x86_64-pc-windows-msvc.zip", + url: `${UV_RELEASE_BASE}/${UV_PIN}/uv-x86_64-pc-windows-msvc.zip`, + sha256: "28cbe5d30907a774bfe27a517a39b494ec6f7d3816bda8bbf6f9645490449182", + }, + "win32-arm64": { + filename: "uv-aarch64-pc-windows-msvc.zip", + url: `${UV_RELEASE_BASE}/${UV_PIN}/uv-aarch64-pc-windows-msvc.zip`, + sha256: "fadb43ba13091f44e1786fc3967e65c7786d86192aa205d718307c649927cfc2", + }, + "darwin-arm64": { + filename: "uv-aarch64-apple-darwin.tar.gz", + url: `${UV_RELEASE_BASE}/${UV_PIN}/uv-aarch64-apple-darwin.tar.gz`, + sha256: "dc3bee4abbb3bac267a3985a23ea7617d19d41ff381dbaf560ba415ad65af68f", + }, + "darwin-x64": { + filename: "uv-x86_64-apple-darwin.tar.gz", + url: `${UV_RELEASE_BASE}/${UV_PIN}/uv-x86_64-apple-darwin.tar.gz`, + sha256: "f86836c637333c65bbc7902acc9c49888eef9fbd15dccbc1946b10e30b041073", + }, + "linux-x64": { + filename: "uv-x86_64-unknown-linux-gnu.tar.gz", + url: `${UV_RELEASE_BASE}/${UV_PIN}/uv-x86_64-unknown-linux-gnu.tar.gz`, + sha256: "c2def3db178ade63933fa15ffc96e882c196ce53e06173dcee05b36c5f6f68f5", + }, + "linux-arm64": { + filename: "uv-aarch64-unknown-linux-gnu.tar.gz", + url: `${UV_RELEASE_BASE}/${UV_PIN}/uv-aarch64-unknown-linux-gnu.tar.gz`, + sha256: "f8e23ec786b18660ade6b033b6191b7e9c283c872eeb8c4531d56a873decf160", + }, + }, +}; + +function platformArchKey(): PlatformKey { + return `${process.platform}-${process.arch}`; +} + +/** Managed archive for the current platform, or null when the tool has no pin. */ +export function archiveFor(tool: "node" | "uv"): ToolchainArchive | null { + const key = platformArchKey(); + const table = tool === "node" ? NODE_ARCHIVES[NODE_PIN] : UV_ARCHIVES[UV_PIN]; + return table?.[key] ?? null; +} + +export function pinFor(tool: "node" | "uv"): string { + return tool === "node" ? NODE_PIN : UV_PIN; +} diff --git a/apps/daemon/src/host/toolchains/install.ts b/apps/daemon/src/host/toolchains/install.ts new file mode 100644 index 000000000..1092751b6 --- /dev/null +++ b/apps/daemon/src/host/toolchains/install.ts @@ -0,0 +1,217 @@ +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, readdirSync, renameSync, rmSync, statSync } from "node:fs"; +import path from "node:path"; +import { archiveFor, pinFor } from "./catalog"; +import type { ToolchainArchive, ToolchainName } from "./types"; + +/** + * Managed install pipeline: fetch -> verify sha256 -> extract to staging -> + * atomic rename to tools//. The previous tree is rotated to + * `.prev` (never deleted while it may still be running — Windows locks it + * with EBUSY/EPERM). A failed or cancelled install leaves the previous tree + * active. Cancel is a cooperative flag checked between phases. + */ + +export class ToolchainInstallError extends Error { + readonly code: string; + constructor(message: string, code = "install_failed") { + super(message); + this.name = "ToolchainInstallError"; + this.code = code; + } +} + +export interface InstallContext { + dataDir: string; + fetchImpl: typeof fetch; + /** Cooperative cancel flag, checked between phases. */ + cancelled: () => boolean; + /** Injectable extractor (defaults to `tar -xf`). */ + extract?: (archivePath: string, destinationDir: string) => Promise; + /** Injectable archive (tests); defaults to the catalog pin. */ + archive?: ToolchainArchive; + /** Phase progress callback (activating fires just before the rename). */ + onPhase?: (phase: "downloading" | "extracting" | "activating") => void; +} + +function ensureCancel(ctx: InstallContext, phase: string): void { + if (ctx.cancelled()) { + throw new ToolchainInstallError(`Install cancelled during ${phase}`, "cancelled"); + } +} + +function classifyFsError(error: unknown): string { + const code = (error as NodeJS.ErrnoException)?.code ?? ""; + if (code === "EPERM" || code === "EBUSY") { + return "disk"; + } + return "install_failed"; +} + +async function defaultExtract(archivePath: string, destinationDir: string): Promise { + const proc = Bun.spawn(["tar", "-xf", archivePath, "-C", destinationDir], { + stdout: "pipe", + stderr: "pipe", + }); + const exitCode = await proc.exited; + if (exitCode !== 0) { + const stderr = await new Response(proc.stderr).text(); + throw new ToolchainInstallError(`Archive extraction failed (tar exit ${exitCode}): ${stderr.slice(0, 400)}`); + } +} + +/** Extract, then collapse a single top-level directory into `destinationDir`. */ +async function extractAndFlatten( + archivePath: string, + destinationDir: string, + extract: (archivePath: string, destinationDir: string) => Promise, +): Promise { + const staging = `${destinationDir}.staging-${Date.now()}`; + mkdirSync(staging, { recursive: true }); + try { + await extract(archivePath, staging); + const topLevel = readdirSync(staging).filter((entry) => !entry.startsWith(".")); + if (topLevel.length === 1 && statSync(path.join(staging, topLevel[0]!)).isDirectory()) { + // node-v24.18.0-win-x64/... or uv-x86_64-pc-windows-msvc/... + renameSync(path.join(staging, topLevel[0]!), destinationDir); + } else { + renameSync(staging, destinationDir); + } + } finally { + // Remove the staging dir when the tree moved out of it; keep it (with its + // partial contents) when extraction failed so the archive can be inspected. + try { + if (readdirSync(staging).length === 0) { + rmSync(staging, { recursive: true, force: true }); + } + } catch { + // best-effort + } + } +} + +export async function installToolchain(tool: "node" | "uv", ctx: InstallContext): Promise { + const archive = ctx.archive ?? archiveFor(tool); + if (!archive) { + throw new ToolchainInstallError(`No managed archive is catalogued for ${tool} on this platform`, "no_archive"); + } + const pin = pinFor(tool); + const baseDir = path.join(ctx.dataDir, "toolchains"); + const toolsDir = path.join(baseDir, "tools", tool); + const versionDir = path.join(toolsDir, pin); + const downloadsDir = path.join(baseDir, "downloads"); + + mkdirSync(downloadsDir, { recursive: true }); + mkdirSync(toolsDir, { recursive: true }); + ensureCancel(ctx, "download"); + ctx.onPhase?.("downloading"); + + // Download + verify while streaming: hash and persist chunks as they + // arrive instead of buffering the whole archive in memory. + const archivePath = path.join(downloadsDir, archive.filename); + let response: Response; + try { + response = await ctx.fetchImpl(archive.url); + } catch (error) { + throw new ToolchainInstallError( + `Download failed: ${error instanceof Error ? error.message : String(error)}`, + "network", + ); + } + if (!response.ok) { + // Release the error body so the underlying connection returns to the pool. + try { + await response.body?.cancel(); + } catch { + // best-effort + } + throw new ToolchainInstallError(`Download failed: HTTP ${response.status} for ${archive.url}`, "network"); + } + ensureCancel(ctx, "download"); + const digest = createHash("sha256"); + const writer = Bun.file(archivePath).writer(); + try { + for await (const chunk of response.body as unknown as AsyncIterable) { + digest.update(chunk); + writer.write(chunk); + ensureCancel(ctx, "download"); + } + } finally { + await writer.end(); + } + ensureCancel(ctx, "download"); + const checksum = digest.digest("hex"); + if (checksum !== archive.sha256) { + rmSync(archivePath, { force: true }); + throw new ToolchainInstallError( + `Checksum mismatch for ${archive.filename}: expected ${archive.sha256}, got ${checksum}`, + "checksum_mismatch", + ); + } + ensureCancel(ctx, "extract"); + ctx.onPhase?.("extracting"); + + // Stage the new tree outside the active path. + const stagingTarget = `${versionDir}.incoming`; + if (existsSync(stagingTarget)) { + renameSync(stagingTarget, `${stagingTarget}.old-${Date.now()}`); + } + try { + await extractAndFlatten(archivePath, stagingTarget, ctx.extract ?? defaultExtract); + ensureCancel(ctx, "activating"); + } catch (error) { + // A cancelled or failed extract must not leak a partial staging tree. + try { + rmSync(stagingTarget, { recursive: true, force: true }); + } catch { + // best-effort + } + if (error instanceof ToolchainInstallError && error.code === "cancelled") { + throw error; + } + throw new ToolchainInstallError( + error instanceof Error ? error.message : String(error), + error instanceof ToolchainInstallError ? error.code : classifyFsError(error), + ); + } + + // Activate atomically: rotate the previous tree, then rename staging in. + ctx.onPhase?.("activating"); + if (existsSync(versionDir)) { + const prevPath = `${versionDir}.prev`; + if (existsSync(prevPath)) { + try { + rmSync(prevPath, { recursive: true, force: true }); + } catch { + // Windows keeps the old tree busy; archive it instead of deleting. + try { + renameSync(prevPath, `${prevPath}-${Date.now()}`); + } catch { + // Leave it; the rename below will fail with a clear error if truly locked. + } + } + } + renameSync(versionDir, prevPath); + } + try { + renameSync(stagingTarget, versionDir); + } catch (error) { + // Roll the previous tree back so the active install is never missing. + const prevPath = `${versionDir}.prev`; + if (existsSync(prevPath)) { + renameSync(prevPath, versionDir); + } + throw new ToolchainInstallError( + `Activation failed: ${error instanceof Error ? error.message : String(error)}`, + classifyFsError(error), + ); + } + // Archive the partial download no longer needed. + try { + rmSync(archivePath, { force: true }); + } catch { + // best-effort + } +} + +export { pinFor }; diff --git a/apps/daemon/src/host/toolchains/locate.ts b/apps/daemon/src/host/toolchains/locate.ts new file mode 100644 index 000000000..5e08a29fc --- /dev/null +++ b/apps/daemon/src/host/toolchains/locate.ts @@ -0,0 +1,204 @@ +import { existsSync, readdirSync, statSync } from "node:fs"; +import path from "node:path"; +import type { ResolvedToolchain, ToolchainName, ToolchainSource } from "./types"; + +/** + * Source resolution precedence: + * explicit custom -> explicit unconfigured -> managed -> bundled -> system -> unconfigured. + * `managed`/`bundled`/`system` are derived on demand and never persisted. + */ + +const EXE = (name: string): string => (process.platform === "win32" ? `${name}.exe` : name); + +const TOOL_BINARIES: Record = { + node: ["node"], + uv: ["uv"], + ripgrep: ["rg"], +}; + +/** Candidate roots for the app-shipped runtime seed. */ +export function bundledRoots(dataDir: string): string[] { + const execDir = path.dirname(process.execPath); + const cwd = process.cwd(); + const roots = [path.join(execDir, "..", "runtime"), path.join(execDir, "runtime"), path.join(cwd, "runtime")]; + // The desktop sidecar also seeds the daemon data dir in dev. + if (dataDir && dataDir !== cwd) { + roots.push(path.join(dataDir, "runtime")); + } + return [...new Set(roots.map((root) => path.resolve(root)))]; +} + +function findFileIn(dirs: string[], relative: string[]): string | null { + for (const dir of dirs) { + const candidate = path.join(dir, ...relative); + if (existsSync(candidate)) { + return candidate; + } + } + return null; +} + +/** Locate the bundled seed binary for a tool, or null. */ +export function bundledToolPath(tool: ToolchainName, dataDir: string): string | null { + if (tool === "node") { + // Argos never bundles Node (the daemon itself is a Bun binary). + return null; + } + const dirByTool: Record, string> = { + uv: "uv", + ripgrep: "ripgrep", + }; + const root = findFileIn( + bundledRoots(dataDir), + dirByTool[tool as Exclude] ? [dirByTool[tool as Exclude]] : [], + ); + if (!root) { + return null; + } + return findFileIn( + [root], + TOOL_BINARIES[tool].map((name) => EXE(name)), + ); +} + +/** Default well-known install dirs merged with PATH dirs for system detection. */ +export function systemSearchDirs(env: NodeJS.ProcessEnv): string[] { + const home = env.USERPROFILE ?? env.HOME ?? ""; + const programFiles = env.ProgramFiles ?? (process.platform === "win32" ? "C:\\Program Files" : ""); + const localAppData = env.LOCALAPPDATA ?? ""; + const dirs: string[] = []; + for (const entry of (env.PATH ?? "").split(path.delimiter)) { + if (entry.trim()) { + dirs.push(entry.trim()); + } + } + if (process.platform === "win32") { + if (home) { + dirs.push( + path.join(home, "AppData", "Roaming", "nvm"), + path.join(home, "scoop", "shims"), + path.join(home, ".cargo", "bin"), + ); + } + if (programFiles) { + dirs.push(path.join(programFiles, "nodejs")); + } + if (localAppData) { + dirs.push(path.join(localAppData, "Programs", "uv")); + } + } else { + dirs.push( + "/usr/local/bin", + "/opt/homebrew/bin", + "/usr/bin", + path.join(home, ".local", "bin"), + path.join(home, ".cargo", "bin"), + ); + if (home) { + dirs.push(path.join(home, ".nvm", "versions"), path.join(home, ".volta", "bin"), path.join(home, ".bun", "bin")); + } + } + return [...new Set(dirs)]; +} + +/** Locate a system-installed binary for a tool, or null. */ +export function systemToolPath(tool: ToolchainName, env: NodeJS.ProcessEnv): string | null { + const binaries = TOOL_BINARIES[tool].map((name) => EXE(name)); + for (const dir of expandVersionManagerDirs(systemSearchDirs(env))) { + for (const binary of binaries) { + const candidate = path.join(dir, binary); + if (isFile(candidate)) { + return candidate; + } + } + } + return null; +} + +/** Version-manager roots hide a bin dir per installed version; expand them. */ +function expandVersionManagerDirs(dirs: string[]): string[] { + const expanded: string[] = []; + for (const dir of dirs) { + expanded.push(dir); + const normalized = dir.replace(/\\/g, "/"); + if (!normalized.includes("/.nvm")) continue; + // nvm layout: /node//bin (POSIX), \ (Windows) + let entries: string[] = []; + try { + entries = readdirSync(dir); + } catch { + continue; + } + for (const entry of entries) { + const versionDir = path.join(dir, entry); + if (!isDirectory(versionDir)) continue; + expanded.push(process.platform === "win32" ? versionDir : path.join(versionDir, "bin")); + } + } + return expanded; +} + +function isFile(candidate: string): boolean { + try { + return statSync(candidate).isFile(); + } catch { + return false; + } +} + +function isDirectory(candidate: string): boolean { + try { + return statSync(candidate).isDirectory(); + } catch { + return false; + } +} + +export interface DerivedResolveOptions { + dataDir: string; + env: NodeJS.ProcessEnv; + /** Resolved path of the managed tree, when present. */ + managedPath?: string | null; + /** Version probe override (tests). */ + probeVersion?: (binaryPath: string) => Promise; +} + +/** + * Derive a non-explicit source: managed -> bundled -> system -> unconfigured. + * Standalone so hosts and tests can run it against an isolated dataDir/env. + */ +export async function resolveDerivedToolchain( + tool: ToolchainName, + opts: DerivedResolveOptions, +): Promise { + const probe = opts.probeVersion ?? (async () => null); + + if (tool === "node" || tool === "uv") { + if (opts.managedPath && existsSync(opts.managedPath)) { + return { + source: "managed", + explicit: false, + path: opts.managedPath, + version: await probe(opts.managedPath), + error: null, + }; + } + } + + const bundled = bundledToolPath(tool, opts.dataDir); + if (bundled) { + return { source: "bundled", explicit: false, path: bundled, version: await probe(bundled), error: null }; + } + + const system = systemToolPath(tool, opts.env); + if (system) { + return { source: "system", explicit: false, path: system, version: await probe(system), error: null }; + } + + return { source: "unconfigured", explicit: false, path: null, version: null, error: null }; +} + +/** Directory that must go on PATH for a resolved binary to be usable. */ +export function binDirFor(binaryPath: string): string { + return path.dirname(binaryPath); +} diff --git a/apps/daemon/src/host/toolchains/service.ts b/apps/daemon/src/host/toolchains/service.ts new file mode 100644 index 000000000..c18784845 --- /dev/null +++ b/apps/daemon/src/host/toolchains/service.ts @@ -0,0 +1,383 @@ +import { existsSync, rmSync } from "node:fs"; +import fs from "node:fs"; +import path from "node:path"; +import type { ToolchainName, ToolchainSource, ToolchainStatus } from "@argos/shared-contracts/routes"; +import { NODE_PIN, UV_PIN, pinFor } from "./catalog"; +import { installToolchain, ToolchainInstallError } from "./install"; +import { binDirFor, bundledToolPath, resolveDerivedToolchain, systemToolPath } from "./locate"; +import { loadState, saveState } from "./state"; +import type { InstallProgress, ResolvedToolchain, ToolchainSourceEntry, ToolchainStateFile } from "./types"; + +/** + * One resolver for external runtimes. Consumers (ACP launch, MCP stdio) must + * resolve node/uv/ripgrep only through this service. Explicit user choices + * (custom path, explicit unconfigured) persist; derived sources are computed + * on demand. See docs/features/managed-toolchains. + */ + +export interface ToolchainServiceDeps { + dataDir: string; + env?: NodeJS.ProcessEnv; + fetchImpl?: typeof fetch; + /** Injectable version probe for tests. */ + probeVersion?: (binaryPath: string) => Promise; + now?: () => number; +} + +const DEFAULT_ENV: NodeJS.ProcessEnv = process.env; +const MANAGED_TOOLS = ["node", "uv"] as const; +type ManagedTool = (typeof MANAGED_TOOLS)[number]; + +export class ToolchainService { + private readonly dataDir: string; + private readonly env: NodeJS.ProcessEnv; + private readonly fetchImpl: typeof fetch; + private readonly probeVersionImpl: (binaryPath: string) => Promise; + private readonly now: () => number; + private state: ToolchainStateFile | null = null; + private versionCache = new Map(); + private syncCache = new Map(); + private installJobs = new Map(); + private cancelFlags = new Map(); + constructor(deps: ToolchainServiceDeps) { + this.dataDir = deps.dataDir; + this.env = deps.env ?? DEFAULT_ENV; + this.fetchImpl = deps.fetchImpl ?? fetch; + this.probeVersionImpl = + deps.probeVersion ?? + (async (binaryPath) => { + try { + const proc = Bun.spawn([binaryPath, "--version"], { stdout: "pipe", stderr: "pipe" }); + const timer = setTimeout(() => proc.kill(), 5000); + const exitCode = await proc.exited; + clearTimeout(timer); + if (exitCode !== 0) return null; + const text = await new Response(proc.stdout).text(); + return text.trim().split(/\s+/).pop() || null; + } catch { + return null; + } + }); + this.now = deps.now ?? Date.now; + } + + private async withState(): Promise { + if (!this.state) { + this.state = await loadState(this.dataDir); + } + return this.state; + } + + private async persist(state: ToolchainStateFile): Promise { + this.state = state; + await saveState(this.dataDir, state); + } + + private async probeVersion(binaryPath: string): Promise { + if (this.versionCache.has(binaryPath)) { + return this.versionCache.get(binaryPath) ?? null; + } + const version = await this.probeVersionImpl(binaryPath); + this.versionCache.set(binaryPath, version); + return version; + } + + private managedToolPath(tool: ManagedTool): string | null { + const pin = pinFor(tool); + const binary = tool === "node" ? "node" : "uv"; + const candidate = path.join( + this.dataDir, + "toolchains", + "tools", + tool, + pin, + process.platform === "win32" ? `${binary}.exe` : tool === "node" ? path.join("bin", binary) : binary, + ); + return existsSync(candidate) ? candidate : null; + } + + async resolve(tool: ToolchainName): Promise { + const state = await this.withState(); + const entry: ToolchainSourceEntry | undefined = state.sources[tool]; + + let resolved: ResolvedToolchain; + if (entry?.source === "custom") { + const version = entry.path ? await this.probeVersion(entry.path).catch(() => null) : null; + resolved = { + source: "custom", + explicit: true, + path: entry.path ?? null, + version: version ?? null, + error: entry.path && !existsSync(entry.path) ? "Configured path does not exist" : null, + }; + } else if (entry?.source === "unconfigured") { + resolved = { source: "unconfigured", explicit: true, path: null, version: null, error: null }; + } else { + resolved = await this.resolveDerived(tool); + } + + this.syncCache.set(tool, resolved); + return resolved; + } + + private async resolveDerived(tool: ToolchainName): Promise { + return await resolveDerivedToolchain(tool, { + dataDir: this.dataDir, + env: this.env, + managedPath: tool === "node" || tool === "uv" ? this.managedToolPath(tool) : null, + probeVersion: (binaryPath) => this.probeVersion(binaryPath), + }); + } + + /** Warm the synchronous cache (call at daemon startup). */ + async warmup(): Promise { + for (const tool of ["node", "uv", "ripgrep"] as ToolchainName[]) { + await this.resolve(tool); + } + } + + private cached(tool: ToolchainName): ResolvedToolchain { + return ( + this.syncCache.get(tool) ?? { source: "unconfigured", explicit: false, path: null, version: null, error: null } + ); + } + + async status(tool: ToolchainName): Promise { + const install = this.installJobs.get(tool as ManagedTool) ?? null; + const installError = this.installErrors.get(tool as ManagedTool) ?? null; + const resolved = await this.resolve(tool); + return { + tool, + source: resolved.source, + explicit: resolved.explicit, + path: resolved.path, + version: resolved.version, + error: installError ?? resolved.error, + pin: tool === "node" ? NODE_PIN : tool === "uv" ? UV_PIN : null, + install, + }; + } + + async list(): Promise { + return Promise.all((["node", "uv", "ripgrep"] as ToolchainName[]).map((tool) => this.status(tool))); + } + + async setSource( + tool: ToolchainName, + source: "custom" | "unconfigured", + customPath?: string, + ): Promise { + const state = await this.withState(); + if (source === "custom") { + if (!customPath || !existsSync(customPath)) { + throw new Error(`Custom toolchain path does not exist: ${customPath}`); + } + state.sources[tool] = { source: "custom", explicit: true, path: customPath }; + } else { + state.sources[tool] = { source: "unconfigured", explicit: true }; + } + this.versionCache.clear(); + await this.persist(state); + return this.status(tool); + } + + async removeSource(tool: ToolchainName): Promise { + const state = await this.withState(); + delete state.sources[tool]; + // Reverting a managed install removes its tree so bundled/system can + // serve again; without this the derived managed source would simply + // re-resolve and the UI revert would do nothing. + const tree = path.join(this.dataDir, "toolchains", "tools", tool); + try { + fs.rmSync(tree, { recursive: true, force: true }); + } catch (error) { + console.warn(`[toolchains] failed to remove managed tree for ${tool}:`, error); + } + this.versionCache.clear(); + await this.persist(state); + return this.status(tool); + } + + install(tool: "node" | "uv"): { started: boolean } { + if (this.installJobs.has(tool)) { + return { started: false }; + } + const progress: InstallProgress = { phase: "downloading", tool, version: pinFor(tool), startedAt: this.now() }; + this.installJobs.set(tool, progress); + this.cancelFlags.set(tool, false); + void this.runInstall(tool); + return { started: true }; + } + + private async runInstall(tool: ManagedTool): Promise { + const advance = (phase: InstallProgress["phase"]) => { + const job = this.installJobs.get(tool); + if (job) { + this.installJobs.set(tool, { ...job, phase }); + } + }; + let succeeded = false; + try { + await installToolchain(tool, { + dataDir: this.dataDir, + fetchImpl: this.fetchImpl, + cancelled: () => this.cancelFlags.get(tool) === true, + onPhase: (phase) => advance(phase), + extract: async (archivePath, destinationDir) => { + const proc = Bun.spawn(["tar", "-xf", archivePath, "-C", destinationDir], { + stdout: "pipe", + stderr: "pipe", + }); + const exitCode = await proc.exited; + if (exitCode !== 0) { + const stderr = await new Response(proc.stderr).text(); + throw new ToolchainInstallError( + `Archive extraction failed (tar exit ${exitCode}): ${stderr.slice(0, 400)}`, + ); + } + }, + }); + succeeded = true; + } catch (error) { + const job = this.installJobs.get(tool); + if (job) { + const message = error instanceof Error ? error.message : String(error); + this.installJobs.set(tool, { ...job, phase: "idle" }); + // Surface the failure on the next status poll via a synthetic error map. + this.installErrors.set(tool, message); + } + } + if (succeeded) { + // Activation changed the tree: refresh the version probe and the warm + // sync cache so consumers immediately see the managed tool. + this.versionCache.clear(); + await this.resolve(tool).catch(() => undefined); + } + // The job record stays until the next `status()`/`install()` observes the + // terminal state; keep it for one poll so the UI sees completion. + setTimeout(() => { + this.installJobs.delete(tool); + this.installErrors.delete(tool); + this.cancelFlags.delete(tool); + }, 2500); + } + + private installErrors = new Map(); + + cancelInstall(tool: "node" | "uv"): void { + this.cancelFlags.set(tool, true); + } + + /** + * Rewrite a spawn command through the resolved toolchains. Unresolvable + * commands return unchanged so PATH lookup (and its error) still applies. + */ + async resolveCommand(command: string, args: string[]): Promise<{ command: string; args: string[] }> { + if (command === "node" || command === "npm" || command === "npx") { + const node = await this.resolve("node"); + if (!node.path) { + return { command, args }; + } + if (command === "node") { + return { command: node.path, args }; + } + const cliRelative = process.platform === "win32" ? "node_modules/npm/bin" : "../lib/node_modules/npm/bin"; + const cli = path.join(binDirFor(node.path), cliRelative, command === "npx" ? "npx-cli.js" : "npm-cli.js"); + if (!existsSync(cli)) { + return { command, args }; + } + return { command: node.path, args: [cli, ...args] }; + } + if (command === "uv" || command === "uvx") { + const uv = await this.resolve("uv"); + if (!uv.path) { + return { command, args }; + } + if (command === "uv") { + return { command: uv.path, args }; + } + const uvxName = process.platform === "win32" ? "uvx.exe" : "uvx"; + const uvx = path.join(binDirFor(uv.path), uvxName); + if (existsSync(uvx)) { + return { command: uvx, args }; + } + // `uvx pkg` is equivalent to `uv tool run pkg`; never pass uvx's + // arguments to bare `uv`. + return { command: uv.path, args: ["tool", "run", ...args] }; + } + return { command, args }; + } + + /** + * Synchronous variant backed by the warm cache. Serves sync host seams + * (MCP `processCommandWithArgs`); callers should have run `warmup()` once + * at startup — cache misses resolve to the input unchanged. + */ + resolveCommandSync(command: string, args: string[]): { command: string; args: string[] } { + if (command === "node" || command === "npm" || command === "npx") { + const node = this.cached("node"); + if (!node.path) { + return { command, args }; + } + if (command === "node") { + return { command: node.path, args }; + } + const cliRelative = process.platform === "win32" ? "node_modules/npm/bin" : "../lib/node_modules/npm/bin"; + const cli = path.join(binDirFor(node.path), cliRelative, command === "npx" ? "npx-cli.js" : "npm-cli.js"); + if (!existsSync(cli)) { + return { command, args }; + } + return { command: node.path, args: [cli, ...args] }; + } + if (command === "uv" || command === "uvx") { + const uv = this.cached("uv"); + if (!uv.path) { + return { command, args }; + } + if (command === "uv") { + return { command: uv.path, args }; + } + const uvxName = process.platform === "win32" ? "uvx.exe" : "uvx"; + const uvx = path.join(binDirFor(uv.path), uvxName); + if (existsSync(uvx)) { + return { command: uvx, args }; + } + // `uvx pkg` is equivalent to `uv tool run pkg`; never pass uvx's + // arguments to bare `uv`. + return { command: uv.path, args: ["tool", "run", ...args] }; + } + return { command, args }; + } + + /** Bin dirs that should be prepended to PATH for spawned consumers. */ + async binDirs(): Promise { + const dirs: string[] = []; + for (const tool of ["node", "uv", "ripgrep"] as ToolchainName[]) { + const resolved = await this.resolve(tool); + if (resolved.path) { + dirs.push(binDirFor(resolved.path)); + } + } + return [...new Set(dirs)]; + } + + /** Synchronous `binDirs` backed by the warm cache. */ + binDirsSync(): string[] { + const dirs: string[] = []; + for (const tool of ["node", "uv", "ripgrep"] as ToolchainName[]) { + const resolved = this.cached(tool); + if (resolved.path) { + dirs.push(binDirFor(resolved.path)); + } + } + return [...new Set(dirs)]; + } + + /** Synchronous bin dir of one tool, or null when unresolved. */ + binDirForToolSync(tool: ToolchainName): string | null { + const resolved = this.cached(tool); + return resolved.path ? binDirFor(resolved.path) : null; + } +} + +export type { ToolchainSource }; diff --git a/apps/daemon/src/host/toolchains/state.ts b/apps/daemon/src/host/toolchains/state.ts new file mode 100644 index 000000000..461617c3b --- /dev/null +++ b/apps/daemon/src/host/toolchains/state.ts @@ -0,0 +1,72 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import type { ToolchainName, ToolchainStateFile } from "./types"; + +/** + * Persists only explicit user choices (custom path / explicit unconfigured). + * Derived sources (managed/bundled/system) are recomputed on demand so a PATH + * refresh or a removed bundled seed cannot leave a stale pointer behind. + * Corrupt state is quarantined under a timestamped name — a fixed + * `state.json.corrupt` would throw EEXIST on the second corruption. + */ + +const STATE_VERSION = 1 as const; + +export function toolchainsDir(dataDir: string): string { + return join(dataDir, "toolchains"); +} + +function stateFilePath(dataDir: string): string { + return join(toolchainsDir(dataDir), "state.json"); +} + +export function emptyState(): ToolchainStateFile { + return { version: STATE_VERSION, sources: {} }; +} + +export async function loadState(dataDir: string): Promise { + const filePath = stateFilePath(dataDir); + if (!existsSync(filePath)) { + return emptyState(); + } + try { + const raw = await Bun.file(filePath).text(); + const parsed = JSON.parse(raw) as Partial | null; + if (!parsed || typeof parsed !== "object" || parsed.version !== STATE_VERSION) { + throw new Error("unsupported state version"); + } + const sources = parsed.sources ?? {}; + const clean: ToolchainStateFile["sources"] = {}; + for (const [tool, entry] of Object.entries(sources)) { + if (!entry || (entry.source !== "custom" && entry.source !== "unconfigured")) { + continue; + } + if (entry.source === "custom" && typeof entry.path !== "string") { + continue; + } + clean[tool as ToolchainName] = { source: entry.source, explicit: true, path: entry.path }; + } + return { version: STATE_VERSION, sources: clean }; + } catch { + // Quarantine under a timestamped name so repeated corruption cannot make + // every subsequent load throw (EEXIST on a fixed quarantine name). + try { + await Bun.write( + join(toolchainsDir(dataDir), `state.corrupt-${Date.now()}.json`), + await Bun.file(filePath).arrayBuffer(), + ); + // Persist a VALID empty state — an empty string would re-corrupt on + // every load and grow a quarantine copy per daemon start. + await Bun.write(filePath, JSON.stringify(emptyState(), null, 2)); + } catch { + // best-effort quarantine; a fresh state is returned regardless + } + return emptyState(); + } +} + +export async function saveState(dataDir: string, state: ToolchainStateFile): Promise { + const dir = toolchainsDir(dataDir); + await Bun.write(join(dir, ".keep"), ""); + await Bun.write(stateFilePath(dataDir), JSON.stringify(state, null, 2)); +} diff --git a/apps/daemon/src/host/toolchains/types.ts b/apps/daemon/src/host/toolchains/types.ts new file mode 100644 index 000000000..f5a0776c6 --- /dev/null +++ b/apps/daemon/src/host/toolchains/types.ts @@ -0,0 +1,37 @@ +import type { ToolchainName, ToolchainSource } from "@argos/shared-contracts/routes"; + +export type { ToolchainName, ToolchainSource }; + +/** A derived or explicit resolution result for one tool. */ +export interface ResolvedToolchain { + source: ToolchainSource; + explicit: boolean; + path: string | null; + version: string | null; + error: string | null; +} + +/** Persisted explicit user choice (custom path / explicit unconfigured). */ +export interface ToolchainSourceEntry { + source: "custom" | "unconfigured"; + explicit: true; + path?: string; +} + +export interface ToolchainStateFile { + version: 1; + sources: Partial>; +} + +export interface InstallProgress { + phase: "idle" | "downloading" | "extracting" | "activating"; + tool: ToolchainName; + version: string; + startedAt: number; +} + +export interface ToolchainArchive { + filename: string; + url: string; + sha256: string; +} diff --git a/apps/daemon/src/index.ts b/apps/daemon/src/index.ts index 9e7a7d4e0..39f61b8ce 100644 --- a/apps/daemon/src/index.ts +++ b/apps/daemon/src/index.ts @@ -13,6 +13,7 @@ import { DaemonArgosAgentRuntime } from "./host/daemonArgosAgentRuntime"; import { BunEventPublisher } from "./host/bun-event-publisher"; import { initializeDatabase } from "./host/db-init"; import { createDaemonDispatcher } from "./dispatch/daemonDispatcher"; +import { ToolchainService } from "./host/toolchains/service"; import { DaemonWorkspacePresenter } from "./workspace/daemonWorkspacePresenter"; import { DaemonTerminalRuntime } from "./terminal/daemonTerminalRuntime"; import { ProviderImportService } from "@argos/backend-core"; @@ -330,6 +331,15 @@ export async function startDaemon(options?: { const piProfiles = new PiAgentProfileManager(paths.getDataDir(), resolveDaemonVersion()); const agentWorkspaceDir = pathJoin(paths.getDataDir(), "agent-workspace"); + + // One resolver for external runtimes; consumers (ACP launch, MCP stdio) + // must go through it. Warmed before dependent subsystems start so the sync + // seams (MCP process rewriting) never fall back to PATH mid-startup. + const toolchainService = new ToolchainService({ dataDir: paths.getDataDir() }); + const toolchainWarmup = toolchainService.warmup().catch((error) => { + logger.warn("[daemon] toolchain warmup failed:", error); + }); + const piProviderExecutionPort = new PiProviderExecutionPort( configPresenter, sessionRepository, @@ -377,6 +387,7 @@ export async function startDaemon(options?: { dataDir: paths.getDataDir(), appVersion: resolveDaemonVersion(), db, + toolchains: toolchainService, }); // Route execution by session provider: ACP-backed sessions go to the ACP port, @@ -506,6 +517,7 @@ export async function startDaemon(options?: { eventPublisher, configPresenter, configDir: paths.getConfigDir(), + toolchains: toolchainService, knowledge: knowledgeRuntime.runtime, sessionRepository, db, @@ -513,8 +525,8 @@ export async function startDaemon(options?: { const mcpRuntime = new DaemonMcpRuntime(configPresenter, mcpPorts); const pluginRuntimeRegistry = new PluginRuntimeRegistry(mcpRuntime.serverManager); mcpPorts.services.pluginRuntime = pluginRuntimeRegistry; - void mcpRuntime - .startEnabledServers() + void toolchainWarmup + .then(() => mcpRuntime.startEnabledServers()) .then(({ started, failed }) => { logger.info(`[daemon] MCP startup complete: ${started.length} started, ${failed.length} failed`); }) @@ -835,6 +847,7 @@ export async function startDaemon(options?: { workspacePresenter, knowledgeRuntime.runtime, terminalRuntime, + toolchainService, ); setRouteDispatcher(dispatcher); diff --git a/apps/daemon/test/toolchainsRoutes.test.ts b/apps/daemon/test/toolchainsRoutes.test.ts new file mode 100644 index 000000000..e4c25b4db --- /dev/null +++ b/apps/daemon/test/toolchainsRoutes.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it, vi } from "bun:test"; +import { createDaemonDispatcher } from "../src/dispatch/daemonDispatcher"; + +/** Route surface for the managed toolchains service. */ + +const createToolchainsStub = () => ({ + list: vi.fn(async () => [ + { + tool: "node", + source: "managed", + explicit: false, + path: "/tools/node/v24.18.0/node.exe", + version: "v24.18.0", + error: null, + pin: "v24.18.0", + install: null, + }, + { + tool: "uv", + source: "unconfigured", + explicit: false, + path: null, + version: null, + error: null, + pin: "0.9.18", + install: null, + }, + { + tool: "ripgrep", + source: "unconfigured", + explicit: false, + path: null, + version: null, + error: null, + pin: null, + install: null, + }, + ]), + status: vi.fn(async (tool: string) => ({ + tool, + source: "unconfigured", + explicit: false, + path: null, + version: null, + error: null, + pin: tool === "ripgrep" ? null : "x", + install: null, + })), + setSource: vi.fn(async (tool: string, source: string, customPath?: string) => ({ + tool, + source, + explicit: true, + path: customPath ?? null, + version: null, + error: null, + pin: null, + install: null, + })), + removeSource: vi.fn(async (tool: string) => ({ + tool, + source: "unconfigured", + explicit: false, + path: null, + version: null, + error: null, + pin: null, + install: null, + })), + install: vi.fn((_tool: string) => ({ started: true })), + cancelInstall: vi.fn((_tool: string) => {}), +}); + +const createDispatcher = (toolchains?: ReturnType) => + createDaemonDispatcher( + { + getDefaultModel: vi.fn(() => ({ providerId: "provider-1", modelId: "model-1" })), + } as any, + { publish: vi.fn() } as any, + {} as any, + {} as any, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + "unknown", + undefined, + undefined, + undefined, + undefined, + toolchains as any, + ); + +describe("toolchains routes", () => { + it("lists toolchain status", async () => { + const toolchains = createToolchainsStub(); + const dispatcher = createDispatcher(toolchains); + + await expect(dispatcher("toolchains.list", {})).resolves.toEqual({ + tools: await toolchains.list(), + }); + expect(toolchains.list).toHaveBeenCalled(); + }); + + it("sets and removes explicit sources", async () => { + const toolchains = createToolchainsStub(); + const dispatcher = createDispatcher(toolchains); + + await expect( + dispatcher("toolchains.setSource", { tool: "node", source: "custom", path: "/opt/node/node" }), + ).resolves.toEqual({ + status: { + tool: "node", + source: "custom", + explicit: true, + path: "/opt/node/node", + version: null, + error: null, + pin: null, + install: null, + }, + }); + expect(toolchains.setSource).toHaveBeenCalledWith("node", "custom", "/opt/node/node"); + + await expect(dispatcher("toolchains.removeSource", { tool: "uv" })).resolves.toEqual({ + status: { + tool: "uv", + source: "unconfigured", + explicit: false, + path: null, + version: null, + error: null, + pin: null, + install: null, + }, + }); + }); + + it("rejects a custom source without a path", async () => { + const dispatcher = createDispatcher(createToolchainsStub()); + await expect(dispatcher("toolchains.setSource", { tool: "node", source: "custom" })).rejects.toThrow(); + }); + + it("starts and cancels installs", async () => { + const toolchains = createToolchainsStub(); + const dispatcher = createDispatcher(toolchains); + + await expect(dispatcher("toolchains.install", { tool: "uv" })).resolves.toMatchObject({ started: true }); + expect(toolchains.install).toHaveBeenCalledWith("uv"); + + await expect(dispatcher("toolchains.cancelInstall", { tool: "uv" })).resolves.toMatchObject({ cancelled: true }); + expect(toolchains.cancelInstall).toHaveBeenCalledWith("uv"); + }); + + it("throws a clear error when the service is unavailable", async () => { + const dispatcher = createDispatcher(undefined); + await expect(dispatcher("toolchains.list", {})).rejects.toThrow("Toolchain service is not available"); + }); +}); diff --git a/apps/daemon/test/toolchainsService.test.ts b/apps/daemon/test/toolchainsService.test.ts new file mode 100644 index 000000000..f43abba33 --- /dev/null +++ b/apps/daemon/test/toolchainsService.test.ts @@ -0,0 +1,305 @@ +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "bun:test"; +import { ToolchainService } from "../src/host/toolchains/service"; +import { installToolchain, ToolchainInstallError } from "../src/host/toolchains/install"; +import { pinFor } from "../src/host/toolchains/catalog"; +import { systemToolPath } from "../src/host/toolchains/locate"; + +/** + * Hermetic coverage for the toolchain resolver, command rewrites, and the + * managed-install pipeline. All filesystem fixtures live under temp dirs; the + * version probe is injected so no real binary is spawned. + */ + +const EMPTY_ENV = { PATH: "", HOME: "", USERPROFILE: "", ProgramFiles: "", LOCALAPPDATA: "" } as NodeJS.ProcessEnv; +const nodeBin = () => (process.platform === "win32" ? "node.exe" : path.join("bin", "node")); +const uvBin = () => (process.platform === "win32" ? "uv.exe" : "uv"); + +describe("toolchain service", () => { + const roots: string[] = []; + + afterEach(() => { + while (roots.length > 0) { + const root = roots.pop(); + if (root) fs.rmSync(root, { recursive: true, force: true }); + } + }); + + const tempRoot = (): string => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "argos-toolchains-svc-")); + roots.push(dir); + return dir; + }; + + const createManagedNode = (dataDir: string): string => { + const nodePath = path.join(dataDir, "toolchains", "tools", "node", pinFor("node"), nodeBin()); + fs.mkdirSync(path.dirname(nodePath), { recursive: true }); + fs.writeFileSync(nodePath, "binary"); + return nodePath; + }; + + const createManagedUv = (dataDir: string): string => { + const uvPath = path.join(dataDir, "toolchains", "tools", "uv", pinFor("uv"), uvBin()); + fs.mkdirSync(path.dirname(uvPath), { recursive: true }); + fs.writeFileSync(uvPath, "binary"); + return uvPath; + }; + + it("resolves a managed tree ahead of system lookups", async () => { + const dataDir = tempRoot(); + const nodePath = createManagedNode(dataDir); + const service = new ToolchainService({ + dataDir, + env: EMPTY_ENV, + probeVersion: async () => pinFor("node"), + }); + + const resolved = await service.resolve("node"); + expect(resolved.source).toBe("managed"); + expect(resolved.explicit).toBe(false); + expect(resolved.path).toBe(nodePath); + expect(resolved.version).toBe(pinFor("node")); + }); + + it("prefers an explicit custom path over the managed tree", async () => { + const dataDir = tempRoot(); + createManagedNode(dataDir); + const customBin = path.join(tempRoot(), "custom-node"); + fs.writeFileSync(customBin, "binary"); + + const service = new ToolchainService({ dataDir, env: EMPTY_ENV, probeVersion: async () => "1.2.3" }); + await service.setSource("node", "custom", customBin); + + const resolved = await service.resolve("node"); + expect(resolved.source).toBe("custom"); + expect(resolved.explicit).toBe(true); + expect(resolved.path).toBe(customBin); + expect(resolved.version).toBe("1.2.3"); + }); + + it("keeps an explicit unconfigured choice over any derived source", async () => { + const dataDir = tempRoot(); + createManagedNode(dataDir); + + const service = new ToolchainService({ dataDir, env: EMPTY_ENV, probeVersion: async () => null }); + await service.setSource("node", "unconfigured"); + + const resolved = await service.resolve("node"); + expect(resolved.source).toBe("unconfigured"); + expect(resolved.explicit).toBe(true); + expect(resolved.path).toBeNull(); + + // Reverting falls back to derived resolution: removeSource also removed + // the managed tree, so recreate it to observe the managed source again. + await service.removeSource("node"); + createManagedNode(dataDir); + expect((await service.resolve("node")).source).toBe("managed"); + }); + + it("rewrites npx through managed node without touching args on fallback", async () => { + const dataDir = tempRoot(); + const nodePath = createManagedNode(dataDir); + const service = new ToolchainService({ dataDir, env: EMPTY_ENV, probeVersion: async () => pinFor("node") }); + await service.warmup(); + + const nodeDir = path.dirname(nodePath); + const cliRelative = + process.platform === "win32" ? "node_modules/npm/bin/npx-cli.js" : "../lib/node_modules/npm/bin/npx-cli.js"; + const cli = path.resolve(nodeDir, cliRelative); + fs.mkdirSync(path.dirname(cli), { recursive: true }); + fs.writeFileSync(cli, "// npx cli"); + + const rewritten = service.resolveCommandSync("npx", ["-y", "some-agent", "--acp"]); + expect(rewritten.command).toBe(nodePath); + expect(rewritten.args[0]).toBe(cli); + expect(rewritten.args.slice(1)).toEqual(["-y", "some-agent", "--acp"]); + + // Unknown commands pass through untouched. + expect(service.resolveCommandSync("rg", ["--version"])).toEqual({ command: "rg", args: ["--version"] }); + }); + + it("rewrites uvx to the uv sibling binary", async () => { + const dataDir = tempRoot(); + const uvPath = createManagedUv(dataDir); + const service = new ToolchainService({ dataDir, env: EMPTY_ENV, probeVersion: async () => pinFor("uv") }); + await service.warmup(); + + const rewritten = service.resolveCommandSync("uvx", ["some-server"]); + const uvDir = path.dirname(uvPath); + const expectedUvx = path.join(uvDir, process.platform === "win32" ? "uvx.exe" : "uvx"); + fs.mkdirSync(uvDir, { recursive: true }); + fs.writeFileSync(expectedUvx, "binary"); + const afterSibling = service.resolveCommandSync("uvx", ["some-server"]); + expect(afterSibling.command).toBe(expectedUvx); + expect(afterSibling.args).toEqual(["some-server"]); + + // Without a sibling uvx binary, fall back to `uv tool run` — bare `uv` + // with uvx's arguments is not a valid invocation. + fs.rmSync(expectedUvx); + const fallback = service.resolveCommandSync("uvx", ["some-server"]); + expect(fallback.command).toBe(uvPath); + expect(fallback.args).toEqual(["tool", "run", "some-server"]); + }); + + it("reverting a managed install removes its tree", async () => { + const dataDir = tempRoot(); + const uvPath = createManagedUv(dataDir); + const service = new ToolchainService({ dataDir, env: EMPTY_ENV, probeVersion: async () => pinFor("uv") }); + await service.warmup(); + expect((await service.resolve("uv")).source).toBe("managed"); + + // A managed source is derived (not explicit); removeSource must still + // remove the tree so bundled/system can serve again. + await service.removeSource("uv"); + expect(fs.existsSync(uvPath)).toBe(false); + expect((await service.resolve("uv")).source).toBe("unconfigured"); + }); + + it("reports bin dirs from the warm cache", async () => { + const dataDir = tempRoot(); + const uvPath = createManagedUv(dataDir); + const service = new ToolchainService({ dataDir, env: EMPTY_ENV, probeVersion: async () => null }); + expect(service.binDirsSync()).toEqual([]); + await service.warmup(); + expect(service.binDirsSync()).toContain(path.dirname(uvPath)); + expect(service.binDirForToolSync("uv")).toBe(path.dirname(uvPath)); + }); +}); + +describe("managed install pipeline", () => { + const roots: string[] = []; + + afterEach(() => { + while (roots.length > 0) { + const root = roots.pop(); + if (root) fs.rmSync(root, { recursive: true, force: true }); + } + }); + + const tempRoot = (): string => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "argos-toolchains-install-")); + roots.push(dir); + return dir; + }; + + const fakeArchive = (content: string) => { + const bytes = new TextEncoder().encode(content); + const sha256 = createHash("sha256").update(bytes).digest("hex"); + return { + bytes, + archive: { filename: "node-vTEST.tar.gz", url: "https://example.invalid/node.tar.gz", sha256 }, + }; + }; + + it("activates a verified archive atomically and the service sees it", async () => { + const dataDir = tempRoot(); + const { bytes, archive } = fakeArchive("payload"); + const nodeRoot = path.join(dataDir, "toolchains", "tools", "node", pinFor("node")); + + await installToolchain("node", { + dataDir, + fetchImpl: (async () => new Response(bytes)) as typeof fetch, + cancelled: () => false, + archive, + extract: async (_archivePath, destinationDir) => { + // Emulate an archive with one top-level dir, platform layout inside. + const top = path.join(destinationDir, "node-vTEST"); + fs.mkdirSync(process.platform === "win32" ? top : path.join(top, "bin"), { recursive: true }); + fs.writeFileSync(path.join(top, nodeBin()), "binary"); + }, + }); + + expect(fs.existsSync(nodeRoot)).toBe(true); + expect(fs.existsSync(path.join(nodeRoot, nodeBin()))).toBe(true); + + const service = new ToolchainService({ dataDir, env: EMPTY_ENV, probeVersion: async () => pinFor("node") }); + const resolved = await service.resolve("node"); + expect(resolved.source).toBe("managed"); + }); + + it("fails on checksum mismatch without touching the active tree", async () => { + const dataDir = tempRoot(); + const { bytes } = fakeArchive("payload"); + const versionDir = path.join(dataDir, "toolchains", "tools", "node", pinFor("node")); + fs.mkdirSync(versionDir, { recursive: true }); + fs.writeFileSync(path.join(versionDir, nodeBin()), "previous"); + + await expect( + installToolchain("node", { + dataDir, + fetchImpl: (async () => new Response(bytes)) as typeof fetch, + cancelled: () => false, + archive: { filename: "node-vTEST.tar.gz", url: "https://example.invalid/x", sha256: "deadbeef" }, + }), + ).rejects.toBeInstanceOf(ToolchainInstallError); + + expect(fs.existsSync(path.join(versionDir, nodeBin()))).toBe(true); + expect(fs.readFileSync(path.join(versionDir, nodeBin()), "utf-8")).toBe("previous"); + }); + + it("cancel during download leaves nothing behind", async () => { + const dataDir = tempRoot(); + const { bytes, archive } = fakeArchive("payload"); + + await expect( + installToolchain("node", { + dataDir, + fetchImpl: (async () => new Response(bytes)) as typeof fetch, + cancelled: () => true, + archive, + }), + ).rejects.toMatchObject({ code: "cancelled" }); + + expect(fs.existsSync(path.join(dataDir, "toolchains", "tools", "node", pinFor("node")))).toBe(false); + }); + + // Rollback semantics rely on Windows' mandatory locks: an open handle inside + // the staged tree blocks its activation rename. POSIX has no equivalent, so + // this scenario is Windows-only. + it.skipIf(process.platform !== "win32")("keeps the previous tree active when activation is blocked", async () => { + const dataDir = tempRoot(); + const { bytes, archive } = fakeArchive("payload"); + const versionDir = path.join(dataDir, "toolchains", "tools", "node", pinFor("node")); + fs.mkdirSync(versionDir, { recursive: true }); + fs.writeFileSync(path.join(versionDir, nodeBin()), "previous"); + + let lockFd: number | null = null; + try { + await expect( + installToolchain("node", { + dataDir, + fetchImpl: (async () => new Response(bytes)) as typeof fetch, + cancelled: () => false, + archive, + extract: async (_archivePath, destinationDir) => { + fs.mkdirSync(destinationDir, { recursive: true }); + fs.writeFileSync(path.join(destinationDir, nodeBin()), "next"); + // Hold a handle inside the staged tree so the activation rename + // fails and the rollback path must fire. + lockFd = fs.openSync(path.join(destinationDir, nodeBin()), "r+"); + }, + }), + ).rejects.toBeInstanceOf(ToolchainInstallError); + } finally { + if (lockFd != null) fs.closeSync(lockFd); + } + + // The previous content is still active. + expect(fs.existsSync(path.join(versionDir, nodeBin()))).toBe(true); + expect(fs.readFileSync(path.join(versionDir, nodeBin()), "utf-8")).toBe("previous"); + }); +}); + +describe("system detection", () => { + it("finds binaries from injected PATH entries before defaults", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "argos-toolchains-sys-")); + const uvName = process.platform === "win32" ? "uv.exe" : "uv"; + fs.writeFileSync(path.join(dir, uvName), "binary"); + const found = systemToolPath("uv", { PATH: dir } as NodeJS.ProcessEnv); + expect(found).toBe(path.join(dir, uvName)); + fs.rmSync(dir, { recursive: true, force: true }); + }); +}); diff --git a/apps/daemon/test/toolchainsState.test.ts b/apps/daemon/test/toolchainsState.test.ts new file mode 100644 index 000000000..f21ca6000 --- /dev/null +++ b/apps/daemon/test/toolchainsState.test.ts @@ -0,0 +1,96 @@ +import { existsSync, mkdirSync } from "node:fs"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "bun:test"; +import { loadState, saveState, emptyState, toolchainsDir } from "../src/host/toolchains/state"; + +describe("toolchain state store", () => { + const roots: string[] = []; + + afterEach(() => { + while (roots.length > 0) { + const root = roots.pop(); + if (root) fs.rmSync(root, { recursive: true, force: true }); + } + }); + + const tempRoot = (): string => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "argos-toolchains-")); + roots.push(dir); + return dir; + }; + + it("round-trips explicit sources", async () => { + const dataDir = tempRoot(); + const state = emptyState(); + state.sources.node = { source: "custom", explicit: true, path: "D:/tools/node/node.exe" }; + state.sources.uv = { source: "unconfigured", explicit: true }; + await saveState(dataDir, state); + + const loaded = await loadState(dataDir); + expect(loaded.sources.node).toEqual({ source: "custom", explicit: true, path: "D:/tools/node/node.exe" }); + expect(loaded.sources.uv).toEqual({ source: "unconfigured", explicit: true }); + expect(loaded.sources.ripgrep).toBeUndefined(); + }); + + it("returns empty state when no file exists", async () => { + const dataDir = tempRoot(); + const loaded = await loadState(dataDir); + expect(loaded).toEqual({ version: 1, sources: {} }); + }); + + it("quarantines corrupt state under a timestamped name and recovers", async () => { + const dataDir = tempRoot(); + mkdirSync(toolchainsDir(dataDir), { recursive: true }); + await Bun.write(path.join(toolchainsDir(dataDir), "state.json"), "{ not json"); + + const loaded = await loadState(dataDir); + expect(loaded).toEqual({ version: 1, sources: {} }); + + // The corrupt file is preserved beside the live one with a timestamp, + // and the live file is reset to a VALID empty state (not an empty string, + // which would re-corrupt on every load). + const files = fs.readdirSync(toolchainsDir(dataDir)); + expect(files.some((file) => file.startsWith("state.corrupt-"))).toBe(true); + const resetContent = fs.readFileSync(path.join(toolchainsDir(dataDir), "state.json"), "utf-8"); + expect(() => JSON.parse(resetContent)).not.toThrow(); + }); + + it("survives repeated corruption without throwing", async () => { + const dataDir = tempRoot(); + mkdirSync(toolchainsDir(dataDir), { recursive: true }); + const statePath = path.join(toolchainsDir(dataDir), "state.json"); + + await Bun.write(statePath, "garbage-one"); + expect(await loadState(dataDir)).toEqual({ version: 1, sources: {} }); + + await Bun.write(statePath, "garbage-two"); + expect(await loadState(dataDir)).toEqual({ version: 1, sources: {} }); + + const quarantined = fs.readdirSync(toolchainsDir(dataDir)).filter((file) => file.startsWith("state.corrupt-")); + expect(quarantined.length).toBe(2); + }); + + it("drops malformed source entries instead of throwing", async () => { + const dataDir = tempRoot(); + mkdirSync(toolchainsDir(dataDir), { recursive: true }); + await Bun.write( + path.join(toolchainsDir(dataDir), "state.json"), + JSON.stringify({ + version: 1, + sources: { + node: { source: "managed", explicit: true }, + uv: { source: "custom", explicit: true }, + ripgrep: "bogus", + }, + }), + ); + + const loaded = await loadState(dataDir); + expect(loaded.sources.node).toBeUndefined(); + expect(loaded.sources.uv).toBeUndefined(); + expect(loaded.sources.ripgrep).toBeUndefined(); + expect(existsSync(path.join(toolchainsDir(dataDir), "state.json"))).toBe(true); + }); +}); diff --git a/docs/features/managed-toolchains/plan.md b/docs/features/managed-toolchains/plan.md new file mode 100644 index 000000000..97b370357 --- /dev/null +++ b/docs/features/managed-toolchains/plan.md @@ -0,0 +1,66 @@ +# Plan: Managed toolchains + +## 1. Shared contracts + +- `packages/shared-contracts/src/routes/toolchains.routes.ts`: + - `toolchains.list` → `{ tools: ToolchainStatus[] }` (status: source, explicit, path, version, + error, pin, install progress). + - `toolchains.setSource` `{ tool, source: "custom" | "unconfigured", path? }` — explicit only. + - `toolchains.install` `{ tool }` → starts/attaches the managed install job. + - `toolchains.cancelInstall` `{ tool }`. + - `toolchains.removeSource` `{ tool }` — clears explicit source + managed tree (revert). +- Types in the routes file; catalog entry in `packages/shared-contracts/src/routes.ts`. + +## 2. Daemon service (`apps/daemon/src/host/toolchains/`) + +- `types.ts` — `ToolchainName`, `ToolchainSource`, `ToolchainStatus`, `ResolvedToolchain`. +- `state.ts` — `state.json` under `/toolchains/`: `{ version: 1, sources: { node?, uv?, + ripgrep? } }`, entries `{ source, explicit, path? }`. Corrupt → timestamped quarantine + (`state.corrupt-.json`) + fresh state. +- `catalog.ts` — `NODE_PIN = "v24.18.0"`, `UV_PIN = "0.9.18"`; per platform-arch archive + `{ filename, url, sha256 }`; `RIPGREP` handled as bundled/system only (no managed pin yet — + bundled seed covers it; documented). +- `resolve.ts` — precedence: explicit custom → explicit unconfigured → managed tree → bundled + seed → system (PATH + default dirs: nvm/volta/homebrew/Program Files) → unconfigured. Returns + `{ source, explicit, path, version }` (version probed lazily via `--version` with timeout, + cached). +- `install.ts` — pipeline: fetch archive (verify sha256 while streaming to `downloads/`) → + extract via `tar -xf` to staging → flatten the single top-level dir → atomic rename to + `tools/-` → set active pointer. Rotate `.prev`. Cooperative cancel between + phases; cancelled staging dirs are cleaned best-effort. +- `service.ts` — `ToolchainService` facade: `list()`, `setSource()`, `removeSource()`, + `install()`, `cancelInstall()`, `resolve(tool)`, `resolveCommand(command, args)`, `binPaths()`. + Injectable `deps` (dataDir, fetchImpl, now, probeVersion) for tests. + +## 3. Daemon wiring + +- `apps/daemon/src/index.ts` — construct the service after dataDir is known; pass into + `createDaemonAcpPorts`, `createDaemonMcpPorts`, dispatcher. +- `apps/daemon/src/host/acpPorts.ts` — `resolveCommand`: rewrite `npx`/`npm`/`node`/`uvx`/`uv` + through the service (`npx` → node + npx-cli.js per D5); `buildSpawnEnv`: prepend resolved + toolchain bin dirs to PATH. +- `apps/daemon/src/host/daemonMcpPorts.ts` — `getUvRuntimePath`/`getBunRuntimePath` return + resolved uv/node dirs; `processCommandWithArgs` applies the same rewrites as + `acpPorts.resolveCommand`. +- `apps/daemon/src/dispatch/daemonDispatcher.ts` — `toolchains.*` route handlers. + +## 4. UI + +- `packages/shared/src/settingsNavigation.ts` — `settings-toolchains` item (tools group, + `lucide:cpu`), title map entry. +- `packages/ui/api/ToolchainClient.ts` — typed client over the routes. +- `packages/ui/settings/components/ToolchainsSettings.tsx` — per-tool card: source badge, path, + version, pin, actions (install/repair, cancel, revert, set custom path via folder picker, + clear). Refreshes status on an interval while an install is in flight. +- `packages/ui/settings/main.tsx` — componentMap entry; browser-safe (no desktop dependency). + +## 5. Tests (daemon, bun test) + +- `toolchainsState.test.ts` — persistence round-trip, explicit-only writes, corrupt quarantine + (timestamped, no collision on double corruption). +- `toolchainsResolve.test.ts` — precedence matrix (explicit custom/unconfigured over managed/ + bundled/system; missing everything → unconfigured), npx rewrite, env PATH prepend. +- `toolchainsInstall.test.ts` — fake fetcher + fake extractor: sha256 mismatch fails without + touching the active tree; success activates atomically; cancel between phases leaves previous + tree active; `.prev` rotation. +- `toolchainsRoutes.test.ts` — dispatcher route surface. diff --git a/docs/features/managed-toolchains/spec.md b/docs/features/managed-toolchains/spec.md new file mode 100644 index 000000000..3f47da755 --- /dev/null +++ b/docs/features/managed-toolchains/spec.md @@ -0,0 +1,96 @@ +# Spec: Managed toolchains (Node, uv, ripgrep) + +Inspired by ThinkInAIXYZ/deepchat#2193 ("move Node and uv to managed installs"), re-designed for +Argos' daemon-first architecture. The upstream RFC's core ideas — one resolver, an explicit +persisted source, verified managed installs, atomic activation — apply directly; the +Electron-specific parts (installer Node removal, CLI hosting, OCR ABI gates) do not map and are +not attempted. + +## Problem + +Argos has no central runtime resolver. Today: + +1. **The daemon is runtime-blind.** `acpPorts.ts` and `daemonMcpPorts.ts` ship identity/no-op + runtime ports, so the headless daemon (the first-class deployment per `distro/`) can only run + `npx`-distribution ACP agents or `uvx` MCP servers if Node/uv happen to be on the daemon's + PATH. The bundled `runtime/uv` and `runtime/ripgrep` shipped inside the desktop app are + invisible to it (the sidecar spawn forwards only `process.env`). +2. **Three divergent resolution paths**: desktop `RuntimeHelper` (Electron-coupled), the host-port + seams with two divergent implementations, and ad-hoc per-consumer logic (pi worker, sidecar, + DuckDB extensions). +3. **No verification**: `installRuntime.mjs` pins versions inline without checksums; ACP binary + downloads are unverified `fetch`es. +4. **No toolchain UX**: missing runtimes surface as raw spawn failures or thrown errors; there is + no status view, install, or repair flow. + +## Goals + +- One daemon-owned `ToolchainService` resolves `node`, `uv`, and `ripgrep` through an explicit + persisted source: `bundled | managed | system | custom | unconfigured`. +- Managed installs download pinned archives with SHA-256 verification, extract to a staging dir, + and activate atomically via rename. A failed or cancelled install leaves the previous tree + active. +- Daemon consumers resolve only through the service: ACP launch (npx/uvx/binary agents) and MCP + stdio (`npx`/`uvx`/`uv` commands). `npx` is rewritten to `node ` so managed + Node works without shell/`.cmd` spawning. +- Bundled detection works for the headless daemon (probe `execDir/../runtime`, `execDir/runtime`, + `cwd/runtime`), so packaged daemons see the uv/ripgrep seed. +- Settings page ("Toolchains") with per-tool source, resolved path/version, install/repair/ + cancel/revert actions. +- Corrupt `state.json` is quarantined under a timestamped name and state resets to unconfigured + (upstream review flagged a fixed-name quarantine collision; we fix it from the start). + +## Non-goals (documented follow-ups) + +- Windows login-shell PATH refresh (detection stays `process.env` on win32; system detection + additionally scans nvm/volta/default install dirs). +- Download resume; checksums for build-time bundled seeds (`installRuntime.mjs`); ACP agent + archive checksums. +- Migrating desktop `RuntimeHelper`/`SkillExecutionService` onto the service (desktop keeps its + existing resolution; the daemon is the scope). +- pi worker / sidecar bun resolution (Bun is self-hosted by the daemon binary; nothing to manage). +- Per-skill python/node policy migration; OCR-style ABI gating (no OCR in Argos). +- Missing-toolchain aggregated banner outside the settings page (consumers fail with typed errors + that reach existing failure paths). + +## Decisions + +- **D1 — Daemon-owned service** at `apps/daemon/src/host/toolchains/`, Bun-runtime code + (`Bun.file`/`Bun.write` per the bun-file-io rule). Desktop reaches it only via + `toolchains.*` routes. +- **D2 — Persist only explicit sources** (upstream's `persist first-run sources` evolution, + learned the hard way): `state.json` records `custom` and `unconfigured` choices marked + `explicit: true`. `bundled`/`managed`/`system` are derived on demand: bundled = seed found on + disk; managed = installed tree present; system = found on PATH/dirs. Precedence: + explicit custom → explicit unconfigured → managed → bundled → system → unconfigured. + Rationale: derived selections keep working when PATH refreshes or the bundled seed disappears, + and cannot leak a stale pointer. +- **D3 — Pins + real checksums in `catalog.ts`**: Node `v24.18.0` (nodejs.org SHASUMS256) and + uv `0.9.18` (GitHub release assets, hashes captured from the release artifacts at + implementation time). Filenames embed the version for Node; uv asset names do not, so the + catalog test asserts the hash table is keyed per tool+asset and non-empty — a pin bump without + hashes fails review, not first install. +- **D4 — Atomic activation**: download → `staging/` dir → verify sha256 → extract to + `tools/-.staging` → rename to `tools/-` → update the `active` + pointer in state. Previous tree is kept as `tools/-.prev` (rotated, never + deleted while active — EBUSY/EPERM on Windows classifies as a disk error, per upstream's + `archive busy previous trees` fix). Cancel is a cooperative flag checked between phases. +- **D5 — `npx` rewrite, not `.cmd` spawn**: `resolveCommand("npx", args)` returns + `/ [node_modules/npm/bin/npx-cli.js, ...args]` (and `npm` similarly). + `.cmd` shims require `shell: true`, which the process managers deliberately avoid. +- **D6 — Extraction via `tar`**: `tar -xf` handles both `.zip` (Windows ships bsdtar) and + `.tar.gz`. No new extract dependency. +- **D7 — consumers**: daemon `acpPorts.resolveCommand/buildSpawnEnv` and + `daemonMcpPorts.getBunRuntimePath/getUvRuntimePath/processCommandWithArgs` route through the + service. Desktop hosts are unchanged in this PR. +- **D8 — Settings page** `settings-toolchains` in the `tools` nav group; `ToolchainClient` + (`packages/ui/api/ToolchainClient.ts`) over `toolchains.*` routes. + +## Risks / constraints + +- Node tarballs are ~30 MB; install progress is reported via `toolchains.status` polling of the + service's in-memory install job (no event stream in v1). +- `system` detection quality varies per platform (documented; upstream has the same known gap). +- If neither managed nor bundled nor system node exists, `resolveCommand("npx")` returns the + input unchanged — existing behavior (PATH lookup fails downstream with a clear spawn error) + rather than a new crash path. diff --git a/docs/features/managed-toolchains/tasks.md b/docs/features/managed-toolchains/tasks.md new file mode 100644 index 000000000..8c023f168 --- /dev/null +++ b/docs/features/managed-toolchains/tasks.md @@ -0,0 +1,25 @@ +# Tasks: Managed toolchains + +- [x] T1 Contracts: `toolchains.*` routes + catalog entries +- [x] T2 Daemon: state store (persist explicit sources, timestamped quarantine) +- [x] T3 Daemon: catalog (Node v24.18.0 + uv 0.9.18, real sha256, per-platform archives) +- [x] T4 Daemon: resolver (precedence, bundled probing, system detection, version probe cache) +- [x] T5 Daemon: installer (sha256-verified download, staging, atomic activation, `.prev` + rotation, cooperative cancel) +- [x] T6 Daemon: `ToolchainService` facade + sync/async `resolveCommand` rewrites +- [x] T7 Daemon wiring: index.ts, `acpPorts` (new `resolveCommandWithArgs` seam), + `daemonMcpPorts`, dispatcher routes +- [x] T8 UI: nav item (`settings-toolchains`, tools group) + `ToolchainClient` + + `ToolchainsSettings` page +- [x] T9 Tests: state (5), service/resolver/rewrite (6), install pipeline (4), routes (5) +- [x] T10 Docs: fix bundled-runtime drift (AGENTS.md / CONTRIBUTING.md) +- [x] T11 `bun run format` + `bun run lint` + `bun run typecheck` + `bun test` + +## Verification results + +- Daemon: 405 tests pass (21 new); `tsc --noEmit` clean. +- Desktop + UI: `test:main` 1737 passed / 6 skipped; both typechecks clean. +- `bun run lint`: agent-cleanup, architecture, and route-catalog drift guards (416 routes) + + oxlint clean. +- Managed-install checksums captured from the official release artifacts at implementation + time (Node SHASUMS256.txt; uv release assets hashed locally). diff --git a/packages/acp-runtime/src/host/ports.ts b/packages/acp-runtime/src/host/ports.ts index c3c9fb8f7..635346276 100644 --- a/packages/acp-runtime/src/host/ports.ts +++ b/packages/acp-runtime/src/host/ports.ts @@ -26,6 +26,17 @@ export interface RuntimePort { expandPath(target: string): string; /** Swap a bare command (npx/npm/node/uvx) for the bundled bin when enabled. */ resolveCommand(command: string, useBundled: boolean, checkExists: boolean): string; + /** + * Optional command+args rewrite for hosts that manage runtimes themselves + * (e.g. `npx -y pkg` -> `node npx-cli.js -y pkg` under a managed Node). + * When it resolves, its result wins over `resolveCommand`; returning null + * falls back to `resolveCommand` with the args untouched. + */ + resolveCommandWithArgs?(input: { + command: string; + args: string[]; + useBundled: boolean; + }): Promise<{ command: string; args: string[] } | null>; /** Prepend bundled runtime dirs to PATH in the spawn env. */ buildSpawnEnv(base: Record): Record; } diff --git a/packages/acp-runtime/src/process/acpProcessManager.ts b/packages/acp-runtime/src/process/acpProcessManager.ts index 0bc9b52de..68a8f9b58 100644 --- a/packages/acp-runtime/src/process/acpProcessManager.ts +++ b/packages/acp-runtime/src/process/acpProcessManager.ts @@ -1213,8 +1213,17 @@ export class AcpProcessManager implements AgentProcessManager "${processedCommand}"`); } - // Use expanded args - const processedArgs = expandedArgs; - let env = mergeCommandEnvironment(); let shellEnv: Record = {}; diff --git a/packages/shared-contracts/src/routes.ts b/packages/shared-contracts/src/routes.ts index 574224701..cf87f595f 100644 --- a/packages/shared-contracts/src/routes.ts +++ b/packages/shared-contracts/src/routes.ts @@ -395,6 +395,13 @@ import { systemSetPendingProviderInstallRoute, } from "./routes/system.routes"; import { toolsListDefinitionsRoute } from "./routes/tools.routes"; +import { + toolchainsListRoute, + toolchainsSetSourceRoute, + toolchainsRemoveSourceRoute, + toolchainsInstallRoute, + toolchainsCancelInstallRoute, +} from "./routes/toolchains.routes"; import { memoryListRoute, memoryGetStatusRoute, @@ -497,6 +504,7 @@ export * from "./routes/sync.routes"; export * from "./routes/system.routes"; export * from "./routes/tab.routes"; export * from "./routes/tools.routes"; +export * from "./routes/toolchains.routes"; export * from "./routes/memory.routes"; export * from "./routes/knowledge.routes"; export * from "./routes/upgrade.routes"; @@ -621,6 +629,11 @@ export const ARGOS_ROUTE_CATALOG = { [configCreateArgosAgentRoute.name]: configCreateArgosAgentRoute, [configUpdateArgosAgentRoute.name]: configUpdateArgosAgentRoute, [configDeleteArgosAgentRoute.name]: configDeleteArgosAgentRoute, + [toolchainsListRoute.name]: toolchainsListRoute, + [toolchainsSetSourceRoute.name]: toolchainsSetSourceRoute, + [toolchainsRemoveSourceRoute.name]: toolchainsRemoveSourceRoute, + [toolchainsInstallRoute.name]: toolchainsInstallRoute, + [toolchainsCancelInstallRoute.name]: toolchainsCancelInstallRoute, [configResolveArgosAgentConfigRoute.name]: configResolveArgosAgentConfigRoute, [configGetAgentMcpSelectionsRoute.name]: configGetAgentMcpSelectionsRoute, [configGetAcpSharedMcpSelectionsRoute.name]: configGetAcpSharedMcpSelectionsRoute, diff --git a/packages/shared-contracts/src/routes/system.routes.ts b/packages/shared-contracts/src/routes/system.routes.ts index 0d9ddb25d..be67f8807 100644 --- a/packages/shared-contracts/src/routes/system.routes.ts +++ b/packages/shared-contracts/src/routes/system.routes.ts @@ -11,6 +11,7 @@ export const SettingsRouteNameSchema = zod.enum([ "settings-mcp", "settings-argos-agents", "settings-acp", + "settings-toolchains", "settings-remote", "settings-server", "settings-notifications-hooks", diff --git a/packages/shared-contracts/src/routes/toolchains.routes.ts b/packages/shared-contracts/src/routes/toolchains.routes.ts new file mode 100644 index 000000000..a0ad65579 --- /dev/null +++ b/packages/shared-contracts/src/routes/toolchains.routes.ts @@ -0,0 +1,100 @@ +import zod from "zod"; +import { defineRouteContract } from "../common"; + +/** + * Managed toolchains: one daemon-owned resolver for external runtimes + * (Node, uv, ripgrep) with an explicit persisted source and verified + * managed installs. See docs/features/managed-toolchains. + */ + +export const TOOLCHAIN_NAMES = ["node", "uv", "ripgrep"] as const; +export type ToolchainName = (typeof TOOLCHAIN_NAMES)[number]; + +export const TOOLCHAIN_SOURCES = ["bundled", "managed", "system", "custom", "unconfigured"] as const; +export const TOOLCHAIN_SOURCE_SCHEMA = zod.enum(TOOLCHAIN_SOURCES); +export type ToolchainSource = (typeof TOOLCHAIN_SOURCES)[number]; + +const toolchainNameSchema = zod.enum(TOOLCHAIN_NAMES); + +export const toolchainsInstallStateSchema = zod.object({ + phase: zod.enum(["idle", "downloading", "extracting", "activating"]), + tool: toolchainNameSchema, + version: zod.string(), + startedAt: zod.number(), +}); + +export const toolchainsStatusSchema = zod.object({ + tool: toolchainNameSchema, + source: TOOLCHAIN_SOURCE_SCHEMA, + explicit: zod.boolean(), + path: zod.string().nullable(), + version: zod.string().nullable(), + error: zod.string().nullable(), + pin: zod.string().nullable(), + install: toolchainsInstallStateSchema.nullable(), +}); + +export type ToolchainStatus = zod.infer; +export type ToolchainsInstallState = zod.infer; + +export const toolchainsListRoute = defineRouteContract({ + name: "toolchains.list", + input: zod.object({}).default({}), + output: zod.object({ + tools: zod.array(toolchainsStatusSchema), + }), +}); + +// Only explicit user choices persist: a selected custom path, or an +// explicit "unconfigured". Derived sources (managed/bundled/system) are +// recomputed on demand so a PATH refresh or a removed seed cannot leave a +// stale pointer behind. +export const toolchainsSetSourceRoute = defineRouteContract({ + name: "toolchains.setSource", + input: zod + .object({ + tool: toolchainNameSchema, + source: zod.enum(["custom", "unconfigured"]), + path: zod.string().min(1).optional(), + }) + .superRefine((value, ctx) => { + if (value.source === "custom" && !value.path) { + ctx.addIssue({ code: "custom", message: "A custom source requires a path", path: ["path"] }); + } + }), + output: zod.object({ + status: toolchainsStatusSchema, + }), +}); + +export const toolchainsRemoveSourceRoute = defineRouteContract({ + name: "toolchains.removeSource", + input: zod.object({ + tool: toolchainNameSchema, + }), + output: zod.object({ + status: toolchainsStatusSchema, + }), +}); + +export const toolchainsInstallRoute = defineRouteContract({ + name: "toolchains.install", + input: zod.object({ + tool: zod.enum(["node", "uv"]), + }), + output: zod.object({ + started: zod.boolean(), + status: toolchainsStatusSchema, + }), +}); + +export const toolchainsCancelInstallRoute = defineRouteContract({ + name: "toolchains.cancelInstall", + input: zod.object({ + tool: zod.enum(["node", "uv"]), + }), + output: zod.object({ + cancelled: zod.boolean(), + status: toolchainsStatusSchema, + }), +}); diff --git a/packages/shared/src/settingsNavigation.ts b/packages/shared/src/settingsNavigation.ts index 57a76afcd..573788280 100644 --- a/packages/shared/src/settingsNavigation.ts +++ b/packages/shared/src/settingsNavigation.ts @@ -9,6 +9,7 @@ export interface SettingsNavigationItem { | "settings-mcp" | "settings-argos-agents" | "settings-acp" + | "settings-toolchains" | "settings-remote" | "settings-server" | "settings-notifications-hooks" @@ -143,6 +144,15 @@ export const SETTINGS_NAVIGATION_ITEMS: SettingsNavigationItem[] = [ groupKey: "models", keywords: ["acp", "agent client protocol"], }, + { + routeName: "settings-toolchains", + path: "/toolchains", + titleKey: "routes.settings-toolchains", + icon: "lucide:cpu", + position: 4.75, + groupKey: "tools", + keywords: ["toolchains", "runtime", "node", "uv", "python", "install"], + }, { routeName: "settings-dashboard", path: "/dashboard", @@ -342,6 +352,7 @@ const TITLE_MAP: Record = { "routes.settings-mcp": "MCP Settings", "routes.settings-argos-agents": "Argos Agents", "routes.settings-acp": "ACP Agents", + "routes.settings-toolchains": "Toolchains", "routes.settings-server": "Server", "routes.settings-remote": "Remote", "routes.settings-notifications-hooks": "Hooks", diff --git a/packages/ui/api/ToolchainClient.ts b/packages/ui/api/ToolchainClient.ts new file mode 100644 index 000000000..23cc9761b --- /dev/null +++ b/packages/ui/api/ToolchainClient.ts @@ -0,0 +1,42 @@ +import type { ArgosBridge } from "@argos/shared-contracts/bridge"; +import { + toolchainsCancelInstallRoute, + toolchainsInstallRoute, + toolchainsListRoute, + toolchainsRemoveSourceRoute, + toolchainsSetSourceRoute, +} from "@argos/shared-contracts/routes"; +import type { ArgosRouteInput } from "@argos/shared-contracts/routes"; +import { getArgosBridge } from "./core"; + +export function createToolchainClient(bridge: ArgosBridge = getArgosBridge()) { + async function list() { + return await bridge.invoke(toolchainsListRoute.name, {} as ArgosRouteInput); + } + + async function setSource(input: ArgosRouteInput) { + return await bridge.invoke(toolchainsSetSourceRoute.name, input); + } + + async function removeSource(tool: "node" | "uv" | "ripgrep") { + return await bridge.invoke(toolchainsRemoveSourceRoute.name, { tool }); + } + + async function install(tool: "node" | "uv") { + return await bridge.invoke(toolchainsInstallRoute.name, { tool }); + } + + async function cancelInstall(tool: "node" | "uv") { + return await bridge.invoke(toolchainsCancelInstallRoute.name, { tool }); + } + + return { + list, + setSource, + removeSource, + install, + cancelInstall, + }; +} + +export type ToolchainClient = ReturnType; diff --git a/packages/ui/settings/components/ToolchainsSettings.tsx b/packages/ui/settings/components/ToolchainsSettings.tsx new file mode 100644 index 000000000..fdff19fd7 --- /dev/null +++ b/packages/ui/settings/components/ToolchainsSettings.tsx @@ -0,0 +1,276 @@ +import { useEffect, useEffectEvent, useRef, useState } from "react"; +import { Icon } from "@iconify/react"; +import { Button } from "#shadcn/components/ui/button"; +import { Badge } from "#shadcn/components/ui/badge"; +import { Input } from "#shadcn/components/ui/input"; +import { Skeleton } from "#shadcn/components/ui/skeleton"; +import { toast } from "#/components/use-toast"; +import { createToolchainClient } from "#api/ToolchainClient"; +import type { ToolchainName, ToolchainSource, ToolchainStatus } from "@argos/shared-contracts/routes"; + +const toolchainClient = createToolchainClient(); + +const SOURCE_BADGE_CLASS: Record = { + managed: "bg-green-600/15 text-green-700 dark:text-green-400 border-green-600/30", + bundled: "bg-blue-600/15 text-blue-700 dark:text-blue-400 border-blue-600/30", + system: "bg-violet-600/15 text-violet-700 dark:text-violet-400 border-violet-600/30", + custom: "bg-amber-600/15 text-amber-700 dark:text-amber-400 border-amber-600/30", + unconfigured: "bg-muted text-muted-foreground border-border", +}; + +const TOOL_DESCRIPTIONS: Record = { + node: { + title: "Node.js", + description: + "Runs npx-based ACP agents and Node MCP servers. Managed installs are pinned, SHA-256 verified, and isolated from your system.", + }, + uv: { + title: "uv", + description: + "Runs uvx-based MCP servers and Python tooling. Ships as a bundled seed; a managed install overrides it with the pinned release.", + }, + ripgrep: { + title: "ripgrep", + description: "Fast file search used by agent tools. Resolved from the bundled seed or your system install.", + }, +}; + +const MANAGEABLE: Array = ["node", "uv", "ripgrep"]; + +function SourceBadge({ source }: { source: ToolchainSource }) { + const label = source.charAt(0).toUpperCase() + source.slice(1); + return ( + + {label} + + ); +} + +function ToolchainCard({ + status, + busy, + onInstall, + onCancel, + onRevert, + onSetCustom, +}: { + status: ToolchainStatus; + busy: boolean; + onInstall: (tool: "node" | "uv") => void; + onCancel: (tool: "node" | "uv") => void; + onRevert: (tool: ToolchainName) => void; + onSetCustom: (tool: ToolchainName, path: string) => void; +}) { + const [customPath, setCustomPath] = useState(""); + const [showCustomInput, setShowCustomInput] = useState(false); + const meta = TOOL_DESCRIPTIONS[status.tool]; + const installable = status.tool !== "ripgrep"; + const installing = status.install != null && status.install.phase !== "idle"; + + return ( +
+
+
+ {meta.title} + + {status.version ? {status.version} : null} + {status.pin ? pin {status.pin} : null} +
+
+ {installable ? ( + installing ? ( + + ) : ( + + ) + ) : null} + {status.explicit || status.source === "managed" ? ( + + ) : null} +
+
+ +

{meta.description}

+ + {installing ? ( +
+ + {status.install?.phase === "downloading" ? "Downloading…" : null} + {status.install?.phase === "extracting" ? "Extracting…" : null} + {status.install?.phase === "activating" ? "Activating…" : null} + {status.install?.phase === "idle" ? "Finishing…" : null} +
+ ) : null} + + {status.error ? ( +
+ + {status.error} +
+ ) : null} + +
+ {status.path ?? "Not found — npx/uvx commands will fall back to PATH lookup."} +
+ + {showCustomInput ? ( +
+ setCustomPath(event.target.value)} + placeholder="Path to the executable…" + className="h-8 font-mono text-xs" + /> + + +
+ ) : ( + + )} +
+ ); +} + +export default function ToolchainsSettings() { + const [tools, setTools] = useState(null); + const [loadError, setLoadError] = useState(null); + const [busy, setBusy] = useState(false); + const pollRef = useRef(null); + + const refresh = async () => { + try { + const result = await toolchainClient.list(); + setTools(result.tools); + setLoadError(null); + return result.tools; + } catch (error) { + // Fail visible: keep the previous snapshot (if any) on screen and show + // an explicit retry path instead of skeletons forever. + setLoadError(error instanceof Error ? error.message : String(error)); + setTools((current) => current ?? []); + return []; + } + }; + + const refreshEvent = useEffectEvent(() => void refresh()); + + useEffect(() => { + queueMicrotask(() => refreshEvent()); + }, []); + + // Poll while any install is in flight so progress phases stay live. + const installing = tools?.some((tool) => tool.install != null && tool.install.phase !== "idle"); + const pollTick = useEffectEvent(() => void refresh()); + useEffect(() => { + if (installing && pollRef.current == null) { + pollRef.current = window.setInterval(() => pollTick(), 1500); + } else if (!installing && pollRef.current != null) { + window.clearInterval(pollRef.current); + pollRef.current = null; + } + return () => { + if (pollRef.current != null) { + window.clearInterval(pollRef.current); + pollRef.current = null; + } + }; + }, [installing]); + + const run = async (action: () => Promise, successTitle: string) => { + setBusy(true); + try { + await action(); + await refresh(); + toast({ title: successTitle }); + } catch (error) { + toast({ + title: "Action failed", + description: error instanceof Error ? error.message : String(error), + variant: "destructive", + }); + } + setBusy(false); + }; + + return ( +
+
+

Toolchains

+

+ External runtimes used by ACP agents, MCP servers, and agent tools. Managed installs are pinned and SHA-256 + verified; nothing here mutates your system installation. +

+
+ + {tools == null && !loadError ? ( +
+ {[0, 1, 2].map((index) => ( + + ))} +
+ ) : loadError && !tools?.length ? ( +
+ Could not load toolchains: {loadError} + +
+ ) : tools ? ( +
+ {MANAGEABLE.map((tool) => { + const status = tools.find((entry) => entry.tool === tool); + if (!status) return null; + return ( + void run(() => toolchainClient.install(name), `Installing ${name}…`)} + onCancel={(name) => void run(() => toolchainClient.cancelInstall(name), "Cancellation requested")} + onRevert={(name) => void run(() => toolchainClient.removeSource(name), "Reverted")} + onSetCustom={(name, path) => + void run(() => toolchainClient.setSource({ tool: name, source: "custom", path }), "Custom path saved") + } + /> + ); + })} +
+ ) : null} +
+ ); +} diff --git a/packages/ui/settings/main.tsx b/packages/ui/settings/main.tsx index f59134cd2..2d4d63ed3 100644 --- a/packages/ui/settings/main.tsx +++ b/packages/ui/settings/main.tsx @@ -23,6 +23,7 @@ import ModelProviderSettings from "./components/ModelProviderSettings"; import McpSettings from "./components/McpSettings"; import ArgosAgentsSettings from "./components/ArgosAgentsSettings"; import AcpSettings from "./components/AcpSettings"; +import ToolchainsSettings from "./components/ToolchainsSettings"; import RemoteSettings from "./components/RemoteSettings"; import ServerSettings from "./components/ServerSettings"; import NotificationsHooksSettings from "./components/NotificationsHooksSettings"; @@ -65,6 +66,7 @@ const componentMap: Record = { "settings-mcp": McpSettings, "settings-argos-agents": ArgosAgentsSettings, "settings-acp": AcpSettings, + "settings-toolchains": ToolchainsSettings, "settings-remote": RemoteSettings, "settings-server": ServerSettings, "settings-notifications-hooks": NotificationsHooksSettings,