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
3 changes: 2 additions & 1 deletion frontend/src/backend/wails.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,8 @@ describe("Wails backend adapter", () => {
expect(bridge.probe).toHaveBeenCalledWith({ provider: "custom", api_base_url: "https://proxy.test/v1", api_key: "secret", model: "m", agents: null });
expect(bridge.getProvider).toHaveBeenCalledWith({ id: "acme" });
expect(bridge.deleteProvider).toHaveBeenCalledWith({ id: "acme" });
expect(bridge.install).toHaveBeenCalledWith(expect.objectContaining({ agents: ["codex"], timeout: 180, agent_version: "" }));
// 0 means "use the Go default" rather than a duplicated number here.
expect(bridge.install).toHaveBeenCalledWith(expect.objectContaining({ agents: ["codex"], timeout: 0, agent_version: "" }));
expect(bridge.register).toHaveBeenCalledWith({ provider: "ppio", agents: null });
expect(bridge.activate).toHaveBeenCalledWith(expect.objectContaining({ agent_id: "codex", profile_id: "", small_fast_model: "" }));
expect(bridge.launch).toHaveBeenCalledWith({ agent_id: "codex" });
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/backend/wails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,9 @@ export const wailsApi = {
agent_version: input.agent_version ?? "",
skip_test: input.skip_test,
registry: input.registry ?? "",
timeout: input.timeout ?? 180,
// 0 asks Go for its own default. Repeating a number here made this a second
// source of truth that silently disagreed when the Go side changed.
timeout: input.timeout ?? 0,
})) as CancellableRequest<InstallResponse>,
openRegister: (provider: ProviderId, agents: string[]): Promise<OpenRegistrationResponse> =>
call(() => ProviderService.OpenRegistration({ provider, agents: agents.length ? agents : null })) as Promise<OpenRegistrationResponse>,
Expand Down
17 changes: 16 additions & 1 deletion frontend/src/styles/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -728,6 +728,8 @@
padding: 0 10px;
border: 1px solid var(--border-strong);
border-radius: var(--radius-control);
color: var(--text-primary);
background: var(--window-bg);
display: grid;
grid-template-columns: 20px minmax(0, 1fr) 30px;
align-items: center;
Expand All @@ -742,11 +744,19 @@

.secure-field > svg { color: var(--text-tertiary); }

/* The background belongs to .secure-field, which draws the border and the focus
ring; this input is only the text surface inside it. Both declarations are
required rather than inherited: an <input> takes neither its parent's colour
nor a transparent background from the UA sheet, so without them the field
rendered white-on-white in dark mode. .field-stack > input does not reach here
because .secure-field sits between them. */
.secure-field input {
min-width: 0;
height: 40px;
border: 0;
outline: none;
color: inherit;
background: transparent;
}

.secure-field button {
Expand Down Expand Up @@ -794,14 +804,19 @@
padding: 0 11px;
border: 1px solid var(--border-strong);
border-radius: var(--radius-control);
color: var(--text-primary);
background: var(--window-bg);
display: grid;
grid-template-columns: 20px minmax(0, 1fr);
align-items: center;
gap: 6px;
}

.search-field svg { color: var(--text-tertiary); }
.search-field input { height: 36px; border: 0; outline: 0; }
/* Same wrapper pattern as .secure-field: the box owns the border and background,
the input is only the text surface, so it needs both declarations to stop the
UA sheet painting it white. */
.search-field input { height: 36px; border: 0; outline: 0; color: inherit; background: transparent; }

.model-list {
max-height: 270px;
Expand Down
90 changes: 90 additions & 0 deletions frontend/src/styles/input-surface.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/**
* Guards the text inputs that sit inside a styled wrapper.
*
* .secure-field (the API Key row) and .search-field draw their own border, focus
* ring and background, and hold the <input> as a bare text surface. That shape is
* what makes them fragile: `.field-stack > input` -- the rule that gives every
* other field its themed colours -- is a child selector, so the wrapper in
* between stops it from applying. Nothing else colours these inputs, and the UA
* sheet's default is white-on-black, so a missing declaration renders the field
* white in dark mode while the box around it goes dark.
*
* Two things therefore have to hold, and neither is covered elsewhere:
*
* - the wrapper carries themed color/background, and its input inherits them
* rather than falling back to the UA sheet, and
* - color-scheme tracks the forced palette, which is what colours the parts of
* a password field the stylesheet cannot reach: the masking dots, the caret
* and the autofill background.
*
* jsdom does not resolve var(), so getComputedStyle sees none of this; the
* assertions read the stylesheets as text, as agent-mark.test.ts does.
*/
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";

import { describe, expect, it } from "vitest";

/** Comments carry `--token` names and `:` in prose, so they must go first. */
function sheet(name: string): string {
const css = readFileSync(fileURLToPath(new URL(name, import.meta.url)), "utf8");
return css.replace(/\/\*[\s\S]*?\*\//g, " ");
}

/**
* The declarations of the last block whose prelude, with whitespace collapsed,
* is exactly `selector`. Last rather than first because a later block at equal
* specificity is what actually applies.
*/
function declarations(css: string, selector: string): Map<string, string> {
const want = selector.replace(/\s+/g, " ").trim();
const found = new Map<string, string>();
let seen = false;
for (const block of css.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
if (block[1].replace(/\s+/g, " ").trim() !== want) continue;
seen = true;
found.clear();
for (const part of block[2].split(";")) {
const split = part.indexOf(":");
if (split < 0) continue;
found.set(part.slice(0, split).trim(), part.slice(split + 1).trim().replace(/\s+/g, " "));
}
}
expect(seen, `${selector} should exist`).toBe(true);
return found;
}

const WRAPPED_FIELDS = ["secure-field", "search-field"];

describe("inputs inside a styled wrapper", () => {
it("themes the wrapper rather than leaving it on the UA default", () => {
const app = sheet("app.css");
for (const field of WRAPPED_FIELDS) {
const rules = declarations(app, `.${field}`);
expect(rules.get("color"), field).toBe("var(--text-primary)");
expect(rules.get("background"), field).toBe("var(--window-bg)");
}
});

it("hands the wrapper's colours to the input", () => {
// inherit/transparent rather than repeating the tokens: the wrapper is what
// draws the field, and a second copy of the values would be one more place to
// miss on a palette change.
const app = sheet("app.css");
for (const field of WRAPPED_FIELDS) {
const rules = declarations(app, `.${field} input`);
expect(rules.get("color"), field).toBe("inherit");
expect(rules.get("background"), field).toBe("transparent");
}
});

it("moves color-scheme with an explicitly forced palette", () => {
// Without these two, forcing a theme leaves color-scheme following the
// desktop, so the password dots, caret and autofill background come from the
// opposite palette -- the API Key field reading white on a dark form.
const tokens = sheet("tokens.css");
expect(declarations(tokens, ":root").get("color-scheme")).toBe("light dark");
expect(declarations(tokens, ":root.theme-dark").get("color-scheme")).toBe("dark");
expect(declarations(tokens, ":root.theme-light").get("color-scheme")).toBe("light");
});
});
14 changes: 14 additions & 0 deletions frontend/src/styles/tokens.css
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,13 @@
--footer-height) live in :root above and must stay there: duplicating them
would mean every future change had to be made twice, and forcing light mode
would drop whichever copy was missed. */
/* The mirror of .theme-dark below: forcing light on a dark desktop otherwise
leaves color-scheme at `light dark`, so the engine keeps painting controls
dark against the light palette. */
:root.theme-light {
color-scheme: light;
}

@media (prefers-color-scheme: dark) {
/* Skipped when the user forced light: the class wins over the desktop. */
:root:not(.theme-light) {
Expand Down Expand Up @@ -95,7 +102,14 @@
}

/* An explicit choice, independent of the desktop setting. */
/* An explicit choice has to move color-scheme too, not just the custom
properties. color-scheme is what tells the engine how to paint the parts of a
control we do not draw ourselves: the dots in a password field, the caret, and
the autofill background. Left at the :root default of `light dark` it follows
the desktop, so forcing dark on a light desktop produced a dark field with a
light caret and light autofill -- the API Key row still reading as white. */
:root.theme-dark {
color-scheme: dark;
--page-bg: #151517;
--window-bg: #1e1e20;
--sidebar-bg: rgba(24, 24, 26, 0.94);
Expand Down
16 changes: 13 additions & 3 deletions internal/binding/services.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,23 @@ package binding

import (
"context"
"fmt"
"net/url"
"strings"
"time"

"github.com/MaimoryLab/OneAgent/internal/app"
oneerrors "github.com/MaimoryLab/OneAgent/internal/errors"
"github.com/MaimoryLab/OneAgent/internal/install"
"github.com/MaimoryLab/OneAgent/internal/process"
"github.com/MaimoryLab/OneAgent/internal/provider"
)

// maxInstallTimeoutSeconds caps an explicitly requested timeout. It sits above
// DefaultCommandTimeout on purpose: a ceiling equal to the default would mean a
// caller could not ask for the default explicitly.
const maxInstallTimeoutSeconds = 6 * 60 * 60

type Services struct {
Status *StatusService
Provider *ProviderService
Expand Down Expand Up @@ -306,9 +313,12 @@ func (s *AgentService) Install(ctx context.Context, request InstallRequest) (Ins
if s == nil || s.core == nil {
return InstallResponse{}, notReady("Agent installation is not configured")
}
timeout := 180 * time.Second
if request.Timeout < 0 || request.Timeout > 3600 {
return InstallResponse{}, oneerrors.New(oneerrors.InvalidRequest, "timeout must be an integer between 1 and 3600")
// The default lives in internal/install so Go owns it alone. The frontend used
// to send a hardcoded 180, which made this a second source of truth that could
// disagree silently.
timeout := install.DefaultCommandTimeout
if request.Timeout < 0 || request.Timeout > maxInstallTimeoutSeconds {
return InstallResponse{}, oneerrors.New(oneerrors.InvalidRequest, fmt.Sprintf("timeout must be an integer between 0 and %d, where 0 selects the default", maxInstallTimeoutSeconds))
}
if request.Timeout > 0 {
timeout = time.Duration(request.Timeout) * time.Second
Expand Down
48 changes: 48 additions & 0 deletions internal/binding/services_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@ import (
"strings"
"sync"
"testing"
"time"

"github.com/MaimoryLab/OneAgent/internal/app"
"github.com/MaimoryLab/OneAgent/internal/catalog"
oneerrors "github.com/MaimoryLab/OneAgent/internal/errors"
"github.com/MaimoryLab/OneAgent/internal/install"
"github.com/MaimoryLab/OneAgent/internal/platform"
"github.com/MaimoryLab/OneAgent/internal/provider"
)
Expand Down Expand Up @@ -325,6 +327,52 @@ func TestAgentServiceInstallsThroughGoUseCase(t *testing.T) {
}
}

// Timeout 0 has to mean "use the Go default", because that is what the frontend
// now sends. Previously the frontend sent a hardcoded 180 and this branch was
// never exercised, so the two sides could disagree without any test noticing.
func TestAgentServiceTreatsZeroTimeoutAsTheGoDefault(t *testing.T) {
home := t.TempDir()
core := app.NewUseCases(app.StatusOptions{
Home: home,
Platform: platform.For("linux", "amd64"),
Lookup: func(string) (string, bool) { return "", false },
})
service := &AgentService{core: core}
response, err := service.Install(context.Background(), InstallRequest{
Agents: []string{"codex"}, Provider: "ppio", APIKey: "secret", Model: "model-a",
Configure: true, SkipTest: true, Timeout: 0,
})
if err != nil {
t.Fatalf("zero timeout was rejected instead of taking the default: %v", err)
}
if !response.OK {
t.Fatalf("zero timeout install response = %#v", response)
}
}

// The ceiling must exceed the default, or a caller could not request the default
// value explicitly. Both rejections return InvalidRequest rather than silently
// clamping, because a clamped timeout would be a surprise, not a fix.
func TestAgentServiceRejectsOutOfRangeTimeouts(t *testing.T) {
service := &AgentService{core: app.NewUseCases(app.StatusOptions{
Home: t.TempDir(),
Platform: platform.For("linux", "amd64"),
Lookup: func(string) (string, bool) { return "", false },
})}
for _, timeout := range []int{-1, maxInstallTimeoutSeconds + 1} {
_, err := service.Install(context.Background(), InstallRequest{
Agents: []string{"codex"}, Provider: "ppio", APIKey: "secret", Model: "m", Timeout: timeout,
})
if err == nil {
t.Fatalf("timeout %d was accepted", timeout)
}
}
if int(install.DefaultCommandTimeout/time.Second) > maxInstallTimeoutSeconds {
t.Fatalf("the ceiling %d is below the default %v, so the default cannot be requested explicitly",
maxInstallTimeoutSeconds, install.DefaultCommandTimeout)
}
}

func TestInstallResultBindingPreservesFieldPresence(t *testing.T) {
tests := []struct {
name string
Expand Down
16 changes: 14 additions & 2 deletions internal/desktopapp/desktopapp.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,17 @@ type Options struct {

const (
inspectTimeout = 10 * time.Second
// installTimeout bounds local work: hdiutil, ditto, codesign, spctl. These are
// disk and CPU bound, so elapsed time is a fair limit for them.
installTimeout = 20 * time.Minute
// downloadTimeout is the backstop for fetching an installer, where elapsed
// time is not a fair limit — a 200 MB DMG on a slow link is healthy, and
// downloadStallTimeout is what distinguishes that from a dead transfer.
downloadTimeout = 60 * time.Minute
// Matches install.DownloadStallTimeout. Not imported from there because this
// package does not otherwise depend on internal/install, and a desktop
// installer download has the same characteristics as a runtime archive.
downloadStallTimeout = 120 * time.Second
)

var packageVersionPattern = regexp.MustCompile(`^[0-9]+(?:\.[0-9]+){1,3}$`)
Expand Down Expand Up @@ -654,14 +664,16 @@ func installChatGPTWindowsInstaller(ctx context.Context, options Options, status
}

func downloadFile(ctx context.Context, options Options, url, destination, target string) error {
downloadCtx, cancel := context.WithTimeout(ctx, installTimeout)
downloadCtx, cancel := context.WithTimeout(ctx, downloadTimeout)
defer cancel()
request, err := http.NewRequestWithContext(downloadCtx, http.MethodGet, url, nil)
if err != nil {
return err
}
client := options.Downloader
if client == nil {
// http.DefaultClient sets no Timeout, which is what we want here: the
// stall check below is the limit on the body transfer.
client = http.DefaultClient
}
response, err := client.Do(request)
Expand All @@ -676,7 +688,7 @@ func downloadFile(ctx context.Context, options Options, url, destination, target
if err != nil {
return err
}
written, copyErr := process.CopyWithProgress(file, response.Body, response.ContentLength, target, options.Output)
written, copyErr := process.CopyWithStallTimeout(downloadCtx, file, response.Body, response.ContentLength, target, options.Output, downloadStallTimeout)
closeErr := file.Close()
contextErr := downloadCtx.Err()
if copyErr != nil || closeErr != nil || contextErr != nil || written == 0 {
Expand Down
Loading
Loading