fix(workspace): repair managed workspace routing and block reserved names

Prevent workspace-main / agent-main collision by adding ensureManagedWorkspaceRouting() repair on chat create/send/switch, and reject reserved workspace names (main, default, chat-slot-*).
This commit is contained in:
kumarabhirup 2026-03-15 00:29:57 -07:00
parent 584d974f07
commit 46fe15df81
No known key found for this signature in database
GPG Key ID: DB7CA2289CAB0167
7 changed files with 267 additions and 53 deletions

View File

@ -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");
});

View File

@ -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;

View File

@ -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([

View File

@ -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 },
);
}

View File

@ -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");

View File

@ -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", () => {

View File

@ -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-<name>.
}
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) {