Skip to content
33 changes: 32 additions & 1 deletion packages/core/__tests__/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdirSync, writeFileSync, rmSync, realpathSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { loadConfig, findConfigFile, validateConfig } from "../src/config.js";
import {
loadConfig,
findConfigFile,
validateConfig,
generateLegacyWrappedStorageKey,
} from "../src/config.js";
import { ConfigNotFoundError } from "../src/types.js";

describe("Config Loading", () => {
Expand Down Expand Up @@ -229,6 +234,32 @@ projects:
expect(Object.values(config.projects)).toEqual([]);
expect(Object.values(config.degradedProjects)).toHaveLength(1);
});

it("sanitizes wrapped dot-path handling for storage key and derived sessionPrefix", () => {
const configPath = join(testDir, "dot-path-config.yaml");
writeFileSync(
configPath,
[
"projects:",
" dot-project:",
" path: .",
"",
].join("\n"),
);

const config = loadConfig(configPath);
const project = config.projects["dot-project"];
const storageKey = generateLegacyWrappedStorageKey(configPath, ".");

expect(project).toBeDefined();
if (!project) {
throw new Error("dot-project missing from loaded config");
}
expect(storageKey).toMatch(/^[a-z0-9]{12}-[a-zA-Z0-9_-]+$/);
expect(storageKey).not.toContain(".");
expect(project.sessionPrefix).toMatch(/^[a-zA-Z0-9_-]+$/);
expect(project.sessionPrefix).not.toBe(".");
});
});

describe("Config Discovery Priority", () => {
Expand Down
15 changes: 12 additions & 3 deletions packages/core/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@ import {
} from "./global-config.js";
import { loadEffectiveProjectConfig } from "./project-resolver.js";

const STORAGE_KEY_COMPONENT_PATTERN = /^[a-zA-Z0-9_-]+$/;

function sanitizeStorageKeyComponent(value: string): string {
const sanitized = value.replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
return STORAGE_KEY_COMPONENT_PATTERN.test(sanitized) ? sanitized : "project";
}

function inferScmPlugin(project: {
repo?: string;
scm?: Record<string, unknown>;
Expand Down Expand Up @@ -72,11 +79,12 @@ function classifyConfigShape(configPath: string): "wrapped" | "flat-or-nonobject
: "flat-or-nonobject";
}

function generateLegacyWrappedStorageKey(configPath: string, projectPath: string): string {
export function generateLegacyWrappedStorageKey(configPath: string, projectPath: string): string {
const resolvedConfigPath = realpathSync(configPath);
const configDir = dirname(resolvedConfigPath);
const hash = createHash("sha256").update(configDir).digest("hex").slice(0, 12);
return `${hash}-${basename(projectPath)}`;
const projectBasename = basename(resolve(configDir, projectPath));
return `${hash}-${sanitizeStorageKeyComponent(projectBasename)}`;
}

function applyWrappedLocalStorageKeys(configPath: string, parsed: unknown): unknown {
Expand Down Expand Up @@ -584,7 +592,8 @@ function applyProjectDefaults(config: OrchestratorConfig): OrchestratorConfig {
// This preserves the long-standing semantics on this branch, where
// `/repos/integrator` becomes `int` regardless of the config key.
if (!project.sessionPrefix) {
project.sessionPrefix = generateSessionPrefix(basename(project.path));
const safePathComponent = sanitizeStorageKeyComponent(basename(project.path));
project.sessionPrefix = generateSessionPrefix(safePathComponent);
}

const inferredPlugin = inferScmPlugin(project);
Expand Down
Loading