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/.github/workflows/ci.yml b/.github/workflows/ci.yml index a2ce43a..b9a1961 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,3 +55,21 @@ jobs: name: video-fs-source path: dist/source/video-fs include-hidden-files: true + # The browser suite stays on Linux: on a hosted Windows runner the dev server + # compiles slowly enough that the first test can outrun its two-minute limit. + verify-windows: + runs-on: windows-latest + timeout-minutes: 30 + env: + NEXT_TELEMETRY_DISABLED: "1" + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: npm + - run: npm ci + - run: node node_modules/electron/install.js + - run: npm run check + - run: npm run setup + - run: npm run build 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/desktop/agent-setup.mjs b/desktop/agent-setup.mjs index ed572f5..aba71b7 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,24 +294,27 @@ function contextHookLauncherForProject(mcp, projectId, agent) { }; } -function hookConfig(launcher) { - const command = [launcher.command, ...launcher.args] - .map(shellQuote) - .join(" "); +function hookConfig(launcher, agent) { return `${JSON.stringify( { // Auto-trust the project's own .mcp.json so users never see the CLI's // interactive "approve this MCP server" prompt — the desktop wrote that // config itself, so approval is implicit. Current Claude releases need // the explicit server allowlist even when the blanket legacy flag is set. - enableAllProjectMcpServers: true, - enabledMcpjsonServers: ["video-fs"], + // Codex loads no hooks at all from a file with a top-level key it does + // not know, so these stay out of its file. + ...(agent === "claude" + ? { + enableAllProjectMcpServers: true, + enabledMcpjsonServers: ["video-fs"], + } + : {}), hooks: { UserPromptSubmit: [ { hooks: [ { - command, + ...hookCommand([launcher.command, ...launcher.args], agent), timeout: 5, type: "command", }, @@ -324,6 +328,19 @@ function hookConfig(launcher) { )}\n`; } +/** Both agents hand a hook command to sh, except on Windows. There Codex uses + * PowerShell, and Claude Code uses Git Bash, or PowerShell when Git is not + * installed, so no one spelling of a command line runs under both. Claude Code + * 2.1.139 and later start a hook that lists its arguments directly, with no + * shell; an earlier release skips such a hook and sends the prompt without it. */ +function hookCommand(words, agent) { + if (process.platform !== "win32") { + return { command: words.map(shellQuote).join(" ") }; + } + if (agent === "codex") return { command: powershellCommand(words) }; + return { args: words.slice(1), command: words[0] }; +} + /** Existing projects may predate the explicit MCP server allowlist. Merge it * in ONLY when the settings file is our own earlier output (identified by the * desktop's context-hook command) — user-authored settings are never touched, @@ -362,6 +379,34 @@ async function enableProjectMcpServers(settingsPath) { } } +/** Earlier desktop output put Claude's two MCP approval keys into the Codex + * hooks file as well, and Codex loads no hooks from a file with a top-level + * key it does not know. Remove them ONLY from our own earlier output, under + * the same test as above. */ +async function removeClaudeKeysFromCodexHooks(hooksPath) { + try { + const parsed = JSON.parse(await readFile(hooksPath, "utf8")); + if (!parsed || typeof parsed !== "object") return; + const keys = Object.keys(parsed); + const appOwned = + keys.every( + (key) => + key === "hooks" || + key === "enableAllProjectMcpServers" || + key === "enabledMcpjsonServers", + ) && + JSON.stringify(parsed.hooks ?? {}).includes("--agent-context-hook"); + if (!appOwned || keys.every((key) => key === "hooks")) return; + await writeFile( + hooksPath, + `${JSON.stringify({ hooks: parsed.hooks }, null, 2)}\n`, + { mode: 0o600 }, + ); + } catch { + // Missing or hand-mangled hooks stay untouched. + } +} + async function writePrivateFileIfAbsent(filePath, contents) { const existing = await readFile(filePath, "utf8").catch((error) => { if (error?.code === "ENOENT") return null; @@ -747,6 +792,16 @@ function shellQuote(value) { return `'${value.replaceAll("'", "'\"'\"'")}'`; } +/** PowerShell reads a line that starts with a quoted string as a value, not as + * a program to run, unless the call operator comes first. */ +function powershellCommand(words) { + return `& ${words + .map((word) => + /^[A-Za-z0-9_-]+$/.test(word) ? word : `'${word.replaceAll("'", "''")}'`, + ) + .join(" ")}`; +} + function tomlString(value) { return JSON.stringify(value); } diff --git a/desktop/agent-setup.test.mjs b/desktop/agent-setup.test.mjs index 95bf0b8..62c6c2e 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", @@ -67,14 +68,15 @@ test("fresh project receives Claude and Codex project-local configuration at mod assert.deepEqual(result.codexProjectRoot, { status: "created" }); assert.equal(await readlink(path.join(input.projectRoot, "AGENTS.md")), "VIDEO_FS_AGENT_GUIDE.md"); assert.equal(await readlink(path.join(input.projectRoot, "CLAUDE.md")), "AGENTS.md"); - assert.equal((await stat(path.join(input.projectRoot, "VIDEO_FS_AGENT_GUIDE.md"))).mode & 0o777, 0o600); for (const relativePath of [ + "VIDEO_FS_AGENT_GUIDE.md", ".mcp.json", ".claude/settings.json", ".codex/config.toml", ".codex/hooks.json", ]) { - assert.equal((await stat(path.join(input.projectRoot, relativePath))).mode & 0o777, 0o600); + const mode = (await stat(path.join(input.projectRoot, relativePath))).mode & 0o777; + if (process.platform !== "win32") assert.equal(mode, 0o600); } assert.match( await readFile(path.join(input.projectRoot, "AGENTS.md"), "utf8"), @@ -179,6 +181,50 @@ test("legacy app-owned Claude settings gain the explicit Video FS approval", asy assert.deepEqual(settings.enabledMcpjsonServers, ["video-fs"]); }); +test("legacy app-owned Codex hooks lose the Claude keys that make Codex reject the file", async () => { + const input = await fixture("legacy-codex-hooks"); + const hooks = { + UserPromptSubmit: [ + { + hooks: [ + { + command: + "'/Applications/Video FS.app/Contents/MacOS/Video FS' --agent-context-hook --agent codex --project-id legacy-codex-hooks", + timeout: 5, + type: "command", + }, + ], + }, + ], + }; + await mkdir(path.join(input.projectRoot, ".codex")); + await writeFile( + path.join(input.projectRoot, ".codex", "hooks.json"), + `${JSON.stringify( + { + enableAllProjectMcpServers: true, + enabledMcpjsonServers: ["video-fs"], + hooks, + }, + null, + 2, + )}\n`, + "utf8", + ); + + const result = await setupAgentProject(input); + + assert.deepEqual( + JSON.parse( + await readFile(path.join(input.projectRoot, ".codex", "hooks.json"), "utf8"), + ), + { hooks }, + ); + // The command the desktop wrote earlier is still today's, except on + // Windows, where Codex needs it written for PowerShell. + if (process.platform !== "win32") assert.deepEqual(result.conflicts, []); +}); + test("fresh project becomes a valid Codex project root without user Git setup", async () => { const input = await fixture("codex-root"); const first = await setupAgentProject(input); @@ -192,7 +238,8 @@ test("fresh project becomes a valid Codex project root without user Git setup", "rev-parse", "--show-toplevel", ]); - assert.equal(stdout.trim(), await realpath(input.projectRoot)); + // Git prints forward slashes on every platform. + assert.equal(path.resolve(stdout.trim()), await realpath(input.projectRoot)); await assert.rejects( () => execFileAsync("git", [ @@ -347,17 +394,39 @@ test("both generated launchers bind exactly the opened project", async () => { const codexHooks = JSON.parse( await readFile(path.join(input.projectRoot, ".codex", "hooks.json"), "utf8"), ); - const claudeCommand = - claudeHooks.hooks.UserPromptSubmit[0].hooks[0].command; + const claudeHook = claudeHooks.hooks.UserPromptSubmit[0].hooks[0]; const codexCommand = codexHooks.hooks.UserPromptSubmit[0].hooks[0].command; - assert.match( - claudeCommand, - /--agent-context-hook --agent claude --project-id bound-project$/, - ); assert.match( codexCommand, /--agent-context-hook --agent codex --project-id bound-project$/, ); + if (process.platform === "win32") { + // No shell that runs a hook on Windows reads the sh spelling: Codex gets + // the command written for PowerShell, and Claude Code the program with its + // arguments as a list, which it starts without a shell. + assert.equal( + codexCommand, + `& '${launcher}' --agent-context-hook --agent codex --project-id bound-project`, + ); + assert.deepEqual(claudeHook, { + args: [ + "--agent-context-hook", + "--agent", + "claude", + "--project-id", + "bound-project", + ], + command: launcher, + timeout: 5, + type: "command", + }); + return; + } + const claudeCommand = claudeHook.command; + assert.match( + claudeCommand, + /--agent-context-hook --agent claude --project-id bound-project$/, + ); assert.equal( claudeCommand.replace("--agent claude", "--agent codex"), codexCommand, 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 d87fc55..6dbfdae 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,7 @@ 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)); async function fixture() { const directory = await mkdtemp( @@ -125,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, @@ -153,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({ @@ -194,22 +192,12 @@ 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, - [ - electron, - mainPath, - "--agent-context-hook", - "--agent", - "codex", - "--project-id", - projectId, - ].join(" "), - ); 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/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/desktop/connection-state.mjs b/desktop/connection-state.mjs index 5c902bc..357b989 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. 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."); } if (typeof process.getuid === "function" && info.uid !== process.getuid()) { 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/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 33869e2..ad884e7 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/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/); }); 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/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). diff --git a/lib/agent-binaries.ts b/lib/agent-binaries.ts index 785c2ee..2c7cf80 100644 --- a/lib/agent-binaries.ts +++ b/lib/agent-binaries.ts @@ -1,37 +1,189 @@ 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) { + 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 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 }; + } + // 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 { - await access(candidate, constants.X_OK); - if ((await stat(candidate)).isFile()) return candidate; - } catch { /* Try the next installation location. */ } + 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) break; + if (/\.(?:exe|com)$/i.test(target)) return { args, command: target }; + if (!/\.(?:cmd|bat)$/i.test(target)) break; + launcher = target; } - return null; + 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..55925af 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,64 @@ 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") { + 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."); + 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 +121,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 +180,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 +228,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 +257,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 +273,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/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/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/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/lib/workspace.ts b/lib/workspace.ts index 88eb3cb..98ddda2 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, 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)) { + 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 }); } @@ -829,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/scripts/doctor.mjs b/scripts/doctor.mjs index dea3845..5a6e99c 100644 --- a/scripts/doctor.mjs +++ b/scripts/doctor.mjs @@ -10,13 +10,17 @@ 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."); 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"); diff --git a/scripts/paper-mcp.mjs b/scripts/paper-mcp.mjs index 9875d14..1941f0d 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; @@ -1139,7 +1140,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 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; diff --git a/scripts/source-files.mjs b/scripts/source-files.mjs index 90e114f..5ddd6d4 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/agent-binaries.test.ts b/tests/agent-binaries.test.ts index 180fe45..42f12f5 100644 --- a/tests/agent-binaries.test.ts +++ b/tests/agent-binaries.test.ts @@ -1,15 +1,97 @@ +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 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"); + +/** `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", () => { - 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 +104,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 +114,125 @@ 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("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"); + 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); +}); 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/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`), diff --git a/tests/desktop-hosted-auth.test.ts b/tests/desktop-hosted-auth.test.ts index c01db60..b7b1453 100644 --- a/tests/desktop-hosted-auth.test.ts +++ b/tests/desktop-hosted-auth.test.ts @@ -37,7 +37,11 @@ const SECRET = "desktop-auth-test-secret-that-is-more-than-thirty-two-characters const VERIFIER = Buffer.alloc(32, 7).toString("base64url"); let issuedToken: string; -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`; } diff --git a/tests/local-library.test.ts b/tests/local-library.test.ts index 47ab31b..eb03506 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 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); + } expect(JSON.stringify(await getLocalLibraryUpload(ids[0]))).not.toContain(temporary); }); 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; + } + }); +}); 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 }); + } + }); });