diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8cf85b6d..daa78481 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,7 @@
### 变更
- 桌面端(`OCTOP_DESKTOP` / `FREEOS_DESKTOP`)首次打开不再经过验证启动密码、选择数据库、创建管理员三步:自动绑定本地 SQLite 并使用已有 guest / local-session。首屏只保留配置 LLM API Key,可「跳过,进入工作台」,之后仍可在设置里配模型。自托管服务端向导不变。
+- 侧栏「设置」分组改名为「能力」/ Capabilities。原「控制」与「管理」子模块并入顶层「系统设置」/ System Settings(页内分节;工作台 / 远程桌面仍走全屏路由)。旧 `/admin/*`、`/acp`、`/agent-config` 地址会重定向。
- 用户可见吉祥物从章鱼换成 XYAI 白色机器人姿势包(欢迎 / 空态 / 探头 / 思考 / 打字 / 任务);桌面启动页与 openXYOS 首页使用同一角色,不再加载章鱼 WebM。
- 桌面更新通道只跟踪 `github.com/XYAIStudio/FreeOS/releases`。不再读取 PyPI `octop`、腾讯云 COS 或其它 Octop 镜像;当前已是最新 FreeOS `v0.0.1` 时不会误报更新。
- 默认主题与残留 Octop `rose` 存储一次性迁移为 XYAI 蓝 `#0033FF`(之后仍可手动选玫瑰粉)。
@@ -21,6 +22,7 @@
### 修复
- 桌面 / 本地会话不再被 `setup_required` 短路:`GET /setup/status` 之后仍可调用 `POST /auth/local-session`,不会误跳进服务端密码向导。
+- 组织页「启动边车」会拉起捆绑的 Node + openXYOS(Windows 用 `node.exe` / `cmd /c`,不再直接 Popen `.bat`),并在右侧预览区嵌入控制台(支持全屏)。openXYOS 在 FreeOS 桌面环境下关闭 `X-Frame-Options`,避免 iframe 空白。安装 / 桌面启动时宿主与 Wails 都会确保边车常开(需 `dist/index.html` 已打包)。
- Windows 打开时不再因 WebView2 `Navigate`/`SetURL` 在 Chromium 未就绪时触发 Go panic。主窗口等 WebView 点火后再跳转;`SetURL` 失败会重试并显示 FreeOS 提示,而不是原始堆栈。设置窗口延后创建,避免两个 WebView2 同时初始化同一用户目录。用户数据目录必须可写,并设置 `WEBVIEW2_USER_DATA_FOLDER`。
- 已安装的 FreeOS 不再继承残留的 `~/.octop` 作为桌面家目录。无 `FREEOS_STAMP` 的 `~/.octop/portable` 会被丢弃;健康检查要求 `product=freeos`,避免连上旧 Octop 进程后出现红标登录墙。桌面端 `?desktop=1` 会记住本地会话并持续重试,不再落到登录页。
- Windows NSIS 安装完成后双击桌面 / 开始菜单快捷方式(或 `FreeOS.exe`)能打开窗口:快捷方式工作目录固定为 `$INSTDIR`,WebView2 用户数据写到 `%LOCALAPPDATA%\FreeOS\WebView2`(不再落到 Program Files),结束页用未提权 token 启动以免把 `~/.freeos` 标成 High integrity,并忽略非 `FreeOS.exe` 的残留 `desktop.pid`。
diff --git a/dashboard/src/layouts/Sidebar.tsx b/dashboard/src/layouts/Sidebar.tsx
index 586cd892..eed7090f 100644
--- a/dashboard/src/layouts/Sidebar.tsx
+++ b/dashboard/src/layouts/Sidebar.tsx
@@ -36,8 +36,8 @@ import { typeSize } from "../utils/mobileTypeScale";
import { DESKTOP_DRAG_REGION_CLASS } from "../utils/desktopChrome";
const NAV_GROUPS_STORAGE_KEY = "octop:sidebar-nav-groups";
-/** Minimal settings pane: skip the "设置" group header (duplicates the pane title). */
-const MINIMAL_SETTINGS_HIDDEN_HEADERS = new Set(["nav.settings"]);
+/** Minimal settings pane: skip the Capabilities group header (duplicates the pane title). */
+const MINIMAL_SETTINGS_HIDDEN_HEADERS = new Set(["nav.capabilities"]);
function loadCollapsedGroups(): Set {
try {
@@ -190,7 +190,7 @@ function NavItemButton({
}}
>
{t(item.labelKey)}
- {item.key === "admin-advanced" && role === "admin" && hasUpdate ? (
+ {item.key === "system-settings" && role === "admin" && hasUpdate ? (
{t("nav.newVersionBadge", "有新版本")}
@@ -254,7 +254,7 @@ function NavList({
isMobile?: boolean;
isGroupCollapsed: (groupKey: string) => boolean;
toggleGroup: (groupKey: string) => void;
- /** all = classic; primary = top flat entries; grouped = settings/control/admin */
+ /** all = classic; primary = top flat entries; grouped = capabilities */
sectionFilter?: "all" | "primary" | "grouped";
hideGroupHeaderKeys?: ReadonlySet;
}) {
diff --git a/dashboard/src/layouts/SidebarCollapsedIconNav.tsx b/dashboard/src/layouts/SidebarCollapsedIconNav.tsx
index dbc2c5c7..e765151d 100644
--- a/dashboard/src/layouts/SidebarCollapsedIconNav.tsx
+++ b/dashboard/src/layouts/SidebarCollapsedIconNav.tsx
@@ -24,7 +24,7 @@ export default function SidebarCollapsedIconNav({
{items.map((item) => {
const active = selectedKey === item.key;
const showUpdateBadge =
- item.key === "admin-advanced" && role === "admin" && hasUpdate;
+ item.key === "system-settings" && role === "admin" && hasUpdate;
return (
{
}
expect(isGroupedNavKey("chat")).toBe(false);
expect(isGroupedNavKey("experts")).toBe(false);
+ expect(isGroupedNavKey("system-settings")).toBe(false);
+ expect(isGroupedNavKey("workbench")).toBe(false);
});
it("places grouped keys only under sections with groupKey", () => {
@@ -42,4 +44,38 @@ describe("sidebarNav", () => {
expect(isGroupedNavKey(key)).toBe(false);
}
});
+
+ it("renames settings to capabilities and folds control/admin into system-settings", () => {
+ const sections = buildNavSections(adminUser, { mobileEnabled: true });
+ expect(sections.map((s) => s.groupKey)).toEqual([
+ undefined,
+ "nav.capabilities",
+ ]);
+ const primary = sections[0].items.map((i) => i.key);
+ expect(primary).toContain("system-settings");
+ expect(primary).not.toContain("workbench");
+ expect(primary).not.toContain("admin-users");
+ const grouped = sections[1].items.map((i) => i.key);
+ expect(grouped).toEqual([
+ "personalization",
+ "channels",
+ "connectors",
+ "skill-packages",
+ "knowledge-bases",
+ ]);
+ });
+
+ it("hides system-settings when the user has no control or admin modules", () => {
+ const guest = {
+ id: 2,
+ username: "guest",
+ role: "user",
+ permissions: [],
+ } as OctopUser;
+ const sections = buildNavSections(guest);
+ const keys = sections.flatMap((s) => s.items.map((i) => i.key));
+ expect(keys).not.toContain("system-settings");
+ expect(keys).not.toContain("workbench");
+ expect(keys).toContain("personalization");
+ });
});
diff --git a/dashboard/src/layouts/sidebarNav.tsx b/dashboard/src/layouts/sidebarNav.tsx
index 4ae19757..d75e90b2 100644
--- a/dashboard/src/layouts/sidebarNav.tsx
+++ b/dashboard/src/layouts/sidebarNav.tsx
@@ -1,27 +1,20 @@
import type { ReactNode } from "react";
import {
- Monitor,
MessageSquareText,
Timer,
SlidersHorizontal,
Waypoints,
Link2,
Database,
- Cpu,
- Users as UsersIcon,
Activity,
- Share2,
Sparkles,
- Puzzle,
Package,
- HardDrive,
GraduationCap,
- Shield,
- PanelsTopLeft,
Building2,
} from "lucide-react";
import type { OctopUser } from "../api/modules/auth";
-import { navAllowed, userCan } from "../utils/permissions";
+import { canSeeSystemSettings } from "../pages/SystemSettings/tabs";
+import { navAllowed } from "../utils/permissions";
export const EXPANDED_WIDTH = 220;
export const COLLAPSED_WIDTH = 56;
@@ -44,7 +37,7 @@ export interface NavSection {
}
/**
- * Catalog of nav item keys that live under settings / control / admin groups.
+ * Catalog of nav item keys that live under the Capabilities group.
* Permission-independent — used for pane/route helpers; visibility still
* comes from {@link buildNavSections}.
*/
@@ -54,16 +47,6 @@ export const SIDEBAR_GROUPED_NAV_KEYS = [
"connectors",
"skill-packages",
"knowledge-bases",
- "workbench",
- "remote-desktop",
- "acp",
- "admin-users",
- "models",
- "admin-storage",
- "admin-plugins",
- "admin-security",
- "admin-advanced",
- "agent-config",
] as const;
const GROUPED_NAV_KEY_SET = new Set(SIDEBAR_GROUPED_NAV_KEYS);
@@ -109,6 +92,18 @@ export function buildNavSections(
icon: ,
labelKey: "nav.tokenUsage",
},
+ ...(canSeeSystemSettings(user, { mobileEnabled: opts?.mobileEnabled })
+ ? [
+ {
+ key: "system-settings",
+ path: "/system-settings",
+ icon: (
+
+ ),
+ labelKey: "nav.systemSettings",
+ } satisfies NavItem,
+ ]
+ : []),
],
},
];
@@ -154,94 +149,7 @@ export function buildNavSections(
});
}
if (settingsItems.length > 0) {
- sections.push({ groupKey: "nav.settings", items: settingsItems });
- }
-
- const controlItems: NavItem[] = [];
- if (navAllowed(user, "workbench")) {
- controlItems.push({
- key: "workbench",
- path: "/workbench",
- icon: ,
- labelKey: "nav.workbench",
- });
- }
- // Server desktop + phone share one nav entry; phone tab also needs host capability.
- if (
- userCan(user, "desktop") ||
- (opts?.mobileEnabled && userCan(user, "mobile"))
- ) {
- controlItems.push({
- key: "remote-desktop",
- path: "/remote-desktop",
- icon: ,
- labelKey: "nav.remoteDesktop",
- });
- }
- // ACP: no module key this round — admin role only.
- if (navAllowed(user, "acp")) {
- controlItems.push({
- key: "acp",
- path: "/acp",
- icon: ,
- labelKey: "nav.acp",
- });
- }
- if (controlItems.length > 0) {
- sections.push({ groupKey: "nav.control", items: controlItems });
- }
-
- const adminItems: NavItem[] = [];
- if (navAllowed(user, "admin-users")) {
- adminItems.push({
- key: "admin-users",
- path: "/admin/users",
- icon: ,
- labelKey: "nav.adminUsers",
- });
- }
- if (navAllowed(user, "models")) {
- adminItems.push({
- key: "models",
- path: "/admin/models",
- icon: ,
- labelKey: "nav.models",
- });
- }
- if (navAllowed(user, "admin-storage")) {
- adminItems.push({
- key: "admin-storage",
- path: "/admin/backend",
- icon: ,
- labelKey: "nav.adminStorage",
- });
- }
- if (navAllowed(user, "admin-plugins")) {
- adminItems.push({
- key: "admin-plugins",
- path: "/admin/plugins",
- icon: ,
- labelKey: "nav.adminPlugins",
- });
- }
- if (navAllowed(user, "admin-security")) {
- adminItems.push({
- key: "admin-security",
- path: "/admin/security",
- icon: ,
- labelKey: "nav.security",
- });
- }
- if (navAllowed(user, "admin-advanced")) {
- adminItems.push({
- key: "admin-advanced",
- path: "/admin/advanced",
- icon: ,
- labelKey: "nav.adminAdvanced",
- });
- }
- if (adminItems.length > 0) {
- sections.push({ groupKey: "nav.admin", items: adminItems });
+ sections.push({ groupKey: "nav.capabilities", items: settingsItems });
}
return sections;
}
diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json
index 2cf602ca..05afe2fa 100644
--- a/dashboard/src/locales/en.json
+++ b/dashboard/src/locales/en.json
@@ -76,7 +76,15 @@
"saveFailed": "Could not update organization module",
"actionFailed": "Action failed",
"enabled": "Organization module enabled",
- "disabled": "Organization module disabled"
+ "disabled": "Organization module disabled",
+ "previewTitle": "openXYOS preview",
+ "previewOffline": "Sidecar is offline. Start it to preview the organization console here.",
+ "previewLoading": "Starting openXYOS…",
+ "fullscreen": "Fullscreen",
+ "exitFullscreen": "Exit fullscreen"
+ },
+ "systemSettings": {
+ "subtitle": "Workbench, remote desktop, and host administration in one place."
},
"desktopChrome": {
"group": "Window controls",
@@ -263,6 +271,8 @@
"skills": "Skills",
"agentConfig": "Configuration",
"settings": "Settings",
+ "capabilities": "Capabilities",
+ "systemSettings": "System Settings",
"voice": "Voice",
"observability": "Observability",
"https": "HTTPS",
diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json
index 6e255d06..76f87524 100644
--- a/dashboard/src/locales/zh.json
+++ b/dashboard/src/locales/zh.json
@@ -76,7 +76,15 @@
"saveFailed": "无法更新组织模块",
"actionFailed": "操作失败",
"enabled": "组织模块已启用",
- "disabled": "组织模块已关闭"
+ "disabled": "组织模块已关闭",
+ "previewTitle": "openXYOS 预览",
+ "previewOffline": "边车未运行。启动后可在此预览组织控制台。",
+ "previewLoading": "正在启动 openXYOS…",
+ "fullscreen": "全屏",
+ "exitFullscreen": "退出全屏"
+ },
+ "systemSettings": {
+ "subtitle": "工作台、远程桌面与主机管理集中在这里。"
},
"desktopChrome": {
"group": "窗口控制",
@@ -263,6 +271,8 @@
"skills": "技能",
"agentConfig": "运行配置",
"settings": "设置",
+ "capabilities": "能力",
+ "systemSettings": "系统设置",
"voice": "语音",
"observability": "可观测",
"https": "HTTPS",
diff --git a/dashboard/src/pages/Organization/Organization.module.less b/dashboard/src/pages/Organization/Organization.module.less
index 3e8fcd9d..05137182 100644
--- a/dashboard/src/pages/Organization/Organization.module.less
+++ b/dashboard/src/pages/Organization/Organization.module.less
@@ -4,15 +4,100 @@
gap: 20px;
}
+.workspace {
+ display: grid;
+ grid-template-columns: minmax(320px, 42%) minmax(0, 1fr);
+ gap: 16px;
+ align-items: stretch;
+ min-height: 0;
+}
+
+.column {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ min-width: 0;
+}
+
+.preview {
+ display: flex;
+ flex-direction: column;
+ min-height: 560px;
+ border-radius: 16px;
+ border: 1px solid var(--fn-border-primary);
+ background: var(--fn-bg-primary);
+ overflow: hidden;
+}
+
+.previewFullscreen {
+ position: fixed;
+ inset: 0;
+ z-index: 1200;
+ min-height: 0;
+ border-radius: 0;
+ background: var(--fn-bg-primary);
+}
+
+.previewHeader {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 12px 14px;
+ border-bottom: 1px solid var(--fn-border-primary);
+}
+
+.previewTitle {
+ margin: 0;
+ font-size: 15px;
+ font-weight: 650;
+}
+
+.previewBody {
+ position: relative;
+ flex: 1;
+ min-height: 0;
+ background: #fff;
+}
+
+.previewEmpty {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ justify-content: center;
+ gap: 12px;
+ padding: 24px;
+ background: var(--fn-bg-secondary);
+}
+
+@media (max-width: 1100px) {
+ .workspace {
+ grid-template-columns: 1fr;
+ }
+
+ .preview {
+ min-height: 420px;
+ }
+}
+
.hero {
position: relative;
overflow: hidden;
border-radius: 20px;
padding: 28px 28px 24px;
color: #fff;
- background:
- radial-gradient(1200px 400px at 10% -20%, rgba(107, 140, 255, 0.55), transparent 60%),
- radial-gradient(800px 360px at 90% 120%, rgba(0, 51, 255, 0.45), transparent 55%),
+ background: radial-gradient(
+ 1200px 400px at 10% -20%,
+ rgba(107, 140, 255, 0.55),
+ transparent 60%
+ ),
+ radial-gradient(
+ 800px 360px at 90% 120%,
+ rgba(0, 51, 255, 0.45),
+ transparent 55%
+ ),
linear-gradient(135deg, #001a80 0%, #0033ff 48%, #4b74fa 100%);
box-shadow: 0 18px 48px rgba(0, 51, 255, 0.22);
}
@@ -215,7 +300,11 @@
border-radius: 16px;
padding: 18px 20px;
border: 1px solid rgba(245, 158, 11, 0.35);
- background: linear-gradient(180deg, rgba(245, 158, 11, 0.08), var(--fn-bg-primary));
+ background: linear-gradient(
+ 180deg,
+ rgba(245, 158, 11, 0.08),
+ var(--fn-bg-primary)
+ );
}
.recoverTitle {
@@ -274,10 +363,11 @@
}
.embed {
+ display: block;
width: 100%;
- min-height: 640px;
- border: 1px solid var(--fn-border-primary);
- border-radius: 12px;
+ height: 100%;
+ min-height: 100%;
+ border: 0;
background: #fff;
}
diff --git a/dashboard/src/pages/Organization/index.tsx b/dashboard/src/pages/Organization/index.tsx
index e59cf2c9..07f61d60 100644
--- a/dashboard/src/pages/Organization/index.tsx
+++ b/dashboard/src/pages/Organization/index.tsx
@@ -4,6 +4,8 @@ import {
ArrowDownUp,
Building2,
ExternalLink,
+ Maximize2,
+ Minimize2,
Package,
Play,
RefreshCw,
@@ -41,25 +43,55 @@ export default function OrganizationPage() {
const [busy, setBusy] = useState(null);
const [loopProof, setLoopProof] = useState(null);
const [lastActionNotes, setLastActionNotes] = useState([]);
+ const [previewFullscreen, setPreviewFullscreen] = useState(false);
+ const [previewPending, setPreviewPending] = useState(false);
+
+ const applyOverview = useCallback(
+ async (quiet = false) => {
+ if (!quiet) setLoading(true);
+ try {
+ const next = await orgModuleApi.overview();
+ setOverview(next);
+ return next;
+ } catch (err) {
+ if (!quiet) {
+ message.error(
+ err instanceof Error ? err.message : t("organization.loadFailed"),
+ );
+ }
+ return null;
+ } finally {
+ if (!quiet) setLoading(false);
+ }
+ },
+ [t],
+ );
const load = useCallback(async () => {
- setLoading(true);
- try {
- const next = await orgModuleApi.overview();
- setOverview(next);
- } catch (err) {
- message.error(
- err instanceof Error ? err.message : t("organization.loadFailed"),
- );
- } finally {
- setLoading(false);
- }
- }, [t]);
+ await applyOverview(false);
+ }, [applyOverview]);
useEffect(() => {
void load();
}, [load]);
+ useEffect(() => {
+ if (overview?.sidecar_reachable) return;
+ const id = window.setInterval(() => {
+ void applyOverview(true);
+ }, 4000);
+ return () => window.clearInterval(id);
+ }, [applyOverview, overview?.sidecar_reachable]);
+
+ useEffect(() => {
+ if (!previewFullscreen) return;
+ const onKey = (event: KeyboardEvent) => {
+ if (event.key === "Escape") setPreviewFullscreen(false);
+ };
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, [previewFullscreen]);
+
const sidecarUp = Boolean(overview?.sidecar_reachable);
const firstRun = useMemo(() => {
if (!overview) return false;
@@ -77,7 +109,7 @@ export default function OrganizationPage() {
message.success(
enabled ? t("organization.enabled") : t("organization.disabled"),
);
- await load();
+ await applyOverview(false);
} catch (err) {
message.error(
err instanceof Error ? err.message : t("organization.saveFailed"),
@@ -87,11 +119,25 @@ export default function OrganizationPage() {
}
};
- const runAction = async (key: Exclude, fn: () => Promise) => {
+ const waitForSidecar = async () => {
+ for (let i = 0; i < 30; i += 1) {
+ const next = await applyOverview(true);
+ if (next?.sidecar_reachable) return next;
+ await new Promise((resolve) => window.setTimeout(resolve, 1000));
+ }
+ return applyOverview(true);
+ };
+
+ const runAction = async (
+ key: Exclude,
+ fn: () => Promise,
+ ) => {
setBusy(key);
try {
await fn();
- await load();
+ if (key !== "sidecar") {
+ await applyOverview(false);
+ }
} catch (err) {
message.error(
err instanceof Error ? err.message : t("organization.actionFailed"),
@@ -103,13 +149,21 @@ export default function OrganizationPage() {
const startSidecar = () =>
runAction("sidecar", async () => {
- const result = await orgModuleApi.startSidecar();
- setLastActionNotes([result.detail, result.command].filter(Boolean));
- message.success(
- result.reachable
- ? t("organization.sidecarStarted")
- : t("organization.sidecarStartPending"),
- );
+ setPreviewPending(true);
+ try {
+ const result = await orgModuleApi.startSidecar();
+ setLastActionNotes([result.detail, result.command].filter(Boolean));
+ const next = result.reachable
+ ? await applyOverview(true)
+ : await waitForSidecar();
+ message.success(
+ next?.sidecar_reachable
+ ? t("organization.sidecarStarted")
+ : t("organization.sidecarStartPending"),
+ );
+ } finally {
+ setPreviewPending(false);
+ }
});
const assemble = () =>
@@ -149,10 +203,10 @@ export default function OrganizationPage() {
? formatServerIsoDateTime(overview.last_sync, timeZone)
: t("organization.neverSynced");
const catalog = overview?.catalog ?? [];
- const embedUrl =
- overview?.enabled && sidecarUp
- ? `${overview.sidecar_url.replace(/\/$/, "")}/`
- : "";
+ const previewUrl = overview?.sidecar_url
+ ? `${overview.sidecar_url.replace(/\/$/, "")}/`
+ : "";
+ const showFrame = Boolean(sidecarUp && previewUrl);
return (
{t("organization.heroStory")}
- {overview?.enabled
- ? t("organization.on")
- : t("organization.off")}
+ {overview?.enabled ? t("organization.on") : t("organization.off")}
{sidecarUp
@@ -187,288 +239,365 @@ export default function OrganizationPage() {
-
-
- {t("organization.toggleLabel")}
- void toggle(checked)}
- />
-
-
- {!sidecarUp && (
-
-
- {t("organization.startSidecarTitle")}
-
-
- {t("organization.startSidecarBody")}
-
-
- }
- loading={busy === "sidecar"}
- disabled={!overview?.start_available && !overview}
- onClick={() => void startSidecar()}
- >
- {t("organization.startSidecarAction")}
-
- {overview?.start_command && (
- {overview.start_command}
- )}
-
-
- )}
-
- {firstRun && (
-
- {t("organization.emptyTitle")}
- {t("organization.emptyBody")}
-
- )}
-
-
-
-
-
-
{t("organization.dataPlane")}
-
FreeOS
-
-
-
-
-
-
- {metric(overview?.freeos.employees)}
-
-
- {t("organization.metricEmployees")}
-
-
-
-
- {metric(overview?.freeos.org_skills)}
-
-
- {t("organization.metricSkills")}
-
-
-
-
- {metric(overview?.freeos.mcp)}
-
-
- {t("organization.metricMcp")}
-
-
-
-
- {metric(overview?.freeos.tasks)}
-
-
- {t("organization.metricTasks")}
-
-
+
+
+
+
+ {t("organization.toggleLabel")}
+ void toggle(checked)}
+ />
-
-
-
-
- {t("organization.flowUp")}
-
-
-
- {t("organization.flowDown")}
-
-
-
+ {!sidecarUp && (
+
+
+ {t("organization.startSidecarTitle")}
+
+
+ {t("organization.startSidecarBody")}
+
+
+ }
+ loading={busy === "sidecar"}
+ disabled={!overview?.start_available && !overview}
+ onClick={() => void startSidecar()}
+ >
+ {t("organization.startSidecarAction")}
+
+ {overview?.start_command && (
+ {overview.start_command}
+ )}
+
+
+ )}
-
-
-
-
- {t("organization.controlPlane")}
+ {firstRun && (
+
+
+ {t("organization.emptyTitle")}
- openXYOS
-
-
-
-
-
-
- {metric(overview?.openxyos.modules)}
-
-
- {t("organization.metricModules")}
-
-
-
-
- {overview?.openxyos.governance
- ? t("organization.on")
- : t("organization.off")}
-
-
- {t("organization.metricGovernance")}
-
-
-
-
- {sidecarUp ? t("organization.sidecarUp") : "—"}
-
-
- {t("organization.metricHealth")}
-
-
-
-
- {overview?.openxyos.tenant_id || "—"}
-
-
- {t("organization.metricTenant")}
-
-
-
-
-
+
{t("organization.emptyBody")}
+
+ )}
-
-
- void packBack()}
- >
-
- {t("organization.packTitle")}
- {t("organization.packBody")}
- {t("organization.packAction")}
-
- void runLoop()}
- >
-
- {t("organization.loopTitle")}
- {t("organization.loopBody")}
- {t("organization.loopAction")}
-
-
+
+
+
+
+
+ {t("organization.dataPlane")}
+
+
FreeOS
+
+
+
+
+
+
+ {metric(overview?.freeos.employees)}
+
+
+ {t("organization.metricEmployees")}
+
+
+
+
+ {metric(overview?.freeos.org_skills)}
+
+
+ {t("organization.metricSkills")}
+
+
+
+
+ {metric(overview?.freeos.mcp)}
+
+
+ {t("organization.metricMcp")}
+
+
+
+
+ {metric(overview?.freeos.tasks)}
+
+
+ {t("organization.metricTasks")}
+
+
+
+
-
- {t("organization.timelineTitle")}
- {lastLoop ? (
-
- -
- {t("organization.timelineOk")}: {String(lastLoop.ok)}
-
- {lastLoop.employees?.length ? (
- -
- {t("organization.timelineEmployees")}:{" "}
- {lastLoop.employees.join(", ")}
-
- ) : null}
- {lastLoop.skills?.length ? (
- -
- {t("organization.timelineSkills")}: {lastLoop.skills.length}
-
- ) : null}
- -
- {t("organization.timelineRemote")}:{" "}
- {String(Boolean(lastLoop.remote_applied))}
-
- -
- {t("organization.timelineGovernance")}:{" "}
- {String(Boolean(lastLoop.governance_blocked))}
-
- {(lastLoop.notes ?? []).slice(0, 6).map((note) => (
- - {note}
- ))}
-
- ) : (
- {t("organization.timelineEmpty")}
- )}
- {lastActionNotes.length > 0 && (
-
- {lastActionNotes.slice(0, 8).map((note) => (
- - {note}
- ))}
-
- )}
-
+
+
+
+ {t("organization.flowUp")}
+
+
+
+ {t("organization.flowDown")}
+
+
+
-
- {t("organization.catalogTitle")}
-
- {catalog.map((row) => (
-
-
- {isZh ? row.label_zh : row.label}{" "}
- {row.locked ? t("organization.locked") : t("organization.optional")}
+
+
+
+
+ {t("organization.controlPlane")}
+
+
openXYOS
+
+
+
+
+
+
+ {metric(overview?.openxyos.modules)}
+
+
+ {t("organization.metricModules")}
+
+
+
+
+ {overview?.openxyos.governance
+ ? t("organization.on")
+ : t("organization.off")}
+
+
+ {t("organization.metricGovernance")}
+
+
+
+
+ {sidecarUp ? t("organization.sidecarUp") : "—"}
+
+
+ {t("organization.metricHealth")}
+
+
+
+
+ {overview?.openxyos.tenant_id || "—"}
+
+
+ {t("organization.metricTenant")}
+
+
+
+
+
+
+
+ void assemble()}
+ >
+
+
+ {t("organization.assembleTitle")}
-
- {isZh ? row.description_zh : row.description}
+
+ {t("organization.assembleBody")}
-
- ))}
+
+ {t("organization.assembleAction")}
+
+
+
void packBack()}
+ >
+
+
+ {t("organization.packTitle")}
+
+
+ {t("organization.packBody")}
+
+
+ {t("organization.packAction")}
+
+
+
void runLoop()}
+ >
+
+
+ {t("organization.loopTitle")}
+
+
+ {t("organization.loopBody")}
+
+
+ {t("organization.loopAction")}
+
+
+
+
+
+
+ {t("organization.timelineTitle")}
+
+ {lastLoop ? (
+
+ -
+ {t("organization.timelineOk")}: {String(lastLoop.ok)}
+
+ {lastLoop.employees?.length ? (
+ -
+ {t("organization.timelineEmployees")}:{" "}
+ {lastLoop.employees.join(", ")}
+
+ ) : null}
+ {lastLoop.skills?.length ? (
+ -
+ {t("organization.timelineSkills")}:{" "}
+ {lastLoop.skills.length}
+
+ ) : null}
+ -
+ {t("organization.timelineRemote")}:{" "}
+ {String(Boolean(lastLoop.remote_applied))}
+
+ -
+ {t("organization.timelineGovernance")}:{" "}
+ {String(Boolean(lastLoop.governance_blocked))}
+
+ {(lastLoop.notes ?? []).slice(0, 6).map((note) => (
+ - {note}
+ ))}
+
+ ) : (
+ {t("organization.timelineEmpty")}
+ )}
+ {lastActionNotes.length > 0 && (
+
+ {lastActionNotes.slice(0, 8).map((note) => (
+ - {note}
+ ))}
+
+ )}
+
+
+
+
+ {t("organization.catalogTitle")}
+
+
+ {catalog.map((row) => (
+
+
+ {isZh ? row.label_zh : row.label}{" "}
+
+ {row.locked
+ ? t("organization.locked")
+ : t("organization.optional")}
+
+
+
+ {isZh ? row.description_zh : row.description}
+
+
+ ))}
+
+
-
- {embedUrl && (
-
-
-
{t("organization.embedTitle")}
-
}
- >
- {t("organization.openSidecar")}
-
+
+
);
diff --git a/dashboard/src/pages/Settings/AdvancedSettings/index.tsx b/dashboard/src/pages/Settings/AdvancedSettings/index.tsx
index fa165a8c..ba4added 100644
--- a/dashboard/src/pages/Settings/AdvancedSettings/index.tsx
+++ b/dashboard/src/pages/Settings/AdvancedSettings/index.tsx
@@ -47,7 +47,7 @@ export default function AdvancedSettingsPage() {
const moved = searchParams.get("tab");
if (moved === "voice" || moved === "search") {
- return
;
+ return
;
}
if (forbidden) return
;
diff --git a/dashboard/src/pages/SystemSettings/SystemSettings.module.less b/dashboard/src/pages/SystemSettings/SystemSettings.module.less
new file mode 100644
index 00000000..04d7a0e9
--- /dev/null
+++ b/dashboard/src/pages/SystemSettings/SystemSettings.module.less
@@ -0,0 +1,73 @@
+.page {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ min-height: 0;
+}
+
+.body {
+ display: flex;
+ flex: 1;
+ min-height: 0;
+ min-width: 0;
+}
+
+.nav {
+ flex-shrink: 0;
+ width: 188px;
+ padding: 8px 8px 16px;
+ border-right: 1px solid var(--fn-border-primary);
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.navItem {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ width: 100%;
+ border: none;
+ border-radius: var(--fn-radius-md, 8px);
+ background: transparent;
+ color: var(--fn-text-secondary);
+ font-size: 13px;
+ line-height: 1.3;
+ text-align: left;
+ padding: 8px 10px;
+ cursor: pointer;
+}
+
+.navItemActive {
+ background: var(--fn-sidebar-item-active-bg);
+ color: var(--fn-sidebar-item-active-text);
+ font-weight: 500;
+}
+
+.content {
+ flex: 1;
+ min-width: 0;
+ min-height: 0;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+}
+
+.mobileNav {
+ padding: 0 0 12px;
+}
+
+@media (max-width: 768px) {
+ .body {
+ flex-direction: column;
+ }
+
+ .nav {
+ display: none;
+ }
+
+ .content {
+ overflow: auto;
+ }
+}
diff --git a/dashboard/src/pages/SystemSettings/index.tsx b/dashboard/src/pages/SystemSettings/index.tsx
new file mode 100644
index 00000000..8766d687
--- /dev/null
+++ b/dashboard/src/pages/SystemSettings/index.tsx
@@ -0,0 +1,196 @@
+import { lazy, Suspense, useCallback, useEffect, useMemo } from "react";
+import { useLocation, useNavigate } from "react-router-dom";
+import { useTranslation } from "react-i18next";
+import { Segmented } from "antd";
+import {
+ Cpu,
+ HardDrive,
+ Monitor,
+ PanelsTopLeft,
+ Puzzle,
+ Settings2,
+ Share2,
+ Shield,
+ SlidersHorizontal,
+ Users,
+} from "lucide-react";
+import PageShell from "../../layouts/PageShell";
+import PageLoading from "../../components/PageLoading";
+import { useCurrentUser } from "../../hooks/useCurrentUser";
+import { useIsMobile } from "../../hooks/useIsMobile";
+import { usePathTabs } from "../../hooks/usePathTabs";
+import { useServerCapabilities } from "../../hooks/useServerCapabilities";
+import {
+ SYSTEM_SETTINGS_EXTERNAL_PATH,
+ SYSTEM_SETTINGS_LABEL_KEY,
+ SYSTEM_SETTINGS_TABS,
+ allowedSystemSettingsTabs,
+ systemSettingsPath,
+ type SystemSettingsTab,
+} from "./tabs";
+import styles from "./SystemSettings.module.less";
+
+const ACPPage = lazy(() => import("../Agent/ACP"));
+const AgentConfigPage = lazy(() => import("../Agent/Config"));
+const ModelsPage = lazy(() => import("../Settings/Models"));
+const AdminUsersPage = lazy(() => import("../Admin/Users"));
+const AdminStoragePage = lazy(() => import("../Admin/Storage"));
+const AdminPluginsPage = lazy(() => import("../Admin/Plugins"));
+const AdminSecurityPage = lazy(() => import("../Settings/Security"));
+const AdvancedSettingsPage = lazy(() => import("../Settings/AdvancedSettings"));
+
+const TAB_ICONS = {
+ workbench: PanelsTopLeft,
+ "remote-desktop": Monitor,
+ acp: Share2,
+ users: Users,
+ models: Cpu,
+ storage: HardDrive,
+ plugins: Puzzle,
+ security: Shield,
+ advanced: SlidersHorizontal,
+ "agent-config": Settings2,
+} as const;
+
+const HUB_PANELS: readonly SystemSettingsTab[] = [
+ "acp",
+ "users",
+ "models",
+ "storage",
+ "plugins",
+ "security",
+ "advanced",
+ "agent-config",
+];
+
+export default function SystemSettingsPage() {
+ const { t } = useTranslation();
+ const isMobile = useIsMobile();
+ const user = useCurrentUser();
+ const { mobileEnabled } = useServerCapabilities();
+ const navigate = useNavigate();
+ const location = useLocation();
+ const allowed = useMemo(
+ () => allowedSystemSettingsTabs(user, { mobileEnabled }),
+ [user, mobileEnabled],
+ );
+ const allowedSet = useMemo(() => new Set(allowed), [allowed]);
+ const defaultTab = allowed[0] ?? "users";
+ const isAllowed = useCallback(
+ (tab: SystemSettingsTab) => allowedSet.has(tab),
+ [allowedSet],
+ );
+
+ const { activeTab, handleTabChange, isMounted } = usePathTabs({
+ basePath: "/system-settings",
+ tabs: SYSTEM_SETTINGS_TABS,
+ storageKey: "freeos:system-settings:tab",
+ defaultTab,
+ isAllowed,
+ });
+
+ useEffect(() => {
+ const external = SYSTEM_SETTINGS_EXTERNAL_PATH[activeTab];
+ if (!external) return;
+ if (location.pathname.startsWith("/system-settings")) {
+ navigate(`${external}${location.search}${location.hash}`, {
+ replace: true,
+ });
+ }
+ }, [activeTab, location.hash, location.pathname, location.search, navigate]);
+
+ const onSelect = useCallback(
+ (value: string | number) => {
+ const next = String(value) as SystemSettingsTab;
+ const external = SYSTEM_SETTINGS_EXTERNAL_PATH[next];
+ if (external) {
+ navigate(`${external}${location.search}${location.hash}`);
+ return;
+ }
+ handleTabChange(next);
+ },
+ [handleTabChange, location.hash, location.search, navigate],
+ );
+
+ const options = allowed.map((tab) => {
+ const Icon = TAB_ICONS[tab];
+ return {
+ value: tab,
+ label: t(SYSTEM_SETTINGS_LABEL_KEY[tab]),
+ icon:
,
+ };
+ });
+
+ return (
+
+
+ {isMobile && (
+
+ ({
+ value: opt.value,
+ label: opt.label,
+ }))}
+ />
+
+ )}
+
+ {!isMobile && (
+
+ )}
+
+
}>
+ {HUB_PANELS.map((tab) =>
+ isMounted(tab) && allowedSet.has(tab) ? (
+
+ {tab === "acp" ?
: null}
+ {tab === "users" ?
: null}
+ {tab === "models" ?
: null}
+ {tab === "storage" ?
: null}
+ {tab === "plugins" ?
: null}
+ {tab === "security" ?
: null}
+ {tab === "advanced" ?
: null}
+ {tab === "agent-config" ?
: null}
+
+ ) : null,
+ )}
+
+
+
+
+
+ );
+}
+
+export { systemSettingsPath };
diff --git a/dashboard/src/pages/SystemSettings/tabs.test.ts b/dashboard/src/pages/SystemSettings/tabs.test.ts
new file mode 100644
index 00000000..fc053414
--- /dev/null
+++ b/dashboard/src/pages/SystemSettings/tabs.test.ts
@@ -0,0 +1,71 @@
+import { describe, expect, it } from "vitest";
+import type { OctopUser } from "../../api/modules/auth";
+import {
+ allowedSystemSettingsTabs,
+ canSeeSystemSettings,
+ isSystemSettingsNavPath,
+ systemSettingsPath,
+} from "./tabs";
+
+const adminUser = {
+ id: 1,
+ username: "admin",
+ role: "admin",
+ permissions: ["*"],
+} as OctopUser;
+
+describe("system settings tabs", () => {
+ it("maps hub tabs to /system-settings and keeps fullscreen tools external", () => {
+ expect(systemSettingsPath("users")).toBe("/system-settings/users");
+ expect(systemSettingsPath("workbench")).toBe("/workbench");
+ expect(systemSettingsPath("remote-desktop")).toBe("/remote-desktop");
+ });
+
+ it("treats old control/admin URLs as the system-settings nav item", () => {
+ expect(isSystemSettingsNavPath("/system-settings/users")).toBe(true);
+ expect(isSystemSettingsNavPath("/admin/users")).toBe(true);
+ expect(isSystemSettingsNavPath("/workbench/terminal")).toBe(true);
+ expect(isSystemSettingsNavPath("/chat")).toBe(false);
+ });
+
+ it("lists every former control and admin module for admins", () => {
+ expect(
+ allowedSystemSettingsTabs(adminUser, { mobileEnabled: true }),
+ ).toEqual([
+ "workbench",
+ "remote-desktop",
+ "acp",
+ "users",
+ "models",
+ "storage",
+ "plugins",
+ "security",
+ "advanced",
+ "agent-config",
+ ]);
+ });
+
+ it("hides the hub from guests without module permissions", () => {
+ const guest = {
+ id: 2,
+ username: "guest",
+ role: "user",
+ permissions: [],
+ } as OctopUser;
+ expect(canSeeSystemSettings(guest)).toBe(false);
+ expect(allowedSystemSettingsTabs(guest)).toEqual([]);
+ });
+
+ it("keeps desktop-only users on remote-desktop without admin tabs", () => {
+ const desktopUser = {
+ id: 3,
+ username: "ops",
+ role: "user",
+ permissions: ["desktop"],
+ } as OctopUser;
+ expect(allowedSystemSettingsTabs(desktopUser)).toEqual([
+ "remote-desktop",
+ "agent-config",
+ ]);
+ });
+});
diff --git a/dashboard/src/pages/SystemSettings/tabs.ts b/dashboard/src/pages/SystemSettings/tabs.ts
new file mode 100644
index 00000000..d1426bfb
--- /dev/null
+++ b/dashboard/src/pages/SystemSettings/tabs.ts
@@ -0,0 +1,124 @@
+import type { OctopUser } from "../../api/modules/auth";
+import { navAllowed, userCan } from "../../utils/permissions";
+
+export const SYSTEM_SETTINGS_TABS = [
+ "workbench",
+ "remote-desktop",
+ "acp",
+ "users",
+ "models",
+ "storage",
+ "plugins",
+ "security",
+ "advanced",
+ "agent-config",
+] as const;
+
+export type SystemSettingsTab = (typeof SYSTEM_SETTINGS_TABS)[number];
+
+export const SYSTEM_SETTINGS_TAB_SET = new Set
(SYSTEM_SETTINGS_TABS);
+
+/** Fullscreen tools stay on their existing routes; other tabs live under the hub. */
+export const SYSTEM_SETTINGS_EXTERNAL_PATH: Partial<
+ Record
+> = {
+ workbench: "/workbench",
+ "remote-desktop": "/remote-desktop",
+};
+
+export const SYSTEM_SETTINGS_LABEL_KEY: Record = {
+ workbench: "nav.workbench",
+ "remote-desktop": "nav.remoteDesktop",
+ acp: "nav.acp",
+ users: "nav.adminUsers",
+ models: "nav.models",
+ storage: "nav.adminStorage",
+ plugins: "nav.adminPlugins",
+ security: "nav.security",
+ advanced: "nav.adminAdvanced",
+ "agent-config": "nav.agentConfig",
+};
+
+export function systemSettingsPath(tab: SystemSettingsTab): string {
+ return SYSTEM_SETTINGS_EXTERNAL_PATH[tab] ?? `/system-settings/${tab}`;
+}
+
+export function isSystemSettingsNavPath(pathname: string): boolean {
+ if (
+ pathname === "/system-settings" ||
+ pathname.startsWith("/system-settings/")
+ ) {
+ return true;
+ }
+ if (pathname === "/workbench" || pathname.startsWith("/workbench/")) {
+ return true;
+ }
+ if (pathname === "/terminal" || pathname === "/remote-browser") {
+ return true;
+ }
+ if (
+ pathname === "/remote-desktop" ||
+ pathname.startsWith("/remote-desktop/") ||
+ pathname === "/remote-phone" ||
+ pathname.startsWith("/remote-phone/") ||
+ pathname === "/remote-android" ||
+ pathname.startsWith("/remote-android/")
+ ) {
+ return true;
+ }
+ if (pathname === "/acp" || pathname.startsWith("/acp/")) {
+ return true;
+ }
+ if (pathname === "/agent-config") {
+ return true;
+ }
+ return pathname.startsWith("/admin/");
+}
+
+export function allowedSystemSettingsTabs(
+ user: OctopUser | null,
+ opts?: { mobileEnabled?: boolean },
+): SystemSettingsTab[] {
+ const tabs: SystemSettingsTab[] = [];
+ if (navAllowed(user, "workbench")) {
+ tabs.push("workbench");
+ }
+ if (
+ userCan(user, "desktop") ||
+ (opts?.mobileEnabled && userCan(user, "mobile"))
+ ) {
+ tabs.push("remote-desktop");
+ }
+ if (navAllowed(user, "acp")) {
+ tabs.push("acp");
+ }
+ if (navAllowed(user, "admin-users")) {
+ tabs.push("users");
+ }
+ if (navAllowed(user, "models")) {
+ tabs.push("models");
+ }
+ if (navAllowed(user, "admin-storage")) {
+ tabs.push("storage");
+ }
+ if (navAllowed(user, "admin-plugins")) {
+ tabs.push("plugins");
+ }
+ if (navAllowed(user, "admin-security")) {
+ tabs.push("security");
+ }
+ if (navAllowed(user, "admin-advanced")) {
+ tabs.push("advanced");
+ }
+ if (tabs.length > 0) {
+ tabs.push("agent-config");
+ }
+ return tabs;
+}
+
+export function canSeeSystemSettings(
+ user: OctopUser | null,
+ opts?: { mobileEnabled?: boolean },
+): boolean {
+ return allowedSystemSettingsTabs(user, opts).length > 0;
+}
diff --git a/dashboard/src/routes/controlAdminPath.test.ts b/dashboard/src/routes/controlAdminPath.test.ts
index 1c9a4724..bdff3b4d 100644
--- a/dashboard/src/routes/controlAdminPath.test.ts
+++ b/dashboard/src/routes/controlAdminPath.test.ts
@@ -36,9 +36,15 @@ describe("pathPermissionKeys", () => {
it("keeps sso on users page, not advanced", () => {
expect(pathPermissionKeys("/admin/users")).toEqual([...PERM.usersPage]);
+ expect(pathPermissionKeys("/system-settings/users")).toEqual([
+ ...PERM.usersPage,
+ ]);
expect(pathPermissionKeys("/admin/advanced")).toEqual([
...PERM.advancedPage,
]);
+ expect(pathPermissionKeys("/system-settings/advanced")).toEqual([
+ ...PERM.advancedPage,
+ ]);
expect([...PERM.advancedPage]).not.toContain("sso");
expect(NAV_PERMISSIONS["admin-users"]).toEqual(PERM.usersPage);
expect(NAV_PERMISSIONS["admin-advanced"]).toEqual(PERM.advancedPage);
@@ -46,6 +52,9 @@ describe("pathPermissionKeys", () => {
it("keeps voice and search on models page, not advanced", () => {
expect(pathPermissionKeys("/admin/models")).toEqual([...PERM.modelsPage]);
+ expect(pathPermissionKeys("/system-settings/models")).toEqual([
+ ...PERM.modelsPage,
+ ]);
expect(pathPermissionKeys("/admin/voice")).toEqual([...PERM.modelsPage]);
expect([...PERM.modelsPage]).toContain("voice");
expect([...PERM.modelsPage]).toContain("search");
@@ -89,6 +98,7 @@ describe("pathPermissionKeys", () => {
),
).toBe(true);
expect(canAccessPath(user, "/admin/users")).toBe(false);
+ expect(canAccessPath(user, "/system-settings/users")).toBe(false);
expect(canAccessPath({ role: "admin", permissions: [] }, "/acp")).toBe(
true,
);
@@ -118,6 +128,15 @@ describe("unknown dashboard paths", () => {
expect(resolveSelectedKey("/does-not-exist")).toBe("");
});
+ it("highlights system-settings for former control and admin URLs", () => {
+ expect(resolveSelectedKey("/system-settings/users")).toBe(
+ "system-settings",
+ );
+ expect(resolveSelectedKey("/admin/users")).toBe("system-settings");
+ expect(resolveSelectedKey("/workbench/terminal")).toBe("system-settings");
+ expect(resolveSelectedKey("/acp")).toBe("system-settings");
+ });
+
it("are caught by the not-found route", () => {
expect(routeConfigs.some((rc) => rc.path === "*")).toBe(true);
});
diff --git a/dashboard/src/routes/index.tsx b/dashboard/src/routes/index.tsx
index 373c012d..f473fef6 100644
--- a/dashboard/src/routes/index.tsx
+++ b/dashboard/src/routes/index.tsx
@@ -1,11 +1,11 @@
import { lazy } from "react";
import { Navigate, useLocation } from "react-router-dom";
+import { isSystemSettingsNavPath } from "../pages/SystemSettings/tabs";
// Lazy-loaded pages — Common
const ExpertsPage = lazy(() => import("../pages/Experts"));
const CronJobsPage = lazy(() => import("../pages/Control/CronJobs"));
const ConnectorsPage = lazy(() => import("../pages/Agent/Connectors"));
-const ACPPage = lazy(() => import("../pages/Agent/ACP"));
const SkillPackagesPage = lazy(() => import("../pages/SkillPackages"));
const KnowledgeBasesPage = lazy(() => import("../pages/KnowledgeBases"));
const PersonalizationPage = lazy(
@@ -16,19 +16,8 @@ const TokenUsagePage = lazy(() => import("../pages/Control/TokenUsage"));
// Lazy-loaded pages — Control
const RemoteDesktopPage = lazy(() => import("../pages/Control/RemoteDesktop"));
-// Lazy-loaded pages — Settings
-const ModelsPage = lazy(() => import("../pages/Settings/Models"));
-
-// Lazy-loaded pages — Admin
-const OctopAdminUsersPage = lazy(() => import("../pages/Admin/Users"));
-const AdminSecurityPage = lazy(() => import("../pages/Settings/Security"));
-const AdvancedSettingsPage = lazy(
- () => import("../pages/Settings/AdvancedSettings"),
-);
-const AdminStoragePage = lazy(() => import("../pages/Admin/Storage"));
-const AdminPluginsPage = lazy(() => import("../pages/Admin/Plugins"));
-const AgentConfigPage = lazy(() => import("../pages/Agent/Config"));
const OrganizationPage = lazy(() => import("../pages/Organization"));
+const SystemSettingsPage = lazy(() => import("../pages/SystemSettings"));
// Misc
const PwaDebugPage = lazy(() => import("../pages/PwaDebug"));
@@ -55,7 +44,7 @@ export const pathToKey: Record = {
"/connectors": "connectors",
"/skill-packages": "skill-packages",
"/knowledge-bases": "knowledge-bases",
- "/acp": "acp",
+ "/acp": "system-settings",
"/personalization": "personalization",
"/personalization/skills": "personalization",
"/personalization/tools": "personalization",
@@ -66,32 +55,42 @@ export const pathToKey: Record = {
"/personalization/memory": "personalization",
"/skills": "personalization",
"/token-usage": "token-usage",
- "/agent-config": "agent-config",
+ "/agent-config": "system-settings",
+ "/system-settings": "system-settings",
// Control
"/channels": "channels",
- "/workbench": "workbench",
- "/workbench/terminal": "workbench",
- "/workbench/browser": "workbench",
- "/terminal": "workbench",
- "/remote-browser": "workbench",
- "/remote-desktop": "remote-desktop",
- "/remote-desktop/desktop": "remote-desktop",
- "/remote-desktop/phone": "remote-desktop",
- "/remote-desktop/phone/screen": "remote-desktop",
- "/remote-desktop/phone/shell": "remote-desktop",
- "/remote-phone": "remote-desktop",
- "/remote-android": "remote-desktop",
+ "/workbench": "system-settings",
+ "/workbench/terminal": "system-settings",
+ "/workbench/browser": "system-settings",
+ "/terminal": "system-settings",
+ "/remote-browser": "system-settings",
+ "/remote-desktop": "system-settings",
+ "/remote-desktop/desktop": "system-settings",
+ "/remote-desktop/phone": "system-settings",
+ "/remote-desktop/phone/screen": "system-settings",
+ "/remote-desktop/phone/shell": "system-settings",
+ "/remote-phone": "system-settings",
+ "/remote-android": "system-settings",
"/subagents": "personalization",
"/mbti": "personalization",
"/memory": "personalization",
// Admin
- "/admin/models": "models",
- // Admin
- "/admin/users": "admin-users",
- "/admin/backend": "admin-storage",
- "/admin/plugins": "admin-plugins",
- "/admin/advanced": "admin-advanced",
- "/admin/security": "admin-security",
+ "/admin/models": "system-settings",
+ "/admin/users": "system-settings",
+ "/admin/backend": "system-settings",
+ "/admin/plugins": "system-settings",
+ "/admin/advanced": "system-settings",
+ "/admin/security": "system-settings",
+ "/system-settings/acp": "system-settings",
+ "/system-settings/users": "system-settings",
+ "/system-settings/models": "system-settings",
+ "/system-settings/storage": "system-settings",
+ "/system-settings/plugins": "system-settings",
+ "/system-settings/security": "system-settings",
+ "/system-settings/advanced": "system-settings",
+ "/system-settings/agent-config": "system-settings",
+ "/system-settings/workbench": "system-settings",
+ "/system-settings/remote-desktop": "system-settings",
};
/**
@@ -137,9 +136,8 @@ export function isPersonalizationPath(pathname: string): boolean {
export function resolveSelectedKey(pathname: string): string {
if (pathToKey[pathname]) return pathToKey[pathname];
if (pathname.startsWith("/chat/")) return "chat";
- if (pathname.startsWith("/workbench/")) return "workbench";
- if (pathname.startsWith("/remote-desktop/")) return "remote-desktop";
if (pathname.startsWith("/personalization/")) return "personalization";
+ if (isSystemSettingsNavPath(pathname)) return "system-settings";
return "";
}
@@ -162,9 +160,13 @@ export const routeConfigs: RouteConfig[] = [
element: ,
},
{ path: "/token-usage", element: },
+ { path: "/system-settings/*", element: },
// Control (RequirePermission via pathPermissionKeys in MainLayout)
- { path: "/acp", element: },
+ {
+ path: "/acp",
+ element: ,
+ },
{
path: "/channels",
element: ,
@@ -208,73 +210,104 @@ export const routeConfigs: RouteConfig[] = [
},
{ path: "/workspace", element: },
- // Settings
- { path: "/admin/models", element: },
-
- // Admin (RequirePermission wrapper applied in MainLayout)
- { path: "/admin/users", element: },
+ // Settings / admin — folded into System Settings (old URLs still work)
+ {
+ path: "/admin/models",
+ element: ,
+ },
+ {
+ path: "/admin/users",
+ element: ,
+ },
{
path: "/admin/sso",
- element: ,
+ element: ,
},
{
path: "/admin/shared-models",
- element: ,
+ element: ,
+ },
+ {
+ path: "/models",
+ element: ,
+ },
+ {
+ path: "/admin/backend",
+ element: ,
},
- { path: "/models", element: },
- { path: "/admin/backend", element: },
{
path: "/admin/audit",
- element: ,
+ element: ,
+ },
+ {
+ path: "/admin/agents",
+ element: ,
+ },
+ {
+ path: "/admin/plugins",
+ element: ,
+ },
+ {
+ path: "/admin/advanced",
+ element: ,
+ },
+ {
+ path: "/admin/security",
+ element: ,
},
- { path: "/admin/agents", element: },
- { path: "/admin/plugins", element: },
- { path: "/admin/advanced", element: },
- { path: "/admin/security", element: },
{
path: "/admin/voice",
- element: ,
+ element: ,
},
{
path: "/admin/updates",
- element: ,
+ element: ,
},
// Legacy redirects — keeps old bookmarks working
- { path: "/admin/storage", element: },
+ {
+ path: "/admin/storage",
+ element: ,
+ },
{ path: "/orca/cron", element: },
{ path: "/orca/channels", element: },
{
path: "/orca/admin/users",
- element: ,
+ element: ,
},
{
path: "/orca/admin/audit",
- element: ,
+ element: ,
},
{ path: "/octop/cron", element: },
{ path: "/octop/channels", element: },
{
path: "/octop/admin/users",
- element: ,
+ element: ,
},
{
path: "/octop/admin/audit",
- element: ,
+ element: ,
},
{
path: "/advanced-settings",
- element: ,
+ element: ,
+ },
+ {
+ path: "/environments",
+ element: ,
+ },
+ {
+ path: "/agent-config",
+ element: ,
},
- { path: "/environments", element: },
- { path: "/agent-config", element: },
{
path: "/updates",
- element: ,
+ element: ,
},
{
path: "/plugins",
- element: ,
+ element: ,
},
{ path: "/sessions", element: },
{ path: "/cron-jobs", element: },
diff --git a/dashboard/src/routes/prefetch.ts b/dashboard/src/routes/prefetch.ts
index fcd16355..31abdcc2 100644
--- a/dashboard/src/routes/prefetch.ts
+++ b/dashboard/src/routes/prefetch.ts
@@ -17,7 +17,8 @@ const ROUTE_PREFETCHERS: Record Promise> = {
"/remote-desktop/phone": () => import("../pages/Control/RemoteDesktop"),
"/remote-phone": () => import("../pages/Control/RemoteDesktop"),
"/remote-android": () => import("../pages/Control/RemoteDesktop"),
- "/acp": () => import("../pages/Agent/ACP"),
+ "/acp": () => import("../pages/SystemSettings"),
+ "/system-settings": () => import("../pages/SystemSettings"),
"/personalization": () => import("../pages/Agent/Personalization"),
"/personalization/skills": () => import("../pages/Agent/Personalization"),
"/personalization/tools": () => import("../pages/Agent/Personalization"),
@@ -28,13 +29,13 @@ const ROUTE_PREFETCHERS: Record Promise> = {
"/subagents": () => import("../pages/Agent/Personalization"),
"/mbti": () => import("../pages/Agent/Personalization"),
"/memory": () => import("../pages/Agent/Personalization"),
- "/admin/models": () => import("../pages/Settings/Models"),
- "/admin/users": () => import("../pages/Admin/Users"),
- "/admin/backend": () => import("../pages/Admin/Storage"),
- "/admin/plugins": () => import("../pages/Admin/Plugins"),
- "/admin/security": () => import("../pages/Settings/Security"),
- "/admin/advanced": () => import("../pages/Settings/AdvancedSettings"),
- "/agent-config": () => import("../pages/Agent/Config"),
+ "/admin/models": () => import("../pages/SystemSettings"),
+ "/admin/users": () => import("../pages/SystemSettings"),
+ "/admin/backend": () => import("../pages/SystemSettings"),
+ "/admin/plugins": () => import("../pages/SystemSettings"),
+ "/admin/security": () => import("../pages/SystemSettings"),
+ "/admin/advanced": () => import("../pages/SystemSettings"),
+ "/agent-config": () => import("../pages/SystemSettings"),
};
export function prefetchRoute(path: string): void {
diff --git a/dashboard/src/utils/chatStreamError.test.ts b/dashboard/src/utils/chatStreamError.test.ts
index 910eb4ca..2faf867c 100644
--- a/dashboard/src/utils/chatStreamError.test.ts
+++ b/dashboard/src/utils/chatStreamError.test.ts
@@ -42,7 +42,7 @@ describe("classifyChatStreamError", () => {
),
).toBe("stream_errors.insufficient_balance");
expect(chatStreamErrorAction(msg)).toEqual({
- path: "/admin/models",
+ path: "/system-settings/models",
labelKey: "modelConfig.configureButton",
});
});
@@ -64,7 +64,7 @@ describe("classifyChatStreamError", () => {
classifyChatStreamError("GraphRecursionError: GRAPH_RECURSION_LIMIT"),
).toBe("stream_errors.recursion_limit");
expect(chatStreamErrorAction(msg)).toEqual({
- path: "/agent-config",
+ path: "/system-settings/agent-config",
labelKey: "chat.goToAgentConfig",
});
});
diff --git a/dashboard/src/utils/chatStreamError.ts b/dashboard/src/utils/chatStreamError.ts
index f5ed50e2..9773e0a2 100644
--- a/dashboard/src/utils/chatStreamError.ts
+++ b/dashboard/src/utils/chatStreamError.ts
@@ -24,15 +24,15 @@ export type StreamErrorAction = {
const STREAM_ERROR_ACTIONS: Partial> =
{
"stream_errors.auth": {
- path: "/admin/models",
+ path: "/system-settings/models",
labelKey: "modelConfig.configureButton",
},
"stream_errors.insufficient_balance": {
- path: "/admin/models",
+ path: "/system-settings/models",
labelKey: "modelConfig.configureButton",
},
"stream_errors.recursion_limit": {
- path: "/agent-config",
+ path: "/system-settings/agent-config",
labelKey: "chat.goToAgentConfig",
},
};
diff --git a/dashboard/src/utils/permissions.ts b/dashboard/src/utils/permissions.ts
index 2ce167c1..31d5f3d9 100644
--- a/dashboard/src/utils/permissions.ts
+++ b/dashboard/src/utils/permissions.ts
@@ -119,38 +119,52 @@ export function userCanKey(
* ``null`` means no special gate.
*/
export function pathPermissionKeys(pathname: string): PermissionKeys | null {
- if (pathname.startsWith("/admin/users") || pathname === "/admin/sso") {
+ if (
+ pathname.startsWith("/admin/users") ||
+ pathname === "/admin/sso" ||
+ pathname.startsWith("/system-settings/users")
+ ) {
return PERM.usersPage;
}
if (
pathname.startsWith("/admin/models") ||
pathname === "/models" ||
- pathname.startsWith("/admin/voice")
+ pathname.startsWith("/admin/voice") ||
+ pathname.startsWith("/system-settings/models")
) {
return PERM.modelsPage;
}
- if (pathname.startsWith("/admin/backend")) {
+ if (
+ pathname.startsWith("/admin/backend") ||
+ pathname.startsWith("/system-settings/storage")
+ ) {
return PERM.storage;
}
if (
pathname.startsWith("/admin/plugins") ||
pathname === "/plugins" ||
- pathname.startsWith("/plugins/")
+ pathname.startsWith("/plugins/") ||
+ pathname.startsWith("/system-settings/plugins")
) {
return PERM.plugins;
}
if (
pathname.startsWith("/admin/security") ||
- pathname.startsWith("/admin/audit")
+ pathname.startsWith("/admin/audit") ||
+ pathname.startsWith("/system-settings/security")
) {
return PERM.securityPage;
}
if (
pathname.startsWith("/admin/advanced") ||
- pathname.startsWith("/admin/updates")
+ pathname.startsWith("/admin/updates") ||
+ pathname.startsWith("/system-settings/advanced")
) {
return PERM.advancedPage;
}
+ if (pathname.startsWith("/system-settings/acp")) {
+ return "admin";
+ }
if (pathname.startsWith("/admin/")) {
return "admin";
}
@@ -218,6 +232,9 @@ export function routeNeedsPermission(routePath: string): boolean {
}
if (probe === "/workbench" || probe.startsWith("/workbench/")) return true;
if (probe.startsWith("/admin")) return true;
+ if (probe === "/system-settings" || probe.startsWith("/system-settings/")) {
+ return true;
+ }
return false;
}
diff --git a/desktop/src/main.go b/desktop/src/main.go
index 7dbf8fcc..2d594c27 100644
--- a/desktop/src/main.go
+++ b/desktop/src/main.go
@@ -202,7 +202,11 @@ func (a *App) boot() {
a.sidecar = sidecar
if serr != nil {
logStartupError("organization sidecar", serr)
+ } else if werr := waitSidecarLive(45 * time.Second); werr != nil {
+ logStartupError("organization sidecar health", werr)
}
+ } else {
+ log.Printf("organization sidecar not bundled under %s", orgSidecarDir(root))
}
cmd, err := startOctop(root, port)
a.cmd = cmd
diff --git a/desktop/src/process_sidecar.go b/desktop/src/process_sidecar.go
index 87ca1527..4c1bc6ea 100644
--- a/desktop/src/process_sidecar.go
+++ b/desktop/src/process_sidecar.go
@@ -4,12 +4,15 @@ import (
"crypto/rand"
"encoding/hex"
"fmt"
+ "log"
+ "net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
+ "time"
)
func orgSidecarDir(root string) string {
@@ -32,12 +35,40 @@ func sidecarReady(root string) bool {
if _, err := os.Stat(sidecarNodeExe(root)); err != nil {
return false
}
- if _, err := os.Stat(filepath.Join(sidecarAppDir(root), "backend", "server.ts")); err != nil {
+ app := sidecarAppDir(root)
+ if _, err := os.Stat(filepath.Join(app, "backend", "server.ts")); err != nil {
+ return false
+ }
+ if _, err := os.Stat(filepath.Join(app, "dist", "index.html")); err != nil {
+ log.Printf("organization sidecar frontend missing under %s", app)
return false
}
return true
}
+func waitSidecarLive(timeout time.Duration) error {
+ deadline := time.Now().Add(timeout)
+ url := sidecarURL() + "/api/health/livez"
+ var last error
+ for time.Now().Before(deadline) {
+ resp, err := http.Get(url)
+ if err == nil {
+ resp.Body.Close()
+ if resp.StatusCode < 500 {
+ return nil
+ }
+ last = fmt.Errorf("HTTP %d", resp.StatusCode)
+ } else {
+ last = err
+ }
+ time.Sleep(400 * time.Millisecond)
+ }
+ if last == nil {
+ return fmt.Errorf("sidecar not reachable at %s", url)
+ }
+ return fmt.Errorf("sidecar not reachable at %s: %w", url, last)
+}
+
func sidecarDataDir(home string) string {
return filepath.Join(home, "org-os")
}
diff --git a/desktop/src/process_sidecar_test.go b/desktop/src/process_sidecar_test.go
index f3685524..762ca52c 100644
--- a/desktop/src/process_sidecar_test.go
+++ b/desktop/src/process_sidecar_test.go
@@ -46,3 +46,36 @@ func TestSidecarLaunchEnvPointsAtHomeDatabase(t *testing.T) {
t.Fatalf("CORS_ORIGIN=%q", env["CORS_ORIGIN"])
}
}
+
+func TestSidecarReadyRequiresFrontendBuild(t *testing.T) {
+ root := t.TempDir()
+ if sidecarReady(root) {
+ t.Fatal("empty tree should not be ready")
+ }
+ node := sidecarNodeExe(root)
+ if err := os.MkdirAll(filepath.Dir(node), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(node, []byte("node"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ app := sidecarAppDir(root)
+ if err := os.MkdirAll(filepath.Join(app, "backend"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(app, "backend", "server.ts"), []byte(""), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if sidecarReady(root) {
+ t.Fatal("missing dist/index.html should not be ready")
+ }
+ if err := os.MkdirAll(filepath.Join(app, "dist"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(app, "dist", "index.html"), []byte(""), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if !sidecarReady(root) {
+ t.Fatal("bundled node + server + frontend should be ready")
+ }
+}
diff --git a/modules/openxyos/backend/server.ts b/modules/openxyos/backend/server.ts
index 398c3937..873d3523 100644
--- a/modules/openxyos/backend/server.ts
+++ b/modules/openxyos/backend/server.ts
@@ -63,11 +63,24 @@ async function main() {
// 信任 nginx 代理(修复 X-Forwarded-For 报错)
app.set("trust proxy", 1);
- // 安全头
+ // 安全头。FreeOS embeds this sidecar in the Organization preview iframe
+ // (dashboard origin is a different port on 127.0.0.1). Default helmet
+ // frameguard is SAMEORIGIN and blanks that preview.
+ const embeddable = Boolean(process.env.FREEOS_HOME || process.env.AIR_GAP_MODE === "true");
app.use(helmet({
contentSecurityPolicy: false, // CSP 由 Vite 构建层管理
crossOriginEmbedderPolicy: false,
+ ...(embeddable ? { frameguard: false as const } : {}),
}));
+ if (embeddable) {
+ app.use((_req, res, next) => {
+ res.setHeader(
+ "Content-Security-Policy",
+ "frame-ancestors 'self' http://127.0.0.1:* http://localhost:* http://[::1]:*",
+ );
+ next();
+ });
+ }
// CORS — 白名单 + WebView 兼容
const allowedOrigins = process.env.CORS_ORIGIN
diff --git a/src/octop/api/routers/org_module.py b/src/octop/api/routers/org_module.py
index 709d4809..4d9a2725 100644
--- a/src/octop/api/routers/org_module.py
+++ b/src/octop/api/routers/org_module.py
@@ -16,7 +16,11 @@
from octop.modules.org_os.overview import build_overview
from octop.modules.org_os.proxy import identity_headers, proxy_request
from octop.modules.org_os.service import OrgModuleService, org_module_from_paths
-from octop.modules.org_os.sidecar_launch import find_sidecar_launcher, start_sidecar
+from octop.modules.org_os.sidecar_launch import (
+ sidecar_can_start,
+ sidecar_start_command,
+ start_sidecar,
+)
router = APIRouter()
@@ -78,20 +82,23 @@ async def org_module_overview(
) -> dict[str, Any]:
service = _service(server)
counts = _plane_counts(server, user)
- return build_overview(
+ snapshot = build_overview(
service,
agents=counts["agents"],
connectors=counts["connectors"],
cron_jobs=counts["cron_jobs"],
skill_packages=counts["skill_packages"],
- start_available=find_sidecar_launcher() is not None,
- ).to_dict()
+ start_available=sidecar_can_start(),
+ )
+ payload = snapshot.to_dict()
+ payload["start_command"] = sidecar_start_command()
+ return payload
@router.post("/sidecar/start", summary="Start the bundled openXYOS sidecar")
async def org_module_start_sidecar(
server: OctopServer = Depends(get_server),
- _: Any = Depends(require_permission("plugins")),
+ _user: Any = Depends(current_user),
) -> dict[str, Any]:
service = _service(server)
if not service.is_enabled():
diff --git a/src/octop/infra/server.py b/src/octop/infra/server.py
index 66abe235..a582c669 100644
--- a/src/octop/infra/server.py
+++ b/src/octop/infra/server.py
@@ -309,6 +309,7 @@ async def start(self) -> None:
self.plugin_manager.seed_bundled()
self.plugin_manager.load_installed(install_deps=True)
self._apply_desktop_org_defaults()
+ self._ensure_desktop_org_sidecar()
import time # noqa: PLC0415
@@ -566,6 +567,34 @@ def _apply_desktop_org_defaults(self) -> None:
if service.enable_from_desktop_env():
logger.info("organization module enabled (FREEOS_ORG_ENABLE)")
+ def _ensure_desktop_org_sidecar(self) -> None:
+ """Keep the bundled openXYOS sidecar running on desktop / first launch."""
+ desktop = (
+ (os.environ.get("OCTOP_DESKTOP") or os.environ.get("FREEOS_DESKTOP") or "")
+ .strip()
+ .lower()
+ )
+ org_flag = (os.environ.get("FREEOS_ORG_ENABLE") or "").strip().lower()
+ if desktop not in {"1", "true", "yes", "on"} and org_flag not in {
+ "1",
+ "true",
+ "yes",
+ "on",
+ }:
+ return
+ from octop.modules.org_os.service import org_module_from_paths
+ from octop.modules.org_os.sidecar_launch import ensure_sidecar
+
+ service = org_module_from_paths(self.paths)
+ result = ensure_sidecar(service, wait=20.0)
+ logger.info(
+ "organization sidecar ensure started=%s already=%s reachable=%s detail=%s",
+ result.started,
+ result.already,
+ result.reachable,
+ result.detail,
+ )
+
def _setup_logging(self) -> None:
log_dir = self.paths.logs_dir
log_dir.mkdir(parents=True, exist_ok=True)
diff --git a/src/octop/modules/org_os/sidecar_launch.py b/src/octop/modules/org_os/sidecar_launch.py
index fd09161e..5e0fb700 100644
--- a/src/octop/modules/org_os/sidecar_launch.py
+++ b/src/octop/modules/org_os/sidecar_launch.py
@@ -2,10 +2,12 @@
from __future__ import annotations
+import logging
import os
import subprocess
import sys
import time
+from contextlib import suppress
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@@ -13,6 +15,14 @@
from octop.infra.utils.paths import PathLayout
from octop.modules.org_os.service import OrgModuleService
+logger = logging.getLogger(__name__)
+
+# Windows: hide the console and detach from the host so closing the dashboard
+# does not tear the sidecar down with a ctrl-c on the parent console.
+_WIN_CREATE_NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000)
+_WIN_DETACHED = getattr(subprocess, "DETACHED_PROCESS", 0x00000008)
+_WIN_NEW_GROUP = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200)
+
@dataclass(frozen=True)
class SidecarStartResult:
@@ -36,22 +46,69 @@ def to_dict(self) -> dict[str, Any]:
}
+@dataclass(frozen=True)
+class SidecarRuntime:
+ node: Path
+ app: Path
+ root: Path
+
+ @property
+ def server(self) -> Path:
+ return self.app / "backend" / "server.ts"
+
+ @property
+ def frontend(self) -> Path:
+ return self.app / "dist" / "index.html"
+
+
def portable_root() -> Path | None:
green = (os.environ.get("OCTOP_GREEN_PACKAGES") or "").strip()
if green:
- return Path(green).expanduser().resolve().parent
+ parent = Path(green).expanduser().resolve().parent
+ if (parent / "org-sidecar").is_dir():
+ return parent
home = PathLayout.from_env().root
- candidate = home / "portable"
- if (candidate / "org-sidecar").is_dir():
- return candidate
+ for candidate in (home / "portable", home):
+ if (candidate / "org-sidecar").is_dir():
+ return candidate
return None
-def find_sidecar_launcher() -> Path | None:
+def sidecar_bundle_dir() -> Path | None:
root = portable_root()
- if root is not None:
- bundled = root / "org-sidecar"
- for name in ("start-sidecar.bat", "start-sidecar.sh"):
+ if root is None:
+ return None
+ bundled = root / "org-sidecar"
+ return bundled if bundled.is_dir() else None
+
+
+def find_sidecar_runtime() -> SidecarRuntime | None:
+ bundled = sidecar_bundle_dir()
+ if bundled is None:
+ return None
+ node_candidates = (
+ bundled / "node" / "node.exe",
+ bundled / "node" / "bin" / "node",
+ )
+ node = next((path for path in node_candidates if path.is_file()), None)
+ if node is None:
+ return None
+ app = bundled / "openxyos"
+ runtime = SidecarRuntime(node=node, app=app, root=bundled)
+ if not runtime.server.is_file():
+ return None
+ return runtime
+
+
+def find_sidecar_launcher() -> Path | None:
+ bundled = sidecar_bundle_dir()
+ if bundled is not None:
+ names = (
+ ("start-sidecar.bat", "start-sidecar.sh")
+ if sys.platform == "win32"
+ else ("start-sidecar.sh", "start-sidecar.bat")
+ )
+ for name in names:
path = bundled / name
if path.is_file():
if name.endswith(".bat") and sys.platform != "win32":
@@ -67,10 +124,163 @@ def find_sidecar_launcher() -> Path | None:
return None
-def start_sidecar(service: OrgModuleService, *, wait: float = 12.0) -> SidecarStartResult:
+def sidecar_can_start() -> bool:
+ return find_sidecar_runtime() is not None or find_sidecar_launcher() is not None
+
+
+def sidecar_start_command() -> str:
+ runtime = find_sidecar_runtime()
+ if runtime is not None:
+ return sidecar_node_command(runtime)
+ launcher = find_sidecar_launcher()
+ if launcher is not None:
+ return str(launcher)
+ if sys.platform == "win32":
+ return r"org-sidecar\start-sidecar.bat"
+ return "bash scripts/run-org-sidecar.sh"
+
+
+def sidecar_node_command(runtime: SidecarRuntime) -> str:
+ return f"{runtime.node} --import tsx backend/server.ts"
+
+
+def _load_or_create_secrets(data_dir: Path) -> tuple[str, str]:
+ env_path = data_dir / "sidecar.env"
+ jwt = ""
+ cookie = ""
+ if env_path.is_file():
+ for line in env_path.read_text(encoding="utf-8").splitlines():
+ stripped = line.strip()
+ if not stripped or stripped.startswith("#") or "=" not in stripped:
+ continue
+ key, value = stripped.split("=", 1)
+ if key.strip() == "JWT_SECRET":
+ jwt = value.strip()
+ elif key.strip() == "COOKIE_SECRET":
+ cookie = value.strip()
+ if not jwt:
+ jwt = os.urandom(32).hex()
+ if not cookie:
+ cookie = os.urandom(32).hex()
+ data_dir.mkdir(parents=True, exist_ok=True)
+ env_path.write_text(
+ f"JWT_SECRET={jwt}\nCOOKIE_SECRET={cookie}\n",
+ encoding="utf-8",
+ )
+ with suppress(OSError):
+ env_path.chmod(0o600)
+ return jwt, cookie
+
+
+def sidecar_launch_env(home: Path, *, dashboard_port: int | None = None) -> dict[str, str]:
+ data_dir = Path(os.environ.get("FREEOS_ORG_DATA") or (home / "org-os"))
+ jwt, cookie = _load_or_create_secrets(data_dir)
+ port = (os.environ.get("FREEOS_ORG_SIDECAR_PORT") or "3780").strip() or "3780"
+ if dashboard_port is None:
+ raw = (os.environ.get("OCTOP_PORT") or "").strip()
+ dashboard_port = int(raw) if raw.isdigit() else 8088
+ origin = (
+ f"http://127.0.0.1:{dashboard_port},http://localhost:{dashboard_port},"
+ "http://127.0.0.1:18900,http://localhost:18900,"
+ "http://127.0.0.1:8088,http://localhost:8088"
+ )
+ env = os.environ.copy()
+ env.update(
+ {
+ "NODE_ENV": "production",
+ "PORT": port,
+ "DB_DIALECT": "sqlite",
+ "DATABASE_PATH": str(data_dir / "xiongyuan.db"),
+ "AIR_GAP_MODE": "true",
+ "ALLOW_PUBLIC_REGISTRATION": env.get("ALLOW_PUBLIC_REGISTRATION") or "false",
+ "SEED_DEMO_DATA": env.get("SEED_DEMO_DATA") or "false",
+ "JWT_SECRET": jwt,
+ "COOKIE_SECRET": cookie,
+ "CORS_ORIGIN": env.get("CORS_ORIGIN") or origin,
+ "FREEOS_HOME": str(home),
+ "OCTOP_HOME": str(home),
+ "FREEOS_ORG_SIDECAR_PORT": port,
+ }
+ )
+ return env
+
+
+def sidecar_log_path(home: Path | None = None) -> Path:
+ root = home or PathLayout.from_env().root
+ log_dir = root / "logs"
+ log_dir.mkdir(parents=True, exist_ok=True)
+ return log_dir / "org-sidecar.log"
+
+
+def launch_sidecar_argv(runtime: SidecarRuntime | None, launcher: Path | None) -> list[str]:
+ """Argv used to spawn the sidecar. Prefer bundled Node over .bat/.sh."""
+ if runtime is not None:
+ return [str(runtime.node), "--import", "tsx", "backend/server.ts"]
+ if launcher is None:
+ return []
+ if sys.platform == "win32" and launcher.suffix.lower() in {".bat", ".cmd"}:
+ return ["cmd", "/c", str(launcher)]
+ if launcher.suffix == ".sh" or launcher.name.endswith(".sh"):
+ return ["bash", str(launcher)]
+ return [str(launcher)]
+
+
+def _popen_kwargs(cwd: Path, env: dict[str, str], log_file: Any) -> dict[str, Any]:
+ kwargs: dict[str, Any] = {
+ "cwd": str(cwd),
+ "env": env,
+ "stdout": log_file,
+ "stderr": subprocess.STDOUT,
+ }
+ if sys.platform == "win32":
+ kwargs["creationflags"] = _WIN_CREATE_NO_WINDOW | _WIN_DETACHED | _WIN_NEW_GROUP
+ kwargs["close_fds"] = True
+ else:
+ kwargs["start_new_session"] = True
+ return kwargs
+
+
+def _spawn(service: OrgModuleService) -> tuple[list[str], str]:
+ runtime = find_sidecar_runtime()
+ launcher = find_sidecar_launcher()
+ argv = launch_sidecar_argv(runtime, launcher)
+ if not argv:
+ return [], "no bundled sidecar runtime or launcher"
+ if runtime is not None:
+ cwd = runtime.app
+ elif launcher is not None:
+ cwd = launcher.parent
+ else:
+ return [], "no bundled sidecar runtime or launcher"
+ home = service.home
+ env = sidecar_launch_env(home)
+ if runtime is not None and not runtime.frontend.is_file():
+ logger.warning("openXYOS frontend build missing at %s", runtime.frontend)
+ log_path = sidecar_log_path(home)
+ command = " ".join(argv)
+ log_file = log_path.open("a", encoding="utf-8")
+ log_file.write(f"\n--- start {time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())} ---\n")
+ log_file.flush()
+ # NOCA:DangerousSubprocessUseAudit(argv list; launcher/node is a bundled binary we own)
+ subprocess.Popen(argv, **_popen_kwargs(cwd, env, log_file))
+ return argv, command
+
+
+def _wait_reachable(service: OrgModuleService, wait: float) -> Any:
+ deadline = time.time() + max(wait, 1.0)
+ last = service.probe_sidecar()
+ while time.time() < deadline and not last.reachable:
+ time.sleep(0.4)
+ last = service.probe_sidecar()
+ return last
+
+
+def start_sidecar(service: OrgModuleService, *, wait: float = 25.0) -> SidecarStartResult:
health = service.probe_sidecar()
+ command = sidecar_start_command()
+ runtime = find_sidecar_runtime()
launcher = find_sidecar_launcher()
- command = str(launcher) if launcher is not None else service.status().start_command
+ launcher_label = str(runtime.node if runtime is not None else launcher or "")
if health.reachable:
return SidecarStartResult(
started=False,
@@ -79,40 +289,82 @@ def start_sidecar(service: OrgModuleService, *, wait: float = 12.0) -> SidecarSt
url=health.url,
command=command,
detail="sidecar already reachable",
- launcher=str(launcher or ""),
+ launcher=launcher_label,
)
- if launcher is None or not launcher.is_file():
+ if not sidecar_can_start():
return SidecarStartResult(
started=False,
already=False,
reachable=False,
url=health.url,
command=command,
- detail="no bundled sidecar launcher; run scripts/run-org-sidecar.sh from a source tree",
+ detail=(
+ "no bundled sidecar runtime; install the desktop package "
+ "or run scripts/run-org-sidecar.sh from a source tree"
+ ),
+ launcher=launcher_label,
+ )
+ try:
+ argv, command = _spawn(service)
+ except OSError as exc:
+ logger.exception("failed to spawn openXYOS sidecar")
+ return SidecarStartResult(
+ started=False,
+ already=False,
+ reachable=False,
+ url=health.url,
+ command=command,
+ detail=f"spawn failed: {exc}",
+ launcher=launcher_label,
+ )
+ last = _wait_reachable(service, wait)
+ missing_fe = runtime is not None and not runtime.frontend.is_file()
+ if last.reachable:
+ detail = "ok"
+ elif missing_fe:
+ detail = (
+ "started but frontend build (dist/index.html) is missing; "
+ "sidecar may still come up — check logs/org-sidecar.log"
)
- popen_kwargs: dict[str, Any] = {
- "cwd": str(launcher.parent),
- "stdout": subprocess.DEVNULL,
- "stderr": subprocess.DEVNULL,
- "start_new_session": True,
- }
- if sys.platform == "win32":
- cmd: list[str] = [str(launcher)]
else:
- cmd = ["bash", str(launcher)]
- # NOCA:DangerousSubprocessUseAudit(argv list; launcher is a bundled script we own)
- subprocess.Popen(cmd, **popen_kwargs)
- deadline = time.time() + max(wait, 1.0)
- last = service.probe_sidecar()
- while time.time() < deadline and not last.reachable:
- time.sleep(0.4)
- last = service.probe_sidecar()
+ detail = "started but not reachable yet; check logs/org-sidecar.log"
+ logger.info("openXYOS sidecar spawn argv=%s reachable=%s", argv, last.reachable)
return SidecarStartResult(
started=True,
already=False,
reachable=last.reachable,
url=last.url,
command=command,
- detail="ok" if last.reachable else "started but not reachable yet",
- launcher=str(launcher),
+ detail=detail,
+ launcher=launcher_label,
)
+
+
+def ensure_sidecar(service: OrgModuleService, *, wait: float = 20.0) -> SidecarStartResult:
+ """Keep the bundled sidecar up on desktop / first launch.
+
+ Only auto-starts when the portable ``org-sidecar`` runtime is present.
+ A source-tree ``scripts/run-org-sidecar.sh`` is left for an explicit
+ Start click so host boot never runs ``npm start`` in tests or unpackaged trees.
+ """
+ health = service.probe_sidecar()
+ command = sidecar_start_command()
+ if health.reachable:
+ return SidecarStartResult(
+ started=False,
+ already=True,
+ reachable=True,
+ url=health.url,
+ command=command,
+ detail="sidecar already reachable",
+ )
+ if find_sidecar_runtime() is None:
+ return SidecarStartResult(
+ started=False,
+ already=False,
+ reachable=False,
+ url=health.url,
+ command=command,
+ detail="no bundled sidecar runtime",
+ )
+ return start_sidecar(service, wait=wait)
diff --git a/tests/unit/test_sidecar_launch.py b/tests/unit/test_sidecar_launch.py
new file mode 100644
index 00000000..5bf730d8
--- /dev/null
+++ b/tests/unit/test_sidecar_launch.py
@@ -0,0 +1,125 @@
+"""Sidecar launcher discovery and Windows spawn argv."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+
+from octop.modules.org_os.service import OrgModuleService
+from octop.modules.org_os.sidecar_launch import (
+ SidecarRuntime,
+ ensure_sidecar,
+ find_sidecar_runtime,
+ launch_sidecar_argv,
+ portable_root,
+ sidecar_can_start,
+ sidecar_launch_env,
+)
+
+
+def _write_runtime(root: Path) -> SidecarRuntime:
+ bundled = root / "org-sidecar"
+ win_node = bundled / "node" / "node.exe"
+ posix_node = bundled / "node" / "bin" / "node"
+ win_node.parent.mkdir(parents=True, exist_ok=True)
+ posix_node.parent.mkdir(parents=True, exist_ok=True)
+ win_node.write_text("node", encoding="utf-8")
+ posix_node.write_text("node", encoding="utf-8")
+ app = bundled / "openxyos"
+ (app / "backend").mkdir(parents=True)
+ (app / "backend" / "server.ts").write_text("// sidecar", encoding="utf-8")
+ (app / "dist").mkdir()
+ (app / "dist" / "index.html").write_text("", encoding="utf-8")
+ return SidecarRuntime(node=win_node, app=app, root=bundled)
+
+
+def test_portable_root_follows_green_packages(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ staging = tmp_path / "portable"
+ (staging / "org-sidecar").mkdir(parents=True)
+ (staging / "packages").mkdir()
+ monkeypatch.setenv("OCTOP_GREEN_PACKAGES", str(staging / "packages"))
+ assert portable_root() == staging.resolve()
+
+
+def test_find_sidecar_runtime_from_green_packages(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ staging = tmp_path / "portable"
+ runtime = _write_runtime(staging)
+ monkeypatch.setenv("OCTOP_GREEN_PACKAGES", str(staging / "packages"))
+ (staging / "packages").mkdir(exist_ok=True)
+ found = find_sidecar_runtime()
+ assert found is not None
+ assert found.node == runtime.node
+ assert found.frontend.is_file()
+ assert sidecar_can_start() is True
+
+
+def test_launch_argv_prefers_bundled_node(tmp_path: Path) -> None:
+ runtime = _write_runtime(tmp_path)
+ launcher = tmp_path / "org-sidecar" / "start-sidecar.bat"
+ launcher.write_text("echo", encoding="utf-8")
+ argv = launch_sidecar_argv(runtime, launcher)
+ assert argv[0] == str(runtime.node)
+ assert argv[1:] == ["--import", "tsx", "backend/server.ts"]
+
+
+def test_launch_argv_windows_bat(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
+ monkeypatch.setattr("octop.modules.org_os.sidecar_launch.sys.platform", "win32")
+ launcher = tmp_path / "start-sidecar.bat"
+ launcher.write_text("echo", encoding="utf-8")
+ argv = launch_sidecar_argv(None, launcher)
+ assert argv == ["cmd", "/c", str(launcher)]
+
+
+def test_start_sidecar_already_reachable(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ from octop.modules.org_os import sidecar_launch
+ from octop.modules.org_os.service import SidecarHealth
+
+ service = OrgModuleService(config_path=tmp_path / "config.json", home=tmp_path)
+ monkeypatch.setattr(
+ service,
+ "probe_sidecar",
+ lambda timeout=2.0: SidecarHealth(reachable=True, url="http://127.0.0.1:3780"),
+ )
+ monkeypatch.setattr(sidecar_launch, "find_sidecar_runtime", lambda: None)
+ monkeypatch.setattr(sidecar_launch, "find_sidecar_launcher", lambda: None)
+ result = sidecar_launch.start_sidecar(service, wait=0.1)
+ assert result.already is True
+ assert result.reachable is True
+ assert result.started is False
+
+
+def test_ensure_sidecar_skips_source_tree_script(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ from octop.modules.org_os import sidecar_launch
+ from octop.modules.org_os.service import SidecarHealth
+
+ service = OrgModuleService(config_path=tmp_path / "config.json", home=tmp_path)
+ monkeypatch.setattr(
+ service,
+ "probe_sidecar",
+ lambda timeout=2.0: SidecarHealth(reachable=False, url="http://127.0.0.1:3780"),
+ )
+ monkeypatch.setattr(sidecar_launch, "find_sidecar_runtime", lambda: None)
+ spawned: list[object] = []
+ monkeypatch.setattr(
+ sidecar_launch, "_spawn", lambda service: spawned.append(service) or ([], "")
+ )
+ result = ensure_sidecar(service, wait=0.1)
+ assert result.started is False
+ assert result.detail == "no bundled sidecar runtime"
+ assert spawned == []
+
+
+def test_sidecar_launch_env_writes_secrets(tmp_path: Path) -> None:
+ env = sidecar_launch_env(tmp_path, dashboard_port=8099)
+ assert env["DATABASE_PATH"] == str(tmp_path / "org-os" / "xiongyuan.db")
+ assert "http://127.0.0.1:8099" in env["CORS_ORIGIN"]
+ secrets = (tmp_path / "org-os" / "sidecar.env").read_text(encoding="utf-8")
+ assert "JWT_SECRET=" in secrets
+ assert "COOKIE_SECRET=" in secrets