Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ function handleExistingWorktree({
const setupConfig = loadSetupConfig({
mainRepoPath: project.mainRepoPath,
worktreePath: existingWorktree.path,
projectName: project.name,
});

return {
Expand Down Expand Up @@ -252,6 +253,7 @@ async function handleNewWorktree({
const setupConfig = loadSetupConfig({
mainRepoPath: project.mainRepoPath,
worktreePath,
projectName: project.name,
});

return {
Expand Down Expand Up @@ -394,6 +396,7 @@ export const createCreateProcedures = () => {
const setupConfig = loadSetupConfig({
mainRepoPath: project.mainRepoPath,
worktreePath: orphanedWorktree.path,
projectName: project.name,
});
return {
workspace,
Expand Down Expand Up @@ -472,6 +475,7 @@ export const createCreateProcedures = () => {
const setupConfig = loadSetupConfig({
mainRepoPath: project.mainRepoPath,
worktreePath,
projectName: project.name,
});

return {
Expand Down Expand Up @@ -663,6 +667,7 @@ export const createCreateProcedures = () => {
const setupConfig = loadSetupConfig({
mainRepoPath: project.mainRepoPath,
worktreePath: worktree.path,
projectName: project.name,
});

track("workspace_opened", {
Expand Down Expand Up @@ -775,6 +780,7 @@ export const createCreateProcedures = () => {
const setupConfig = loadSetupConfig({
mainRepoPath: project.mainRepoPath,
worktreePath: existingWorktree.path,
projectName: project.name,
});

track("workspace_opened", {
Expand Down Expand Up @@ -831,6 +837,7 @@ export const createCreateProcedures = () => {
const setupConfig = loadSetupConfig({
mainRepoPath: project.mainRepoPath,
worktreePath: input.worktreePath,
projectName: project.name,
});

track("workspace_created", {
Expand Down
22 changes: 12 additions & 10 deletions apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,11 +202,12 @@ export const createDeleteProcedures = () => {
worktree = getWorktree(workspace.worktreeId);

if (worktree && project && existsSync(worktree.path)) {
teardownPromise = runTeardown(
project.mainRepoPath,
worktree.path,
workspace.name,
);
teardownPromise = runTeardown({
mainRepoPath: project.mainRepoPath,
worktreePath: worktree.path,
workspaceName: workspace.name,
projectName: project.name,
});
} else {
console.warn(
`[workspace/delete] Skipping teardown: worktree=${!!worktree}, project=${!!project}, pathExists=${worktree ? existsSync(worktree.path) : "N/A"}`,
Expand Down Expand Up @@ -424,11 +425,12 @@ export const createDeleteProcedures = () => {
);

if (exists) {
const teardownResult = await runTeardown(
project.mainRepoPath,
worktree.path,
worktree.branch,
);
const teardownResult = await runTeardown({
mainRepoPath: project.mainRepoPath,
worktreePath: worktree.path,
workspaceName: worktree.branch,
projectName: project.name,
});
if (!teardownResult.success) {
return {
success: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ export const createInitProcedures = () => {
const setupConfig = loadSetupConfig({
mainRepoPath: project.mainRepoPath,
worktreePath: relations.worktree?.path,
projectName: project.name,
});
const defaultPreset = getDefaultPreset();

Expand Down
131 changes: 130 additions & 1 deletion apps/desktop/src/lib/trpc/routers/workspaces/utils/setup.test.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,35 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { PROJECTS_DIR_NAME, SUPERSET_DIR_NAME } from "shared/constants";
import { loadSetupConfig } from "./setup";

const TEST_DIR = join(__dirname, ".test-tmp");
const MAIN_REPO = join(TEST_DIR, "main-repo");
const WORKTREE = join(TEST_DIR, "worktree");
const PROJECT_NAME = "test-project";
const USER_CONFIG_DIR = join(
homedir(),
SUPERSET_DIR_NAME,
PROJECTS_DIR_NAME,
PROJECT_NAME,
);

describe("loadSetupConfig", () => {
beforeEach(() => {
mkdirSync(join(MAIN_REPO, ".superset"), { recursive: true });
});

afterEach(() => {
// Clean up
// Clean up test dir
if (existsSync(TEST_DIR)) {
rmSync(TEST_DIR, { recursive: true, force: true });
}
// Clean up user override dir
if (existsSync(USER_CONFIG_DIR)) {
rmSync(USER_CONFIG_DIR, { recursive: true, force: true });
}
});

test("returns null when config.json does not exist", () => {
Expand Down Expand Up @@ -96,4 +109,120 @@ describe("loadSetupConfig", () => {
});
expect(config).toEqual(mainConfig);
});

test("user override takes priority over main repo config", () => {
const mainConfig = { setup: ["npm install"] };
const userConfig = { setup: ["custom-setup.sh"] };

writeFileSync(
join(MAIN_REPO, ".superset", "config.json"),
JSON.stringify(mainConfig),
);

mkdirSync(USER_CONFIG_DIR, { recursive: true });
writeFileSync(
join(USER_CONFIG_DIR, "config.json"),
JSON.stringify(userConfig),
);

const config = loadSetupConfig({
mainRepoPath: MAIN_REPO,
projectName: PROJECT_NAME,
});
expect(config).toEqual(userConfig);
});

test("user override takes priority over worktree config", () => {
const worktreeConfig = { setup: ["worktree-setup.sh"] };
const userConfig = { setup: ["user-override-setup.sh"] };

mkdirSync(join(WORKTREE, ".superset"), { recursive: true });
writeFileSync(
join(WORKTREE, ".superset", "config.json"),
JSON.stringify(worktreeConfig),
);

mkdirSync(USER_CONFIG_DIR, { recursive: true });
writeFileSync(
join(USER_CONFIG_DIR, "config.json"),
JSON.stringify(userConfig),
);

const config = loadSetupConfig({
mainRepoPath: MAIN_REPO,
worktreePath: WORKTREE,
projectName: PROJECT_NAME,
});
expect(config).toEqual(userConfig);
});

test("falls back to worktree/main when no user override exists", () => {
const mainConfig = { setup: ["npm install"] };

writeFileSync(
join(MAIN_REPO, ".superset", "config.json"),
JSON.stringify(mainConfig),
);

const config = loadSetupConfig({
mainRepoPath: MAIN_REPO,
projectName: PROJECT_NAME,
});
expect(config).toEqual(mainConfig);
});

test("works when projectName is not provided (backwards compat)", () => {
const mainConfig = { setup: ["npm install"] };

writeFileSync(
join(MAIN_REPO, ".superset", "config.json"),
JSON.stringify(mainConfig),
);

const config = loadSetupConfig({
mainRepoPath: MAIN_REPO,
});
expect(config).toEqual(mainConfig);
});

test("user override with empty arrays skips setup", () => {
const mainConfig = { setup: ["npm install"] };
const userConfig = { setup: [], teardown: [] };

writeFileSync(
join(MAIN_REPO, ".superset", "config.json"),
JSON.stringify(mainConfig),
);

mkdirSync(USER_CONFIG_DIR, { recursive: true });
writeFileSync(
join(USER_CONFIG_DIR, "config.json"),
JSON.stringify(userConfig),
);

const config = loadSetupConfig({
mainRepoPath: MAIN_REPO,
projectName: PROJECT_NAME,
});
expect(config).toEqual(userConfig);
expect(config?.setup).toEqual([]);
});

test("falls back to main repo when user override has invalid JSON", () => {
const mainConfig = { setup: ["npm install"] };

writeFileSync(
join(MAIN_REPO, ".superset", "config.json"),
JSON.stringify(mainConfig),
);

mkdirSync(USER_CONFIG_DIR, { recursive: true });
writeFileSync(join(USER_CONFIG_DIR, "config.json"), "{ invalid json");

const config = loadSetupConfig({
mainRepoPath: MAIN_REPO,
projectName: PROJECT_NAME,
});
expect(config).toEqual(mainConfig);
});
});
69 changes: 59 additions & 10 deletions apps/desktop/src/lib/trpc/routers/workspaces/utils/setup.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { cpSync, existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { CONFIG_FILE_NAME, PROJECT_SUPERSET_DIR_NAME } from "shared/constants";
import {
CONFIG_FILE_NAME,
PROJECT_SUPERSET_DIR_NAME,
PROJECTS_DIR_NAME,
SUPERSET_DIR_NAME,
} from "shared/constants";
import type { SetupConfig } from "shared/types";

/**
Expand All @@ -27,13 +33,7 @@ export function copySupersetConfigToWorktree(
}
}

function readConfigFromPath(basePath: string): SetupConfig | null {
const configPath = join(
basePath,
PROJECT_SUPERSET_DIR_NAME,
CONFIG_FILE_NAME,
);

function readConfigFile(configPath: string): SetupConfig | null {
if (!existsSync(configPath)) {
return null;
}
Expand All @@ -59,17 +59,66 @@ function readConfigFromPath(basePath: string): SetupConfig | null {
}
}

function readConfigFromPath(basePath: string): SetupConfig | null {
return readConfigFile(
join(basePath, PROJECT_SUPERSET_DIR_NAME, CONFIG_FILE_NAME),
);
}

/**
* Resolves setup/teardown config with a three-tier priority:
* 1. User override: ~/.superset/projects/<projectName>/config.json
* 2. Worktree: <worktreePath>/.superset/config.json
* 3. Main repo: <mainRepoPath>/.superset/config.json
*
* First config found wins entirely (no merging between levels).
*/
export function loadSetupConfig({
mainRepoPath,
worktreePath,
projectName,
}: {
mainRepoPath: string;
worktreePath?: string;
projectName?: string;
}): SetupConfig | null {
// 1. Check user-level override (~/.superset/projects/<projectName>/config.json)
if (
projectName &&
!projectName.includes("/") &&
!projectName.includes("\\")
) {
const userConfigPath = join(
homedir(),
SUPERSET_DIR_NAME,
PROJECTS_DIR_NAME,
projectName,
CONFIG_FILE_NAME,
);
const config = readConfigFile(userConfigPath);
if (config) {
console.log(`[setup] Using user override config from ${userConfigPath}`);
return config;
}
}
Comment on lines +90 to +108
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Path traversal: projectName of ".." bypasses the slash check.

The guard on lines 92–94 rejects / and \, but ".." contains neither and would resolve join(homedir(), SUPERSET_DIR_NAME, PROJECTS_DIR_NAME, "..", CONFIG_FILE_NAME) to ~/.superset/config.json, escaping the projects/ directory. While impact is limited (reads only, within the user's home dir, and projectName comes from an internal database), it's still a validation gap.

Proposed fix
 	if (
 		projectName &&
 		!projectName.includes("/") &&
-		!projectName.includes("\\")
+		!projectName.includes("\\") &&
+		projectName !== ".." &&
+		projectName !== "."
 	) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 1. Check user-level override (~/.superset/projects/<projectName>/config.json)
if (
projectName &&
!projectName.includes("/") &&
!projectName.includes("\\")
) {
const userConfigPath = join(
homedir(),
SUPERSET_DIR_NAME,
PROJECTS_DIR_NAME,
projectName,
CONFIG_FILE_NAME,
);
const config = readConfigFile(userConfigPath);
if (config) {
console.log(`[setup] Using user override config from ${userConfigPath}`);
return config;
}
}
// 1. Check user-level override (~/.superset/projects/<projectName>/config.json)
if (
projectName &&
!projectName.includes("/") &&
!projectName.includes("\\") &&
projectName !== ".." &&
projectName !== "."
) {
const userConfigPath = join(
homedir(),
SUPERSET_DIR_NAME,
PROJECTS_DIR_NAME,
projectName,
CONFIG_FILE_NAME,
);
const config = readConfigFile(userConfigPath);
if (config) {
console.log(`[setup] Using user override config from ${userConfigPath}`);
return config;
}
}
🤖 Prompt for AI Agents
In `@apps/desktop/src/lib/trpc/routers/workspaces/utils/setup.ts` around lines 90
- 108, The current user-level override lookup accepts projectName values like
".." and can escape the intended projects directory; before building
userConfigPath in the setup routine, validate projectName is a single safe path
segment: ensure it is non-empty, does not include path separators, equals
path.basename(projectName), and that path.normalize(projectName) does not start
with ".." or contain ".." segments (also explicitly reject "." and ".."); only
then call join(homedir(), SUPERSET_DIR_NAME, PROJECTS_DIR_NAME, projectName,
CONFIG_FILE_NAME) and readConfigFile(userConfigPath).


// 2. Check worktree-specific config
if (worktreePath) {
const config = readConfigFromPath(worktreePath);
if (config) return config;
if (config) {
console.log(
`[setup] Using worktree config from ${join(worktreePath, PROJECT_SUPERSET_DIR_NAME, CONFIG_FILE_NAME)}`,
);
return config;
}
}

return readConfigFromPath(mainRepoPath);
// 3. Fall back to main repo config
const config = readConfigFromPath(mainRepoPath);
if (config) {
console.log(
`[setup] Using main repo config from ${join(mainRepoPath, PROJECT_SUPERSET_DIR_NAME, CONFIG_FILE_NAME)}`,
);
}
return config;
}
Loading