Skip to content
Open
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
9 changes: 1 addition & 8 deletions plugins/codex/scripts/codex-companion.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
runAppServerTurn
} from "./lib/codex.mjs";
import { readStdinIfPiped } from "./lib/fs.mjs";
import { readTaskPrompt } from "./lib/task-prompt.mjs";
import { collectReviewContext, ensureGitRepository, resolveReviewTarget } from "./lib/git.mjs";
import { binaryAvailable, terminateProcessTree } from "./lib/process.mjs";
import { loadPromptTemplate, interpolateTemplate } from "./lib/prompts.mjs";
Expand Down Expand Up @@ -610,14 +611,6 @@ function buildTaskRequest({ cwd, model, effort, prompt, write, resumeLast, jobId
};
}

function readTaskPrompt(cwd, options, positionals) {
if (options["prompt-file"]) {
return fs.readFileSync(path.resolve(cwd, options["prompt-file"]), "utf8");
}

const positionalPrompt = positionals.join(" ");
return positionalPrompt || readStdinIfPiped();
}

function requireTaskRequest(prompt, resumeLast) {
if (!prompt && !resumeLast) {
Expand Down
42 changes: 42 additions & 0 deletions plugins/codex/scripts/lib/task-prompt.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import fs from "node:fs";
import path from "node:path";

import { readStdinIfPiped } from "./fs.mjs";

export function readTaskPrompt(cwd, options, positionals) {
if (options["prompt-file"]) {
return readPromptFile(cwd, options["prompt-file"]);
}
const positionalPrompt = positionals.join(" ");
return positionalPrompt || readStdinIfPiped();
}

function readPromptFile(cwd, promptFileOption) {
const realCwd = fs.realpathSync(cwd);
const resolved = path.resolve(realCwd, promptFileOption);

// Resolve symlinks. If the file does not yet exist, fall back to resolving
// the parent directory and recombining — this preserves the existing
// behavior where readFileSync would throw a clear ENOENT.
let realResolved;
try {
realResolved = fs.realpathSync(resolved);
} catch (error) {
if (error && error.code === "ENOENT") {
const parent = path.dirname(resolved);
const realParent = fs.realpathSync(parent);
realResolved = path.join(realParent, path.basename(resolved));
} else {
throw error;
}
}

const rel = path.relative(realCwd, realResolved);
if (rel !== "" && (rel.startsWith("..") || path.isAbsolute(rel))) {
throw new Error(
`--prompt-file must be a path inside ${realCwd} (got ${realResolved})`
);
}

return fs.readFileSync(realResolved, "utf8");
}
71 changes: 71 additions & 0 deletions tests/task-prompt.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import fs from "node:fs";
import path from "node:path";
import test from "node:test";
import assert from "node:assert/strict";

import { readTaskPrompt } from "../plugins/codex/scripts/lib/task-prompt.mjs";
import { makeTempDir } from "./helpers.mjs";

test("happy path — relative prompt-file inside cwd is read and returned", () => {
const cwd = makeTempDir();
fs.writeFileSync(path.join(cwd, "prompt.txt"), "hello from file");
const result = readTaskPrompt(cwd, { "prompt-file": "prompt.txt" }, []);
assert.equal(result, "hello from file");
});

test("happy path — absolute path inside cwd is accepted", () => {
const cwd = makeTempDir();
const filePath = path.join(cwd, "prompt.txt");
fs.writeFileSync(filePath, "absolute path content");
const result = readTaskPrompt(cwd, { "prompt-file": filePath }, []);
assert.equal(result, "absolute path content");
});

test("rejection — relative path escaping cwd throws", () => {
const inside = makeTempDir();
const outside = makeTempDir();
fs.writeFileSync(path.join(outside, "secret.txt"), "secret");
const outsideBase = path.basename(outside);
assert.throws(
() => readTaskPrompt(inside, { "prompt-file": `../${outsideBase}/secret.txt` }, []),
/must be (a path )?inside/i
);
});

test("rejection — absolute path outside cwd throws", () => {
const inside = makeTempDir();
const outside = makeTempDir();
const secretPath = path.join(outside, "secret.txt");
fs.writeFileSync(secretPath, "secret");
assert.throws(
() => readTaskPrompt(inside, { "prompt-file": secretPath }, []),
/must be (a path )?inside/i
);
});

test("rejection — symlink inside cwd pointing outside cwd throws", {
skip: process.platform === "win32"
}, () => {
const inside = makeTempDir();
const outside = makeTempDir();
fs.writeFileSync(path.join(outside, "secret.txt"), "secret");
fs.symlinkSync(path.join(outside, "secret.txt"), path.join(inside, "link.txt"));
assert.throws(
() => readTaskPrompt(inside, { "prompt-file": "link.txt" }, []),
/must be (a path )?inside/i
);
});

test("fallback — no prompt-file uses positional args joined with space", () => {
const cwd = makeTempDir();
const result = readTaskPrompt(cwd, {}, ["hello", "world"]);
assert.equal(result, "hello world");
});

test("fallback — no prompt-file and no positionals returns empty string when stdin is a TTY", {
skip: !process.stdin.isTTY
}, () => {
const cwd = makeTempDir();
const result = readTaskPrompt(cwd, {}, []);
assert.equal(result, "");
});