diff --git a/editors/vscode/.oxlintrc.json b/editors/vscode/.oxlintrc.json new file mode 100644 index 000000000..5364a1ad3 --- /dev/null +++ b/editors/vscode/.oxlintrc.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://raw.githubusercontent.com/oxc-project/oxc/main/npm/oxlint/configuration_schema.json", + "env": { + "node": true + }, + "ignorePatterns": ["dist/**", "node_modules/**"] +} diff --git a/editors/vscode/.vscodeignore b/editors/vscode/.vscodeignore new file mode 100644 index 000000000..2af0265fb --- /dev/null +++ b/editors/vscode/.vscodeignore @@ -0,0 +1,13 @@ +.vscode/** +.vscode-test/** +src/** +.gitignore +.oxlintrc.json +tsconfig.json +build.mjs +bun.lock +bun.lockb +**/*.map +**/.eslintrc* +**/*.ts +node_modules/** diff --git a/editors/vscode/README.md b/editors/vscode/README.md new file mode 100644 index 000000000..454bb4d22 --- /dev/null +++ b/editors/vscode/README.md @@ -0,0 +1,3 @@ +# OpenPencil for VS Code + +Design-as-code integration for OpenPencil `.op` files: custom editor, MCP configuration, AI skills, and code generation. Under active development; see `openpencil/.superpowers/sdd/` for the implementation plan. diff --git a/editors/vscode/build.mjs b/editors/vscode/build.mjs new file mode 100644 index 000000000..e10dd4c6d --- /dev/null +++ b/editors/vscode/build.mjs @@ -0,0 +1,21 @@ +import esbuild from "esbuild"; + +const watch = process.argv.includes("--watch"); + +const ctx = await esbuild.context({ + entryPoints: ["src/extension.ts"], + bundle: true, + outfile: "dist/extension.js", + external: ["vscode"], + format: "cjs", + platform: "node", + target: "node18", + sourcemap: true, +}); + +if (watch) { + await ctx.watch(); +} else { + await ctx.rebuild(); + await ctx.dispose(); +} diff --git a/editors/vscode/bun.lock b/editors/vscode/bun.lock new file mode 100644 index 000000000..6961aa090 --- /dev/null +++ b/editors/vscode/bun.lock @@ -0,0 +1,121 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "openpencil-vscode", + "devDependencies": { + "@types/bun": "latest", + "@types/node": "^20", + "@types/vscode": "~1.90", + "esbuild": "^0.21", + "jsonc-parser": "^3", + "oxlint": "latest", + "typescript": "^5", + }, + }, + }, + "packages": { + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="], + + "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.74.0", "", { "os": "android", "cpu": "arm" }, "sha512-+gHd12muVI9ZLBaWLPkHt3Fj7jihFjgQ1MGtBaRL8vWrWrI0P7dLUty/cHrHS0oqPYIRgQUJsPu2CExQuMcwNw=="], + + "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.74.0", "", { "os": "android", "cpu": "arm64" }, "sha512-xjKdoMB+H+RCOByv/7l7nfIGW9mlOisqYdcyC75UqYuQecLpReAeEYUf2CNeDEI3KtmUgxpRw/+c63y4AeF/Bw=="], + + "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.74.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-iUK7wvc6sejMKsC+Pt67mntoF5weFcyEunhZfLJceU6gL419mexz5wBkSx/EnkFBExMLNtOi9fnDSc5xfK0IzQ=="], + + "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.74.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-ggKc/tn5SJ1u2yG2izC6VKODfYKV8MQ2AicJlNzOjuyrC29udvOef6/JzK2r32xqCnBDLFouR1VCkjzEI0/N9Q=="], + + "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.74.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-u++dH/43jy9hTLbneaWlS0gla/Bp1JdwJ2zgevCl8nDFUh6qRCGMxcL0f0lb7By3A9p/LfFr+7cG4HU1hG856g=="], + + "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.74.0", "", { "os": "linux", "cpu": "arm" }, "sha512-Sj1zmtFDVTPeIbIz4ZfcXAbFHqCmKCXdCUlAJzvTF7I20NTH1RDpoF2PhkqNODutJzVhJYmm3oz0GwgY+tvE2g=="], + + "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.74.0", "", { "os": "linux", "cpu": "arm" }, "sha512-//PKyQb/tQXcHArx2f7z+oVI/eMS2Jpv+edNuAtOrgIhWdGcpHxogveAxzmF2rpH1AIHp4Hq04RF/rgJdiICnQ=="], + + "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.74.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-/k1Me+aX2tjuH10K62mLS0y8cLkJBHX6Ce0xPK+eWeel4bSdEGZ8dv4+hYMzg0GrSmjwy4yAYsDPeEeKBft/2w=="], + + "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.74.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-3tFSjBxc5D8/zvjEuLvOqcA8ZXKD0+6NuaVO/edeamNc49MoAsbfaC9s1UiwODwgF6slGaF8yJA2TPkukd77tg=="], + + "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.74.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9QggtPkSPXOCTu8Szis7auOK/sC7KdQaN+/TujP7YVVhzCAOhgdRfgv8uEz0r2tk5xdgus5rLYUrCDoZNtiRUw=="], + + "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.74.0", "", { "os": "linux", "cpu": "none" }, "sha512-VM5VPUJ4DJIWiK+AZn8FScUqMr6OFrCAYybMYjEEi7W13ParI64MByiXTkKMqZpBmvQ9zxl9Ebq2VUOiZRJYUg=="], + + "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.74.0", "", { "os": "linux", "cpu": "none" }, "sha512-SaDY1gh9rOA592J54g+gu5hkOFFQBZsMmIYHs+NRHG+Uq0OxtuuCXMWQ3vu1830Eugv5uMXyjG+bv2Z9y4IXjw=="], + + "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.74.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-ZATQeHZCyr6MbDveg0obD5sxLHFOghtOdC5jwVwYlvFWqtFOxctgFEG6Ef/64hYvZrWyhyCckB10AelqLopeDA=="], + + "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.74.0", "", { "os": "linux", "cpu": "x64" }, "sha512-+aIvJyrdeD7LwCQ2WYLMUWNmnbeDRSPb40aBYtPjD9+PTqUwgJnk+HK5yLfSMeqXrMrDhE9uTmtt2y50tvjhHw=="], + + "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.74.0", "", { "os": "linux", "cpu": "x64" }, "sha512-XyktaR8lhK2qWiCK0Tk8oYD+/cgn+oHA6ddRnxSSXUKkkojkV78CmShZUxQF+yrBFs0SuW+JBOPG6hecyc/iZg=="], + + "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.74.0", "", { "os": "none", "cpu": "arm64" }, "sha512-mzbjrPl4neaVUiJ1fUiEUxTGaSZBoiKtaoB6jmIpz9S+VOA2vDYmJpihQ82w6178V5jxziclTg8Cgj5yF6tTDg=="], + + "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.74.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-vUAe9okpS2Oa5+lX67lqHMuNUvfkleRKwrUDJ/WJBsgmddvZ1mrsh2HVmuFDRzqFELhaJhFaCNOuR6a7L3rtIA=="], + + "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.74.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-yyXXJyYYSXL4I8K8jAWjJs+J3fa9gH2JmEbo4f5adm+1tNC9itseicBNuwK7BDHvqQ5J534s+yDULu89vYL2ZQ=="], + + "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.74.0", "", { "os": "win32", "cpu": "x64" }, "sha512-VTC9IYTIMrVUk/i6Ms1ohzzDKZFkWn0KU2OBbPBzgmVZ2V30165T/zK4LztTr0Xgp9fZ1qQZ1rsZAu/rEmySlA=="], + + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + + "@types/node": ["@types/node@20.19.43", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA=="], + + "@types/vscode": ["@types/vscode@1.90.0", "", {}, "sha512-oT+ZJL7qHS9Z8bs0+WKf/kQ27qWYR3trsXpq46YDjFqBsMLG4ygGGjPaJ2tyrH0wJzjOEmDyg9PDJBBhWg9pkQ=="], + + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + + "esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="], + + "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], + + "oxlint": ["oxlint@1.74.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.74.0", "@oxlint/binding-android-arm64": "1.74.0", "@oxlint/binding-darwin-arm64": "1.74.0", "@oxlint/binding-darwin-x64": "1.74.0", "@oxlint/binding-freebsd-x64": "1.74.0", "@oxlint/binding-linux-arm-gnueabihf": "1.74.0", "@oxlint/binding-linux-arm-musleabihf": "1.74.0", "@oxlint/binding-linux-arm64-gnu": "1.74.0", "@oxlint/binding-linux-arm64-musl": "1.74.0", "@oxlint/binding-linux-ppc64-gnu": "1.74.0", "@oxlint/binding-linux-riscv64-gnu": "1.74.0", "@oxlint/binding-linux-riscv64-musl": "1.74.0", "@oxlint/binding-linux-s390x-gnu": "1.74.0", "@oxlint/binding-linux-x64-gnu": "1.74.0", "@oxlint/binding-linux-x64-musl": "1.74.0", "@oxlint/binding-openharmony-arm64": "1.74.0", "@oxlint/binding-win32-arm64-msvc": "1.74.0", "@oxlint/binding-win32-ia32-msvc": "1.74.0", "@oxlint/binding-win32-x64-msvc": "1.74.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.24.0", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-odGl2s2x5IOJoj3A0v1k0PGBXVFBZeZ2+AK/+K2MJur7Ghi3bkyX5NuLUWHKqa4js1wjep3hJeuTQJOlr+4+dA=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + } +} diff --git a/editors/vscode/package.json b/editors/vscode/package.json new file mode 100644 index 000000000..4e621d8c9 --- /dev/null +++ b/editors/vscode/package.json @@ -0,0 +1,105 @@ +{ + "name": "openpencil-vscode", + "displayName": "OpenPencil", + "description": "OpenPencil design-as-code integration for VS Code: .op file editing, MCP configuration, AI skills, and code generation.", + "version": "0.0.1", + "publisher": "openpencil", + "private": true, + "license": "MIT", + "engines": { + "vscode": "^1.90.0" + }, + "categories": [ + "Other" + ], + "main": "./dist/extension.js", + "capabilities": { + "untrustedWorkspaces": { + "supported": "limited", + "description": "Without trust, .op files show a read-only placeholder; no local daemon is started and no MCP config is written." + } + }, + "contributes": { + "customEditors": [ + { + "viewType": "openpencil.penEditor", + "displayName": "OpenPencil", + "selector": [ + { + "filenamePattern": "*.op" + } + ], + "priority": "default" + } + ], + "commands": [ + { + "command": "openpencil.configureMcp", + "title": "OpenPencil: Configure MCP" + }, + { + "command": "openpencil.removeMcp", + "title": "OpenPencil: Remove MCP" + }, + { + "command": "openpencil.installSkill", + "title": "OpenPencil: Install Skill" + }, + { + "command": "openpencil.removeSkill", + "title": "OpenPencil: Remove Skill" + }, + { + "command": "openpencil.generateCode", + "title": "OpenPencil: Generate Code" + } + ], + "chatParticipants": [ + { + "id": "openpencil.assistant", + "name": "openpencil", + "description": "OpenPencil design assistant" + } + ], + "configuration": { + "title": "OpenPencil", + "properties": { + "openpencil.dev.daemonPath": { + "type": "string", + "default": "", + "description": "Path to the op-host-web-server daemon binary. When empty, probes /target/debug/op-host-web-server." + }, + "openpencil.proxy.port": { + "type": "number", + "default": 0, + "description": "Port for the local MCP proxy. 0 selects a port automatically." + }, + "openpencil.codegen.framework": { + "type": "string", + "enum": [ + "react", + "vue" + ], + "default": "react", + "description": "Target framework for generated code." + } + } + } + }, + "scripts": { + "build": "node build.mjs", + "watch": "node build.mjs --watch", + "test": "bun test", + "typecheck": "tsc --noEmit", + "lint": "oxlint" + }, + "devDependencies": { + "@types/vscode": "~1.90", + "@types/node": "^20", + "@types/bun": "latest", + "typescript": "^5", + "esbuild": "^0.21", + "jsonc-parser": "^3", + "oxlint": "latest" + } +} diff --git a/editors/vscode/src/daemon/daemon-client.test.ts b/editors/vscode/src/daemon/daemon-client.test.ts new file mode 100644 index 000000000..339a639b5 --- /dev/null +++ b/editors/vscode/src/daemon/daemon-client.test.ts @@ -0,0 +1,149 @@ +import { test, expect } from "bun:test"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { DaemonClient, type DaemonLogger, type SpawnOptions } from "./daemon-client"; + +const here = dirname(fileURLToPath(import.meta.url)); +const FIXTURE = join(here, "..", "..", "test", "fixtures", "fake-daemon.mjs"); + +interface CapturingLogger extends DaemonLogger { + lines: string[]; +} +function capturingLogger(): CapturingLogger { + const lines: string[] = []; + return { + lines, + info: (l) => lines.push(`info:${l}`), + error: (l) => lines.push(`error:${l}`), + }; +} + +function opts(extra: string[], override: Partial = {}): SpawnOptions { + return { + command: [process.execPath, FIXTURE, ...extra], + allowOrigin: "vscode-webview://test", + logger: capturingLogger(), + handshakeTimeoutMs: 2_000, + ...override, + }; +} + +async function expectSpawnRejects(o: SpawnOptions): Promise { + await expect(DaemonClient.spawn(o)).rejects.toThrow(); +} + +test("parses the handshake into port/token/version", async () => { + const client = await DaemonClient.spawn( + opts(["--fake-port", "45001", "--fake-token", "abc123", "--fake-version", "1.2.3"]), + ); + expect(client.handshake.port).toBe(45001); + expect(client.handshake.token).toBe("abc123"); + expect(client.handshake.version).toBe("1.2.3"); + expect(client.baseUrl).toBe("http://127.0.0.1:45001"); + expect(client.alive).toBe(true); + await client.dispose(); +}); + +test("forwards --serve-web/--managed/--file/--allow-origin verbatim, in order", async () => { + const logger = capturingLogger(); + const client = await DaemonClient.spawn( + opts(["--fake-port", "45002", "--echo-argv"], { + logger, + filePath: "/tmp/design.op", + allowOrigin: "vscode-webview://xyz", + }), + ); + await new Promise((r) => setTimeout(r, 200)); + const argvLine = logger.lines.find((l) => l.includes("argv ")); + expect(argvLine).toBeDefined(); + // The fixture echoes everything after its own prefix flags; the daemon flags + // the client appended must appear in the documented order. + const daemonArgs = argvLine!.slice(argvLine!.indexOf("--serve-web")); + expect(daemonArgs).toBe( + "--serve-web --managed --port 0 --file /tmp/design.op --allow-origin vscode-webview://xyz", + ); + await client.dispose(); +}); + +test("omits --file when no filePath is given", async () => { + const logger = capturingLogger(); + const client = await DaemonClient.spawn( + opts(["--fake-port", "45003", "--echo-argv"], { logger, allowOrigin: "vscode-webview://nofile" }), + ); + await new Promise((r) => setTimeout(r, 200)); + const argvLine = logger.lines.find((l) => l.includes("argv "))!; + const daemonArgs = argvLine.slice(argvLine.indexOf("--serve-web")); + expect(daemonArgs).toBe("--serve-web --managed --port 0 --allow-origin vscode-webview://nofile"); + await client.dispose(); +}); + +// Every reject path funnels through spawn()'s cleanup(), which kills the child +// and AWAITS its exit before the rejection surfaces. So a settled rejection is +// itself proof the child was reaped — no separate leak assertion is possible +// (spawn returns no client on failure), and the awaited cleanup guarantees no +// orphan survives the rejection. +test("rejects on handshake timeout (child reaped in cleanup)", async () => { + await expectSpawnRejects(opts(["--no-handshake"], { handshakeTimeoutMs: 300 })); +}); + +test("rejects on garbage handshake (child reaped in cleanup)", async () => { + await expectSpawnRejects(opts(["--garbage-handshake"])); +}); + +test("rejects on early exit before handshake (child already gone)", async () => { + await expectSpawnRejects(opts(["--early-exit"])); +}); + +// A daemon that half-writes then closes stdout while staying ALIVE does not +// deliver a parent-side EOF under this runtime (the live child keeps the pipe's +// write end referenced), so the handshake timeout is the backstop that catches +// it — and the timeout path runs the same cleanup(), so the child is still +// reaped. The client keeps defensive stdout end/error handlers regardless, so a +// runtime that DOES deliver EOF fast-fails instead of waiting for the timeout. +test("rejects a half-written-then-closed stdout via the timeout backstop", async () => { + await expectSpawnRejects(opts(["--close-stdout"], { handshakeTimeoutMs: 400 })); +}); + +test("redacts the token from forwarded log lines", async () => { + const logger = capturingLogger(); + const client = await DaemonClient.spawn( + opts(["--fake-token", "SECRETTOKEN", "--echo-log"], { logger }), + ); + // Give the post-handshake stderr line time to arrive. + await new Promise((r) => setTimeout(r, 200)); + const all = logger.lines.join("\n"); + expect(all).not.toContain("SECRETTOKEN"); + expect(all).toContain(""); + await client.dispose(); +}); + +test("never logs the token in plaintext across any line", async () => { + const logger = capturingLogger(); + const client = await DaemonClient.spawn( + opts(["--fake-token", "PLAINTEXTLEAK", "--echo-log"], { logger }), + ); + await new Promise((r) => setTimeout(r, 200)); + for (const line of logger.lines) expect(line).not.toContain("PLAINTEXTLEAK"); + await client.dispose(); +}); + +test("dispose closes stdin so the daemon self-exits cleanly (code 0, not killed)", async () => { + const client = await DaemonClient.spawn(opts(["--fake-port", "45010"])); + let exitCode: number | null = -1; + client.onExit((code) => { + exitCode = code; + }); + await client.dispose(); + expect(client.alive).toBe(false); + expect(exitCode).toBe(0); // stdin-EOF lease → graceful exit, not SIGKILL +}); + +test("logs a version-skew warning without failing", async () => { + const logger = capturingLogger(); + const client = await DaemonClient.spawn( + opts(["--fake-version", "0.0.1"], { logger, expectedVersion: "9.9.9" }), + ); + expect(client.handshake.version).toBe("0.0.1"); + expect(logger.lines.some((l) => l.includes("differs from expected"))).toBe(true); + await client.dispose(); +}); diff --git a/editors/vscode/src/daemon/daemon-client.ts b/editors/vscode/src/daemon/daemon-client.ts new file mode 100644 index 000000000..a536fda54 --- /dev/null +++ b/editors/vscode/src/daemon/daemon-client.ts @@ -0,0 +1,261 @@ +// Wraps `op-host-web-server --serve-web --managed` as a child process: spawns +// it, reads the single-line handshake JSON under a bounded timeout, redacts the +// token from every forwarded log line, and shuts it down via the stdin-EOF +// parent-death lease. Node-only (no vscode import) so it is fully unit-testable. + +import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process"; + +export interface DaemonHandshake { + port: number; + token: string; + version: string; +} + +export interface DaemonLogger { + info(line: string): void; + error(line: string): void; +} + +export interface SpawnOptions { + /** Full command line — tests inject [process.execPath, fixturePath]; prod + * injects [binaryPath]. Daemon args are appended after this prefix. */ + command: string[]; + filePath?: string; // --file + allowOrigin: string; // webview origin for --allow-origin + logger: DaemonLogger; + handshakeTimeoutMs?: number; // default 10_000 + /** Version the extension expects (from its own manifest metadata); a + * mismatching handshake version logs a warning (never a failure — dev + * builds drift), and the warning must NOT include the token. */ + expectedVersion?: string; +} + +const DEFAULT_HANDSHAKE_TIMEOUT_MS = 10_000; +const DISPOSE_SIGKILL_DELAY_MS = 3_000; +const MALFORMED_HANDSHAKE_PREVIEW_BYTES = 32; + +/** Wait for a child to exit, resolving immediately if it already has. */ +function awaitExit(child: ChildProcessWithoutNullStreams): Promise { + if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(); + return new Promise((resolve) => child.once("exit", () => resolve())); +} + +export class DaemonClient { + readonly handshake: DaemonHandshake; + + private readonly child: ChildProcessWithoutNullStreams; + private readonly exitCallbacks: Array<(code: number | null) => void> = []; + private exited = false; + private disposing?: Promise; + + private constructor( + child: ChildProcessWithoutNullStreams, + handshake: DaemonHandshake, + logger: DaemonLogger, + ) { + this.child = child; + this.handshake = handshake; + + child.once("exit", (code) => { + this.exited = true; + for (const cb of this.exitCallbacks) cb(code); + }); + + // Forward every post-handshake stdout/stderr line, redacting the token + // first — daemon diagnostics can echo URLs/headers carrying it. + this.forwardLines(child.stdout, (line) => logger.info(this.redact(line))); + this.forwardLines(child.stderr, (line) => logger.error(this.redact(line))); + } + + static async spawn(opts: SpawnOptions): Promise { + const [exe, ...prefix] = opts.command; + if (exe === undefined) { + throw new Error("DaemonClient.spawn: opts.command must not be empty"); + } + const args = [ + ...prefix, + "--serve-web", + "--managed", + "--port", + "0", + ...(opts.filePath ? ["--file", opts.filePath] : []), + "--allow-origin", + opts.allowOrigin, + ]; + + const child = spawn(exe, args, { stdio: ["pipe", "pipe", "pipe"] }); + const timeoutMs = opts.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS; + + // Every rejection path funnels through cleanup() so a rejected spawn (which + // returns no disposable client) can never leak a managed daemon. + const cleanup = async (): Promise => { + if (child.exitCode === null && child.signalCode === null) { + child.kill("SIGKILL"); + } + await awaitExit(child); + }; + + try { + const line = await readFirstLine(child, timeoutMs); + const handshake = parseHandshake(line); + if (opts.expectedVersion && opts.expectedVersion !== handshake.version) { + opts.logger.info( + `daemon version ${handshake.version} differs from expected ${opts.expectedVersion}`, + ); + } + return new DaemonClient(child, handshake, opts.logger); + } catch (err) { + await cleanup(); + // Redact any token-shaped content and cap length before surfacing. + throw new Error(`daemon handshake failed: ${previewError(err)}`); + } + } + + get baseUrl(): string { + return `http://127.0.0.1:${this.handshake.port}`; + } + + get alive(): boolean { + return !this.exited; + } + + onExit(cb: (code: number | null) => void): void { + this.exitCallbacks.push(cb); + } + + /** Closes stdin (parent-death lease), resolving when the process exits; + * SIGKILL fallback after 3s. Idempotent. */ + dispose(): Promise { + if (this.disposing) return this.disposing; + this.disposing = (async () => { + if (this.exited) return; + const exited = awaitExit(this.child); + this.child.stdin.end(); + const timer = setTimeout(() => { + if (!this.exited) this.child.kill("SIGKILL"); + }, DISPOSE_SIGKILL_DELAY_MS); + try { + await exited; + } finally { + clearTimeout(timer); + } + })(); + return this.disposing; + } + + private redact(line: string): string { + return line.split(this.handshake.token).join(""); + } + + private forwardLines( + stream: NodeJS.ReadableStream, + onLine: (line: string) => void, + ): void { + let buffer = ""; + stream.setEncoding("utf8"); + stream.on("data", (chunk: string) => { + buffer += chunk; + let idx: number; + while ((idx = buffer.indexOf("\n")) >= 0) { + const line = buffer.slice(0, idx); + buffer = buffer.slice(idx + 1); + if (line.length > 0) onLine(line); + } + }); + stream.on("end", () => { + if (buffer.length > 0) onLine(buffer); + }); + } +} + +/** Read the first newline-terminated line from stdout under a bounded timeout. + * Rejects on timeout, on the stream ending before a line arrives, or on a + * stream error. Detaches its own listeners on settle so the client's own + * forwarders take over cleanly. */ +function readFirstLine( + child: ChildProcessWithoutNullStreams, + timeoutMs: number, +): Promise { + return new Promise((resolve, reject) => { + const stdout = child.stdout; + let buffer = ""; + let settled = false; + + const cleanup = () => { + clearTimeout(timer); + stdout.removeListener("data", onData); + stdout.removeListener("end", onEnd); + stdout.removeListener("error", onError); + child.removeListener("exit", onExit); + }; + const done = (fn: () => void) => { + if (settled) return; + settled = true; + cleanup(); + // Leave the leftover bytes for the client's forwarder by pausing here; + // the forwarder re-attaches after spawn() resolves. + stdout.pause(); + fn(); + }; + + const timer = setTimeout( + () => done(() => reject(new Error("handshake timeout"))), + timeoutMs, + ); + const onData = (chunk: string) => { + buffer += chunk; + const idx = buffer.indexOf("\n"); + if (idx >= 0) { + const line = buffer.slice(0, idx); + done(() => resolve(line)); + } + }; + const onEnd = () => + done(() => reject(new Error("stdout closed before handshake"))); + const onError = (err: Error) => done(() => reject(err)); + const onExit = () => + done(() => reject(new Error("process exited before handshake"))); + + stdout.setEncoding("utf8"); + stdout.on("data", onData); + stdout.once("end", onEnd); + stdout.once("error", onError); + child.once("exit", onExit); + stdout.resume(); + }); +} + +function parseHandshake(line: string): DaemonHandshake { + let value: unknown; + try { + value = JSON.parse(line); + } catch { + throw new Error("handshake is not JSON"); + } + if (typeof value !== "object" || value === null) { + throw new Error("handshake is not an object"); + } + const rec = value as Record; + const { ok, port, token, version } = rec; + if (ok !== true) throw new Error('handshake missing "ok":true'); + if (typeof port !== "number" || !Number.isInteger(port) || port < 0 || port > 65535) { + throw new Error("handshake port invalid"); + } + if (typeof token !== "string" || token.length === 0) { + throw new Error("handshake token invalid"); + } + if (typeof version !== "string") { + throw new Error("handshake version invalid"); + } + return { port, token, version }; +} + +/** Redact token-shaped runs and cap length so a raw handshake line can never + * reach a log via an error message. We do not know the token on the failure + * path (parse failed), so cap by bytes — the preview cannot contain a full + * secret because a malformed handshake never produced a usable token. */ +function previewError(err: unknown): string { + const message = err instanceof Error ? err.message : String(err); + if (message.length <= MALFORMED_HANDSHAKE_PREVIEW_BYTES) return message; + return `${message.slice(0, MALFORMED_HANDSHAKE_PREVIEW_BYTES)}…`; +} diff --git a/editors/vscode/src/daemon/daemon-http.test.ts b/editors/vscode/src/daemon/daemon-http.test.ts new file mode 100644 index 000000000..905bd6bfe --- /dev/null +++ b/editors/vscode/src/daemon/daemon-http.test.ts @@ -0,0 +1,134 @@ +import { test, expect, afterEach } from "bun:test"; +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import { DaemonHttp } from "./daemon-http"; + +type Handler = (req: IncomingMessage, res: ServerResponse) => void; + +let server: Server | undefined; + +afterEach(async () => { + if (server) { + await new Promise((r) => server!.close(() => r())); + server = undefined; + } +}); + +/** Start a stub daemon on a random loopback port; returns its baseUrl. */ +async function stub(handler: Handler): Promise { + server = createServer(handler); + await new Promise((r) => server!.listen(0, "127.0.0.1", () => r())); + const addr = server.address(); + if (typeof addr !== "object" || addr === null) throw new Error("no address"); + return `http://127.0.0.1:${addr.port}`; +} + +const TOKEN = "tok-123"; + +test("attaches the token header to every request", async () => { + let seenToken: string | undefined; + const base = await stub((req, res) => { + seenToken = req.headers["x-openpencil-token"] as string | undefined; + res.writeHead(200, { "content-type": "application/json" }); + res.end('{"version":1}'); + }); + await new DaemonHttp(base, TOKEN).version(); + expect(seenToken).toBe(TOKEN); +}); + +test("version parses {\"version\":N}", async () => { + const base = await stub((_req, res) => { + res.writeHead(200, { "content-type": "application/json" }); + res.end('{"version":7}'); + }); + expect(await new DaemonHttp(base, TOKEN).version()).toBe(7); +}); + +test("version rejects a 401 (missing/wrong token)", async () => { + const base = await stub((_req, res) => { + res.writeHead(401, { "content-type": "application/json" }); + res.end('{"ok":false,"error":"unauthorized"}'); + }); + await expect(new DaemonHttp(base, TOKEN).version()).rejects.toThrow(); +}); + +test("version rejects negative / fractional / string versions", async () => { + for (const raw of ['{"version":-1}', '{"version":1.5}', '{"version":"7"}']) { + const base = await stub((_req, res) => { + res.writeHead(200, { "content-type": "application/json" }); + res.end(raw); + }); + await expect(new DaemonHttp(base, TOKEN).version()).rejects.toThrow(); + await new Promise((r) => server!.close(() => r())); + server = undefined; + } +}); + +test("ready is false when / returns 404 (bundle-less help page)", async () => { + const base = await stub((req, res) => { + if (req.url === "/") { + res.writeHead(404); + res.end("help page mentioning op_host_web.js"); + } else { + res.writeHead(200); + res.end("ok"); + } + }); + expect(await new DaemonHttp(base, TOKEN).ready()).toBe(false); +}); + +test("ready is false when / is 200 but /pkg/op_host_web.js is 404", async () => { + const base = await stub((req, res) => { + if (req.url === "/pkg/op_host_web.js") { + res.writeHead(404); + res.end("missing"); + } else { + res.writeHead(200); + res.end("shell"); + } + }); + expect(await new DaemonHttp(base, TOKEN).ready()).toBe(false); +}); + +test("ready is true when both / and /pkg/op_host_web.js are 200", async () => { + const base = await stub((_req, res) => { + res.writeHead(200); + res.end("ok"); + }); + expect(await new DaemonHttp(base, TOKEN).ready()).toBe(true); +}); + +test("getDocument returns the raw body", async () => { + const base = await stub((_req, res) => { + res.writeHead(200, { "content-type": "application/json" }); + res.end('{"document":{"version":"1.0.0","children":[]}}'); + }); + expect(await new DaemonHttp(base, TOKEN).getDocument()).toContain('"children":[]'); +}); + +test("mcpRaw passes the body through and returns status/headers/body", async () => { + let receivedBody = ""; + const base = await stub((req, res) => { + const chunks: Buffer[] = []; + req.on("data", (c) => chunks.push(c as Buffer)); + req.on("end", () => { + receivedBody = Buffer.concat(chunks).toString(); + res.writeHead(200, { "content-type": "application/json", "mcp-session-id": "s1" }); + res.end('{"jsonrpc":"2.0","id":1,"result":{}}'); + }); + }); + const out = await new DaemonHttp(base, TOKEN).mcpRaw( + '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}', + { "mcp-session-id": "s1" }, + ); + expect(receivedBody).toContain('"method":"initialize"'); + expect(out.status).toBe(200); + expect(out.headers["mcp-session-id"]).toBe("s1"); + expect(out.body).toContain('"result"'); +}); + +test("rejects when the daemon never responds (injected short timeout)", async () => { + const base = await stub(() => { + // never respond + }); + await expect(new DaemonHttp(base, TOKEN, 200).version()).rejects.toThrow(); +}); diff --git a/editors/vscode/src/daemon/daemon-http.ts b/editors/vscode/src/daemon/daemon-http.ts new file mode 100644 index 000000000..1734f28a8 --- /dev/null +++ b/editors/vscode/src/daemon/daemon-http.ts @@ -0,0 +1,90 @@ +// Token-authenticated HTTP client for the managed daemon's control endpoints. +// Node-only (global fetch, no vscode import). Document save/open deliberately do +// NOT go through here — those flow through the postMessage bridge's +// snapshot/open-document so the daemon's SyncGate stays authoritative; this +// layer only reads version, probes readiness, reads the raw document, and +// passes MCP requests through for the McpProxy. + +export class DaemonHttp { + private readonly baseUrl: string; + private readonly token: string; + private readonly timeoutMs: number; + + constructor(baseUrl: string, token: string, timeoutMs = 8_000) { + this.baseUrl = baseUrl.replace(/\/+$/, ""); + this.token = token; + this.timeoutMs = timeoutMs; + } + + /** GET /api/mcp/version → {"version":N} (note: no "ok" field). N must be a + * non-negative safe integer (mirrors the Rust u64), else reject. */ + async version(): Promise { + const res = await this.fetch("/api/mcp/version"); + if (res.status !== 200) { + throw new Error(`version: unexpected status ${res.status}`); + } + const body = (await res.json()) as unknown; + if (typeof body !== "object" || body === null) { + throw new Error("version: response is not an object"); + } + const v = (body as Record).version; + if (typeof v !== "number" || !Number.isSafeInteger(v) || v < 0) { + throw new Error(`version: invalid version value ${JSON.stringify(v)}`); + } + return v; + } + + /** The editor shell is servable only when both the root page AND the wasm + * glue return 200. The bundle-less daemon serves a 404 help page at `/` + * that itself mentions "op_host_web.js", so a body substring probe would + * false-positive — we probe HTTP status only. */ + async ready(): Promise { + const [root, glue] = await Promise.all([ + this.fetch("/"), + this.fetch("/pkg/op_host_web.js"), + ]); + // Drain bodies so the sockets can close promptly. + await Promise.all([root.text().catch(() => ""), glue.text().catch(() => "")]); + return root.status === 200 && glue.status === 200; + } + + async getDocument(): Promise { + const res = await this.fetch("/api/mcp/document"); + if (res.status !== 200) { + throw new Error(`getDocument: unexpected status ${res.status}`); + } + return await res.text(); + } + + /** POST /mcp passthrough for the McpProxy: forwards a raw JSON-RPC body and + * returns the status, headers, and body verbatim (plus the injected token). */ + async mcpRaw( + body: string, + extraHeaders?: Record, + ): Promise<{ status: number; headers: Record; body: string }> { + const res = await this.fetch("/mcp", { + method: "POST", + headers: { + "content-type": "application/json", + ...extraHeaders, + }, + body, + }); + const headers: Record = {}; + res.headers.forEach((value, key) => { + headers[key] = value; + }); + return { status: res.status, headers, body: await res.text() }; + } + + private fetch(path: string, init?: RequestInit): Promise { + return fetch(`${this.baseUrl}${path}`, { + ...init, + headers: { + "X-OpenPencil-Token": this.token, + ...init?.headers, + }, + signal: AbortSignal.timeout(this.timeoutMs), + }); + } +} diff --git a/editors/vscode/src/daemon/daemon-pool.test.ts b/editors/vscode/src/daemon/daemon-pool.test.ts new file mode 100644 index 000000000..0911b546a --- /dev/null +++ b/editors/vscode/src/daemon/daemon-pool.test.ts @@ -0,0 +1,164 @@ +import { test, expect } from "bun:test"; +import type { DaemonClient, DaemonLogger } from "./daemon-client"; +import { DaemonPool } from "./daemon-pool"; + +// A stand-in DaemonClient: records dispose, lets tests drive onExit + fail modes. +class FakeClient { + disposed = false; + private exitCbs: Array<(code: number | null) => void> = []; + constructor(public readonly id: number) {} + onExit(cb: (code: number | null) => void): void { + this.exitCbs.push(cb); + } + dispose(): Promise { + this.disposed = true; + return Promise.resolve(); + } + crash(): void { + for (const cb of this.exitCbs) cb(1); + } + get baseUrl(): string { + return `http://127.0.0.1:${this.id}`; + } +} + +function asClient(f: FakeClient): DaemonClient { + return f as unknown as DaemonClient; +} + +const silentLogger: DaemonLogger = { info: () => {}, error: () => {} }; + +function poolWithSpawns() { + const spawned: FakeClient[] = []; + let next = 1; + let failNext = false; + const spawn = async (): Promise => { + if (failNext) { + failNext = false; + throw new Error("spawn failed"); + } + const f = new FakeClient(next++); + spawned.push(f); + return asClient(f); + }; + const pool = new DaemonPool(spawn, silentLogger); + return { + pool, + spawned, + setFailNext: () => { + failNext = true; + }, + }; +} + +test("acquire spawns once per file; second acquire reuses", async () => { + const { pool, spawned } = poolWithSpawns(); + const a = await pool.acquire("/a.op", "vscode-webview://x"); + const b = await pool.acquire("/a.op", "vscode-webview://x"); + expect(spawned.length).toBe(1); + expect(a).toBe(b); +}); + +test("concurrent acquires for the same file coalesce onto one spawn", async () => { + const { pool, spawned } = poolWithSpawns(); + const [a, b] = await Promise.all([ + pool.acquire("/a.op", "o"), + pool.acquire("/a.op", "o"), + ]); + expect(spawned.length).toBe(1); + expect(a).toBe(b); +}); + +test("release disposes and evicts; re-acquire spawns fresh", async () => { + const { pool, spawned } = poolWithSpawns(); + await pool.acquire("/a.op", "o"); + await pool.release("/a.op"); + expect((spawned[0] as unknown as FakeClient).disposed).toBe(true); + await pool.acquire("/a.op", "o"); + expect(spawned.length).toBe(2); +}); + +test("setActive/active track the routing target; undefined clears", async () => { + const { pool } = poolWithSpawns(); + let changes = 0; + pool.onActiveChanged(() => { + changes += 1; + }); + const client = await pool.acquire("/a.op", "o"); + pool.setActive("/a.op"); + expect(pool.active?.filePath).toBe("/a.op"); + expect(pool.active?.client).toBe(client); + expect(changes).toBe(1); + pool.setActive(undefined); + expect(pool.active).toBeUndefined(); + expect(changes).toBe(2); +}); + +test("setActive to a file with no live daemon is treated as clear", async () => { + const { pool } = poolWithSpawns(); + pool.setActive("/never-spawned.op"); + expect(pool.active).toBeUndefined(); +}); + +test("crash restarts once and notifies with the new client", async () => { + const { pool, spawned } = poolWithSpawns(); + const restarts: Array<{ file: string; hasClient: boolean }> = []; + pool.onRestart((file, client) => restarts.push({ file, hasClient: client !== undefined })); + await pool.acquire("/a.op", "o"); + (spawned[0] as unknown as FakeClient).crash(); + await new Promise((r) => setTimeout(r, 0)); + expect(spawned.length).toBe(2); // respawned once + expect(restarts).toEqual([{ file: "/a.op", hasClient: true }]); + expect(pool.clientFor("/a.op")).toBe(asClient(spawned[1])); +}); + +test("second crash gives up, evicts, notifies with no client", async () => { + const { pool, spawned } = poolWithSpawns(); + const restarts: Array = []; + pool.onRestart((_file, client) => restarts.push(client !== undefined)); + await pool.acquire("/a.op", "o"); + (spawned[0] as unknown as FakeClient).crash(); + await new Promise((r) => setTimeout(r, 0)); + (spawned[1] as unknown as FakeClient).crash(); + await new Promise((r) => setTimeout(r, 0)); + expect(spawned.length).toBe(2); // no third spawn + expect(restarts).toEqual([true, false]); + expect(pool.clientFor("/a.op")).toBeUndefined(); +}); + +test("restart failure evicts and notifies with no client", async () => { + const { pool, spawned, setFailNext } = poolWithSpawns(); + const restarts: Array = []; + pool.onRestart((_f, client) => restarts.push(client !== undefined)); + await pool.acquire("/a.op", "o"); + setFailNext(); + (spawned[0] as unknown as FakeClient).crash(); + await new Promise((r) => setTimeout(r, 0)); + expect(restarts).toEqual([false]); + expect(pool.clientFor("/a.op")).toBeUndefined(); +}); + +test("dispose()-driven exit does not trigger a restart", async () => { + const { pool, spawned } = poolWithSpawns(); + let restartCalls = 0; + pool.onRestart(() => { + restartCalls += 1; + }); + await pool.acquire("/a.op", "o"); + await pool.release("/a.op"); // dispose → onExit fires but entry.disposed + (spawned[0] as unknown as FakeClient).crash(); // late exit signal + await new Promise((r) => setTimeout(r, 0)); + expect(restartCalls).toBe(0); + expect(spawned.length).toBe(1); +}); + +test("disposeAll disposes every client and clears active", async () => { + const { pool, spawned } = poolWithSpawns(); + await pool.acquire("/a.op", "o"); + await pool.acquire("/b.op", "o"); + pool.setActive("/a.op"); + await pool.disposeAll(); + expect(spawned.every((f) => (f as unknown as FakeClient).disposed)).toBe(true); + expect(pool.active).toBeUndefined(); + expect(pool.clientFor("/a.op")).toBeUndefined(); +}); diff --git a/editors/vscode/src/daemon/daemon-pool.ts b/editors/vscode/src/daemon/daemon-pool.ts new file mode 100644 index 000000000..885a0941a --- /dev/null +++ b/editors/vscode/src/daemon/daemon-pool.ts @@ -0,0 +1,143 @@ +// One managed daemon per open .op file. Concurrent acquires for the same file +// coalesce onto a single spawn. Tracks the routing "active" file (the McpProxy +// target) and applies the crash policy: a non-dispose exit respawns once, then +// evicts. No vscode import — the spawn factory is injected so it's unit-testable. + +import type { DaemonClient, DaemonLogger } from "./daemon-client"; + +type SpawnFn = (filePath: string, allowOrigin: string) => Promise; + +interface Entry { + client: DaemonClient; + allowOrigin: string; + restarts: number; + disposed: boolean; +} + +export class DaemonPool { + private readonly spawnFn: SpawnFn; + private readonly logger: DaemonLogger; + private readonly entries = new Map(); + private readonly inflight = new Map>(); + private activeFile?: string; + private readonly activeCbs: Array<() => void> = []; + private readonly restartCbs: Array<(filePath: string, client: DaemonClient | undefined) => void> = []; + + constructor(spawnFn: SpawnFn, logger: DaemonLogger) { + this.spawnFn = spawnFn; + this.logger = logger; + } + + /** Acquire the daemon for a file, spawning one if needed. Concurrent calls + * for the same file share a single spawn. */ + acquire(filePath: string, allowOrigin: string): Promise { + const existing = this.entries.get(filePath); + if (existing && !existing.disposed) return Promise.resolve(existing.client); + const pending = this.inflight.get(filePath); + if (pending) return pending; + + const p = this.spawnFn(filePath, allowOrigin).then((client) => { + this.inflight.delete(filePath); + const entry: Entry = { client, allowOrigin, restarts: 0, disposed: false }; + this.entries.set(filePath, entry); + this.wireExit(filePath, entry); + return client; + }); + // On spawn failure, clear the inflight slot so a later acquire can retry. + p.catch(() => this.inflight.delete(filePath)); + this.inflight.set(filePath, p); + return p; + } + + clientFor(filePath: string): DaemonClient | undefined { + const e = this.entries.get(filePath); + return e && !e.disposed ? e.client : undefined; + } + + setActive(filePath: string | undefined): void { + if (filePath !== undefined && !this.clientFor(filePath)) { + // Cannot route to a file with no live daemon; treat as clear. + filePath = undefined; + } + if (this.activeFile === filePath) return; + this.activeFile = filePath; + for (const cb of this.activeCbs) cb(); + } + + get active(): { filePath: string; client: DaemonClient } | undefined { + if (this.activeFile === undefined) return undefined; + const client = this.clientFor(this.activeFile); + return client ? { filePath: this.activeFile, client } : undefined; + } + + async release(filePath: string): Promise { + const entry = this.entries.get(filePath); + if (!entry) return; + entry.disposed = true; + this.entries.delete(filePath); + if (this.activeFile === filePath) this.setActive(undefined); + await entry.client.dispose(); + } + + async disposeAll(): Promise { + const all = [...this.entries.values()]; + this.entries.clear(); + this.inflight.clear(); + this.activeFile = undefined; + await Promise.all( + all.map((e) => { + e.disposed = true; + return e.client.dispose(); + }), + ); + } + + onActiveChanged(cb: () => void): void { + this.activeCbs.push(cb); + } + onRestart(cb: (filePath: string, client: DaemonClient | undefined) => void): void { + this.restartCbs.push(cb); + } + + private wireExit(filePath: string, entry: Entry): void { + entry.client.onExit(() => { + // A dispose()-driven exit is expected (entry.disposed) — ignore it. + if (entry.disposed) return; + void this.handleCrash(filePath, entry); + }); + } + + private async handleCrash(filePath: string, entry: Entry): Promise { + // Only the current entry for this file may drive a restart. + if (this.entries.get(filePath) !== entry) return; + if (entry.restarts >= 1) { + // Second crash: give up, evict, notify with no client. + this.logger.error(`daemon for ${filePath} crashed again; giving up`); + this.entries.delete(filePath); + if (this.activeFile === filePath) this.setActive(undefined); + this.emitRestart(filePath, undefined); + return; + } + this.logger.error(`daemon for ${filePath} exited; restarting once`); + try { + const client = await this.spawnFn(filePath, entry.allowOrigin); + const next: Entry = { client, allowOrigin: entry.allowOrigin, restarts: entry.restarts + 1, disposed: false }; + this.entries.set(filePath, next); + this.wireExit(filePath, next); + if (this.activeFile === filePath) { + // Re-announce active so listeners recompute against the new client. + for (const cb of this.activeCbs) cb(); + } + this.emitRestart(filePath, client); + } catch (err) { + this.logger.error(`daemon for ${filePath} restart failed: ${String(err)}`); + this.entries.delete(filePath); + if (this.activeFile === filePath) this.setActive(undefined); + this.emitRestart(filePath, undefined); + } + } + + private emitRestart(filePath: string, client: DaemonClient | undefined): void { + for (const cb of this.restartCbs) cb(filePath, client); + } +} diff --git a/editors/vscode/src/extension.ts b/editors/vscode/src/extension.ts new file mode 100644 index 000000000..fda4cd304 --- /dev/null +++ b/editors/vscode/src/extension.ts @@ -0,0 +1,12 @@ +import * as vscode from "vscode"; + +let outputChannel: vscode.OutputChannel | undefined; + +export function activate(_context: vscode.ExtensionContext): void { + outputChannel = vscode.window.createOutputChannel("OpenPencil"); +} + +export function deactivate(): void { + outputChannel?.dispose(); + outputChannel = undefined; +} diff --git a/editors/vscode/src/mcp/mcp-config.test.ts b/editors/vscode/src/mcp/mcp-config.test.ts new file mode 100644 index 000000000..039a2ee32 --- /dev/null +++ b/editors/vscode/src/mcp/mcp-config.test.ts @@ -0,0 +1,100 @@ +import { test, expect } from "bun:test"; +import { parse } from "jsonc-parser"; +import { + adapterFor, + detectIde, + McpConfigParseError, + type IdeKind, +} from "./mcp-config"; + +const URL = "http://127.0.0.1:41000/mcp"; + +function noDir(): boolean { + return false; +} + +test("detectIde: appName keywords win", () => { + expect(detectIde({ appName: "Cursor", hasDir: noDir })).toBe("cursor"); + expect(detectIde({ appName: "Trae AI", hasDir: noDir })).toBe("trae"); + expect(detectIde({ appName: "Windsurf", hasDir: noDir })).toBe("windsurf"); + expect(detectIde({ appName: "Visual Studio Code", hasDir: noDir })).toBe("vscode"); +}); + +test("detectIde: ambiguous appName falls back to marker directory", () => { + expect(detectIde({ appName: "Code - OSS", hasDir: (d) => d === ".cursor" })).toBe("cursor"); + expect(detectIde({ appName: "Code", hasDir: (d) => d === ".trae" })).toBe("trae"); + expect(detectIde({ appName: "Code", hasDir: (d) => d === ".windsurf" })).toBe("windsurf"); + expect(detectIde({ appName: "Code", hasDir: noDir })).toBe("vscode"); +}); + +test("configPath differs per IDE", () => { + expect(adapterFor("vscode").configPath("/w")).toBe("/w/.vscode/mcp.json"); + expect(adapterFor("cursor").configPath("/w")).toBe("/w/.cursor/mcp.json"); + expect(adapterFor("trae").configPath("/w")).toBe("/w/.trae/mcp.json"); + expect(adapterFor("windsurf").configPath("/w")).toBe("/w/.windsurf/mcp.json"); +}); + +test("vscode upsert into an empty file produces a servers.openpencil http entry", () => { + const out = adapterFor("vscode").upsert(null, URL); + const parsed = parse(out) as { servers: { openpencil: { type: string; url: string } } }; + expect(parsed.servers.openpencil.type).toBe("http"); + expect(parsed.servers.openpencil.url).toBe(URL); +}); + +test("fork adapters use mcpServers with a bare url entry", () => { + for (const kind of ["cursor", "trae", "windsurf"] as IdeKind[]) { + const out = adapterFor(kind).upsert(null, URL); + const parsed = parse(out) as { mcpServers: { openpencil: { url: string; type?: string } } }; + expect(parsed.mcpServers.openpencil.url).toBe(URL); + expect(parsed.mcpServers.openpencil.type).toBeUndefined(); + } +}); + +test("upsert preserves comments and other servers in existing JSONC", () => { + const existing = `{ + // my servers + "servers": { + "other": { "type": "http", "url": "http://other" } + } +}`; + const out = adapterFor("vscode").upsert(existing, URL); + expect(out).toContain("// my servers"); // comment preserved + const parsed = parse(out) as { servers: Record }; + expect(parsed.servers.other.url).toBe("http://other"); // untouched + expect(parsed.servers.openpencil.url).toBe(URL); // added +}); + +test("upsert overwrites an existing openpencil url, keeping siblings", () => { + const existing = `{"mcpServers":{"openpencil":{"url":"http://old","extra":1},"keep":{"url":"http://keep"}}}`; + const out = adapterFor("cursor").upsert(existing, URL); + const parsed = parse(out) as { mcpServers: Record }; + expect(parsed.mcpServers.openpencil.url).toBe(URL); + expect(parsed.mcpServers.openpencil.extra).toBe(1); // sibling field preserved + expect(parsed.mcpServers.keep.url).toBe("http://keep"); +}); + +test("upsert throws on malformed existing JSONC (never clobbers)", () => { + expect(() => adapterFor("vscode").upsert("{ not: json ", URL)).toThrow(McpConfigParseError); +}); + +test("remove deletes the openpencil entry, keeping the rest", () => { + const existing = `{"mcpServers":{"openpencil":{"url":"http://x"},"keep":{"url":"http://keep"}}}`; + const out = adapterFor("cursor").remove(existing); + expect(out).not.toBeNull(); + const parsed = parse(out!) as { mcpServers: Record }; + expect(parsed.mcpServers.openpencil).toBeUndefined(); + expect(parsed.mcpServers.keep).toBeDefined(); +}); + +test("remove returns null when there is nothing to remove", () => { + expect(adapterFor("cursor").remove(null)).toBeNull(); + expect(adapterFor("cursor").remove('{"mcpServers":{"other":{"url":"x"}}}')).toBeNull(); +}); + +test("no adapter output ever contains the token header name", () => { + for (const kind of ["vscode", "cursor", "trae", "windsurf"] as IdeKind[]) { + const out = adapterFor(kind).upsert(null, URL); + expect(out).not.toContain("X-OpenPencil-Token"); + expect(out.toLowerCase()).not.toContain("token"); + } +}); diff --git a/editors/vscode/src/mcp/mcp-config.ts b/editors/vscode/src/mcp/mcp-config.ts new file mode 100644 index 000000000..0f2dca520 --- /dev/null +++ b/editors/vscode/src/mcp/mcp-config.ts @@ -0,0 +1,114 @@ +// Per-IDE MCP config adapters. Pure text-in/text-out using jsonc-parser's +// modify+applyEdits so comments and trailing commas in an existing config are +// preserved. The config only ever carries the proxy URL — never a token (the +// two-tier credential contract keeps the daemon token in-process only). No +// vscode import; the command shell (configure-command.ts) drives these. + +import { applyEdits, modify, parse, type ParseError } from "jsonc-parser"; + +export type IdeKind = "vscode" | "cursor" | "trae" | "windsurf"; + +export interface IdeProbe { + appName: string; + /** Feature probe: does a marker directory (e.g. ".cursor") exist in the + * workspace or home? Corroborates an ambiguous appName. */ + hasDir(rel: string): boolean; +} + +/** Malformed existing JSONC — the caller must not overwrite it blindly. */ +export class McpConfigParseError extends Error { + constructor(readonly errors: ParseError[]) { + super("existing MCP config is not valid JSONC"); + } +} + +/** appName is the primary signal; a marker directory corroborates when the + * name is ambiguous (the spec requires appName + feature probe). */ +export function detectIde(probe: IdeProbe): IdeKind { + const name = probe.appName.toLowerCase(); + if (name.includes("cursor")) return "cursor"; + if (name.includes("trae")) return "trae"; + if (name.includes("windsurf")) return "windsurf"; + if (probe.hasDir(".cursor")) return "cursor"; + if (probe.hasDir(".trae")) return "trae"; + if (probe.hasDir(".windsurf")) return "windsurf"; + return "vscode"; +} + +export interface McpAdapter { + kind: IdeKind; + configPath(workspaceRoot: string): string; + /** Upsert the "openpencil" server entry, preserving everything else. Throws + * McpConfigParseError if existingText is present but not valid JSONC. */ + upsert(existingText: string | null, proxyUrl: string): string; + /** Remove the "openpencil" server entry, preserving everything else. Returns + * null if there was nothing to change (no file / entry absent). */ + remove(existingText: string | null): string | null; + needsReload: boolean; +} + +const MODIFY_OPTS = { + formattingOptions: { insertSpaces: true, tabSize: 2 }, +} as const; + +/** vscode uses `servers` with a typed http entry; the forks use `mcpServers` + * with a bare url entry. Fields are set individually so an existing + * openpencil entry's other keys are preserved (only url/type are overwritten). */ +function makeAdapter( + kind: IdeKind, + dir: string, + rootKey: "servers" | "mcpServers", + fields: (url: string) => Array<[string, unknown]>, + needsReload: boolean, +): McpAdapter { + const path = [rootKey, "openpencil"]; + return { + kind, + needsReload, + configPath: (root) => `${root}/${dir}/mcp.json`, + upsert(existingText, proxyUrl) { + let text = ensureObject(existingText); + for (const [field, value] of fields(proxyUrl)) { + // Apply sequentially: each modify is computed against the current text + // (offsets shift as edits land), and per-field writes preserve siblings. + const edits = modify(text, [...path, field], value, MODIFY_OPTS); + text = applyEdits(text, edits); + } + return text; + }, + remove(existingText) { + if (existingText === null) return null; + assertValidJsonc(existingText); + const current = parse(existingText) as Record | undefined; + const root = current?.[rootKey] as Record | undefined; + if (!root || !("openpencil" in root)) return null; // nothing to remove + const edits = modify(existingText, path, undefined, MODIFY_OPTS); + return applyEdits(existingText, edits); + }, + }; +} + +const ADAPTERS: Record = { + vscode: makeAdapter("vscode", ".vscode", "servers", (url) => [["type", "http"], ["url", url]], true), + cursor: makeAdapter("cursor", ".cursor", "mcpServers", (url) => [["url", url]], false), + trae: makeAdapter("trae", ".trae", "mcpServers", (url) => [["url", url]], false), + windsurf: makeAdapter("windsurf", ".windsurf", "mcpServers", (url) => [["url", url]], true), +}; + +export function adapterFor(kind: IdeKind): McpAdapter { + return ADAPTERS[kind]; +} + +/** Null/empty → a fresh object to modify; otherwise validate the existing JSONC + * (never clobber a malformed user file). */ +function ensureObject(existingText: string | null): string { + if (existingText === null || existingText.trim() === "") return "{}"; + assertValidJsonc(existingText); + return existingText; +} + +function assertValidJsonc(text: string): void { + const errors: ParseError[] = []; + parse(text, errors, { allowTrailingComma: true }); + if (errors.length > 0) throw new McpConfigParseError(errors); +} diff --git a/editors/vscode/src/mcp/mcp-proxy.test.ts b/editors/vscode/src/mcp/mcp-proxy.test.ts new file mode 100644 index 000000000..0308e50af --- /dev/null +++ b/editors/vscode/src/mcp/mcp-proxy.test.ts @@ -0,0 +1,173 @@ +import { test, expect, afterEach } from "bun:test"; +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import { McpProxy } from "./mcp-proxy"; +import type { DaemonLogger } from "../daemon/daemon-client"; + +const silent: DaemonLogger = { info: () => {}, error: () => {} }; + +interface FakeDaemon { + baseUrl: string; + token: string; + server: Server; + lastToken?: string; + lastBody?: string; +} + +const daemons: FakeDaemon[] = []; +let proxy: McpProxy | undefined; + +afterEach(async () => { + if (proxy) { + await proxy.dispose(); + proxy = undefined; + } + for (const d of daemons.splice(0)) { + await new Promise((r) => d.server.close(() => r())); + } +}); + +async function fakeDaemon(token: string, respond: (body: string) => { status: number; body: string; sessionId?: string }): Promise { + const server = createServer((req: IncomingMessage, res: ServerResponse) => { + d.lastToken = req.headers["x-openpencil-token"] as string | undefined; + const chunks: Buffer[] = []; + req.on("data", (c) => chunks.push(c as Buffer)); + req.on("end", () => { + d.lastBody = Buffer.concat(chunks).toString(); + const out = respond(d.lastBody!); + const headers: Record = { "content-type": "application/json" }; + if (out.sessionId) headers["mcp-session-id"] = out.sessionId; + res.writeHead(out.status, headers).end(out.body); + }); + }); + await new Promise((r) => server.listen(0, "127.0.0.1", () => r())); + const addr = server.address(); + if (typeof addr !== "object" || addr === null) throw new Error("no addr"); + // One object shared by the request handler and the caller. + const d: FakeDaemon = { token, server, baseUrl: `http://127.0.0.1:${addr.port}` }; + daemons.push(d); + return d; +} + +/** A mutable pool stub: set `.current` to switch the active daemon. */ +class PoolStub { + current?: FakeDaemon; + private cbs: Array<() => void> = []; + get active() { + if (!this.current) return undefined; + return { + filePath: "/a.op", + client: { baseUrl: this.current.baseUrl, handshake: { token: this.current.token } }, + }; + } + onActiveChanged(cb: () => void) { + this.cbs.push(cb); + } +} + +async function startProxy(pool: PoolStub): Promise<{ port: number; url: string }> { + proxy = new McpProxy(pool, silent); + const port = await proxy.listen(0); + return { port, url: `http://127.0.0.1:${port}/mcp` }; +} + +const INIT = '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}'; + +test("no active document → -32002 error echoing the request id", async () => { + const pool = new PoolStub(); + const { url } = await startProxy(pool); + const res = await fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: INIT }); + expect(res.status).toBe(200); + const json = (await res.json()) as { id: number; error: { code: number; message: string } }; + expect(json.id).toBe(1); + expect(json.error.code).toBe(-32002); + expect(json.error.message).toContain("No active OpenPencil document"); +}); + +test("active daemon → forwards with the token injected, returns its response", async () => { + const d = await fakeDaemon("TOKEN-A", () => ({ status: 200, body: '{"jsonrpc":"2.0","id":1,"result":{"ok":true}}' })); + const pool = new PoolStub(); + pool.current = d; + const { url } = await startProxy(pool); + const res = await fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: INIT }); + expect(res.status).toBe(200); + expect(d.lastToken).toBe("TOKEN-A"); + expect(d.lastBody).toContain('"method":"initialize"'); + const json = (await res.json()) as { result: { ok: boolean } }; + expect(json.result.ok).toBe(true); +}); + +test("switching active routes subsequent requests to the new daemon", async () => { + const a = await fakeDaemon("TA", () => ({ status: 200, body: '{"jsonrpc":"2.0","id":1,"result":"A"}' })); + const b = await fakeDaemon("TB", () => ({ status: 200, body: '{"jsonrpc":"2.0","id":1,"result":"B"}' })); + const pool = new PoolStub(); + pool.current = a; + const { url } = await startProxy(pool); + let json = (await (await fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: INIT })).json()) as { result: string }; + expect(json.result).toBe("A"); + pool.current = b; + json = (await (await fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: INIT })).json()) as { result: string }; + expect(json.result).toBe("B"); + expect(b.lastToken).toBe("TB"); +}); + +test("a request carrying an Origin header is rejected 403 (DNS-rebinding defense)", async () => { + const pool = new PoolStub(); + const { url } = await startProxy(pool); + const res = await fetch(url, { + method: "POST", + headers: { "content-type": "application/json", origin: "http://evil.example" }, + body: INIT, + }); + expect(res.status).toBe(403); +}); + +test("a bad Host header is rejected 403", async () => { + const pool = new PoolStub(); + const { port } = await startProxy(pool); + // Send a raw request with a spoofed Host via a manual socket. + const { connect } = await import("node:net"); + const status = await new Promise((resolve) => { + const sock = connect(port, "127.0.0.1", () => { + const body = INIT; + sock.write( + `POST /mcp HTTP/1.1\r\nHost: evil.com\r\nContent-Type: application/json\r\nContent-Length: ${Buffer.byteLength(body)}\r\nConnection: close\r\n\r\n${body}`, + ); + }); + let raw = ""; + sock.setEncoding("utf8"); + sock.on("data", (d) => (raw += d)); + sock.on("end", () => resolve(Number(raw.split(" ")[1]))); + }); + expect(status).toBe(403); +}); + +test("non-POST or non-/mcp paths are 404", async () => { + const pool = new PoolStub(); + const { port } = await startProxy(pool); + const get = await fetch(`http://127.0.0.1:${port}/mcp`, { method: "GET" }); + expect(get.status).toBe(404); + const wrong = await fetch(`http://127.0.0.1:${port}/other`, { method: "POST", body: "{}" }); + expect(wrong.status).toBe(404); +}); + +test("upstream 500 is passed through verbatim", async () => { + const d = await fakeDaemon("T", () => ({ status: 500, body: '{"jsonrpc":"2.0","id":1,"error":{"code":-32603}}' })); + const pool = new PoolStub(); + pool.current = d; + const { url } = await startProxy(pool); + const res = await fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: INIT }); + expect(res.status).toBe(500); +}); + +test("mcp-session-id is forwarded upstream and returned downstream", async () => { + const d = await fakeDaemon("T", () => ({ status: 200, body: "{}", sessionId: "sess-9" })); + const pool = new PoolStub(); + pool.current = d; + const { url } = await startProxy(pool); + const res = await fetch(url, { + method: "POST", + headers: { "content-type": "application/json", "mcp-session-id": "sess-9" }, + body: INIT, + }); + expect(res.headers.get("mcp-session-id")).toBe("sess-9"); +}); diff --git a/editors/vscode/src/mcp/mcp-proxy.ts b/editors/vscode/src/mcp/mcp-proxy.ts new file mode 100644 index 000000000..567dc4908 --- /dev/null +++ b/editors/vscode/src/mcp/mcp-proxy.ts @@ -0,0 +1,148 @@ +// Stable loopback MCP endpoint. IDE AI agents point at a single, port-stable +// URL (written once into their MCP config); every request is routed to the +// CURRENTLY active .op document's daemon — so a daemon restart on a new port is +// transparent to the client. No vscode import (the port-persistence lives in +// extension.ts); the pool is injected as a narrow interface. + +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import type { DaemonLogger } from "../daemon/daemon-client"; + +interface RoutableClient { + readonly baseUrl: string; + readonly handshake: { token: string }; +} +interface ActivePool { + readonly active: { filePath: string; client: RoutableClient } | undefined; + onActiveChanged(cb: () => void): void; +} + +const NO_ACTIVE_MESSAGE = "No active OpenPencil document. Open a .op file in the editor first."; + +export class McpProxy { + private readonly pool: ActivePool; + private readonly logger: DaemonLogger; + private server?: Server; + private boundPort = 0; + + constructor(pool: ActivePool, logger: DaemonLogger) { + this.pool = pool; + this.logger = logger; + } + + /** Bind to 127.0.0.1 on preferredPort (0 = OS-assigned); returns the actual + * port. Rejects if the port is taken so the caller can pick another. */ + listen(preferredPort: number): Promise { + return new Promise((resolve, reject) => { + const server = createServer((req, res) => void this.handle(req, res)); + server.on("error", reject); + server.listen(preferredPort, "127.0.0.1", () => { + server.removeListener("error", reject); + const addr = server.address(); + if (typeof addr !== "object" || addr === null) { + reject(new Error("proxy: no bound address")); + return; + } + this.server = server; + this.boundPort = addr.port; + resolve(addr.port); + }); + }); + } + + get port(): number { + return this.boundPort; + } + + dispose(): Promise { + const server = this.server; + this.server = undefined; + if (!server) return Promise.resolve(); + return new Promise((resolve) => server.close(() => resolve())); + } + + private async handle(req: IncomingMessage, res: ServerResponse): Promise { + if (req.method !== "POST" || req.url !== "/mcp") { + res.writeHead(404).end(); + return; + } + // Loopback cross-site defenses: MCP clients send no Origin; a request that + // carries one is a browser (DNS-rebinding vector) → reject. The Host header + // must name our own loopback authority. + if (req.headers.origin !== undefined) { + res.writeHead(403).end("origin not allowed"); + return; + } + if (!this.hostAllowed(req.headers.host)) { + res.writeHead(403).end("host not allowed"); + return; + } + + const body = await readBody(req); + const active = this.pool.active; + if (!active) { + const id = extractJsonRpcId(body); + const payload = JSON.stringify({ + jsonrpc: "2.0", + id, + error: { code: -32002, message: NO_ACTIVE_MESSAGE }, + }); + res.writeHead(200, { "content-type": "application/json" }).end(payload); + return; + } + + try { + const forwardHeaders: Record = { + "content-type": "application/json", + "X-OpenPencil-Token": active.client.handshake.token, + }; + const sessionId = req.headers["mcp-session-id"]; + if (typeof sessionId === "string") forwardHeaders["mcp-session-id"] = sessionId; + + const upstream = await fetch(`${active.client.baseUrl}/mcp`, { + method: "POST", + headers: forwardHeaders, + body, + }); + const upstreamBody = await upstream.text(); + const outHeaders: Record = { "content-type": "application/json" }; + const upstreamSession = upstream.headers.get("mcp-session-id"); + if (upstreamSession) outHeaders["mcp-session-id"] = upstreamSession; + res.writeHead(upstream.status, outHeaders).end(upstreamBody); + } catch (err) { + this.logger.error(`mcp proxy forward failed: ${String(err)}`); + const id = extractJsonRpcId(body); + res + .writeHead(502, { "content-type": "application/json" }) + .end(JSON.stringify({ jsonrpc: "2.0", id, error: { code: -32000, message: "daemon unreachable" } })); + } + } + + private hostAllowed(host: string | undefined): boolean { + if (!host) return false; + return host === `127.0.0.1:${this.boundPort}` || host === `localhost:${this.boundPort}`; + } +} + +function readBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on("data", (c: Buffer) => chunks.push(c)); + req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); + req.on("error", reject); + }); +} + +/** Best-effort JSON-RPC id for an error echo; null if the body isn't parseable + * or has no id (JSON-RPC allows a null id on errors). */ +function extractJsonRpcId(body: string): number | string | null { + try { + const v = JSON.parse(body) as unknown; + if (typeof v === "object" && v !== null) { + const id = (v as Record).id; + if (typeof id === "number" || typeof id === "string") return id; + } + } catch { + /* fall through */ + } + return null; +} diff --git a/editors/vscode/src/protocol/bridge.test.ts b/editors/vscode/src/protocol/bridge.test.ts new file mode 100644 index 000000000..5d27f2689 --- /dev/null +++ b/editors/vscode/src/protocol/bridge.test.ts @@ -0,0 +1,208 @@ +import { test, expect } from "bun:test"; +import { encodeOutbound, parseInboundFromPage, type BridgeInboundFromPage, type BridgeOutboundToPage } from "./bridge"; + +// --------------------------------------------------------------------------- +// encodeOutbound — one sample per BridgeOutboundToPage variant, round-tripped +// through JSON.parse to confirm the wire shape matches Rust's field names. +// --------------------------------------------------------------------------- + +test("encodeOutbound: init", () => { + const msg: BridgeOutboundToPage = { type: "op-bridge/init", token: "t0k" }; + expect(JSON.parse(encodeOutbound(msg))).toEqual({ type: "op-bridge/init", token: "t0k" }); +}); + +test("encodeOutbound: open-document", () => { + const msg: BridgeOutboundToPage = { type: "op-bridge/open-document", json: '{"a":1}' }; + expect(JSON.parse(encodeOutbound(msg))).toEqual({ type: "op-bridge/open-document", json: '{"a":1}' }); +}); + +test("encodeOutbound: snapshot", () => { + const msg: BridgeOutboundToPage = { type: "op-bridge/snapshot", purpose: "backup", requestId: "r1" }; + expect(JSON.parse(encodeOutbound(msg))).toEqual({ + type: "op-bridge/snapshot", + purpose: "backup", + requestId: "r1", + }); +}); + +test("encodeOutbound: save-committed", () => { + const msg: BridgeOutboundToPage = { type: "op-bridge/save-committed", generation: 3, revision: 41 }; + expect(JSON.parse(encodeOutbound(msg))).toEqual({ + type: "op-bridge/save-committed", + generation: 3, + revision: 41, + }); +}); + +test("encodeOutbound: resolve-conflict", () => { + const msg: BridgeOutboundToPage = { type: "op-bridge/resolve-conflict", mode: "use-local", requestId: "r1" }; + expect(encodeOutbound(msg)).toBe( + '{"type":"op-bridge/resolve-conflict","mode":"use-local","requestId":"r1"}', + ); +}); + +// --------------------------------------------------------------------------- +// parseInboundFromPage — one legal sample per BridgeInboundFromPage variant. +// Field names/values copied verbatim from bridge_protocol.rs's event_* output. +// --------------------------------------------------------------------------- + +test("parseInboundFromPage: ready", () => { + const raw = '{"type":"op-bridge/ready","generation":2,"revision":17}'; + const expected: BridgeInboundFromPage = { type: "op-bridge/ready", generation: 2, revision: 17 }; + expect(parseInboundFromPage(raw)).toEqual(expected); +}); + +test("parseInboundFromPage: dirty-changed", () => { + const raw = '{"type":"op-bridge/dirty-changed","generation":2,"revision":17,"dirty":true}'; + const expected: BridgeInboundFromPage = { + type: "op-bridge/dirty-changed", + generation: 2, + revision: 17, + dirty: true, + }; + expect(parseInboundFromPage(raw)).toEqual(expected); +}); + +test("parseInboundFromPage: opened", () => { + const raw = '{"type":"op-bridge/opened","generation":5}'; + const expected: BridgeInboundFromPage = { type: "op-bridge/opened", generation: 5 }; + expect(parseInboundFromPage(raw)).toEqual(expected); +}); + +test("parseInboundFromPage: snapshot-result", () => { + const raw = '{"type":"op-bridge/snapshot-result","requestId":"r1","docJson":"{}","generation":2,"revision":17}'; + const expected: BridgeInboundFromPage = { + type: "op-bridge/snapshot-result", + requestId: "r1", + docJson: "{}", + generation: 2, + revision: 17, + }; + expect(parseInboundFromPage(raw)).toEqual(expected); +}); + +test("parseInboundFromPage: snapshot-conflict", () => { + const raw = '{"type":"op-bridge/snapshot-conflict","requestId":"r1","serverVersion":12}'; + const expected: BridgeInboundFromPage = { + type: "op-bridge/snapshot-conflict", + requestId: "r1", + serverVersion: 12, + }; + expect(parseInboundFromPage(raw)).toEqual(expected); +}); + +test("parseInboundFromPage: sync-conflict", () => { + const raw = '{"type":"op-bridge/sync-conflict","generation":2,"revision":5,"serverVersion":12}'; + const expected: BridgeInboundFromPage = { + type: "op-bridge/sync-conflict", + generation: 2, + revision: 5, + serverVersion: 12, + }; + expect(parseInboundFromPage(raw)).toEqual(expected); +}); + +test("parseInboundFromPage: conflict-resolved", () => { + const raw = '{"type":"op-bridge/conflict-resolved","requestId":"r1"}'; + const expected: BridgeInboundFromPage = { type: "op-bridge/conflict-resolved", requestId: "r1" }; + expect(parseInboundFromPage(raw)).toEqual(expected); +}); + +// --------------------------------------------------------------------------- +// Rejection cases — foreign traffic, malformed payloads, wrong field types. +// Mirrors bridge_protocol.rs::tests::parse_inbound_messages. +// --------------------------------------------------------------------------- + +test("parseInboundFromPage: unknown type -> null (react-devtools, etc.)", () => { + expect(parseInboundFromPage('{"type":"react-devtools"}')).toBeNull(); +}); + +test("parseInboundFromPage: not JSON -> null", () => { + expect(parseInboundFromPage("not json")).toBeNull(); +}); + +test("parseInboundFromPage: non-string input -> null", () => { + expect(parseInboundFromPage(undefined)).toBeNull(); + expect(parseInboundFromPage(null)).toBeNull(); + expect(parseInboundFromPage(42)).toBeNull(); + expect(parseInboundFromPage({ type: "op-bridge/opened", generation: 1 })).toBeNull(); + expect(parseInboundFromPage(["op-bridge/opened"])).toBeNull(); +}); + +test("parseInboundFromPage: JSON array or scalar -> null", () => { + expect(parseInboundFromPage("[1,2,3]")).toBeNull(); + expect(parseInboundFromPage("42")).toBeNull(); + expect(parseInboundFromPage('"a string"')).toBeNull(); + expect(parseInboundFromPage("null")).toBeNull(); +}); + +test("parseInboundFromPage: missing type field -> null", () => { + expect(parseInboundFromPage('{"generation":1}')).toBeNull(); +}); + +test("parseInboundFromPage: non-string type field -> null", () => { + expect(parseInboundFromPage('{"type":123}')).toBeNull(); +}); + +test("parseInboundFromPage: missing required field -> null", () => { + expect(parseInboundFromPage('{"type":"op-bridge/ready","generation":2}')).toBeNull(); + expect(parseInboundFromPage('{"type":"op-bridge/opened"}')).toBeNull(); + expect(parseInboundFromPage('{"type":"op-bridge/conflict-resolved"}')).toBeNull(); +}); + +test("parseInboundFromPage: wrong field type -> null", () => { + // generation as a string, not a number. + expect(parseInboundFromPage('{"type":"op-bridge/opened","generation":"5"}')).toBeNull(); + // dirty as a number, not a boolean. + expect( + parseInboundFromPage('{"type":"op-bridge/dirty-changed","generation":1,"revision":1,"dirty":1}'), + ).toBeNull(); + // requestId as a number, not a string. + expect( + parseInboundFromPage('{"type":"op-bridge/conflict-resolved","requestId":7}'), + ).toBeNull(); +}); + +test("parseInboundFromPage: u64 field rejects negative, fractional, non-numeric", () => { + expect(parseInboundFromPage('{"type":"op-bridge/opened","generation":-1}')).toBeNull(); + expect(parseInboundFromPage('{"type":"op-bridge/opened","generation":1.5}')).toBeNull(); + expect(parseInboundFromPage('{"type":"op-bridge/opened","generation":null}')).toBeNull(); + expect(parseInboundFromPage('{"type":"op-bridge/opened","generation":true}')).toBeNull(); + // Past Number.MAX_SAFE_INTEGER — Rust's u64 range exceeds JS's safe-integer + // range, so values beyond it must be rejected rather than silently rounded. + expect( + parseInboundFromPage(`{"type":"op-bridge/opened","generation":${Number.MAX_SAFE_INTEGER + 2}}`), + ).toBeNull(); + // Zero is a valid u64. + expect(parseInboundFromPage('{"type":"op-bridge/opened","generation":0}')).toEqual({ + type: "op-bridge/opened", + generation: 0, + }); +}); + +// --------------------------------------------------------------------------- +// docJson escaped-quote round trip (snapshot-result payload carries an +// embedded JSON document as a string, mirroring +// bridge_protocol.rs::tests::snapshot_result_json_escapes_doc_payload). +// --------------------------------------------------------------------------- + +test("parseInboundFromPage: snapshot-result docJson round-trips escaped quotes", () => { + const innerDoc = '{"a":"b \\" c"}'; + const raw = JSON.stringify({ + type: "op-bridge/snapshot-result", + requestId: "r1", + docJson: innerDoc, + generation: 2, + revision: 17, + }); + const parsed = parseInboundFromPage(raw); + expect(parsed).toEqual({ + type: "op-bridge/snapshot-result", + requestId: "r1", + docJson: innerDoc, + generation: 2, + revision: 17, + }); + // The inner docJson string itself parses back into the original object. + expect(JSON.parse((parsed as { docJson: string }).docJson)).toEqual({ a: 'b " c' }); +}); diff --git a/editors/vscode/src/protocol/bridge.ts b/editors/vscode/src/protocol/bridge.ts new file mode 100644 index 000000000..e6c98c63f --- /dev/null +++ b/editors/vscode/src/protocol/bridge.ts @@ -0,0 +1,124 @@ +// `postMessage` bridge protocol codec between the VS Code extension host +// and the wasm-backed web editor. This is a TypeScript mirror of the Rust +// wire codec in `crates/op-editor-core/src/bridge_protocol.rs` — the Rust +// source is authoritative; keep this file in lockstep with it. +// +// Wire format is a JSON STRING on both directions (Rust `parse(raw: &str)` +// / `event_*() -> String`). Inbound parsing must never throw: foreign +// postMessage traffic (e.g. react-devtools) is ignored, not treated as an +// error. +// +// Message `type` values — outbound (extension → page): `op-bridge/init`, +// `op-bridge/open-document`, `op-bridge/snapshot`, `op-bridge/save-committed`, +// `op-bridge/resolve-conflict`; inbound (page → extension): `op-bridge/ready`, +// `op-bridge/dirty-changed`, `op-bridge/opened`, `op-bridge/snapshot-result`, +// `op-bridge/snapshot-conflict`, `op-bridge/sync-conflict`, +// `op-bridge/conflict-resolved`. Field names are camelCase (`requestId`, +// `serverVersion`, `docJson`). + +export type BridgeOutboundToPage = + | { type: "op-bridge/init"; token: string } + | { type: "op-bridge/open-document"; json: string } + | { type: "op-bridge/snapshot"; purpose: "save" | "backup" | "conflict-backup"; requestId: string } + | { type: "op-bridge/save-committed"; generation: number; revision: number } + | { type: "op-bridge/resolve-conflict"; mode: "use-local" | "accept-remote"; requestId: string }; + +export type BridgeInboundFromPage = + | { type: "op-bridge/ready"; generation: number; revision: number } + | { type: "op-bridge/dirty-changed"; generation: number; revision: number; dirty: boolean } + | { type: "op-bridge/opened"; generation: number } + | { type: "op-bridge/snapshot-result"; requestId: string; docJson: string; generation: number; revision: number } + | { type: "op-bridge/snapshot-conflict"; requestId: string; serverVersion: number } + | { type: "op-bridge/sync-conflict"; generation: number; revision: number; serverVersion: number } + | { type: "op-bridge/conflict-resolved"; requestId: string }; + +/** Serializes an outbound message to the wire JSON-string format. */ +export function encodeOutbound(msg: BridgeOutboundToPage): string { + return JSON.stringify(msg); +} + +/** u64 field validation mirroring Rust's `Value::as_u64()`: a JSON number + * that is a non-negative safe integer. Rejects negatives, fractions, + * strings, and anything past Number.MAX_SAFE_INTEGER. */ +function isU64(x: unknown): x is number { + return typeof x === "number" && Number.isSafeInteger(x) && x >= 0; +} + +function isNonEmptyRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** Parses a raw inbound `postMessage` payload. Returns null for anything + * that isn't a well-formed, known bridge message — foreign traffic (e.g. + * react-devtools) must be ignored, never thrown on. Mirror of Rust's + * `BridgeInbound::parse -> Option`. */ +export function parseInboundFromPage(raw: unknown): BridgeInboundFromPage | null { + if (typeof raw !== "string") return null; + + let value: unknown; + try { + value = JSON.parse(raw); + } catch { + return null; + } + if (!isNonEmptyRecord(value)) return null; + + const ty = value["type"]; + if (typeof ty !== "string") return null; + + switch (ty) { + case "op-bridge/ready": { + const generation = value["generation"]; + const revision = value["revision"]; + if (!isU64(generation) || !isU64(revision)) return null; + return { type: "op-bridge/ready", generation, revision }; + } + case "op-bridge/dirty-changed": { + const generation = value["generation"]; + const revision = value["revision"]; + const dirty = value["dirty"]; + if (!isU64(generation) || !isU64(revision) || typeof dirty !== "boolean") return null; + return { type: "op-bridge/dirty-changed", generation, revision, dirty }; + } + case "op-bridge/opened": { + const generation = value["generation"]; + if (!isU64(generation)) return null; + return { type: "op-bridge/opened", generation }; + } + case "op-bridge/snapshot-result": { + const requestId = value["requestId"]; + const docJson = value["docJson"]; + const generation = value["generation"]; + const revision = value["revision"]; + if ( + typeof requestId !== "string" || + typeof docJson !== "string" || + !isU64(generation) || + !isU64(revision) + ) { + return null; + } + return { type: "op-bridge/snapshot-result", requestId, docJson, generation, revision }; + } + case "op-bridge/snapshot-conflict": { + const requestId = value["requestId"]; + const serverVersion = value["serverVersion"]; + if (typeof requestId !== "string" || !isU64(serverVersion)) return null; + return { type: "op-bridge/snapshot-conflict", requestId, serverVersion }; + } + case "op-bridge/sync-conflict": { + const generation = value["generation"]; + const revision = value["revision"]; + const serverVersion = value["serverVersion"]; + if (!isU64(generation) || !isU64(revision) || !isU64(serverVersion)) return null; + return { type: "op-bridge/sync-conflict", generation, revision, serverVersion }; + } + case "op-bridge/conflict-resolved": { + const requestId = value["requestId"]; + if (typeof requestId !== "string") return null; + return { type: "op-bridge/conflict-resolved", requestId }; + } + default: + return null; + } +} diff --git a/editors/vscode/src/session/pen-session.test.ts b/editors/vscode/src/session/pen-session.test.ts new file mode 100644 index 000000000..6fe35017e --- /dev/null +++ b/editors/vscode/src/session/pen-session.test.ts @@ -0,0 +1,411 @@ +import { test, expect } from "bun:test"; +import type { BridgeOutboundToPage } from "../protocol/bridge"; +import { + CancelledError, + ConflictPendingError, + DisposedError, + NotReadyError, + PenSession, + type SessionHost, +} from "./pen-session"; + +// ---- MockHost: records outbound messages + host effects, with a manual timer +// queue and controllable dialog/persist outcomes. ---- + +interface ScheduledTimer { + fn: () => void; + ms: number; + cancelled: boolean; +} + +class MockHost implements SessionHost { + posted: BridgeOutboundToPage[] = []; + contentChangedCount = 0; + writes: Uint8Array[] = []; + backups: { name: string; bytes: Uint8Array }[] = []; + fallbacks: Uint8Array[] = []; + warnings: string[] = []; + timers: ScheduledTimer[] = []; + + // Controllable outcomes: + writeFileImpl: (bytes: Uint8Array) => Promise = async (b) => { + this.writes.push(b); + }; + writeBackupImpl: (name: string, bytes: Uint8Array) => Promise = async (name, bytes) => { + this.backups.push({ name, bytes }); + }; + writeBackupFallbackImpl: (bytes: Uint8Array) => Promise = async (b) => { + this.fallbacks.push(b); + return "/tmp/fallback"; + }; + conflictChoices: Array<"use-local" | "accept-remote" | undefined> = []; + externalChoices: Array<"reload" | "keep-local" | "save-disk-copy" | undefined> = []; + + postToPage(msg: BridgeOutboundToPage): void { + this.posted.push(msg); + } + contentChanged(): void { + this.contentChangedCount += 1; + } + writeFile(bytes: Uint8Array): Promise { + return this.writeFileImpl(bytes); + } + writeBackup(name: string, bytes: Uint8Array): Promise { + return this.writeBackupImpl(name, bytes); + } + writeBackupFallback(bytes: Uint8Array): Promise { + return this.writeBackupFallbackImpl(bytes); + } + async showConflictDialog(): Promise<"use-local" | "accept-remote" | undefined> { + return this.conflictChoices.shift(); + } + async showExternalChangeDialog(): Promise<"reload" | "keep-local" | "save-disk-copy" | undefined> { + return this.externalChoices.shift(); + } + schedule(fn: () => void, ms: number): () => void { + const t: ScheduledTimer = { fn, ms, cancelled: false }; + this.timers.push(t); + return () => { + t.cancelled = true; + }; + } + warn(message: string): void { + this.warnings.push(message); + } + + // Fire the most recently scheduled non-cancelled timer. + fireLatestTimer(): void { + for (let i = this.timers.length - 1; i >= 0; i--) { + if (!this.timers[i].cancelled) { + this.timers[i].cancelled = true; + this.timers[i].fn(); + return; + } + } + } + lastPosted(): BridgeOutboundToPage { + return this.posted[this.posted.length - 1]; + } + postedOfType( + type: T, + ): Extract[] { + return this.posted.filter((m) => m.type === type) as Extract< + BridgeOutboundToPage, + { type: T } + >[]; + } +} + +const flush = () => new Promise((r) => setTimeout(r, 0)); + +/** Build a started session and bring it to `ready` (init → ready → opened). */ +async function readySession(host = new MockHost()): Promise<{ host: MockHost; s: PenSession }> { + const s = new PenSession(host, "tok", '{"boot":true}'); + s.start(); + s.onPageMessage(JSON.stringify({ type: "op-bridge/ready", generation: 1, revision: 0 })); + await flush(); + // boot open sends open-document; complete it with `opened`. + s.onPageMessage(JSON.stringify({ type: "op-bridge/opened", generation: 1 })); + await flush(); + return { host, s }; +} + +function sendResult(s: PenSession, requestId: string, docJson: string, gen = 2, rev = 1): void { + s.onPageMessage( + JSON.stringify({ + type: "op-bridge/snapshot-result", + requestId, + docJson, + generation: gen, + revision: rev, + }), + ); +} + +test("start posts init and retries until ready, then cancels the timer", async () => { + const host = new MockHost(); + const s = new PenSession(host, "tok", "{}"); + s.start(); + expect(host.postedOfType("op-bridge/init").length).toBe(1); + host.fireLatestTimer(); // retry once → second init + expect(host.postedOfType("op-bridge/init").length).toBe(2); + s.onPageMessage(JSON.stringify({ type: "op-bridge/ready", generation: 1, revision: 0 })); + await flush(); + // After ready, firing any leftover timer must not post another init. + const initsBefore = host.postedOfType("op-bridge/init").length; + host.fireLatestTimer(); + expect(host.postedOfType("op-bridge/init").length).toBe(initsBefore); +}); + +test("boot sends open-document and reaches ready on opened", async () => { + const { host, s } = await readySession(); + const opens = host.postedOfType("op-bridge/open-document"); + expect(opens.length).toBe(1); + expect(opens[0].json).toBe('{"boot":true}'); + expect(s.state).toBe("ready"); +}); + +test("save/backup/revert reject before ready", async () => { + const host = new MockHost(); + const s = new PenSession(host, "tok", "{}"); + s.start(); + await expect(s.save()).rejects.toBeInstanceOf(NotReadyError); + await expect(s.backup()).rejects.toBeInstanceOf(NotReadyError); + await expect(s.revert("{}")).rejects.toBeInstanceOf(NotReadyError); +}); + +test("save happy path emits save-committed with the snapshot-result pair", async () => { + const { host, s } = await readySession(); + const p = s.save(); + await flush(); + const snap = host.postedOfType("op-bridge/snapshot")[0]; + expect(snap.purpose).toBe("save"); + sendResult(s, snap.requestId, '{"saved":1}', 9, 4); + await p; + expect(new TextDecoder().decode(host.writes[0])).toBe('{"saved":1}'); + const committed = host.postedOfType("op-bridge/save-committed")[0]; + expect(committed.generation).toBe(9); + expect(committed.revision).toBe(4); +}); + +test("writeFile failure rejects save and does NOT emit save-committed", async () => { + const { host, s } = await readySession(); + host.writeFileImpl = async () => { + throw new Error("disk full"); + }; + const p = s.save(); + await flush(); + const snap = host.postedOfType("op-bridge/snapshot")[0]; + sendResult(s, snap.requestId, "{}"); + await expect(p).rejects.toThrow("disk full"); + expect(host.postedOfType("op-bridge/save-committed").length).toBe(0); +}); + +test("two saves serialize (second snapshot only after first resolves)", async () => { + const { host, s } = await readySession(); + const p1 = s.save(); + const p2 = s.save(); + await flush(); + expect(host.postedOfType("op-bridge/snapshot").length).toBe(1); // serialized + const snap1 = host.postedOfType("op-bridge/snapshot")[0]; + sendResult(s, snap1.requestId, "{}"); + await p1; + await flush(); + expect(host.postedOfType("op-bridge/snapshot").length).toBe(2); + const snap2 = host.postedOfType("op-bridge/snapshot")[1]; + sendResult(s, snap2.requestId, "{}"); + await p2; +}); + +test("snapshot-conflict for a save opens the conflict and rejects the save", async () => { + const { host, s } = await readySession(); + host.conflictChoices = ["use-local"]; + const p = s.save(); + await flush(); + const snap = host.postedOfType("op-bridge/snapshot")[0]; + s.onPageMessage( + JSON.stringify({ type: "op-bridge/snapshot-conflict", requestId: snap.requestId, serverVersion: 5 }), + ); + await expect(p).rejects.toBeInstanceOf(ConflictPendingError); + expect(s.state).toBe("conflict"); +}); + +test("already-cancelled queued item rejects with Cancelled when it dequeues", async () => { + const { host, s } = await readySession(); + // Block the queue with a first save, then enqueue a cancelled second. + const p1 = s.save(); + let cancelled = false; + const p2 = s.save(() => cancelled); + cancelled = true; + await flush(); + const snap1 = host.postedOfType("op-bridge/snapshot")[0]; + sendResult(s, snap1.requestId, "{}"); + await p1; + await expect(p2).rejects.toBeInstanceOf(CancelledError); +}); + +test("dispose rejects in-flight and later calls", async () => { + const { s } = await readySession(); + const inflight = s.save(); // snapshot posted, awaiting result + await flush(); + s.dispose(); + await expect(inflight).rejects.toBeInstanceOf(DisposedError); + expect(s.state).toBe("disposed"); + await expect(s.save()).rejects.toBeInstanceOf(DisposedError); +}); + +test("sync-conflict → use-local → resolved returns to ready (steady state)", async () => { + const { host, s } = await readySession(); + host.conflictChoices = ["use-local"]; + s.onPageMessage( + JSON.stringify({ type: "op-bridge/sync-conflict", generation: 1, revision: 0, serverVersion: 3 }), + ); + await flush(); + const resolve = host.postedOfType("op-bridge/resolve-conflict")[0]; + expect(resolve.mode).toBe("use-local"); + s.onPageMessage(JSON.stringify({ type: "op-bridge/conflict-resolved", requestId: resolve.requestId })); + await flush(); + expect(s.state).toBe("ready"); +}); + +test("accept-remote buffers early conflict-resolved, persists before consuming, waits opened", async () => { + // Force the conflict to arise during an open flow so completion waits `opened`. + const host = new MockHost(); + const s = new PenSession(host, "tok", "{}"); + s.start(); + s.onPageMessage(JSON.stringify({ type: "op-bridge/ready", generation: 1, revision: 0 })); + await flush(); + // Do NOT send the boot `opened` yet — state is open-pending. Now a conflict. + host.conflictChoices = ["accept-remote"]; + s.onPageMessage( + JSON.stringify({ type: "op-bridge/sync-conflict", generation: 1, revision: 0, serverVersion: 7 }), + ); + await flush(); + const resolve = host.postedOfType("op-bridge/resolve-conflict")[0]; + expect(resolve.mode).toBe("accept-remote"); + // conflict-resolved arrives EARLY (before we persist) — must be buffered. + s.onPageMessage(JSON.stringify({ type: "op-bridge/conflict-resolved", requestId: resolve.requestId })); + // now the local bytes arrive; persist must happen before completion. + sendResult(s, resolve.requestId, '{"local":1}'); + await flush(); + expect(host.backups.length).toBe(1); // persisted local bytes + expect(s.state).toBe("conflict"); // still waiting for opened + s.onPageMessage(JSON.stringify({ type: "op-bridge/opened", generation: 2 })); + await flush(); + expect(s.state).toBe("ready"); +}); + +test("accept-remote persist: writeBackup fails twice → fallback blocks then completes (steady state)", async () => { + const { host, s } = await readySession(); + let attempts = 0; + host.writeBackupImpl = async () => { + attempts += 1; + throw new Error("backup fail"); + }; + host.conflictChoices = ["accept-remote"]; + s.onPageMessage( + JSON.stringify({ type: "op-bridge/sync-conflict", generation: 5, revision: 0, serverVersion: 9 }), + ); + await flush(); + const resolve = host.postedOfType("op-bridge/resolve-conflict")[0]; + sendResult(s, resolve.requestId, '{"local":2}', 5, 0); + s.onPageMessage(JSON.stringify({ type: "op-bridge/conflict-resolved", requestId: resolve.requestId })); + await flush(); + expect(attempts).toBe(2); // tried twice + expect(host.fallbacks.length).toBe(1); // then fallback + // steady-state completion: dirty-changed with generation > 5. + s.onPageMessage( + JSON.stringify({ type: "op-bridge/dirty-changed", generation: 6, revision: 1, dirty: true }), + ); + await flush(); + expect(s.state).toBe("ready"); +}); + +test("single transaction: snapshot-conflict then sync-conflict = ONE dialog", async () => { + const { host, s } = await readySession(); + let dialogs = 0; + host.showConflictDialog = async () => { + dialogs += 1; + return undefined; // dismiss to keep it simple + }; + const p = s.save(); + await flush(); + const snap = host.postedOfType("op-bridge/snapshot")[0]; + s.onPageMessage( + JSON.stringify({ type: "op-bridge/snapshot-conflict", requestId: snap.requestId, serverVersion: 4 }), + ); + await expect(p).rejects.toBeInstanceOf(ConflictPendingError); + // A follow-up sync-conflict during the SAME open transaction must not re-prompt. + s.onPageMessage( + JSON.stringify({ type: "op-bridge/sync-conflict", generation: 1, revision: 0, serverVersion: 5 }), + ); + await flush(); + expect(dialogs).toBe(1); +}); + +test("dismiss then save re-prompts with stored version; use-local lets the save complete", async () => { + const { host, s } = await readySession(); + // First conflict, dismissed. + host.conflictChoices = [undefined]; + s.onPageMessage( + JSON.stringify({ type: "op-bridge/sync-conflict", generation: 1, revision: 0, serverVersion: 8 }), + ); + await flush(); + expect(s.state).toBe("conflict"); + // Now save() re-prompts; choose use-local this time. + host.conflictChoices = ["use-local"]; + const p = s.save(); + await flush(); + const resolve = host.postedOfType("op-bridge/resolve-conflict").at(-1)!; + expect(resolve.mode).toBe("use-local"); + s.onPageMessage(JSON.stringify({ type: "op-bridge/conflict-resolved", requestId: resolve.requestId })); + await flush(); + // conflict cleared → the save proceeds; feed its snapshot result. + const snap = host.postedOfType("op-bridge/snapshot").at(-1)!; + sendResult(s, snap.requestId, '{"done":1}'); + await p; + expect(s.state).toBe("ready"); + expect(host.postedOfType("op-bridge/save-committed").length).toBe(1); +}); + +test("externalFileChanged: clean → auto revert", async () => { + const { host, s } = await readySession(); + const p = s.externalFileChanged('{"disk":1}', false); + await flush(); + const open = host.postedOfType("op-bridge/open-document").at(-1)!; + expect(open.json).toBe('{"disk":1}'); + s.onPageMessage(JSON.stringify({ type: "op-bridge/opened", generation: 3 })); + await p; +}); + +test("externalFileChanged dirty + save-disk-copy persists disk bytes then keeps local", async () => { + const { host, s } = await readySession(); + host.externalChoices = ["save-disk-copy"]; + await s.externalFileChanged('{"disk":2}', true); + expect(host.backups.length).toBe(1); + expect(new TextDecoder().decode(host.backups[0].bytes)).toBe('{"disk":2}'); + // no revert issued + expect(host.postedOfType("op-bridge/open-document").length).toBe(1); // only the boot open +}); + +test("unknown / unmatched messages are ignored (no throw, warn on stray result)", async () => { + const { host, s } = await readySession(); + s.onPageMessage(JSON.stringify({ type: "react-devtools" })); + s.onPageMessage("not json"); + s.onPageMessage( + JSON.stringify({ + type: "op-bridge/snapshot-result", + requestId: "rX", + docJson: "{}", + generation: 2, + revision: 1, + }), + ); + expect(host.warnings.some((w) => w.includes("unmatched snapshot-result"))).toBe(true); + expect(s.state).toBe("ready"); +}); + +test("dirty-changed false→true fires contentChanged once; ignored before boot", async () => { + const host = new MockHost(); + const s = new PenSession(host, "tok", "{}"); + s.start(); + s.onPageMessage(JSON.stringify({ type: "op-bridge/ready", generation: 1, revision: 0 })); + await flush(); + // Before the boot `opened`, dirty churn is ignored. + s.onPageMessage( + JSON.stringify({ type: "op-bridge/dirty-changed", generation: 1, revision: 1, dirty: true }), + ); + expect(host.contentChangedCount).toBe(0); + s.onPageMessage(JSON.stringify({ type: "op-bridge/opened", generation: 1 })); + await flush(); + s.onPageMessage( + JSON.stringify({ type: "op-bridge/dirty-changed", generation: 2, revision: 2, dirty: true }), + ); + expect(host.contentChangedCount).toBe(1); + expect(s.isRustDirty).toBe(true); + // A second dirty:true (no edge) does not re-fire. + s.onPageMessage( + JSON.stringify({ type: "op-bridge/dirty-changed", generation: 3, revision: 3, dirty: true }), + ); + expect(host.contentChangedCount).toBe(1); +}); diff --git a/editors/vscode/src/session/pen-session.ts b/editors/vscode/src/session/pen-session.ts new file mode 100644 index 000000000..e4dba87f0 --- /dev/null +++ b/editors/vscode/src/session/pen-session.ts @@ -0,0 +1,605 @@ +// PenSession — the document protocol state machine. Translates the postMessage +// bridge stream into VS Code custom-document lifecycle actions. No vscode +// import: every host effect goes through the injected SessionHost, so the whole +// machine is unit-testable. The rules mirror the landed Rust semantics in +// crates/op-editor-core/src/sync_gate.rs and crates/op-host-web/src/vscode_bridge.rs. + +import type { BridgeInboundFromPage, BridgeOutboundToPage } from "../protocol/bridge"; +import { parseInboundFromPage } from "../protocol/bridge"; + +export interface SessionHost { + postToPage(msg: BridgeOutboundToPage): void; + /** VS Code's CustomDocumentContentChangeEvent has no dirty boolean — each + * fire marks the doc dirty; only save/revert clears it. So the host exposes + * only the "became dirty" direction; the session tracks the Rust dirty bool + * and calls contentChanged() once on the false→true edge. (true→false is not + * signalled — an undo back to clean leaving the tab dirty is an accepted + * limitation; save/revert clears it.) */ + contentChanged(): void; + writeFile(bytes: Uint8Array): Promise; + /** MUST throw on failure. */ + writeBackup(name: string, bytes: Uint8Array): Promise; + /** Durable fallback for the accept-remote obligation when writeBackup fails + * twice; only resolves once bytes are persisted somewhere findable. */ + writeBackupFallback(bytes: Uint8Array): Promise; + showConflictDialog(serverVersion: number): Promise<"use-local" | "accept-remote" | undefined>; + showExternalChangeDialog(): Promise<"reload" | "keep-local" | "save-disk-copy" | undefined>; + /** Timer abstraction for the init retry loop and fallbacks; returns cancel. */ + schedule(fn: () => void, ms: number): () => void; + warn(message: string): void; +} + +export type SessionState = "booting" | "ready" | "open-pending" | "conflict" | "disposed"; + +export class DisposedError extends Error { + constructor() { + super("session disposed"); + } +} +export class NotReadyError extends Error { + constructor() { + super("session not ready"); + } +} +export class CancelledError extends Error { + constructor() { + super("operation cancelled"); + } +} +export class ConflictPendingError extends Error { + constructor() { + super("a sync conflict must be resolved first"); + } +} + +const INIT_RETRY_MS = 500; +const INIT_MAX_TRIES = 20; +const ACCEPT_REMOTE_APPLY_TIMEOUT_MS = 10_000; + +interface Deferred { + promise: Promise; + resolve: (value: T) => void; + reject: (err: unknown) => void; +} +function deferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (err: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +type SnapshotResult = Extract; + +export class PenSession { + private readonly host: SessionHost; + private readonly token: string; + private readonly initialDocJson: string; + private readonly encoder = new TextEncoder(); + + private _state: SessionState = "booting"; + private generation = 0; + private rustDirty = false; + private booted = false; // first `opened` seen (initial boot completed) + private requestCounter = 0; + + private initCancel?: () => void; + private initTries = 0; + + // Resolved by an `opened` message. Registered by boot, revert, and conflict + // completions that arose during an open flow. + private pendingOpen?: Deferred; + + // Snapshot serialization: at most one snapshot in flight. + private queueTail: Promise = Promise.resolve(); + // requestId → handlers for the currently-awaited snapshot round trip. + private readonly resultWaiters = new Map void>(); + private readonly conflictWaiters = new Map void>(); + + // The single active conflict transaction, if any. + private conflict?: ConflictTxn; + + // Every live deferred is tracked so dispose() can reject them all. + private readonly liveWaiters = new Set>(); + + constructor(host: SessionHost, token: string, initialDocJson: string) { + this.host = host; + this.token = token; + this.initialDocJson = initialDocJson; + } + + get state(): SessionState { + return this._state; + } + get isRustDirty(): boolean { + return this.rustDirty; + } + + /** Begin the init retry loop: post init immediately and re-post every 500ms + * (cap 20) until `ready` arrives — postMessage success does not prove the + * page received it, and the Rust listener installs late in mount_ck. */ + start(): void { + if (this._state !== "booting") return; + this.postInit(); + this.scheduleInitRetry(); + } + + onPageMessage(raw: unknown): void { + if (this._state === "disposed") return; + const msg = parseInboundFromPage(raw); + if (!msg) return; + switch (msg.type) { + case "op-bridge/ready": + this.handleReady(msg.generation, msg.revision); + break; + case "op-bridge/opened": + this.handleOpened(msg.generation); + break; + case "op-bridge/dirty-changed": + this.handleDirty(msg.generation, msg.revision, msg.dirty); + break; + case "op-bridge/snapshot-result": + this.handleSnapshotResult(msg); + break; + case "op-bridge/snapshot-conflict": + this.handleSnapshotConflict(msg.requestId, msg.serverVersion); + break; + case "op-bridge/sync-conflict": + this.handleSyncConflict(msg.generation, msg.serverVersion); + break; + case "op-bridge/conflict-resolved": + this.handleConflictResolved(msg.requestId); + break; + } + } + + // ---- lifecycle entry points ---- + + save(isCancelled?: () => boolean): Promise { + return this.enqueueGuarded(async () => { + if (isCancelled?.()) throw new CancelledError(); + const result = await this.runSnapshot("save"); + const bytes = this.encoder.encode(result.docJson); + await this.host.writeFile(bytes); // throws → no save-committed + this.post({ + type: "op-bridge/save-committed", + generation: result.generation, + revision: result.revision, + }); + // A successful save is a clean baseline from VS Code's perspective. + this.rustDirty = false; + }, isCancelled); + } + + backup(isCancelled?: () => boolean): Promise { + return this.enqueueGuarded(async () => { + if (isCancelled?.()) throw new CancelledError(); + const result = await this.runSnapshot("backup"); + return this.encoder.encode(result.docJson); + }, isCancelled); + } + + revert(diskJson: string): Promise { + if (this._state === "disposed") return Promise.reject(new DisposedError()); + if (!this.isReadyish()) return Promise.reject(new NotReadyError()); + return this.openDocument(diskJson).then(() => { + this.rustDirty = false; + }); + } + + async externalFileChanged(diskJson: string, isRustDirty: boolean): Promise { + if (this._state === "disposed") throw new DisposedError(); + if (!isRustDirty) { + await this.revert(diskJson); + return; + } + const choice = await this.host.showExternalChangeDialog(); + if (choice === "reload") { + await this.revert(diskJson); + } else if (choice === "save-disk-copy") { + // Persist the DISK bytes durably before keeping the local editor, so both + // versions survive (same durability discipline as accept-remote). + await this.persistBytes(`disk-copy-${this.stamp()}`, this.encoder.encode(diskJson)); + } + // keep-local / undefined → keep the editor as-is. + } + + dispose(): void { + if (this._state === "disposed") return; + this._state = "disposed"; + this.initCancel?.(); + this.initCancel = undefined; + const err = new DisposedError(); + for (const d of this.liveWaiters) d.reject(err); + this.liveWaiters.clear(); + this.resultWaiters.clear(); + this.conflictWaiters.clear(); + this.pendingOpen = undefined; + this.conflict = undefined; + } + + // ---- init ---- + + private postInit(): void { + this.post({ type: "op-bridge/init", token: this.token }); + } + private scheduleInitRetry(): void { + this.initCancel = this.host.schedule(() => { + if (this._state !== "booting") return; + this.initTries += 1; + if (this.initTries >= INIT_MAX_TRIES) { + this.initCancel?.(); + this.initCancel = undefined; + this.host.warn("OpenPencil editor did not become ready"); + return; + } + this.postInit(); + this.scheduleInitRetry(); + }, INIT_RETRY_MS); + } + + // ---- message handlers ---- + + private handleReady(generation: number, _revision: number): void { + if (this._state !== "booting") return; // idempotent (HTML rebuild re-sends) + this.initCancel?.(); + this.initCancel = undefined; + this.generation = generation; + // The host-opened bytes are authoritative — push the boot document and wait + // for `opened` before serving lifecycle operations. + void this.openDocument(this.initialDocJson).then(() => { + this.booted = true; + }); + } + + private handleOpened(generation: number): void { + this.generation = generation; + if (this.pendingOpen) { + const d = this.pendingOpen; + this.pendingOpen = undefined; + this.untrack(d); + d.resolve(); + } + } + + private handleDirty(generation: number, _revision: number, dirty: boolean): void { + this.generation = generation; + // Steady-state accept-remote completion waits for the first dirty-changed + // whose generation strictly exceeds the conflict-time generation. + const c = this.conflict; + if (c?.awaitGreaterGen !== undefined && generation > c.awaitGreaterGen.gen) { + const d = c.awaitGreaterGen.deferred; + c.awaitGreaterGen = undefined; + this.untrack(d); + d.resolve(); + } + if (!this.booted) return; // ignore dirty churn until the initial open lands + const was = this.rustDirty; + this.rustDirty = dirty; + if (!was && dirty) this.host.contentChanged(); + } + + private handleSnapshotResult(msg: SnapshotResult): void { + this.generation = msg.generation; + const waiter = this.resultWaiters.get(msg.requestId); + if (!waiter) { + this.host.warn(`dropping unmatched snapshot-result ${msg.requestId}`); + return; + } + this.resultWaiters.delete(msg.requestId); + waiter(msg); + } + + private handleSnapshotConflict(requestId: string, serverVersion: number): void { + const waiter = this.conflictWaiters.get(requestId); + if (waiter) { + this.conflictWaiters.delete(requestId); + waiter(serverVersion); + return; + } + // No registered waiter (e.g. a late conflict) — fold into the transaction. + this.openOrUpdateConflict(serverVersion, /*aroseInOpenFlow*/ this._state === "open-pending"); + } + + private handleSyncConflict(generation: number, serverVersion: number): void { + this.openOrUpdateConflict(serverVersion, this._state === "open-pending", generation); + } + + private handleConflictResolved(requestId: string): void { + const c = this.conflict; + if (!c || c.resolveRequestId !== requestId) return; + if (c.awaitResolved) { + const d = c.awaitResolved; + c.awaitResolved = undefined; + this.untrack(d); + d.resolve(); + } else { + // Arrived before we were ready to consume it (accept-remote persist in + // flight) — buffer for later. + c.resolvedBuffered = true; + } + } + + // ---- snapshot round trips ---- + + private runSnapshot(purpose: "save" | "backup"): Promise { + const requestId = this.nextRequestId(); + const result = this.track(deferred()); + this.resultWaiters.set(requestId, (msg) => { + this.untrack(result); + result.resolve(msg); + }); + this.conflictWaiters.set(requestId, (serverVersion) => { + this.untrack(result); + // This save/backup's own request conflicted: open the transaction and + // reject the operation so VS Code surfaces the failed save. + this.openOrUpdateConflict(serverVersion, this._state === "open-pending"); + result.reject(new ConflictPendingError()); + }); + this.post({ type: "op-bridge/snapshot", purpose, requestId }); + return result.promise; + } + + // ---- open documents ---- + + private openDocument(json: string): Promise { + this._state = "open-pending"; + const d = this.track(deferred()); + this.pendingOpen = d; + this.post({ type: "op-bridge/open-document", json }); + return d.promise.then(() => { + if (this._state === "open-pending") this._state = "ready"; + }); + } + + private isReadyish(): boolean { + return this._state === "ready"; + } + + // ---- conflict transaction ---- + + private openOrUpdateConflict( + serverVersion: number, + aroseInOpenFlow: boolean, + generation = this.generation, + ): void { + if (this.conflict) { + // Single active transaction: a second conflict event only refreshes the + // stored version; it does not open a second dialog. + this.conflict.serverVersion = serverVersion; + return; + } + this.conflict = { + serverVersion, + aroseInOpenFlow, + conflictGeneration: generation, + resolvedBuffered: false, + }; + this._state = "conflict"; + void this.runConflictTransaction(); + } + + private async runConflictTransaction(): Promise { + // Loop supports use-local retry-failure re-prompts. + for (;;) { + const c = this.conflict; + if (!c) return; + const choice = await this.host.showConflictDialog(c.serverVersion); + if (this._state === "disposed" || this.conflict !== c) return; + if (choice === undefined) { + // Dismissed: keep the transaction open; re-entry happens via a later + // save()/backup() (the Rust conflict latch is consumable and is not + // re-emitted while the gate stays conflicted). + return; + } + const requestId = this.nextRequestId(); + c.resolveRequestId = requestId; + if (choice === "use-local") { + const retry = await this.resolveUseLocal(c, requestId); + if (retry) continue; // retry-failure re-prompt + this.closeConflict(); + return; + } + // accept-remote + await this.resolveAcceptRemote(c, requestId); + this.closeConflict(); + return; + } + } + + /** Returns true if a retry-failure snapshot-conflict re-opened the dialog. */ + private async resolveUseLocal(c: ConflictTxn, requestId: string): Promise { + const resolved = this.track(deferred()); + const retried = this.track(deferred()); + c.awaitResolved = resolved; + if (c.resolvedBuffered) { + c.resolvedBuffered = false; + this.untrack(resolved); + resolved.resolve(); + } + this.conflictWaiters.set(requestId, (serverVersion) => { + this.untrack(retried); + retried.resolve(serverVersion); + }); + this.post({ type: "op-bridge/resolve-conflict", mode: "use-local", requestId }); + + const outcome = await Promise.race([ + resolved.promise.then(() => ({ kind: "resolved" as const })), + retried.promise.then((serverVersion) => ({ kind: "retry" as const, serverVersion })), + ]); + this.conflictWaiters.delete(requestId); + if (outcome.kind === "retry") { + c.awaitResolved = undefined; + this.untrack(resolved); + c.serverVersion = outcome.serverVersion; + return true; + } + this.untrack(retried); + // use-local kept OUR document, so there is no remote apply to observe: + // open-flow still waits `opened`, but steady-state completes immediately. + await this.awaitCompletion(c, /*waitRemoteApply*/ false); + return false; + } + + private async resolveAcceptRemote(c: ConflictTxn, requestId: string): Promise { + // 1) Wait for the local bytes and persist them durably BEFORE consuming the + // (possibly-already-buffered) conflict-resolved. + const localResult = this.track(deferred()); + this.resultWaiters.set(requestId, (msg) => { + this.untrack(localResult); + localResult.resolve(msg); + }); + this.post({ type: "op-bridge/resolve-conflict", mode: "accept-remote", requestId }); + const local = await localResult.promise; + await this.persistBytes(`conflict-backup-${this.stamp()}`, this.encoder.encode(local.docJson)); + + // 2) Consume conflict-resolved (buffered or awaited). + if (!c.resolvedBuffered) { + const resolved = this.track(deferred()); + c.awaitResolved = resolved; + await resolved.promise; + c.awaitResolved = undefined; + } else { + c.resolvedBuffered = false; + } + + // 3) Completion bifurcation — accept-remote applies the REMOTE document, so + // steady-state waits for that apply (a generation bump). + await this.awaitCompletion(c, /*waitRemoteApply*/ true); + } + + /** open-flow conflicts complete on `opened`. Steady-state completion depends + * on which side won: accept-remote must observe the remote apply (the first + * dirty-changed whose generation exceeds the conflict-time gen, with a + * schedule fallback); use-local kept our document, so it completes at once. */ + private async awaitCompletion(c: ConflictTxn, waitRemoteApply: boolean): Promise { + if (c.aroseInOpenFlow) { + const d = this.track(deferred()); + this.pendingOpen = d; + await d.promise; + return; + } + if (!waitRemoteApply) return; // use-local steady-state: done immediately + const d = this.track(deferred()); + c.awaitGreaterGen = { gen: c.conflictGeneration, deferred: d }; + const cancel = this.host.schedule(() => { + if (c.awaitGreaterGen?.deferred === d) { + c.awaitGreaterGen = undefined; + this.untrack(d); + this.host.warn("remote document apply not observed; continuing"); + d.resolve(); + } + }, ACCEPT_REMOTE_APPLY_TIMEOUT_MS); + try { + await d.promise; + } finally { + cancel(); + } + } + + private closeConflict(): void { + this.conflict = undefined; + if (this._state === "conflict") this._state = "ready"; + } + + // ---- helpers ---- + + /** Persist bytes durably: writeBackup, retry once, then the blocking + * fallback. Never returns with the bytes only in memory. */ + private async persistBytes(name: string, bytes: Uint8Array): Promise { + try { + await this.host.writeBackup(name, bytes); + return; + } catch { + /* retry once */ + } + try { + await this.host.writeBackup(name, bytes); + return; + } catch { + await this.host.writeBackupFallback(bytes); + } + } + + /** Serialize save/backup jobs; a conflict re-prompts instead of enqueuing. */ + private enqueueGuarded(job: () => Promise, isCancelled?: () => boolean): Promise { + if (this._state === "disposed") return Promise.reject(new DisposedError()); + if (this._state === "conflict") { + return this.reenterConflictThen(job); + } + if (!this.isReadyish()) return Promise.reject(new NotReadyError()); + const run = this.queueTail.then(() => { + if (this._state === "disposed") throw new DisposedError(); + if (isCancelled?.()) throw new CancelledError(); + return job(); + }); + this.queueTail = run.then( + () => undefined, + () => undefined, + ); + return run; + } + + /** A save/backup issued while a conflict is open re-prompts the dialog with + * the stored serverVersion, then proceeds (or rejects) per resolution. */ + private async reenterConflictThen(job: () => Promise): Promise { + const c = this.conflict; + if (!c) return this.enqueueGuarded(job); // resolved meanwhile + // Drive the same transaction dialog now instead of waiting for a re-emitted + // sync-conflict (which the consumable Rust latch never sends). + await this.driveConflictOnce(c); + if (this._state === "disposed") throw new DisposedError(); + if (this._state === "conflict") throw new ConflictPendingError(); // dismissed again + return this.enqueueGuarded(job); + } + + /** Show the dialog once for an already-open transaction (re-entry path). */ + private async driveConflictOnce(c: ConflictTxn): Promise { + const choice = await this.host.showConflictDialog(c.serverVersion); + if (this._state === "disposed" || this.conflict !== c) return; + if (choice === undefined) return; // still dismissed + const requestId = this.nextRequestId(); + c.resolveRequestId = requestId; + if (choice === "use-local") { + const retry = await this.resolveUseLocal(c, requestId); + if (retry) return this.driveConflictOnce(c); + this.closeConflict(); + return; + } + await this.resolveAcceptRemote(c, requestId); + this.closeConflict(); + } + + private post(msg: BridgeOutboundToPage): void { + this.host.postToPage(msg); + } + private nextRequestId(): string { + this.requestCounter += 1; + return `r${this.requestCounter}`; + } + private stamp(): string { + // Monotonic, deterministic-in-tests: derived from the request counter, not + // wall-clock, so backup names are stable and unique within a session. + this.requestCounter += 1; + return `${this.requestCounter}`; + } + private track(d: Deferred): Deferred { + this.liveWaiters.add(d as Deferred); + return d; + } + private untrack(d: Deferred): void { + this.liveWaiters.delete(d as Deferred); + } +} + +interface ConflictTxn { + serverVersion: number; + aroseInOpenFlow: boolean; + conflictGeneration: number; + resolveRequestId?: string; + resolvedBuffered: boolean; + awaitResolved?: Deferred; + awaitGreaterGen?: { gen: number; deferred: Deferred }; +} diff --git a/editors/vscode/src/session/session-registry.test.ts b/editors/vscode/src/session/session-registry.test.ts new file mode 100644 index 000000000..9c9d8e03b --- /dev/null +++ b/editors/vscode/src/session/session-registry.test.ts @@ -0,0 +1,54 @@ +import { test, expect } from "bun:test"; +import { SessionRegistry } from "./session-registry"; +import type { PenSession } from "./pen-session"; + +function fakeSession(id: string): PenSession { + return { id } as unknown as PenSession; +} + +test("register + setActive + activeSession returns the active one", () => { + const r = new SessionRegistry(); + const a = fakeSession("a"); + const b = fakeSession("b"); + r.register("/a.op", a); + r.register("/b.op", b); + r.setActive("/a.op"); + expect(r.activeSession()).toBe(a); + r.setActive("/b.op"); + expect(r.activeSession()).toBe(b); +}); + +test("setActive(undefined) clears the active pointer", () => { + const r = new SessionRegistry(); + r.register("/a.op", fakeSession("a")); + r.setActive("/a.op"); + r.setActive(undefined); + expect(r.activeSession()).toBeUndefined(); +}); + +test("setActive to an unregistered file is ignored", () => { + const r = new SessionRegistry(); + r.register("/a.op", fakeSession("a")); + r.setActive("/a.op"); + r.setActive("/never.op"); + // unchanged — still /a.op + expect((r.activeSession() as unknown as { id: string }).id).toBe("a"); +}); + +test("unregister clears active if it was the active file", () => { + const r = new SessionRegistry(); + r.register("/a.op", fakeSession("a")); + r.setActive("/a.op"); + r.unregister("/a.op"); + expect(r.activeSession()).toBeUndefined(); +}); + +test("unregister a non-active file leaves active intact", () => { + const r = new SessionRegistry(); + const a = fakeSession("a"); + r.register("/a.op", a); + r.register("/b.op", fakeSession("b")); + r.setActive("/a.op"); + r.unregister("/b.op"); + expect(r.activeSession()).toBe(a); +}); diff --git a/editors/vscode/src/session/session-registry.ts b/editors/vscode/src/session/session-registry.ts new file mode 100644 index 000000000..7d972341c --- /dev/null +++ b/editors/vscode/src/session/session-registry.ts @@ -0,0 +1,31 @@ +// filePath → PenSession registry. The active-session pointer's ONLY source of +// truth is the provider's view-state events (not the DaemonPool), so codegen / +// chat always target the editor the user is actually looking at. No vscode +// import — the provider drives it. + +import type { PenSession } from "./pen-session"; + +export class SessionRegistry { + private readonly sessions = new Map(); + private activeFile?: string; + + register(filePath: string, session: PenSession): void { + this.sessions.set(filePath, session); + } + + unregister(filePath: string): void { + this.sessions.delete(filePath); + if (this.activeFile === filePath) this.activeFile = undefined; + } + + /** undefined clears the active pointer — the provider MUST call this when the + * user selects a non-OpenPencil editor, else the last .op stays the target. */ + setActive(filePath: string | undefined): void { + if (filePath !== undefined && !this.sessions.has(filePath)) return; + this.activeFile = filePath; + } + + activeSession(): PenSession | undefined { + return this.activeFile === undefined ? undefined : this.sessions.get(this.activeFile); + } +} diff --git a/editors/vscode/src/smoke.test.ts b/editors/vscode/src/smoke.test.ts new file mode 100644 index 000000000..00ea0d328 --- /dev/null +++ b/editors/vscode/src/smoke.test.ts @@ -0,0 +1,5 @@ +import { test, expect } from "bun:test"; + +test("toolchain smoke test", () => { + expect(1 + 1).toBe(2); +}); diff --git a/editors/vscode/src/vscode/codegen-prompt.test.ts b/editors/vscode/src/vscode/codegen-prompt.test.ts new file mode 100644 index 000000000..2e2dd7b96 --- /dev/null +++ b/editors/vscode/src/vscode/codegen-prompt.test.ts @@ -0,0 +1,98 @@ +import { test, expect } from "bun:test"; +import { buildCodegenPrompt, parseCodegenOutput } from "./codegen-prompt"; + +test("buildCodegenPrompt embeds the framework, the JSON contract, and the doc", () => { + const p = buildCodegenPrompt('{"pages":[]}', "vue"); + expect(p).toContain("vue"); + expect(p).toContain('{"files":[{"path"'); + expect(p).toContain('{"pages":[]}'); +}); + +test("parses a clean files envelope", () => { + const out = parseCodegenOutput('{"files":[{"path":"a/B.tsx","content":"x"}]}'); + expect(out.ok).toBe(true); + if (out.ok) { + expect(out.files.length).toBe(1); + expect(out.files[0].path).toBe("a/B.tsx"); + } +}); + +test("tolerates a ```json fenced block", () => { + const fenced = "```json\n" + '{"files":[{"path":"a.tsx","content":"y"}]}' + "\n```"; + const out = parseCodegenOutput(fenced); + expect(out.ok).toBe(true); +}); + +test("rejects non-JSON output", () => { + const out = parseCodegenOutput("Here is your code: ..."); + expect(out.ok).toBe(false); + if (!out.ok) expect(out.errors[0]).toContain("not valid JSON"); +}); + +test("rejects a non-files shape", () => { + const out = parseCodegenOutput('{"result":"ok"}'); + expect(out.ok).toBe(false); +}); + +test("rejects absolute paths", () => { + const out = parseCodegenOutput('{"files":[{"path":"/etc/passwd","content":"x"}]}'); + expect(out.ok).toBe(false); + if (!out.ok) expect(out.errors.some((e) => e.includes("absolute or drive-rooted"))).toBe(true); +}); + +test("rejects .. traversal", () => { + const out = parseCodegenOutput('{"files":[{"path":"../../evil.tsx","content":"x"}]}'); + expect(out.ok).toBe(false); + if (!out.ok) expect(out.errors.some((e) => e.includes("escapes"))).toBe(true); +}); + +test("rejects Windows-absolute and UNC paths", () => { + expect(parseCodegenOutput('{"files":[{"path":"C:\\\\x.tsx","content":"x"}]}').ok).toBe(false); + expect(parseCodegenOutput('{"files":[{"path":"\\\\\\\\srv\\\\x","content":"x"}]}').ok).toBe(false); +}); + +test("rejects Windows drive-RELATIVE escapes (no separator after the colon)", () => { + // C:foo resolves against the current dir of drive C: — a real escape a + // separator-requiring check misses. + for (const p of ["C:foo", "c:evil.tsx", "C:..\\x", "C:./x", "\\rooted"]) { + const body = JSON.stringify({ files: [{ path: p, content: "x" }] }); + expect(parseCodegenOutput(body).ok).toBe(false); + } +}); + +test("rejects duplicate paths", () => { + const out = parseCodegenOutput( + '{"files":[{"path":"a.tsx","content":"1"},{"path":"a.tsx","content":"2"}]}', + ); + expect(out.ok).toBe(false); + if (!out.ok) expect(out.errors.some((e) => e.includes("duplicate"))).toBe(true); +}); + +test("rejects a single file over 1 MiB", () => { + const big = "x".repeat(1024 * 1024 + 1); + const out = parseCodegenOutput(JSON.stringify({ files: [{ path: "big.tsx", content: big }] })); + expect(out.ok).toBe(false); + if (!out.ok) expect(out.errors.some((e) => e.includes("too large"))).toBe(true); +}); + +test("rejects total output over 10 MiB", () => { + const chunk = "x".repeat(900 * 1024); // under per-file cap + const files = Array.from({ length: 12 }, (_, i) => ({ path: `f${i}.tsx`, content: chunk })); + const out = parseCodegenOutput(JSON.stringify({ files })); + expect(out.ok).toBe(false); + if (!out.ok) expect(out.errors.some((e) => e.includes("total output too large"))).toBe(true); +}); + +test("rejects an empty files array", () => { + const out = parseCodegenOutput('{"files":[]}'); + expect(out.ok).toBe(false); + if (!out.ok) expect(out.errors.some((e) => e.includes("no files"))).toBe(true); +}); + +test("collects multiple violations at once", () => { + const out = parseCodegenOutput( + '{"files":[{"path":"/abs","content":"x"},{"path":"../up","content":"y"}]}', + ); + expect(out.ok).toBe(false); + if (!out.ok) expect(out.errors.length).toBeGreaterThanOrEqual(2); +}); diff --git a/editors/vscode/src/vscode/codegen-prompt.ts b/editors/vscode/src/vscode/codegen-prompt.ts new file mode 100644 index 000000000..c9f5b431a --- /dev/null +++ b/editors/vscode/src/vscode/codegen-prompt.ts @@ -0,0 +1,101 @@ +// Pure helpers for the single-shot codegen command: build the model prompt and +// parse/validate the model's structured response. No vscode import — the +// command shell (codegen-command.ts) handles model selection, streaming, and +// the file writes. Keeping these pure makes the validation (the security- +// sensitive part) unit-testable. + +export type Framework = "react" | "vue"; + +export interface CodegenFile { + path: string; + content: string; +} +export type CodegenParse = + | { ok: true; files: CodegenFile[] } + | { ok: false; errors: string[] }; + +const MAX_FILE_BYTES = 1024 * 1024; // 1 MiB per file +const MAX_TOTAL_BYTES = 10 * 1024 * 1024; // 10 MiB total + +/** Ask the model for a strict JSON envelope so the output is machine-parseable + * rather than free-form prose. */ +export function buildCodegenPrompt(docJson: string, framework: Framework): string { + return [ + `You are generating ${framework} component code from an OpenPencil design document.`, + "Respond with ONLY a JSON object of this exact shape, no prose, no markdown:", + '{"files":[{"path":"relative/Component.tsx","content":""}]}', + "Rules: paths are RELATIVE (no leading slash, no ..), unique, and reasonably small.", + "", + "Design document:", + docJson, + ].join("\n"); +} + +/** Parse + validate the model output. Tolerates a single ```json fenced block. + * Returns an error list (and writes NOTHING) on any violation. */ +export function parseCodegenOutput(text: string): CodegenParse { + const jsonText = stripFence(text).trim(); + let value: unknown; + try { + value = JSON.parse(jsonText); + } catch { + return { ok: false, errors: ["output is not valid JSON"] }; + } + if (typeof value !== "object" || value === null || !Array.isArray((value as { files?: unknown }).files)) { + return { ok: false, errors: ['output must be {"files":[...]}'] }; + } + const rawFiles = (value as { files: unknown[] }).files; + const errors: string[] = []; + const files: CodegenFile[] = []; + const seen = new Set(); + let total = 0; + + rawFiles.forEach((raw, i) => { + if (typeof raw !== "object" || raw === null) { + errors.push(`file[${i}] is not an object`); + return; + } + const { path, content } = raw as Record; + if (typeof path !== "string" || path.length === 0) { + errors.push(`file[${i}] has no path`); + return; + } + if (typeof content !== "string") { + errors.push(`file[${i}] (${path}) has no string content`); + return; + } + if (isUnsafeRoot(path)) errors.push(`absolute or drive-rooted path rejected: ${path}`); + if (hasDotDot(path)) errors.push(`path escapes the output dir: ${path}`); + if (seen.has(path)) errors.push(`duplicate path: ${path}`); + seen.add(path); + const bytes = Buffer.byteLength(content, "utf8"); + if (bytes > MAX_FILE_BYTES) errors.push(`file too large (${bytes} bytes): ${path}`); + total += bytes; + files.push({ path, content }); + }); + + if (total > MAX_TOTAL_BYTES) errors.push(`total output too large (${total} bytes)`); + if (files.length === 0 && errors.length === 0) errors.push("no files in output"); + + return errors.length > 0 ? { ok: false, errors } : { ok: true, files }; +} + +/** Strip a single leading/trailing markdown code fence if present. */ +function stripFence(text: string): string { + const fence = /^```[a-zA-Z]*\n([\s\S]*?)\n```$/; + const m = text.trim().match(fence); + return m ? m[1] : text; +} + +/** Reject anything that is not a pure relative path under the output dir: + * POSIX absolute (`/x`), Windows root-relative or UNC (`\x`, `\\srv`), and ANY + * Windows drive prefix (`C:\x`, `C:/x`, and crucially the drive-RELATIVE forms + * `C:x` / `C:..\x` that resolve against the current dir of drive C: — an + * escape a separator-requiring check would miss). */ +function isUnsafeRoot(p: string): boolean { + return p.startsWith("/") || p.startsWith("\\") || /^[a-zA-Z]:/.test(p); +} + +function hasDotDot(p: string): boolean { + return p.split(/[\\/]/).includes(".."); +} diff --git a/editors/vscode/src/vscode/webview-shell.test.ts b/editors/vscode/src/vscode/webview-shell.test.ts new file mode 100644 index 000000000..a1b5e892d --- /dev/null +++ b/editors/vscode/src/vscode/webview-shell.test.ts @@ -0,0 +1,53 @@ +import { test, expect } from "bun:test"; +import { buildBootHtml, buildWebviewHtml, originOf } from "./webview-shell"; + +test("originOf extracts scheme://host:port", () => { + expect(originOf("http://127.0.0.1:45001/")).toBe("http://127.0.0.1:45001"); + expect(originOf("http://127.0.0.1:45001/index.html?x=1")).toBe("http://127.0.0.1:45001"); +}); + +test("boot HTML reports window.origin via op-shell/ready and has no iframe", () => { + const html = buildBootHtml("N0NCE"); + expect(html).toContain("op-shell/ready"); + expect(html).toContain("window.origin"); + expect(html).not.toContain("'); +}); + +test("full HTML embeds the iframe with frame-src pinned to the daemon origin", () => { + const html = buildWebviewHtml({ iframeSrc: "http://127.0.0.1:45010/", nonce: "N1" }); + expect(html).toContain('src="http://127.0.0.1:45010/"'); + expect(html).toContain("frame-src http://127.0.0.1:45010;"); + expect(html).toContain(' + +`; +} + +/** Phase 2: full shell. Embeds the daemon iframe and relays messages both ways + * with strict source/origin checks. The payload is always a JSON string; the + * shell never parses business messages, only its own "op-shell/" control ones. */ +export function buildWebviewHtml(opts: { iframeSrc: string; nonce: string }): string { + const { iframeSrc, nonce } = opts; + const iframeOrigin = originOf(iframeSrc); + return ` + + + + + + + + + + +`; +} diff --git a/editors/vscode/test/fixtures/fake-daemon.mjs b/editors/vscode/test/fixtures/fake-daemon.mjs new file mode 100644 index 000000000..3c37b02d5 --- /dev/null +++ b/editors/vscode/test/fixtures/fake-daemon.mjs @@ -0,0 +1,92 @@ +#!/usr/bin/env node +// Test double for `op-host-web-server --serve-web --managed`. It mimics only +// the parts DaemonClient depends on: a single-line handshake on stdout, a +// stdin-EOF self-kill lease, and a set of argv-selected failure modes. +// +// Modes (argv switches, checked before the real daemon flags): +// --fake-port port echoed in the handshake (default 41234) +// --fake-token token echoed in the handshake (default "deadbeef") +// --fake-version version echoed in the handshake (default "9.9.9") +// --no-handshake never print a handshake (stays alive → timeout path) +// --garbage-handshake print a non-JSON line +// --delay wait before printing the handshake +// --early-exit exit 1 immediately, before any handshake +// --close-stdout write half a handshake line, end stdout, stay alive +// --echo-log after the handshake, print a stderr line containing +// the token (redaction test) +// --echo-argv after the handshake, print the full received argv to +// stderr (arg-forwarding test; contains no token) +// +// The real daemon flags (--serve-web --managed --port 0 --file ... +// --allow-origin ...) are captured and echoed back inside the handshake's +// `argv` field so tests can assert they were forwarded verbatim. + +import { closeSync } from "node:fs"; + +const argv = process.argv.slice(2); + +function flag(name, fallback) { + const i = argv.indexOf(name); + return i >= 0 && i + 1 < argv.length ? argv[i + 1] : fallback; +} +function has(name) { + return argv.includes(name); +} + +const port = Number(flag("--fake-port", "41234")); +const token = flag("--fake-token", "deadbeef"); +const version = flag("--fake-version", "9.9.9"); + +// Keep the process alive until stdin closes (the parent-death lease) unless a +// failure mode exits earlier. Reading stdin also lets `stdin.end()` reach us. +process.stdin.resume(); +process.stdin.on("end", () => process.exit(0)); +process.stdin.on("close", () => process.exit(0)); + +if (has("--early-exit")) { + process.exit(1); +} + +if (has("--close-stdout")) { + // Half a line, then close fd 1 directly while staying alive: the client's + // handshake reader sees the stream end before a full line arrives. + // (process.stdout.end() does not reliably close the underlying fd, so we + // close it explicitly after flushing the partial write.) + process.stdout.write('{"ok":true,"por', () => { + closeSync(1); + }); + // Do not exit — this proves the client still cleans up the child. + setInterval(() => {}, 1 << 30); +} else if (has("--no-handshake")) { + // Silence — the client must hit its bounded handshake timeout. + setInterval(() => {}, 1 << 30); +} else { + const delay = Number(flag("--delay", "0")); + const emit = () => { + if (has("--garbage-handshake")) { + process.stdout.write("this is not json at all\n"); + } else { + const handshake = { + ok: true, + port, + token, + version, + // Not part of the real contract — a test hook to assert forwarded args. + argv, + }; + process.stdout.write(JSON.stringify(handshake) + "\n"); + if (has("--echo-log")) { + // A diagnostic line that leaks the token — the client must redact it + // before handing it to the logger. + process.stderr.write(`serving with token ${token} ready\n`); + } + if (has("--echo-argv")) { + // Echo the daemon flags the client constructed so a test can assert + // they were forwarded verbatim. Contains no token. + process.stderr.write(`argv ${argv.join(" ")}\n`); + } + } + }; + if (delay > 0) setTimeout(emit, delay); + else emit(); +} diff --git a/editors/vscode/tsconfig.json b/editors/vscode/tsconfig.json new file mode 100644 index 000000000..6581cb670 --- /dev/null +++ b/editors/vscode/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "ESNext", + "moduleResolution": "bundler", + "types": ["bun", "node"], + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true + }, + "include": ["src/**/*.ts"] +}