diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4ec4e077..c4e051d2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,10 @@
### 修复
+- 对话模型选择器底部增加「模型管理」,跳到侧栏同一条「模型」路由(`/models`)。
+- 组织页默认只显示本机 openXYOS 嵌入和顶部蓝色状态栏;「启用组织模块」「组织OS管理」在状态栏右下。互生长导入/导出、源码下载、循环时间线等收到「组织OS管理」。iframe 高度等于可见预览区,openXYOS 弹窗按 iframe 视口水平垂直居中。
+- 嵌入本机 openXYOS 登录不再因 CORS 漏掉 `http://127.0.0.1:3780` 而返回笼统的「服务器内部错误」。启动环境合并 sidecar 自源;无演示种子时写入本地 demo 账号,保证隔空第一台电脑能登录。
+
- Windows 安装期 `start-sidecar.ps1` 不再把数据目录写到只读自动变量 `$home` / `$HOME`(赋值抛 `SessionStateUnauthorizedAccessException`,`$ErrorActionPreference = Continue` 时脚本继续跑,SQLite 会落到 `%USERPROFILE%\\org-os\\xiongyuan.db`)。改为 `$freeosHome`(默认 `%USERPROFILE%\\.freeos`)。
- 预构建 `openxyos-runtime.zip` 现在带上 `backend-dist/migrations/*.sql`。编译后的 `server.js` 用 `__dirname/migrations` 做 `initDatabase()`,缺文件会 ENOENT(`013_audit_bundle.sql`)并让 livez 永远起不来。安装期预配也会把已有 live 目录里的 `backend/migrations` 补到 `backend-dist/migrations`。
- Windows 安装期 `start-sidecar.ps1` 不再用 PowerShell 5.1 默认的 `UseShellExecute=true` 拉起 Node(那样不会继承 `$env:CORS_ORIGIN`,生产环境 `parseOrigins` 立刻抛错,进程退出,3780 无监听,预配空等 90s 后才以退出码 12 失败)。改为 `UseShellExecute=false` 并显式写入 `ProcessStartInfo.EnvironmentVariables`,把 Node stdout/stderr 记入 `%LOCALAPPDATA%\\FreeOS\\openxyos\\start.log`;进程在 livez 前退出则预配以退出码 10 失败并附上日志摘录。
diff --git a/dashboard/src/layouts/PageShell.module.less b/dashboard/src/layouts/PageShell.module.less
index fa2dd8ab..fbfdda0b 100644
--- a/dashboard/src/layouts/PageShell.module.less
+++ b/dashboard/src/layouts/PageShell.module.less
@@ -73,6 +73,41 @@
border-radius: 8px;
}
+.pathTabsRow {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 4px;
+}
+
+.pathTabsFlush {
+ flex-direction: column;
+ flex-wrap: nowrap;
+ align-items: stretch;
+ width: 100%;
+ padding: 0;
+ background: var(--fn-bg-layout);
+ border-radius: 0;
+
+ .pathTabsRow {
+ flex-wrap: nowrap;
+ width: 100%;
+ overflow-x: auto;
+ scrollbar-width: none;
+
+ &::-webkit-scrollbar {
+ display: none;
+ }
+ }
+}
+
+.pathTabsBelowTitle {
+ flex-shrink: 0;
+ width: 100%;
+ margin: 0 0 12px;
+ background: var(--fn-bg-layout);
+}
+
.pathTabsCompact {
width: 100%;
}
diff --git a/dashboard/src/layouts/PageShell.test.tsx b/dashboard/src/layouts/PageShell.test.tsx
index 936d0064..a73e4838 100644
--- a/dashboard/src/layouts/PageShell.test.tsx
+++ b/dashboard/src/layouts/PageShell.test.tsx
@@ -37,4 +37,55 @@ describe("PageShell path tabs", () => {
"true",
);
});
+
+ it("places a two-row flush strip below the title without a subtitle", () => {
+ const labels = [
+ "技能",
+ "通道",
+ "连接器/MCP",
+ "技能包",
+ "工具",
+ "插件",
+ "子智能体",
+ "MBTI",
+ "记忆",
+ "工作台",
+ "远程桌面",
+ "ACP",
+ "用户",
+ "存储",
+ "主机插件",
+ "安全",
+ "高级",
+ "智能体配置",
+ ];
+ const { container } = render(
+ ({
+ value: label,
+ label,
+ icon: null,
+ })),
+ }}
+ >
+ body
+ ,
+ );
+
+ expect(screen.queryByText("技能、通道、连接器、技能包")).toBeNull();
+ expect(container.querySelector("div[class*='titleActions']")).toBeNull();
+ const rows = container.querySelectorAll("[data-testid='path-tabs-row']");
+ expect(rows).toHaveLength(2);
+ expect(rows[0].querySelectorAll("[role='tab']")).toHaveLength(9);
+ expect(rows[1].querySelectorAll("[role='tab']")).toHaveLength(9);
+ expect(container.querySelector("div[class*='pathTabsFlush']")).toBeTruthy();
+ expect(
+ container.querySelector("div[class*='pathTabsBelowTitle']"),
+ ).toBeTruthy();
+ });
});
diff --git a/dashboard/src/layouts/PageShell.tsx b/dashboard/src/layouts/PageShell.tsx
index 6c1ce737..cbb915b4 100644
--- a/dashboard/src/layouts/PageShell.tsx
+++ b/dashboard/src/layouts/PageShell.tsx
@@ -32,8 +32,14 @@ interface PageShellProps {
/**
* Path tabs shared by Workbench / Personalization:
* desktop → title-row actions (wraps); mobile → full-width bar above content.
+ * Use `pathTabsPlacement="below-title"` for a two-row strip under the title.
*/
pathTabs?: PathTabsConfig;
+ /**
+ * `title-row` (default): desktop chips sit in the title actions slot and wrap.
+ * `below-title`: two equal rows directly under the title, on the page background.
+ */
+ pathTabsPlacement?: "title-row" | "below-title";
/** Render agent picker below the title row, outside the scrollable content card. */
agentScoped?: boolean;
/** When true, the content area does not scroll; children fill remaining height. */
@@ -41,36 +47,75 @@ interface PageShellProps {
children: React.ReactNode;
}
+function splitPathTabRows(options: PathTabOption[]): PathTabOption[][] {
+ if (options.length <= 1) return [options];
+ const mid = Math.ceil(options.length / 2);
+ return [options.slice(0, mid), options.slice(mid)];
+}
+
+function PathTabButton({
+ opt,
+ active,
+ compact,
+ onChange,
+}: {
+ opt: PathTabOption;
+ active: boolean;
+ compact: boolean;
+ onChange: (value: string | number) => void;
+}) {
+ return (
+
+ );
+}
+
function PathTabsBar({
pathTabs,
compact,
+ splitRows,
+ flush,
}: {
pathTabs: PathTabsConfig;
compact: boolean;
+ splitRows?: boolean;
+ flush?: boolean;
}) {
+ const rows = splitRows
+ ? splitPathTabRows(pathTabs.options)
+ : [pathTabs.options];
return (
- {pathTabs.options.map((opt) => {
- const active = opt.value === pathTabs.value;
- return (
-
- );
- })}
+ {rows.map((row, index) => (
+
+ {row.map((opt) => (
+
+ ))}
+
+ ))}
);
}
@@ -87,6 +132,7 @@ function PathTabsBar({
* - Only the content area scrolls internally
* - `actions` slot: right-aligned in the title row
* - `pathTabs`: desktop in title row (flex-wrap); mobile full-width wrapping bar
+ * - `pathTabsPlacement="below-title"`: two rows under the title, page-bg strip
*
* Tabbed helpers: `PageShell.FillTabs` (Ant Tabs) and `PageShell.Tabbed`
* (custom tab bar) pin the tab chrome and scroll only the body on desktop.
@@ -96,6 +142,7 @@ function PageShell({
subtitle,
actions,
pathTabs,
+ pathTabsPlacement = "title-row",
agentScoped,
fill,
children,
@@ -104,11 +151,16 @@ function PageShell({
const outerPad = isMobile ? 12 : 32;
const outerPadTop = isMobile ? 12 : 24;
const contentPad = isMobile ? 12 : 24;
+ const tabsBelowTitle = Boolean(
+ pathTabs && pathTabsPlacement === "below-title",
+ );
+ const tabsInTitleRow = Boolean(pathTabs && !tabsBelowTitle && !isMobile);
+ const tabsInContent = Boolean(pathTabs && !tabsBelowTitle && isMobile);
/** Fill layout, or mobile path-tabs that must stay pinned above the body. */
- const pinBody = Boolean(fill || (isMobile && pathTabs));
+ const pinBody = Boolean(fill || tabsInContent);
const titleActions =
- !isMobile && pathTabs ? (
+ tabsInTitleRow && pathTabs ? (
<>
{actions}
@@ -133,7 +185,7 @@ function PageShell({
@@ -152,6 +204,12 @@ function PageShell({
) : null}
+ {tabsBelowTitle && pathTabs ? (
+
+ ) : null}
+
{agentScoped && (
@@ -178,7 +236,7 @@ function PageShell({
flexDirection: pinBody ? "column" : undefined,
}}
>
- {isMobile && pathTabs && (
+ {tabsInContent && pathTabs && (
diff --git a/dashboard/src/layouts/sidebarNav.test.ts b/dashboard/src/layouts/sidebarNav.test.ts
index 0c6539db..cb66fe75 100644
--- a/dashboard/src/layouts/sidebarNav.test.ts
+++ b/dashboard/src/layouts/sidebarNav.test.ts
@@ -79,6 +79,12 @@ describe("sidebarNav", () => {
it("labels organization without the OS suffix", () => {
expect(zh.nav.organization).toBe("组织");
expect(en.nav.organization).toBe("Organization");
+ expect(zh.organization.enableModule).toBe("启用组织模块");
+ expect(zh.organization.manageOs).toBe("组织OS管理");
+ expect(en.organization.enableModule).toBe("Enable organization module");
+ expect(en.organization.manageOs).toBe("Organization OS");
+ expect(zh.chat.modelPickerManage).toBe("模型管理");
+ expect(en.chat.modelPickerManage).toBe("Model management");
});
it("locks the employee / colleague / expert glossary", () => {
diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json
index 3f7d23e8..caf52f49 100644
--- a/dashboard/src/locales/en.json
+++ b/dashboard/src/locales/en.json
@@ -12,6 +12,11 @@
"heroTitle": "Organization",
"heroStory": "FreeOS does the work. openXYOS owns organization and governance. They grow each other.",
"toggleLabel": "Enable organization module",
+ "enableModule": "Enable organization module",
+ "manageOs": "Organization OS",
+ "moduleDrawerTitle": "Enable organization module",
+ "manageDrawerTitle": "Organization OS",
+ "statusBarTitle": "Organization",
"on": "Enabled",
"off": "Disabled",
"sidecarUp": "Local openXYOS connected",
@@ -1401,7 +1406,7 @@
"subagentPickerSearch": "Search subagents",
"subagentPickerEmpty": "No installed subagents",
"subagentPickerManage": "Manage subagents",
- "modelPickerManage": "Manage models",
+ "modelPickerManage": "Model management",
"sessionWorking": "Working",
"composerContextLabel": "Message context",
"contextWindow": {
diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json
index c9180e71..9cbe109e 100644
--- a/dashboard/src/locales/zh.json
+++ b/dashboard/src/locales/zh.json
@@ -12,6 +12,11 @@
"heroTitle": "组织",
"heroStory": "FreeOS 负责干活,openXYOS 负责组织与治理,两者互相生长。",
"toggleLabel": "启用组织模块",
+ "enableModule": "启用组织模块",
+ "manageOs": "组织OS管理",
+ "moduleDrawerTitle": "启用组织模块",
+ "manageDrawerTitle": "组织OS管理",
+ "statusBarTitle": "组织",
"on": "已启用",
"off": "已关闭",
"sidecarUp": "本机 openXYOS 已连接",
@@ -1401,7 +1406,7 @@
"subagentPickerSearch": "搜索子智能体",
"subagentPickerEmpty": "暂无已安装的子智能体",
"subagentPickerManage": "管理子智能体",
- "modelPickerManage": "管理模型",
+ "modelPickerManage": "模型管理",
"sessionWorking": "正在回复",
"composerContextLabel": "消息附加上下文",
"contextWindow": {
diff --git a/dashboard/src/pages/Agent/Personalization/index.tsx b/dashboard/src/pages/Agent/Personalization/index.tsx
index 57fd833a..f5fc2c03 100644
--- a/dashboard/src/pages/Agent/Personalization/index.tsx
+++ b/dashboard/src/pages/Agent/Personalization/index.tsx
@@ -153,10 +153,10 @@ export default function PersonalizationPage() {
return (
{isMounted("skills") && (
diff --git a/dashboard/src/pages/Chat/components/ChatInputActionsRow.test.tsx b/dashboard/src/pages/Chat/components/ChatInputActionsRow.test.tsx
index ed684f9c..7be9f85b 100644
--- a/dashboard/src/pages/Chat/components/ChatInputActionsRow.test.tsx
+++ b/dashboard/src/pages/Chat/components/ChatInputActionsRow.test.tsx
@@ -1,9 +1,15 @@
-import { fireEvent, render, waitFor } from "@testing-library/react";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { describe, expect, it, vi } from "vitest";
import type { ResolvedModel } from "../../../api/types";
import ChatInputActionsRow from "./ChatInputActionsRow";
+const navigate = vi.fn();
+vi.mock("react-router-dom", async (importOriginal) => {
+ const actual = await importOriginal();
+ return { ...actual, useNavigate: () => navigate };
+});
+
vi.mock("./ContextWindowRing", () => ({
default: () => null,
}));
@@ -59,4 +65,76 @@ describe("ChatInputActionsRow compact pickers", () => {
});
expect(document.querySelector(".ant-drawer-content")).toBeNull();
});
+
+ it("opens 模型管理 on the sidebar Models route", async () => {
+ navigate.mockClear();
+ const { container } = render(
+
+
+ ,
+ );
+
+ const modelButton = container
+ .querySelector("svg.lucide-cpu")
+ ?.closest("button");
+ fireEvent.click(modelButton!);
+ const manage = await screen.findByText("模型管理");
+ fireEvent.click(manage);
+ expect(navigate).toHaveBeenCalledWith("/models");
+ });
+
+ it("still shows 模型管理 when no models are configured", async () => {
+ const { container } = render(
+
+
+ ,
+ );
+
+ const modelButton = container
+ .querySelector("svg.lucide-cpu")
+ ?.closest("button");
+ expect(modelButton).not.toBeNull();
+ fireEvent.click(modelButton!);
+ expect(await screen.findByText("模型管理")).toBeInTheDocument();
+ });
});
diff --git a/dashboard/src/pages/Chat/components/ChatInputActionsRow.tsx b/dashboard/src/pages/Chat/components/ChatInputActionsRow.tsx
index 1faa12b5..65726b76 100644
--- a/dashboard/src/pages/Chat/components/ChatInputActionsRow.tsx
+++ b/dashboard/src/pages/Chat/components/ChatInputActionsRow.tsx
@@ -223,9 +223,7 @@ export default function ChatInputActionsRow({
return () => observer.disconnect();
}, [isMobile]);
- const showModelPicker = Boolean(
- availableModels && availableModels.length > 0 && onModelChange,
- );
+ const showModelPicker = Boolean(onModelChange);
const effectiveModelRef = selectedModel || defaultModel || "";
const selectedModelInfo = availableModels?.find(
(model) => modelOptionValue(model) === effectiveModelRef,
@@ -533,11 +531,11 @@ export default function ChatInputActionsRow({
onClick={() => {
closeCompactPicker();
setModelPickerOpen(false);
- navigate("/admin/models");
+ navigate("/models");
}}
>
- {t("chat.modelPickerManage")}
+ {t("chat.modelPickerManage", "模型管理")}
)}
diff --git a/dashboard/src/pages/Organization/Organization.module.less b/dashboard/src/pages/Organization/Organization.module.less
index 075bfd8d..bae9a0b0 100644
--- a/dashboard/src/pages/Organization/Organization.module.less
+++ b/dashboard/src/pages/Organization/Organization.module.less
@@ -1,138 +1,121 @@
+/*
+ * Organization page layout
+ * ------------------------
+ * The main view is a slim blue status bar + a full-area iframe. The iframe
+ * box is height-constrained to the remaining window (not stretched to match
+ * leftover chrome). That matters for openXYOS dialogs: `position:fixed`
+ * inside the iframe is relative to the iframe element's box. A tall iframe
+ * (old two-column layout) centered modals far below the visible FreeOS
+ * viewport. Keeping the iframe == the visible pane centers overlays both
+ * vertically and horizontally.
+ */
.page {
display: flex;
flex-direction: column;
- gap: 20px;
+ flex: 1;
+ min-height: 0;
+ height: 100%;
+ gap: 0;
}
-.workspace {
- display: grid;
- grid-template-columns: minmax(320px, 42%) minmax(0, 1fr);
- gap: 16px;
- align-items: stretch;
- min-height: 0;
+.statusBar {
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ gap: 12px;
+ flex-shrink: 0;
+ min-height: 56px;
+ padding: 10px 16px;
+ color: #fff;
+ background: #0033ff;
+ box-shadow: 0 8px 24px rgba(0, 51, 255, 0.22);
}
-.column {
+.statusMeta {
display: flex;
- flex-direction: column;
- gap: 16px;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px;
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;
+.statusTitle {
+ margin: 0 8px 0 0;
+ font-size: 15px;
+ font-weight: 680;
+ letter-spacing: -0.02em;
}
-.previewFullscreen {
- position: fixed;
- inset: 0;
- z-index: 1200;
- min-height: 0;
- border-radius: 0;
- background: var(--fn-bg-primary);
+.chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ border-radius: 999px;
+ padding: 3px 10px;
+ font-size: 12px;
+ background: rgba(255, 255, 255, 0.14);
+ border: 1px solid rgba(255, 255, 255, 0.18);
}
-.previewHeader {
+.statusActions {
display: flex;
+ flex-wrap: wrap;
+ justify-content: flex-end;
align-items: center;
- justify-content: space-between;
- gap: 12px;
- padding: 12px 14px;
- border-bottom: 1px solid var(--fn-border-primary);
+ gap: 8px;
}
-.previewTitle {
- margin: 0;
- font-size: 15px;
- font-weight: 650;
+.statusActions :global(.ant-btn) {
+ color: #0033ff;
+ border-color: transparent;
+ background: #fff;
+}
+
+.statusActions :global(.ant-btn:hover) {
+ color: #0033ff !important;
+ border-color: #fff !important;
+ background: #f4f6ff !important;
}
-.previewBody {
+.embedPane {
position: relative;
flex: 1;
min-height: 0;
background: #fff;
+ overflow: hidden;
+}
+
+.embed {
+ display: block;
+ width: 100%;
+ height: 100%;
+ border: 0;
+ background: #fff;
}
-.previewEmpty {
+.previewOffline {
position: absolute;
inset: 0;
display: flex;
- flex-direction: column;
- align-items: flex-start;
+ align-items: center;
justify-content: center;
- gap: 12px;
padding: 24px;
+ color: var(--fn-text-secondary);
background: var(--fn-bg-secondary);
+ pointer-events: none;
}
-@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%
- ),
- linear-gradient(135deg, #001a80 0%, #0033ff 48%, #4b74fa 100%);
- box-shadow: 0 18px 48px rgba(0, 51, 255, 0.22);
-}
-
-.heroTitle {
- margin: 0 0 8px;
- font-size: 28px;
- font-weight: 720;
- letter-spacing: -0.03em;
+.drawerBody {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
}
.heroStory {
margin: 0;
- max-width: 720px;
- font-size: 16px;
+ font-size: 14px;
line-height: 1.6;
- color: rgba(255, 255, 255, 0.9);
-}
-
-.heroMeta {
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
- margin-top: 16px;
-}
-
-.chip {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- border-radius: 999px;
- padding: 4px 10px;
- font-size: 12px;
- background: rgba(255, 255, 255, 0.14);
- border: 1px solid rgba(255, 255, 255, 0.18);
+ color: var(--fn-text-secondary);
}
.dual {
@@ -150,6 +133,15 @@
.flow {
min-height: 72px;
}
+
+ .statusBar {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .statusActions {
+ justify-content: flex-end;
+ }
}
.plane {
@@ -157,7 +149,6 @@
padding: 20px;
background: var(--fn-bg-primary);
border: 1px solid var(--fn-border-primary);
- box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
}
.planeHead {
@@ -234,7 +225,6 @@
display: grid;
place-items: center;
margin: 8px 0;
- animation: spin 12s linear infinite;
}
.orbitCore {
@@ -245,18 +235,6 @@
box-shadow: 0 0 0 6px rgba(0, 51, 255, 0.12);
}
-@keyframes spin {
- to {
- transform: rotate(360deg);
- }
-}
-
-@media (prefers-reduced-motion: reduce) {
- .orbit {
- animation: none;
- }
-}
-
.actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -313,11 +291,6 @@
font-weight: 680;
}
-.recoverBody {
- margin: 0 0 12px;
- color: var(--fn-text-secondary);
-}
-
.timeline {
border-radius: 16px;
padding: 18px 20px;
@@ -362,15 +335,6 @@
color: var(--fn-text-secondary);
}
-.embed {
- display: block;
- width: 100%;
- height: 100%;
- min-height: 100%;
- border: 0;
- background: #fff;
-}
-
.empty {
border-radius: 16px;
padding: 22px;
@@ -383,3 +347,10 @@
font-size: 18px;
font-weight: 680;
}
+
+.moduleToggle {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ flex-wrap: wrap;
+}
diff --git a/dashboard/src/pages/Organization/index.test.tsx b/dashboard/src/pages/Organization/index.test.tsx
new file mode 100644
index 00000000..39b9eec2
--- /dev/null
+++ b/dashboard/src/pages/Organization/index.test.tsx
@@ -0,0 +1,96 @@
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import OrganizationPage from "./index";
+import type { OrgOverview } from "../../api/modules/orgModule";
+
+const overview: OrgOverview = {
+ enabled: true,
+ sidecar_reachable: true,
+ sidecar_url: "http://127.0.0.1:3780",
+ start_available: false,
+ install_ready: true,
+ start_command: "",
+ home: "/tmp",
+ last_sync: null,
+ freeos: {
+ employees: 1,
+ employee_states: {},
+ agents: 0,
+ spawned_colleagues: 1,
+ org_skills: 0,
+ skill_packages: 0,
+ mcp: 0,
+ tasks: 0,
+ },
+ openxyos: {
+ reachable: true,
+ url: "http://127.0.0.1:3780",
+ detail: "ok",
+ modules: 4,
+ governance: true,
+ tenant_id: "1",
+ approvals: 0,
+ },
+ last_loop: null,
+ notes: [],
+ catalog: [
+ {
+ key: "org",
+ label: "Org",
+ label_zh: "组织",
+ description: "Org tree",
+ description_zh: "组织树",
+ locked: true,
+ },
+ ],
+ module_toggles: { org: true },
+};
+
+vi.mock("../../api/modules/orgModule", () => ({
+ orgModuleApi: {
+ overview: vi.fn(async () => overview),
+ setEnabled: vi.fn(),
+ startSidecar: vi.fn(),
+ assemble: vi.fn(),
+ produce: vi.fn(),
+ pack: vi.fn(),
+ runLoop: vi.fn(),
+ setModules: vi.fn(),
+ downloadSource: vi.fn(),
+ },
+}));
+
+vi.mock("../../hooks/useServerTimezone", () => ({
+ useServerTimezone: () => "UTC",
+}));
+
+describe("OrganizationPage", () => {
+ it("defaults to the local embed and keeps extras behind the two status buttons", async () => {
+ render();
+
+ await waitFor(() => {
+ expect(
+ screen.getByTitle("organization.previewTitle"),
+ ).toBeInTheDocument();
+ });
+ const frame = screen.getByTitle("organization.previewTitle");
+ expect(frame).toHaveAttribute("src", "http://127.0.0.1:3780/");
+
+ expect(screen.queryByText("organization.assembleAction")).toBeNull();
+ expect(screen.queryByText("organization.startSidecarAction")).toBeNull();
+
+ fireEvent.click(screen.getByTestId("org-enable-module"));
+ expect(
+ await screen.findByText("organization.catalogTitle"),
+ ).toBeInTheDocument();
+
+ fireEvent.click(screen.getByTestId("org-manage-os"));
+ expect(
+ await screen.findByText("organization.assembleTitle"),
+ ).toBeInTheDocument();
+ expect(screen.getByText("organization.loopTitle")).toBeInTheDocument();
+ expect(
+ screen.getAllByText("organization.downloadSource").length,
+ ).toBeGreaterThan(0);
+ });
+});
diff --git a/dashboard/src/pages/Organization/index.tsx b/dashboard/src/pages/Organization/index.tsx
index 83714f90..c5145a9f 100644
--- a/dashboard/src/pages/Organization/index.tsx
+++ b/dashboard/src/pages/Organization/index.tsx
@@ -1,14 +1,12 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
-import { Button, Input, Space, Switch, Tag } from "antd";
+import { Button, Drawer, Input, Space, Switch, Tag } from "antd";
import {
ArrowDownUp,
Building2,
ExternalLink,
- Maximize2,
- Minimize2,
Package,
Play,
- RefreshCw,
+ Settings2,
Shield,
Users,
Workflow,
@@ -34,6 +32,7 @@ import { resolveOpenxyosSourceDest } from "./pickSourceDest";
import styles from "./Organization.module.less";
type ActionKey = "assemble" | "pack" | "loop" | "sidecar" | "produce" | null;
+type DrawerKey = "module" | "manage" | null;
type LastReceipt = {
kind: "assemble" | "pack" | "loop";
@@ -55,7 +54,7 @@ export default function OrganizationPage() {
const [loopProof, setLoopProof] = useState(null);
const [lastActionNotes, setLastActionNotes] = useState([]);
const [lastReceipt, setLastReceipt] = useState(null);
- const [previewFullscreen, setPreviewFullscreen] = useState(false);
+ const [drawer, setDrawer] = useState(null);
const [moduleToggles, setModuleToggles] = useState>(
{},
);
@@ -110,15 +109,6 @@ export default function OrganizationPage() {
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;
@@ -403,32 +393,13 @@ export default function OrganizationPage() {
}, [showFrame, pushTogglesToPreview]);
return (
-
- {(sidecarUp || overview?.enabled) && (
- }
- onClick={() => void downloadSource()}
- loading={downloading || pickingFolder}
- >
- {t("organization.downloadSource")}
-
- )}
- } onClick={() => void load()}>
- {t("common.refresh")}
-
-
- }
- >
+
-
- {t("organization.heroTitle")}
- {t("organization.heroStory")}
- {t("organization.glossary")}
-
+
+
+
+ {t("organization.statusBarTitle")}
+
{overview?.enabled ? t("organization.on") : t("organization.off")}
@@ -441,428 +412,429 @@ export default function OrganizationPage() {
{t("organization.lastSync")}: {lastSync}
+
+
+ }
+ onClick={() => setDrawer("manage")}
+ data-testid="org-manage-os"
+ >
+ {t("organization.manageOs")}
+
+ {showFrame && (
+ }
+ style={{ color: "#fff" }}
+ >
+ {t("organization.openSidecar")}
+
+ )}
+
-
-
-
-
-
{t("organization.toggleLabel")}
-
void toggle(checked)}
- />
+
+ {!sidecarUp && (
+
+ {t("organization.previewOffline")}
+ )}
+
+
+
- {firstRun && (
-
-
- {t("organization.emptyTitle")}
-
- {t("organization.emptyBody")}
-
- )}
+
setDrawer(null)}
+ width={480}
+ destroyOnHidden
+ >
+
+
+
+ {t("organization.toggleLabel")}
+ void toggle(checked)}
+ />
+
+
{t("organization.glossary")}
+
+
+ {t("organization.catalogTitle")}
+
+
+ {t("organization.catalogToggleHint")}
+
+
+ {catalog.map((row) => (
+
+
+ {isZh ? row.label_zh : row.label}{" "}
+
+ {row.locked
+ ? t("organization.locked")
+ : moduleToggles[row.key] === false
+ ? t("organization.catalogDisabled")
+ : t("organization.catalogEnabled")}
+
+
+
+ {isZh ? row.description_zh : row.description}
+
+ {!row.locked && (
+
+ void toggleModule(row.key, checked)
+ }
+ />
+ )}
+
+ ))}
+
+
+
+
+
+
setDrawer(null)}
+ width={720}
+ destroyOnHidden
+ >
+
+
{t("organization.heroStory")}
+
{t("organization.glossary")}
+
+ {firstRun && (
+
+
+ {t("organization.emptyTitle")}
+
+ {t("organization.emptyBody")}
+
+ )}
-
-
-
-
-
- {t("organization.dataPlane")}
-
-
FreeOS
-
-
+
+
+
+
+
+ {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")}
-
-
+
+
+
+
+
+ {metric(overview?.freeos.employees)}
+
+
+ {t("organization.metricEmployees")}
+
-
-
-
-
-
- {t("organization.flowUp")}
+
+
+ {metric(overview?.freeos.org_skills)}
+
+
+ {t("organization.metricSkills")}
+
-
-
+
+
+ {metric(overview?.freeos.mcp)}
+
+
+ {t("organization.metricMcp")}
+
-
- {t("organization.flowDown")}
-
+
+
+ {metric(overview?.freeos.tasks)}
+
+
+ {t("organization.metricTasks")}
+
+
+
+
+
+
+ {t("organization.flowUp")}
+
+
+
+ {t("organization.flowDown")}
+
+
+
-
-
-
-
- {t("organization.controlPlane")}
-
-
openXYOS
-
-
+
+
+
+
+ {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.sidecarOpening")}
-
-
- {t("organization.metricHealth")}
-
-
-
-
- {overview?.openxyos.tenant_id || "—"}
-
-
- {t("organization.metricTenant")}
-
-
+
+
+
+
+
+ {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.sidecarOpening")}
+
+
+ {t("organization.metricHealth")}
+
+
+
+
+ {overview?.openxyos.tenant_id || "—"}
+
+
+ {t("organization.metricTenant")}
+
+
+
+
+
- {progressLabel && (
-
- )}
+ {progressLabel && (
+
+ )}
-
-
+ {lastReceipt ? (
- {t("organization.timelineTitle")}
+ {t("organization.receiptTitle")}
- {lastLoop ? (
-
- -
- {t("organization.timelineOk")}: {flagLabel(lastLoop.ok)}
-
- {lastLoop.employees?.length ? (
- -
- {t("organization.timelineEmployees")}:{" "}
- {lastLoop.employees.join(", ")}
-
- ) : null}
- {lastLoop.skills?.length ? (
- -
- {t("organization.timelineSkills")}:{" "}
- {lastLoop.skills.length}
-
- ) : null}
+
+ {lastReceipt.lines.map((line) => (
+ - {line}
+ ))}
+
+
+ ) : null}
+
+
+
+ {t("organization.timelineTitle")}
+
+ {lastLoop ? (
+
+ -
+ {t("organization.timelineOk")}: {flagLabel(lastLoop.ok)}
+
+ {lastLoop.employees?.length ? (
-
- {t("organization.timelineRemote")}:{" "}
- {flagLabel(Boolean(lastLoop.remote_applied))}
+ {t("organization.timelineEmployees")}:{" "}
+ {lastLoop.employees.join(", ")}
+ ) : null}
+ {lastLoop.skills?.length ? (
-
- {t("organization.timelineGovernance")}:{" "}
- {flagLabel(Boolean(lastLoop.governance_blocked))}
+ {t("organization.timelineSkills")}: {lastLoop.skills.length}
- {(lastLoop.notes ?? []).slice(0, 6).map((note) => (
- - {note}
- ))}
-
- ) : (
- {t("organization.timelineEmpty")}
- )}
- {lastActionNotes.length > 0 && (
-
- {lastActionNotes.slice(0, 8).map((note) => (
- - {note}
- ))}
-
- )}
-
-
-
-
- {t("organization.landedTitle")}
-
- {landed ? (
-
- {JSON.stringify(landed, null, 2)}
-
- ) : (
- {t("organization.landedEmpty")}
- )}
-
+ ) : null}
+
+ {t("organization.timelineRemote")}:{" "}
+ {flagLabel(Boolean(lastLoop.remote_applied))}
+
+
+ {t("organization.timelineGovernance")}:{" "}
+ {flagLabel(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.landedTitle")}
+
+ {landed ? (
+
+ {JSON.stringify(landed, null, 2)}
+
+ ) : (
+ {t("organization.landedEmpty")}
+ )}
+
+ {(sidecarUp || overview?.enabled) && (
- {t("organization.catalogTitle")}
+ {t("organization.downloadSource")}
-
- {t("organization.catalogToggleHint")}
+
+ {t("organization.downloadSourceHint")}
-
- {catalog.map((row) => (
-
-
- {isZh ? row.label_zh : row.label}{" "}
-
- {row.locked
- ? t("organization.locked")
- : moduleToggles[row.key] === false
- ? t("organization.catalogDisabled")
- : t("organization.catalogEnabled")}
-
-
-
- {isZh ? row.description_zh : row.description}
-
- {!row.locked && (
-
- void toggleModule(row.key, checked)
- }
- />
- )}
-
- ))}
-
-
-
- {(sidecarUp || overview?.enabled) && (
-
- )}
-
-
-
+
+ )}
-
+
);
}
diff --git a/desktop/src/build/windows/nsis/start-sidecar.ps1 b/desktop/src/build/windows/nsis/start-sidecar.ps1
index 425492c4..5d90b439 100644
--- a/desktop/src/build/windows/nsis/start-sidecar.ps1
+++ b/desktop/src/build/windows/nsis/start-sidecar.ps1
@@ -82,7 +82,10 @@ $env:ALLOW_PUBLIC_REGISTRATION = 'false'
$env:JWT_SECRET = $jwt
$env:COOKIE_SECRET = $cookie
$env:FREEOS_INGEST_TOKEN = $ingest
-$env:CORS_ORIGIN = 'http://127.0.0.1:8088,http://localhost:8088,http://127.0.0.1:18900,http://localhost:18900'
+# Include the sidecar's own origin. Login POSTs from the embedded page at
+# http://127.0.0.1:3780 send that Origin; omitting it made cors() throw and
+# the UI showed the generic 服务器内部错误 message.
+$env:CORS_ORIGIN = 'http://127.0.0.1:8088,http://localhost:8088,http://127.0.0.1:18900,http://localhost:18900,http://127.0.0.1:3780,http://localhost:3780,http://[::1]:3780'
$env:FREEOS_HOME = $freeosHome
$env:OCTOP_HOME = $freeosHome
$env:FREEOS_ORG_SIDECAR_PORT = '3780'
diff --git a/modules/openxyos/backend/routes/auth.ts b/modules/openxyos/backend/routes/auth.ts
index 21e56fcc..024df889 100644
--- a/modules/openxyos/backend/routes/auth.ts
+++ b/modules/openxyos/backend/routes/auth.ts
@@ -8,6 +8,7 @@ import { LTSProvider, getAuthProvider } from "../services/auth-provider";
import { dbGet, dbRun } from "../db";
import bcrypt from "bcryptjs";
import { localizedError } from "../utils/locale";
+import { logError } from "../utils/error-handler";
function authError(req: Parameters
[0], message: string): string {
const known: Record = {
@@ -77,8 +78,17 @@ authRoutes.post("/login", async (req, res) => {
tokens: result.tokens,
},
});
- } catch (err: any) {
- res.status(500).json({ success: false, error: err.message || String(err) });
+ } catch (err: unknown) {
+ const tid = logError("POST /login", err);
+ res.status(500).json({
+ success: false,
+ error: localizedError(
+ req,
+ "登录服务暂时不可用。请确认本机 openXYOS 已启动,或稍后重试。",
+ "Sign-in is temporarily unavailable. Confirm local openXYOS is running, or try again shortly.",
+ ),
+ traceId: tid,
+ });
}
});
diff --git a/modules/openxyos/backend/seed.ts b/modules/openxyos/backend/seed.ts
index b6e84401..55918b61 100644
--- a/modules/openxyos/backend/seed.ts
+++ b/modules/openxyos/backend/seed.ts
@@ -13,6 +13,55 @@ function seedAdminEmail(): string {
return process.env.SEED_ADMIN_EMAIL?.trim() || "admin@example.com";
}
+const LOCAL_BOOTSTRAP_PASSWORD = "openxyos-demo-2026";
+
+function localBootstrapPassword(name: "SEED_ADMIN_PASSWORD" | "SEED_DEMO_PASSWORD"): string {
+ const value = process.env[name]?.trim();
+ if (value && value.length >= 12) return value;
+ return LOCAL_BOOTSTRAP_PASSWORD;
+}
+
+/** First-run local accounts when demo seed is off (FreeOS desktop sidecar). */
+export function ensureLocalBootstrap(): void {
+ dbRun(
+ "INSERT OR IGNORE INTO tenants (id, name, slug, status, plan) VALUES (1, ?, ?, 'active', 'community')",
+ ["openXYOS", "openxyos"],
+ );
+ dbRun(
+ "INSERT OR IGNORE INTO tenants (id, name, slug, status, plan) VALUES (2, ?, ?, 'active', 'community')",
+ ["openXYOS 本地租户", "openxyos-local"],
+ );
+ dbRun(
+ "UPDATE tenants SET status = 'active' WHERE id IN (1, 2) AND status NOT IN ('active', 'trial')",
+ );
+
+ const demoHash = bcrypt.hashSync(localBootstrapPassword("SEED_DEMO_PASSWORD"), 10);
+ const adminHash = bcrypt.hashSync(localBootstrapPassword("SEED_ADMIN_PASSWORD"), 10);
+ const adminEmail = seedAdminEmail();
+
+ if (!dbGet("SELECT id FROM users WHERE email = ?", ["demo@demo.com"])) {
+ dbRun(
+ "INSERT INTO users (email, password_hash, nickname, role, tenant_id) VALUES (?, ?, ?, 'admin', 2)",
+ ["demo@demo.com", demoHash, "管理员"],
+ );
+ console.log("[seed] local bootstrap admin demo@demo.com");
+ }
+ if (!dbGet("SELECT id FROM users WHERE email = ?", ["user@demo.com"])) {
+ dbRun(
+ "INSERT INTO users (email, password_hash, nickname, role, tenant_id) VALUES (?, ?, ?, 'user', 2)",
+ ["user@demo.com", demoHash, "同事"],
+ );
+ console.log("[seed] local bootstrap user user@demo.com");
+ }
+ if (adminEmail !== "demo@demo.com" && !dbGet("SELECT id FROM users WHERE email = ?", [adminEmail])) {
+ dbRun(
+ "INSERT INTO users (email, password_hash, nickname, role, tenant_id) VALUES (?, ?, ?, 'super_admin', 1)",
+ [adminEmail, adminHash, "超级管理员"],
+ );
+ console.log(`[seed] local bootstrap super admin ${adminEmail}`);
+ }
+}
+
// 辅助:日期加月份 → ISO日期字符串
function addMonths(dateStr: string | null | undefined, months: number): string | null {
if (!dateStr) return null;
@@ -23,7 +72,8 @@ function addMonths(dateStr: string | null | undefined, months: number): string |
export function seedDatabase() {
if (process.env.SEED_DEMO_DATA !== "true") {
- console.log("[seed] Demo data is disabled. Set SEED_DEMO_DATA=true to opt in.");
+ console.log("[seed] Demo data is disabled. Seeding local first-login accounts only.");
+ ensureLocalBootstrap();
return;
}
diff --git a/modules/openxyos/backend/server.ts b/modules/openxyos/backend/server.ts
index 1e6d976c..7f42ba23 100644
--- a/modules/openxyos/backend/server.ts
+++ b/modules/openxyos/backend/server.ts
@@ -52,6 +52,11 @@ import { seedDatabase } from "./seed";
import { authenticate } from "./middleware";
import { setupWebSocket } from "./services/websocket";
import { globalErrorHandler } from "./utils/error-handler";
+import {
+ isAllowedCorsOrigin,
+ mergeCorsOrigins,
+ parseOriginList,
+} from "./utils/cors-origins";
async function main() {
const app = express();
@@ -83,16 +88,17 @@ async function main() {
});
}
- // CORS — 白名单 + WebView 兼容
- const allowedOrigins = process.env.CORS_ORIGIN
- ? process.env.CORS_ORIGIN.split(",").map(s => s.trim())
- : [
- "http://localhost:5173", "http://localhost:5174", "http://localhost:3000",
- "http://127.0.0.1:5173", "http://127.0.0.1:5174", "http://127.0.0.1:3000",
- "https://os.cnxy.tech", "https://www.os.cnxy.tech",
- ];
- // 开发模式:允许鸿蒙 WebView local 访问
- const isDev = !process.env.NODE_ENV || process.env.NODE_ENV !== "production";
+ // CORS — 白名单 + 本机 sidecar 自源(iframe 登录)+ WebView 兼容
+ const defaultOrigins = [
+ "http://localhost:5173", "http://localhost:5174", "http://localhost:3000",
+ "http://127.0.0.1:5173", "http://127.0.0.1:5174", "http://127.0.0.1:3000",
+ "https://os.cnxy.tech", "https://www.os.cnxy.tech",
+ ];
+ const configuredOrigins = parseOriginList(process.env.CORS_ORIGIN);
+ const allowedOrigins = mergeCorsOrigins(
+ configuredOrigins.length ? configuredOrigins : defaultOrigins,
+ PORT,
+ );
app.use((req, res, next) => {
const origin = req.headers.origin;
// WebView 从 resource:// rawfile 加载时 origin 为空/null,本地开发放行
@@ -106,10 +112,10 @@ async function main() {
}
cors({
origin: (o, cb) => {
- if (!o || allowedOrigins.includes(o)) {
+ if (isAllowedCorsOrigin(o, allowedOrigins, req.headers.host)) {
cb(null, true);
} else {
- cb(new Error("CORS blocked"));
+ cb(null, false);
}
},
credentials: true,
diff --git a/modules/openxyos/backend/services/auth-provider.ts b/modules/openxyos/backend/services/auth-provider.ts
index a31cf773..c9055dc7 100644
--- a/modules/openxyos/backend/services/auth-provider.ts
+++ b/modules/openxyos/backend/services/auth-provider.ts
@@ -104,8 +104,11 @@ export class LTSProvider implements AuthProvider {
return { success: false, error: "租户已停用", code: "TENANT_SUSPENDED" };
}
- // 更新最后登录时间
- dbRun("UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?", [row.id]);
+ try {
+ dbRun("UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?", [row.id]);
+ } catch (err) {
+ console.warn("[auth] last_login update skipped:", err instanceof Error ? err.message : err);
+ }
const user: TokenClaims = {
id: row.id,
diff --git a/modules/openxyos/backend/utils/cors-origins.ts b/modules/openxyos/backend/utils/cors-origins.ts
new file mode 100644
index 00000000..ab96d0ee
--- /dev/null
+++ b/modules/openxyos/backend/utils/cors-origins.ts
@@ -0,0 +1,61 @@
+/**
+ * CORS helpers for the local sidecar.
+ *
+ * Login POSTs from the page at http://127.0.0.1:3780 send Origin: that same
+ * URL. FreeOS used to allow only the dashboard ports (8088 / 18900), so the
+ * cors() callback threw and the global handler returned the opaque
+ * 「服务器内部错误,请稍后重试或联系管理员」. Always merge the listen origin
+ * and treat same-host Origin as allowed. Unknown origins are rejected
+ * without throwing (no 500).
+ */
+
+export function sidecarSelfOrigins(port: string | number): string[] {
+ const p = String(port).trim() || "3780";
+ return [
+ `http://127.0.0.1:${p}`,
+ `http://localhost:${p}`,
+ `http://[::1]:${p}`,
+ ];
+}
+
+export function mergeCorsOrigins(
+ configured: readonly string[],
+ port: string | number,
+): string[] {
+ const seen = new Set();
+ const out: string[] = [];
+ for (const origin of [...configured, ...sidecarSelfOrigins(port)]) {
+ const clean = origin.trim();
+ if (!clean || seen.has(clean)) continue;
+ seen.add(clean);
+ out.push(clean);
+ }
+ return out;
+}
+
+export function parseOriginList(value: string | undefined): string[] {
+ if (!value?.trim()) return [];
+ return value
+ .split(",")
+ .map((item) => item.trim())
+ .filter(Boolean);
+}
+
+export function isAllowedCorsOrigin(
+ origin: string | undefined,
+ allowed: readonly string[],
+ reqHost?: string,
+): boolean {
+ if (!origin) return true;
+ if (allowed.includes(origin)) return true;
+ if (reqHost) {
+ try {
+ const parsed = new URL(origin);
+ const host = reqHost.split(",")[0]?.trim();
+ if (host && parsed.host === host) return true;
+ } catch {
+ return false;
+ }
+ }
+ return false;
+}
diff --git a/modules/openxyos/frontend/src/index.css b/modules/openxyos/frontend/src/index.css
index 5d8d9b06..3429a56f 100644
--- a/modules/openxyos/frontend/src/index.css
+++ b/modules/openxyos/frontend/src/index.css
@@ -97,6 +97,48 @@ html, body, #root {
-moz-osx-font-smoothing: grayscale;
}
+/*
+ * FreeOS Organization embeds this app in an iframe whose box is the visible
+ * preview pane. `position:fixed` is relative to that box. Center overlays
+ * with grid + place-items so dialogs stay in the visible iframe (vertical
+ * and horizontal), and cap height so tall forms scroll inside the dialog.
+ */
+html.ox-embedded,
+html.ox-embedded body,
+html.ox-embedded #root {
+ height: 100%;
+ max-height: 100%;
+}
+
+.ox-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 120;
+ display: grid;
+ place-items: center;
+ align-content: center;
+ justify-items: center;
+ overflow: auto;
+ max-height: 100%;
+ padding: 16px;
+ box-sizing: border-box;
+}
+
+.ox-overlay > .ox-dialog,
+.ox-dialog {
+ margin: auto;
+ max-height: min(90dvh, calc(100% - 24px));
+ overflow: auto;
+}
+
+html.ox-embedded .fixed.inset-0 {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ max-height: 100%;
+ overflow: auto;
+}
+
/* ===== 跨浏览器兼容修复 ===== */
/* Safari/旧WebKit backdrop-filter */
diff --git a/modules/openxyos/frontend/src/main.tsx b/modules/openxyos/frontend/src/main.tsx
index ca9adc5e..85c22000 100644
--- a/modules/openxyos/frontend/src/main.tsx
+++ b/modules/openxyos/frontend/src/main.tsx
@@ -6,6 +6,10 @@ import App from "./OpenApp";
import { LocaleProvider } from "./i18n";
import "./index.css";
+if (typeof window !== "undefined" && window.self !== window.top) {
+ document.documentElement.classList.add("ox-embedded");
+}
+
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: 1, staleTime: 10000 } },
});
diff --git a/modules/openxyos/frontend/src/openxyos.css b/modules/openxyos/frontend/src/openxyos.css
index 13922dc9..b999b23b 100644
--- a/modules/openxyos/frontend/src/openxyos.css
+++ b/modules/openxyos/frontend/src/openxyos.css
@@ -5,7 +5,7 @@
.ox-architecture{padding:108px max(28px,calc((100vw - 1124px)/2));background:linear-gradient(180deg,rgba(22,57,47,.18),rgba(5,9,9,0));border-block:1px solid var(--line)}.ox-layers{display:flex;align-items:stretch;gap:12px}.ox-layers>div{flex:1;min-height:210px;padding:23px;border:1px solid var(--line);border-radius:8px;display:flex;flex-direction:column;gap:10px;background:rgba(10,18,17,.76)}.ox-layers>div>small{color:#668078;font:600 10px ui-monospace,monospace;letter-spacing:.12em;margin-bottom:8px}.ox-layers b{padding:12px 14px;border:1px solid rgba(143,187,173,.13);border-radius:5px;background:rgba(255,255,255,.018);font-size:13px;font-weight:540}.ox-layers .core{border-color:rgba(49,230,161,.3);box-shadow:inset 0 0 50px rgba(49,230,161,.035)}.ox-layers .core small{color:var(--green)}.ox-layers>span{width:35px;display:grid;place-items:center;color:var(--green);writing-mode:vertical-lr;letter-spacing:8px;text-shadow:0 0 12px var(--green)}
.ox-module-table{border:1px solid var(--line);border-radius:8px;overflow:hidden;background:rgba(10,18,17,.6)}.ox-module-table>header,.ox-module-table .row{display:grid;grid-template-columns:1.25fr 1fr .55fr .45fr;align-items:center;gap:20px;min-height:58px;padding:0 22px;border-bottom:1px solid var(--line)}.ox-module-table>header{min-height:43px;color:#526b62;background:rgba(255,255,255,.018);font:600 10px ui-monospace,monospace;letter-spacing:.1em}.ox-module-table .row:last-child{border-bottom:0}.ox-module-table code{color:#bdd3cb;font-size:13px}.ox-module-table .row>span{color:#93a9a1;font-size:14px}.ox-module-table em{justify-self:start;padding:4px 8px;border-radius:4px;font-size:10px;font-style:normal}.ox-module-table em.core{color:var(--green);background:rgba(49,230,161,.08);border:1px solid rgba(49,230,161,.16)}.ox-module-table em.sample{color:var(--cyan);background:rgba(85,199,255,.08);border:1px solid rgba(85,199,255,.16)}.ox-module-table em.base{color:#c1c8c5;border:1px solid var(--line)}.ox-module-table .row>small{color:#6e877e;font:500 11px ui-monospace,monospace;display:flex;align-items:center;gap:7px}.ox-module-table .row>small i{width:6px;height:6px;border-radius:50%;background:var(--green);box-shadow:0 0 10px var(--green)}
.ox-source{display:grid;grid-template-columns:1fr 1fr;gap:80px;align-items:center}.ox-tree{padding:29px;border:1px solid var(--line);border-radius:9px;background:#08100f;box-shadow:0 25px 80px rgba(0,0,0,.35);font:500 13px/2.15 ui-monospace,monospace;color:#647c73}.ox-tree p{margin:0}.ox-tree p:first-child{color:var(--green);margin-bottom:8px}.ox-tree b{color:#b8d0c7;font-weight:550}.ox-tree em{float:right;color:#526960;font-style:normal;font-size:11px}.ox-contribute{position:relative;overflow:hidden;max-width:1124px;margin:40px auto 100px;padding:78px 30px;text-align:center;border:1px solid rgba(49,230,161,.24);border-radius:12px;background:radial-gradient(circle at 50% 0,rgba(49,230,161,.12),transparent 45%),#09110f}.ox-contribute>div:first-child{width:67px;height:67px;margin:0 auto 24px;display:grid;place-items:center;border:1px solid rgba(49,230,161,.35);border-radius:50%;color:var(--green);background:#0c1a16;box-shadow:0 0 50px rgba(49,230,161,.13)}.ox-contribute>p{max-width:670px;margin:0 auto}.ox-contribute .ox-actions{justify-content:center}.ox-footer{min-height:94px;padding:22px max(28px,calc((100vw - 1180px)/2));border-top:1px solid var(--line);display:flex;align-items:center;gap:28px;color:#536b62;font-size:12px}.ox-footer>p{margin-right:auto}.ox-footer .ox-brand b{font-size:15px}.ox-footer .ox-brand>i{width:27px;height:27px}
-.ox-overlay{position:fixed;inset:0;z-index:120;display:grid;place-items:center;padding:20px;background:rgba(1,4,3,.82);-webkit-backdrop-filter:blur(14px);backdrop-filter:blur(14px)}.ox-dialog{position:relative;width:min(470px,100%);padding:33px;border:1px solid rgba(126,188,168,.24);border-radius:11px;background:#0b1412;box-shadow:0 30px 120px #000}.ox-dialog .close{position:absolute;right:14px;top:14px;width:32px;height:32px;border:0;border-radius:5px;display:grid;place-items:center;color:#758e85;background:transparent;cursor:pointer}.ox-dialog .icon{width:45px;height:45px;display:grid;place-items:center;color:var(--green);border:1px solid rgba(49,230,161,.25);border-radius:7px;background:rgba(49,230,161,.07)}.ox-dialog h2{font-size:25px;margin:20px 0 8px}.ox-dialog>p{color:var(--muted);font-size:14px;line-height:1.7;margin:0 0 20px}.ox-dialog .account{width:100%;min-height:70px;margin-top:10px;padding:0 17px;display:flex;align-items:center;justify-content:space-between;text-align:left;border:1px solid var(--line);border-radius:7px;color:var(--text);background:rgba(255,255,255,.02);cursor:pointer}.ox-dialog .account:hover{border-color:rgba(49,230,161,.4);background:rgba(49,230,161,.04)}.ox-dialog .account:disabled{opacity:.55;cursor:wait}.ox-dialog .account span{display:flex;flex-direction:column;gap:4px}.ox-dialog .account b{font-size:14px}.ox-dialog .account small{color:#6e877e;font-size:11px}.ox-dialog .manual{display:block;margin:20px auto 0;border:0;background:transparent;color:#7d978e;font-size:12px;text-decoration:underline;cursor:pointer}.ox-dialog .feedback{color:var(--green);margin:14px 0 0}.ox-dialog .error{color:#ff8a8a;margin:14px 0 0}
+.ox-overlay{position:fixed;inset:0;z-index:120;display:grid;place-items:center;align-content:center;justify-items:center;overflow:auto;max-height:100%;max-height:100dvh;padding:20px;box-sizing:border-box;background:rgba(1,4,3,.82);-webkit-backdrop-filter:blur(14px);backdrop-filter:blur(14px)}.ox-dialog{position:relative;margin:auto;width:min(470px,100%);max-height:min(90dvh,calc(100% - 24px));overflow:auto;padding:33px;border:1px solid rgba(126,188,168,.24);border-radius:11px;background:#0b1412;box-shadow:0 30px 120px #000}.ox-dialog .close{position:absolute;right:14px;top:14px;width:32px;height:32px;border:0;border-radius:5px;display:grid;place-items:center;color:#758e85;background:transparent;cursor:pointer}.ox-dialog .icon{width:45px;height:45px;display:grid;place-items:center;color:var(--green);border:1px solid rgba(49,230,161,.25);border-radius:7px;background:rgba(49,230,161,.07)}.ox-dialog h2{font-size:25px;margin:20px 0 8px}.ox-dialog>p{color:var(--muted);font-size:14px;line-height:1.7;margin:0 0 20px}.ox-dialog .account{width:100%;min-height:70px;margin-top:10px;padding:0 17px;display:flex;align-items:center;justify-content:space-between;text-align:left;border:1px solid var(--line);border-radius:7px;color:var(--text);background:rgba(255,255,255,.02);cursor:pointer}.ox-dialog .account:hover{border-color:rgba(49,230,161,.4);background:rgba(49,230,161,.04)}.ox-dialog .account:disabled{opacity:.55;cursor:wait}.ox-dialog .account span{display:flex;flex-direction:column;gap:4px}.ox-dialog .account b{font-size:14px}.ox-dialog .account small{color:#6e877e;font-size:11px}.ox-dialog .manual{display:block;margin:20px auto 0;border:0;background:transparent;color:#7d978e;font-size:12px;text-decoration:underline;cursor:pointer}.ox-dialog .feedback{color:var(--green);margin:14px 0 0}.ox-dialog .error{color:#ff8a8a;margin:14px 0 0}
@keyframes orbit{to{stroke-dashoffset:-190}}@keyframes signal{from{left:-10px}to{left:42px}}@keyframes float{50%{transform:translateY(6px)}}
@media(max-width:900px){.ox-links{display:none}.ox-nav{justify-content:space-between}.ox-hero{grid-template-columns:1fr;gap:50px;padding-top:125px}.ox-console{justify-self:start;max-width:620px;transform:none}.ox-cards{grid-template-columns:repeat(2,1fr)}.ox-source{grid-template-columns:1fr;gap:40px}.ox-footer{flex-wrap:wrap}.ox-footer>p{order:3;width:100%;margin:0}.ox-layers{flex-direction:column}.ox-layers>span{width:100%;height:28px;writing-mode:initial}}@media(max-width:620px){.ox-nav{height:61px;padding:0 16px}.ox-brand em,.ox-nav-actions a{display:none}.ox-hero{min-height:auto;padding:108px 19px 70px}.ox-hero h1{font-size:43px}.ox-hero-copy>p{font-size:16px}.ox-actions>*{width:100%}.ox-meta{gap:10px 16px}.ox-terminal{padding:22px 19px}.ox-section,.ox-architecture{padding:76px 19px}.ox-cards{grid-template-columns:1fr}.ox-cards article{min-height:210px}.ox-module-table>header{display:none}.ox-module-table .row{grid-template-columns:1fr auto;gap:7px;padding:14px 15px}.ox-module-table .row>span{grid-column:1}.ox-module-table em{grid-column:2;grid-row:1}.ox-module-table .row>small{grid-column:2;grid-row:2}.ox-tree{padding:19px;font-size:11px;overflow:auto}.ox-tree em{display:none}.ox-contribute{margin:20px 19px 70px;padding:57px 20px}.ox-footer{padding:24px 19px}.ox-dialog{padding:27px 20px}}@media(prefers-reduced-motion:reduce){.ox-page *{scroll-behavior:auto!important;animation-duration:.01ms!important;animation-iteration-count:1!important}.ox-console{transform:none}}
diff --git a/modules/openxyos/package.json b/modules/openxyos/package.json
index b99570bb..76b6a5e7 100644
--- a/modules/openxyos/package.json
+++ b/modules/openxyos/package.json
@@ -10,8 +10,9 @@
"postinstall": "node scripts/patch-express-types.cjs",
"typecheck": "tsc --noEmit",
"lint": "eslint . --ext .ts,.tsx --max-warnings 0",
- "test": "npm run test:runtime-config && npm run test:r0-access-control && npm run test:module-settings && npm run test:llm-onboarding && npm run test:agent-studio && npm run test:file-security && npm run test:download-token && npm run test:ws-ticket && npm run test:auth-provider && npm run test:pep && npm run test:audit-bundle && npm run test:tool-gateway && npm run test:v100",
+ "test": "npm run test:runtime-config && npm run test:cors-origins && npm run test:r0-access-control && npm run test:module-settings && npm run test:llm-onboarding && npm run test:agent-studio && npm run test:file-security && npm run test:download-token && npm run test:ws-ticket && npm run test:auth-provider && npm run test:pep && npm run test:audit-bundle && npm run test:tool-gateway && npm run test:v100",
"test:runtime-config": "tsx scripts/test-runtime-config.ts",
+ "test:cors-origins": "tsx scripts/test-cors-origins.ts",
"test:r0-access-control": "tsx scripts/test-r0-access-control.ts",
"test:file-security": "tsx scripts/test-file-security.ts",
"test:download-token": "tsx scripts/test-download-token.ts",
diff --git a/modules/openxyos/scripts/test-cors-origins.ts b/modules/openxyos/scripts/test-cors-origins.ts
new file mode 100644
index 00000000..c3dc31c3
--- /dev/null
+++ b/modules/openxyos/scripts/test-cors-origins.ts
@@ -0,0 +1,36 @@
+import assert from "node:assert/strict";
+import {
+ isAllowedCorsOrigin,
+ mergeCorsOrigins,
+ parseOriginList,
+ sidecarSelfOrigins,
+} from "../backend/utils/cors-origins";
+
+assert.deepEqual(sidecarSelfOrigins(3780), [
+ "http://127.0.0.1:3780",
+ "http://localhost:3780",
+ "http://[::1]:3780",
+]);
+
+const merged = mergeCorsOrigins(
+ ["http://127.0.0.1:8088", "http://localhost:8088"],
+ 3780,
+);
+assert.ok(merged.includes("http://127.0.0.1:8088"));
+assert.ok(merged.includes("http://127.0.0.1:3780"));
+assert.ok(merged.includes("http://localhost:3780"));
+
+assert.deepEqual(parseOriginList("http://a.com, http://b.com"), [
+ "http://a.com",
+ "http://b.com",
+]);
+
+assert.equal(isAllowedCorsOrigin(undefined, merged), true);
+assert.equal(isAllowedCorsOrigin("http://127.0.0.1:3780", merged), true);
+assert.equal(
+ isAllowedCorsOrigin("http://127.0.0.1:3780", ["http://127.0.0.1:8088"], "127.0.0.1:3780"),
+ true,
+);
+assert.equal(isAllowedCorsOrigin("https://evil.example", merged, "127.0.0.1:3780"), false);
+
+console.log("cors origin tests passed");
diff --git a/src/octop/modules/org_os/sidecar_launch.py b/src/octop/modules/org_os/sidecar_launch.py
index 3cf3020a..4cf08586 100644
--- a/src/octop/modules/org_os/sidecar_launch.py
+++ b/src/octop/modules/org_os/sidecar_launch.py
@@ -277,9 +277,16 @@ def sidecar_launch_env(home: Path, *, dashboard_port: int | None = None) -> dict
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"
+ "http://127.0.0.1:8088,http://localhost:8088,"
+ f"http://127.0.0.1:{port},http://localhost:{port},http://[::1]:{port}"
)
env = os.environ.copy()
+ configured = (env.get("CORS_ORIGIN") or "").strip()
+ cors_origin = (
+ ",".join(item for item in f"{configured},{origin}".split(",") if item.strip())
+ if configured
+ else origin
+ )
env.update(
{
"NODE_ENV": "production",
@@ -292,7 +299,7 @@ def sidecar_launch_env(home: Path, *, dashboard_port: int | None = None) -> dict
"JWT_SECRET": jwt,
"COOKIE_SECRET": cookie,
INGEST_TOKEN_KEY: ingest,
- "CORS_ORIGIN": env.get("CORS_ORIGIN") or origin,
+ "CORS_ORIGIN": cors_origin,
"FREEOS_HOME": str(home),
"OCTOP_HOME": str(home),
"FREEOS_ORG_SIDECAR_PORT": port,
diff --git a/tests/unit/desktop/test_openxyos_provisioner.py b/tests/unit/desktop/test_openxyos_provisioner.py
index fc7916ba..538a20d5 100644
--- a/tests/unit/desktop/test_openxyos_provisioner.py
+++ b/tests/unit/desktop/test_openxyos_provisioner.py
@@ -87,6 +87,8 @@ def test_start_sidecar_cors_origin_is_parseorigins_safe() -> None:
origins = [part.strip() for part in match.group(1).split(",") if part.strip()]
assert origins
assert "*" not in origins
+ assert "http://127.0.0.1:3780" in origins
+ assert "http://localhost:3780" in origins
for origin in origins:
parsed = urlparse(origin)
assert parsed.scheme in {"http", "https"}, origin
diff --git a/tests/unit/test_sidecar_launch.py b/tests/unit/test_sidecar_launch.py
index 7b2aa9c7..9be5112f 100644
--- a/tests/unit/test_sidecar_launch.py
+++ b/tests/unit/test_sidecar_launch.py
@@ -178,8 +178,19 @@ 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"]
+ assert "http://127.0.0.1:3780" in env["CORS_ORIGIN"]
+ assert "http://localhost:3780" 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
assert "FREEOS_INGEST_TOKEN=" in secrets
assert env["FREEOS_INGEST_TOKEN"]
+
+
+def test_sidecar_launch_env_merges_existing_cors(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ monkeypatch.setenv("CORS_ORIGIN", "http://127.0.0.1:9000")
+ env = sidecar_launch_env(tmp_path, dashboard_port=8099)
+ assert "http://127.0.0.1:9000" in env["CORS_ORIGIN"]
+ assert "http://127.0.0.1:3780" in env["CORS_ORIGIN"]