From d691ac1b0a3cda5a548ed5643617efdceaffc7a4 Mon Sep 17 00:00:00 2001 From: anessbelbati <102622182+anessbelbati@users.noreply.github.com> Date: Wed, 7 Oct 2026 04:48:44 +0100 Subject: [PATCH 01/19] Keep Claude's MCP approval keys out of the Codex hooks file Setup wrote the same JSON to .claude/settings.json and .codex/hooks.json, including enableAllProjectMcpServers and enabledMcpjsonServers. Codex accepts only "description" and "hooks" at the top of a hooks file, and with any other key it loads no hooks from the file at all: failed to parse hooks config .../.codex/hooks.json: unknown field `enableAllProjectMcpServers`, expected `description` or `hooks` at line 2 column 30 So the context hook never ran under Codex, and every prompt went out without the Video FS context. Measured with Codex 0.160.1 against a stand-in model server, on macOS and on Linux: 0 hooks loaded from the file as written and no context in the model request; with the two keys gone, 1 hook loaded, it ran, and the context was in the request. The two keys now go into Claude's file only. Setup also removes them from a .codex/hooks.json that is the desktop's own earlier output, under the same ownership test enableProjectMcpServers already uses, so an existing project recovers the next time it is opened. A file the user wrote is left alone. --- desktop/agent-setup.mjs | 45 ++++++++++++++++++++--- desktop/agent-setup.test.mjs | 43 ++++++++++++++++++++++ desktop/codex-hook-compatibility.test.mjs | 2 + 3 files changed, 85 insertions(+), 5 deletions(-) diff --git a/desktop/agent-setup.mjs b/desktop/agent-setup.mjs index ed572f5..7f2c3de 100644 --- a/desktop/agent-setup.mjs +++ b/desktop/agent-setup.mjs @@ -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 [ @@ -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, @@ -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(" "); @@ -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: [ { @@ -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; diff --git a/desktop/agent-setup.test.mjs b/desktop/agent-setup.test.mjs index 95bf0b8..2b0a74e 100644 --- a/desktop/agent-setup.test.mjs +++ b/desktop/agent-setup.test.mjs @@ -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); diff --git a/desktop/codex-hook-compatibility.test.mjs b/desktop/codex-hook-compatibility.test.mjs index d87fc55..2030367 100644 --- a/desktop/codex-hook-compatibility.test.mjs +++ b/desktop/codex-hook-compatibility.test.mjs @@ -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, From ac912d77a688ba3d43547ede09210c1d39401936 Mon Sep 17 00:00:00 2001 From: anessbelbati <102622182+anessbelbati@users.noreply.github.com> Date: Wed, 7 Oct 2026 02:00:36 +0100 Subject: [PATCH 02/19] Keep text files LF on Windows checkouts and read CRLF workflow and skill files --- .gitattributes | 6 ++++++ lib/skills.ts | 3 ++- lib/workflows.ts | 3 ++- scripts/source-files.mjs | 2 +- tests/workflows.test.ts | 42 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 53 insertions(+), 3 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..f6eaf28 --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/lib/skills.ts b/lib/skills.ts index 181f51b..d22dcb7 100644 --- a/lib/skills.ts +++ b/lib/skills.ts @@ -84,7 +84,8 @@ function skillIdFromPath(filePath: string) { } async function readSkillFile(filePath: string): Promise { - const content = await readFile(filePath, "utf8"); + // A CRLF checkout or editor must not hide the frontmatter fence. + const content = (await readFile(filePath, "utf8")).replace(/\r\n/g, "\n"); const { frontmatter, body } = splitFrontmatter(content); const id = yamlValue(frontmatter, "name") || skillIdFromPath(filePath); const description = yamlValue(frontmatter, "description"); diff --git a/lib/workflows.ts b/lib/workflows.ts index b685a27..bd25938 100644 --- a/lib/workflows.ts +++ b/lib/workflows.ts @@ -64,7 +64,8 @@ function splitFrontmatter(content: string) { } async function readWorkflowFile(filePath: string): Promise { - const content = await readFile(filePath, "utf8"); + // A CRLF checkout or editor must not hide the frontmatter fence. + const content = (await readFile(filePath, "utf8")).replace(/\r\n/g, "\n"); const { frontmatter, body } = splitFrontmatter(content); const id = yamlValue(frontmatter, "name") || diff --git a/scripts/source-files.mjs b/scripts/source-files.mjs index 6c58945..d4732e5 100644 --- a/scripts/source-files.mjs +++ b/scripts/source-files.mjs @@ -5,7 +5,7 @@ import path from "node:path"; // test renders and an upstream reference repository. None belong in a release. const directories = [".github", "app", "components", "database", "desktop", "docs/images", "lib", "modal", "opencut", "public", "scripts/video_tracking", "skills", "src", "supabase", "templates", "tests", "types", "workflows"]; const files = [ - ".env.example", ".env.local.example", ".gitignore", ".nvmrc", ".vercelignore", + ".env.example", ".env.local.example", ".gitattributes", ".gitignore", ".nvmrc", ".vercelignore", "LICENSE", "NOTICE", "THIRD_PARTY_NOTICES.md", "README.md", "CONTRIBUTING.md", "SECURITY.md", "CLAUDE.md", "AGENTS.md", "package.json", "package-lock.json", "next.config.ts", "next-env.d.ts", "tsconfig.json", "middleware.ts", "postcss.config.mjs", "eslint.config.mjs", "vitest.config.ts", "trigger.config.ts", "playwright.config.ts", diff --git a/tests/workflows.test.ts b/tests/workflows.test.ts index 696772e..47a7550 100644 --- a/tests/workflows.test.ts +++ b/tests/workflows.test.ts @@ -1,3 +1,6 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { listSkillSummaries } from "@/lib/skills"; import { listWorkflowSummaries, readWorkflow } from "@/lib/workflows"; @@ -113,4 +116,43 @@ describe("workflow loader contract", () => { ); expect(services.ensureCurrentAppUser).toHaveBeenCalledOnce(); }); + + it("reads workflow and skill frontmatter from files with CRLF line endings", async () => { + const root = await mkdtemp(path.join(tmpdir(), "video-fs-crlf-")); + const crlf = (lines: string[]) => lines.join("\r\n"); + await mkdir(path.join(root, "workflows")); + await mkdir(path.join(root, "skills", "windows-checkout"), { recursive: true }); + await writeFile(path.join(root, "workflows", "windows-checkout.md"), crlf([ + "---", "name: windows-checkout", "title: Windows Checkout", "category: Testing", + "description: Loads from a CRLF working tree.", "---", "", "# Method", "", + ])); + await writeFile(path.join(root, "skills", "windows-checkout", "SKILL.md"), crlf([ + "---", "name: windows-checkout", "description: >-", " Folded text", " on two lines.", + "tags: [windows, checkout]", "---", "", "# Skill", "", + ])); + vi.resetModules(); + const cwd = vi.spyOn(process, "cwd").mockReturnValue(root); + try { + const [workflows, skills] = await Promise.all([import("@/lib/workflows"), import("@/lib/skills")]); + expect(await workflows.listWorkflowSummaries()).toEqual([{ + category: "Testing", + description: "Loads from a CRLF working tree.", + id: "windows-checkout", + path: "workflows/windows-checkout.md", + title: "Windows Checkout", + use_when: "Loads from a CRLF working tree.", + }]); + expect(await skills.listSkillSummaries()).toEqual([{ + description: "Folded text on two lines.", + id: "windows-checkout", + path: "skills/windows-checkout/SKILL.md", + tags: ["windows", "checkout"], + use_when_summary: "Folded text on two lines.", + }]); + expect((await workflows.readWorkflow("windows-checkout")).content).not.toContain("\r"); + } finally { + cwd.mockRestore(); + await rm(root, { recursive: true, force: true }); + } + }); }); From 1fbf521a38e7b0e2c58a2c1e83c07be9570a81b4 Mon Sep 17 00:00:00 2001 From: anessbelbati <102622182+anessbelbati@users.noreply.github.com> Date: Wed, 7 Oct 2026 02:00:36 +0100 Subject: [PATCH 03/19] Accept the desktop connection record on Windows --- desktop/connection-state.mjs | 5 ++++- desktop/security.test.mjs | 6 +++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/desktop/connection-state.mjs b/desktop/connection-state.mjs index 5c902bc..6cbb372 100644 --- a/desktop/connection-state.mjs +++ b/desktop/connection-state.mjs @@ -58,7 +58,10 @@ export async function readPrivateConnectionState(filePath, { requireLivePid = tr if (!info?.isFile()) { throw new Error(desktopUnavailableMessage()); } - if ((info.mode & 0o077) !== 0) { + // Windows has no group/other mode bits: Node reports 0666 for every writable + // file, so this check could never pass there. The record's privacy comes from + // the access list of the per-user folder it is written into. + if (process.platform !== "win32" && (info.mode & 0o077) !== 0) { throw new Error("Video FS Desktop connection record permissions must be 0600."); } if (typeof process.getuid === "function" && info.uid !== process.getuid()) { diff --git a/desktop/security.test.mjs b/desktop/security.test.mjs index adb2d02..36bef55 100644 --- a/desktop/security.test.mjs +++ b/desktop/security.test.mjs @@ -146,13 +146,17 @@ test("private state is atomically replaced at mode 0600 and stale records are re token: "a".repeat(43), }; await writePrivateConnectionState(filePath, base); - assert.equal((await stat(filePath)).mode & 0o777, 0o600); + if (process.platform !== "win32") assert.equal((await stat(filePath)).mode & 0o777, 0o600); assert.equal((await readPrivateConnectionState(filePath)).token, base.token); const rotated = { ...base, token: "b".repeat(43) }; await writePrivateConnectionState(filePath, rotated); assert.equal(JSON.parse(await readFile(filePath, "utf8")).token, rotated.token); + // The record written by the app must stay readable where mode bits do not exist. + assert.equal((await readPrivateConnectionState(filePath)).token, rotated.token); + // Windows has no group or other mode bits to reject. + if (process.platform === "win32") return; await chmod(filePath, 0o644); await assert.rejects(() => readPrivateConnectionState(filePath), /permissions must be 0600/); }); From a9066afa7bdd5d89a8469656a23e534b56c66aa8 Mon Sep 17 00:00:00 2001 From: anessbelbati <102622182+anessbelbati@users.noreply.github.com> Date: Wed, 7 Oct 2026 02:00:37 +0100 Subject: [PATCH 04/19] Find and start Claude Code and Codex on Windows --- lib/agent-binaries.ts | 184 +++++++++++++++++++++++++++++++---- lib/agent-connect.ts | 143 ++++++++++++++++++++------- lib/canvas-composer-cli.ts | 17 +++- lib/companion-session.ts | 18 ++-- tests/agent-binaries.test.ts | 179 +++++++++++++++++++++++++++++++++- tests/agent-connect.test.ts | 77 +++++++++++++++ 6 files changed, 552 insertions(+), 66 deletions(-) create mode 100644 tests/agent-connect.test.ts diff --git a/lib/agent-binaries.ts b/lib/agent-binaries.ts index 785c2ee..081269b 100644 --- a/lib/agent-binaries.ts +++ b/lib/agent-binaries.ts @@ -1,37 +1,185 @@ import "server-only"; +import { spawn, type ChildProcess } from "node:child_process"; +import { constants, existsSync, readFileSync } from "node:fs"; import { access, stat } from "node:fs/promises"; -import { constants } from "node:fs"; import { homedir } from "node:os"; import path from "node:path"; import { userAgentCliEnv } from "@/lib/agent-cli-env"; +/** What Windows can start directly or through cmd.exe. An npm install also + * leaves an extension-less sh script next to its .cmd launcher; that one is + * for Git Bash and must never be picked. */ +const WINDOWS_STARTABLE = new Set([".com", ".exe", ".bat", ".cmd"]); + +/** Windows has one PATH whatever its spelling, but a copied environment keeps + * the key it was copied with (usually `Path`). When a copy holds two spellings, + * take the one Node hands to a child process: the first in sorted order. */ +function environmentValue(environment: NodeJS.ProcessEnv, name: string, platform: NodeJS.Platform) { + if (platform !== "win32") return environment[name]; + const key = Object.keys(environment).sort().find((candidate) => candidate.toUpperCase() === name); + return key === undefined ? undefined : environment[key]; +} + /** Finder-launched apps do not inherit an interactive shell's PATH. Use the * same search environment for detection, authentication and actual execution. */ -export function localAgentCliEnv(source: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { - const home = source.HOME || homedir(); +export function localAgentCliEnv( + source: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform, +): NodeJS.ProcessEnv { + const windows = platform === "win32"; + const delimiter = windows ? ";" : ":"; + const home = environmentValue(source, windows ? "USERPROFILE" : "HOME", platform) || homedir(); const directories = [ - ...(source.PATH || "").split(path.delimiter), - path.join(home, ".local", "bin"), path.join(home, ".volta", "bin"), - path.join(home, ".npm-global", "bin"), path.join(home, ".npm", "bin"), - "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", + ...(environmentValue(source, "PATH", platform) || "").split(delimiter), + path.join(home, ".local", "bin"), + ...(windows + // A running app does not see the PATH entry an installer has just added. + // Rooted POSIX directories are left out: on Windows they would resolve + // against the current drive, where any local account may create them. + ? [path.join(environmentValue(source, "APPDATA", platform) || path.join(home, "AppData", "Roaming"), "npm")] + : [ + path.join(home, ".volta", "bin"), + path.join(home, ".npm-global", "bin"), path.join(home, ".npm", "bin"), + "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", + ]), ].filter(Boolean); - return { ...userAgentCliEnv(source), PATH: [...new Set(directories)].join(path.delimiter) }; + const environment = userAgentCliEnv(source); + if (windows) { + for (const key of Object.keys(environment)) { + if (key.toUpperCase() === "PATH") delete environment[key]; + } + } + return { ...environment, PATH: [...new Set(directories)].join(delimiter) }; +} + +function candidateNames(requested: string, environment: NodeJS.ProcessEnv, platform: NodeJS.Platform) { + if (platform !== "win32") return [requested]; + if (WINDOWS_STARTABLE.has(path.extname(requested).toLowerCase())) return [requested]; + const extensions = (environmentValue(environment, "PATHEXT", platform) || "") + .split(";") + .map((extension) => extension.trim().toLowerCase()) + .filter((extension) => WINDOWS_STARTABLE.has(extension)); + return (extensions.length ? extensions : [".com", ".exe", ".bat", ".cmd"]).map((extension) => `${requested}${extension}`); +} + +/** Finds a command the way the platform's own shell would: PATH order, and on + * Windows the PATHEXT extensions inside each directory. */ +export async function resolveCommand( + requested: string, + environment = localAgentCliEnv(), + platform: NodeJS.Platform = process.platform, +): Promise { + const windows = platform === "win32"; + const names = candidateNames(requested, environment, platform); + const directories = path.isAbsolute(requested) + ? [null] + : (environmentValue(environment, "PATH", platform) || "").split(windows ? ";" : ":").filter(Boolean); + for (const directory of directories) { + for (const name of names) { + const candidate = directory === null ? name : path.join(directory, name); + try { + // Windows has no execute bit: there the extension decides what can start. + if (!windows) await access(candidate, constants.X_OK); + if ((await stat(candidate)).isFile()) return candidate; + } catch { /* Try the next installation location. */ } + } + } + return null; } export async function resolveAgentBinary( agent: "claude" | "codex", override?: string, environment = localAgentCliEnv(), + platform: NodeJS.Platform = process.platform, ): Promise { - const requested = override?.trim() || agent; - const candidates = path.isAbsolute(requested) - ? [requested] - : (environment.PATH || "").split(path.delimiter).filter(Boolean).map((directory) => path.join(directory, requested)); - for (const candidate of candidates) { - try { - await access(candidate, constants.X_OK); - if ((await stat(candidate)).isFile()) return candidate; - } catch { /* Try the next installation location. */ } + return resolveCommand(override?.trim() || agent, environment, platform); +} + +export type AgentCliLaunch = { + args: string[]; + command: string; + /** Set when the arguments are already one cmd.exe command line. */ + windowsVerbatimArguments?: boolean; +}; + +/** What a package manager's .cmd launcher forwards to: the script or program + * beside it, and the interpreter it is run with ("node", null when the target + * runs by itself, "other" for anything else). Such a launcher ends in + * ` "%dp0%\" %*`. Where that line appears twice, the last + * is the one that runs when the launcher's own directory has no node.exe. */ +export function commandShimTarget(shim: string): { interpreter: string | null; target: string } | null { + let found: { interpreter: string | null; target: string } | null = null; + for (const line of shim.split(/\r?\n/)) { + const forwards = /^(.*?)"%(?:~dp0|dp0%)\\?([^"]+)"\s+%\*\s*$/.exec(line); + if (!forwards) continue; + const program = forwards[1].trim().replace(/^@/, ""); + const named = program.endsWith('"%_prog%"') + ? /SET "_prog=([A-Za-z0-9_.-]+)"/i.exec(shim)?.[1] ?? "other" + : program; + const interpreter = named === "" || named.endsWith("&") + ? null + : /(?:^|[\\"\s])node(?:\.exe)?"?$/i.test(named) ? "node" : "other"; + found = { interpreter, target: forwards[2] }; } - return null; + return found; +} + +/** Turns a resolved command into what can actually be spawned. + * + * Node refuses to start .cmd and .bat files without a shell, and cmd.exe + * cannot carry a prompt safely: it splits on `&`, expands `%NAME%` and stops at + * the first newline. A package manager's launcher only forwards to a script or + * a program, so start that directly and every argument arrives unchanged. Any + * other batch file goes through cmd.exe, and only with arguments that have no + * meaning to it. */ +export function agentCliLaunch( + file: string, + args: string[], + environment: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform, +): AgentCliLaunch { + const extension = path.extname(file).toLowerCase(); + if (platform !== "win32" || (extension !== ".cmd" && extension !== ".bat")) { + return { args, command: file }; + } + const directory = path.dirname(file); + let shim: ReturnType = null; + try { + shim = commandShimTarget(readFileSync(file, "utf8")); + } catch { /* Unreadable launchers take the cmd.exe route below. */ } + const target = shim ? path.join(directory, ...shim.target.split(/[\\/]+/)) : null; + if (shim && target && existsSync(target)) { + if (shim.interpreter === "node") { + const bundledNode = path.join(directory, "node.exe"); + return { args: [target, ...args], command: existsSync(bundledNode) ? bundledNode : "node" }; + } + if (shim.interpreter === null && /\.(?:exe|com)$/i.test(target)) { + return { args, command: target }; + } + } + if (/["%]/.test(file) || !args.every((argument) => /^[A-Za-z0-9@_.:=+/\\-]+$/.test(argument))) { + throw new Error( + `${path.basename(file)} is a batch file that cannot receive this command safely. Use the program it starts instead.`, + ); + } + return { + args: ["/d", "/s", "/c", `""${file}"${args.map((argument) => ` ${argument}`).join("")}"`], + command: environmentValue(environment, "COMSPEC", platform) || "cmd.exe", + windowsVerbatimArguments: true, + }; +} + +/** Stops an agent CLI and whatever it started. A Windows kill ends only the + * one process it names, and the npm launchers run the real program as a child. */ +export function stopAgentCli(child: ChildProcess, signal: NodeJS.Signals = "SIGTERM") { + if (process.platform !== "win32" || !child.pid) { + child.kill(signal); + return; + } + spawn( + path.join(process.env.SystemRoot || "C:\\Windows", "System32", "taskkill.exe"), + ["/pid", String(child.pid), "/t", "/f"], + { stdio: "ignore", windowsHide: true }, + ).on("error", () => child.kill(signal)); } diff --git a/lib/agent-connect.ts b/lib/agent-connect.ts index c9b1ae6..31d34ed 100644 --- a/lib/agent-connect.ts +++ b/lib/agent-connect.ts @@ -1,8 +1,15 @@ import "server-only"; import { spawn, execFile, type ChildProcess } from "node:child_process"; +import path from "node:path"; import { promisify } from "node:util"; -import { localAgentCliEnv, resolveAgentBinary } from "@/lib/agent-binaries"; +import { + agentCliLaunch, + localAgentCliEnv, + resolveAgentBinary, + resolveCommand, + stopAgentCli, +} from "@/lib/agent-binaries"; const execFileAsync = promisify(execFile); @@ -35,6 +42,11 @@ const store = globalThis as typeof globalThis & { const jobs = store.__videoFsAgentConnectJobs ?? new Map(); store.__videoFsAgentConnectJobs = jobs; +const AGENT_NAMES: Record = { + claude: "Claude Code", + codex: "Codex", +}; + /** Fresh installs land in paths a GUI-launched app may not have. */ function connectEnv() { return localAgentCliEnv(); @@ -44,36 +56,57 @@ async function binaryAvailable(binary: ConnectAgent) { return Boolean(await resolveAgentBinary(binary)); } +function windowsSystemProgram(...segments: string[]) { + return path.join(process.env.SystemRoot || "C:\\Windows", "System32", ...segments); +} + +const STATUS_COMMANDS: Record = { + claude: ["auth", "status"], + codex: ["login", "status"], +}; + /** Signed-in check through each CLI's own status command. */ export async function agentSignedIn(agent: ConnectAgent) { try { - if (agent === "claude") { - const { stdout } = await execFileAsync( - "/usr/bin/env", - ["claude", "auth", "status"], - { env: connectEnv(), timeout: 8000 }, - ); - const parsed = JSON.parse(stdout.trim()) as { loggedIn?: boolean }; - return parsed.loggedIn === true; - } - await execFileAsync("/usr/bin/env", ["codex", "login", "status"], { - env: connectEnv(), + const environment = connectEnv(); + const binary = await resolveAgentBinary(agent, undefined, environment); + if (!binary) return false; + const launch = agentCliLaunch(binary, STATUS_COMMANDS[agent], environment); + const { stdout } = await execFileAsync(launch.command, launch.args, { + env: environment, timeout: 8000, + windowsHide: true, + windowsVerbatimArguments: launch.windowsVerbatimArguments, }); - return true; + if (agent === "codex") return true; + const parsed = JSON.parse(stdout.trim()) as { loggedIn?: boolean }; + return parsed.loggedIn === true; } catch { return false; } } -const INSTALL_COMMANDS: Record = { - claude: ["/bin/bash", ["-c", "curl -fsSL https://claude.ai/install.sh | bash"]], - codex: ["/usr/bin/env", ["npm", "install", "-g", "@openai/codex"]], -}; +/** Each provider's own installer: Claude's script for the platform, Codex from npm. */ +async function installCommand( + agent: ConnectAgent, + environment: NodeJS.ProcessEnv, +): Promise<[string, string[]]> { + if (agent === "claude") { + return process.platform === "win32" + ? [ + windowsSystemProgram("WindowsPowerShell", "v1.0", "powershell.exe"), + ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", "irm https://claude.ai/install.ps1 | iex"], + ] + : ["/bin/bash", ["-c", "curl -fsSL https://claude.ai/install.sh | bash"]]; + } + const npm = await resolveCommand("npm", environment); + if (!npm) throw new Error("npm was not found. Install Node.js, then connect Codex again."); + return [npm, ["install", "-g", "@openai/codex"]]; +} const LOGIN_COMMANDS: Record = { - claude: ["claude", "auth", "login", "--claudeai"], - codex: ["codex", "login"], + claude: ["auth", "login", "--claudeai"], + codex: ["login"], }; function setStage(job: ConnectJob, stage: ConnectStage, detail?: string | null) { @@ -81,13 +114,55 @@ function setStage(job: ConnectJob, stage: ConnectStage, detail?: string | null) job.detail = detail ?? null; } -function startLogin(job: ConnectJob) { - setStage(job, "awaiting_browser"); - const child = spawn("/usr/bin/env", LOGIN_COMMANDS[job.agent], { - env: connectEnv(), +/** Opens the provider's sign-in page in the default browser. The address is + * handed over as a single argument and never passes through a shell. */ +function openInBrowser(url: string) { + const [command, args]: [string, string[]] = + process.platform === "win32" + ? [windowsSystemProgram("rundll32.exe"), ["url.dll,FileProtocolHandler", url]] + : process.platform === "darwin" + ? ["open", [url]] + : ["xdg-open", [url]]; + spawn(command, args, { stdio: "ignore", windowsHide: true }).on("error", () => {}); +} + +/** Starts a CLI command for a job. Resolving the program is asynchronous, so a + * cancel or a newer job may have taken over by the time it is found. */ +async function startJobProcess( + job: ConnectJob, + stage: ConnectStage, + resolve: (environment: NodeJS.ProcessEnv) => Promise<[string, string[]]>, +) { + const environment = connectEnv(); + const [command, args] = await resolve(environment); + if (jobs.get(job.agent) !== job || job.stage !== stage) return null; + const launch = agentCliLaunch(command, args, environment); + const child = spawn(launch.command, launch.args, { + env: environment, stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + windowsVerbatimArguments: launch.windowsVerbatimArguments, }); job.child = child; + return child; +} + +async function startLogin(job: ConnectJob) { + setStage(job, "awaiting_browser"); + let child: ChildProcess | null; + try { + child = await startJobProcess(job, "awaiting_browser", async (environment) => { + const binary = await resolveAgentBinary(job.agent, undefined, environment); + if (!binary) { + throw new Error(`${AGENT_NAMES[job.agent]} could not be found after installing. Restart the app and connect again.`); + } + return [binary, LOGIN_COMMANDS[job.agent]]; + }); + } catch (error) { + setStage(job, "error", error instanceof Error ? error.message : "Sign-in could not start."); + return; + } + if (!child) return; let output = ""; let opened = false; const scan = (chunk: string) => { @@ -98,7 +173,7 @@ function startLogin(job: ConnectJob) { const url = /https:\/\/[^\s"'\])]+/.exec(chunk)?.[0]; if (url && /login|auth|oauth|authorize|sso/i.test(url)) { opened = true; - spawn("open", [url], { stdio: "ignore" }).on("error", () => {}); + openInBrowser(url); } }; child.stdout?.setEncoding("utf8"); @@ -146,17 +221,19 @@ export async function startAgentConnect(agent: ConnectAgent) { setStage(job, "connected"); return agentConnectStatus(agent); } - startLogin(job); + await startLogin(job); return agentConnectStatus(agent); } setStage(job, "installing"); - const [command, args] = INSTALL_COMMANDS[agent]; - const child = spawn(command, args, { - env: connectEnv(), - stdio: ["ignore", "pipe", "pipe"], - }); - job.child = child; + let child: ChildProcess | null; + try { + child = await startJobProcess(job, "installing", (environment) => installCommand(agent, environment)); + } catch (error) { + setStage(job, "error", error instanceof Error ? error.message : "The installer could not start."); + return agentConnectStatus(agent); + } + if (!child) return agentConnectStatus(agent); let output = ""; const capture = (chunk: string) => { output = `${output}${chunk}`.slice(-8000); @@ -173,7 +250,7 @@ export async function startAgentConnect(agent: ConnectAgent) { job.child = null; if (job.stage === "idle") return; // cancelled if (code === 0) { - startLogin(job); + void startLogin(job); } else { setStage( job, @@ -189,7 +266,7 @@ export function cancelAgentConnect(agent: ConnectAgent) { const job = jobs.get(agent); if (!job) return false; setStage(job, "idle"); - job.child?.kill("SIGTERM"); + if (job.child) stopAgentCli(job.child); job.child = null; return true; } diff --git a/lib/canvas-composer-cli.ts b/lib/canvas-composer-cli.ts index 80f6de4..3cad2d2 100644 --- a/lib/canvas-composer-cli.ts +++ b/lib/canvas-composer-cli.ts @@ -1,5 +1,5 @@ import { spawn } from "node:child_process"; -import { localAgentCliEnv, resolveAgentBinary } from "@/lib/agent-binaries"; +import { agentCliLaunch, localAgentCliEnv, resolveAgentBinary, stopAgentCli } from "@/lib/agent-binaries"; /** How the local composer reaches the reasoner: the user's own Claude Code or * Codex CLI, headless, cwd-bound to the project so the desktop hooks inject @@ -90,20 +90,27 @@ export async function runCliComposer({ ); } const { agent, binary } = resolved; - const args = agent === "claude" ? claudeArgs(prompt) : codexArgs(prompt); + const environment = localAgentCliEnv(); + const launch = agentCliLaunch( + binary, + agent === "claude" ? claudeArgs(prompt) : codexArgs(prompt), + environment, + ); return await new Promise((resolve, reject) => { - const child = spawn(binary, args, { + const child = spawn(launch.command, launch.args, { cwd, - env: localAgentCliEnv(), + env: environment, stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + windowsVerbatimArguments: launch.windowsVerbatimArguments, }); const toolNamesById = new Map(); let finalText = ""; let stderrTail = ""; let buffered = ""; const timeout = setTimeout(() => { - child.kill("SIGKILL"); + stopAgentCli(child, "SIGKILL"); reject(new Error("The local agent run timed out.")); }, CLI_TIMEOUT_MS); diff --git a/lib/companion-session.ts b/lib/companion-session.ts index fe436ad..2c711a2 100644 --- a/lib/companion-session.ts +++ b/lib/companion-session.ts @@ -6,7 +6,7 @@ import { EventEmitter } from "node:events"; import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; import path from "node:path"; import { AGENT_CHARTER } from "@/lib/agent-charter"; -import { localAgentCliEnv, resolveAgentBinary } from "@/lib/agent-binaries"; +import { agentCliLaunch, localAgentCliEnv, resolveAgentBinary } from "@/lib/agent-binaries"; import { ensureAgentContextRoot } from "@/lib/agent-context/paths"; import { COMPANION_MODELS, @@ -476,7 +476,8 @@ async function spawnSession( const storePath = await companionStorePath(projectId); const mcpConfigPath = path.join(projectDirectory, ".mcp.json"); const resumeSessionId = carryover?.resumeSessionId ?? null; - const child = spawn( + const environment = localAgentCliEnv(); + const launch = agentCliLaunch( binary, [ "-p", @@ -506,12 +507,15 @@ async function spawnSession( model, ...(resumeSessionId ? ["--resume", resumeSessionId] : []), ], - { - cwd: projectDirectory, - env: localAgentCliEnv(), - stdio: ["pipe", "pipe", "pipe"], - }, + environment, ); + const child = spawn(launch.command, launch.args, { + cwd: projectDirectory, + env: environment, + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + windowsVerbatimArguments: launch.windowsVerbatimArguments, + }); const session: CompanionSession = { agent: "claude", busy: false, diff --git a/tests/agent-binaries.test.ts b/tests/agent-binaries.test.ts index 180fe45..537c6ea 100644 --- a/tests/agent-binaries.test.ts +++ b/tests/agent-binaries.test.ts @@ -1,15 +1,85 @@ +import { execFile } from "node:child_process"; import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { promisify } from "node:util"; import { afterEach, describe, expect, it, vi } from "vitest"; vi.mock("server-only", () => ({})); -import { localAgentCliEnv, resolveAgentBinary } from "@/lib/agent-binaries"; +import { agentCliLaunch, commandShimTarget, localAgentCliEnv, resolveAgentBinary } from "@/lib/agent-binaries"; +const execFileAsync = promisify(execFile); const directories: string[] = []; afterEach(async () => { await Promise.all(directories.splice(0).map(dir => rm(dir, { recursive: true, force: true }))); }); +// These three launchers are copied from real installs on Windows. + +/** `npm install -g @openai/codex`: a Node script behind the launcher. */ +const NPM_NODE_LAUNCHER = [ + "@ECHO off", + "GOTO start", + ":find_dp0", + "SET dp0=%~dp0", + "EXIT /b", + ":start", + "SETLOCAL", + "CALL :find_dp0", + "", + 'IF EXIST "%dp0%\\node.exe" (', + ' SET "_prog=%dp0%\\node.exe"', + ") ELSE (", + ' SET "_prog=node"', + " SET PATHEXT=%PATHEXT:;.JS;=;%", + ")", + "", + 'endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\\node_modules\\@openai\\codex\\bin\\codex.js" %*', + "", +].join("\r\n"); + +/** `npm install -g @anthropic-ai/claude-code`: a program behind the launcher. */ +const NPM_PROGRAM_LAUNCHER = [ + "@ECHO off", + "GOTO start", + ":find_dp0", + "SET dp0=%~dp0", + "EXIT /b", + ":start", + "SETLOCAL", + "CALL :find_dp0", + '"%dp0%\\node_modules\\@anthropic-ai\\claude-code\\bin\\claude.exe" %*', + "", +].join("\r\n"); + +/** npm's own launcher works out its script while it runs. */ +const NPM_OWN_LAUNCHER = [ + ":: Created by npm, please don't edit manually.", + "@ECHO OFF", + "", + "SETLOCAL", + "", + 'SET "NODE_EXE=%~dp0\\node.exe"', + 'IF NOT EXIST "%NODE_EXE%" (', + ' SET "NODE_EXE=node"', + ")", + "", + 'SET "NPM_PREFIX_JS=%~dp0\\node_modules\\npm\\bin\\npm-prefix.js"', + 'SET "NPM_CLI_JS=%~dp0\\node_modules\\npm\\bin\\npm-cli.js"', + "FOR /F \"delims=\" %%F IN ('CALL \"%NODE_EXE%\" \"%NPM_PREFIX_JS%\"') DO (", + ' SET "NPM_PREFIX_NPM_CLI_JS=%%F\\node_modules\\npm\\bin\\npm-cli.js"', + ")", + 'IF EXIST "%NPM_PREFIX_NPM_CLI_JS%" (', + ' SET "NPM_CLI_JS=%NPM_PREFIX_NPM_CLI_JS%"', + ")", + "", + '"%NODE_EXE%" "%NPM_CLI_JS%" %*', + "", +].join("\r\n"); + +const windowsEnvironment = { ComSpec: "C:\\Windows\\system32\\cmd.exe", NODE_ENV: "test" } as const; + describe("agent installation discovery", () => { - it("finds a native install even when the GUI PATH only contains system directories", async () => { + // The two POSIX cases need a POSIX host: Windows has no execute bit, and its + // paths hold the ":" that separates PATH entries here. + it.skipIf(process.platform === "win32")("finds a native install even when the GUI PATH only contains system directories", async () => { const home = await mkdtemp(path.join(os.tmpdir(), "video-fs-agent-path-")); directories.push(home); const directory = path.join(home, ".local", "bin"); await mkdir(directory, { recursive: true }); @@ -22,7 +92,7 @@ describe("agent installation discovery", () => { expect(environment.PATH).toContain(path.join(home, ".volta", "bin")); }); - it("respects an executable override and does not claim a missing or nonexecutable override is installed", async () => { + it.skipIf(process.platform === "win32")("respects an executable override and does not claim a missing or nonexecutable override is installed", async () => { const directory = await mkdtemp(path.join(os.tmpdir(), "video-fs-agent-override-")); directories.push(directory); expect(await resolveAgentBinary("codex", directory)).toBeNull(); const binary = path.join(directory, "custom-codex"); @@ -32,4 +102,107 @@ describe("agent installation discovery", () => { await chmod(binary, 0o755); expect(await resolveAgentBinary("codex", binary)).toBe(binary); }); + + it("finds Windows installs by extension in the folders their installers use", async () => { + const home = await mkdtemp(path.join(os.tmpdir(), "video-fs-agent-windows-")); directories.push(home); + const system = path.join(home, "System32"); + const native = path.join(home, ".local", "bin"); + const npm = path.join(home, "AppData", "Roaming", "npm"); + for (const directory of [system, native, npm]) await mkdir(directory, { recursive: true }); + await writeFile(path.join(native, "claude.exe"), ""); + // npm leaves a shell script for Git Bash beside each launcher. Windows cannot start it. + await writeFile(path.join(npm, "codex"), "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + await writeFile(path.join(npm, "codex.cmd"), NPM_NODE_LAUNCHER); + const environment = localAgentCliEnv({ + APPDATA: path.join(home, "AppData", "Roaming"), FAL_KEY: "private-fixture", NODE_ENV: "test", + Path: system, PATHEXT: ".COM;.EXE;.BAT;.CMD;.VBS;.JS", USERPROFILE: home, + }, "win32"); + // Nothing rooted at "/" is added: Windows would read it against the current drive. + expect(environment.PATH).toBe([system, native, npm].join(";")); + expect(environment.Path).toBeUndefined(); + expect(environment.FAL_KEY).toBeUndefined(); + expect(await resolveAgentBinary("claude", undefined, environment, "win32")).toBe(path.join(native, "claude.exe")); + expect(await resolveAgentBinary("codex", undefined, environment, "win32")).toBe(path.join(npm, "codex.cmd")); + }); + + it("accepts a Windows override with or without its extension", async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "video-fs-agent-override-")); directories.push(directory); + const binary = path.join(directory, "custom-codex.exe"); + expect(await resolveAgentBinary("codex", binary, windowsEnvironment, "win32")).toBeNull(); + await writeFile(binary, ""); + expect(await resolveAgentBinary("codex", binary, windowsEnvironment, "win32")).toBe(binary); + expect(await resolveAgentBinary("codex", path.join(directory, "custom-codex"), windowsEnvironment, "win32")).toBe(binary); + expect(await resolveAgentBinary("codex", directory, windowsEnvironment, "win32")).toBeNull(); + }); +}); + +describe("agent launch on Windows", () => { + it("reads which program a package manager's launcher starts", () => { + expect(commandShimTarget(NPM_NODE_LAUNCHER)).toEqual({ interpreter: "node", target: "node_modules\\@openai\\codex\\bin\\codex.js" }); + expect(commandShimTarget(NPM_PROGRAM_LAUNCHER)).toEqual({ interpreter: null, target: "node_modules\\@anthropic-ai\\claude-code\\bin\\claude.exe" }); + expect(commandShimTarget('@"%~dp0\\node.exe" "%~dp0\\node_modules\\tool\\cli.js" %*\r\n')).toEqual({ interpreter: "node", target: "node_modules\\tool\\cli.js" }); + expect(commandShimTarget('@python "%~dp0\\tool.py" %*\r\n')).toEqual({ interpreter: "other", target: "tool.py" }); + expect(commandShimTarget(NPM_OWN_LAUNCHER)).toBeNull(); + }); + + it("starts the program behind a launcher instead of the batch file", async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "video-fs-agent-launcher-")); directories.push(directory); + const prompt = ["exec", "--json", 'Line one\nLine "two" & %PATH%']; + + const codex = path.join(directory, "codex.cmd"); + const script = path.join(directory, "node_modules", "@openai", "codex", "bin", "codex.js"); + await writeFile(codex, NPM_NODE_LAUNCHER); + await mkdir(path.dirname(script), { recursive: true }); + await writeFile(script, ""); + expect(agentCliLaunch(codex, prompt, windowsEnvironment, "win32")).toEqual({ args: [script, ...prompt], command: "node" }); + // A Node installed beside the launcher is the one the launcher itself picks. + const bundledNode = path.join(directory, "node.exe"); + await writeFile(bundledNode, ""); + expect(agentCliLaunch(codex, prompt, windowsEnvironment, "win32")).toEqual({ args: [script, ...prompt], command: bundledNode }); + + const claude = path.join(directory, "claude.cmd"); + const program = path.join(directory, "node_modules", "@anthropic-ai", "claude-code", "bin", "claude.exe"); + await writeFile(claude, NPM_PROGRAM_LAUNCHER); + await mkdir(path.dirname(program), { recursive: true }); + await writeFile(program, ""); + expect(agentCliLaunch(claude, prompt, windowsEnvironment, "win32")).toEqual({ args: prompt, command: program }); + + // A program needs no launcher, and other platforms start whatever was found. + expect(agentCliLaunch(program, prompt, windowsEnvironment, "win32")).toEqual({ args: prompt, command: program }); + expect(agentCliLaunch(codex, prompt, windowsEnvironment, "linux")).toEqual({ args: prompt, command: codex }); + }); + + it("sends any other batch file through cmd.exe, and only with arguments cmd.exe leaves alone", async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "video-fs-agent-batch-")); directories.push(directory); + const npm = path.join(directory, "npm.cmd"); + await writeFile(npm, NPM_OWN_LAUNCHER); + expect(agentCliLaunch(npm, ["install", "-g", "@openai/codex"], windowsEnvironment, "win32")).toEqual({ + args: ["/d", "/s", "/c", `""${npm}" install -g @openai/codex"`], + command: "C:\\Windows\\system32\\cmd.exe", + windowsVerbatimArguments: true, + }); + for (const unsafe of ["two words", "a&b", "%PATH%", 'say "hi"', "line\nbreak", "caret^", ""]) { + expect(() => agentCliLaunch(npm, ["exec", unsafe], windowsEnvironment, "win32")).toThrow(/cannot receive this command safely/); + } + // A launcher whose program is gone is treated like any other batch file. + const codex = path.join(directory, "codex.cmd"); + await writeFile(codex, NPM_NODE_LAUNCHER); + expect(() => agentCliLaunch(codex, ["exec", "two words"], windowsEnvironment, "win32")).toThrow(/cannot receive this command safely/); + }); + + it("hands a prompt to the program behind a launcher unchanged", async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "video-fs-agent-prompt-")); directories.push(directory); + const script = path.join(directory, "node_modules", "@openai", "codex", "bin", "codex.js"); + await mkdir(path.dirname(script), { recursive: true }); + await writeFile(script, "process.stdout.write(JSON.stringify(process.argv.slice(2)));\n"); + const launcher = path.join(directory, "codex.cmd"); + await writeFile(launcher, NPM_NODE_LAUNCHER); + const args = ["exec", 'Line one\nLine "two" & more', "%PATH% ^ | < > !x!", "trailing\\", ""]; + const launch = agentCliLaunch(launcher, args, process.env, "win32"); + const { stdout } = await execFileAsync(launch.command, launch.args, { + encoding: "utf8", + windowsVerbatimArguments: launch.windowsVerbatimArguments, + }); + expect(JSON.parse(stdout)).toEqual(args); + }, 30_000); }); diff --git a/tests/agent-connect.test.ts b/tests/agent-connect.test.ts new file mode 100644 index 0000000..10f8e25 --- /dev/null +++ b/tests/agent-connect.test.ts @@ -0,0 +1,77 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +vi.mock("server-only", () => ({})); +import { agentSignedIn } from "@/lib/agent-connect"; + +const directories: string[] = []; +afterEach(async () => { + vi.unstubAllEnvs(); + await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); +}); + +// On Windows the two kinds of batch file an install can sit behind are started +// in different ways. Elsewhere a CLI is one executable file. +type Install = "batch file" | "executable" | "npm launcher"; +const installs: Install[] = process.platform === "win32" ? ["npm launcher", "batch file"] : ["executable"]; + +/** Puts a stand-in CLI first on PATH. It answers the one command it expects + * and fails any other, so a command line that arrives changed shows up. */ +async function standIn(name: "claude" | "codex", install: Install, command: string[], reply: string, exitCode: number) { + const directory = await mkdtemp(path.join(os.tmpdir(), "video-fs-agent-status-")); + directories.push(directory); + const script = path.join(directory, "node_modules", name, "bin", `${name}.js`); + await mkdir(path.dirname(script), { recursive: true }); + await writeFile(script, [ + `if (JSON.stringify(process.argv.slice(2)) !== ${JSON.stringify(JSON.stringify(command))}) process.exit(64);`, + `process.stdout.write(${JSON.stringify(reply)});`, + `process.exitCode = ${exitCode};`, + "", + ].join("\n")); + const target = `node_modules\\${name}\\bin\\${name}.js`; + if (install === "executable") { + await writeFile(path.join(directory, name), `#!/bin/sh\nexec node "${script}" "$@"\n`, { mode: 0o755 }); + } else if (install === "npm launcher") { + await writeFile(path.join(directory, `${name}.cmd`), [ + "@ECHO off", + "GOTO start", + ":find_dp0", + "SET dp0=%~dp0", + "EXIT /b", + ":start", + "SETLOCAL", + "CALL :find_dp0", + "", + 'IF EXIST "%dp0%\\node.exe" (', + ' SET "_prog=%dp0%\\node.exe"', + ") ELSE (", + ' SET "_prog=node"', + " SET PATHEXT=%PATHEXT:;.JS;=;%", + ")", + "", + `endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\\${target}" %*`, + "", + ].join("\r\n")); + } else { + await writeFile(path.join(directory, `${name}.cmd`), ["@ECHO off", `node "%~dp0${target}" %1 %2`, "EXIT /b %ERRORLEVEL%", ""].join("\r\n")); + } + vi.stubEnv("PATH", `${directory}${path.delimiter}${process.env.PATH ?? ""}`); +} + +describe.each(installs)("agent sign-in status from an installed %s", (install) => { + it("believes Claude Code's own answer", async () => { + await standIn("claude", install, ["auth", "status"], JSON.stringify({ loggedIn: true }), 0); + expect(await agentSignedIn("claude")).toBe(true); + // A signed-out Claude Code prints its answer and exits with 1. + await standIn("claude", install, ["auth", "status"], JSON.stringify({ loggedIn: false }), 1); + expect(await agentSignedIn("claude")).toBe(false); + }, 30_000); + + it("takes Codex's exit code as its answer", async () => { + await standIn("codex", install, ["login", "status"], "Logged in using ChatGPT\n", 0); + expect(await agentSignedIn("codex")).toBe(true); + await standIn("codex", install, ["login", "status"], "Not logged in\n", 1); + expect(await agentSignedIn("codex")).toBe(false); + }, 30_000); +}); From 411685c2db62ca79b8a9103fc2f20ffd88cbac90 Mon Sep 17 00:00:00 2001 From: anessbelbati <102622182+anessbelbati@users.noreply.github.com> Date: Wed, 7 Oct 2026 02:00:37 +0100 Subject: [PATCH 05/19] Retry the atomic project write while a reader holds the file on Windows --- lib/workspace.ts | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/lib/workspace.ts b/lib/workspace.ts index 88eb3cb..a84c319 100644 --- a/lib/workspace.ts +++ b/lib/workspace.ts @@ -214,13 +214,32 @@ export async function ensureProjectsRoot() { await mkdir(DATA_ROOT, { recursive: true }); } +/** Windows refuses to replace a file while another handle to it is open, so a + * read that overlaps the rename fails the write with EPERM. Readers and virus + * scanners let go within milliseconds: wait for the gap instead of losing the + * write. */ +async function renameOverReaders(source: string, target: string) { + const deadline = Date.now() + 10_000; + for (let delay = 5; ; delay = Math.min(delay * 2, 100)) { + try { + await rename(source, target); + return; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + const busy = code === "EPERM" || code === "EACCES" || code === "EBUSY"; + if (process.platform !== "win32" || !busy || Date.now() > deadline) throw error; + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } +} + async function writeAtomicText(target: string, content: string) { // A same-directory rename publishes the complete file in one step. Reads // during background work must never see writeFile's truncate/write window. const temporary = path.join(path.dirname(target), `.video-fs-write-${randomUUID()}.tmp`); try { await writeFile(temporary, content, { encoding: "utf8", flag: "wx", mode: 0o600 }); - await rename(temporary, target); + await renameOverReaders(temporary, target); } finally { await rm(temporary, { force: true }); } From 7750b6973e86ab33a363f490b4bddcaea0336ecc Mon Sep 17 00:00:00 2001 From: anessbelbati <102622182+anessbelbati@users.noreply.github.com> Date: Wed, 7 Oct 2026 02:00:37 +0100 Subject: [PATCH 06/19] Write a data root that reads back unchanged from a Windows path --- desktop/local-release.test.mjs | 38 ++++++++++++++++++++++++++++++++-- scripts/setup-local.mjs | 11 +++++++++- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/desktop/local-release.test.mjs b/desktop/local-release.test.mjs index 66ed31b..2dd44db 100644 --- a/desktop/local-release.test.mjs +++ b/desktop/local-release.test.mjs @@ -1,12 +1,27 @@ import assert from "node:assert/strict"; -import { copyFile, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { copyFile, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import test from "node:test"; +import nextEnv from "@next/env"; import { setupLocal } from "../scripts/setup-local.mjs"; import { desktopCloudConfiguration } from "./cloud-config.mjs"; import { writePrivateConnectionState } from "./connection-state.mjs"; +/** Reads the data root the way the app, the doctor and the desktop shell do. + * The loader skips names the process already has, so start without it. */ +function loadedDataRoot(root) { + delete process.env.VIDEO_FS_DATA_ROOT; + const before = new Set(Object.keys(process.env)); + try { + return nextEnv.loadEnvConfig(root, true, { info() {}, error() {} }, true).parsedEnv?.VIDEO_FS_DATA_ROOT; + } finally { + for (const key of Object.keys(process.env)) { + if (!before.has(key)) delete process.env[key]; + } + } +} + test("fresh setup creates a private local environment and preserves existing credentials", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "video-fs-setup-")); try { @@ -17,7 +32,7 @@ test("fresh setup creates a private local environment and preserves existing cre assert.match(contents, /^APP_MODE=local$/m); assert.match(contents, /^NEXT_PUBLIC_DESKTOP_CLOUD_ENABLED=false$/m); assert.match(contents, /^PAPER_MCP_TOKEN=[a-f0-9]{64}$/m); - assert.ok(contents.includes(JSON.stringify(path.join(root, "data", "projects")))); + assert.equal(loadedDataRoot(root), path.join(root, "data", "projects")); if (process.platform !== "win32") assert.equal((await stat(file)).mode & 0o777, 0o600); await writeFile(file, "FAL_KEY=keep-existing-value\n"); assert.equal(await setupLocal(root), false); @@ -27,6 +42,25 @@ test("fresh setup creates a private local environment and preserves existing cre } }); +test("the data root written by setup reads back unchanged from any checkout path", async () => { + const parent = await mkdtemp(path.join(os.tmpdir(), "video-fs-setup-paths-")); + try { + // Env quoting goes wrong at a backslash followed by "n" or "r" and at an + // apostrophe. Windows puts the backslash before every folder name; on other + // systems it has to be part of the name. + const names = ["repos", "new", "it's here", ...(process.platform === "win32" ? [] : ["windows\\repos\\new", "it's\\here"])]; + for (const name of names) { + const root = path.join(parent, name); + await mkdir(root); + await copyFile(new URL("../.env.example", import.meta.url), path.join(root, ".env.example")); + assert.equal(await setupLocal(root), true); + assert.equal(loadedDataRoot(root), path.join(root, "data", "projects")); + } + } finally { + await rm(parent, { recursive: true, force: true }); + } +}); + test("desktop cloud access requires an explicit opt-in and a valid origin", () => { assert.equal(desktopCloudConfiguration({}).enabled, false); assert.deepEqual(desktopCloudConfiguration({ NEXT_PUBLIC_DESKTOP_CLOUD_ENABLED: "true", NEXT_PUBLIC_DESKTOP_CLOUD_URL: "https://studio.example" }), { enabled: true, origin: "https://studio.example" }); diff --git a/scripts/setup-local.mjs b/scripts/setup-local.mjs index c791f0c..4f2307a 100644 --- a/scripts/setup-local.mjs +++ b/scripts/setup-local.mjs @@ -2,11 +2,20 @@ import { randomBytes } from "node:crypto"; import { readFile, writeFile } from "node:fs/promises"; import path from "node:path"; +/** The env loader unescapes nothing inside single quotes or backticks. Inside + * double quotes a Windows path comes back with every backslash doubled, and + * with a line break wherever a folder name starts with "n" or "r". */ +function envValue(value) { + if (!/[\\"]/.test(value)) return JSON.stringify(value); + if (!value.includes("'")) return `'${value}'`; + return value.includes("`") ? value : `\`${value}\``; +} + export async function setupLocal(root = process.cwd()) { const template = await readFile(path.join(root, ".env.example"), "utf8"); const contents = template .replace(/^PAPER_MCP_TOKEN=$/m, `PAPER_MCP_TOKEN=${randomBytes(32).toString("hex")}`) - .replace(/^VIDEO_FS_DATA_ROOT=$/m, `VIDEO_FS_DATA_ROOT=${JSON.stringify(path.join(root, "data", "projects"))}`); + .replace(/^VIDEO_FS_DATA_ROOT=$/m, () => `VIDEO_FS_DATA_ROOT=${envValue(path.join(root, "data", "projects"))}`); try { await writeFile(path.join(root, ".env.local"), contents, { flag: "wx", mode: 0o600 }); return true; From 2aeb16a6c7e410bcfb8d02ca7982e361c356b30e Mon Sep 17 00:00:00 2001 From: anessbelbati <102622182+anessbelbati@users.noreply.github.com> Date: Wed, 7 Oct 2026 02:00:38 +0100 Subject: [PATCH 07/19] Detect Windows command launchers in doctor --- scripts/doctor.mjs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/scripts/doctor.mjs b/scripts/doctor.mjs index dea3845..7028682 100644 --- a/scripts/doctor.mjs +++ b/scripts/doctor.mjs @@ -10,7 +10,11 @@ function check(label, ok, help, required = true) { if (!ok && required) failed = true; } function installed(command, args = ["--version"]) { - return spawnSync(command, args, { stdio: "ignore", timeout: 10_000 }).status === 0; + const options = { stdio: "ignore", timeout: 10_000 }; + if (process.platform !== "win32") return spawnSync(command, args, options).status === 0; + // npm installs Windows commands as .cmd launchers, which only cmd.exe starts. + // No Windows file name holds a double quote, so the quoted name stays one word. + return !command.includes('"') && spawnSync(`"${command}" ${args.join(" ")}`, { ...options, shell: true }).status === 0; } check("Node.js 22.13+", Number(process.versions.node.split(".")[0]) > 22 || (Number(process.versions.node.split(".")[0]) === 22 && Number(process.versions.node.split(".")[1]) >= 13), "Use the Node version in .nvmrc."); check("Git", installed("git"), "Install Git for agent project setup."); From 3d49ec2a4eafb759d39e09fa14836b833aa1b785 Mon Sep 17 00:00:00 2001 From: anessbelbati <102622182+anessbelbati@users.noreply.github.com> Date: Wed, 7 Oct 2026 02:00:38 +0100 Subject: [PATCH 08/19] Stop asserting POSIX file modes and path spellings in tests on Windows --- desktop/agent-setup.test.mjs | 8 ++++-- desktop/auth-session-store.test.mjs | 2 +- desktop/codex-hook-compatibility.test.mjs | 35 ++++++++++++----------- desktop/companion-compose-store.test.mjs | 10 +++---- desktop/companion-window-state.test.mjs | 4 +-- tests/agent-interactions.test.ts | 2 +- tests/local-library.test.ts | 7 +++-- 7 files changed, 38 insertions(+), 30 deletions(-) diff --git a/desktop/agent-setup.test.mjs b/desktop/agent-setup.test.mjs index 2b0a74e..64121e4 100644 --- a/desktop/agent-setup.test.mjs +++ b/desktop/agent-setup.test.mjs @@ -67,14 +67,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"), @@ -235,7 +236,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", [ diff --git a/desktop/auth-session-store.test.mjs b/desktop/auth-session-store.test.mjs index 90e7b11..71a9e52 100644 --- a/desktop/auth-session-store.test.mjs +++ b/desktop/auth-session-store.test.mjs @@ -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); diff --git a/desktop/codex-hook-compatibility.test.mjs b/desktop/codex-hook-compatibility.test.mjs index 2030367..370a7cd 100644 --- a/desktop/codex-hook-compatibility.test.mjs +++ b/desktop/codex-hook-compatibility.test.mjs @@ -10,6 +10,7 @@ import { import os from "node:os"; import path from "node:path"; import test from "node:test"; +import { fileURLToPath } from "node:url"; import electron from "electron"; import { setupAgentProject } from "./agent-setup.mjs"; import { writePrivateConnectionState } from "./connection-state.mjs"; @@ -20,10 +21,15 @@ const token = "codex-hook-compat-private-token-000000000000"; const snapshotId = "turn_codex_compat"; const selectedSha = "a".repeat(64); const contentSha = "b".repeat(64); -const mainPath = path.resolve( - path.dirname(new URL(import.meta.url).pathname), - "main.mjs", -); +const mainPath = fileURLToPath(new URL("./main.mjs", import.meta.url)); + +/** The words a shell runs for a generated hook command. The generator + * single-quotes any word holding a backslash, as every Windows path does. */ +function shellWords(command) { + return command + .match(/(?:[^\s']+|'[^']*')+/g) + .map((word) => word.replaceAll("'", "")); +} async function fixture() { const directory = await mkdtemp( @@ -197,18 +203,15 @@ test("installed token-free Codex UserPromptSubmit launcher freezes and injects e // 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, - [ - electron, - mainPath, - "--agent-context-hook", - "--agent", - "codex", - "--project-id", - projectId, - ].join(" "), - ); + assert.deepEqual(shellWords(command), [ + electron, + mainPath, + "--agent-context-hook", + "--agent", + "codex", + "--project-id", + projectId, + ]); assert.doesNotMatch(command, new RegExp(token)); const result = await runInstalledHook(input); diff --git a/desktop/companion-compose-store.test.mjs b/desktop/companion-compose-store.test.mjs index a1d602c..a21c397 100644 --- a/desktop/companion-compose-store.test.mjs +++ b/desktop/companion-compose-store.test.mjs @@ -39,7 +39,7 @@ test("persists a versioned per-project compose state atomically at mode 0600", a const second = await writeCompanionCompose(directory, "project-a", state); assert.equal(first.changed, true); assert.equal(second.changed, false); - assert.equal((await stat(file)).mode & 0o777, 0o600); + if (process.platform !== "win32") assert.equal((await stat(file)).mode & 0o777, 0o600); assert.equal((await stat(file)).mtimeMs, firstMtime); assert.deepEqual((await readCompanionCompose(directory, "project-a")).state, state); }); @@ -61,10 +61,8 @@ test("stores private attachments by content hash without retaining source paths" "utf8", ); assert.equal(stateText.includes("/Users/person"), false); - assert.equal( - (await stat(path.join(directory, "blobs", stored.hash))).mode & 0o777, - 0o600, - ); + const blobMode = (await stat(path.join(directory, "blobs", stored.hash))).mode & 0o777; + if (process.platform !== "win32") assert.equal(blobMode, 0o600); assert.equal( Buffer.from( (await readCompanionAttachment(directory, "project-a", stored.hash)).data, @@ -117,6 +115,8 @@ test("reports missing, unreadable, and stale private blobs", async () => { /changed/, ); await writeFile(blob, "original"); + // A Windows file mode cannot take read access away. + if (process.platform === "win32") return; await chmod(blob, 0o000); const issue = (await readCompanionCompose(directory, "project-a")).issues[0]; assert.match(issue.message, /unreadable|changed/); diff --git a/desktop/companion-window-state.test.mjs b/desktop/companion-window-state.test.mjs index 4ee5a87..dc2d780 100644 --- a/desktop/companion-window-state.test.mjs +++ b/desktop/companion-window-state.test.mjs @@ -70,7 +70,7 @@ test("persists mode-0600 window/display state", async () => { }; await writeCompanionWindowState(file, state); assert.deepEqual(await readCompanionWindowState(file), state); - assert.equal((await stat(file)).mode & 0o777, 0o600); + if (process.platform !== "win32") assert.equal((await stat(file)).mode & 0o777, 0o600); assert.ok(JSON.parse(await readFile(file, "utf8"))); }); @@ -93,5 +93,5 @@ test("persists only supported per-project models with mode-0600", async () => { "project-a": "opus", "project-b": "haiku", }); - assert.equal((await stat(file)).mode & 0o777, 0o600); + if (process.platform !== "win32") assert.equal((await stat(file)).mode & 0o777, 0o600); }); diff --git a/tests/agent-interactions.test.ts b/tests/agent-interactions.test.ts index e1253b9..88f7136 100644 --- a/tests/agent-interactions.test.ts +++ b/tests/agent-interactions.test.ts @@ -104,7 +104,7 @@ describe("durable agent input and approval requests", () => { "agent-requests", "request_input_1.json", ); - expect((await stat(recordPath)).mode & 0o777).toBe(0o600); + if (process.platform !== "win32") expect((await stat(recordPath)).mode & 0o777).toBe(0o600); expect(JSON.parse(await readFile(recordPath, "utf8"))).toMatchObject({ projectId, question: "Which frame should lead?", diff --git a/tests/local-library.test.ts b/tests/local-library.test.ts index 47ab31b..256af99 100644 --- a/tests/local-library.test.ts +++ b/tests/local-library.test.ts @@ -26,8 +26,11 @@ describe("local Library uploads", () => { expect((await listLocalLibraryUploads()).map(asset => asset.kind).sort()).toEqual(["audio", "document", "image", "image", "video"]); const asset = await localLibraryFile(ids[0]); expect(await readFile(asset.absolutePath, "utf8")).toBe("image"); - expect((await stat(asset.absolutePath)).mode & 0o777).toBe(0o600); - expect((await stat(path.dirname(asset.absolutePath))).mode & 0o777).toBe(0o700); + // Windows keeps no owner-only mode bits; the profile folder's access list does that job there. + if (process.platform !== "win32") { + expect((await stat(asset.absolutePath)).mode & 0o777).toBe(0o600); + expect((await stat(path.dirname(asset.absolutePath))).mode & 0o777).toBe(0o700); + } expect(JSON.stringify(await getLocalLibraryUpload(ids[0]))).not.toContain(temporary); }); From c573c086481c5af46e40cd36744b0d2fd729f509 Mon Sep 17 00:00:00 2001 From: anessbelbati <102622182+anessbelbati@users.noreply.github.com> Date: Wed, 7 Oct 2026 02:34:55 +0100 Subject: [PATCH 09/19] Read what an agent writes to the desktop hook and MCP bridge on Windows --- desktop/agent-setup.test.mjs | 1 + desktop/main.mjs | 10 +-- desktop/mcp-bridge.test.mjs | 129 +++++++++++++++++++++++++++++++++++ desktop/package.json | 1 + desktop/standard-input.mjs | 17 +++++ scripts/paper-mcp.mjs | 3 +- 6 files changed, 156 insertions(+), 5 deletions(-) create mode 100644 desktop/mcp-bridge.test.mjs create mode 100644 desktop/standard-input.mjs diff --git a/desktop/agent-setup.test.mjs b/desktop/agent-setup.test.mjs index 64121e4..6ed5e78 100644 --- a/desktop/agent-setup.test.mjs +++ b/desktop/agent-setup.test.mjs @@ -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", diff --git a/desktop/main.mjs b/desktop/main.mjs index f057ab5..f474069 100644 --- a/desktop/main.mjs +++ b/desktop/main.mjs @@ -36,6 +36,7 @@ import { readPrivateConnectionState, writePrivateConnectionState, } from "./connection-state.mjs"; +import { standardInput } from "./standard-input.mjs"; import { desktopWindowOptions } from "./window-options.mjs"; import { readCompanionWindowState, @@ -277,15 +278,16 @@ if (process.argv.includes("--mcp")) { function readStdin() { return new Promise((resolve, reject) => { let value = ""; - process.stdin.setEncoding("utf8"); - process.stdin.on("data", (chunk) => { + const stdin = standardInput(); + stdin.setEncoding("utf8"); + stdin.on("data", (chunk) => { value += chunk; if (Buffer.byteLength(value) > 256 * 1024) { reject(new Error("Hook input is too large.")); } }); - process.stdin.on("end", () => resolve(value)); - process.stdin.on("error", reject); + stdin.on("end", () => resolve(value)); + stdin.on("error", reject); }); } diff --git a/desktop/mcp-bridge.test.mjs b/desktop/mcp-bridge.test.mjs new file mode 100644 index 0000000..ff57941 --- /dev/null +++ b/desktop/mcp-bridge.test.mjs @@ -0,0 +1,129 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import electron from "electron"; +import { writePrivateConnectionState } from "./connection-state.mjs"; + +const projectId = "mcp-bridge"; +const token = "mcp-bridge-private-token-0000000000000000000"; +const mainPath = fileURLToPath(new URL("./main.mjs", import.meta.url)); + +async function fixture() { + const directory = await mkdtemp(path.join(os.tmpdir(), "video-fs-mcp-bridge-")); + const dataRoot = path.join(directory, "projects"); + const projectRoot = path.join(dataRoot, projectId); + await mkdir(projectRoot, { recursive: true }); + await writeFile( + path.join(projectRoot, "project.json"), + `${JSON.stringify({ id: projectId, name: "MCP bridge" })}\n`, + ); + return { dataRoot, statePath: path.join(directory, "desktop-connection.json") }; +} + +async function startApp() { + const server = createServer((request, response) => { + request.resume(); + response.setHeader("content-type", "application/json"); + response.end("{}"); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + assert.ok(address && typeof address !== "string"); + return { + appUrl: `http://127.0.0.1:${address.port}`, + close: () => { + server.closeAllConnections(); + return new Promise((resolve) => server.close(resolve)); + }, + }; +} + +/** Writes one JSON-RPC request the way an agent does and resolves with the + * reply that carries its id. */ +function request(child, message) { + return new Promise((resolve, reject) => { + let stdout = ""; + let stderr = ""; + const deadline = setTimeout( + () => reject(new Error(`MCP bridge did not reply: ${stderr}`)), + 60_000, + ); + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += chunk; + for (const line of stdout.split("\n").slice(0, -1)) { + let reply; + try { + reply = JSON.parse(line); + } catch { + continue; + } + if (reply?.id !== message.id) continue; + clearTimeout(deadline); + resolve(reply); + } + }); + child.once("error", reject); + child.once("exit", (code) => { + clearTimeout(deadline); + reject(new Error(`MCP bridge exited ${code} before replying: ${stderr}`)); + }); + child.stdin.write(`${JSON.stringify(message)}\n`); + }); +} + +function stop(child) { + if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(); + return new Promise((resolve) => { + const forced = setTimeout(() => child.kill("SIGKILL"), 5_000); + child.once("exit", () => { + clearTimeout(forced); + resolve(); + }); + child.kill(); + }); +} + +test("desktop MCP bridge answers an agent over standard input and output", async () => { + const input = await fixture(); + const app = await startApp(); + await writePrivateConnectionState(input.statePath, { + appUrl: app.appUrl, + dataRoot: input.dataRoot, + mcp: { args: [mainPath, "--mcp"], command: electron }, + pid: process.pid, + startedAt: new Date().toISOString(), + token, + }); + const child = spawn(electron, [mainPath, "--mcp", "--project-id", projectId], { + env: { ...process.env, VIDEO_FS_DESKTOP_STATE_FILE: input.statePath }, + stdio: ["pipe", "pipe", "pipe"], + }); + + try { + const reply = await request(child, { + id: 1, + jsonrpc: "2.0", + method: "initialize", + params: { + capabilities: {}, + clientInfo: { name: "codex", version: "0.0.0" }, + protocolVersion: "2025-06-18", + }, + }); + assert.equal(reply.error, undefined); + assert.equal(reply.result.serverInfo.name, "video-fs-paper-mode"); + } finally { + await stop(child); + await app.close(); + } +}); diff --git a/desktop/package.json b/desktop/package.json index 1acf477..30cc625 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -27,6 +27,7 @@ "connection-state.mjs", "auth-session-store.mjs", "project-binding.mjs", + "standard-input.mjs", "generated/paper-mcp.mjs", "package.json", "cloud-config.mjs", diff --git a/desktop/standard-input.mjs b/desktop/standard-input.mjs new file mode 100644 index 0000000..c4a03d6 --- /dev/null +++ b/desktop/standard-input.mjs @@ -0,0 +1,17 @@ +import net from "node:net"; +import process from "node:process"; + +/** On Windows, Electron replaces process.stdin with a stream that has already + * ended, so nothing an agent writes to the hook or the MCP bridge arrives. The + * pipe is still open on descriptor 0: read it there, the way Node itself does. */ +export function standardInput() { + if (process.platform !== "win32" || !process.versions.electron) { + return process.stdin; + } + try { + return new net.Socket({ fd: 0, readable: true, writable: false }); + } catch { + // Not a pipe: started by hand, with nothing to read. + return process.stdin; + } +} diff --git a/scripts/paper-mcp.mjs b/scripts/paper-mcp.mjs index 3e9a912..d6e9bf9 100644 --- a/scripts/paper-mcp.mjs +++ b/scripts/paper-mcp.mjs @@ -9,6 +9,7 @@ import { import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; import { resolveBoundProjectId } from "../desktop/project-binding.mjs"; +import { standardInput } from "../desktop/standard-input.mjs"; const appUrl = (process.env.VIDEO_FS_APP_URL || "http://127.0.0.1:3000").replace(/\/+$/, ""); const defaultProjectId = process.env.VIDEO_FS_PROJECT_ID || null; @@ -1081,7 +1082,7 @@ if (defaultProjectId) { ); } -const transport = new StdioServerTransport(); +const transport = new StdioServerTransport(standardInput(), process.stdout); await server.connect(transport); // Presence heartbeat: this process only lives while an agent session keeps the From 0a5800d60cea84b83a87ab1e50d98b846552ff73 Mon Sep 17 00:00:00 2001 From: anessbelbati <102622182+anessbelbati@users.noreply.github.com> Date: Wed, 7 Oct 2026 02:34:55 +0100 Subject: [PATCH 10/19] Install Claude Code from an app that was started in PowerShell 7 --- lib/agent-connect.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/lib/agent-connect.ts b/lib/agent-connect.ts index 31d34ed..55925af 100644 --- a/lib/agent-connect.ts +++ b/lib/agent-connect.ts @@ -92,12 +92,19 @@ async function installCommand( environment: NodeJS.ProcessEnv, ): Promise<[string, string[]]> { if (agent === "claude") { - return process.platform === "win32" - ? [ - windowsSystemProgram("WindowsPowerShell", "v1.0", "powershell.exe"), - ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", "irm https://claude.ai/install.ps1 | iex"], - ] - : ["/bin/bash", ["-c", "curl -fsSL https://claude.ai/install.sh | bash"]]; + if (process.platform !== "win32") { + return ["/bin/bash", ["-c", "curl -fsSL https://claude.ai/install.sh | bash"]]; + } + // An app started from PowerShell 7 inherits its module path, which hides + // Windows PowerShell's own Get-FileHash from the installer. Without the + // variable Windows PowerShell falls back to its own modules. + for (const key of Object.keys(environment)) { + if (key.toUpperCase() === "PSMODULEPATH") delete environment[key]; + } + return [ + windowsSystemProgram("WindowsPowerShell", "v1.0", "powershell.exe"), + ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", "irm https://claude.ai/install.ps1 | iex"], + ]; } const npm = await resolveCommand("npm", environment); if (!npm) throw new Error("npm was not found. Install Node.js, then connect Codex again."); From bc2617e7acfc2b14e406de5d00471f892f93ac5c Mon Sep 17 00:00:00 2001 From: anessbelbati <102622182+anessbelbati@users.noreply.github.com> Date: Wed, 7 Oct 2026 02:34:55 +0100 Subject: [PATCH 11/19] Follow a command launcher that forwards to another launcher --- lib/agent-binaries.ts | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/lib/agent-binaries.ts b/lib/agent-binaries.ts index 081269b..2c7cf80 100644 --- a/lib/agent-binaries.ts +++ b/lib/agent-binaries.ts @@ -143,20 +143,24 @@ export function agentCliLaunch( if (platform !== "win32" || (extension !== ".cmd" && extension !== ".bat")) { return { args, command: file }; } - const directory = path.dirname(file); - let shim: ReturnType = null; - try { - shim = commandShimTarget(readFileSync(file, "utf8")); - } catch { /* Unreadable launchers take the cmd.exe route below. */ } - const target = shim ? path.join(directory, ...shim.target.split(/[\\/]+/)) : null; - if (shim && target && existsSync(target)) { + // Yarn's launcher forwards to a second launcher, which starts the program. + let launcher = file; + for (let hop = 0; hop < 4; hop += 1) { + const directory = path.dirname(launcher); + let shim: ReturnType = null; + try { + shim = commandShimTarget(readFileSync(launcher, "utf8")); + } catch { /* Unreadable launchers take the cmd.exe route below. */ } + const target = shim ? path.join(directory, ...shim.target.split(/[\\/]+/)) : null; + if (!shim || !target || !existsSync(target)) break; if (shim.interpreter === "node") { const bundledNode = path.join(directory, "node.exe"); return { args: [target, ...args], command: existsSync(bundledNode) ? bundledNode : "node" }; } - if (shim.interpreter === null && /\.(?:exe|com)$/i.test(target)) { - return { args, command: target }; - } + if (shim.interpreter !== null) break; + if (/\.(?:exe|com)$/i.test(target)) return { args, command: target }; + if (!/\.(?:cmd|bat)$/i.test(target)) break; + launcher = target; } if (/["%]/.test(file) || !args.every((argument) => /^[A-Za-z0-9@_.:=+/\\-]+$/.test(argument))) { throw new Error( From 2f88961e4a549e8cf0582fe8a296a42339892bb6 Mon Sep 17 00:00:00 2001 From: anessbelbati <102622182+anessbelbati@users.noreply.github.com> Date: Wed, 7 Oct 2026 02:34:56 +0100 Subject: [PATCH 12/19] Name the Windows FFmpeg install in doctor --- scripts/doctor.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/doctor.mjs b/scripts/doctor.mjs index 7028682..5a6e99c 100644 --- a/scripts/doctor.mjs +++ b/scripts/doctor.mjs @@ -20,7 +20,7 @@ check("Node.js 22.13+", Number(process.versions.node.split(".")[0]) > 22 || (Num check("Git", installed("git"), "Install Git for agent project setup."); check("Local mode", process.env.APP_MODE === "local" && process.env.NEXT_PUBLIC_APP_MODE === "local", "Set APP_MODE=local and NEXT_PUBLIC_APP_MODE=local in .env.local."); check("MCP credential", Boolean(process.env.PAPER_MCP_TOKEN?.trim()), "Run npm run setup on a fresh checkout, or set PAPER_MCP_TOKEN to a random secret."); -check("FFmpeg", installed("ffmpeg", ["-version"]) && installed("ffprobe", ["-version"]), "Install ffmpeg for media processing (brew install ffmpeg / apt install ffmpeg).", false); +check("FFmpeg", installed("ffmpeg", ["-version"]) && installed("ffprobe", ["-version"]), `Install ffmpeg for media processing (${process.platform === "win32" ? "winget install Gyan.FFmpeg, then open a new terminal" : "brew install ffmpeg / apt install ffmpeg"}).`, false); check("Agent CLI", process.env.VIDEO_FS_COMPOSER_CLI ? installed(process.env.VIDEO_FS_COMPOSER_CLI) : installed("claude") || installed("codex"), "Install and sign into Claude Code or Codex for agent prompts.", false); const dataRoot = process.env.VIDEO_FS_DATA_ROOT?.trim() || path.join(process.cwd(), "data", "projects"); const settingsRoot = process.env.VIDEO_FS_SETTINGS_ROOT?.trim() || path.join(path.dirname(dataRoot), "settings"); From 3b1a0d1e2fe6aa04176c367ebd6ab3af8e14d709 Mon Sep 17 00:00:00 2001 From: anessbelbati <102622182+anessbelbati@users.noreply.github.com> Date: Wed, 7 Oct 2026 02:34:56 +0100 Subject: [PATCH 13/19] Say only what holds about file privacy on Windows --- desktop/connection-state.mjs | 4 ++-- tests/local-library.test.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/desktop/connection-state.mjs b/desktop/connection-state.mjs index 6cbb372..357b989 100644 --- a/desktop/connection-state.mjs +++ b/desktop/connection-state.mjs @@ -59,8 +59,8 @@ export async function readPrivateConnectionState(filePath, { requireLivePid = tr throw new Error(desktopUnavailableMessage()); } // Windows has no group/other mode bits: Node reports 0666 for every writable - // file, so this check could never pass there. The record's privacy comes from - // the access list of the per-user folder it is written into. + // file, so this check could never pass there. On Windows the record is as + // private as the folder it is in, by default Electron's per-user data folder. if (process.platform !== "win32" && (info.mode & 0o077) !== 0) { throw new Error("Video FS Desktop connection record permissions must be 0600."); } diff --git a/tests/local-library.test.ts b/tests/local-library.test.ts index 256af99..eb03506 100644 --- a/tests/local-library.test.ts +++ b/tests/local-library.test.ts @@ -26,7 +26,7 @@ describe("local Library uploads", () => { expect((await listLocalLibraryUploads()).map(asset => asset.kind).sort()).toEqual(["audio", "document", "image", "image", "video"]); const asset = await localLibraryFile(ids[0]); expect(await readFile(asset.absolutePath, "utf8")).toBe("image"); - // Windows keeps no owner-only mode bits; the profile folder's access list does that job there. + // Windows has no owner-only mode bits to assert. if (process.platform !== "win32") { expect((await stat(asset.absolutePath)).mode & 0o777).toBe(0o600); expect((await stat(path.dirname(asset.absolutePath))).mode & 0o777).toBe(0o700); From 36702f6334930f4e6f87076c3af99672be65643a Mon Sep 17 00:00:00 2001 From: anessbelbati <102622182+anessbelbati@users.noreply.github.com> Date: Wed, 7 Oct 2026 02:48:23 +0100 Subject: [PATCH 14/19] Cover a launcher chain with the launchers Yarn writes --- tests/agent-binaries.test.ts | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/tests/agent-binaries.test.ts b/tests/agent-binaries.test.ts index 537c6ea..42f12f5 100644 --- a/tests/agent-binaries.test.ts +++ b/tests/agent-binaries.test.ts @@ -11,7 +11,7 @@ const execFileAsync = promisify(execFile); const directories: string[] = []; afterEach(async () => { await Promise.all(directories.splice(0).map(dir => rm(dir, { recursive: true, force: true }))); }); -// These three launchers are copied from real installs on Windows. +// These launchers are copied from real installs on Windows. /** `npm install -g @openai/codex`: a Node script behind the launcher. */ const NPM_NODE_LAUNCHER = [ @@ -74,6 +74,18 @@ const NPM_OWN_LAUNCHER = [ "", ].join("\r\n"); +/** `yarn global add @openai/codex`: the launcher on PATH forwards to a second one. */ +const YARN_FIRST_LAUNCHER = '@"%~dp0\\..\\..\\Yarn\\Data\\global\\node_modules\\.bin\\codex.cmd" %*\r\n'; +const YARN_SECOND_LAUNCHER = [ + '@IF EXIST "%~dp0\\node.exe" (', + ' "%~dp0\\node.exe" "%~dp0\\..\\@openai\\codex\\bin\\codex.js" %*', + ") ELSE (", + " @SETLOCAL", + " @SET PATHEXT=%PATHEXT:;.JS;=;%", + ' node "%~dp0\\..\\@openai\\codex\\bin\\codex.js" %*', + ")", +].join("\r\n"); + const windowsEnvironment = { ComSpec: "C:\\Windows\\system32\\cmd.exe", NODE_ENV: "test" } as const; describe("agent installation discovery", () => { @@ -172,6 +184,24 @@ describe("agent launch on Windows", () => { expect(agentCliLaunch(codex, prompt, windowsEnvironment, "linux")).toEqual({ args: prompt, command: codex }); }); + it("follows a launcher that forwards to another launcher", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "video-fs-agent-yarn-")); directories.push(root); + const modules = path.join(root, "Yarn", "Data", "global", "node_modules"); + const first = path.join(root, "prefix", "bin", "codex.cmd"); + const second = path.join(modules, ".bin", "codex.cmd"); + const script = path.join(modules, "@openai", "codex", "bin", "codex.js"); + for (const file of [first, second, script]) await mkdir(path.dirname(file), { recursive: true }); + await writeFile(first, YARN_FIRST_LAUNCHER); + await writeFile(second, YARN_SECOND_LAUNCHER); + await writeFile(script, ""); + expect(commandShimTarget(YARN_FIRST_LAUNCHER)).toEqual({ interpreter: null, target: "..\\..\\Yarn\\Data\\global\\node_modules\\.bin\\codex.cmd" }); + expect(commandShimTarget(YARN_SECOND_LAUNCHER)).toEqual({ interpreter: "node", target: "..\\@openai\\codex\\bin\\codex.js" }); + expect(agentCliLaunch(first, ["exec", "two words"], windowsEnvironment, "win32")).toEqual({ args: [script, "exec", "two words"], command: "node" }); + // A chain that never reaches a program is treated like any other batch file. + await writeFile(second, YARN_FIRST_LAUNCHER); + expect(() => agentCliLaunch(first, ["exec", "two words"], windowsEnvironment, "win32")).toThrow(/cannot receive this command safely/); + }); + it("sends any other batch file through cmd.exe, and only with arguments cmd.exe leaves alone", async () => { const directory = await mkdtemp(path.join(os.tmpdir(), "video-fs-agent-batch-")); directories.push(directory); const npm = path.join(directory, "npm.cmd"); From 2f85915af0a27def1aa20c8d10c9973c9108ff65 Mon Sep 17 00:00:00 2001 From: anessbelbati <102622182+anessbelbati@users.noreply.github.com> Date: Wed, 7 Oct 2026 04:52:32 +0100 Subject: [PATCH 15/19] Write each agent's hook for the shell that runs it on Windows On Windows no single spelling of a command line runs under both agents. Codex hands a hook command to PowerShell: PowerShell 7, or Windows PowerShell when 7 is not installed. Claude Code hands it to Git Bash, or to PowerShell when Git is not installed. The sh-quoted command the desktop writes on macOS and Linux fails under PowerShell ("hook exited with code 1"), and a command written for PowerShell is a syntax error under Git Bash, which blocks the prompt. Codex now gets the command written for PowerShell, with the call operator in front of the quoted program. Claude Code gets the program and its arguments as a list (2.1.139 and later), which it starts directly, with no shell, so one file works with and without Git Bash. An earlier Claude Code release skips a hook written that way and sends the prompt without the context. The onboarding status check reads a hook that lists its arguments. The Codex compatibility test runs the installed command through the shell Codex uses on each platform instead of starting Electron itself. Measured on windows-latest with Codex 0.160.1 and Claude Code 2.1.292 against stand-in model servers: the hook ran and its context was in the model request under Codex with PowerShell 7 and with Windows PowerShell 5.1, and under Claude Code with Git Bash present and with Git hidden. What is written on macOS and Linux does not change. --- desktop/agent-setup.mjs | 28 ++++++++++++--- desktop/agent-setup.test.mjs | 39 +++++++++++++++----- desktop/codex-hook-compatibility.test.mjs | 43 +++++++---------------- lib/agent-onboarding-status.ts | 7 +++- tests/agent-onboarding-status.test.ts | 27 ++++++++++++++ 5 files changed, 101 insertions(+), 43 deletions(-) diff --git a/desktop/agent-setup.mjs b/desktop/agent-setup.mjs index 7f2c3de..aba71b7 100644 --- a/desktop/agent-setup.mjs +++ b/desktop/agent-setup.mjs @@ -295,9 +295,6 @@ function contextHookLauncherForProject(mcp, projectId, agent) { } function hookConfig(launcher, agent) { - const command = [launcher.command, ...launcher.args] - .map(shellQuote) - .join(" "); return `${JSON.stringify( { // Auto-trust the project's own .mcp.json so users never see the CLI's @@ -317,7 +314,7 @@ function hookConfig(launcher, agent) { { hooks: [ { - command, + ...hookCommand([launcher.command, ...launcher.args], agent), timeout: 5, type: "command", }, @@ -331,6 +328,19 @@ function hookConfig(launcher, agent) { )}\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, @@ -782,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); } diff --git a/desktop/agent-setup.test.mjs b/desktop/agent-setup.test.mjs index 6ed5e78..62c6c2e 100644 --- a/desktop/agent-setup.test.mjs +++ b/desktop/agent-setup.test.mjs @@ -220,8 +220,9 @@ test("legacy app-owned Codex hooks lose the Claude keys that make Codex reject t ), { hooks }, ); - // With the two keys gone the file is today's output, so nothing conflicts. - assert.deepEqual(result.conflicts, []); + // 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 () => { @@ -393,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, diff --git a/desktop/codex-hook-compatibility.test.mjs b/desktop/codex-hook-compatibility.test.mjs index 370a7cd..6dbfdae 100644 --- a/desktop/codex-hook-compatibility.test.mjs +++ b/desktop/codex-hook-compatibility.test.mjs @@ -23,14 +23,6 @@ const selectedSha = "a".repeat(64); const contentSha = "b".repeat(64); const mainPath = fileURLToPath(new URL("./main.mjs", import.meta.url)); -/** The words a shell runs for a generated hook command. The generator - * single-quotes any word holding a backslash, as every Windows path does. */ -function shellWords(command) { - return command - .match(/(?:[^\s']+|'[^']*')+/g) - .map((word) => word.replaceAll("'", "")); -} - async function fixture() { const directory = await mkdtemp( path.join(os.tmpdir(), "video-fs-codex-compat-"), @@ -131,17 +123,17 @@ async function startServer() { }; } -function runInstalledHook({ projectRoot, statePath }) { - const args = [ - mainPath, - "--agent-context-hook", - "--agent", - "codex", - "--project-id", - projectId, - ]; +/** Codex hands a hook command to the user's shell, which is PowerShell on + * Windows, and writes the hook input to that shell's standard input. PowerShell + * does not wait for a windowed program such as Electron, so, as Codex does, + * this reads the output until the pipes close. */ +function runInstalledHook({ command, projectRoot, statePath }) { + const [shell, ...args] = + process.platform === "win32" + ? ["powershell.exe", "-NoProfile", "-Command", command] + : ["/bin/sh", "-c", command]; return new Promise((resolve, reject) => { - const child = spawn(electron, args, { + const child = spawn(shell, args, { env: { ...process.env, VIDEO_FS_DESKTOP_STATE_FILE: statePath, @@ -159,12 +151,12 @@ function runInstalledHook({ projectRoot, statePath }) { stderr += chunk; }); child.once("error", reject); - child.once("exit", (code) => { + child.once("close", (code) => { if (code !== 0) { reject(new Error(`Installed hook exited ${code}: ${stderr}`)); return; } - resolve({ args, output: JSON.parse(stdout), stderr }); + resolve({ output: JSON.parse(stdout), stderr }); }); child.stdin.end( JSON.stringify({ @@ -203,18 +195,9 @@ test("installed token-free Codex UserPromptSubmit launcher freezes and injects e // 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.deepEqual(shellWords(command), [ - electron, - mainPath, - "--agent-context-hook", - "--agent", - "codex", - "--project-id", - projectId, - ]); assert.doesNotMatch(command, new RegExp(token)); - const result = await runInstalledHook(input); + const result = await runInstalledHook({ ...input, command }); const additionalContext = result.output.hookSpecificOutput.additionalContext; assert.equal( diff --git a/lib/agent-onboarding-status.ts b/lib/agent-onboarding-status.ts index 131f17a..180c0d9 100644 --- a/lib/agent-onboarding-status.ts +++ b/lib/agent-onboarding-status.ts @@ -60,7 +60,12 @@ function commandStrings(value: unknown): string[] { if (typeof value === "string") return [value]; if (Array.isArray(value)) return value.flatMap(commandStrings); if (!value || typeof value !== "object") return []; - return Object.values(value as Record).flatMap(commandStrings); + const entry = value as Record; + return [ + // On Windows the Claude Code hook lists its arguments apart from its program. + ...(Array.isArray(entry.args) ? [[entry.command, ...entry.args].join(" ")] : []), + ...Object.values(entry).flatMap(commandStrings), + ]; } function escapeRegularExpression(value: string) { diff --git a/tests/agent-onboarding-status.test.ts b/tests/agent-onboarding-status.test.ts index 2770f1f..2c8c2f5 100644 --- a/tests/agent-onboarding-status.test.ts +++ b/tests/agent-onboarding-status.test.ts @@ -62,6 +62,33 @@ describe("agent onboarding UI status", () => { }); }); + it("recognizes the Claude hook written for Windows, with its arguments as a list", () => { + const claudeHook = (boundProjectId: string) => + JSON.stringify({ + hooks: { + UserPromptSubmit: [ + { + hooks: [ + { + args: ["--agent-context-hook", "--agent", "claude", "--project-id", boundProjectId], + command: "C:\\Program Files\\Video FS\\Video FS.exe", + type: "command", + }, + ], + }, + ], + }, + }); + const status = (boundProjectId: string) => + agentOnboardingStatusFromFiles( + { ...generatedFiles(), claudeHook: claudeHook(boundProjectId) }, + projectId, + true, + ); + expect(status(projectId).claude.contextHookInstalled).toBe(true); + expect(status(`${projectId}-extra`).claude.contextHookInstalled).toBe(false); + }); + it("does not call a config installed when it is bound to another project", () => { const status = agentOnboardingStatusFromFiles( generatedFiles(`${projectId}-extra`), From 65ac65e91bf2a303d437f08fede10d9d8161ba36 Mon Sep 17 00:00:00 2001 From: anessbelbati <102622182+anessbelbati@users.noreply.github.com> Date: Wed, 7 Oct 2026 03:48:03 +0100 Subject: [PATCH 16/19] Wait out a read that overlaps a project delete on Windows --- lib/workspace.ts | 10 ++++----- tests/project-delete.test.ts | 41 ++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 5 deletions(-) create mode 100644 tests/project-delete.test.ts diff --git a/lib/workspace.ts b/lib/workspace.ts index a84c319..98ddda2 100644 --- a/lib/workspace.ts +++ b/lib/workspace.ts @@ -214,10 +214,10 @@ export async function ensureProjectsRoot() { await mkdir(DATA_ROOT, { recursive: true }); } -/** Windows refuses to replace a file while another handle to it is open, so a - * read that overlaps the rename fails the write with EPERM. Readers and virus - * scanners let go within milliseconds: wait for the gap instead of losing the - * write. */ +/** Windows refuses to replace a file while another handle to it is open, and + * to move a folder while a file inside it is open, so a read that overlaps the + * rename fails it with EPERM. Readers and virus scanners let go within + * milliseconds: wait for the gap instead of losing the write or the delete. */ async function renameOverReaders(source: string, target: string) { const deadline = Date.now() + 10_000; for (let delay = 5; ; delay = Math.min(delay * 2, 100)) { @@ -848,7 +848,7 @@ export async function deleteProject(projectId: string, ownerUserId?: string | nu JSON.stringify({ ...meta, deletedAt: nowIso(), originalId: projectId }, null, 2), "utf8", ); - await rename(source, target); + await renameOverReaders(source, target); return { id: projectId, trashed_path: path.relative(process.cwd(), target).replaceAll(path.sep, "/") }; } diff --git a/tests/project-delete.test.ts b/tests/project-delete.test.ts new file mode 100644 index 0000000..a29703e --- /dev/null +++ b/tests/project-delete.test.ts @@ -0,0 +1,41 @@ +import { mkdtemp, open, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +/** + * Integration: deleteProject moves the project folder to the trash, and + * Windows refuses that move while a file in the folder is open. + * VIDEO_FS_DATA_ROOT must be set before the workspace module loads, hence the + * dynamic imports. + */ + +let dataRoot: string; +let workspace: typeof import("@/lib/workspace"); + +beforeAll(async () => { + dataRoot = await mkdtemp(path.join(tmpdir(), "vfs-delete-")); + process.env.VIDEO_FS_DATA_ROOT = path.join(dataRoot, "projects"); + workspace = await import("@/lib/workspace"); +}); + +afterAll(async () => { + delete process.env.VIDEO_FS_DATA_ROOT; + await rm(dataRoot, { recursive: true, force: true }); +}); + +describe("workspace deleteProject", () => { + it("deletes a project while a read of one of its files is still in flight", async () => { + const project = await workspace.createProject("Busy delete"); + const reader = await open(path.join(workspace.projectRoot(project.id), "project.json"), "r"); + const released = new Promise((resolve) => { + setTimeout(() => void reader.close().then(resolve), 150); + }); + try { + expect((await workspace.deleteProject(project.id)).id).toBe(project.id); + await expect(workspace.readProjectMeta(project.id)).rejects.toThrow(); + } finally { + await released; + } + }); +}); From ffcfb722eab314bc86847c0129846b0457cbebe5 Mon Sep 17 00:00:00 2001 From: anessbelbati <102622182+anessbelbati@users.noreply.github.com> Date: Wed, 7 Oct 2026 03:48:03 +0100 Subject: [PATCH 17/19] Keep one token expiry for the whole desktop sign-in test --- tests/desktop-hosted-auth.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/desktop-hosted-auth.test.ts b/tests/desktop-hosted-auth.test.ts index 8746a9e..5268dff 100644 --- a/tests/desktop-hosted-auth.test.ts +++ b/tests/desktop-hosted-auth.test.ts @@ -36,7 +36,11 @@ import { const SECRET = "desktop-auth-test-secret-that-is-more-than-thirty-two-characters"; const VERIFIER = Buffer.alloc(32, 7).toString("base64url"); -function clerkToken(exp = Math.floor(Date.now() / 1000) + 600) { +// One expiry for the whole run: a token made again after the clock passes a +// second boundary would no longer equal the one the mock handed out. +const CLERK_TOKEN_EXPIRY = Math.floor(Date.now() / 1000) + 600; + +function clerkToken(exp = CLERK_TOKEN_EXPIRY) { const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url"); return `${encode({ alg: "none" })}.${encode({ exp, sub: "user_desktop" })}.sig`; } From fdd2390c7638b57b4e404170f9b905d8662d26ae Mon Sep 17 00:00:00 2001 From: anessbelbati <102622182+anessbelbati@users.noreply.github.com> Date: Wed, 7 Oct 2026 04:53:47 +0100 Subject: [PATCH 18/19] Run the checks on Windows in CI --- .github/workflows/ci.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4f4f965..1b7daf9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,3 +47,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 From a2102eda0db1717bbe02f55637260ba90bf226ee Mon Sep 17 00:00:00 2001 From: anessbelbati <102622182+anessbelbati@users.noreply.github.com> Date: Wed, 7 Oct 2026 04:46:36 +0100 Subject: [PATCH 19/19] Say what CI shows on Windows --- README.md | 8 +++++--- docs/local-development.md | 4 +++- docs/release-validation.md | 8 +++++--- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 1e7a88c..24db306 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. diff --git a/docs/local-development.md b/docs/local-development.md index 9b1ad9a..4224f2a 100644 --- a/docs/local-development.md +++ b/docs/local-development.md @@ -21,6 +21,7 @@ box. `VIDEO_FS_COMPOSER_CLI` can select an absolute executable path. The CLI use its own authenticated account. The app's provider keys are not agent credentials. Setup checks both installations without sending a prompt. Finder-launched Desktop also searches the standard native, Homebrew, npm and Volta locations. +On Windows it searches `%USERPROFILE%\.local\bin` and `%APPDATA%\npm`. Claude powers the in-app companion; Claude and Codex both support the project terminal. Use **Account → Setup guide** to recheck or connect them. @@ -50,7 +51,8 @@ absolute `VIDEO_FS_SETTINGS_ROOT` can override the directory; keep it outside project and shared directories. The file is plain JSON, written atomically with owner-only permissions on -macOS/Linux (directory `0700`, file `0600`). It is not keychain-encrypted; +macOS/Linux (directory `0700`, file `0600`). On Windows those modes do not +apply; the file takes the access rules of its folder. It is not keychain-encrypted; anyone with access to your OS account can read it. Keys are excluded from source exports and desktop bundles, never included in project files or browser storage, and never returned by the settings API. Keep private backups private. diff --git a/docs/release-validation.md b/docs/release-validation.md index 162caa9..b6f99c5 100644 --- a/docs/release-validation.md +++ b/docs/release-validation.md @@ -68,8 +68,10 @@ needs further optimization before distributing desktop binaries. This validation does not exercise paid provider generation, hosted billing, Apple signing/notarization, auto-updates, Windows, or Linux desktop behavior. -The [Linux CI workflow](https://github.com/Openpod/impractical-video/actions/workflows/ci.yml) -reports its own results for every public commit. It provisions Electron's sandbox -helper and a virtual display for the installed-hook test. +The [CI workflow](https://github.com/Openpod/impractical-video/actions/workflows/ci.yml) +reports its own results for every public commit. On Linux it provisions Electron's +sandbox helper and a virtual display for the installed-hook test. On Windows it +runs lint, TypeScript, the Vitest and desktop tests, setup, and the production +build; the browser suite runs on Linux only. The clean export contains no Git history; the original repository history has not been cleared for public release. See [releasing.md](releasing.md).