diff --git a/apps/web/app/api/profiles/route.test.ts b/apps/web/app/api/profiles/route.test.ts index a7b928a5a4e..30b311c9dbe 100644 --- a/apps/web/app/api/profiles/route.test.ts +++ b/apps/web/app/api/profiles/route.test.ts @@ -2,8 +2,14 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; vi.mock("@/lib/workspace", () => ({ discoverWorkspaces: vi.fn(() => []), + ensureManagedWorkspaceRouting: vi.fn(), getActiveWorkspaceName: vi.fn(() => null), resolveOpenClawStateDir: vi.fn(() => "/home/testuser/.openclaw-dench"), + resolveWorkspaceDirForName: vi.fn((name: string) => + name === "default" + ? "/home/testuser/.openclaw-dench/workspace" + : `/home/testuser/.openclaw-dench/workspace-${name}`, + ), resolveWorkspaceRoot: vi.fn(() => null), setUIActiveWorkspace: vi.fn(), })); @@ -170,6 +176,11 @@ describe("profiles API", () => { expect(json.stateDir).toBe(STATE_DIR); expect(json.workspaceRoot).toBe(`${STATE_DIR}/workspace-work`); expect(json.workspace.name).toBe("work"); + expect(workspace.ensureManagedWorkspaceRouting).toHaveBeenCalledWith( + "work", + `${STATE_DIR}/workspace-work`, + { markDefault: false }, + ); expect(workspace.setUIActiveWorkspace).toHaveBeenCalledWith("work"); }); diff --git a/apps/web/app/api/profiles/switch/route.ts b/apps/web/app/api/profiles/switch/route.ts index 8fbdda1a2a5..b08be48cc07 100644 --- a/apps/web/app/api/profiles/switch/route.ts +++ b/apps/web/app/api/profiles/switch/route.ts @@ -1,7 +1,9 @@ import { discoverWorkspaces, + ensureManagedWorkspaceRouting, getActiveWorkspaceName, resolveOpenClawStateDir, + resolveWorkspaceDirForName, resolveWorkspaceRoot, setUIActiveWorkspace, } from "@/lib/workspace"; @@ -44,6 +46,10 @@ export async function POST(req: Request) { ); } + const workspaceRoot = + discovered.find((workspace) => workspace.name === requestedWorkspace)?.workspaceDir + ?? resolveWorkspaceDirForName(requestedWorkspace); + ensureManagedWorkspaceRouting(requestedWorkspace, workspaceRoot, { markDefault: false }); setUIActiveWorkspace(requestedWorkspace); const activeWorkspace = getActiveWorkspaceName(); const selected = discoverWorkspaces().find((workspace) => workspace.name === activeWorkspace) ?? null; diff --git a/apps/web/app/api/workspace/init/route.test.ts b/apps/web/app/api/workspace/init/route.test.ts index 7c5972ef06b..05e426cae63 100644 --- a/apps/web/app/api/workspace/init/route.test.ts +++ b/apps/web/app/api/workspace/init/route.test.ts @@ -69,6 +69,18 @@ describe("POST /api/workspace/init", () => { expect(response.status).toBe(400); }); + it("rejects reserved workspace names like main", async () => { + const workspace = await import("@/lib/workspace"); + vi.mocked(workspace.isValidWorkspaceName).mockImplementation( + (name: string) => name !== "main", + ); + + const response = await callInit({ workspace: "main" }); + expect(response.status).toBe(400); + const json = await response.json(); + expect(String(json.error)).toContain("reserved"); + }); + it("returns 409 when workspace already exists", async () => { const workspace = await import("@/lib/workspace"); vi.mocked(workspace.discoverWorkspaces).mockReturnValue([ diff --git a/apps/web/app/api/workspace/init/route.ts b/apps/web/app/api/workspace/init/route.ts index 79b0e95fbf9..67e3cd17e10 100644 --- a/apps/web/app/api/workspace/init/route.ts +++ b/apps/web/app/api/workspace/init/route.ts @@ -115,7 +115,10 @@ export async function POST(req: Request) { } if (!WORKSPACE_NAME_RE.test(workspaceName) || !isValidWorkspaceName(workspaceName)) { return Response.json( - { error: "Invalid workspace name. Use letters, numbers, hyphens, or underscores." }, + { + error: + "Invalid or reserved workspace name. Use letters, numbers, hyphens, or underscores. Reserved names include 'main', 'default', and 'chat-slot-*'.", + }, { status: 400 }, ); } diff --git a/apps/web/app/api/workspace/switch/route.ts b/apps/web/app/api/workspace/switch/route.ts index 79119615cdd..75e873d8d92 100644 --- a/apps/web/app/api/workspace/switch/route.ts +++ b/apps/web/app/api/workspace/switch/route.ts @@ -1,7 +1,9 @@ import { discoverWorkspaces, + ensureManagedWorkspaceRouting, getActiveWorkspaceName, resolveOpenClawStateDir, + resolveWorkspaceDirForName, resolveWorkspaceRoot, setUIActiveWorkspace, setDefaultAgentInConfig, @@ -46,6 +48,10 @@ export async function POST(req: Request) { ); } + const workspaceRoot = + discovered.find((workspace) => workspace.name === requestedWorkspace)?.workspaceDir + ?? resolveWorkspaceDirForName(requestedWorkspace); + ensureManagedWorkspaceRouting(requestedWorkspace, workspaceRoot, { markDefault: false }); setUIActiveWorkspace(requestedWorkspace); setDefaultAgentInConfig(requestedWorkspace); trackServer("workspace_switched"); diff --git a/apps/web/lib/workspace-profiles.test.ts b/apps/web/lib/workspace-profiles.test.ts index 196cc6f3762..6f9e07d2c63 100644 --- a/apps/web/lib/workspace-profiles.test.ts +++ b/apps/web/lib/workspace-profiles.test.ts @@ -159,6 +159,16 @@ describe("workspace (flat workspace model)", () => { }); }); + describe("isValidWorkspaceName", () => { + it("rejects reserved workspace names that collide with managed agent routing", async () => { + const { isValidWorkspaceName } = await importWorkspace(); + expect(isValidWorkspaceName("main")).toBe(false); + expect(isValidWorkspaceName("default")).toBe(false); + expect(isValidWorkspaceName("chat-slot-main-1")).toBe(false); + expect(isValidWorkspaceName("work")).toBe(true); + }); + }); + // ─── getActiveWorkspaceName ─────────────────────────────────────── describe("getActiveWorkspaceName", () => { @@ -603,6 +613,57 @@ describe("workspace (flat workspace model)", () => { }); }); + describe("ensureManagedWorkspaceRouting", () => { + it("repairs the default workspace agent and chat slots without flipping the active default agent", async () => { + const { ensureManagedWorkspaceRouting, mockExists, mockReadFile, mockWriteFile } = + await importWorkspace(); + const configPath = join(STATE_DIR, "openclaw.json"); + const defaultWorkspaceDir = join(STATE_DIR, "workspace"); + const staleWorkspaceDir = join(STATE_DIR, "workspace-main"); + const otherWorkspaceDir = join(STATE_DIR, "workspace-kumareth"); + + mockExists.mockImplementation((p) => String(p) === configPath); + mockReadFile.mockImplementation((p) => { + if (String(p) === configPath) { + return JSON.stringify({ + agents: { + defaults: { + workspace: staleWorkspaceDir, + }, + list: [ + { id: "main", workspace: staleWorkspaceDir }, + { id: "chat-slot-main-1", workspace: staleWorkspaceDir }, + { id: "chat-slot-main-2", workspace: staleWorkspaceDir }, + { id: "kumareth", workspace: otherWorkspaceDir, default: true }, + ], + }, + }) as never; + } + return "" as never; + }); + + ensureManagedWorkspaceRouting("default", defaultWorkspaceDir, { + markDefault: false, + poolSize: 2, + }); + + expect(mockWriteFile).toHaveBeenCalled(); + const written = JSON.parse(String(mockWriteFile.mock.calls.at(-1)?.[1])) as { + agents: { + defaults: { workspace?: string }; + list: Array<{ id: string; workspace?: string; default?: boolean }>; + }; + }; + + expect(written.agents.defaults.workspace).toBe(defaultWorkspaceDir); + expect(written.agents.list.find((agent) => agent.id === "main")?.workspace).toBe(defaultWorkspaceDir); + expect(written.agents.list.find((agent) => agent.id === "chat-slot-main-1")?.workspace).toBe(defaultWorkspaceDir); + expect(written.agents.list.find((agent) => agent.id === "chat-slot-main-2")?.workspace).toBe(defaultWorkspaceDir); + expect(written.agents.list.find((agent) => agent.id === "kumareth")?.default).toBe(true); + expect(written.agents.list.find((agent) => agent.id === "main")?.default).toBeUndefined(); + }); + }); + // ─── registerWorkspacePath / getRegisteredWorkspacePath ──────────── describe("registerWorkspacePath / getRegisteredWorkspacePath", () => { diff --git a/apps/web/lib/workspace.ts b/apps/web/lib/workspace.ts index efb37fc06d8..fe80a1d90fe 100644 --- a/apps/web/lib/workspace.ts +++ b/apps/web/lib/workspace.ts @@ -25,6 +25,13 @@ const ROOT_WORKSPACE_DIRNAME = "workspace"; const WORKSPACE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/i; const DEFAULT_WORKSPACE_NAME = "default"; const DENCHCLAW_PROFILE = "dench"; +const GATEWAY_MAIN_AGENT_ID = "main"; +const CHAT_SLOT_PREFIX = "chat-slot-"; +const DEFAULT_CHAT_POOL_SIZE = 5; +const RESERVED_WORKSPACE_NAMES = new Set([ + DEFAULT_WORKSPACE_NAME, + GATEWAY_MAIN_AGENT_ID, +]); /** In-memory override; takes precedence over persisted state. */ let _uiActiveWorkspace: string | null | undefined; @@ -252,8 +259,14 @@ export function registerWorkspacePath(_profile: string, _absolutePath: string): // ~/.openclaw-dench/workspace (default) and ~/.openclaw-dench/workspace-. } +function isReservedWorkspaceName(name: string): boolean { + const lowered = name.toLowerCase(); + return RESERVED_WORKSPACE_NAMES.has(lowered) || lowered.startsWith(CHAT_SLOT_PREFIX); +} + export function isValidWorkspaceName(name: string): boolean { - return normalizeWorkspaceName(name) !== null; + const normalized = normalizeWorkspaceName(name); + return normalized !== null && !isReservedWorkspaceName(normalized); } // --------------------------------------------------------------------------- @@ -276,8 +289,6 @@ type OpenClawConfig = { [key: string]: unknown; }; -const GATEWAY_MAIN_AGENT_ID = "main"; - function workspaceNameToAgentId(workspaceName: string): string { return workspaceName === DEFAULT_WORKSPACE_NAME ? GATEWAY_MAIN_AGENT_ID : workspaceName; } @@ -302,7 +313,11 @@ function readOpenClawConfig(): OpenClawConfig { return {}; } try { - return JSON.parse(readFileSync(configPath, "utf-8")) as OpenClawConfig; + const parsed = JSON.parse(readFileSync(configPath, "utf-8")) as unknown; + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed as OpenClawConfig; + } + return {}; } catch { return {}; } @@ -317,6 +332,95 @@ function writeOpenClawConfig(config: OpenClawConfig): void { writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8"); } +function ensureConfigAgents(config: OpenClawConfig): void { + if (!config.agents) { + config.agents = {}; + } +} + +function syncDefaultWorkspacePointer( + config: OpenClawConfig, + workspaceName: string, + workspaceDir: string, +): boolean { + if (workspaceName !== DEFAULT_WORKSPACE_NAME) { + return false; + } + ensureConfigAgents(config); + if (!config.agents!.defaults) { + config.agents!.defaults = {}; + } + if (config.agents!.defaults.workspace === workspaceDir) { + return false; + } + config.agents!.defaults.workspace = workspaceDir; + return true; +} + +function ensureConfigAgentList(config: OpenClawConfig): OpenClawAgentEntry[] { + ensureConfigAgents(config); + if (!Array.isArray(config.agents!.list)) { + config.agents!.list = []; + const currentDefaultWorkspace = config.agents!.defaults?.workspace; + if (currentDefaultWorkspace) { + config.agents!.list.push({ + id: GATEWAY_MAIN_AGENT_ID, + workspace: currentDefaultWorkspace, + }); + } + } + return config.agents!.list; +} + +function upsertAgentWorkspace( + list: OpenClawAgentEntry[], + agentId: string, + workspaceDir: string, +): boolean { + const existing = list.find((agent) => agent.id === agentId); + if (existing) { + if (existing.workspace === workspaceDir) { + return false; + } + existing.workspace = workspaceDir; + return true; + } + list.push({ id: agentId, workspace: workspaceDir }); + return true; +} + +function applyDefaultAgentMarker(list: OpenClawAgentEntry[], targetAgentId: string): boolean { + let changed = false; + for (const agent of list) { + if (agent.id === targetAgentId) { + if (agent.default !== true) { + agent.default = true; + changed = true; + } + continue; + } + if ("default" in agent) { + delete agent.default; + changed = true; + } + } + return changed; +} + +function ensureChatSlotEntries( + list: OpenClawAgentEntry[], + baseId: string, + workspaceDir: string, + poolSize: number, +): boolean { + let changed = false; + for (let i = 1; i <= poolSize; i++) { + const slotId = `${CHAT_SLOT_PREFIX}${baseId}-${i}`; + changed = upsertAgentWorkspace(list, slotId, workspaceDir) || changed; + } + return changed; +} + /** * Upsert an agent entry in `agents.list[]`. If the list doesn't exist yet, * bootstrap it with a "main" entry pointing to `agents.defaults.workspace` @@ -325,69 +429,80 @@ function writeOpenClawConfig(config: OpenClawConfig): void { * Workspace name "default" maps to agent ID "main" (the gateway's built-in * default agent ID); all other workspace names are used as-is. */ -export function ensureAgentInConfig(workspaceName: string, workspaceDir: string): void { +export function ensureAgentInConfig( + workspaceName: string, + workspaceDir: string, + options?: { markDefault?: boolean }, +): void { + const normalized = normalizeWorkspaceName(workspaceName); + if (!normalized) { + throw new Error("Invalid workspace name."); + } const config = readOpenClawConfig(); - if (!config.agents) { - config.agents = {}; + let changed = syncDefaultWorkspacePointer(config, normalized, workspaceDir); + const list = ensureConfigAgentList(config); + const resolvedId = workspaceNameToAgentId(normalized); + + changed = upsertAgentWorkspace(list, resolvedId, workspaceDir) || changed; + if (options?.markDefault ?? true) { + changed = applyDefaultAgentMarker(list, resolvedId) || changed; } - const resolvedId = workspaceNameToAgentId(workspaceName); - - if (!Array.isArray(config.agents.list)) { - config.agents.list = []; - const currentDefaultWorkspace = config.agents.defaults?.workspace; - if (currentDefaultWorkspace) { - config.agents.list.push({ - id: GATEWAY_MAIN_AGENT_ID, - workspace: currentDefaultWorkspace, - }); - } + if (changed) { + writeOpenClawConfig(config); } - - const existing = config.agents.list.find((a) => a.id === resolvedId); - if (existing) { - existing.workspace = workspaceDir; - } else { - config.agents.list.push({ id: resolvedId, workspace: workspaceDir }); - } - - for (const agent of config.agents.list) { - if (agent.id === resolvedId) { - agent.default = true; - } else { - delete agent.default; - } - } - - writeOpenClawConfig(config); } -const CHAT_SLOT_PREFIX = "chat-slot-"; -const DEFAULT_CHAT_POOL_SIZE = 5; - /** * Pre-create a pool of chat agent slots in `agents.list[]` so the gateway * knows about them at startup. Each slot shares the workspace directory * of the parent workspace agent, enabling concurrent chat sessions. */ export function ensureChatAgentPool(workspaceName: string, workspaceDir: string, poolSize = DEFAULT_CHAT_POOL_SIZE): void { + const normalized = normalizeWorkspaceName(workspaceName); + if (!normalized) { + throw new Error("Invalid workspace name."); + } const config = readOpenClawConfig(); - if (!config.agents) { config.agents = {}; } - if (!Array.isArray(config.agents.list)) { config.agents.list = []; } + let changed = syncDefaultWorkspacePointer(config, normalized, workspaceDir); + const list = ensureConfigAgentList(config); + const baseId = workspaceNameToAgentId(normalized); - const baseId = workspaceNameToAgentId(workspaceName); - let changed = false; + changed = ensureChatSlotEntries(list, baseId, workspaceDir, poolSize) || changed; - for (let i = 1; i <= poolSize; i++) { - const slotId = `${CHAT_SLOT_PREFIX}${baseId}-${i}`; - const existing = config.agents.list.find((a) => a.id === slotId); - if (!existing) { - config.agents.list.push({ id: slotId, workspace: workspaceDir }); - changed = true; - } else if (existing.workspace !== workspaceDir) { - existing.workspace = workspaceDir; - changed = true; - } + if (changed) { + writeOpenClawConfig(config); + } +} + +/** + * Repair the managed agent mapping for a workspace in a single config pass. + * This is used on chat creation/send so stale `main` or `chat-slot-main-*` + * entries are redirected back to the intended managed workspace. + */ +export function ensureManagedWorkspaceRouting( + workspaceName: string, + workspaceDir: string, + options?: { markDefault?: boolean; poolSize?: number }, +): void { + const normalized = normalizeWorkspaceName(workspaceName); + if (!normalized) { + throw new Error("Invalid workspace name."); + } + const config = readOpenClawConfig(); + let changed = syncDefaultWorkspacePointer(config, normalized, workspaceDir); + const list = ensureConfigAgentList(config); + const resolvedId = workspaceNameToAgentId(normalized); + + changed = upsertAgentWorkspace(list, resolvedId, workspaceDir) || changed; + changed = ensureChatSlotEntries( + list, + resolvedId, + workspaceDir, + options?.poolSize ?? DEFAULT_CHAT_POOL_SIZE, + ) || changed; + if (options?.markDefault) { + changed = applyDefaultAgentMarker(list, resolvedId) || changed; } if (changed) {