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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion frontend/src/components/NavigationSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Boxes, FolderCog, Gauge, Languages, Layers3 } from "lucide-react";
import { NavLink } from "react-router-dom";

import { type TranslationKey, useI18n } from "../i18n";
import { useWizard } from "../state/WizardContext";
import { TaskCenter } from "./TaskCenter";
import { ThemePicker } from "./ThemePicker";

Expand All @@ -16,6 +17,7 @@ const navItems: Array<{ to: string; label: TranslationKey | "Provider"; icon: ty

export function NavigationSidebar() {
const { locale, setLocale, t } = useI18n();
const { state } = useWizard();
return (
<aside className="navigation-sidebar">
<div className="brand-lockup">
Expand Down Expand Up @@ -55,7 +57,9 @@ export function NavigationSidebar() {
</select>
</label>

<TaskCenter />
{/* Last child, so the language picker's margin-top: auto pushes both to
the bottom of the sidebar as one group. */}
<TaskCenter logDir={state.status?.paths.logs || ""} />
</aside>
);
}
39 changes: 38 additions & 1 deletion frontend/src/components/RuntimeSection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { OneAgentApiError } from "../backend/errors";
import type { RuntimeStatus } from "../types/api";
import { RuntimePrompt } from "./RuntimePrompt";
import { RuntimeSection } from "./RuntimeSection";
import { RuntimeSection, runtimeRoot } from "./RuntimeSection";

const installRuntime = vi.fn();
const getSettings = vi.fn();
Expand Down Expand Up @@ -106,6 +106,43 @@ describe("RuntimeSection", () => {
});
});

describe("runtimeRoot", () => {
// The install note used to spell out "~/.oneagent/runtimes", which names a
// path that does not exist on Windows. The directory now comes from the
// backend's installPath, so both separator styles have to work.
it("names the managed parent from a Windows path", () => {
expect(runtimeRoot([runtime({ installPath: "C:\\Users\\u\\.oneagent\\runtimes\\node\\v24.18.1" })])).toBe(
"C:\\Users\\u\\.oneagent\\runtimes",
);
});

it("names the managed parent from a POSIX path", () => {
expect(runtimeRoot([runtime()])).toBe("/home/user/.oneagent/runtimes");
});

it("skips a runtime with no path and uses the next one", () => {
expect(runtimeRoot([runtime({ installPath: "" }), runtime()])).toBe("/home/user/.oneagent/runtimes");
});

it("does not collapse a mixed-separator path to the drive letter", () => {
// Picking one separator for the whole string turned this into "C:", which
// the note then rendered as the install directory.
expect(runtimeRoot([runtime({ installPath: "C:\\Users\\u/.oneagent/runtimes/node/v1" })])).toBe(
"C:\\Users\\u\\.oneagent\\runtimes",
);
});

it("ignores a path too short to have a managed parent", () => {
expect(runtimeRoot([runtime({ installPath: "node/v1" })])).toBe("");
});

it("returns nothing rather than a half path when no runtime carries one", () => {
// The caller falls back to a sentence without a directory; returning a
// fragment here would render "运行时会安装到 ,".
expect(runtimeRoot([runtime({ installPath: "" })])).toBe("");
});
});

describe("RuntimePrompt", () => {
beforeEach(() => {
installRuntime.mockReset();
Expand Down
28 changes: 27 additions & 1 deletion frontend/src/components/RuntimeSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,27 @@ interface RuntimeSectionProps {
onInstalled: () => void | Promise<void>;
}

/**
* The directory the runtimes land in, taken from the backend rather than spelled
* out here. `installPath` is an absolute, platform-correct path — on Windows it
* reads C:\Users\<name>\.oneagent\runtimes\..., which a hardcoded "~/.oneagent"
* would misreport. Two segments come off the end (the runtime id and its
* versioned directory) to name the shared parent.
*/
export function runtimeRoot(runtimes: RuntimeStatus[]): string {
for (const runtime of runtimes) {
// Split on either separator rather than picking one for the whole string: a
// path that mixes them (a drive letter followed by forward slashes) would
// otherwise collapse to "C:" and render that as the install directory.
const segments = runtime.installPath?.split(/[/\\]/) ?? [];
if (segments.length < 3) continue;
const separator = runtime.installPath.includes("\\") ? "\\" : "/";
const parent = segments.slice(0, -2).join(separator);
if (parent) return parent;
}
return "";
}

export function RuntimeSection({ runtimes, onInstalled }: RuntimeSectionProps) {
const { t } = useI18n();
const { resetProgress } = useTaskCenter();
Expand All @@ -25,6 +46,9 @@ export function RuntimeSection({ runtimes, onInstalled }: RuntimeSectionProps) {
const supported = runtimes.filter((runtime) => runtime.supported || runtime.installed);
if (!supported.length) return null;
const missing = supported.filter((runtime) => !runtime.installed);
// Any supported runtime carries the same managed root, so the whole list is
// the source rather than just the missing ones.
const root = runtimeRoot(supported);

const install = async (runtimeId: string) => {
setPending(runtimeId);
Expand Down Expand Up @@ -93,7 +117,9 @@ export function RuntimeSection({ runtimes, onInstalled }: RuntimeSectionProps) {
</div>
{missing.length ? (
<p className="runtime-note">
{t("运行时会安装到 ~/.oneagent/runtimes,并写入登录 PATH,不需要管理员权限。")}
{root
? t("运行时会安装到 {dir},并写入登录 PATH,不需要管理员权限。").replace("{dir}", root)
: t("运行时会安装到 OneAgent 的托管目录,并写入登录 PATH,不需要管理员权限。")}
</p>
) : null}
<MirrorSetting label={t("下载源")} />
Expand Down
29 changes: 29 additions & 0 deletions frontend/src/components/TaskCenter.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,35 @@ describe("TaskCenter", () => {
expect(screen.queryByText("boom")).toBeNull();
expect(screen.getByText(/暂无任务日志/)).toBeTruthy();
});

it("shows the backend's own log directory, not a Unix path", async () => {
// The label used to hardcode "~/.oneagent/logs", which names a directory
// that does not exist on Windows.
const user = userEvent.setup();
// Braces, not a bare attribute string: JSX string literals do not process
// backslash escapes, so logDir="C:\\Users" would pass two backslashes.
const windowsDir = "C:\\Users\\u\\.oneagent\\logs";
render(
<TaskCenterProvider>
<TaskCenter logDir={windowsDir} />
</TaskCenterProvider>,
);
await user.click(screen.getByRole("button", { name: /任务中心/ }));
expect(screen.getByText(`完整日志:${windowsDir}`)).toBeTruthy();
expect(screen.queryByText(/~\/\.oneagent/)).toBeNull();
});

it("drops the path from the label when the backend reported none", async () => {
const user = userEvent.setup();
render(
<TaskCenterProvider>
<TaskCenter />
</TaskCenterProvider>,
);
await user.click(screen.getByRole("button", { name: /任务中心/ }));
// "完整日志:" with nothing after it would read as a missing value.
expect(screen.getByText("完整日志")).toBeTruthy();
});
});

describe("DownloadProgress", () => {
Expand Down
13 changes: 10 additions & 3 deletions frontend/src/components/TaskCenter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,14 @@ import { useTaskCenter } from "../state/TaskCenterContext";
*
* Installs run without a console, so this is where a user sees what a command
* is doing while it runs. Internal downloads use their install row rather than
* a command-shaped log line. The durable copy is in ~/.oneagent/logs/<date>.log.
* a command-shaped log line.
*
* @param logDir Absolute directory the backend reports as `paths.logs`, holding
* one file per day. Passed in rather than read from context so this stays
* renderable on its own; on Windows it is C:\Users\<name>\.oneagent\logs, which
* a literal "~/.oneagent/logs" misreports.
*/
export function TaskCenter() {
export function TaskCenter({ logDir = "" }: { logDir?: string }) {
const { t } = useI18n();
const { log, progress, clear } = useTaskCenter();
const [open, setOpen] = useState(false);
Expand Down Expand Up @@ -49,7 +54,9 @@ export function TaskCenter() {
<p className="task-center-empty">{t("暂无任务日志,安装 Agent 时会显示在这里。下载进度会显示在对应安装区域。")}</p>
)}
<div className="task-center-actions">
<span>{t("完整日志:~/.oneagent/logs")}</span>
<span title={logDir || undefined}>
{logDir ? t("完整日志:{dir}").replace("{dir}", logDir) : t("完整日志")}
</span>
<button type="button" onClick={clear} disabled={!lines.length}>
<Trash2 size={14} aria-hidden="true" />
{t("清空")}
Expand Down
6 changes: 4 additions & 2 deletions frontend/src/i18n.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,8 @@ const english = {
"任务中心": "Task center",
"有任务正在运行": "A task is running",
"暂无任务日志,安装 Agent 时会显示在这里。下载进度会显示在对应安装区域。": "No task logs yet. Agent commands will show output here. Downloads show progress in their install area.",
"完整日志:~/.oneagent/logs": "Full logs: ~/.oneagent/logs",
"完整日志:{dir}": "Full logs: {dir}",
"完整日志": "Full logs",
"清空": "Clear",
"下载进度": "Download progress",
"已下载 {done} MB / {total} MB({percent}%)": "Downloaded {done} MB of {total} MB ({percent}%)",
Expand Down Expand Up @@ -182,7 +183,8 @@ const english = {
"本机已有": "Already on this machine",
"安装": "Install",
"安装中": "Installing",
"运行时会安装到 ~/.oneagent/runtimes,并写入登录 PATH,不需要管理员权限。": "Runtimes install into ~/.oneagent/runtimes and are added to your login PATH. No administrator rights needed.",
"运行时会安装到 {dir},并写入登录 PATH,不需要管理员权限。": "Runtimes install into {dir} and are added to your login PATH. No administrator rights needed.",
"运行时会安装到 OneAgent 的托管目录,并写入登录 PATH,不需要管理员权限。": "Runtimes install into OneAgent's managed directory and are added to your login PATH. No administrator rights needed.",
"运行时下载": "Runtime downloads",
"下载源": "Download source",
"Agent 安装源": "Agent install source",
Expand Down
5 changes: 5 additions & 0 deletions internal/app/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,11 @@ func (u *UseCases) GetStatus(ctx context.Context) (StatusResponse, error) {
options := u.status
paths := map[string]string{
"profile": filepath.Join(options.Home, ".oneagent", "profile.json"),
// The Task Center points users at this directory when a command fails.
// It has to come from here rather than being spelled out in the UI: a
// hardcoded "~/.oneagent/logs" names a path that does not exist on
// Windows, where this resolves to C:\Users\<name>\.oneagent\logs.
"logs": CommandLogDir(options.Home),
}
capabilities := Capabilities{
CanInstall: make(map[string]bool, len(manifest.Agents)),
Expand Down
5 changes: 5 additions & 0 deletions internal/app/status_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ func TestStatusUsesInjectedHomeAndCommandLookup(t *testing.T) {
if status.Paths["profile"] != filepath.Join(home, ".oneagent", "profile.json") {
t.Fatalf("profile path escaped injected home: %q", status.Paths["profile"])
}
// The Task Center renders this directory verbatim. Without it the UI has to
// spell out "~/.oneagent/logs", which names nothing on Windows.
if status.Paths["logs"] != CommandLogDir(home) {
t.Fatalf("logs path = %q, want %q", status.Paths["logs"], CommandLogDir(home))
}
wire, err := json.Marshal(status)
if err != nil {
t.Fatal(err)
Expand Down
1 change: 1 addition & 0 deletions internal/app/testdata/status-empty-linux-arm64.json
Original file line number Diff line number Diff line change
Expand Up @@ -533,6 +533,7 @@
}
],
"paths": {
"logs": "${HOME}/.oneagent/logs",
"profile": "${HOME}/.oneagent/profile.json",
"codex_config": "${HOME}/.codex/config.toml",
"claude-code_config": "${HOME}/.claude/settings.json",
Expand Down
121 changes: 121 additions & 0 deletions internal/install/path_platform_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package install

import (
"context"
"path/filepath"
"strings"
"testing"
"time"

"github.com/MaimoryLab/OneAgent/internal/platform"
"github.com/MaimoryLab/OneAgent/internal/process"
"github.com/MaimoryLab/OneAgent/internal/securefs"
)

// A Runner that answers PowerShell lookups and records the argv it is handed,
// so the Windows PATH branch can be asserted on any host without touching the
// real user environment.
type pathRunner struct {
found map[string]string
calls [][]string
stdout string
exitCode int
}

func (r *pathRunner) LookPath(command string) (string, bool) {
path, ok := r.found[command]
return path, ok
}

func (r *pathRunner) Run(_ context.Context, argv []string, _ map[string]string, _ time.Duration) (process.Result, error) {
r.calls = append(r.calls, append([]string(nil), argv...))
return process.Result{Args: argv, Stdout: r.stdout, ExitCode: r.exitCode}, nil
}

func windowsRuntime(home string, runner process.Runner) Runtime {
return NewRuntime(home, platform.For("windows", "amd64"), runner, map[string]string{"USERPROFILE": home})
}

func TestPersistRuntimePathRewritesOnlyTheUserPathOnWindows(t *testing.T) {
runner := &pathRunner{found: map[string]string{"powershell": `C:\Windows\powershell.exe`}, stdout: "updated\n"}
managed := filepath.Join(RuntimeRoot(`C:\Users\u`), "node", "v1", "bin")
changed, err := PersistRuntimePath(context.Background(), windowsRuntime(`C:\Users\u`, runner), securefs.New(securefs.Options{OS: "windows"}), []string{managed})
if err != nil || !changed {
t.Fatalf("PersistRuntimePath = %v, %v", changed, err)
}
if len(runner.calls) != 1 {
t.Fatalf("expected one PowerShell call, got %d", len(runner.calls))
}
argv := runner.calls[0]
script := strings.Join(argv, " ")
// The directory has to reach the script, or the install silently records
// nothing. Separators are not asserted: filepath.Join follows the host that
// compiled the test, so a windows/amd64 build emits "\" here while this same
// test run on macOS emits "/". Only the windows binary's output is the
// product's behaviour.
if !strings.Contains(script, managed) {
t.Errorf("managed directory %q is absent from the script: %s", managed, script)
}
// 'User' scope only: the machine-wide PATH needs elevation and would leak
// this install into every other account on the box.
if !strings.Contains(script, "'Path','User'") {
t.Errorf("script does not scope PATH to the user: %s", script)
}
if strings.Contains(script, "'Machine'") {
t.Errorf("script touches the machine-wide PATH: %s", script)
}
// -NoProfile keeps a user's profile from altering the result; Bypass is
// needed because the default client policy refuses to run the script at all.
for _, flag := range []string{"-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass"} {
if !containsArg(argv, flag) {
t.Errorf("missing %s in %v", flag, argv)
}
}
}

func TestPersistRuntimePathReportsNoChangeWhenPowerShellIsSilent(t *testing.T) {
// The script only prints "updated" when it actually rewrote PATH. Reporting
// a change anyway would make the installer claim work it did not do.
runner := &pathRunner{found: map[string]string{"powershell": `C:\Windows\powershell.exe`}}
changed, err := PersistRuntimePath(context.Background(), windowsRuntime(`C:\Users\u`, runner), securefs.New(securefs.Options{OS: "windows"}), []string{`C:\Users\u\.oneagent\runtimes\node\v1\bin`})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if changed {
t.Error("reported a PATH change when PowerShell printed nothing")
}
}

func TestPersistRuntimePathFallsBackToPwsh(t *testing.T) {
// Windows without the legacy powershell.exe still has pwsh; failing there
// would block the install on an otherwise healthy machine.
runner := &pathRunner{found: map[string]string{"pwsh": `C:\pwsh.exe`}, stdout: "updated"}
if _, err := PersistRuntimePath(context.Background(), windowsRuntime(`C:\Users\u`, runner), securefs.New(securefs.Options{OS: "windows"}), []string{`C:\dir`}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(runner.calls) != 1 || runner.calls[0][0] != `C:\pwsh.exe` {
t.Fatalf("did not fall back to pwsh: %v", runner.calls)
}
}

func TestPersistRuntimePathSurfacesAMissingPowerShell(t *testing.T) {
runner := &pathRunner{found: map[string]string{}}
_, err := PersistRuntimePath(context.Background(), windowsRuntime(`C:\Users\u`, runner), securefs.New(securefs.Options{OS: "windows"}), []string{`C:\dir`})
if err == nil {
t.Fatal("expected an error when no PowerShell is present")
}
if !strings.Contains(err.Error(), "PowerShell") {
t.Errorf("error does not name the missing prerequisite: %v", err)
}
}

func TestPersistRuntimePathIsANoOpWithoutDirectories(t *testing.T) {
runner := &pathRunner{found: map[string]string{"powershell": `C:\Windows\powershell.exe`}}
changed, err := PersistRuntimePath(context.Background(), windowsRuntime(`C:\Users\u`, runner), securefs.New(securefs.Options{OS: "windows"}), nil)
if err != nil || changed {
t.Fatalf("PersistRuntimePath = %v, %v", changed, err)
}
if len(runner.calls) != 0 {
t.Errorf("spawned PowerShell with nothing to record: %v", runner.calls)
}
}