diff --git a/opencode-bridge.ts b/opencode-bridge.ts index b3a6e22c..7c02eea6 100644 --- a/opencode-bridge.ts +++ b/opencode-bridge.ts @@ -2305,9 +2305,10 @@ export function setupOpenCodeBridge(ipcMain, _getWindows) { // ----------------------------------------------------------------------- function getSkillsCli() { + const command = process.platform === "win32" ? "npx.cmd" : "npx"; try { - execSync("npx skills --version", { stdio: "ignore", timeout: 10_000 }); - return "npx"; + execSync(`${command} skills --version`, { stdio: "ignore", timeout: 10_000 }); + return command; } catch { return null; } @@ -2323,10 +2324,12 @@ export function setupOpenCodeBridge(ipcMain, _getWindows) { const args = ["skills", "add", source, "-y"]; if (globalScope) args.push("-g"); const env = { ...process.env, DISABLE_TELEMETRY: "1", DO_NOT_TRACK: "1" }; + const workingDirectory = globalScope || !cwd || cwd === "/" ? homedir() : cwd; const child = spawn(cli, args, { - cwd: globalScope ? homedir() : cwd, + cwd: workingDirectory, stdio: ["ignore", "pipe", "pipe"], env, + shell: process.platform === "win32", windowsHide: true, }); @@ -2375,10 +2378,13 @@ export function setupOpenCodeBridge(ipcMain, _getWindows) { const args = ["skills", "rm", skillName, "-y"]; if (globalScope) args.push("-g"); const env = { ...process.env, DISABLE_TELEMETRY: "1", DO_NOT_TRACK: "1" }; + const workingDirectory = + globalScope || !directory || directory === "/" ? homedir() : directory; const child = spawn(cli, args, { - cwd: globalScope ? homedir() : directory, + cwd: workingDirectory, stdio: ["ignore", "pipe", "pipe"], env, + shell: process.platform === "win32", windowsHide: true, }); @@ -2417,10 +2423,13 @@ export function setupOpenCodeBridge(ipcMain, _getWindows) { if (globalScope) args.push("-g"); args.push("-y"); const env = { ...process.env, DISABLE_TELEMETRY: "1", DO_NOT_TRACK: "1" }; + const workingDirectory = + globalScope || !directory || directory === "/" ? homedir() : directory; const child = spawn(cli, args, { - cwd: globalScope ? homedir() : directory, + cwd: workingDirectory, stdio: ["ignore", "pipe", "pipe"], env, + shell: process.platform === "win32", windowsHide: true, }); diff --git a/server/web-server.ts b/server/web-server.ts index 67a8e8c3..d07b262d 100644 --- a/server/web-server.ts +++ b/server/web-server.ts @@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto"; import { existsSync } from "node:fs"; import { mkdir, readFile, readdir, realpath, stat, writeFile } from "node:fs/promises"; import { homedir, tmpdir } from "node:os"; -import { basename, dirname, extname, join, resolve } from "node:path"; +import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path"; import type { HarnessEvent } from "../src/agents/backend.ts"; import type { HarnessId } from "../src/agents/index.ts"; import { @@ -425,6 +425,13 @@ function parseAllowedRoots() { const allowedRoots = parseAllowedRoots(); +function isWithinAllowedRoot(path: string) { + return allowedRoots.some((root) => { + const relativePath = relative(root, path); + return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath)); + }); +} + function parsePositiveIntegerEnv(name: string, fallback: number) { const raw = process.env[name]?.trim(); if (!raw) return fallback; @@ -488,8 +495,7 @@ async function resolveSafeDirectory(inputPath: string | null) { const actual = await realpath(requested); const info = await stat(actual); if (!info.isDirectory()) throw new Error("Path is not a directory"); - const allowed = allowedRoots.some((root) => actual === root || actual.startsWith(`${root}/`)); - if (!allowed) throw new Error("Path outside OPENGUI_ALLOWED_ROOTS"); + if (!isWithinAllowedRoot(actual)) throw new Error("Path outside OPENGUI_ALLOWED_ROOTS"); return actual; } @@ -501,7 +507,7 @@ async function listServerDirectories(inputPath: string | null) { .map((entry) => ({ name: entry.name, path: join(path, entry.name), type: "dir" as const })) .sort((a, b) => a.name.localeCompare(b.name)); const parent = dirname(path); - const canGoUp = allowedRoots.some((root) => parent === root || parent.startsWith(`${root}/`)); + const canGoUp = isWithinAllowedRoot(parent); return { path, parent: canGoUp ? parent : null, roots: allowedRoots, entries: dirs }; } diff --git a/src/App.tsx b/src/App.tsx index 380b4583..613aa3f8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -51,7 +51,13 @@ import { SetupWizard } from "./components/SetupWizard"; import { TitleBar } from "./components/TitleBar"; import "./index.css"; -function AppContent({ detachedProject }: { detachedProject?: string }) { +function AppContent({ + detachedProject, + suppressBootErrors, +}: { + detachedProject?: string; + suppressBootErrors?: boolean; +}) { const client = useOpenGuiClient(); const lastEscapeAtRef = useRef(0); const [queueMode, setQueueMode] = useState("queue"); @@ -288,6 +294,7 @@ function AppContent({ detachedProject }: { detachedProject?: string }) { const isBooting = bootState === "checking-server" || bootState === "starting-server"; useEffect(() => { + if (suppressBootErrors) return; if (isBooting) return; const message = bootState === "error" ? bootError : lastError; if (!message) return; @@ -295,7 +302,7 @@ function AppContent({ detachedProject }: { detachedProject?: string }) { description: bootState === "error" && normalizedBootLogs ? normalizedBootLogs : undefined, duration: 8000, }); - }, [bootState, bootError, isBooting, lastError, normalizedBootLogs]); + }, [bootState, bootError, isBooting, lastError, normalizedBootLogs, suppressBootErrors]); useEffect(() => { let cancelled = false; @@ -529,7 +536,7 @@ export function App() { - + {showWizard && setShowWizard(false)} />} diff --git a/src/components/DiscoverPlugins.tsx b/src/components/DiscoverPlugins.tsx index f34b7626..2263659c 100644 --- a/src/components/DiscoverPlugins.tsx +++ b/src/components/DiscoverPlugins.tsx @@ -3,7 +3,7 @@ import { toast } from "sonner"; import { Globe, Loader2, Search, X } from "lucide-react"; import { useTranslation } from "react-i18next"; import { usePluginsPlatform } from "@/hooks/use-plugins-platform"; -import { useConnectionState } from "@/hooks/use-agent-state"; +import { useActions, useConnectionState } from "@/hooks/use-agent-state"; import { Input } from "@/components/ui/input"; import { Spinner } from "@/components/ui/spinner"; import { @@ -69,6 +69,7 @@ export function DiscoverPlugins() { const pluginsApi = usePluginsPlatform(); const catalogApi = pluginsApi?.marketplace; const { activeDirectory } = useConnectionState(); + const { refreshProviders } = useActions(); const shell = useDesktopShell(); const [query, setQuery] = useState(""); @@ -198,7 +199,11 @@ export function DiscoverPlugins() { ); const runPluginAction = useCallback( - async (key: string, action: () => Promise, skillName?: string) => { + async ( + key: string, + action: () => Promise<{ exitCode?: number } | void>, + skillName?: string, + ) => { setBusyKeys((prev) => new Set(prev).add(key)); setInstallProgress({ phase: "starting", @@ -207,16 +212,28 @@ export function DiscoverPlugins() { }); setShowProgress(true); try { - await action(); - await fetchInstalled(); - } catch {} + const result = await action(); + if (result?.exitCode !== undefined && result.exitCode !== 0) { + setInstallProgress((prev) => ({ ...prev, phase: "failed" })); + } else { + setInstallProgress((prev) => ({ ...prev, phase: "completed" })); + await fetchInstalled(); + await refreshProviders(); + } + } catch (error) { + setInstallProgress((prev) => ({ + ...prev, + phase: "failed", + rawLines: [...prev.rawLines, error instanceof Error ? error.message : String(error)], + })); + } setBusyKeys((prev) => { const next = new Set(prev); next.delete(key); return next; }); }, - [fetchInstalled], + [fetchInstalled, refreshProviders], ); // Install @@ -230,7 +247,11 @@ export function DiscoverPlugins() { if (!pluginsApi) return; const key = installed.remoteKey || installed.location; await runPluginAction(key, async () => { - await pluginsApi.update(installed.name, scopedDirectory, installed.scope === "global"); + return await pluginsApi.update( + installed.name, + scopedDirectory, + installed.scope === "global", + ); }); }, [pluginsApi, scopedDirectory, runPluginAction], @@ -241,7 +262,11 @@ export function DiscoverPlugins() { if (!pluginsApi) return; const key = installed.remoteKey || installed.location; await runPluginAction(key, async () => { - await pluginsApi.remove(installed.name, scopedDirectory, installed.scope === "global"); + return await pluginsApi.remove( + installed.name, + scopedDirectory, + installed.scope === "global", + ); }); }, [pluginsApi, scopedDirectory, runPluginAction], @@ -254,7 +279,7 @@ export function DiscoverPlugins() { await runPluginAction( key, async () => { - await pluginsApi.install(source, scopedDirectory, globalScope); + return await pluginsApi.install(source, scopedDirectory, globalScope); }, installPlugin.name, ); diff --git a/src/components/SetupWizard.tsx b/src/components/SetupWizard.tsx index 4340f42b..f769e38b 100644 --- a/src/components/SetupWizard.tsx +++ b/src/components/SetupWizard.tsx @@ -9,6 +9,7 @@ import { LoaderCircle, RotateCw, Terminal, + X, } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -136,8 +137,18 @@ export function SetupWizard({ onComplete }: Props) { return (
-
-
+
+
+
{Math.max(currentStepNumber, 0) + 1} / 4 @@ -163,7 +174,7 @@ export function SetupWizard({ onComplete }: Props) {
{step === "harness" && ( -
+
{harnessState === "detecting" && ( } @@ -217,7 +228,7 @@ export function SetupWizard({ onComplete }: Props) { )} {step === "opencode" && ( -
+
+
} title={t("setupWizard.defaultChatDirectoryTitle")} @@ -310,14 +321,14 @@ export function SetupWizard({ onComplete }: Props) { )} {step === "appearance" && ( -
+
setStep("folder")} onNext={() => setStep("finish")} />
)} {step === "finish" && ( -
+
} title={