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
45 changes: 40 additions & 5 deletions desktop/agent-setup.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,8 @@ export async function setupAgentProject({ projectId: rawProjectId, state }) {
`args = ${tomlStringArray(launcher.args)}`,
"",
].join("\n");
const claudeHookConfig = hookConfig(claudeHookLauncher);
const codexHookConfig = hookConfig(codexHookLauncher);
const claudeHookConfig = hookConfig(claudeHookLauncher, "claude");
const codexHookConfig = hookConfig(codexHookLauncher, "codex");
const guide = agentGuide(projectId, codexProjectRoot);

for (const output of [
Expand Down Expand Up @@ -109,6 +109,7 @@ export async function setupAgentProject({ projectId: rawProjectId, state }) {
claudeHookConfig,
);
await enableProjectMcpServers(path.join(projectRoot, ".claude", "settings.json"));
await removeClaudeKeysFromCodexHooks(path.join(projectRoot, ".codex", "hooks.json"));
const codexHookResult = await writePrivateFileIfAbsent(
path.join(projectRoot, ".codex", "hooks.json"),
codexHookConfig,
Expand Down Expand Up @@ -293,7 +294,7 @@ function contextHookLauncherForProject(mcp, projectId, agent) {
};
}

function hookConfig(launcher) {
function hookConfig(launcher, agent) {
const command = [launcher.command, ...launcher.args]
.map(shellQuote)
.join(" ");
Expand All @@ -303,8 +304,14 @@ function hookConfig(launcher) {
// interactive "approve this MCP server" prompt — the desktop wrote that
// config itself, so approval is implicit. Current Claude releases need
// the explicit server allowlist even when the blanket legacy flag is set.
enableAllProjectMcpServers: true,
enabledMcpjsonServers: ["video-fs"],
// Codex loads no hooks at all from a file with a top-level key it does
// not know, so these stay out of its file.
...(agent === "claude"
? {
enableAllProjectMcpServers: true,
enabledMcpjsonServers: ["video-fs"],
}
: {}),
hooks: {
UserPromptSubmit: [
{
Expand Down Expand Up @@ -362,6 +369,34 @@ async function enableProjectMcpServers(settingsPath) {
}
}

/** Earlier desktop output put Claude's two MCP approval keys into the Codex
* hooks file as well, and Codex loads no hooks from a file with a top-level
* key it does not know. Remove them ONLY from our own earlier output, under
* the same test as above. */
async function removeClaudeKeysFromCodexHooks(hooksPath) {
try {
const parsed = JSON.parse(await readFile(hooksPath, "utf8"));
if (!parsed || typeof parsed !== "object") return;
const keys = Object.keys(parsed);
const appOwned =
keys.every(
(key) =>
key === "hooks" ||
key === "enableAllProjectMcpServers" ||
key === "enabledMcpjsonServers",
) &&
JSON.stringify(parsed.hooks ?? {}).includes("--agent-context-hook");
if (!appOwned || keys.every((key) => key === "hooks")) return;
await writeFile(
hooksPath,
`${JSON.stringify({ hooks: parsed.hooks }, null, 2)}\n`,
{ mode: 0o600 },
);
} catch {
// Missing or hand-mangled hooks stay untouched.
}
}

async function writePrivateFileIfAbsent(filePath, contents) {
const existing = await readFile(filePath, "utf8").catch((error) => {
if (error?.code === "ENOENT") return null;
Expand Down
43 changes: 43 additions & 0 deletions desktop/agent-setup.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,49 @@ test("legacy app-owned Claude settings gain the explicit Video FS approval", asy
assert.deepEqual(settings.enabledMcpjsonServers, ["video-fs"]);
});

test("legacy app-owned Codex hooks lose the Claude keys that make Codex reject the file", async () => {
const input = await fixture("legacy-codex-hooks");
const hooks = {
UserPromptSubmit: [
{
hooks: [
{
command:
"'/Applications/Video FS.app/Contents/MacOS/Video FS' --agent-context-hook --agent codex --project-id legacy-codex-hooks",
timeout: 5,
type: "command",
},
],
},
],
};
await mkdir(path.join(input.projectRoot, ".codex"));
await writeFile(
path.join(input.projectRoot, ".codex", "hooks.json"),
`${JSON.stringify(
{
enableAllProjectMcpServers: true,
enabledMcpjsonServers: ["video-fs"],
hooks,
},
null,
2,
)}\n`,
"utf8",
);

const result = await setupAgentProject(input);

assert.deepEqual(
JSON.parse(
await readFile(path.join(input.projectRoot, ".codex", "hooks.json"), "utf8"),
),
{ hooks },
);
// With the two keys gone the file is today's output, so nothing conflicts.
assert.deepEqual(result.conflicts, []);
});

test("fresh project becomes a valid Codex project root without user Git setup", async () => {
const input = await fixture("codex-root");
const first = await setupAgentProject(input);
Expand Down
2 changes: 2 additions & 0 deletions desktop/codex-hook-compatibility.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,8 @@ test("installed token-free Codex UserPromptSubmit launcher freezes and injects e
"utf8",
),
);
// Codex loads no hooks from a file with a top-level key it does not know.
assert.deepEqual(Object.keys(hooks), ["hooks"]);
const command = hooks.hooks.UserPromptSubmit[0].hooks[0].command;
assert.equal(
command,
Expand Down