Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
10 changes: 8 additions & 2 deletions plugins/codex/scripts/codex-companion.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {
runAppServerReview,
runAppServerTurn
} from "./lib/codex.mjs";
import { resolveClaudeSessionPath } from "./lib/claude-session-transfer.mjs";
import { prepareClaudeSessionImport, resolveClaudeSessionPath } from "./lib/claude-session-transfer.mjs";
import { readStdinIfPiped } from "./lib/fs.mjs";
import { collectReviewContext, ensureGitRepository, resolveReviewTarget } from "./lib/git.mjs";
import { binaryAvailable, terminateProcessTree } from "./lib/process.mjs";
Expand Down Expand Up @@ -626,7 +626,13 @@ async function executeTransfer(cwd, options = {}) {
const sourcePath = resolveClaudeSessionPath(cwd, {
source: options.source
});
const result = await importExternalAgentSession(cwd, { sourcePath });
const prepared = prepareClaudeSessionImport(cwd, sourcePath);
let result;
try {
result = await importExternalAgentSession(cwd, { sourcePath: prepared.importPath });
} finally {
prepared.cleanup();
}
const payload = {
threadId: result.threadId,
resumeCommand: `codex resume ${result.threadId}`,
Expand Down
87 changes: 81 additions & 6 deletions plugins/codex/scripts/lib/claude-session-transfer.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import path from "node:path";
import { ensureAbsolutePath } from "./fs.mjs";

export const TRANSCRIPT_PATH_ENV = "CODEX_COMPANION_TRANSCRIPT_PATH";
const CLAUDE_PROJECTS_DIR = path.join(os.homedir(), ".claude", "projects");
const CLAUDE_CONFIG_DIR_ENV = "CLAUDE_CONFIG_DIR";

function resolveUserPath(cwd, value) {
if (value === "~") {
Expand All @@ -17,6 +17,42 @@ function resolveUserPath(cwd, value) {
return ensureAbsolutePath(cwd, value);
}

function defaultClaudeProjectsDir() {
return path.join(os.homedir(), ".claude", "projects");
}

function configuredClaudeProjectsDir(cwd) {
const configured = process.env[CLAUDE_CONFIG_DIR_ENV];
return configured ? path.join(resolveUserPath(cwd, configured), "projects") : null;
}

function allowedClaudeProjectsDirs(cwd) {
return [...new Set([configuredClaudeProjectsDir(cwd), defaultClaudeProjectsDir()].filter(Boolean))];
}

function realpathIfExists(value) {
try {
return fs.realpathSync(value);
} catch {
return null;
}
}

function isWithin(root, candidate) {
const relative = path.relative(root, candidate);
return relative !== "" && relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
}

function findContainingProjectsRoot(cwd, source) {
for (const configuredPath of allowedClaudeProjectsDirs(cwd)) {
const realRoot = realpathIfExists(configuredPath);
if (realRoot && isWithin(realRoot, source)) {
return { configuredPath, realRoot };
}
}
return null;
}

export function resolveClaudeSessionPath(cwd, options = {}) {
const requestedPath = options.source || process.env[TRANSCRIPT_PATH_ENV];
if (!requestedPath) {
Expand All @@ -29,16 +65,55 @@ export function resolveClaudeSessionPath(cwd, options = {}) {
}

let source;
let projects;
try {
source = fs.realpathSync(sourcePath);
projects = fs.realpathSync(CLAUDE_PROJECTS_DIR);
} catch {
throw new Error(`Claude session file not found: ${sourcePath}`);
}
const relative = path.relative(projects, source);
if (relative === "" || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
throw new Error(`Codex can import Claude sessions only from ${CLAUDE_PROJECTS_DIR}: ${source}`);

if (!findContainingProjectsRoot(cwd, source)) {
throw new Error(
`Codex can import Claude sessions only from ${allowedClaudeProjectsDirs(cwd).join(" or ")}: ${source}`
);
}
return source;
}

export function prepareClaudeSessionImport(cwd, sourcePath) {
const source = fs.realpathSync(sourcePath);
const defaultProjects = defaultClaudeProjectsDir();
const defaultRoot = realpathIfExists(defaultProjects);
if (defaultRoot && isWithin(defaultRoot, source)) {
return { sourcePath: source, importPath: source, staged: false, cleanup() {} };
}

const sourceRoot = findContainingProjectsRoot(cwd, source);
if (!sourceRoot) {
throw new Error(`Claude session is outside the configured projects roots: ${source}`);
}

const relative = path.relative(sourceRoot.realRoot, source);
const importPath = path.join(defaultProjects, relative);
fs.mkdirSync(path.dirname(importPath), { recursive: true });
Comment thread
fscfede-beep marked this conversation as resolved.
Outdated

if (fs.existsSync(importPath)) {
throw new Error(`Cannot stage Claude session for Codex because the destination already exists: ${importPath}`);
Comment thread
fscfede-beep marked this conversation as resolved.
Outdated
}

fs.copyFileSync(source, importPath, fs.constants.COPYFILE_EXCL);
const canonicalImportPath = fs.realpathSync(importPath);
return {
sourcePath: source,
importPath: canonicalImportPath,
staged: true,
cleanup() {
try {
fs.unlinkSync(canonicalImportPath);
} catch (error) {
if (error?.code !== "ENOENT") {
throw error;
}
}
}
};
}
49 changes: 48 additions & 1 deletion tests/runtime.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,7 @@ test("transfer delegates the current Claude session directly to native import",
env: {
...buildEnv(binDir),
HOME: home,
USERPROFILE: home,
CODEX_HOME: path.join(home, ".codex"),
CODEX_COMPANION_TRANSCRIPT_PATH: sourcePath
}
Expand All @@ -244,6 +245,50 @@ test("transfer delegates the current Claude session directly to native import",
);
});

test("transfer supports CLAUDE_CONFIG_DIR and stages a temporary default-root copy", () => {
const home = makeTempDir();
const repo = path.join(home, "repo");
const binDir = makeTempDir();
const claudeConfigDir = path.join(home, ".claude-work");
const projectDir = path.join(claudeConfigDir, "projects", "-repo");
const sourcePath = path.join(projectDir, "session-alt-root.jsonl");
fs.mkdirSync(repo, { recursive: true });
fs.mkdirSync(projectDir, { recursive: true });
installFakeCodex(binDir);
initGitRepo(repo);
fs.writeFileSync(
sourcePath,
`${JSON.stringify({ type: "user", cwd: repo, message: { role: "user", content: "Transfer from relocated config." } })}\n`,
"utf8"
);

const result = run("node", [SCRIPT, "transfer", "--json"], {
cwd: repo,
env: {
...buildEnv(binDir),
HOME: home,
USERPROFILE: home,
CODEX_HOME: path.join(home, ".codex"),
CLAUDE_CONFIG_DIR: claudeConfigDir,
CODEX_COMPANION_TRANSCRIPT_PATH: sourcePath
}
});

assert.equal(result.status, 0, result.stderr);
const payload = JSON.parse(result.stdout);
const original = fs.realpathSync(sourcePath);
assert.equal(payload.sourcePath, original);

const fakeState = JSON.parse(fs.readFileSync(path.join(binDir, "fake-codex-state.json"), "utf8"));
const importedPath = fakeState.lastExternalAgentImport.sourcePath;
const defaultProjects = path.join(home, ".claude", "projects");
const relativeImport = path.relative(defaultProjects, importedPath);
assert.equal(relativeImport.startsWith("..") || path.isAbsolute(relativeImport), false);
assert.notEqual(importedPath, original);
assert.equal(fs.existsSync(importedPath), false);
assert.equal(fs.existsSync(original), true);
});

test("transfer reports an actionable upgrade error when native import is unsupported", () => {
const home = makeTempDir();
const repo = path.join(home, "repo");
Expand All @@ -265,6 +310,7 @@ test("transfer reports an actionable upgrade error when native import is unsuppo
env: {
...buildEnv(binDir),
HOME: home,
USERPROFILE: home,
CODEX_HOME: path.join(home, ".codex")
}
});
Expand Down Expand Up @@ -295,6 +341,7 @@ test("transfer fails visibly when native import completes without a ledger recor
env: {
...buildEnv(binDir),
HOME: home,
USERPROFILE: home,
CODEX_HOME: path.join(home, ".codex")
}
});
Expand All @@ -320,7 +367,7 @@ test("transfer rejects sources outside the Claude projects directory", () => {

const result = run("node", [SCRIPT, "transfer", "--source", sourcePath], {
cwd: repo,
env: { ...buildEnv(binDir), HOME: home }
env: { ...buildEnv(binDir), HOME: home, USERPROFILE: home }
});

assert.notEqual(result.status, 0);
Expand Down