Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
d691ac1
Keep Claude's MCP approval keys out of the Codex hooks file
anessbelbati Oct 7, 2026
ac912d7
Keep text files LF on Windows checkouts and read CRLF workflow and sk…
anessbelbati Oct 7, 2026
1fbf521
Accept the desktop connection record on Windows
anessbelbati Oct 7, 2026
a9066af
Find and start Claude Code and Codex on Windows
anessbelbati Oct 7, 2026
411685c
Retry the atomic project write while a reader holds the file on Windows
anessbelbati Oct 7, 2026
7750b69
Write a data root that reads back unchanged from a Windows path
anessbelbati Oct 7, 2026
2aeb16a
Detect Windows command launchers in doctor
anessbelbati Oct 7, 2026
3d49ec2
Stop asserting POSIX file modes and path spellings in tests on Windows
anessbelbati Oct 7, 2026
c573c08
Read what an agent writes to the desktop hook and MCP bridge on Windows
anessbelbati Oct 7, 2026
0a5800d
Install Claude Code from an app that was started in PowerShell 7
anessbelbati Oct 7, 2026
bc2617e
Follow a command launcher that forwards to another launcher
anessbelbati Oct 7, 2026
2f88961
Name the Windows FFmpeg install in doctor
anessbelbati Oct 7, 2026
3b1a0d1
Say only what holds about file privacy on Windows
anessbelbati Oct 7, 2026
36702f6
Cover a launcher chain with the launchers Yarn writes
anessbelbati Oct 7, 2026
2f85915
Write each agent's hook for the shell that runs it on Windows
anessbelbati Oct 7, 2026
65ac65e
Wait out a read that overlaps a project delete on Windows
anessbelbati Oct 7, 2026
ffcfb72
Keep one token expiry for the whole desktop sign-in test
anessbelbati Oct 7, 2026
fdd2390
Run the checks on Windows in CI
anessbelbati Oct 7, 2026
a2102ed
Say what CI shows on Windows
anessbelbati Oct 7, 2026
e93fa7b
Merge remote-tracking branch 'upstream/main' into fix/windows-support
anessbelbati Oct 12, 2026
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
6 changes: 6 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# The Markdown and JSON loaders, and the tests that read source files, match
# "\n". Git for Windows checks text out as CRLF unless told otherwise.
* text=auto eol=lf

# The vendored editor keeps the bytes it has upstream.
opencut/** -text
18 changes: 18 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,21 @@ jobs:
name: video-fs-source
path: dist/source/video-fs
include-hidden-files: true
# The browser suite stays on Linux: on a hosted Windows runner the dev server
# compiles slowly enough that the first test can outrun its two-minute limit.
verify-windows:
runs-on: windows-latest
timeout-minutes: 30
env:
NEXT_TELEMETRY_DISABLED: "1"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
- run: npm ci
- run: node node_modules/electron/install.js
- run: npm run check
- run: npm run setup
- run: npm run build
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ See [validation and limitations](docs/release-validation.md).
## Quickstart

Requirements: **Node.js 22.13+**, npm, and Git. macOS is the primary desktop
platform; the local web app can also run on Linux. Windows is not yet verified.
platform; the local web app can also run on Linux. On Windows the checks, setup,
and production build pass in CI; the desktop app is not yet verified by hand.

```bash
git clone https://github.com/Openpod/impractical-video.git
Expand Down Expand Up @@ -83,8 +84,9 @@ copies already added to projects intact.
| Explore character examples, environments, styles, and videos | Internet access; the public Impractical catalog needs no account or API key |
| Hosted accounts, billing and shared catalogs | A separately configured hosted deployment |

Install FFmpeg with `brew install ffmpeg` on macOS or `sudo apt install ffmpeg`
on Debian/Ubuntu. Restart the app after changing environment variables.
Install FFmpeg with `brew install ffmpeg` on macOS, `sudo apt install ffmpeg`
on Debian/Ubuntu, or `winget install Gyan.FFmpeg` on Windows. Restart the app
after changing environment variables.
Generation is not offline; prompts and selected media are sent to the provider
when you request generation. Purchasing Impractical credits explicitly connects
Desktop to the hosted account, checkout and generation service.
Expand Down
73 changes: 64 additions & 9 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,24 +294,27 @@ function contextHookLauncherForProject(mcp, projectId, agent) {
};
}

function hookConfig(launcher) {
const command = [launcher.command, ...launcher.args]
.map(shellQuote)
.join(" ");
function hookConfig(launcher, agent) {
return `${JSON.stringify(
{
// Auto-trust the project's own .mcp.json so users never see the CLI's
// 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: [
{
hooks: [
{
command,
...hookCommand([launcher.command, ...launcher.args], agent),
timeout: 5,
type: "command",
},
Expand All @@ -324,6 +328,19 @@ function hookConfig(launcher) {
)}\n`;
}

/** Both agents hand a hook command to sh, except on Windows. There Codex uses
* PowerShell, and Claude Code uses Git Bash, or PowerShell when Git is not
* installed, so no one spelling of a command line runs under both. Claude Code
* 2.1.139 and later start a hook that lists its arguments directly, with no
* shell; an earlier release skips such a hook and sends the prompt without it. */
function hookCommand(words, agent) {
if (process.platform !== "win32") {
return { command: words.map(shellQuote).join(" ") };
}
if (agent === "codex") return { command: powershellCommand(words) };
return { args: words.slice(1), command: words[0] };
}

/** Existing projects may predate the explicit MCP server allowlist. Merge it
* in ONLY when the settings file is our own earlier output (identified by the
* desktop's context-hook command) — user-authored settings are never touched,
Expand Down Expand Up @@ -362,6 +379,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 Expand Up @@ -747,6 +792,16 @@ function shellQuote(value) {
return `'${value.replaceAll("'", "'\"'\"'")}'`;
}

/** PowerShell reads a line that starts with a quoted string as a value, not as
* a program to run, unless the call operator comes first. */
function powershellCommand(words) {
return `& ${words
.map((word) =>
/^[A-Za-z0-9_-]+$/.test(word) ? word : `'${word.replaceAll("'", "''")}'`,
)
.join(" ")}`;
}

function tomlString(value) {
return JSON.stringify(value);
}
Expand Down
87 changes: 78 additions & 9 deletions desktop/agent-setup.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ test("packaging manifest includes the self-contained setup helper", async () =>
assert.ok(manifest.build.files.includes("agent-context-hook.mjs"));
assert.ok(manifest.build.files.includes("setup-agent.mjs"));
assert.ok(manifest.build.files.includes("main.mjs"));
assert.ok(manifest.build.files.includes("standard-input.mjs"));
assert.ok(
manifest.build.extraResources.some(
(entry) => entry.from === "../skills" && entry.to === "skills",
Expand All @@ -67,14 +68,15 @@ test("fresh project receives Claude and Codex project-local configuration at mod
assert.deepEqual(result.codexProjectRoot, { status: "created" });
assert.equal(await readlink(path.join(input.projectRoot, "AGENTS.md")), "VIDEO_FS_AGENT_GUIDE.md");
assert.equal(await readlink(path.join(input.projectRoot, "CLAUDE.md")), "AGENTS.md");
assert.equal((await stat(path.join(input.projectRoot, "VIDEO_FS_AGENT_GUIDE.md"))).mode & 0o777, 0o600);
for (const relativePath of [
"VIDEO_FS_AGENT_GUIDE.md",
".mcp.json",
".claude/settings.json",
".codex/config.toml",
".codex/hooks.json",
]) {
assert.equal((await stat(path.join(input.projectRoot, relativePath))).mode & 0o777, 0o600);
const mode = (await stat(path.join(input.projectRoot, relativePath))).mode & 0o777;
if (process.platform !== "win32") assert.equal(mode, 0o600);
}
assert.match(
await readFile(path.join(input.projectRoot, "AGENTS.md"), "utf8"),
Expand Down Expand Up @@ -179,6 +181,50 @@ 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 },
);
// The command the desktop wrote earlier is still today's, except on
// Windows, where Codex needs it written for PowerShell.
if (process.platform !== "win32") 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 All @@ -192,7 +238,8 @@ test("fresh project becomes a valid Codex project root without user Git setup",
"rev-parse",
"--show-toplevel",
]);
assert.equal(stdout.trim(), await realpath(input.projectRoot));
// Git prints forward slashes on every platform.
assert.equal(path.resolve(stdout.trim()), await realpath(input.projectRoot));
await assert.rejects(
() =>
execFileAsync("git", [
Expand Down Expand Up @@ -347,17 +394,39 @@ test("both generated launchers bind exactly the opened project", async () => {
const codexHooks = JSON.parse(
await readFile(path.join(input.projectRoot, ".codex", "hooks.json"), "utf8"),
);
const claudeCommand =
claudeHooks.hooks.UserPromptSubmit[0].hooks[0].command;
const claudeHook = claudeHooks.hooks.UserPromptSubmit[0].hooks[0];
const codexCommand = codexHooks.hooks.UserPromptSubmit[0].hooks[0].command;
assert.match(
claudeCommand,
/--agent-context-hook --agent claude --project-id bound-project$/,
);
assert.match(
codexCommand,
/--agent-context-hook --agent codex --project-id bound-project$/,
);
if (process.platform === "win32") {
// No shell that runs a hook on Windows reads the sh spelling: Codex gets
// the command written for PowerShell, and Claude Code the program with its
// arguments as a list, which it starts without a shell.
assert.equal(
codexCommand,
`& '${launcher}' --agent-context-hook --agent codex --project-id bound-project`,
);
assert.deepEqual(claudeHook, {
args: [
"--agent-context-hook",
"--agent",
"claude",
"--project-id",
"bound-project",
],
command: launcher,
timeout: 5,
type: "command",
});
return;
}
const claudeCommand = claudeHook.command;
assert.match(
claudeCommand,
/--agent-context-hook --agent claude --project-id bound-project$/,
);
assert.equal(
claudeCommand.replace("--agent claude", "--agent codex"),
codexCommand,
Expand Down
2 changes: 1 addition & 1 deletion desktop/auth-session-store.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ test("desktop refresh credentials are encrypted, private, restorable, and cleara
const disk = await readFile(filePath);
assert.equal(disk.includes(Buffer.from(refreshToken)), false);
assert.notEqual(disk.toString("utf8"), refreshToken);
assert.equal((await stat(filePath)).mode & 0o777, 0o600);
if (process.platform !== "win32") assert.equal((await stat(filePath)).mode & 0o777, 0o600);
assert.equal(await readDesktopAuthSession(filePath, safeStorage), refreshToken);
await clearDesktopAuthSession(filePath);
assert.equal(await readDesktopAuthSession(filePath, safeStorage), null);
Expand Down
Loading