Skip to content
Closed
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@

- 桌面首次启动不再要求注册/登录。首屏是可选模型配置(云密钥或本机 Ollama,可跳过);跳过或保存后进入默认智能体对话,而不是停在工作台列表。已有提供商或会话的用户不会被再次拦住。账号仍可稍后在头像菜单里领取,供保存/导出/组织房间使用。首次运行与安全条目一致:不预填云密钥,本机 Ollama 优先。

### 修复

- 桌面 `POST /api/auth/local-session` 对已有 `~/.freeos`(多用户 / 组织映射行)或 WebView 非 `127.0.0.1` Host 返回 403,前端重试后掉进注册登录。本机会话在 loopback / `*.localhost` / Origin 为本机时签发 JWT 并选用已有工作室账号;Windows 宿主覆盖父进程残留的 `OCTOP_DESKTOP`;SPA 在 `/` 跳到 `/projects` 丢掉 `?desktop=1` 之前记住桌面壳,不再强制登录。

### 文档

- 增加阿拉伯语(`README.ar.md`)与葡萄牙语(`README.pt.md`)项目简介,并在各语言 README 的语言切换链接中列出。
Expand Down
19 changes: 16 additions & 3 deletions dashboard/src/api/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,10 @@ function handleSetupRequired(): void {
/** Remembered Wails shell flag — keep ``?desktop=1`` if the query was dropped. */
function setupRedirectPath(): string {
try {
if (window.sessionStorage.getItem("freeos:desktop-shell") === "1") {
if (
window.sessionStorage.getItem("freeos:desktop-shell") === "1" ||
window.localStorage.getItem("freeos:desktop-shell") === "1"
) {
return "/setup?desktop=1";
}
} catch {
Expand Down Expand Up @@ -204,7 +207,7 @@ function buildHeaders(path: string, extra?: HeadersInit): HeadersInit {

// Apply the global JWT first; the caller's `extra` (including a
// wizard token) can still override it below.
const token = getAuthToken();
const token = getAuthToken().trim();
if (token) {
headers.Authorization = `Bearer ${token}`;
}
Expand Down Expand Up @@ -242,7 +245,7 @@ function buildAuthHeaders(path: string): Record<string, string> {
const headers: Record<string, string> = {
"Accept-Language": i18n.language?.startsWith("zh") ? "zh" : "en",
};
const token = getAuthToken();
const token = getAuthToken().trim();
if (token) {
headers.Authorization = `Bearer ${token}`;
}
Expand Down Expand Up @@ -271,6 +274,16 @@ function handleUnauthorized(): void {
path.startsWith("/invite")
)
return;
try {
if (
window.sessionStorage.getItem("freeos:desktop-shell") === "1" ||
window.localStorage.getItem("freeos:desktop-shell") === "1"
) {
return;
}
} catch {
/* private mode */
}
_redirectingToLogin = true;

const takenOver = !window.dispatchEvent(
Expand Down
27 changes: 27 additions & 0 deletions dashboard/src/components/AuthGuard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -282,4 +282,31 @@ describe("AuthGuard local session", () => {
expect(screen.queryByText("usable app")).toBeNull();
view.unmount();
});

it("keeps retrying after the router drops ?desktop=1", async () => {
sessionStorage.setItem("freeos:desktop-shell", "1");
getAuthStatus.mockResolvedValue({ setup_required: false, desktop: false });
localSession.mockRejectedValue(new Error("interactive login required"));

const view = render(
<MemoryRouter initialEntries={["/chat"]}>
<Routes>
<Route
path="/chat"
element={
<AuthGuard>
<div>usable app</div>
</AuthGuard>
}
/>
<Route path="/login" element={<div>login wall</div>} />
<Route path="/setup" element={<div>setup wizard</div>} />
</Routes>
</MemoryRouter>,
);

await waitFor(() => expect(localSession).toHaveBeenCalled());
expect(screen.queryByText("login wall")).toBeNull();
view.unmount();
});
});
2 changes: 1 addition & 1 deletion dashboard/src/components/AuthGuard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ export default function AuthGuard({ children }: AuthGuardProps) {
return;
}

const token = getAuthToken();
const token = getAuthToken().trim();
if (!token) {
if (
await tryLocalSession(
Expand Down
2 changes: 2 additions & 0 deletions dashboard/src/hooks/useUnauthorizedRedirect.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { UNAUTHORIZED_EVENT } from "../api/request";
import { isDesktopShell } from "../utils/desktopShell";

/**
* Route an expired session to /login through the router.
Expand All @@ -15,6 +16,7 @@ export function useUnauthorizedRedirect(): void {
useEffect(() => {
const handler = (event: Event) => {
event.preventDefault();
if (isDesktopShell()) return;
navigate("/login", { replace: true });
};
window.addEventListener(UNAUTHORIZED_EVENT, handler);
Expand Down
3 changes: 3 additions & 0 deletions dashboard/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// registered synchronously before Chrome fires the event (which can happen
// before React mounts and useEffect runs).
import "./pwa-prompt";
import { isDesktopShell } from "./utils/desktopShell";

import { createRoot } from "react-dom/client";
import App from "./App.tsx";
Expand All @@ -14,6 +15,8 @@ import {
} from "./utils/reloadOnStaleChunk";

if (typeof window !== "undefined") {
// Capture `?desktop=1` before React Router replaces `/`.
isDesktopShell();
// Recover from post-deploy stale hashed chunks (white screen → soft reload).
installChunkLoadRecovery();

Expand Down
2 changes: 1 addition & 1 deletion dashboard/src/routes/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,6 @@ export const routeConfigs: RouteConfig[] = [

// Misc
{ path: "/pwa-debug", element: <PwaDebugPage /> },
{ path: "/", element: <Navigate to="/projects" replace /> },
{ path: "/", element: <RedirectPreserveSearch to="/projects" /> },
{ path: "*", element: <NotFoundPage /> },
];
7 changes: 7 additions & 0 deletions dashboard/src/utils/desktopShell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,20 @@ import { isDesktopShell } from "./desktopShell";
describe("isDesktopShell", () => {
afterEach(() => {
sessionStorage.clear();
localStorage.clear();
});

it("detects the desktop query and remembers it after the query is dropped", () => {
expect(isDesktopShell("?desktop=1")).toBe(true);
expect(isDesktopShell("")).toBe(true);
});

it("survives a dropped query after React Router replaces /", () => {
expect(isDesktopShell("?desktop=1")).toBe(true);
sessionStorage.clear();
expect(isDesktopShell("")).toBe(true);
});

it("is false without the query or a remembered flag", () => {
expect(isDesktopShell("")).toBe(false);
expect(isDesktopShell("?foo=1")).toBe(false);
Expand Down
44 changes: 29 additions & 15 deletions dashboard/src/utils/desktopShell.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,39 @@
const DESKTOP_FLAG = "freeos:desktop-shell";

function rememberDesktopFlag(): void {
if (typeof window === "undefined") return;
try {
window.sessionStorage.setItem(DESKTOP_FLAG, "1");
window.localStorage.setItem(DESKTOP_FLAG, "1");
} catch {
// Ignore quota / private-mode failures; the query string still counts.
}
}

function rememberedDesktopFlag(): boolean {
if (typeof window === "undefined") return false;
try {
return (
window.sessionStorage.getItem(DESKTOP_FLAG) === "1" ||
window.localStorage.getItem(DESKTOP_FLAG) === "1"
);
} catch {
return false;
}
}

/** True when the SPA is inside the Wails shell (`?desktop=1` or remembered). */
export function isDesktopShell(search?: string): boolean {
const query =
search ?? (typeof window === "undefined" ? "" : window.location.search);
if (new URLSearchParams(query).get("desktop") === "1") {
if (typeof window !== "undefined") {
try {
window.sessionStorage.setItem(DESKTOP_FLAG, "1");
} catch {
// Ignore quota / private-mode failures; the query string still counts.
}
}
rememberDesktopFlag();
return true;
}
if (typeof window === "undefined") {
return false;
}
try {
return window.sessionStorage.getItem(DESKTOP_FLAG) === "1";
} catch {
return false;
}
return rememberedDesktopFlag();
}

/** Capture `?desktop=1` before React Router navigates `/` → `/projects`. */
if (typeof window !== "undefined") {
isDesktopShell();
}
40 changes: 38 additions & 2 deletions desktop/src/process.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,46 @@ import (
)

func mustEnv(cmd *exec.Cmd, extra map[string]string) {
cmd.Env = os.Environ()
cmd.Env = mergeLaunchEnv(os.Environ(), extra)
}

// mergeLaunchEnv overlays extra onto base so later keys win. On Windows the
// environment is case-insensitive and CreateProcess keeps the first duplicate,
// so a leftover OCTOP_DESKTOP=0 from the parent would otherwise shadow "1".
func mergeLaunchEnv(base []string, extra map[string]string) []string {
type entry struct {
key string
value string
}
order := make([]entry, 0, len(base)+len(extra))
index := map[string]int{}
put := func(key, value string) {
lk := key
if runtime.GOOS == "windows" {
lk = strings.ToLower(key)
}
if i, ok := index[lk]; ok {
order[i] = entry{key: key, value: value}
return
}
index[lk] = len(order)
order = append(order, entry{key: key, value: value})
}
for _, pair := range base {
key, value, ok := strings.Cut(pair, "=")
if !ok {
continue
}
put(key, value)
}
for key, value := range extra {
cmd.Env = append(cmd.Env, key+"="+value)
put(key, value)
}
out := make([]string, 0, len(order))
for _, item := range order {
out = append(out, item.key+"="+item.value)
}
return out
}

const defaultSidecarPort = 3780
Expand Down
48 changes: 48 additions & 0 deletions desktop/src/process_env_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package main

import (
"runtime"
"strings"
"testing"
)

func TestMergeLaunchEnvOverridesParentDesktopFlags(t *testing.T) {
got := mergeLaunchEnv(
[]string{"PATH=/bin", "OCTOP_DESKTOP=0", "FREEOS_DESKTOP=", "OCTOP_GREEN_PACKAGES="},
map[string]string{
"OCTOP_DESKTOP": "1",
"FREEOS_DESKTOP": "1",
"OCTOP_GREEN_PACKAGES": `/opt/freeos/packages`,
},
)
env := map[string]string{}
counts := map[string]int{}
for _, pair := range got {
key, value, ok := strings.Cut(pair, "=")
if !ok {
t.Fatalf("bad pair %q", pair)
}
lk := key
if runtime.GOOS == "windows" {
lk = strings.ToLower(key)
}
counts[lk]++
env[lk] = value
}
if counts["OCTOP_DESKTOP"] != 1 && counts["octop_desktop"] != 1 {
t.Fatalf("duplicate or missing OCTOP_DESKTOP: %+v", got)
}
desktop := env["OCTOP_DESKTOP"]
if desktop == "" {
desktop = env["octop_desktop"]
}
if desktop != "1" {
t.Fatalf("OCTOP_DESKTOP=%q, want 1 in %v", desktop, got)
}
if env["FREEOS_DESKTOP"] != "1" && env["freeos_desktop"] != "1" {
t.Fatalf("FREEOS_DESKTOP not overridden: %v", got)
}
if env["OCTOP_GREEN_PACKAGES"] == "" && env["octop_green_packages"] == "" {
t.Fatalf("OCTOP_GREEN_PACKAGES empty: %v", got)
}
}
29 changes: 18 additions & 11 deletions src/octop/api/routers/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import logging
from typing import Any

from fastapi import APIRouter, Depends, Request, Response
Expand All @@ -13,26 +14,24 @@
claim_local_account,
ensure_local_user,
is_desktop_process,
is_loopback_host,
is_unclaimed_local_user,
request_looks_local,
)
from octop.infra.users.permissions import effective_permissions
from octop.infra.utils.locale import normalize_locale, resolve_request_locale

router = APIRouter()
logger = logging.getLogger(__name__)


def _is_local_client(request: Request) -> bool:
if is_desktop_process():
return True
host = (request.client.host if request.client else "") or ""
if is_loopback_host(host):
return True
forwarded = (request.headers.get("x-forwarded-for") or "").split(",")[0].strip()
if is_loopback_host(forwarded):
return True
req_host = (request.headers.get("host") or "").split(":")[0].lower()
return is_loopback_host(req_host)
return request_looks_local(
client_host=(request.client.host if request.client else "") or "",
forwarded_for=request.headers.get("x-forwarded-for") or "",
http_host=request.headers.get("host") or "",
origin=request.headers.get("origin") or "",
referer=request.headers.get("referer") or "",
)


def _user_json(
Expand Down Expand Up @@ -99,6 +98,14 @@ class RegisterBody(BaseModel):
async def local_session(request: Request, server: Any = Depends(get_server)) -> dict[str, Any]:
"""Issue a JWT without a login form on desktop / loopback first launch."""
if not _is_local_client(request):
logger.warning(
"local-session denied client=%s host=%s origin=%s forwarded=%s desktop=%s",
request.client.host if request.client else "",
request.headers.get("host"),
request.headers.get("origin"),
request.headers.get("x-forwarded-for"),
is_desktop_process(),
)
raise OctopError(ErrorCode.FORBIDDEN, "local session is only available on this device")
locale = normalize_locale(resolve_request_locale(request))
user = await ensure_local_user(server, locale=locale)
Expand Down
6 changes: 5 additions & 1 deletion src/octop/api/routers/org_identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,16 @@ async def identity_status() -> dict[str, Any]:

async def forward(request: Request, server: Any, path: str) -> Response:
require_integrated()
extra_headers: dict[str, str] = {}
authorization = (request.headers.get("authorization") or "").strip()
if authorization:
extra_headers["Authorization"] = authorization
try:
return await proxy_request(
request,
base_url=org_module_from_paths(server.paths).sidecar_url(),
path=path,
extra_headers={"Authorization": request.headers.get("authorization", "")},
extra_headers=extra_headers,
)
except httpx.HTTPError as exc:
raise OctopError(
Expand Down
Loading
Loading