Skip to content
5 changes: 5 additions & 0 deletions .changeset/core-session-prefix-sanitize.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@aoagents/ao-core": patch
---

Harden session prefix generation by sanitizing inputs inside `generateSessionPrefix` via `sanitizeIdentifierComponent`, and derive prefixes from full paths via `deriveSessionPrefixFromProjectPath` when basenames collapse to the generic `project` fallback — covering global registry registration and local wrapped YAML. Legacy wrapped `storageKey` values append a short path fingerprint in that fallback case so distinct risky paths do not share the same storage directory key.
75 changes: 74 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 @@ -233,6 +238,74 @@ 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(".");
});

it("disambiguates legacy wrapped storage keys when the sanitized basename is the generic fallback", () => {
const configPath = join(testDir, "legacy-fingerprint.yaml");
writeFileSync(configPath, "projects: {}\n");
mkdirSync(join(testDir, "nested"), { recursive: true });

const fsRootKey = generateLegacyWrappedStorageKey(configPath, "/");
expect(fsRootKey).toMatch(/^[a-f0-9]{12}-project-[a-f0-9]{8}$/);

const normalKey = generateLegacyWrappedStorageKey(configPath, "nested");
expect(normalKey).toMatch(/^[a-f0-9]{12}-nested$/);
expect(normalKey).not.toBe(fsRootKey);
});

it("does not collapse distinct risky paths to the same legacy wrapped storageKey", () => {
const configPath = join(testDir, "risky-paths.yaml");
writeFileSync(
configPath,
[
"projects:",
" fs-root:",
" path: /",
" parent-relative:",
" path: ..",
"",
].join("\n"),
);

const config = loadConfig(configPath);
const a = config.projects["fs-root"];
const b = config.projects["parent-relative"];
expect(a?.sessionPrefix).toBeDefined();
expect(b?.sessionPrefix).toBeDefined();
expect(a!.sessionPrefix).not.toBe(b!.sessionPrefix);

// Zod-validated config strips unknown keys — storageKey is injected pre-parse only.
const storageKeyFs = generateLegacyWrappedStorageKey(configPath, "/");
const storageKeyParent = generateLegacyWrappedStorageKey(configPath, "..");
expect(storageKeyFs).not.toBe(storageKeyParent);
expect(storageKeyFs).toMatch(/^[a-z0-9]{12}-[a-zA-Z0-9_-]+$/);
expect(storageKeyParent).toMatch(/^[a-z0-9]{12}-[a-zA-Z0-9_-]+$/);
});
});

describe("Config Discovery Priority", () => {
Expand Down
23 changes: 23 additions & 0 deletions packages/core/src/__tests__/global-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,29 @@ describe("global-config storage identity", () => {
expect(config?.projects[idB]?.sessionPrefix).toBe("ao-1");
});

it("re-derives invalid stored session prefixes from the registered path", () => {
const repoPath = createRepo("dot-path-project");
writeFileSync(
configPath,
[
"projects:",
" dot:",
" projectId: dot",
` path: ${repoPath}`,
" displayName: Dot",
" sessionPrefix: .",
"notifiers: {}",
"notificationRouting: {}",
"reactions: {}",
"",
].join("\n"),
);

const resolved = resolveProjectIdentity("dot", loadGlobalConfig(configPath)!, configPath);

expect(resolved?.sessionPrefix).toBe("dpp");
});

it("strips stale shadow fields from legacy entries and rewrites the config", () => {
const repoPath = createRepo("legacy", "https://github.com/OpenAI/demo.git");
writeFileSync(
Expand Down
38 changes: 38 additions & 0 deletions packages/core/src/__tests__/migration-storage-v2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1371,6 +1371,44 @@ describe.skipIf(process.platform === "win32")("migration edge cases", () => {
}
});

it("blocks migration when active sessions use a re-derived prefix for an invalid stored prefix", async () => {
const projectPath = join(testDir, "dot-path-project");
mkdirSync(projectPath, { recursive: true });
writeFileSync(
configPath,
[
"projects:",
" dot:",
` path: ${projectPath}`,
" sessionPrefix: .",
"",
].join("\n"),
);

vi.resetModules();
// The invalid stored prefix "." should be ignored; the path-derived prefix
// for "dot-path-project" is "dpp", matching the active session below.
vi.doMock("node:child_process", async (importOriginal) => {
const actual = await importOriginal<typeof ChildProcess>();
return { ...actual, execSync: vi.fn(() => "dpp-1\n") };
});

const { migrateStorage: migrateStorageWithMock } = await import("../migration/storage-v2.js");

try {
await expect(
migrateStorageWithMock({
aoBaseDir,
globalConfigPath: configPath,
log: () => {},
}),
).rejects.toThrow(/active AO tmux session/);
} finally {
vi.doUnmock("node:child_process");
vi.resetModules();
}
});

it("migrates worktree directories to new layout", async () => {
const hashDir = join(aoBaseDir, "aaaaaa000000-myproject");
mkdirSync(join(hashDir, "sessions"), { recursive: true });
Expand Down
18 changes: 18 additions & 0 deletions packages/core/src/__tests__/paths.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { describe, expect, it } from "vitest";
import { join } from "node:path";
import {
deriveSessionPrefixFromProjectPath,
generateProjectId,
generateSessionName,
generateSessionPrefix,
sanitizeIdentifierComponent,
generateTmuxName,
getArchiveDir,
getFeedbackReportsDir,
Expand Down Expand Up @@ -36,9 +38,25 @@ describe("paths", () => {
it("keeps session prefix generation unchanged", () => {
expect(generateSessionPrefix("agent-orchestrator")).toBe("ao");
expect(generateSessionPrefix("Integrator")).toBe("int");
expect(generateSessionPrefix("MyApp")).toBe("ma");
expect(generateSessionName("ao", 7)).toBe("ao-7");
});

it("sanitizes path-like fragments before deriving session prefixes", () => {
expect(sanitizeIdentifierComponent(".")).toBe("project");
expect(sanitizeIdentifierComponent("/")).toBe("project");
expect(sanitizeIdentifierComponent("..")).toBe("project");
expect(generateSessionPrefix(".")).toBe("pro");
expect(generateSessionPrefix("..")).toBe("pro");
});

it("deriveSessionPrefixFromProjectPath avoids collisions for distinct risky paths", () => {
expect(deriveSessionPrefixFromProjectPath("/")).not.toBe(
deriveSessionPrefixFromProjectPath(join(process.cwd(), "..")),
);
expect(deriveSessionPrefixFromProjectPath("/")).toMatch(/^[a-zA-Z0-9_-]+$/);
});

it("uses the storage key as the tmux hash segment", () => {
const tmuxName = generateTmuxName(storageKey, "ao", 3);
expect(tmuxName).toBe("aaaaaaaaaaaa-ao-3");
Expand Down
20 changes: 16 additions & 4 deletions packages/core/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,11 @@ import {
type LoadedConfig,
type OrchestratorConfig,
} from "./types.js";
import { generateSessionPrefix } from "./paths.js";
import {
deriveSessionPrefixFromProjectPath,
generateSessionPrefix,
sanitizeIdentifierComponent,
} from "./paths.js";
import { getDefaultRuntime } from "./platform.js";
import {
getGlobalConfigPath,
Expand Down Expand Up @@ -74,11 +78,19 @@ 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 resolvedProjectPath = resolve(configDir, projectPath);
const projectBasename = basename(resolvedProjectPath);
let component = sanitizeIdentifierComponent(projectBasename);
// Avoid collisions when distinct paths sanitize to the generic fallback (e.g. "/" vs "..").
if (component === "project") {
const pathFingerprint = createHash("sha256").update(resolvedProjectPath).digest("hex").slice(0, 8);
component = `project-${pathFingerprint}`;
}
return `${hash}-${component}`;
}

function applyWrappedLocalStorageKeys(configPath: string, parsed: unknown): unknown {
Expand Down Expand Up @@ -595,7 +607,7 @@ 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));
project.sessionPrefix = deriveSessionPrefixFromProjectPath(project.path);
}

const inferredPlugin = inferScmPlugin(project);
Expand Down
26 changes: 16 additions & 10 deletions packages/core/src/global-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ import { atomicWriteFileSync } from "./atomic-write.js";
import { detectScmPlatform } from "./config-generator.js";
import { withFileLockSync } from "./file-lock.js";
import { ProjectResolveError, type ProjectResolveErrorKind } from "./types.js";
import { generateSessionPrefix } from "./paths.js";
import {
deriveSessionPrefixFromProjectPath,
generateSessionPrefix,
sanitizeIdentifierComponent,
} from "./paths.js";
import { normalizeOriginUrl } from "./storage-key.js";
import { getDefaultRuntime } from "./platform.js";
import { recordActivityEvent } from "./activity-events.js";
Expand Down Expand Up @@ -695,7 +699,13 @@ function normalizeLegacyRepoValue(
}

function getRegisteredSessionPrefix(entry: GlobalProjectEntry, projectId: string): string {
return entry.sessionPrefix ?? generateSessionPrefix(basename(entry.path ?? projectId));
if (entry.sessionPrefix && sanitizeIdentifierComponent(entry.sessionPrefix) === entry.sessionPrefix) {
return entry.sessionPrefix;
}
if (entry.path?.trim()) {
return deriveSessionPrefixFromProjectPath(resolve(entry.path.trim()));
}
return generateSessionPrefix(projectId);
}

function findSessionPrefixOwner(
Expand Down Expand Up @@ -810,10 +820,9 @@ export function registerProjectInGlobalConfig(
?? normalizeRepoIdentity(originUrl)
?? (localConfig?.repo ? normalizeLegacyRepoValue(localConfig.repo) : undefined);
const defaultBranch = existing?.defaultBranch ?? localConfig?.defaultBranch ?? "main";
const requestedSessionPrefix =
existing?.sessionPrefix ??
localConfig?.sessionPrefix ??
generateSessionPrefix(basename(requestedProjectPath));
const requestedSessionPrefix = existing
? getRegisteredSessionPrefix(existing, effectiveProjectId)
: localConfig?.sessionPrefix ?? deriveSessionPrefixFromProjectPath(requestedProjectPath);
const source = existing?.source ?? (repoIdentity ? "ao-project-add" : "local");
const registeredAt = existing?.registeredAt ?? Math.floor(Date.now() / 1000);
const explicitSessionPrefix = !existing?.sessionPrefix && Boolean(localConfig?.sessionPrefix);
Expand Down Expand Up @@ -906,10 +915,7 @@ export function resolveProjectIdentity(

const projectPath = entry.path;
const name = (entry.displayName as string | undefined) ?? projectId;
const sessionPrefix =
typeof entry.sessionPrefix === "string" && entry.sessionPrefix.length > 0
? entry.sessionPrefix
: generateSessionPrefix(basename(projectPath));
const sessionPrefix = getRegisteredSessionPrefix(entry, projectId);
const defaultBranch =
typeof entry.defaultBranch === "string" && entry.defaultBranch.length > 0
? entry.defaultBranch
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,8 @@ export {
// Legacy path functions (deprecated — migration only)
generateConfigHash,
generateProjectId,
sanitizeIdentifierComponent,
deriveSessionPrefixFromProjectPath,
generateSessionPrefix,
getProjectBaseDir,
getSessionsDir,
Expand Down
34 changes: 23 additions & 11 deletions packages/core/src/migration/storage-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,15 @@ import {
unlinkSync,
type Dirent,
} from "node:fs";
import { basename, join } from "node:path";
import { basename, join, resolve } from "node:path";
import { homedir } from "node:os";
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
import { parseKeyValueContent } from "../key-value.js";
import { generateSessionPrefix } from "../paths.js";
import {
deriveSessionPrefixFromProjectPath,
generateSessionPrefix,
sanitizeIdentifierComponent,
} from "../paths.js";
import { atomicWriteFileSync } from "../atomic-write.js";
import { withFileLockSync } from "../file-lock.js";
import { recordActivityEvent } from "../activity-events.js";
Expand Down Expand Up @@ -209,15 +213,23 @@ function extractProjectPrefixes(globalConfigPath?: string): string[] {
const projects = parsed?.["projects"] as Record<string, Record<string, unknown>> | undefined;
if (!projects || typeof projects !== "object") return [];

return Array.from(new Set(Object.entries(projects).map(([projectId, entry]) => {
if (entry && typeof entry["sessionPrefix"] === "string" && entry["sessionPrefix"].trim()) {
return entry["sessionPrefix"].trim();
}
if (entry && typeof entry["path"] === "string" && entry["path"].trim()) {
return generateSessionPrefix(basename(entry["path"].trim()));
}
return generateSessionPrefix(projectId);
})));
return Array.from(
new Set(
Object.entries(projects).map(([projectId, entry]) => {
const storedPrefix =
entry && typeof entry["sessionPrefix"] === "string"
? entry["sessionPrefix"].trim()
: "";
if (storedPrefix && sanitizeIdentifierComponent(storedPrefix) === storedPrefix) {
return storedPrefix;
}
if (entry && typeof entry["path"] === "string" && entry["path"].trim()) {
return deriveSessionPrefixFromProjectPath(resolve(entry["path"].trim()));
}
return generateSessionPrefix(projectId);
}),
),
);
} catch {
return [];
}
Expand Down
Loading
Loading