diff --git a/bun.lock b/bun.lock index 6191d4916..e0ac63f8b 100644 --- a/bun.lock +++ b/bun.lock @@ -227,6 +227,7 @@ "name": "@opencode-ai/desktop", "version": "1.15.46", "dependencies": { + "@bharatcode/mobile-connector": "workspace:*", "@zip.js/zip.js": "2.7.62", "drizzle-orm": "catalog:", "effect": "catalog:", @@ -354,6 +355,18 @@ "@typescript/native-preview": "catalog:", }, }, + "packages/mobile-connector": { + "name": "@bharatcode/mobile-connector", + "version": "0.1.0", + "dependencies": { + "@opentunnel/client": "0.5.2", + "effect": "^4.0.0", + "qrcode": "1.5.4", + }, + "devDependencies": { + "pngjs": "^5.0.0", + }, + }, "packages/opencode": { "name": "bharatcode", "version": "1.15.46", @@ -709,12 +722,12 @@ "@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch", "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", "gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch", - "effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch", "@ai-sdk/anthropic@3.0.111": "patches/@ai-sdk%2Fanthropic@3.0.111.patch", "@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch", "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", - "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", + "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", + "effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch", "@ai-sdk/amazon-bedrock@4.0.166": "patches/@ai-sdk%2Famazon-bedrock@4.0.166.patch", "@ai-sdk/groq@3.0.31": "patches/@ai-sdk%2Fgroq@3.0.31.patch", "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", @@ -1000,6 +1013,8 @@ "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + "@bharatcode/mobile-connector": ["@bharatcode/mobile-connector@workspace:packages/mobile-connector"], + "@bufbuild/protobuf": ["@bufbuild/protobuf@2.12.0", "", {}, "sha512-B/XlCaFIP8LOwzo+bz5uFzATYokcwCKQcghqnlfwSmM5eX/qTkvDBnDPs+gXtX/RyjxJ4DRikECcPJbyALA8FA=="], "@bufbuild/protoplugin": ["@bufbuild/protoplugin@2.12.0", "", { "dependencies": { "@bufbuild/protobuf": "2.12.0", "@typescript/vfs": "^1.6.2", "typescript": "5.4.5" } }, "sha512-ORlDITp8AFUXzIhLRoMCG+ud+D3MPKWb5HQXBoskMMnjeyEjE1H1qLonVNPyOr8lkx3xSfYUo8a0dvOZJVAzow=="], @@ -1388,6 +1403,10 @@ "@opentui/solid": ["@opentui/solid@0.4.5", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.4.5", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-B0RSkXnrtPVfEJOX+Hj+axjLJ3lzbG1BZw5I7Pvb9OPp48Vzg2cW2a3cSa86/q48ndLt647i/XwFPIw/jqnI5g=="], + "@opentunnel/client": ["@opentunnel/client@0.5.2", "", { "dependencies": { "@opentunnel/protocol": "0.5.2" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-zEpYXzapzYwoOY1p43cUbX91cK+aaXHapcWxPDh/QL4OSL1q/qHCVl9l/nPsNt19UKQ99W4V/Z6Y0PJsIKC4+A=="], + + "@opentunnel/protocol": ["@opentunnel/protocol@0.5.2", "", { "peerDependencies": { "effect": "^4.0.0" } }, "sha512-pB+xK/TEA7JV0QktvF+1UkJDheoRqc5KMqs/ltpU5i5kngzjGeAYvR00Z8qp6KEXIFJtlpBvwy4TXn9+CBYK0Q=="], + "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], "@oslojs/binary": ["@oslojs/binary@1.0.0", "", {}, "sha512-9RCU6OwXU6p67H4NODbuxv2S3eenuQ4/WFLrsq+K/k682xrznH5EVWA7N4VFk9VYVcbFtKqur5YQQZc0ySGhsQ=="], @@ -2072,6 +2091,8 @@ "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + "camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="], + "caniuse-lite": ["caniuse-lite@1.0.30001793", "", {}, "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA=="], "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], @@ -2174,6 +2195,8 @@ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="], + "decimal.js": ["decimal.js@10.5.0", "", {}, "sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw=="], "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], @@ -2212,6 +2235,8 @@ "diff": ["diff@8.0.2", "", {}, "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg=="], + "dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="], + "dir-compare": ["dir-compare@4.2.0", "", { "dependencies": { "minimatch": "^3.0.5", "p-limit": "^3.1.0 " } }, "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ=="], "dmg-builder": ["dmg-builder@26.16.1", "", { "dependencies": { "app-builder-lib": "26.16.1", "builder-util": "26.16.0", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0" } }, "sha512-pnI/3Qb24Uk+rMTgIUrsVUKosVgwmBUdF8Zeb8TexOSbpq8MWc7v6l+n+FrEqVkjNZwzBN+XpDS9ENgZ/rkWAw=="], @@ -2250,7 +2275,7 @@ "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], - "effect": ["effect@4.0.0-beta.83", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w=="], + "effect": ["effect@4.0.5", "", {}, "sha512-BY4/28NlpOd99yzmV741xbVGmp+1U7tks4oB7F/Png64bTa5tm5eCA6W0RiXsnuLDDoJpjfVSo4Dnq4BZb/89Q=="], "ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="], @@ -2400,7 +2425,7 @@ "find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="], - "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + "find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], @@ -2676,7 +2701,7 @@ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.1", "", { "os": "win32", "cpu": "x64" }, "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg=="], - "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + "locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], @@ -2874,7 +2899,7 @@ "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], - "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + "p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], "p-map": ["p-map@7.0.4", "", {}, "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ=="], @@ -2938,6 +2963,8 @@ "plist": ["plist@3.1.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ=="], + "pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="], + "poe-oauth": ["poe-oauth@0.0.8", "", {}, "sha512-zlaRVLR6vuxBIYUkZoTIVo3f8h3qd27gv9Ms+kmGiYEiiV4TdccddTdNcGyI0DnuJ9tVi+5LP3Bvzez59IFbjw=="], "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], @@ -2994,6 +3021,8 @@ "pvutils": ["pvutils@1.1.5", "", {}, "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA=="], + "qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="], + "qs": ["qs@6.15.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw=="], "quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="], @@ -3040,6 +3069,8 @@ "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + "require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="], + "resedit": ["resedit@1.7.2", "", { "dependencies": { "pe-library": "^0.4.1" } }, "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA=="], "reselect": ["reselect@4.1.8", "", {}, "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ=="], @@ -3104,6 +3135,8 @@ "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + "set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="], + "set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], @@ -3392,6 +3425,8 @@ "which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="], + "which-module": ["which-module@2.0.1", "", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="], + "why-is-node-running": ["why-is-node-running@3.2.2", "", { "bin": { "why-is-node-running": "cli.js" } }, "sha512-NKUzAelcoCXhXL4dJzKIwXeR8iEVqsA0Lq6Vnd0UXvgaKbzVo4ZTHROF2Jidrv+SgxOQ03fMinnNhzZATxOD3A=="], "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], @@ -3656,20 +3691,50 @@ "@opencode-ai/app/@opencode-ai/client": ["@opencode-ai/client@vendor/opencode-ai-client-1.17.13-v2.tgz", {}, "sha512-332kgNifvpQOF9e3UA+pIa5xPrMhLaQkUiNiO+meS0Ba9HjSE6hfsWnEojMkD0DPSLqPP6rCF1dDoF7U0Y0OCQ=="], + "@opencode-ai/app/effect": ["effect@4.0.0-beta.83", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w=="], + + "@opencode-ai/client/effect": ["effect@4.0.0-beta.83", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w=="], + + "@opencode-ai/codemode/effect": ["effect@4.0.0-beta.83", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w=="], + + "@opencode-ai/core/effect": ["effect@4.0.0-beta.83", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w=="], + "@opencode-ai/core/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "@opencode-ai/desktop/@actions/artifact": ["@actions/artifact@4.0.0", "", { "dependencies": { "@actions/core": "^1.10.0", "@actions/github": "^6.0.1", "@actions/http-client": "^2.1.0", "@azure/core-http": "^3.0.5", "@azure/storage-blob": "^12.15.0", "@octokit/core": "^5.2.1", "@octokit/plugin-request-log": "^1.0.4", "@octokit/plugin-retry": "^3.0.9", "@octokit/request": "^8.4.1", "@octokit/request-error": "^5.1.1", "@protobuf-ts/plugin": "^2.2.3-alpha.1", "archiver": "^7.0.1", "jwt-decode": "^3.1.2", "unzip-stream": "^0.3.1" } }, "sha512-HCc2jMJRAfviGFAh0FsOR/jNfWhirxl7W6z8zDtttt0GltwxBLdEIjLiweOPFl9WbyJRW1VWnPUSAixJqcWUMQ=="], + "@opencode-ai/desktop/effect": ["effect@4.0.0-beta.83", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w=="], + "@opencode-ai/desktop/typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="], + "@opencode-ai/effect-drizzle-sqlite/effect": ["effect@4.0.0-beta.83", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w=="], + + "@opencode-ai/effect-sqlite-node/effect": ["effect@4.0.0-beta.83", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w=="], + + "@opencode-ai/http-recorder/effect": ["effect@4.0.0-beta.83", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w=="], + + "@opencode-ai/httpapi-codegen/effect": ["effect@4.0.0-beta.83", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w=="], + + "@opencode-ai/llm/effect": ["effect@4.0.0-beta.83", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w=="], + "@opencode-ai/plugin/@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="], + "@opencode-ai/plugin/effect": ["effect@4.0.0-beta.83", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w=="], + + "@opencode-ai/protocol/effect": ["effect@4.0.0-beta.83", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w=="], + + "@opencode-ai/schema/effect": ["effect@4.0.0-beta.83", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w=="], + "@opencode-ai/script/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + "@opencode-ai/server/effect": ["effect@4.0.0-beta.83", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w=="], + "@opencode-ai/session-ui/@opencode-ai/client": ["@opencode-ai/client@../app/vendor/opencode-ai-client-1.17.13-v2.tgz", {}, "sha512-332kgNifvpQOF9e3UA+pIa5xPrMhLaQkUiNiO+meS0Ba9HjSE6hfsWnEojMkD0DPSLqPP6rCF1dDoF7U0Y0OCQ=="], "@opencode-ai/session-ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="], + "@opencode-ai/tui/effect": ["effect@4.0.0-beta.83", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w=="], + "@opencode-ai/ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="], "@opentui/core/diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], @@ -3688,6 +3753,8 @@ "@protobuf-ts/plugin/typescript": ["typescript@3.9.10", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-w6fIxVE/H1PkLKcCPsFqKE7Kv7QUwhU8qQY2MueZXWx5cPZdwFupLgKK3vntcK98BtNHZtAF4LA/yl2a7k8R6Q=="], + "@sentry/bundler-plugin-core/find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + "@sentry/bundler-plugin-core/glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="], "@sentry/bundler-plugin-core/magic-string": ["magic-string@0.30.8", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" } }, "sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ=="], @@ -3780,6 +3847,8 @@ "bharatcode/@solid-primitives/scheduled": ["@solid-primitives/scheduled@1.5.2", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-/j2igE0xyNaHhj6kMfcUQn5rAVSTLbAX+CDEBm25hSNBmNiHLu2lM7Usj2kJJ5j36D67bE8wR1hBNA8hjtvsQA=="], + "bharatcode/effect": ["effect@4.0.0-beta.83", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w=="], + "bharatcode/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "builder-util/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -3810,8 +3879,6 @@ "duplexer2/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], - "effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "electron-builder/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "electron-builder/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], @@ -3886,6 +3953,8 @@ "opencode-gitlab-auth/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], + "p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], + "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], "pkg-up/find-up": ["find-up@3.0.0", "", { "dependencies": { "locate-path": "^3.0.0" } }, "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg=="], @@ -3902,6 +3971,8 @@ "proxy-addr/ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + "qrcode/yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="], + "readdir-glob/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], "rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], @@ -4076,14 +4147,46 @@ "@octokit/rest/@octokit/core/before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="], + "@opencode-ai/app/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@opencode-ai/client/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@opencode-ai/codemode/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@opencode-ai/core/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@opencode-ai/desktop/@actions/artifact/@actions/http-client": ["@actions/http-client@2.2.3", "", { "dependencies": { "tunnel": "^0.0.6", "undici": "^5.25.4" } }, "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA=="], + "@opencode-ai/desktop/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@opencode-ai/effect-drizzle-sqlite/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@opencode-ai/effect-sqlite-node/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@opencode-ai/http-recorder/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@opencode-ai/httpapi-codegen/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@opencode-ai/llm/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@opencode-ai/plugin/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@opencode-ai/protocol/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@opencode-ai/schema/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@opencode-ai/server/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@opencode-ai/tui/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@opentui/solid/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@pierre/diffs/@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="], "@pierre/diffs/@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], + "@sentry/bundler-plugin-core/find-up/locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + "@sentry/bundler-plugin-core/glob/minimatch": ["minimatch@8.0.7", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg=="], "@sentry/bundler-plugin-core/glob/minipass": ["minipass@4.2.8", "", {}, "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ=="], @@ -4172,6 +4275,8 @@ "bharatcode/@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.33", "", { "dependencies": { "@ai-sdk/provider": "3.0.12", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-nJ0bAfegMAIJtrzMJtbzer1cS3nb7c7DsyU1S4nrPm7ZU0Mn6SBBZv5IGZZGTbpWTJwqKTSPeZJTXalbAxt1BA=="], + "bharatcode/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "builder-util/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "c12/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], @@ -4242,6 +4347,14 @@ "pkg-up/find-up/locate-path": ["locate-path@3.0.0", "", { "dependencies": { "p-locate": "^3.0.0", "path-exists": "^3.0.0" } }, "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A=="], + "qrcode/yargs/cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="], + + "qrcode/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "qrcode/yargs/y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="], + + "qrcode/yargs/yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="], + "readdir-glob/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="], "rimraf/glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], @@ -4278,6 +4391,8 @@ "@opencode-ai/desktop/@actions/artifact/@actions/http-client/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], + "@sentry/bundler-plugin-core/find-up/locate-path/p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + "@sentry/bundler-plugin-core/glob/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="], "@sentry/bundler-plugin-core/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], @@ -4370,6 +4485,14 @@ "pkg-up/find-up/locate-path/path-exists": ["path-exists@3.0.0", "", {}, "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ=="], + "qrcode/yargs/cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "qrcode/yargs/cliui/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], + + "qrcode/yargs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "qrcode/yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "readdir-glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "rimraf/glob/minimatch/brace-expansion": ["brace-expansion@1.1.15", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="], @@ -4398,6 +4521,10 @@ "pkg-up/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], + "qrcode/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "qrcode/yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "rimraf/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "archiver-utils/glob/jackspeak/@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], diff --git a/docs/admissions-recovery-2026-10-30.md b/docs/admissions-recovery-2026-10-30.md new file mode 100644 index 000000000..68ec8b94d --- /dev/null +++ b/docs/admissions-recovery-2026-10-30.md @@ -0,0 +1,49 @@ +# Admissions recovery review — 2026-10-30 + +Addresses Shrey's Desktop #60 review and the follow-up on merged Mobile #1. + +## Behavior + +- Invalid JSON and invalid journal structures fail closed and report mobile storage failure. Pairing failure preserves the original file. +- Explicit Reset stops access, privately quarantines invalid admissions bytes, revokes device access, and removes tunnel identity. A durable recovery marker is written before quarantine, so a process crash cannot silently reopen without lost-history protection. A failed storage operation rejects Reset and reports an error. +- Healthy journals are never removed by Reset; unresolved forwarding records remain unresolved. +- After corruption recovery, old conversations remain readable. An exact matching stored user message can reconcile without sending. Unknown or mismatched old prompts are rejected with instructions to inspect Desktop and start a new conversation on the phone. +- New conversations created through the connector use a fresh random identity, reject caller-selected IDs, and are recorded durably before they can send. This avoids relying on wall clocks to distinguish old/new conversations. Restart and healthy Reset retain that proof; recovering a newly corrupt journal loses the allowlist and blocks those now-uncertain sessions too. +- The Mobile source and Desktop generated source/tests match byte-for-byte. The pinned integration generator is tested independently on its documented source pin; the Desktop PR follow-up is also merged locally with current dev `f8c7feacae8a8cf89d9b9cb05a780e306f845d7d`. + +## Evidence + +The original tests failed before the recovery implementation. A fresh source reviewer then reproduced a backward-clock duplicate-delivery risk in the first timestamp design. The clock regression failed before replacing that design with durable fresh session identities, then passed afterward. + +| Check | Result | +| --- | --- | +| Mobile connector suite | 65 passed | +| Mirrored Desktop connector suite | 65 passed | +| Real local HTTP pairing/recovery/prompt/reset/restart regression | Passed inside both connector suites | +| Mobile Jest / security / TypeScript / lint | 76 / 15 passed; typecheck and lint passed | +| Desktop source suite on recovery branch | 170 passed | +| Recovery-branch server suite | 324 passed; 2 existing skips | +| Actual Desktop HTTP routes/database + local test-model recovery | Passed; two intended prompts, two model calls, two upstream submissions; retries did not add submissions | +| App unit suite on recovery branch | 748 passed; 1 baseline Arabic UI parity failure | +| App browser-condition suite | 55 passed | +| Mobile Settings Chromium cases | 5 passed | +| Integration generator / host browser helper | 3 / 3 passed | +| Desktop/app/e2e/server typechecks | Passed | + +The app unit failure is `i18n parity > non-English locales have every English key and required plural variants`: four missing `ui.message.outputLimit*` keys in Arabic. It was reproduced from the current dev dictionaries/test in an isolated temporary directory. This change does not alter the parity test or UI locale dictionaries. + +These checks establish source and local fixture behavior. They do not establish installed Electron, physical Android/iOS, native Windows/macOS, store signing, or release acceptance. No installation, deployment, push, PR, or review reply was made for this follow-up. Targeted packaged Linux checks are recorded below. + +## Additional actual-server verification + +The Desktop `packages/opencode/test/server/httpapi-session.test.ts` regression uses the actual HTTP routes and database with the mirrored connector and a local deterministic model server. It checks an original prompt/reply, corrupt journal rejection, explicit recovery, reconciliation of the original stored retry, 409 rejection of unknown old delivery, connector-generated session creation, the new conversation reply, and Reset/reopen retry. Both upstream submission counts and model-call counts remain exactly two for the two intended prompts. This complements the gateway pairing fixture; it does not substitute for an installed Electron/phone test. The complete recovery-branch server suite passed 324 tests with two existing skips. + +## Targeted packaged Linux qualification + +A delegated gpt-6-luna/max worker built a combined temporary tree from support `c76b871` plus an uncommitted, conflict-free merge of recovery `22f91ba`. Later recovery `4d6ac75` adds a server test/documentation only; recovery product source is unchanged. The unpacked Linux package is `/tmp/bc-desktop-qualification.F13MBy/packages/desktop/dist/linux-unpacked`. Its `resources/app.asar` SHA-256 is `d7a49b14cc36e6e029db41cbdcb8b409b2862e17c4e6b27a006d1579208b15b6` (independently checked by the primary agent). The bundled CLI version smoke test passed. + +The packaged Electron 42.3.3 app launched with `isPackaged=true`, version 1.15.46, inside isolated user/network namespaces and Xvfb with a temporary home/profile and no provider credentials. From the top-level renderer, the shipped preload's `exportDebugLogs()` called the registered main IPC and produced a mode-0600 ZIP (705–707 bytes across repeated runs) containing only `support-bundle.json`. Its nine allowlisted top-level keys were `formatVersion`, `generated`, `version`, `os`, `channel`, `auth`, `config`, `database`, and `errors`; auth was signed out. The Settings screen was behind sign-in, so this verifies the packaged export IPC path alongside the separate Chromium Settings UI tests. + +A synthetic corrupt journal was then written under isolated userData. The renderer's shipped `mobileAccess({type:'reset'})` IPC preserved the exact damaged bytes in one `.corrupt-*` backup, removed the active damaged journal, persisted a `.recovery` marker, and returned off/disabled without an error. Backup and marker modes were 0600. No tunnel was enabled, and the namespace had loopback only. These checks do not establish real hosted-tunnel connectivity from the package, physical-phone pairing, native Windows/macOS behavior, signing, or release acceptance. The package was not installed or published. + +Detailed delegated report and retained harness: `/tmp/bc-desktop-qualification.F13MBy/LOCAL_LINUX_QUALIFICATION.md`. The combined staged source tree was `39464cee6416c16391412040016f27ee55bc77ef`. diff --git a/docs/assets/mobile-access/after-provisioning.png b/docs/assets/mobile-access/after-provisioning.png new file mode 100644 index 000000000..4ab90b928 Binary files /dev/null and b/docs/assets/mobile-access/after-provisioning.png differ diff --git a/docs/assets/mobile-access/before-provisioning.png b/docs/assets/mobile-access/before-provisioning.png new file mode 100644 index 000000000..1da3b2215 Binary files /dev/null and b/docs/assets/mobile-access/before-provisioning.png differ diff --git a/docs/mobile-access-acceptance.md b/docs/mobile-access-acceptance.md new file mode 100644 index 000000000..07b3aef0f --- /dev/null +++ b/docs/mobile-access-acceptance.md @@ -0,0 +1,53 @@ +# Mobile access acceptance + +The connector uses the existing native local Desktop sidecar and `https://tunnel.bharatcode.ai`. Mobile never starts another agent. WSL-selected runtimes are outside the current connection scope; do not infer Windows/WSL parity from Linux source checks. + +## October 27 reliability fixes + +Mobile-access controller construction no longer reads or writes storage. Guarded restoration and explicit actions load device state; invalid or inaccessible mobile state reports an error in Mobile devices settings without rejecting Desktop initialization. Failed reads never replace the registry. An explicit, confirmed revoke-all/reset preserves an invalid registry as a private `devices.json.corrupt-*` backup before creating clean state. Permission or other I/O failures do not trigger this recovery. Backups remain inside the Desktop private user-data directory and must not be attached to public issues. + +Reset and Disable cancel in-progress provisioning before joining the action queue. They also invalidate queued Pair or Restore work; asynchronous storage reads cannot start another connection after cancellation. A failed enabled-preference write closes the newly created gateway/tunnel and leaves access disabled. The settings component permits Reset during provisioning, keeps other controls busy until outstanding actions settle, and ignores stale action/poll responses. Storage errors have their own localized message. + +Directory listing enumerates a directory before opening regular-file handles, avoiding reliance on POSIX directory handles. File-handle type/size checks and resolved project confinement remain in place. Actual Windows/macOS acceptance remains a separate check. + +## Reproducible source checks + +Run from package directories: + +```sh +# packages/mobile-connector +node --test + +# packages/desktop +bun run test +bun run typecheck + +# packages/app +bun run typecheck +bun run typecheck:e2e +bun run test:unit +bun run test:browser +bun x playwright test --config e2e/reproduction/mobile-settings/playwright.config.ts +``` + +The dedicated browser fixture renders the production Settings component, language provider and controls. It supplies deterministic responses only at the IPC boundary. It covers storage errors, provisioning/reset cancellation, matching-code approval, decline and confirmed individual revocation. Its screenshots demonstrate renderer behavior, not actual Electron IPC, certificate issuance or a phone connection. The cancellation test controls the browser clock so a later polling refresh cannot hide a stale cancelled-action error. + +The pre-existing Arabic localization-parity failure at the reviewed base concerns four `ui.message.outputLimit*` keys. Record that failure if still present; do not suppress it or relabel the complete app suite green. Exact-source qualification, artifact identity and results must be recorded before claiming a build is ready for user acceptance. + +## Final user acceptance + +Record Desktop candidate hash/source commit, Mobile version/build, OS/device and approximate time with each result. Use a disposable phone identity and preserve existing paired devices. Run destructive reset/storage-recovery checks only in a disposable Desktop profile. + +- [ ] Open the real Desktop Settings → Mobile devices, with the intended account and native local runtime. Verify startup/off status and existing devices. +- [ ] Start first-time connection; verify progress, Cancel and Reset. On a later connection, confirm the saved identity/certificate is reused. +- [ ] Scan the real QR with the physical phone camera. Confirm both screens show the same six-digit code; approve using the real Desktop button. A different/mismatched code must never be approved. +- [ ] Decline a disposable claim, refresh an invitation and test expiry; old claims must not gain access. +- [ ] Open the link with Mobile already running and from a cold standalone start. Neither path should require a development launcher or manual route refresh. +- [ ] Open an existing native Desktop conversation and a fresh test conversation. Send a bounded real-model prompt; see incremental text on the phone and the same conversation on Desktop. +- [ ] Exercise supported model/agent selection, Stop, questions/permissions, files and working changes in a disposable project. +- [ ] Interrupt the phone network during a response, restore it, and verify recovery/history without a duplicate user prompt. Exercise background/resume and Desktop sleep/resume separately. +- [ ] Revoke only the disposable phone during streaming. Further requests must be unauthorized and Send disabled; already-buffered text may arrive. Other devices must remain paired. +- [ ] Re-pair and verify the old credential remains rejected. Turn off remote access and verify it remains off after a user-controlled Desktop restart; deliberate re-enablement should preserve authorized devices. +- [ ] In an isolated profile, exercise Reset during certificate issuance and corrupt-registry recovery. Confirm remote access is off, phone credentials are revoked, and private damaged-state bytes are retained. Retry failed hosted deletion without discarding its stored identity. + +Camera, physical Android/iOS, real Desktop pointer interaction, hosted transport, signed distribution and platform coverage must be reported independently. Passing fixture/source checks does not check these boxes. diff --git a/docs/mobile-access-fixes-2026-10-28.md b/docs/mobile-access-fixes-2026-10-28.md new file mode 100644 index 000000000..0eab2bfde --- /dev/null +++ b/docs/mobile-access-fixes-2026-10-28.md @@ -0,0 +1,35 @@ +# Mobile-access follow-up, October 28, 2026 + +## Desktop changes + +The optional mobile feature no longer reads its directory during controller construction or lets malformed/unavailable storage reject Desktop initialization. Settings reports a fixed storage error without exposing or overwriting saved data. Explicit reset preserves a private backup of a damaged registry before rebuilding it. + +Reset/disable invalidate pending provisioning before entering the action queue. Queued pair and restore operations check cancellation before opening new resources. Failed persistence closes a newly opened tunnel. Settings keeps reset usable during provisioning and ignores late responses from superseded operations. File enumeration also avoids opening directories as regular files. + +The pairing QR has a four-module clear border, verified from its generated PNG pixels. This rendering fix is separate from the Mobile camera initialization failure fixed by Expo Camera 58.0.13. + +## Verification + +Fresh October 28 checks passed: 52 connector tests; 168 Desktop tests; Desktop package and release typechecks; two generator tests and three Linux browser-helper tests in the Mobile companion. The earlier renderer qualification passed five settings Playwright cases, 55 browser tests, 30 sidecar route tests, and app/e2e typechecks. + +The earlier app unit run had 748 passes and one Arabic translation-parity failure reproduced on the clean base, involving four existing `ui.message.outputLimit` keys. No unrelated translations were changed and no failure was suppressed. Earlier detailed results remain in [the October 25 report](mobile-access-verification-2026-10-25.md). + +## Settings renderer fixtures + +These screenshots render the production SettingsMobile component with the same isolated Playwright fixture (1440 × 1080, fake IPC response, and a held pending Pair request). The before image uses the original PR #60 head d9d6c3c; Reset is disabled during provisioning. The after image uses the reliability-fix renderer from 6331ba9; Reset remains available to cancel provisioning. Both are synthetic renderer evidence only: no Desktop profile, sidecar, tunnel, phone, camera, QR image, or pairing payload was used. + +![Before: SettingsMobile renderer fixture with Reset disabled during synthetic provisioning](assets/mobile-access/before-provisioning.png) + +![After: SettingsMobile renderer fixture with Reset enabled during the same synthetic provisioning state](assets/mobile-access/after-provisioning.png) + +## Physical Android result + +On October 28, the user scanned the installed Desktop QR with a OnePlus CPH2691 running Android 17. The phone reached matching-code approval, the user approved on Desktop, and real Desktop conversations loaded. This was a physical camera scan and user-operated approval, rather than a pasted link or an automated IPC approval. + +The phone ran the separate `BharatCode Camera QA` package, built from Mobile source `0d799e09a9d5a275f4958ba88eb1c8cadadfa6c4` with temporary diagnostic counters and `expo-camera` built from source. R8 remained enabled. Installed APK SHA-256: `8462f4085100d318051b704bd6b345a0b992036244813115ee2fb231469f1653`. The existing installed Desktop and its existing sidecar handled pairing; this is not acceptance of a newly packaged Desktop build. + +No camera images, QR contents or credentials were recorded by the counters. No phone permissions were changed by automation, and the existing app data and Desktop profile were preserved. This does not establish iOS, store signing, or all-platform release acceptance. + +## Release boundary + +The previous Linux AppImage was built from `6331ba9`, before the QR-border change. It must not be represented as the final artifact. Official publication follows `.github/workflows/README.md`: build an exact-source cohort, record manual platform acceptance, then publish those same accepted bytes. A source PR or the physical Mobile result above does not replace that gate. diff --git a/docs/mobile-access-trust-boundary.md b/docs/mobile-access-trust-boundary.md new file mode 100644 index 000000000..69c0115b8 --- /dev/null +++ b/docs/mobile-access-trust-boundary.md @@ -0,0 +1,29 @@ +# Desktop–Mobile tunnel trust boundary + +This integration uses the existing OpenTunnel SDK against `https://tunnel.bharatcode.ai`, reviewed against `bharatcode-tunnel` commit `fe7dfa73b35ebb035ba42a62482ec230a7e1a8bf`, particularly the [client SDK documentation](https://github.com/BharatCode-ai/bharatcode-tunnel/blob/fe7dfa73b35ebb035ba42a62482ec230a7e1a8bf/packages/client/README.md) and [route-target trust boundary](https://github.com/BharatCode-ai/bharatcode-tunnel/blob/fe7dfa73b35ebb035ba42a62482ec230a7e1a8bf/docs/trust-boundary.md). It requires no new account or API/Chat service change. + +## Transport and application authority + +The relay forwards encrypted application traffic. The SDK terminates tenant TLS in Desktop main and forwards decrypted bytes to a dedicated loopback mobile gateway. The gateway uses a separate private connection credential to access the already-running Desktop sidecar. The sidecar itself, local management endpoints, account credentials, provider secrets and Desktop IPC are never tunnel routes. + +A tunnel URL, loopback TCP peer, `Host: localhost`, or any `Forwarded`/`X-Forwarded-*` header grants no authority. The gateway authenticates each application request using a device bearer credential and only forwards its explicit route allowlist. It constructs upstream authorization itself and does not forward visitor cookies, credentials or forwarding headers. Configuration/credential/account endpoints and the global event stream are outside the mobile policy. Selected conversation events and sanitized model metadata are projected by the existing connector. + +Desktop management travels through preload IPC. Only an owned renderer's main frame can invoke mobile actions. The tunnel client runs in main. Mobile-access IPC does not return the sidecar password, tunnel token/private key, phone bearer token or pending claim secret. Desktop initialization returns the sidecar URL with null username/password fields. Main retains the local sidecar credential and adds authorization only to requests from an owned Desktop renderer for that exact sidecar origin; credentials are not carried through redirects. This initialization and authorization policy are not exposed to the phone or tunnel. Remote revocation cannot be performed through a public management endpoint; a phone can revoke its own credential. + +## Pairing and device access + +Desktop explicitly opens a five-minute pairing window. Its QR/link contains only a public Desktop identifier and HTTPS tenant origin. A link does not authenticate its holder; pending pairing requests can arrive from anyone who knows the address while that window is open. Desktop shows the phone name and random six-digit confirmation. The owner must compare the code on the phone before approving; phone names alone are untrusted. + +The phone posts a claim request over HTTPS. The response contains a fresh high-entropy claim ID and claim secret; polling uses a JSON POST body. Pending claims are bounded to five, with at most ten accepted attempts per minute per Desktop. Refreshing the QR invalidates pending claims; expiry and decline prevent approval. A successful approval closes the pairing window and declines competing waiting requests. The legacy JSON QR's one-use code remains supported, but that code is never encoded into the new link. + +Only explicit trusted Desktop approval creates a phone credential. It is delivered once in the HTTPS response body, stored in native SecureStore on Mobile, and represented only by its SHA-256 verifier in the Desktop registry. Subsequent requests and streaming use the authorization header. Credentials and claim secrets are not query parameters, cookies or diagnostic output. Native fetch explicitly omits cookies and refuses redirects. Both clients accept only well-formed HTTPS tenant origins below `tunnel.bharatcode.ai`, rejecting the control apex, upstream service, other domains, credentials, paths, query/fragment, unexpected ports and URL parser normalization tricks. Opened app links accept exactly `origin` and `desktopID` once each. + +Revocation removes the verifier and terminates that device's active requests/streams. Further requests receive HTTP 401 and require a new Desktop-approved pairing. Disabling remote access closes the gateway/tunnel, clears pending invitations, and persists the disabled preference. Previously authorized phones remain registered for deliberate re-enablement. Reset additionally revokes every phone and asks the tunnel service to remove the identity; private tunnel state is retained if server removal fails so removal can be retried. + +## Persistence and operations + +The tunnel token, TLS private key, certificate and pending issuance state stay in the private Desktop user-data tree, outside the renderer. Directories use mode 0700 and SDK secret files use mode 0600 on POSIX; native platform user-data permissions also apply. Mobile bearer credentials are separate from SQLite conversation metadata/drafts. Access tokens, private keys and claim secrets must not appear in support logs, screenshots or PR evidence. + +The SDK's cancellable Effect interface keeps status/cancel actions available while issuance waits for DNS/certificate readiness. Pending issuance is resumable. A service-specific `mobile-access/tunnel-bharatcode-ai` store prevents credentials from the old upstream service being sent to the new API. Later starts restore the same tunnel/certificate and phone registry until explicitly disabled or reset. Connectivity state is not proof of an authenticated application request. + +AWS, Route 53, Azure and ZeroSSL credentials belong solely to the hosted service. Neither app includes them. Applications continue normal HTTPS certificate validation. Source checks, real hosted tunnel tests, native/emulator tests and physical-device tests are reported as separate evidence tiers; see the dated verification report for actual coverage. diff --git a/docs/mobile-access-verification-2026-10-25.md b/docs/mobile-access-verification-2026-10-25.md new file mode 100644 index 000000000..48032aa9d --- /dev/null +++ b/docs/mobile-access-verification-2026-10-25.md @@ -0,0 +1,92 @@ +# Self-hosted tunnel integration verification — 2026-10-25 + +Later fixes and physical camera acceptance are recorded in [the October 28 follow-up](mobile-access-fixes-2026-10-28.md). The results below describe the earlier source and artifacts. + +The implementation uses the existing OpenTunnel SDK against `https://tunnel.bharatcode.ai` and retains explicit Desktop-approved pairing. Tunnel service reference: `fe7dfa73b35ebb035ba42a62482ec230a7e1a8bf`. Desktop base: `695c2d956e6bc6f27b881a1cdbe0a7acd0764bc3`; Mobile base: `5403d872dbb85d9b42856312a9c503edf7982cae`. + +## October 27 correction + +The reset claim below originally covered the deletion helper, not cancellation through the Desktop action queue. The October 27 audit reproduced that queue wait and an optional-storage startup failure. Follow-up fixes and current acceptance boundaries are documented in [mobile-access-acceptance.md](mobile-access-acceptance.md). Results below remain historical evidence for the October 25 source and do not qualify a later build. + +## Source verification + +| Gate | Result | +| --- | --- | +| Mobile TypeScript and ESLint | Passed | +| Mobile Jest suite | 71 passed, 12 suites | +| Existing certificate/security regressions | 15 passed | +| Mobile and connector npm audits | 0 known vulnerabilities | +| Expo dependency compatibility | Dependencies up to date | +| Connector Node suite | 44 passed | +| Desktop integration generator, applied twice to a clean pin | 2 passed | +| Linux browser helper regressions | 3 passed | +| Desktop package TypeScript and release configuration | Passed | +| App package TypeScript | Passed | +| Full Desktop workspace typecheck | 19 tasks passed in isolated sibling checkout | +| Desktop package source suite | 168 passed; focused owned-renderer authorization checks: 8 passed / 40 assertions | +| App package unit suite | 748 passed, 1 pre-existing failure; see below | + +`packages/app/src/i18n/parity.test.ts` fails because the Arabic UI dictionary lacks `ui.message.outputLimit`, `ui.message.outputLimit.reasoning`, `ui.message.outputLimit.partial`, and `ui.message.outputLimit.tool`. A fresh clean clone at the Desktop base reproduces the exact 748-pass/1-fail result. This integration does not modify those UI files or suppress the failure. The mobile settings keys use the repository's existing shared English fallback pending reviewed translations. + +One parallel Desktop test run observed a timing-sensitive failure in the unchanged `src/main/stdio.test.ts` test `compiled guard survives real Node stdout and stderr pipe disconnects`: its unguarded child delivered an extra `alive` message before exiting. The focused six-test stdio suite and subsequent full 168-test Desktop suite both passed. No test was suppressed and those stdio files were not changed. + +## Security review + +An independent read-only code reviewer checked the pinned tunnel trust-boundary document, SDK lifecycle, pairing/approval, gateway route/authentication boundaries, persistence, streaming revocation, native link parser, and trusted Desktop IPC. No actionable authentication bypass was found in that scope. This was a focused code review, not a repository-wide security audit. + +The review identified a reset path that could wait for a pending certificate. Reset now uses the stored authenticated tunnel identity to delete directly through the SDK API, with a 30-second operation bound. A regression exercises actual pending storage and the SDK HTTP request, without completing a certificate. Another regression verifies Desktop immediately reflects phone-initiated unpairing. The migration regression pins the separate self-hosted identity store so upstream credentials cannot be reused against the new service. + +A final connection-state regression reproduced idle background activity failures leaving Desktop marked connected; the Mobile client now shows offline/reconnecting, retries identity health, retains pairing, and clears the notice on recovery. + +Other source regressions exercise expired/replayed/declined claims, bounded public-link enrollment, mandatory explicit approval, invalid origins and malformed/deceptive URLs, wrong device credentials, browser origins, spoofed localhost/forwarding headers, sensitive route denial, provider-secret projection, registry persistence, revocation during requests/streams, readable status and cancellable slow issuance. Native fetch explicitly omits cookies and rejects redirects. See [the documented boundary](mobile-access-trust-boundary.md). + +The installed-runtime follow-up exposed two Mobile bugs: a warm pairing link could open the screen without submitting its claim, and long plain replies created a native Text view per Markdown leaf. Native intent routing now validates the original URI before passing it to pairing, with a per-delivery identifier so the same public link can be retried after cancellation. Plain paragraph rendering now joins text leaves while preserving rich Markdown children and heading styles. Regressions cover real store claim submission, repeated intents, malformed links without credential propagation, all 600 reply lines with bounded native view growth, rich spans, and three heading levels. Independent review caught the initial heading-style regression; the revised implementation resolves it. A sustained real-model run also exposed UI starvation from synchronously publishing every streamed text delta. The store now accumulates all deltas and publishes at most once per 100 ms, avoiding a full history reload for each text delta while lifecycle events still reconcile authoritative state. A 200-delta regression reproduces 200 notifications before the fix; the revised path retains every character with bounded notifications. Separate coverage ensures queued text cannot publish after navigation or revocation, and durable replay still reconciles text through history. Independent source review found no actionable issue in this change. The final exact `npm run check` passed TypeScript, ESLint, all 71 Jest tests and all 15 certificate/security checks. + +## Runtime qualification + +The designated `gpt-6-luna`/max qualification delegates own runtime builds and native/hosted testing. Source tests above do not prove real certificate issuance, physical camera scanning, network changes or native streaming. + +Observed hosted results before the UI/native handoff: + +- The real hosted service completed first-time certificate issuance in approximately six minutes. The SDK delivered 195 progress callbacks; pending identity state remained private and persisted through issuance. +- A subsequent connection with the stored certificate attached in approximately 300 ms. +- The public pairing link contained only origin/Desktop identity. A claim remained pending until explicit Desktop approval with matching confirmation, then authenticated health and protected session requests succeeded. +- Real hosted SSE delivered the expected projected assistant text delta from an authenticated deterministic sidecar fixture. This proves the transport/gateway path; it is not a production model-generation test. +- No bearer, wrong bearer, cookie-only, forwarded-header-only, and `Host: localhost` plus spoofed forwards returned 401. A browser Origin with a valid bearer returned 403. +- Closing/recreating the connector in the same Node process retained the same origin, Desktop/device IDs, accepted phone bearer and digest of all five identity/certificate files. This is connector lifecycle retention evidence; a full Desktop process restart is recorded separately below. +- Re-pairing issued a working replacement credential while the old revoked credential remained rejected. Reset deleted the hosted test identity. The initial harness then made a redundant authenticated fetch after reset, which failed because the tunnel had been removed; this is a harness-order limitation, not a passing post-reset authenticated request. + +Integrated Desktop results: + +- A second fresh identity provisioned through the real Electron main-process/preload IPC in approximately five minutes. Status snapshots showed certificate progress while issuance remained pending. The test used the existing Desktop sidecar, rather than creating another agent. +- Native Android claimed the public link and received access only after a matching-code approval through the actual owned-renderer IPC. Authenticated health and session requests then succeeded. The QA Desktop profile was signed out, so visible Settings/approval UI remained behind its existing account gate; approval was invoked through trusted IPC rather than by borrowing credentials or bypassing that gate. +- On the real Desktop tenant, unauthenticated, wrong-token, cookie-only, forwarded-header and localhost-Host spoof requests were rejected. A browser-origin request with a valid bearer was rejected. +- A disposable approved device created an empty session in the real isolated sidecar, then opened its hosted SSE endpoint: HTTP 200 and the initial connected comment arrived. Revoking that device through Desktop IPC removed it, made the old bearer receive 401, and actually aborted the stream after 13 buffered bytes drained. No provider/model call was made. +- A full isolated Electron stop and fresh process launch restored remote access as enabled and connected, with the same public origin, Desktop ID, one saved Android device, and identical SHA-256 digests for the five identity/certificate files and device registry. Android recovered with its existing credential and no new pairing. + +Android emulator results (API 35, x86_64): + +- An isolated development APK used a separate QA application ID. Opening the Desktop link reached native pairing, matching-code approval completed, and authenticated Desktop chats/session data rendered. +- A launcher relaunch restored the saved computer from native storage and reconnected without a new claim. A Metro JavaScript reload left the app on its existing pairing route with a Back to chats action; this was route state, rather than credential loss. +- After the full Desktop process restart, Android showed Offline and the saved-draft reconnect banner, then returned to Connected with the original credential. +- Enabling airplane mode on the QA emulator caused an actual background request failure and visible Offline/saved-draft notice within two seconds. Restoring its network returned the same paired computer to Connected within six seconds. +- An authenticated empty real-sidecar conversation rendered its composer and file/working-change controls after recovery, without sending a prompt. Native SSE server acceptance and incremental text rendering were not directly observed in this run; the separate hosted fixture and real-sidecar stream tests above cover those transport/termination boundaries. +- Revoking the phone through Desktop IPC produced “This phone is no longer authorized. Pair again.” in native UI. The saved computer metadata remained; no new pairing was performed after revocation. A subsequent native force-stop and launcher relaunch created a new app process and restored the same revoked state within approximately six seconds. +- A malformed link was rejected natively before a claim. Warm link dispatch worked. Cold link dispatch first opened the Expo development launcher until its remembered Metro server was selected; standalone cold-start behavior remains unverified. + +The initial isolated-profile run above did not verify production model generation or the signed-in installed Desktop. The follow-up below uses the installed native application and its existing account/session. Physical camera scanning, physical Android/iOS and signed distribution remain unverified. No existing installed Mobile app was replaced by the separate QA package. + +## Installed native Desktop and real-model follow-up + +The follow-up used the installed native Fedora Desktop build, its existing signed-in profile and existing sidecar, plus the separate API 35 Android development QA package. One user-approved controlled Desktop restart restored the same tunnel identity/certificate and paired-device registry. That Desktop process remained running throughout subsequent testing. Approval used the actual owned-renderer preload IPC with matching confirmation codes; pointer-driven Desktop approval UI is not claimed. Three pre-existing paired devices were preserved; disposable QA devices were handled separately. + +- A public pairing link submitted a claim and connected Android only after explicit matching-code Desktop approval. Opening the real public link in the already-running Android app also passed: it automatically submitted a pending claim, displayed the matching confirmation and connected only after approval. This used the native-intent fix above; Metro needed its route context refreshed to include the newly added file in the development bundle. No manual paste was used for that final warm-link test. +- Real account-backed Qwen 3.8 27B requests ran through the phone's authenticated HTTPS tunnel, gateway and existing Desktop sidecar. The completed answers included the requested 1–80, 1–200, 1–120 and 1–1000 sequences. These were model calls, rather than fixture SSE events. +- During the 200-line request, Android Wi-Fi/data were disabled after admission. Mobile showed Offline and its saved-draft/reconnect notice. Desktop completed generation while the phone was offline. Restoring the network recovered the paired connection, cleared the notice and displayed the completed answer; the authoritative session retained one user/assistant pair for that request, without duplicate admission. +- The 1000-line stress run exposed UI starvation described above. After the rendering/store fixes, Mobile rendered all 1000 lines in one numeric TextView and Back navigation worked. The first live sampler omitted UIAutomator errors, so its zero counts are invalid measurements and are not used as proof of missing text or successful incremental growth. +- Incremental real-model rendering passed on the revised source without instrumentation. A read-only React DevTools probe measured the current assistant's committed `Message` props growing from 46 to 114 text characters, with screenshots showing the visible tail advance from line 18 to line 40. The matching assistant's authenticated Desktop lookup explicitly reported incomplete after each screenshot. These pre-revocation samples were approximately 8.24 and 10.17 seconds after sending the prompt. No accessibility-idle assumption or final-response timing was used to infer streaming. +- Desktop revoked only the disposable QA phone while that response was still incomplete. Mobile automatically switched to Offline with “This phone is no longer authorized. Pair again.” in approximately 1.97 seconds, and native accessibility confirmed Send was visible but disabled. Neither app was restarted. Twelve characters already in flight rendered before the phone's text stabilized at 126 characters. After Desktop finished the 1,893-character answer, eleven further observations over 20.27 seconds confirmed the phone stayed offline/unauthorized at that same partial text, with no further reply growth. Revocation does not retract buffered bytes or automatically cancel work owned by Desktop. The device list returned from four entries to the original three. Earlier fresh requests with revoked credentials also returned the re-pair notice. + +Qualification processes ran under a shared 6 GiB memory cap and two-core CPU budget; the emulator child was capped at 4 GiB with 1.5 GiB guest RAM. Earlier attempts that exceeded smaller emulator child caps were discarded. The later UI starvation was observed without a new memory-limit/OOM event. + +No release publication or store submission is part of this work. Physical Android/iOS, camera scanning and signed distribution remain separate qualification tiers. diff --git a/docs/superpowers/plans/2026-10-27-mobile-access-fixes.md b/docs/superpowers/plans/2026-10-27-mobile-access-fixes.md new file mode 100644 index 000000000..118a2c577 --- /dev/null +++ b/docs/superpowers/plans/2026-10-27-mobile-access-fixes.md @@ -0,0 +1,13 @@ +# Mobile Access Reliability Implementation Plan + +**Goal:** Resolve the October 27 audit defects while preserving existing pairing authorization and leaving final physical-device acceptance to the user. + +**Architecture:** Keep mobile access optional: constructor creates the controller without touching persistent state; guarded restoration and explicit actions load the registry. Preserve corrupt state and fail closed for phone access. Cancel provisioning before queueing reset/disable. Keep native default-sidecar scope explicit. + +**Constraints:** No account/relay architecture changes, no restart of existing Desktop/server, no push/issue/PR update without subsequent approval. Primary implements; gpt-6-luna/max owns qualification. Repository package-local commands and typed i18n remain in force. + +- [x] Add failing production-controller regressions for malformed/unavailable storage, reset during issuance and queued cancellation; implement lazy guarded storage loading and cancellation. +- [x] Add focused settings interaction coverage for provisioning/reset, matching-code approval, errors and device management. Preserve user-confirmation behavior and handle clipboard failures. +- [x] Address portable file directory reading if implementation can avoid Windows directory handles; keep actual platform qualification distinct. +- [x] Keep historical results dated; document corrected behavior, supported runtime scope, local submission drafts and user acceptance steps. +- [ ] Run implementation checks, independent review and delegated exact-source qualification. Record inherited localization failure instead of expanding unrelated scope. diff --git a/packages/app/e2e/mobile-settings.spec.ts b/packages/app/e2e/mobile-settings.spec.ts new file mode 100644 index 000000000..791e118e5 --- /dev/null +++ b/packages/app/e2e/mobile-settings.spec.ts @@ -0,0 +1,129 @@ +import { expect, test, type Page } from "@playwright/test" +import type { MobileAction, MobileState } from "../src/mobile-types" + +const fixture = "/e2e/reproduction/mobile-settings/index.html" +const off: MobileState = { enabled: false, connecting: false, status: "off", devices: [], requests: [] } + +async function serve(page: Page, initial: MobileState, change?: (action: MobileAction) => Promise) { + await page.route("**/__fixture/mobile", async (route) => { + const input = route.request().postDataJSON() as MobileAction + const state = input.type === "state" || !change ? initial : await change(input) + await route.fulfill({ json: state }) + }) +} + +test("storage errors are visible without exposing the stored data", async ({ page }) => { + await serve(page, { ...off, status: "error", errorKey: "mobile.error.storage" }) + await page.goto(fixture) + await expect(page.getByRole("alert")).toHaveText( + "Mobile-device storage is unavailable. Your existing device data has been kept. Check storage access and try again.", + ) + await expect(page.getByRole("button", { name: "Connect phone", exact: true })).toBeEnabled() +}) + +test("Reset remains usable during provisioning and completes without a separate Cancel", async ({ page }) => { + await page.clock.install() + await page.clock.pauseAt(new Date()) + let began: () => void = () => {} + const started = new Promise((resolve) => { + began = resolve + }) + let state = { ...off } + let release: () => void = () => {} + const pending = new Promise((resolve) => { + release = resolve + }) + await page.route("**/__fixture/mobile", async (route) => { + const action = route.request().postDataJSON() as MobileAction + if (action.type === "pair") { + state = { ...off, status: "provisioning", connecting: true } + began() + await pending + await route.fulfill({ status: 409, json: { error: "cancelled pairing" } }) + return + } + if (action.type === "reset") { + state = { ...off } + release() + } + await route.fulfill({ json: state }) + }) + await page.goto(fixture) + await expect(page.getByRole("button", { name: "Connect phone", exact: true })).toBeEnabled() + await page.getByRole("button", { name: "Connect phone", exact: true }).click() + await started + await page.clock.runFor(1500) + await expect(page.getByRole("button", { name: "Cancel connection", exact: true })).toBeVisible() + const reset = page.getByRole("button", { name: "Revoke all phones and reset tunnel", exact: true }) + await expect(reset).toBeEnabled() + page.once("dialog", (dialog) => dialog.accept()) + await reset.click() + await expect(page.getByRole("button", { name: "Connect phone", exact: true })).toBeEnabled() + await expect(page.getByRole("button", { name: "Cancel connection", exact: true })).toHaveCount(0) + await expect(page.getByRole("alert")).toHaveCount(0) + await expect(page.getByText("No phones paired yet.", { exact: true })).toBeVisible() +}) + +test("shows the matching code and approves only the selected request", async ({ page }) => { + const waiting: MobileState = { + ...off, + enabled: true, + status: "connected", + requests: [{ claimID: "claim-qa", name: "QA phone", confirmation: "572019", expiresAt: Date.now() + 300000 }], + } + await serve(page, waiting, async (action) => { + expect(action).toEqual({ type: "approve", id: "claim-qa" }) + Object.assign(waiting, { requests: [], devices: [{ id: "device-qa", name: "QA phone", pairedAt: 1 }] }) + return waiting + }) + await page.goto(fixture) + await expect(page.getByText("572019", { exact: true })).toBeVisible() + await expect( + page.getByText("Approve only if this code matches the one on your phone.", { exact: true }), + ).toBeVisible() + await page.getByRole("button", { name: "Approve phone", exact: true }).click() + await expect(page.getByText("572019", { exact: true })).toHaveCount(0) + await expect(page.getByRole("button", { name: "Revoke access", exact: true })).toBeEnabled() + await expect(page.getByText("QA phone", { exact: true })).toHaveCount(1) +}) + +test("revocation requires confirmation and removes the selected phone", async ({ page }) => { + const paired: MobileState = { ...off, devices: [{ id: "device-qa", name: "QA phone", pairedAt: 1 }] } + await serve(page, paired, async (action) => { + expect(action).toEqual({ type: "revoke", id: "device-qa" }) + paired.devices = [] + return paired + }) + await page.goto(fixture) + const revoke = page.getByRole("button", { name: "Revoke access", exact: true }) + await expect(revoke).toBeEnabled() + page.once("dialog", (dialog) => dialog.dismiss()) + await revoke.click() + await expect(page.getByText("QA phone", { exact: true })).toBeVisible() + page.once("dialog", (dialog) => dialog.accept()) + await revoke.click() + await expect(page.getByText("QA phone", { exact: true })).toHaveCount(0) + await expect(page.getByText("No phones paired yet.", { exact: true })).toBeVisible() +}) + +test("decline removes a pending request without pairing it", async ({ page }) => { + const waiting: MobileState = { + ...off, + enabled: true, + status: "connected", + requests: [ + { claimID: "declined-qa", name: "Unapproved phone", confirmation: "109275", expiresAt: Date.now() + 300000 }, + ], + } + await serve(page, waiting, async (action) => { + expect(action).toEqual({ type: "decline", id: "declined-qa" }) + waiting.requests = [] + return waiting + }) + await page.goto(fixture) + await expect(page.getByText("109275", { exact: true })).toBeVisible() + await page.getByRole("button", { name: "Decline", exact: true }).click() + await expect(page.getByText("109275", { exact: true })).toHaveCount(0) + await expect(page.getByText("No phones paired yet.", { exact: true })).toBeVisible() + await expect(page.getByRole("button", { name: "Revoke access", exact: true })).toHaveCount(0) +}) diff --git a/packages/app/e2e/reproduction/mobile-settings/index.html b/packages/app/e2e/reproduction/mobile-settings/index.html new file mode 100644 index 000000000..b6861bc9c --- /dev/null +++ b/packages/app/e2e/reproduction/mobile-settings/index.html @@ -0,0 +1,12 @@ + + + + + + Mobile settings regression fixture + + +
+ + + diff --git a/packages/app/e2e/reproduction/mobile-settings/main.tsx b/packages/app/e2e/reproduction/mobile-settings/main.tsx new file mode 100644 index 000000000..b79e5955b --- /dev/null +++ b/packages/app/e2e/reproduction/mobile-settings/main.tsx @@ -0,0 +1,34 @@ +import { render } from "solid-js/web" +import { SettingsMobile } from "../../../src/components/settings-mobile" +import { PlatformProvider, type Platform } from "../../../src/context/platform" +import { LanguageProvider } from "../../../src/context/language" +import "../../../src/index.css" + +// This isolated fixture uses the production component/providers. Only the IPC +// boundary is supplied by each browser test; no real profile or tunnel is used. +const platform: Platform = { + platform: "desktop", + os: "linux", + openExternal() {}, + async restart() {}, + async notify() {}, + async openDirectoryPickerDialog() { + return null + }, + async mobileAccess(input) { + const response = await fetch("/__fixture/mobile", { method: "POST", body: JSON.stringify(input) }) + if (!response.ok) throw new Error("Fixture action failed") + return response.json() + }, +} + +render( + () => ( + + + + + + ), + document.getElementById("root")!, +) diff --git a/packages/app/e2e/reproduction/mobile-settings/playwright.config.ts b/packages/app/e2e/reproduction/mobile-settings/playwright.config.ts new file mode 100644 index 000000000..4e6a325f7 --- /dev/null +++ b/packages/app/e2e/reproduction/mobile-settings/playwright.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from "@playwright/test" +import config from "../../../playwright.config" + +export default defineConfig({ + ...config, + testDir: "../..", + testMatch: "mobile-settings.spec.ts", + outputDir: "../../test-results/mobile-settings", + workers: 1, + retries: 0, + reporter: [["line"]], + use: { ...config.use, baseURL: "http://127.0.0.1:3286" }, + webServer: { + command: "bun run dev -- --host 127.0.0.1 --port 3286 --strictPort", + cwd: import.meta.dirname + "/../../..", + url: "http://127.0.0.1:3286/e2e/reproduction/mobile-settings/index.html", + reuseExistingServer: false, + timeout: 120_000, + }, +}) diff --git a/packages/app/e2e/tsconfig.json b/packages/app/e2e/tsconfig.json index 635a63990..ef076db5d 100644 --- a/packages/app/e2e/tsconfig.json +++ b/packages/app/e2e/tsconfig.json @@ -11,6 +11,9 @@ "../playwright.config.ts", "../../desktop/src/renderer/router.tsx", "./startup-restore.spec.ts", + "./mobile-settings.spec.ts", + "./reproduction/mobile-settings/**/*.tsx", + "./reproduction/mobile-settings/**/*.ts", "./reproduction/startup/**/*.ts", "./reproduction/startup/**/*.tsx", "./markdown-safety.spec.ts", diff --git a/packages/app/package.json b/packages/app/package.json index 773bb783a..d3c45bcc2 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -10,7 +10,8 @@ "./updater": "./src/updater.ts", "./wsl/types": "./src/wsl/types.ts", "./vite": "./vite.js", - "./index.css": "./src/index.css" + "./index.css": "./src/index.css", + "./mobile-types": "./src/mobile-types.ts" }, "scripts": { "typecheck": "tsgo -b", diff --git a/packages/app/src/components/dialog-settings.tsx b/packages/app/src/components/dialog-settings.tsx index f90fe7cc0..914f1ccdd 100644 --- a/packages/app/src/components/dialog-settings.tsx +++ b/packages/app/src/components/dialog-settings.tsx @@ -13,6 +13,8 @@ import { SettingsServers } from "./settings-servers" import { SettingsAccount } from "./settings-account" import { SettingsMarketplace } from "./settings-marketplace" +import { SettingsMobile } from "./settings-mobile" + export const DialogSettings: Component<{ defaultValue?: string }> = (props) => { const language = useLanguage() const platform = usePlatform() @@ -47,6 +49,7 @@ export const DialogSettings: Component<{ defaultValue?: string }> = (props) => { {language.t("settings.account.title")} + {language.t("mobile.title")} {language.t("settings.tab.general")} @@ -89,6 +92,7 @@ export const DialogSettings: Component<{ defaultValue?: string }> = (props) => { + diff --git a/packages/app/src/components/settings-mobile.tsx b/packages/app/src/components/settings-mobile.tsx new file mode 100644 index 000000000..f28009b81 --- /dev/null +++ b/packages/app/src/components/settings-mobile.tsx @@ -0,0 +1,191 @@ +import { For, Show, onCleanup, onMount } from "solid-js" +import { createStore } from "solid-js/store" +import { Button } from "@opencode-ai/ui/button" +import { useLanguage } from "@/context/language" +import { usePlatform } from "@/context/platform" +import type { MobileAction, MobileState } from "@/mobile-types" + +export function SettingsMobile() { + const language = useLanguage() + const platform = usePlatform() + const [state, setState] = createStore<{ value?: MobileState; busy: boolean; failed: boolean }>({ + busy: false, + failed: false, + }) + let alive = true + let polling = false + let operations = 0 + let revision = 0 + const refresh = async () => { + if (polling || !alive) return + polling = true + const token = revision + try { + const value = await platform.mobileAccess?.({ type: "state" }) + if (alive && token === revision) setState({ value, failed: false }) + } catch { + if (alive && token === revision) setState("failed", true) + } finally { + polling = false + } + } + const action = async (input: MobileAction) => { + if (state.busy && input.type !== "disable" && input.type !== "reset") return + operations++ + const token = ++revision + setState({ busy: true, failed: false }) + try { + const value = await platform.mobileAccess?.(input) + if (alive && token === revision) setState({ value, failed: false }) + } catch { + if (alive && token === revision) setState("failed", true) + } finally { + operations-- + if (alive) setState("busy", operations > 0) + } + } + onMount(() => { + void refresh() + const timer = setInterval(() => void refresh(), 1500) + onCleanup(() => clearInterval(timer)) + }) + onCleanup(() => { + alive = false + }) + return ( +
+
+

{language.t("mobile.title")}

+

{language.t("mobile.description")}

+
+ + + +

+ {language.t( + state.value?.status === "provisioning" + ? "mobile.status.provisioning" + : state.value?.status === "connected" + ? "mobile.status.connected" + : state.value?.status === "reconnecting" + ? "mobile.status.reconnecting" + : state.value?.status === "error" + ? "mobile.status.error" + : "mobile.status.off", + )} +

+ +

+ {language.t( + state.value?.progress === "generating-key" || state.value?.progress === "generating-csr" + ? "mobile.progress.key" + : state.value?.progress === "saving-identity" || state.value?.progress === "ready" + ? "mobile.progress.saving" + : state.value?.progress === "connecting" + ? "mobile.progress.connecting" + : "mobile.progress.certificate", + )} +

+
+
+ + + + +
+ + {(image) => ( +
+ {language.t("mobile.qr")} +

{language.t("mobile.expiry")}

+ +
+ )} +
+ + {(request) => ( +
+ {request.name} + {request.confirmation} +

{language.t("mobile.confirm")}

+
+ + +
+
+ )} +
+
+

{language.t("mobile.paired")}

+ {language.t("mobile.empty")}

} + > + + {(device) => ( +
+ {device.name} + +
+ )} +
+
+
+ +

{language.t("mobile.scope")}

+

+ {language.t(state.value?.enabled ? "mobile.enabled" : "mobile.disabled")} +

+
+ ) +} diff --git a/packages/app/src/components/settings-v2/dialog-settings-v2.tsx b/packages/app/src/components/settings-v2/dialog-settings-v2.tsx index abf7f2702..917142294 100644 --- a/packages/app/src/components/settings-v2/dialog-settings-v2.tsx +++ b/packages/app/src/components/settings-v2/dialog-settings-v2.tsx @@ -10,6 +10,7 @@ import { SettingsProvidersV2 } from "./providers" import { SettingsModelsV2 } from "./models" import { SettingsAccount } from "../settings-account" import { SettingsMarketplace } from "../settings-marketplace" +import { SettingsMobile } from "../settings-mobile" import "./settings-v2.css" import { SettingsServersV2 } from "./servers" import { useDialog } from "@opencode-ai/ui/context/dialog" @@ -94,6 +95,12 @@ export const DialogSettings: Component<{ {language.t("settings.account.title")} + + + + {language.t("mobile.title")} + + {language.t("settings.models.title")} @@ -133,6 +140,11 @@ export const DialogSettings: Component<{ + + + + + diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index 9c009e262..12e863bbc 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -50,6 +50,7 @@ export type AccountPlatform = { } type PlatformBase = AccountPlatform & { + mobileAccess?(input: import("../mobile-types").MobileAction): Promise getChatImportStatus?(): Promise<{ state: "pending" | "complete" | "failed"; imported: number; skipped: number }> /** Account operations for a specific owned runtime; never a remote URL fallback. */ accountForServer?(key: ServerConnection.Key): AccountPlatform diff --git a/packages/app/src/i18n/bharatcode.ts b/packages/app/src/i18n/bharatcode.ts index d6b08e3fa..d8fc5c89e 100644 --- a/packages/app/src/i18n/bharatcode.ts +++ b/packages/app/src/i18n/bharatcode.ts @@ -23,7 +23,10 @@ export const BHARATCODE_NATIVE_ENGLISH = { "desktop.account.error.signInRequired": "Sign in to BharatCode again to continue.", } as const +import { MOBILE_ENGLISH } from "./mobile" + export const BHARATCODE_ENGLISH = { + ...MOBILE_ENGLISH, ...BHARATCODE_NATIVE_ENGLISH, "chatImport.pending": "Restoring previous chats in the background…", "chatImport.complete": "Restored {{count}} previous chats. Refresh this view to see them.", diff --git a/packages/app/src/i18n/mobile.ts b/packages/app/src/i18n/mobile.ts new file mode 100644 index 000000000..38736e716 --- /dev/null +++ b/packages/app/src/i18n/mobile.ts @@ -0,0 +1,41 @@ +export const MOBILE_ENGLISH = { + "mobile.status.off": "Remote access is off.", + "mobile.status.provisioning": "Preparing the secure tunnel. First-time certificate issuance can take a few minutes.", + "mobile.status.connected": "Secure tunnel connected.", + "mobile.status.reconnecting": "Reconnecting to the secure tunnel…", + "mobile.status.error": "Secure tunnel unavailable. Retry the connection.", + "mobile.progress.key": "Generating the private key on this computer…", + "mobile.progress.certificate": "Waiting for the HTTPS certificate. This can take a few minutes…", + "mobile.progress.saving": "Saving the secure tunnel identity…", + "mobile.progress.connecting": "Connecting the secure tunnel…", + "mobile.cancel": "Cancel connection", + "mobile.reset": "Revoke all phones and reset tunnel", + "mobile.resetConfirm": + "Revoke all phones and remove this tunnel? Reconnecting will require fresh pairing and a new certificate.", + "mobile.title": "Mobile devices", + "mobile.description": + "Continue your BharatCode conversations from your phone. Your computer must stay online with BharatCode running.", + "mobile.connect": "Connect phone", + "mobile.preparing": "Preparing secure connection…", + "mobile.refresh": "Refresh QR", + "mobile.qr": "Scan this QR in BharatCode Mobile", + "mobile.expiry": "This pairing code expires after five minutes.", + "mobile.copy": "Copy pairing link", + "mobile.disable": "Turn off remote access", + "mobile.enabled": "Remote access is enabled through OpenTunnel.", + "mobile.disabled": "Remote access is off.", + "mobile.approve": "Approve phone", + "mobile.decline": "Decline", + "mobile.confirm": "Approve only if this code matches the one on your phone.", + "mobile.paired": "Paired phones", + "mobile.empty": "No phones paired yet.", + "mobile.revoke": "Revoke access", + "mobile.revokeConfirm": "Revoke this phone’s access? It will need a new pairing code to reconnect.", + "mobile.scope": "Connects to the local Desktop runtime. WSL workspaces are not available on mobile yet.", + "mobile.error.storage": + "Mobile-device storage is unavailable. Your existing device data has been kept. Check storage access and try again.", + "mobile.error.connection": + "The secure connection could not be established. Check your internet connection and retry.", + "mobile.error.reconnecting": "OpenTunnel is reconnecting. Keep BharatCode running.", + "mobile.error.action": "This mobile-device action could not be completed. Refresh and try again.", +} as const diff --git a/packages/app/src/mobile-types.ts b/packages/app/src/mobile-types.ts new file mode 100644 index 000000000..2708a3f0c --- /dev/null +++ b/packages/app/src/mobile-types.ts @@ -0,0 +1,12 @@ +export type MobileAction = { type: "state" | "pair" | "disable" | "reset" | "approve" | "decline" | "revoke"; id?: string } +export type MobileState = { + enabled: boolean + status: "off" | "provisioning" | "connected" | "reconnecting" | "error" + progress?: string + connecting: boolean + devices: { id: string; name: string; pairedAt: number }[] + requests: { claimID: string; name: string; confirmation: string; expiresAt: number }[] + pairing?: string + qrImage?: string + errorKey?: string +} diff --git a/packages/desktop/electron.vite.config.ts b/packages/desktop/electron.vite.config.ts index 91d3e9e8c..691ee8ae0 100644 --- a/packages/desktop/electron.vite.config.ts +++ b/packages/desktop/electron.vite.config.ts @@ -65,7 +65,10 @@ const require = __cjs_mod__.createRequire(import.meta.url); `, }, }, - externalizeDeps: { include: [nodePtyPkg] }, + externalizeDeps: { + include: [nodePtyPkg], + exclude: ["@bharatcode/mobile-connector", "effect"], + }, }, plugins: [ { diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 405ad8794..8d7ff0406 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -31,7 +31,8 @@ "electron-store": "11.0.2", "electron-updater": "6.8.9", "electron-window-state": "^5.0.3", - "drizzle-orm": "catalog:" + "drizzle-orm": "catalog:", + "@bharatcode/mobile-connector": "workspace:*" }, "devDependencies": { "@actions/artifact": "4.0.0", diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index 4676cb7e0..4cd01a341 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -64,12 +64,15 @@ import { guardConsolePipe } from "./stdio" guardConsolePipe(process.stdout) guardConsolePipe(process.stderr) +import { createMobileAccess } from "@bharatcode/mobile-connector" + const TEST_ONBOARDING = process.env.BHARATCODE_TEST_ONBOARDING === "1" || process.env.OPENCODE_TEST_ONBOARDING === "1" const SIDECAR_VERSION = process.env.OPENCODE_SIDECAR_V2 === "1" ? "v2" : "v1" const jsCallStackFeature = "DocumentPolicyIncludeJSCallStacksInCrashReports" let logger: ReturnType let server: SidecarListener | null = null +let mobileConnection: { url: string; username?: string | null; password: string } | undefined const pendingDeepLinks: string[] = [] @@ -164,7 +167,10 @@ const main = Effect.gen(function* () { }, }, ) + const mobileAccess = yield* Effect.promise(() => createMobileAccess({ userDataPath: app.getPath("userData"), getConnection: () => mobileConnection })) + app.once("will-quit", () => void mobileAccess.close()) const stopSidecars = async () => { + await mobileAccess.close() await killSidecar() await wslServers.stopAll() } @@ -302,6 +308,7 @@ const main = Effect.gen(function* () { } registerIpcHandlers({ account, + mobileAccess, killSidecar: () => killSidecar(), relaunch, awaitInitialization: Effect.fnUntraced( @@ -354,6 +361,7 @@ const main = Effect.gen(function* () { if (SIDECAR_VERSION === "v2") { logger.log("spawning v2 sidecar") const sidecar = yield* Effect.promise(() => startBackgroundCli(logger, shellEnv?.XDG_STATE_HOME)) + mobileConnection = sidecar setSidecarAuthorization(sidecar) yield* initializeConnection( serverReady, @@ -413,6 +421,7 @@ const main = Effect.gen(function* () { username: "opencode", password, } + mobileConnection = connection setSidecarAuthorization(connection) yield* initializeConnection( serverReady, @@ -436,6 +445,7 @@ const main = Effect.gen(function* () { restoreMainWindows() }) + void Effect.runPromise(Deferred.await(serverReady)).then(() => mobileAccess.restore()).catch(() => {}) const windows = restoreMainWindows() if (windows.length) createMenu(menuDeps) // Render the upstream startup/error surface while the barrier is pending. diff --git a/packages/desktop/src/main/ipc.ts b/packages/desktop/src/main/ipc.ts index b0ee590dc..4353ce694 100644 --- a/packages/desktop/src/main/ipc.ts +++ b/packages/desktop/src/main/ipc.ts @@ -36,6 +36,7 @@ const pickerFilters = (ext?: string[]) => { const pickedFiles = createPickedFileAuthorizations() type Deps = { + mobileAccess: import("@bharatcode/mobile-connector").MobileAccess account: ReturnType killSidecar: () => Promise | void relaunch: () => void @@ -104,6 +105,7 @@ export function registerIpcHandlers(deps: Deps) { "account-logout", accountAction((id) => deps.account.logout(id)), ) + ipcMain.handle("mobile-access", accountAction((input) => deps.mobileAccess.action(input as import("@bharatcode/mobile-connector").MobileAction))) const drafts = createDesktopDraftStore(join(app.getPath("userData"), "drafts.sqlite")) const updaterSubscriptions = createUpdaterSubscriptions() app.once("will-quit", updaterSubscriptions.clear) diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts index 268bbdde9..7195d73d1 100644 --- a/packages/desktop/src/preload/index.ts +++ b/packages/desktop/src/preload/index.ts @@ -11,6 +11,7 @@ const updaterHandler = (_: unknown, state: UpdaterState) => { } const api: ElectronAPI = { + mobileAccess: (input) => ipcRenderer.invoke("mobile-access", input), getChatImportStatus: () => ipcRenderer.invoke("chat-import-status"), getAccountStatus: (id) => ipcRenderer.invoke("account-status", id), refreshAccountStatus: (id) => ipcRenderer.invoke("account-refresh", id), diff --git a/packages/desktop/src/preload/types.ts b/packages/desktop/src/preload/types.ts index 4dfa808fe..fcbaa64ac 100644 --- a/packages/desktop/src/preload/types.ts +++ b/packages/desktop/src/preload/types.ts @@ -16,6 +16,8 @@ export type { WslServersState, } from "@opencode-ai/app/wsl/types" +import type { MobileAction, MobileState } from "@opencode-ai/app/mobile-types" + export type ServerReadyData = { url: string username: string | null @@ -55,6 +57,7 @@ export type FatalRendererError = { } export type ElectronAPI = { + mobileAccess: (input: MobileAction) => Promise getChatImportStatus: () => Promise<{ state: "pending" | "complete" | "failed"; imported: number; skipped: number }> getAccountStatus: (runtimeId?: string) => Promise refreshAccountStatus: (runtimeId?: string) => Promise diff --git a/packages/desktop/src/renderer/index.tsx b/packages/desktop/src/renderer/index.tsx index e19afcc76..19fd9dbdb 100644 --- a/packages/desktop/src/renderer/index.tsx +++ b/packages/desktop/src/renderer/index.tsx @@ -219,6 +219,7 @@ const createPlatform = (windowState: DesktopWindowState): Platform => { exportDebugLogs: () => window.api.exportDebugLogs(), getChatImportStatus: () => window.api.getChatImportStatus(), + mobileAccess: (input) => window.api.mobileAccess(input), setForceFocus: (enabled) => window.api.setForceFocus(enabled), diff --git a/packages/mobile-connector/README.md b/packages/mobile-connector/README.md new file mode 100644 index 000000000..b2d95c83b --- /dev/null +++ b/packages/mobile-connector/README.md @@ -0,0 +1,17 @@ +# BharatCode mobile connector + +This package runs in Desktop main and connects the existing authenticated Desktop sidecar to BharatCode Mobile through `https://tunnel.bharatcode.ai`. It never starts another agent. + +In Desktop, open **Settings → Mobile devices → Connect phone**. First-time HTTPS certificate issuance may take a few minutes; progress is shown and startup can be cancelled. Scan, paste or open the pairing link in the native Mobile app, compare the six-digit confirmation on both screens, then approve on Desktop. A link contains only public connection metadata and does not grant access. + +The same tunnel identity/certificate and approved phones are restored on subsequent launches. Turn off remote access to disconnect and prevent automatic startup. Revoke a phone to invalidate its credential and terminate its active streams. Reset revokes every phone and removes the hosted tunnel identity; server deletion must succeed before private tunnel state is discarded. + +The service-specific `mobile-access/tunnel-bharatcode-ai` store avoids reusing upstream OpenTunnel credentials. Existing upstream Mobile connections require fresh pairing with the new service. No account login is required. + +Run source tests from this directory with Node >=22: + +```sh +npm test +``` + +The package's declared SDK dependencies are managed by the root Bun workspace lockfile. Read [the trust boundary](../../docs/mobile-access-trust-boundary.md) before adding routes or changing authentication. Runtime qualification and platform limits are recorded in the associated PR; passing source tests alone does not establish native/device coverage. diff --git a/packages/mobile-connector/package.json b/packages/mobile-connector/package.json new file mode 100644 index 000000000..11b3d1e64 --- /dev/null +++ b/packages/mobile-connector/package.json @@ -0,0 +1,27 @@ +{ + "name": "@bharatcode/mobile-connector", + "version": "0.1.0", + "private": true, + "type": "module", + "engines": { + "node": ">=22" + }, + "exports": { + ".": { + "types": "./src/index.d.ts", + "import": "./src/index.mjs" + } + }, + "scripts": { + "test": "node --test", + "check": "node --check src/gateway.mjs" + }, + "dependencies": { + "@opentunnel/client": "0.5.2", + "effect": "^4.0.0", + "qrcode": "1.5.4" + }, + "devDependencies": { + "pngjs": "^5.0.0" + } +} diff --git a/packages/mobile-connector/src/admissions.mjs b/packages/mobile-connector/src/admissions.mjs new file mode 100644 index 000000000..aaf36f270 --- /dev/null +++ b/packages/mobile-connector/src/admissions.mjs @@ -0,0 +1,69 @@ +import { readFile, writeFile, rename, chmod } from "node:fs/promises" +import { randomUUID } from "node:crypto" + +const record = value => value !== null && typeof value === "object" && !Array.isArray(value) +const invalid = message => Object.assign(new Error(message), { code: "INVALID_ADMISSIONS", mobileStorage: true }) + +async function recoveryBefore(path) { + try { + const value = JSON.parse(await readFile(path + ".recovery", "utf8")) + if (!record(value) || !Number.isSafeInteger(value.before) || value.before < 0) + throw invalid("Invalid mobile admission recovery boundary") + return value.before + } catch (error) { + if (error.code === "ENOENT") return undefined + if (error instanceof SyntaxError) throw invalid("Invalid mobile admission recovery boundary") + error.mobileStorage = true + throw error + } +} + +async function journal(path) { + try { + const value = JSON.parse(await readFile(path, "utf8")) + if (!record(value) || !record(value.admissions) || !record(value.selections) || + Object.entries(value.admissions).some(([key, item]) => + !/^[A-Za-z0-9_-]+\.msg_[A-Za-z0-9_-]+$/.test(key) || !record(item) || + !/^[a-f0-9]{64}$/.test(item.hash) || !["forwarding", "admitted"].includes(item.state)) || + Object.values(value.selections).some(item => !record(item)) || + (value.recoverySessions !== undefined && (!Array.isArray(value.recoverySessions) || + value.recoverySessions.some(id => typeof id !== "string" || !/^ses_[a-f0-9]{32}$/.test(id))))) + throw invalid("Invalid mobile admission journal") + return value + } catch (error) { + if (error.code === "ENOENT") return { admissions: {}, selections: {} } + if (error instanceof SyntaxError) throw invalid("Invalid mobile admission journal") + error.mobileStorage = true + throw error + } +} + +export async function openAdmissions(path) { + const before = await recoveryBefore(path) + return { state: await journal(path), recovered: before !== undefined } +} + +// Reset never discards a healthy journal: it may contain an uncertain delivery. +// Persist the fence BEFORE quarantine so a crash cannot reopen an empty journal +// without its duplicate-delivery protection. Healthy resets leave it unchanged; +// another corrupt journal must fence deliveries made since the previous recovery. +export async function resetAdmissions(path) { + const before = await recoveryBefore(path) + try { + await journal(path) + return + } catch (error) { + if (error.code !== "INVALID_ADMISSIONS") throw error + } + try { + const temporary = path + ".recovery." + randomUUID() + await writeFile(temporary, JSON.stringify({ before: Math.max(before ?? 0, Date.now()) }), { flag: "wx", mode: 0o600 }) + await rename(temporary, path + ".recovery") + // A damaged file can contain previous selections; keep its backup private. + await chmod(path, 0o600) + await rename(path, path + ".corrupt-" + randomUUID()) + } catch (error) { + error.mobileStorage = true + throw error + } +} diff --git a/packages/mobile-connector/src/desktop-access.mjs b/packages/mobile-connector/src/desktop-access.mjs new file mode 100644 index 000000000..7fbc22c17 --- /dev/null +++ b/packages/mobile-connector/src/desktop-access.mjs @@ -0,0 +1,271 @@ +import { join } from "node:path" +import { mkdir, readFile, writeFile, rename } from "node:fs/promises" +import QRCode from "qrcode" +import { createGateway } from "./gateway.mjs" +import { connectTunnel, removeTunnel } from "./tunnel.mjs" +import { openDevices, resetDevices } from "./devices.mjs" +import { resetAdmissions } from "./admissions.mjs" + +export async function createMobileAccess(options) { + const root = join(options.userDataPath, "mobile-access") + const storePath = join(root, "devices.json") + let gateway, + tunnel, + pairing, + qrImage, + pairingExpiresAt, + errorKey, + starting, + controller, + epoch = 0 + let devices = [], + requests = [], + cancellations = 0 + let enabled = false, + status = "off", + progress + const flagPath = join(root, "enabled.json") + const persist = async (value) => { + await mkdir(root, { recursive: true, mode: 0o700 }) + await writeFile(flagPath + ".tmp", JSON.stringify({ enabled: value }), { mode: 0o600 }) + await rename(flagPath + ".tmp", flagPath) + } + const snapshot = () => ({ + enabled, + connecting: !!starting, + status, + progress, + devices: (gateway ? gateway.devices() : devices).map((d) => ({ ...d })), + requests: requests.filter((r) => r.expiresAt > Date.now()).map((r) => ({ ...r })), + pairing: pairingExpiresAt > Date.now() ? pairing : undefined, + qrImage: pairingExpiresAt > Date.now() ? qrImage : undefined, + errorKey, + }) + const emit = () => options.onChange?.(snapshot()) + // Optional mobile storage must never make Desktop's startup initializer fail. + async function loadDevices() { + try { + devices = (await openDevices(storePath)).list() + } catch (error) { + errorKey = "mobile.error.storage" + throw error + } + } + async function enable() { + if (enabled) return + if (starting) return starting + const token = ++epoch + controller = new AbortController() + status = "provisioning" + errorKey = undefined + progress = "creating-tunnel" + emit() + starting = (async () => { + await loadDevices() + controller.signal.throwIfAborted() + const connection = options.getConnection() + if (!connection?.password) throw new Error("Protected local Desktop sidecar required") + gateway = await createGateway({ + runtime: "desktop", + upstream: connection.url, + username: connection.username ?? "opencode", + password: connection.password, + storePath, + onPairingRequest: (claim) => { + requests.push(claim) + emit() + }, + }) + try { + controller.signal.throwIfAborted() + const target = await gateway.listen() + tunnel = await (options.tunnelFactory ?? connectTunnel)({ + target, + storePath: join(root, "tunnel-bharatcode-ai"), + signal: controller.signal, + onProgress: (stage) => { + if (token === epoch) { + progress = stage + emit() + } + }, + }) + if (token !== epoch) { + await tunnel.close() + tunnel = undefined + return + } + enabled = true + status = "connected" + progress = undefined + await persist(true) + void (async () => { + try { + for await (const event of tunnel.events) { + if (token !== epoch) break + if (["disconnected", "reconnecting", "connecting"].includes(event.type)) { + status = "reconnecting" + errorKey = "mobile.error.reconnecting" + } else if (event.type === "connected") { + status = "connected" + errorKey = undefined + } else if (event.type === "stopped") { + status = "error" + errorKey = "mobile.error.connection" + } + emit() + } + } catch { + if (token === epoch) { + status = "error" + errorKey = "mobile.error.connection" + emit() + } + } + })() + } catch (error) { + await gateway?.close() + await tunnel?.close() + gateway = undefined + tunnel = undefined + enabled = false + throw error + } + })() + try { + await starting + } catch (error) { + if (token === epoch) { + status = "error" + errorKey ??= error.mobileStorage ? "mobile.error.storage" : "mobile.error.connection" + } + throw error + } finally { + starting = undefined + emit() + } + } + async function shutdown(disable) { + ++epoch + controller?.abort() + if (starting) { + try { + await starting + } catch {} + } + if (gateway) devices = gateway.devices() + await gateway?.close() + await tunnel?.close() + gateway = undefined + tunnel = undefined + enabled = false + status = "off" + progress = undefined + errorKey = undefined + pairing = undefined + qrImage = undefined + pairingExpiresAt = undefined + requests = [] + if (disable) await persist(false) + emit() + } + async function run(input) { + if ( + !input || + typeof input.type !== "string" || + (input.id !== undefined && (typeof input.id !== "string" || input.id.length > 100)) + ) + throw new Error("Invalid mobile action") + if (input.type === "state") return snapshot() + if (input.type === "pair") { + if (status === "error" && enabled) await shutdown(false) + await enable() + if (!enabled || !gateway || !tunnel) return snapshot() + const invitation = gateway.issuePairing(tunnel.origin) + requests = [] + pairingExpiresAt = invitation.expiresAt + pairing = + "bharatcode://pair?" + new URLSearchParams({ origin: invitation.origin, desktopID: invitation.desktopID }) + qrImage = await QRCode.toDataURL(pairing, { width: 300, margin: 4, errorCorrectionLevel: "M" }) + } else if (input.type === "disable") { + await shutdown(true) + } else if (input.type === "reset") { + await shutdown(true) + try { + await resetAdmissions(join(root, "admissions.json")) + devices = (await resetDevices(storePath)).list() + } catch (error) { + status = "error" + errorKey = "mobile.error.storage" + emit() + throw error + } + await (options.removeTunnel ?? removeTunnel)({ storePath: join(root, "tunnel-bharatcode-ai") }) + } else if (input.type === "approve") { + if (!gateway) throw new Error("Remote access disabled") + await gateway.approve(input.id) + requests = [] + pairing = undefined + qrImage = undefined + } else if (input.type === "decline") { + gateway?.decline(input.id) + requests = requests.filter((r) => r.claimID !== input.id) + } else if (input.type === "revoke") { + if (gateway) await gateway.revoke(input.id) + else { + const registry = await openDevices(storePath) + await registry.remove(input.id) + devices = registry.list() + } + } else { + throw new Error("Unknown mobile action") + } + if (gateway) devices = gateway.devices() + emit() + return snapshot() + } + let actions = Promise.resolve() + return { + snapshot, + action(input) { + // Status and cancellation must remain available during multi-minute issuance. + if (input?.type === "state") return Promise.resolve(snapshot()) + if (input?.type === "disable" || input?.type === "reset") { + cancellations++ + controller?.abort() + } + const generation = cancellations + const operation = actions.then(() => + input?.type === "pair" && generation !== cancellations ? snapshot() : run(input), + ) + actions = operation.catch(() => {}) + return operation + }, + restore() { + const generation = cancellations + const operation = actions.then(async () => { + if (generation !== cancellations) return + try { + await loadDevices() + const preference = JSON.parse(await readFile(flagPath, "utf8")) + if (generation !== cancellations) return + if (preference.enabled === true) await enable() + } catch (error) { + if (error.code !== "ENOENT") { + status = "error" + errorKey ??= "mobile.error.storage" + emit() + } + } + }) + actions = operation.catch(() => {}) + return operation + }, + async close() { + cancellations++ + controller?.abort() + await actions + await shutdown(false) + }, + } +} diff --git a/packages/mobile-connector/src/desktop-api.mjs b/packages/mobile-connector/src/desktop-api.mjs new file mode 100644 index 000000000..68228c27f --- /dev/null +++ b/packages/mobile-connector/src/desktop-api.mjs @@ -0,0 +1,183 @@ +import { writeFile, rename } from 'node:fs/promises' +import { createHash, randomUUID } from 'node:crypto' +import { setTimeout as delay } from 'node:timers/promises' +import { openAdmissions } from './admissions.mjs' + +const fail = (status, message) => Object.assign(new Error(message), { status }) +const normalizedPermission = item => ({ ...item, action: item.permission, resources: item.patterns ?? [] }) +export function desktopMessage(item) { + const info = item.info, parts = item.parts ?? [] + const texts = parts.filter(p => p.type === 'text').map(p => ({id:p.id,type:'text',text:p.text ?? ''})) + const error = info.error?.data?.message ?? info.error?.message + if (error) texts.push({id:info.id + '_error',type:'text',text:`The computer reported: ${error}`}) + return {id:info.id,type:info.role,time:info.time, + ...(info.role === 'user' ? {text:texts.map(p=>p.text).join('\n\n')} : {content:[...texts,...parts.filter(p=>p.type==='tool').map(p=>({id:p.id,type:'tool',name:p.tool,state:{...p.state,content:p.state?.output ? [{type:'text',text:p.state.output}] : []}}))]})} +} + +// Desktop's default runtime serves native session metadata but continues chats +// through its compatibility engine. Keep that same engine and message tables. +export async function createDesktopAPI({ request, storePath }) { + const { state, recovered } = await openAdmissions(storePath) + let writes = Promise.resolve() + const save = () => { + const json = JSON.stringify(state) + const operation = writes.then(async()=>{await writeFile(storePath+'.tmp',json,{mode:0o600});await rename(storePath+'.tmp',storePath)}) + writes = operation.catch(()=>{}) + return operation + } + const locations = new Set(['']) + const pending = new Map() + const located = (path, directory) => path + (path.includes('?') ? '&' : '?') + new URLSearchParams({directory}) + async function session(id, signal) { + const value = (await request('/session/'+id,'GET',undefined,signal)).data + if (!value || typeof value.directory !== 'string') throw fail(502,'Desktop session location unavailable') + locations.add(value.directory) + return value + } + async function stored(id, body, directory, signal) { + let message + try { message = (await request(located(`/session/${id}/message/${body.id}`, directory), 'GET', undefined, signal)).data } + catch (error) { if (error.status !== 404) throw error; return false } + return message?.info?.id === body.id && message.info.role === 'user' && + (message.parts ?? []).filter(p => p.type === 'text').map(p => p.text).join('') === body.prompt.text + } + async function handle(method, raw, body, signal) { + const url = new URL(raw,'http://localhost'), path = url.pathname + if (method === 'POST' && path === '/api/session' && recovered) { + // Session creation can adopt a caller-supplied identity. During recovery + // only our own fresh random identity proves that a session has no lost + // admissions. Wall-clock timestamps cannot prove this after clock changes. + if (!body || typeof body !== 'object' || Array.isArray(body) || body.id !== undefined) + throw fail(400,'A fresh conversation identity is required after mobile history recovery') + if ((state.recoverySessions?.length ?? 0) >= 10000) throw fail(507,'Mobile recovery conversation history is full') + const id = 'ses_' + randomUUID().replaceAll('-','') + const result = await request(raw,'POST',{...body,id},signal) + if (result.data?.data?.id !== id) throw fail(502,'Desktop did not create the requested conversation') + state.recoverySessions = [...(state.recoverySessions ?? []),id] + await save() + return result + } + const match = /^\/api\/session\/([A-Za-z0-9_-]+)\/(message|prompt|interrupt|permission|question|model|agent)$/.exec(path) + if (method==='GET' && path==='/api/session') { + const result = await request(raw,'GET',undefined,signal) + for(const item of result.data.data ?? []) if(item.location?.directory) locations.add(item.location.directory) + return {data:result.data} + } + if (method==='GET' && path==='/api/session/active') { + const data={} + for(const directory of locations) { + const result=await request(located('/session/status',directory),'GET',undefined,signal) + for(const [id,value] of Object.entries(result.data ?? {})) if(value.type!=='idle') data[id]={type:'running'} + } + return {data:{data}} + } + if(method==='GET' && (path==='/api/model' || path==='/api/agent')) { + const directory=url.searchParams.get('location[directory]') ?? '' + const result=await request(located(path==='/api/model'?'/provider':'/agent',directory),'GET',undefined,signal) + const data=path==='/api/agent' ? (result.data ?? []).filter(a=>!a.hidden).map(a=>({id:a.name,name:a.name,description:a.description,mode:a.mode})) + : (result.data.all ?? []).filter(p=>(result.data.connected ?? []).includes(p.id)).flatMap(p=>Object.values(p.models ?? {}).map(m=>({id:m.id,providerID:p.id,name:m.name,enabled:true}))) + return {data:{data}} + } + if(method==='GET' && /^\/api\/(permission|question)\/request$/.test(path)) { + const kind=path.split('/')[2], directory=url.searchParams.get('location[directory]') ?? '' + const result=await request(located('/'+kind,directory),'GET',undefined,signal) + return {data:{data:kind==='permission' ? (result.data ?? []).map(normalizedPermission) : result.data ?? []}} + } + const reply=/^\/api\/session\/([A-Za-z0-9_-]+)\/(permission|question)\/([A-Za-z0-9_-]+)\/reply$/.exec(path) + if(method==='POST' && reply) { + const [,id,kind,requestID]=reply, info=await session(id,signal) + const requests=(await request(located('/'+kind,info.directory),'GET',undefined,signal)).data ?? [] + if(!requests.some(item=>item.id===requestID && item.sessionID===id)) throw fail(404,'Request resolved or unavailable') + const result=await request(located(`/${kind}/${requestID}/reply`,info.directory),'POST',body,signal) + return {data:{data:result.data}} + } + if(!match) return + const [,id,kind]=match + const info=await session(id,signal) + if(method==='GET' && kind==='message') { + const params=new URLSearchParams({limit:url.searchParams.get('limit') ?? '50',directory:info.directory}) + const cursor=url.searchParams.get('cursor'); if(cursor) params.set('before',cursor) + const result=await request(`/session/${id}/message?${params}`,'GET',undefined,signal) + return {data:{data:(result.data ?? []).map(desktopMessage).reverse(),cursor:{next:result.cursor}}} + } + if(method==='GET' && (kind==='permission' || kind==='question')) { + const result=await request(located('/'+kind,info.directory),'GET',undefined,signal) + const data=(result.data ?? []).filter(item=>item.sessionID===id) + return {data:{data:kind==='permission'?data.map(normalizedPermission):data}} + } + if(method==='POST' && (kind==='model' || kind==='agent')) { + if(kind==='agent' ? typeof body?.agent!=='string' : typeof body?.model?.id!=='string' || typeof body?.model?.providerID!=='string') throw fail(400,'Invalid session configuration') + state.selections[id]={...state.selections[id],...body}; await save(); return {data:{}} + } + if(method==='POST' && kind==='interrupt') { + await request(located(`/session/${id}/abort`,info.directory),'POST',undefined,signal) + return {data:{}} + } + if(method==='POST' && kind==='prompt') { + if(!body || !/^msg_[A-Za-z0-9_-]+$/.test(body.id ?? '') || typeof body.prompt?.text!=='string') throw fail(400,'Invalid prompt') + const key=id+'.'+body.id, hash=createHash('sha256').update(JSON.stringify(body)).digest('hex') + const previous=state.admissions[key] + if(previous && previous.hash!==hash) throw fail(409,'Message identity was used for a different prompt') + if(pending.has(key)) { await pending.get(key); return {data:{data:{id:body.id}}} } + if(previous?.state==='admitted') return {data:{data:{id:body.id}}} + if(previous) { + // A crash or lost upstream acknowledgement is ambiguous. A matching + // stored Desktop message proves admission; never blindly execute again. + if (await stored(id,body,info.directory,signal)) { + state.admissions[key]={hash,state:'admitted'}; + if(JSON.stringify(state.selections[id])===JSON.stringify(previous.selection)) delete state.selections[id]; + await save(); return {data:{data:{id:body.id}}} + } + throw fail(409,'Desktop delivery is unconfirmed. Check the conversation on your computer before continuing.') + } + if (recovered && !state.recoverySessions?.includes(id)) { + // With the old journal lost, absence in Desktop is not proof that an + // old request was never forwarded. Only our durably recorded fresh + // conversation identities can submit without that lost history. + if (await stored(id,body,info.directory,signal)) { + state.admissions[key]={hash,state:'admitted'}; await save() + return {data:{data:{id:body.id}}} + } + throw fail(409,'Mobile delivery history was recovered. Check this conversation on your computer; start a new conversation on your phone to send safely.') + } + if(Object.keys(state.admissions).length>=10000) throw fail(507,'Mobile admission history is full') + const operation=(async()=>{ + const selection=state.selections[id], model=selection?.model ?? info.model + state.admissions[key]={hash,state:'forwarding',selection}; await save() + let confirmed = false + try { + let submissionError + // The compatibility engine may keep its async HTTP response open + // after storing the message. Durable identity is the admission proof. + const submission = request(located(`/session/${id}/prompt_async`,info.directory),'POST',{ + messageID:body.id, agent:selection?.agent ?? info.agent, + ...(model ? {model:{providerID:model.providerID,modelID:model.id},variant:model.variant} : {}), + parts:[{type:'text',text:body.prompt.text}], + },signal).catch(error => { submissionError = error }) + for (let attempt = 0; attempt < 30; attempt++) { + if (await stored(id, body, info.directory, signal)) { confirmed = true; break } + if (submissionError) throw submissionError + await delay(100, undefined, { signal }) + } + // Keep the rejection handler attached even when stored admission + // lets this response finish before the upstream request does. + void submission + } catch (error) { + // The engine can store and execute the message before its HTTP + // acknowledgement or admission probe fails. Confirm it without ever + // forwarding the prompt again; otherwise preserve the original error. + confirmed = await stored(id, body, info.directory, signal).catch(() => false) + if (!confirmed) throw error + } + if (!confirmed) throw fail(409, 'Desktop delivery is unconfirmed. Keep this draft and check your computer.') + state.admissions[key]={hash,state:'admitted'}; + if (state.selections[id] === selection) delete state.selections[id]; + await save() + })() + pending.set(key,operation) + try { await operation } finally { pending.delete(key) } + return {data:{data:{id:body.id}}} + } + } + return {handle} +} diff --git a/packages/mobile-connector/src/devices.mjs b/packages/mobile-connector/src/devices.mjs new file mode 100644 index 000000000..09625fe1f --- /dev/null +++ b/packages/mobile-connector/src/devices.mjs @@ -0,0 +1,77 @@ +import { randomBytes, createHash, timingSafeEqual } from "node:crypto" +import { mkdir, readFile, writeFile, rename } from "node:fs/promises" +import { dirname } from "node:path" + +export const secret = () => randomBytes(32).toString("base64url") +export const verifier = (value) => createHash("sha256").update(value).digest("hex") +export function matches(value, expected) { + if (typeof value !== "string" || typeof expected !== "string") return false + const a = Buffer.from(verifier(value)), + b = Buffer.from(expected) + return a.length === b.length && timingSafeEqual(a, b) +} + +export async function openDevices(path) { + if (!path) throw new Error("Private device store path required") + await mkdir(dirname(path), { recursive: true, mode: 0o700 }) + let state + try { + state = JSON.parse(await readFile(path, "utf8")) + if ( + !state || + typeof state !== "object" || + state.version !== 1 || + typeof state.desktopID !== "string" || + !Array.isArray(state.devices) || + state.devices.some((d) => !d || typeof d.id !== "string" || !/^[a-f0-9]{64}$/.test(d.verifier)) + ) + throw Object.assign(new Error("Invalid device registry"), { code: "INVALID_DEVICE_REGISTRY" }) + } catch (error) { + if (error instanceof SyntaxError) error.code = "INVALID_DEVICE_REGISTRY" + if (error.code !== "ENOENT") throw error + state = { version: 1, desktopID: "pc_" + secret().slice(0, 20), devices: [] } + } + let writes = Promise.resolve() + function save(next) { + const operation = writes.then(async () => { + const temporary = path + "." + secret().slice(0, 8) + await writeFile(temporary, JSON.stringify(next), { mode: 0o600 }) + await rename(temporary, path) + state = next + }) + writes = operation.catch(() => {}) + return operation + } + // Mutations build their next state only after preceding writes have settled. + let mutations = Promise.resolve() + const mutate = (fn) => { + const operation = mutations.then(() => save(fn(state))) + mutations = operation.catch(() => {}) + return operation + } + await save(state) + return { + get desktopID() { + return state.desktopID + }, + list: () => state.devices.map(({ verifier: _, ...device }) => ({ ...device })), + authenticate: (token) => state.devices.find((device) => matches(token, device.verifier)), + add: (device) => mutate((current) => ({ ...current, devices: [...current.devices, device] })), + remove: (id) => mutate((current) => ({ ...current, devices: current.devices.filter((d) => d.id !== id) })), + } +} + +// Only an explicit revoke-all/reset may replace corrupt state. Keep its bytes +// privately for recovery; storage permission and I/O errors must still fail closed. +export async function resetDevices(path) { + let registry + try { + registry = await openDevices(path) + } catch (error) { + if (error.code !== "INVALID_DEVICE_REGISTRY") throw error + await rename(path, path + ".corrupt-" + secret()) + registry = await openDevices(path) + } + for (const device of registry.list()) await registry.remove(device.id) + return registry +} diff --git a/packages/mobile-connector/src/files.mjs b/packages/mobile-connector/src/files.mjs new file mode 100644 index 000000000..dc057403a --- /dev/null +++ b/packages/mobile-connector/src/files.mjs @@ -0,0 +1,50 @@ +import { realpath, readdir, open, stat } from "node:fs/promises" +import { resolve, relative, isAbsolute, sep } from "node:path" + +export async function projectFile(directory, path) { + if ( + typeof path !== "string" || + path.includes("\0") || + path.includes("\\") || + isAbsolute(path) || + path.split("/").includes("..") + ) + throw Object.assign(new Error("Invalid project path"), { status: 400 }) + const root = await realpath(directory) + const target = await realpath(resolve(root, path)) + const rel = relative(root, target) + if (rel === ".." || rel.startsWith(".." + sep) || isAbsolute(rel)) + throw Object.assign(new Error("Path is outside the project"), { status: 403 }) + // Windows cannot open directory handles with fs.open; enumerate directories + // before opening a regular file, whose size/type are still checked on its handle. + if ((await stat(target)).isDirectory()) { + const entries = await readdir(target, { withFileTypes: true }) + return { + type: "directory", + path, + truncated: entries.length > 2000, + entries: entries + .slice(0, 2000) + .map((entry) => ({ name: entry.name, directory: entry.isDirectory(), symlink: entry.isSymbolicLink() })) + .sort((a, b) => Number(b.directory) - Number(a.directory) || a.name.localeCompare(b.name)), + } + } + const file = await open(target, "r") + try { + const stat = await file.stat() + if (!stat.isFile()) throw Object.assign(new Error("Unsupported file type"), { status: 400 }) + if (stat.size > 262144) return { type: "file", path, size: stat.size, tooLarge: true } + const bytes = Buffer.alloc(262145) + const { bytesRead } = await file.read(bytes, 0, bytes.length, 0) + if (bytesRead > 262144) return { type: "file", path, tooLarge: true } + const content = bytes.subarray(0, bytesRead) + if (content.includes(0)) return { type: "file", path, binary: true } + try { + return { type: "file", path, text: new TextDecoder("utf-8", { fatal: true }).decode(content) } + } catch { + return { type: "file", path, binary: true } + } + } finally { + await file.close() + } +} diff --git a/packages/mobile-connector/src/gateway.mjs b/packages/mobile-connector/src/gateway.mjs new file mode 100644 index 000000000..4a6022416 --- /dev/null +++ b/packages/mobile-connector/src/gateway.mjs @@ -0,0 +1,266 @@ +import { createServer, request as httpRequest } from 'node:http' +import { hostname, platform } from 'node:os' +import { randomInt } from 'node:crypto' +import { once } from 'node:events' +import { projectEvents } from './live.mjs' +import { projectFile } from './files.mjs' +import { createDesktopAPI } from './desktop-api.mjs' +import { join, dirname } from 'node:path' +import { openDevices, secret, verifier, matches } from './devices.mjs' +import { allowed, sidecarOrigin, tunnelOrigin } from './policy.mjs' + +const lifetime = 300_000 +const maxBody = 1_048_576 + +export async function createGateway(options) { + const registry = await openDevices(options.storePath) + const upstream = sidecarOrigin(options.upstream) + if (!options.password) throw new Error('Sidecar password required') + const now = options.now ?? Date.now + const claims = new Map(), active = new Map() + const desktopAPI = options.runtime === 'desktop' ? await createDesktopAPI({ request: upstreamCall, storePath: join(dirname(options.storePath), 'admissions.json') }) : undefined + let qr, closed = false, attempts = [] + const metadata = () => ({ id: registry.desktopID, name: options.name ?? hostname(), platform: platform() }) + function issuePairing(origin) { + if (closed) throw new Error('Gateway closed') + claims.clear() + qr = { v: 1, desktopID: registry.desktopID, origin: tunnelOrigin(origin), code: secret(), expiresAt: now() + lifetime } + return { ...qr } + } + function claimFor(body) { + const claim = claims.get(body.claimID) + if (!claim || !matches(body.claimSecret, claim.verifier)) throw status(401, 'Invalid pairing claim') + if (claim.expiresAt <= now()) { claims.delete(body.claimID); throw status(410, 'Pairing expired') } + if (claim.state === 'consumed') throw status(410, 'Pairing already consumed') + return claim + } + async function approve(id) { + const claim = claims.get(id) + if (!claim || claim.state !== 'waiting' || claim.expiresAt <= now()) throw new Error('Pairing expired or already resolved') + // Reserve synchronously before the first await: approval cannot issue twice. + claim.state = 'approving' + const token = secret() + const device = { id: 'dev_' + secret().slice(0, 20), name: claim.name, pairedAt: now(), verifier: verifier(token) } + try { await registry.add(device) } catch (error) { claim.state = 'waiting'; throw error } + qr = undefined + for (const other of claims.values()) if (other !== claim && other.state === 'waiting') other.state = 'declined' + claim.state = 'approved' + claim.token = token + claim.device = { id: device.id, name: device.name, pairedAt: device.pairedAt } + } + function decline(id) { + const claim = claims.get(id) + if (claim?.state === 'waiting') claim.state = 'declined' + } + async function revoke(id) { + await registry.remove(id) + for (const abort of active.get(id) ?? []) abort() + active.delete(id) + } + async function handle(req, res) { + try { + if (req.headers.origin) throw status(403, 'Browser-origin requests are not allowed') + if (req.method === 'POST' && req.url === '/mobile/v1/claim') { + const body = await jsonBody(req) + if (!qr || qr.expiresAt <= now() || (body.code !== undefined ? !matches(body.code, verifier(qr.code)) : body.desktopID !== registry.desktopID)) throw status(401, 'Pairing code expired or used') + if (typeof body.deviceName !== 'string' || !body.deviceName.trim() || body.deviceName.length > 80 || /[\x00-\x1f\x7f]/.test(body.deviceName)) throw status(400, 'Invalid device name') + for (const [id, claim] of claims) if (claim.expiresAt <= now()) claims.delete(id) + attempts = attempts.filter(time => now() - time < 60_000) + if (claims.size >= 5 || attempts.length >= 10) throw status(429, 'Too many pairing attempts; refresh on Desktop') + attempts.push(now()) + // A public link is discovery only. Access is issued solely by Desktop approval. + if (body.code !== undefined) qr = undefined + const claimID = secret(), claimSecret = secret(), confirmation = String(randomInt(1_000_000)).padStart(6, '0') + const claim = { claimID, verifier: verifier(claimSecret), name: body.deviceName.trim(), confirmation, expiresAt: now() + lifetime, state: 'waiting' } + claims.set(claimID, claim) + options.onPairingRequest?.({ claimID, name: claim.name, confirmation, expiresAt: claim.expiresAt }) + return reply(res, 201, { claimID, claimSecret, confirmation, expiresAt: claim.expiresAt }) + } + if (req.method === 'POST' && req.url === '/mobile/v1/claim/status') { + const body = await jsonBody(req), claim = claimFor(body) + if (claim.state === 'approved') { + const result = { state: 'approved', token: claim.token, device: claim.device, desktop: metadata() } + claim.state = 'consumed' + delete claim.token + return reply(res, 200, result) + } + return reply(res, 200, { state: claim.state }) + } + const bearer = /^Bearer ([A-Za-z0-9_-]{40,})$/.exec(req.headers.authorization ?? '')?.[1] + const device = bearer && registry.authenticate(bearer) + if (!device) throw status(401, 'Device authorization required') + if (req.method === 'GET' && req.url === '/mobile/v1/health') return reply(res, 200, { + protocol: 1, desktop: metadata(), capabilities: { sessions: true, prompts: true, replay: options.runtime !== 'desktop', permissions: true, questions: true, models: true, live: true, changes: true, files: true, terminal: false }, + }) + if (req.method === 'DELETE' && req.url === '/mobile/v1/device') { + await revoke(device.id) + return reply(res, 200, { revoked: true }) + } + const cancellation = new AbortController() + const abortIncoming = () => { cancellation.abort(); req.destroy(); res.destroy() } + if (!active.has(device.id)) active.set(device.id, new Set()) + active.get(device.id).add(abortIncoming) + res.once('close', () => { cancellation.abort(); active.get(device.id)?.delete(abortIncoming); if (!active.get(device.id)?.size) active.delete(device.id) }) + const live = /^\/mobile\/v1\/session\/([A-Za-z0-9_-]+)\/live$/.exec(req.url) + if (req.method === 'GET' && live) { + let directory + if (options.runtime === 'desktop') { + const session = await upstreamJSON('/session/' + live[1], cancellation.signal) + if (typeof session?.directory !== 'string') throw status(502, 'Desktop session location unavailable') + directory = session.directory + } + const eventPath = (options.runtime === 'desktop' ? '/event' : '/api/event') + (directory === undefined ? '' : '?' + new URLSearchParams({ directory })) + const response = await fetch(upstream + eventPath, { + headers: { authorization: 'Basic ' + Buffer.from(`${options.username ?? 'opencode'}:${options.password}`).toString('base64'), accept: 'text/event-stream' }, + redirect: 'error', signal: cancellation.signal, + }) + if (!response.ok || !response.headers.get('content-type')?.includes('text/event-stream')) { + await response.body?.cancel(); throw status(502, 'Live events unavailable') + } + res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' }) + res.write(': connected\n\n') + const heartbeat = setInterval(() => { if (!res.destroyed) res.write(': heartbeat\n\n') }, 10_000) + try { + for await (const event of projectEvents(response.body, live[1], { desktop: options.runtime === 'desktop', resolveMessage: async id => { + if (!/^[A-Za-z0-9_-]+$/.test(id)) return + return upstreamJSON(`/session/${live[1]}/message/${id}?` + new URLSearchParams({ directory }), cancellation.signal) + } })) { + if (!registry.authenticate(bearer)) break + if (!res.write(`data: ${JSON.stringify(event)}\n\n`)) await once(res, 'drain', { signal: cancellation.signal }) + } + } finally { clearInterval(heartbeat); await response.body?.cancel().catch(() => {}); res.end() } + return + } + const fileURL = new URL(req.url, 'http://localhost') + const files = /^\/mobile\/v1\/session\/([A-Za-z0-9_-]+)\/files$/.exec(fileURL.pathname) + if (req.method === 'GET' && files) { + if ([...fileURL.searchParams.keys()].some(key => key !== 'path') || fileURL.searchParams.getAll('path').length > 1) throw status(400, 'Invalid file query') + const session = await upstreamJSON(`/api/session/${files[1]}`, cancellation.signal) + const directory = session.data?.location?.directory + if (typeof directory !== 'string') throw status(502, 'Session location unavailable') + const data = await projectFile(directory, fileURL.searchParams.get('path') ?? '') + if (!registry.authenticate(bearer)) throw status(401, 'Device authorization revoked') + return reply(res, 200, { data }) + } + const changes = /^\/mobile\/v1\/session\/([A-Za-z0-9_-]+)\/changes$/.exec(req.url) + if (req.method === 'GET' && changes) { + const session = await upstreamJSON(`/api/session/${changes[1]}`, cancellation.signal) + const directory = session.data?.location?.directory + if (typeof directory !== 'string') throw status(502, 'Session location unavailable') + const query = new URLSearchParams({ directory, mode: 'git', context: '3' }) + const data = await upstreamJSON('/vcs/diff?' + query, cancellation.signal) + if (!registry.authenticate(bearer)) throw status(401, 'Device authorization revoked') + return reply(res, 200, { data: Array.isArray(data) ? data : [] }) + } + if (!allowed(req.method, req.url)) throw status(403, 'Route is not available to mobile devices') + const body = ['POST', 'PATCH', 'PUT'].includes(req.method) ? await rawBody(req) : undefined + if (!registry.authenticate(bearer)) throw status(401, 'Device authorization revoked') + if (body?.length && !/^application\/json(?:;|$)/i.test(req.headers['content-type'] ?? '')) throw status(415, 'JSON required') + if (desktopAPI) { + const result = await desktopAPI.handle(req.method, req.url, body?.length ? JSON.parse(body.toString()) : undefined, cancellation.signal) + if (result) { + if (!registry.authenticate(bearer)) throw status(401, 'Device authorization revoked') + return reply(res, result.status ?? 200, result.data) + } + } + forward(req, res, device.id, body) + } catch (error) { + if (!res.headersSent) reply(res, error.status ?? 500, { error: error.status ? error.message : 'Connection request failed' }) + else res.destroy() + } + } + async function upstreamCall(path, method = 'GET', body, signal) { + const response = await fetch(upstream + path, { + method, headers: { + authorization: 'Basic ' + Buffer.from(`${options.username ?? 'opencode'}:${options.password}`).toString('base64'), + ...(body !== undefined ? { 'content-type': 'application/json' } : {}), + }, body: body === undefined ? undefined : JSON.stringify(body), redirect: 'error', + signal: AbortSignal.any([...(signal ? [signal] : []), AbortSignal.timeout(20_000)]), + }) + if (!response.ok) { await response.body?.cancel(); throw status(response.status, 'Computer could not complete the request') } + if (!response.body) return { data: undefined, cursor: response.headers.get('x-next-cursor') ?? undefined } + const chunks = []; let size = 0 + for await (const chunk of response.body) { size += chunk.length; if (size > 16_777_216) throw status(413, 'Computer response is too large'); chunks.push(chunk) } + const text = Buffer.concat(chunks).toString() + return { data: text ? JSON.parse(text) : undefined, cursor: response.headers.get('x-next-cursor') ?? undefined } + } + async function upstreamJSON(path, signal) { return (await upstreamCall(path, 'GET', undefined, signal)).data } + function forward(req, res, deviceID, body) { + const target = new URL(req.url, upstream) + const headers = { authorization: 'Basic ' + Buffer.from(`${options.username ?? 'opencode'}:${options.password}`).toString('base64'), accept: req.headers.accept ?? 'application/json' } + if (body) { headers['content-type'] = 'application/json'; headers['content-length'] = body.length } + const remote = httpRequest(target, { method: req.method, headers }, incoming => { + if (incoming.statusCode >= 300 && incoming.statusCode < 400) { incoming.destroy(); reply(res, 502, { error: 'Unexpected sidecar redirect' }); return } + const contentType = incoming.headers['content-type'] ?? 'application/json' + if (target.pathname === '/api/model' || target.pathname === '/api/agent') { + const chunks = []; let length = 0 + incoming.on('data', chunk => { length += chunk.length; if (length > maxBody) { incoming.destroy(); reply(res, 502, { error: 'Metadata exceeds supported size' }); return } chunks.push(chunk) }) + incoming.on('end', () => { + try { + const result = JSON.parse(Buffer.concat(chunks).toString()) + if (!Array.isArray(result.data)) throw new Error('Invalid metadata') + const fields = target.pathname === '/api/model' ? ['id', 'providerID', 'name', 'enabled'] : ['id', 'name', 'description', 'mode'] + const data = result.data.map(item => Object.fromEntries(fields.filter(key => Object.hasOwn(item, key)).map(key => [key, item[key]]))) + reply(res, incoming.statusCode ?? 502, { data }) + } catch { reply(res, 502, { error: 'Invalid Desktop metadata' }) } + }) + incoming.on('error', () => { if (!res.headersSent) reply(res, 502, { error: 'Desktop metadata unavailable' }) }) + return + } + res.writeHead(incoming.statusCode ?? 502, { 'content-type': contentType, 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' }) + let size = 0 + incoming.on('data', chunk => { + if (!contentType.includes('text/event-stream') && (size += chunk.length) > 16_777_216) { incoming.destroy(); res.destroy() } + }) + incoming.on('error', () => res.destroy()) + incoming.pipe(res) + }) + const abort = () => { remote.destroy(); res.destroy() } + if (!active.has(deviceID)) active.set(deviceID, new Set()) + active.get(deviceID).add(abort) + remote.on('error', () => { if (!res.headersSent && !res.destroyed) reply(res, 502, { error: 'Computer server unavailable' }); else res.destroy() }) + remote.setTimeout(30_000, () => remote.destroy()) + res.once('close', () => { remote.destroy(); active.get(deviceID)?.delete(abort); if (!active.get(deviceID)?.size) active.delete(deviceID) }) + remote.end(body) + } + const server = createServer((req, res) => void handle(req, res)) + server.requestTimeout = 15_000 + return { + issuePairing, approve, decline, revoke, + devices: registry.list, + async listen() { + if (closed) throw new Error('Gateway closed') + if (!server.listening) await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve) }) + return `http://127.0.0.1:${server.address().port}` + }, + async close() { + closed = true; qr = undefined; claims.clear() + for (const connections of active.values()) for (const abort of connections) abort() + active.clear() + if (!server.listening) return + server.closeAllConnections() + await new Promise(resolve => server.close(resolve)) + }, + } +} + +function status(code, message) { return Object.assign(new Error(message), { status: code }) } +function reply(res, code, data) { + if (res.destroyed || res.headersSent) return + res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' }) + res.end(JSON.stringify(data)) +} +async function rawBody(req) { + if (Number(req.headers['content-length']) > maxBody) throw status(413, 'Request is too large') + const chunks = []; let size = 0 + for await (const chunk of req) { + size += chunk.length + if (size > maxBody) throw status(413, 'Request is too large') + chunks.push(chunk) + } + return Buffer.concat(chunks) +} +async function jsonBody(req) { + if (!/^application\/json(?:;|$)/i.test(req.headers['content-type'] ?? '')) throw status(415, 'JSON required') + try { const body = JSON.parse((await rawBody(req)).toString()); if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error(); return body } catch (error) { if (error.status) throw error; throw status(400, 'Invalid JSON') } +} diff --git a/packages/mobile-connector/src/index.d.ts b/packages/mobile-connector/src/index.d.ts new file mode 100644 index 000000000..1bd1b4c8e --- /dev/null +++ b/packages/mobile-connector/src/index.d.ts @@ -0,0 +1,6 @@ +export type MobileDevice = { id: string; name: string; pairedAt: number } +export type MobileClaim = { claimID: string; name: string; confirmation: string; expiresAt: number } +export type MobileState = { enabled: boolean; connecting: boolean; status: "off" | "provisioning" | "connected" | "reconnecting" | "error"; progress?: string; devices: MobileDevice[]; requests: MobileClaim[]; pairing?: string; qrImage?: string; errorKey?: string } +export type MobileAction = { type: 'state' | 'pair' | 'disable' | 'reset' | 'approve' | 'decline' | 'revoke'; id?: string } +export type MobileAccess = { snapshot(): MobileState; action(input: MobileAction): Promise; restore(): Promise; close(): Promise } +export function createMobileAccess(options: { userDataPath: string; getConnection(): { url: string; username?: string | null; password: string } | undefined; onChange?(state: MobileState): void }): Promise diff --git a/packages/mobile-connector/src/index.mjs b/packages/mobile-connector/src/index.mjs new file mode 100644 index 000000000..04835e146 --- /dev/null +++ b/packages/mobile-connector/src/index.mjs @@ -0,0 +1,3 @@ +export { createGateway } from './gateway.mjs' +export { connectTunnel } from './tunnel.mjs' +export { createMobileAccess } from './desktop-access.mjs' diff --git a/packages/mobile-connector/src/live.mjs b/packages/mobile-connector/src/live.mjs new file mode 100644 index 000000000..f3c26afee --- /dev/null +++ b/packages/mobile-connector/src/live.mjs @@ -0,0 +1,58 @@ +import { StringDecoder } from 'node:string_decoder' + +// Never forward the global sidecar event bus. Project only the selected +// conversation's text and invalidation signals; configuration stays local. +export async function* projectEvents(source, sessionID, options = {}) { + const decoder = new StringDecoder('utf8') + let buffer = '' + const roles = new Map(), parts = new Map(), lookedUp = new Set() + for await (const chunk of source) { + buffer += decoder.write(chunk) + let match + while ((match = /\r?\n\r?\n/.exec(buffer))) { + if (match.index > 1_048_576) throw new Error('Event frame too large') + const frame = buffer.slice(0, match.index) + buffer = buffer.slice(match.index + match[0].length) + const data = frame.split(/\r?\n/).filter(line => line.startsWith('data:')).map(line => line.slice(5).replace(/^ /, '')).join('\n') + if (!data) continue + let event + try { event = JSON.parse(data) } catch { continue } + event.data ??= event.properties + if (event.data?.sessionID !== sessionID) continue + if (options.desktop) { + const data = event.data + if (event.type === 'message.part.delta' && !parts.has(data.partID) && options.resolveMessage && !lookedUp.has(data.messageID)) { + lookedUp.add(data.messageID) + if (lookedUp.size > 2000) lookedUp.delete(lookedUp.values().next().value) + try { + const message = await options.resolveMessage(data.messageID) + if (message?.info?.id === data.messageID) { + roles.set(message.info.id, message.info.role) + for (const part of message.parts ?? []) parts.set(part.id, part.type) + } + } catch { /* History refresh recovers completed content if this lookup races removal. */ } + } + if (event.type === 'message.updated' && data.info?.id) roles.set(data.info.id, data.info.role) + if (event.type === 'message.part.updated' && data.part?.id) parts.set(data.part.id, data.part.type) + if (roles.size > 2000) roles.delete(roles.keys().next().value) + if (parts.size > 4000) parts.delete(parts.keys().next().value) + if (event.type === 'message.part.delta' && data.field === 'text' && parts.get(data.partID) === 'text' && roles.get(data.messageID) === 'assistant') { + event = { id: event.id, type: 'session.next.text.delta', data: { sessionID, assistantMessageID: data.messageID, textID: data.partID, delta: data.delta } } + } else if (event.type === 'message.part.updated' && data.part?.type === 'text' && roles.get(data.part.messageID) === 'assistant') { + const phase = data.part.time?.end === undefined ? 'started' : 'ended' + event = { id: event.id, type: 'session.next.text.' + phase, data: { sessionID, assistantMessageID: data.part.messageID, textID: data.part.id, text: data.part.text } } + } else if (/^(permission|question)\.(asked|replied|rejected)$/.test(event.type)) { + event = { id: event.id, type: event.type.replace('.', '.v2.'), data: { sessionID, id: data.id, requestID: data.requestID } } + } else if (/^(message\.(updated|removed|part\.(updated|removed|delta))|session\.(status|error|updated))$/.test(event.type)) { + event = { id: event.id, type: 'session.next.tool.progress', data: { sessionID } } + } else continue + } + const text = /^session\.next\.text\.(started|delta|ended)$/.test(event.type) + const invalidation = /^(permission\.v2\.(asked|replied)|question\.v2\.(asked|replied|rejected)|session\.next\.(step\.(started|ended|failed)|tool\.(called|progress|success|failed)))$/.test(event.type) + if (!text && !invalidation) continue + const fields = text ? ['sessionID', 'assistantMessageID', 'textID', 'delta', 'text'] : ['sessionID', 'requestID', 'id'] + yield { id: event.id, type: event.type, data: Object.fromEntries(fields.filter(key => Object.hasOwn(event.data, key)).map(key => [key, event.data[key]])) } + } + if (buffer.length > 1_048_576) throw new Error('Event frame too large') + } +} diff --git a/packages/mobile-connector/src/policy.mjs b/packages/mobile-connector/src/policy.mjs new file mode 100644 index 000000000..19c036bdf --- /dev/null +++ b/packages/mobile-connector/src/policy.mjs @@ -0,0 +1,30 @@ +export function tunnelOrigin(value) { + if (typeof value !== 'string' || !/^https:\/\/(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+tunnel\.bharatcode\.ai(?::443)?\/?$/i.test(value)) throw new Error('An HTTPS BharatCode tunnel origin is required') + const url = new URL(value) + return url.origin +} + +const id = '[A-Za-z0-9_-]+' +const rules = [ + ['GET', /^\/api\/(session|session\/active|model|agent|location)$/, ['limit', 'order', 'cursor', 'search', 'directory', 'project', 'subpath', 'workspace', 'location[directory]', 'location[workspace]']], + ['POST', /^\/api\/session$/, []], + ['GET', new RegExp(`^/api/session/${id}$`), []], + ['GET', new RegExp(`^/api/session/${id}/(message|history|event|permission|question|context|diff)$`), ['limit', 'order', 'cursor', 'after']], + ['GET', new RegExp(`^/api/session/${id}/message/${id}$`), []], + ['POST', new RegExp(`^/api/session/${id}/(prompt|interrupt|model|agent)$`), []], + ['POST', new RegExp(`^/api/session/${id}/(permission|question)/${id}/reply$`), []], + ['GET', /^\/api\/(permission|question)\/request$/, ['location[directory]', 'location[workspace]']], +] + +export function allowed(method, raw) { + const rawPath = raw.split(/[?#]/, 1)[0] + if (!raw.startsWith('/') || raw.startsWith('//') || /\\|%(2f|5c|2e|00)/i.test(rawPath) || /%00|\x00/i.test(raw)) return false + const url = new URL(raw, 'http://localhost') + return rules.some(([verb, path, keys]) => verb === method && path.test(url.pathname) && [...url.searchParams.keys()].every(key => keys.includes(key))) +} + +export function sidecarOrigin(value) { + const url = new URL(value) + if (url.protocol !== 'http:' || !['127.0.0.1', '[::1]', 'localhost'].includes(url.hostname) || url.username || url.password || url.pathname !== '/' || url.search || url.hash) throw new Error('The sidecar must be a loopback HTTP origin') + return url.origin +} diff --git a/packages/mobile-connector/src/tunnel.mjs b/packages/mobile-connector/src/tunnel.mjs new file mode 100644 index 000000000..474637a55 --- /dev/null +++ b/packages/mobile-connector/src/tunnel.mjs @@ -0,0 +1,67 @@ +import { OpenTunnelClient, OpenTunnelApiClient, OpenTunnelStorage } from '@opentunnel/client/effect' +import { Effect, Exit, ManagedRuntime, Scope, Stream } from 'effect' +import { mkdir, chmod } from 'node:fs/promises' +import { tunnelOrigin } from './policy.mjs' + +export const tunnelAPI = 'https://tunnel.bharatcode.ai' +const profile = 'bharatcode-mobile' + +async function runtimeFor(storePath) { + await mkdir(storePath, { recursive: true, mode: 0o700 }) + await chmod(storePath, 0o700) + return ManagedRuntime.make(OpenTunnelClient.layer({ + api: tunnelAPI, + storage: OpenTunnelStorage.xdg({ env: { ...process.env, XDG_DATA_HOME: storePath } }), + })) +} + +// Use the SDK's cancellable Effect interface: certificate issuance may take minutes. +// Only the authenticated gateway is routed; never expose the Desktop sidecar itself. +export async function connectTunnel({ target, storePath, signal, onProgress }) { + const url = new URL(target) + if (url.protocol !== 'http:' || url.hostname !== '127.0.0.1' || !url.port || url.username || url.password || url.pathname !== '/' || url.search || url.hash) throw new Error('Gateway must listen on loopback') + signal?.throwIfAborted() + const runtime = await runtimeFor(storePath) + const scope = await runtime.runPromise(Scope.make()) + let connection, closing + const run = operation => runtime.runPromise(Effect.flatMap(OpenTunnelClient, operation), { signal }) + const close = () => closing ??= (async () => { + try { if (connection) await runtime.runPromise(connection.close) } + finally { + try { await runtime.runPromise(Scope.close(scope, Exit.void)) } + finally { await runtime.dispose() } + } + })() + try { + const existing = await run(client => client.tunnel.get({ profile })) + if (!existing) { + const pending = await run(client => client.tunnel.pending({ profile })) + await run(client => pending ? client.tunnel.resume({ profile, onProgress }) : client.tunnel.create({ profile, onProgress })) + } + onProgress?.('connecting') + connection = await run(client => Effect.provideService(client.tunnel.connect({ profile, routes: { '@': `${url.hostname}:${url.port}` } }), Scope.Scope, scope)) + signal?.throwIfAborted() + const origin = tunnelOrigin(`https://${connection.tunnel.hostname}`) + const abort = () => { void close().catch(() => {}) } + signal?.addEventListener('abort', abort, { once: true }) + return { origin, events: Stream.toAsyncIterable(connection.events), close: async () => { signal?.removeEventListener('abort', abort); await close() } } + } catch (error) { await close(); throw error } +} + +export async function removeTunnel({ storePath }) { + await mkdir(storePath, { recursive: true, mode: 0o700 }) + await chmod(storePath, 0o700) + const storage = OpenTunnelStorage.xdg({ env: { ...process.env, XDG_DATA_HOME: storePath } }) + const runtime = ManagedRuntime.make(OpenTunnelApiClient.layer({ api: tunnelAPI })) + try { + await runtime.runPromise(Effect.gen(function* () { + const identity = (yield* storage.load(profile)) ?? (yield* storage.loadPending(profile)) + if (!identity) return + const api = yield* OpenTunnelApiClient + const authorized = yield* api.authorized(identity.token) + // Deletion requires the tunnel token, never a ready certificate. + yield* authorized.tunnel['tunnel.remove']({ params: { id: identity.id } }) + yield* storage.remove(profile) + }), { signal: AbortSignal.timeout(30_000) }) + } finally { await runtime.dispose() } +} diff --git a/packages/mobile-connector/test/desktop-access.test.mjs b/packages/mobile-connector/test/desktop-access.test.mjs new file mode 100644 index 000000000..49eb57812 --- /dev/null +++ b/packages/mobile-connector/test/desktop-access.test.mjs @@ -0,0 +1,518 @@ +import { test } from "node:test" +import assert from "node:assert/strict" +import { mkdtemp, rm, mkdir, writeFile, readFile, readdir, stat } from "node:fs/promises" +import { join } from "node:path" +import { tmpdir } from "node:os" +import { createMobileAccess } from "../src/desktop-access.mjs" +import QRCode from "qrcode" +import { PNG } from "pngjs" + +for (const damaged of ['{"admissions":', '{"admissions":null,"selections":{}}', '{"admissions":{},"selections":[]}']) { + test(`explicit reset quarantines invalid admissions and allows pairing: ${damaged}`, async (t) => { + const directory = await mkdtemp(join(tmpdir(), "bc-admissions-reset-")) + t.after(() => rm(directory, { recursive: true, force: true })) + const root = join(directory, "mobile-access") + await mkdir(root) + await writeFile(join(root, "admissions.json"), damaged) + const options = { + userDataPath: directory, + getConnection: () => ({ url: "http://127.0.0.1:4096", password: "private" }), + removeTunnel: async () => {}, + tunnelFactory: async () => ({ origin: "https://pc.tunnel.bharatcode.ai", events: (async function* () {})(), close: async () => {} }), + } + const access = await createMobileAccess(options) + t.after(() => access.close()) + await assert.rejects(access.action({ type: "pair" })) + assert.equal(access.snapshot().errorKey, "mobile.error.storage") + assert.equal(await readFile(join(root, "admissions.json"), "utf8"), damaged) + await access.action({ type: "reset" }) + const backups = (await readdir(root)).filter(name => name.startsWith("admissions.json.corrupt-")) + assert.equal(backups.length, 1) + assert.equal(await readFile(join(root, backups[0]), "utf8"), damaged) + if (process.platform !== "win32") assert.equal((await stat(join(root, backups[0]))).mode & 0o777, 0o600) + const fence = JSON.parse(await readFile(join(root, "admissions.json.recovery"), "utf8")) + assert.ok(Number.isSafeInteger(fence.before)) + await access.action({ type: "pair" }) + assert.equal(access.snapshot().status, "connected") + await access.action({ type: "reset" }) + assert.deepEqual(JSON.parse(await readFile(join(root, "admissions.json.recovery"), "utf8")), fence) + await access.close() + const reopened = await createMobileAccess(options) + t.after(() => reopened.close()) + await reopened.action({ type: "pair" }) + assert.equal(reopened.snapshot().status, "connected") + }) +} + +test("reset preserves a healthy forwarding journal byte-for-byte", async (t) => { + const directory = await mkdtemp(join(tmpdir(), "bc-admissions-healthy-")) + t.after(() => rm(directory, { recursive: true, force: true })) + const root = join(directory, "mobile-access") + await mkdir(root) + const original = JSON.stringify({ admissions: { "ses_one.msg_one": { hash: "a".repeat(64), state: "forwarding" } }, selections: {} }) + await writeFile(join(root, "admissions.json"), original) + const access = await createMobileAccess({ userDataPath: directory, getConnection: () => undefined, removeTunnel: async () => {} }) + t.after(() => access.close()) + await access.action({ type: "reset" }) + assert.equal(await readFile(join(root, "admissions.json"), "utf8"), original) + assert.equal((await readdir(root)).some(name => name.includes(".corrupt-")), false) +}) + +test("reset reports storage failure instead of success when admissions cannot be read", async (t) => { + const directory = await mkdtemp(join(tmpdir(), "bc-admissions-unreadable-")) + t.after(() => rm(directory, { recursive: true, force: true })) + const root = join(directory, "mobile-access") + await mkdir(join(root, "admissions.json"), { recursive: true }) + const access = await createMobileAccess({ userDataPath: directory, getConnection: () => undefined, removeTunnel: async () => {} }) + t.after(() => access.close()) + await assert.rejects(access.action({ type: "reset" })) + assert.equal(access.snapshot().status, "error") + assert.equal(access.snapshot().errorKey, "mobile.error.storage") + assert.equal((await readdir(root)).some(name => name.includes(".corrupt-")), false) +}) + +test("a second damaged journal advances the recovery fence to cover newer uncertain deliveries", async (t) => { + const directory = await mkdtemp(join(tmpdir(), "bc-admissions-second-reset-")) + t.after(() => rm(directory, { recursive: true, force: true })) + const root = join(directory, "mobile-access") + await mkdir(root) + await writeFile(join(root, "admissions.json.recovery"), '{"before":1000}') + await writeFile(join(root, "admissions.json"), '{"admissions":') + const access = await createMobileAccess({ userDataPath: directory, getConnection: () => undefined, removeTunnel: async () => {} }) + t.after(() => access.close()) + const started = Date.now() + await access.action({ type: "reset" }) + assert.ok(JSON.parse(await readFile(join(root, "admissions.json.recovery"), "utf8")).before >= started) +}) + +test("pairing QR preserves a four-module clear border for physical scanners", async (t) => { + const directory = await mkdtemp(join(tmpdir(), "bc-access-qr-margin-")) + t.after(() => rm(directory, { recursive: true, force: true })) + const access = await createMobileAccess({ + userDataPath: directory, + getConnection: () => ({ + url: "http://127.0.0.1:4096", + password: "private", + }), + tunnelFactory: async () => ({ + origin: "https://pc.tunnel.bharatcode.ai", + events: (async function* () {})(), + close: async () => {}, + }), + }) + t.after(() => access.close()) + await access.action({ type: "pair" }) + const state = access.snapshot() + const image = PNG.sync.read(Buffer.from(state.qrImage.split(",")[1], "base64")) + let left = image.width, + top = image.height, + right = -1, + bottom = -1 + for (let y = 0; y < image.height; y++) { + for (let x = 0; x < image.width; x++) { + const offset = (y * image.width + x) * 4 + if (image.data[offset] > 127 || image.data[offset + 3] === 0) continue + left = Math.min(left, x) + right = Math.max(right, x) + top = Math.min(top, y) + bottom = Math.max(bottom, y) + } + } + assert.ok(right > left, "QR must contain a visible symbol") + const modules = QRCode.create(state.pairing, { errorCorrectionLevel: "M" }).modules.size + const modulePixels = Math.floor(Math.min(right - left + 1, bottom - top + 1) / modules) + const clearBorder = Math.min(left, top, image.width - 1 - right, image.height - 1 - bottom) + assert.ok(modulePixels > 0) + assert.ok( + clearBorder >= modulePixels * 4, + `clear border ${clearBorder}px is smaller than four ${modulePixels}px modules`, + ) +}) + +test("reset during restoration cannot start a new connection after cancellation", async (t) => { + const directory = await mkdtemp(join(tmpdir(), "bc-access-restore-cancel-")) + t.after(() => rm(directory, { recursive: true, force: true })) + await mkdir(join(directory, "mobile-access")) + await writeFile(join(directory, "mobile-access", "enabled.json"), '{"enabled":true}') + let connections = 0 + const access = await createMobileAccess({ + userDataPath: directory, + getConnection: () => { + connections++ + throw new Error("cancelled restore reached the sidecar") + }, + removeTunnel: async () => {}, + }) + t.after(() => access.close()) + const restoring = access.restore() + await Promise.resolve() + const resetting = access.action({ type: "reset" }) + await restoring + await resetting + assert.equal(connections, 0) + assert.equal(access.snapshot().enabled, false) +}) + +test("malformed optional mobile state cannot reject Desktop initialization or overwrite the registry", async (t) => { + const directory = await mkdtemp(join(tmpdir(), "bc-access-corrupt-")) + t.after(() => rm(directory, { recursive: true, force: true })) + await mkdir(join(directory, "mobile-access")) + const path = join(directory, "mobile-access", "devices.json") + const damaged = '{"version":1,"desktopID":' + await writeFile(path, damaged) + const access = await createMobileAccess({ + userDataPath: directory, + getConnection: () => undefined, + }) + await access.restore() + assert.equal(access.snapshot().enabled, false) + assert.equal(access.snapshot().status, "error") + assert.equal(access.snapshot().errorKey, "mobile.error.storage") + await assert.rejects(access.action({ type: "pair" })) + await access.close() + assert.equal(await readFile(path, "utf8"), damaged) +}) + +test("unavailable optional mobile directory does not reject initialization or close", async (t) => { + const directory = await mkdtemp(join(tmpdir(), "bc-access-unavailable-")) + t.after(() => rm(directory, { recursive: true, force: true })) + await writeFile(join(directory, "mobile-access"), "keep this file") + const access = await createMobileAccess({ + userDataPath: directory, + getConnection: () => undefined, + }) + await access.restore() + assert.equal(access.snapshot().status, "error") + assert.equal(access.snapshot().errorKey, "mobile.error.storage") + await access.close() + assert.equal(await readFile(join(directory, "mobile-access"), "utf8"), "keep this file") +}) + +test("reset cancels pending issuance before joining the action queue", { timeout: 3000 }, async (t) => { + const directory = await mkdtemp(join(tmpdir(), "bc-access-reset-")) + t.after(() => rm(directory, { recursive: true, force: true })) + let begin + const ready = new Promise((resolve) => { + begin = resolve + }) + let aborted = false, + removed = false + const access = await createMobileAccess({ + userDataPath: directory, + getConnection: () => ({ + url: "http://127.0.0.1:4096", + password: "private", + }), + tunnelFactory: async ({ signal }) => { + begin() + await new Promise((_, reject) => + signal.addEventListener( + "abort", + () => { + aborted = true + reject(new Error("cancelled")) + }, + { once: true }, + ), + ) + }, + removeTunnel: async () => { + removed = true + }, + }) + t.after(() => access.close()) + const pairing = access.action({ type: "pair" }).catch(() => {}) + await ready + const reset = access.action({ type: "reset" }) + try { + await Promise.race([ + reset, + new Promise((_, reject) => setTimeout(() => reject(new Error("reset did not cancel issuance")), 500)), + ]) + assert.equal(aborted, true) + assert.equal(removed, true) + assert.equal(access.snapshot().enabled, false) + assert.equal(access.snapshot().status, "off") + } finally { + await access.action({ type: "disable" }) + await pairing + await reset + } +}) + +test("reset cancels a queued pair before it starts provisioning", async (t) => { + const directory = await mkdtemp(join(tmpdir(), "bc-access-queued-")) + t.after(() => rm(directory, { recursive: true, force: true })) + const access = await createMobileAccess({ + userDataPath: directory, + getConnection: () => { + throw new Error("cancelled pairing must not reach the sidecar") + }, + removeTunnel: async () => {}, + }) + t.after(() => access.close()) + const pair = access.action({ type: "pair" }) + const reset = access.action({ type: "reset" }) + await pair + await reset + assert.equal(access.snapshot().status, "off") +}) + +test("failure to persist enabled state closes the new tunnel and leaves access disabled", async (t) => { + const directory = await mkdtemp(join(tmpdir(), "bc-access-write-")) + t.after(() => rm(directory, { recursive: true, force: true })) + await mkdir(join(directory, "mobile-access", "enabled.json"), { + recursive: true, + }) + let closed = false + const access = await createMobileAccess({ + userDataPath: directory, + getConnection: () => ({ + url: "http://127.0.0.1:4096", + password: "private", + }), + tunnelFactory: async () => ({ + origin: "https://pc.tunnel.bharatcode.ai", + events: (async function* () {})(), + close: async () => { + closed = true + }, + }), + }) + t.after(() => access.close()) + await assert.rejects(access.action({ type: "pair" })) + assert.equal(access.snapshot().enabled, false) + assert.equal(closed, true) +}) + +test("explicit reset preserves a damaged registry backup and allows fresh pairing", async (t) => { + const directory = await mkdtemp(join(tmpdir(), "bc-access-repair-")) + t.after(() => rm(directory, { recursive: true, force: true })) + const root = join(directory, "mobile-access") + await mkdir(root) + const damaged = '{"version":1,"desktopID":' + await writeFile(join(root, "devices.json"), damaged) + const access = await createMobileAccess({ + userDataPath: directory, + getConnection: () => ({ + url: "http://127.0.0.1:4096", + password: "private", + }), + removeTunnel: async () => {}, + tunnelFactory: async () => ({ + origin: "https://pc.tunnel.bharatcode.ai", + events: (async function* () {})(), + close: async () => {}, + }), + }) + t.after(() => access.close()) + await access.restore() + await access.action({ type: "reset" }) + const backups = (await readdir(root)).filter((name) => name.startsWith("devices.json.corrupt-")) + assert.equal(backups.length, 1) + assert.equal(await readFile(join(root, backups[0]), "utf8"), damaged) + await access.action({ type: "pair" }) + assert.equal(access.snapshot().status, "connected") + assert.deepEqual(access.snapshot().devices, []) +}) + +test("Desktop starts no tunnel until enabled, and disabling closes both resources", async (t) => { + const directory = await mkdtemp(join(tmpdir(), "bc-access-")) + t.after(() => rm(directory, { recursive: true, force: true })) + let connects = 0, + closes = 0 + const access = await createMobileAccess({ + userDataPath: directory, + getConnection: () => ({ + url: "http://127.0.0.1:4096", + username: "opencode", + password: "private", + }), + tunnelFactory: async () => { + connects++ + return { + origin: "https://unit.tunnel.bharatcode.ai", + close: async () => { + closes++ + }, + events: (async function* () {})(), + } + }, + }) + t.after(() => access.close()) + await access.restore() + assert.equal(access.snapshot().errorKey, undefined) + assert.equal(connects, 0) + await access.action({ type: "pair" }) + assert.equal(connects, 1) + assert.ok(access.snapshot().qrImage.startsWith("data:image/png;base64,")) + assert.equal(new URL(access.snapshot().pairing).hostname, "pair") + await access.action({ type: "disable" }) + assert.equal(closes, 1) + assert.equal(access.snapshot().enabled, false) + assert.equal(access.snapshot().pairing, undefined) + await assert.rejects(access.action({ type: "approve", id: "unknown" })) +}) + +test("Desktop refuses a remote or unprotected sidecar without creating a tunnel", async (t) => { + const directory = await mkdtemp(join(tmpdir(), "bc-access-")) + t.after(() => rm(directory, { recursive: true, force: true })) + const access = await createMobileAccess({ + userDataPath: directory, + getConnection: () => ({ url: "https://other.example", password: "" }), + tunnelFactory: () => { + throw new Error("must not connect") + }, + }) + t.after(() => access.close()) + await assert.rejects(access.action({ type: "pair" })) + assert.equal(access.snapshot().enabled, false) +}) + +test( + "provisioning reports progress while status stays readable and disconnect cancels startup", + { timeout: 2000 }, + async (t) => { + const directory = await mkdtemp(join(tmpdir(), "bc-access-")) + t.after(() => rm(directory, { recursive: true, force: true })) + let began + const ready = new Promise((resolve) => (began = resolve)) + const access = await createMobileAccess({ + userDataPath: directory, + getConnection: () => ({ + url: "http://127.0.0.1:4096", + password: "private", + }), + tunnelFactory: async ({ signal, onProgress }) => { + onProgress("waiting-certificate") + began() + await new Promise((_, reject) => + signal.addEventListener("abort", () => reject(new Error("cancelled")), { once: true }), + ) + }, + }) + t.after(() => access.close()) + const pairing = access.action({ type: "pair" }).catch(() => {}) + await ready + const snapshot = await access.action({ type: "state" }) + assert.equal(snapshot.status, "provisioning") + assert.equal(snapshot.progress, "waiting-certificate") + await access.action({ type: "disable" }) + await pairing + assert.equal(access.snapshot().status, "off") + }, +) + +test("Desktop emits credential-free links, restores enabled identity and clears revoked phones on reset", async (t) => { + const directory = await mkdtemp(join(tmpdir(), "bc-access-")) + t.after(() => rm(directory, { recursive: true, force: true })) + let connects = 0, + removed = 0 + const options = { + userDataPath: directory, + getConnection: () => ({ + url: "http://127.0.0.1:4096", + password: "private", + }), + tunnelFactory: async () => { + connects++ + return { + origin: "https://pc.tunnel.bharatcode.ai", + events: (async function* () {})(), + close: async () => {}, + } + }, + removeTunnel: async () => { + removed++ + }, + } + const first = await createMobileAccess(options) + await first.action({ type: "pair" }) + const link = new URL(first.snapshot().pairing) + assert.equal(link.protocol, "bharatcode:") + assert.deepEqual([...link.searchParams.keys()].sort(), ["desktopID", "origin"]) + assert.equal(first.snapshot().status, "connected") + await first.close() + const second = await createMobileAccess(options) + t.after(() => second.close()) + await second.restore() + assert.equal(connects, 2) + assert.equal(second.snapshot().enabled, true) + await second.action({ type: "reset" }) + assert.equal(removed, 1) + assert.deepEqual(second.snapshot().devices, []) + assert.equal(second.snapshot().enabled, false) +}) + +test("Desktop status reflects a phone unpairing itself immediately", async (t) => { + const directory = await mkdtemp(join(tmpdir(), "bc-access-")) + t.after(() => rm(directory, { recursive: true, force: true })) + let target + const access = await createMobileAccess({ + userDataPath: directory, + getConnection: () => ({ + url: "http://127.0.0.1:4096", + password: "private", + }), + tunnelFactory: async (options) => { + target = options.target + return { + origin: "https://pc.tunnel.bharatcode.ai", + events: (async function* () {})(), + close: async () => {}, + } + }, + }) + t.after(() => access.close()) + await access.action({ type: "pair" }) + const link = new URL(access.snapshot().pairing) + const post = (path, body) => + fetch(target + path, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }) + const claim = await ( + await post("/mobile/v1/claim", { + desktopID: link.searchParams.get("desktopID"), + deviceName: "Phone", + }) + ).json() + await access.action({ type: "approve", id: claim.claimID }) + const paired = await (await post("/mobile/v1/claim/status", claim)).json() + assert.equal((await access.action({ type: "state" })).devices.length, 1) + assert.equal( + ( + await fetch(target + "/mobile/v1/device", { + method: "DELETE", + headers: { authorization: "Bearer " + paired.token }, + }) + ).status, + 200, + ) + assert.equal((await access.action({ type: "state" })).devices.length, 0) +}) + +test("self-hosted identities use a service-specific store, never the old upstream token", async (t) => { + const directory = await mkdtemp(join(tmpdir(), "bc-access-")) + t.after(() => rm(directory, { recursive: true, force: true })) + let actual + const access = await createMobileAccess({ + userDataPath: directory, + getConnection: () => ({ + url: "http://127.0.0.1:4096", + password: "private", + }), + tunnelFactory: async ({ storePath }) => { + actual = storePath + return { + origin: "https://pc.tunnel.bharatcode.ai", + events: (async function* () {})(), + close: async () => {}, + } + }, + }) + t.after(() => access.close()) + await access.action({ type: "pair" }) + assert.equal(actual, join(directory, "mobile-access", "tunnel-bharatcode-ai")) +}) diff --git a/packages/mobile-connector/test/desktop-api.test.mjs b/packages/mobile-connector/test/desktop-api.test.mjs new file mode 100644 index 000000000..3fdf9b402 --- /dev/null +++ b/packages/mobile-connector/test/desktop-api.test.mjs @@ -0,0 +1,206 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { createDesktopAPI } from '../src/desktop-api.mjs' + +for (const created of [undefined, 999, 1000, Date.now() + 3_600_000]) { + test(`recovery fence blocks unknown old delivery across restart, created=${created}`, async t => { + const root=await mkdtemp(join(tmpdir(),'bc-recovery-fence-'));t.after(()=>rm(root,{recursive:true,force:true})) + const storePath=join(root,'admissions.json') + // Simulate a crash after quarantine but before the replacement journal was written. + await writeFile(storePath+'.recovery',JSON.stringify({before:1000})) + let submissions=0 + const request=async path=>{ + if(path==='/session/ses_one')return {data:{directory:'/work',time:{created}}} + if(path.includes('/prompt_async')){submissions++;return {data:undefined}} + throw Object.assign(new Error('not stored'),{status:404}) + } + for(let i=0;i<2;i++){ + const api=await createDesktopAPI({request,storePath}) + await assert.rejects(api.handle('POST','/api/session/ses_one/prompt',{id:'msg_one',prompt:{text:'Do not duplicate'}}),/recovered.*new conversation/i) + } + assert.equal(submissions,0) + }) +} + +test('recovered history acknowledges stored messages and permits only connector-created conversations',async t=>{ + const root=await mkdtemp(join(tmpdir(),'bc-recovery-proof-'));t.after(()=>rm(root,{recursive:true,force:true})) + const storePath=join(root,'admissions.json') + await writeFile(storePath+'.recovery',JSON.stringify({before:1000})) + let submissions=0, fresh + const request=async(path,method,body)=>{ + if(path==='/api/session' && method==='POST'){fresh=body.id;return {data:{data:{id:fresh}}}} + if(path==='/session/ses_old')return {data:{directory:'/work',time:{created:900}}} + if(path==='/session/'+fresh)return {data:{directory:'/work',time:{created:0}}} + if(path.includes('/prompt_async')){submissions++;return {data:undefined}} + return {data:{info:{id:'msg_one',role:'user'},parts:[{type:'text',text:'Only once'}]}} + } + const api=await createDesktopAPI({request,storePath}) + const prompt={id:'msg_one',prompt:{text:'Only once'}} + await api.handle('POST','/api/session/ses_old/prompt',prompt) + assert.equal(submissions,0) + await assert.rejects(api.handle('POST','/api/session/ses_old/prompt',{...prompt,prompt:{text:'Different'}}),/identity/) + await assert.rejects(api.handle('POST','/api/session',{id:'ses_old'}),/identity/i) + const created=await api.handle('POST','/api/session',{location:{directory:'/work'}}) + assert.match(created.data.data.id,/^ses_[a-f0-9]{32}$/) + await api.handle('POST',`/api/session/${fresh}/prompt`,prompt) + assert.equal(submissions,1) + const reopened=await createDesktopAPI({request,storePath}) + await reopened.handle('POST',`/api/session/${fresh}/prompt`,prompt) + assert.equal(submissions,1) +}) + +test('malformed recovery fence fails closed instead of forgetting prior quarantine',async t=>{ + const root=await mkdtemp(join(tmpdir(),'bc-recovery-invalid-'));t.after(()=>rm(root,{recursive:true,force:true})) + const storePath=join(root,'admissions.json') + await writeFile(storePath+'.recovery','{"before":null}') + await assert.rejects(createDesktopAPI({request:()=>{throw new Error('must not contact sidecar')},storePath}),/recovery/i) +}) + +test('stored admission is acknowledged while the upstream async HTTP acknowledgement is still pending', async t => { + const root = await mkdtemp(join(tmpdir(),'bc-admission-early-')) + t.after(()=>rm(root,{recursive:true,force:true})) + let release, forwarded=0, posted=false + const acknowledgement=new Promise(resolve=>{release=resolve}) + const request=async(path,method,_body)=>{ + if(path==='/session/ses_one') return {data:{id:'ses_one',directory:'/fixture'}} + if(path.includes('/prompt_async')) { forwarded++; posted=true; await acknowledgement; return {data:{}} } + if(path.includes('/message/msg_early')) { + if(!posted) throw Object.assign(new Error('Not stored'),{status:404}) + return {data:{info:{id:'msg_early',role:'user'},parts:[{type:'text',text:'Only once'}]}} + } + throw new Error('Unexpected route '+path) + } + const api=await createDesktopAPI({request,storePath:join(root,'admissions.json')}) + const sending=api.handle('POST','/api/session/ses_one/prompt',{id:'msg_early',prompt:{text:'Only once'}}) + try { + const early=await Promise.race([sending.then(()=>true),new Promise(resolve=>setTimeout(()=>resolve(false),150))]) + assert.equal(early,true,'A stored Desktop message should be admitted without waiting for its HTTP acknowledgement') + assert.equal(forwarded,1) + } finally { release(); await sending } + const reopened=await createDesktopAPI({request,storePath:join(root,'admissions.json')}) + await reopened.handle('POST','/api/session/ses_one/prompt',{id:'msg_early',prompt:{text:'Only once'}}) + assert.equal(forwarded,1) +}) + +test('Desktop history uses legacy parts, preserves cursor and never switches execution engine', async t => { + const root = await mkdtemp(join(tmpdir(), 'bc-desktop-api-')); t.after(() => rm(root, {recursive:true,force:true})) + const calls = [] + const request = async (path, method, body) => { + calls.push({path,method,body}) + if (path === '/session/ses_one') return {data: {directory:'/project',agent:'build'}} + if (path.startsWith('/session/ses_one/message?')) return {data: [{info:{id:'msg_one',role:'assistant'},parts:[{id:'part_one',type:'text',text:'Existing Desktop response'}]}],cursor:'older'} + if (path.includes('/prompt_async')) return {data:undefined} + if (path.includes('/message/msg_stable?')) return {data:{info:{id:'msg_stable',role:'user'},parts:[{type:'text',text:'Continue the same work'}]}} + throw new Error(path) + } + const api = await createDesktopAPI({request, storePath:join(root,'admissions.json')}) + const page = await api.handle('GET','/api/session/ses_one/message?limit=50&order=desc') + assert.equal(page.data.data[0].content[0].text,'Existing Desktop response') + assert.equal(page.data.cursor.next,'older') + const prompt = {id:'msg_stable',prompt:{text:'Continue the same work'}} + await api.handle('POST','/api/session/ses_one/prompt',prompt) + const reopened = await createDesktopAPI({request, storePath:join(root,'admissions.json')}) + await reopened.handle('POST','/api/session/ses_one/prompt',prompt) + assert.equal(calls.filter(c=>c.path.includes('/prompt_async')).length,1) + assert.equal(calls.find(c=>c.path.includes('/prompt_async')).body.messageID,'msg_stable') + await assert.rejects(reopened.handle('POST','/api/session/ses_one/prompt',{...prompt,prompt:{text:'Different'}}), /identity/) +}) +test('an uncertain Desktop admission is never automatically executed a second time', async t => { + const root = await mkdtemp(join(tmpdir(), 'bc-desktop-api-')); t.after(() => rm(root, {recursive:true,force:true})) + let submissions=0 + const request = async path => { + if(path === '/session/ses_one') return {data:{directory:'/project'}} + if(path.includes('prompt_async')) { submissions++; throw new Error('connection lost') } + throw Object.assign(new Error('not found'),{status:404}) + } + const api=await createDesktopAPI({request,storePath:join(root,'admissions.json')}) + const prompt={id:'msg_one',prompt:{text:'Run tests'}} + await assert.rejects(api.handle('POST','/api/session/ses_one/prompt',prompt)) + await assert.rejects(api.handle('POST','/api/session/ses_one/prompt',prompt),/unconfirmed/) + assert.equal(submissions,1) +}) +test('stored Desktop admission wins when the async acknowledgement fails', async t => { + const root = await mkdtemp(join(tmpdir(), 'bc-desktop-api-')); t.after(() => rm(root, {recursive:true,force:true})) + let submissions=0, stored=false + const request = async path => { + if(path === '/session/ses_one') return {data:{directory:'/project'}} + if(path.includes('prompt_async')) { submissions++; stored=true; throw Object.assign(new Error('acknowledgement failed'),{status:500}) } + if(stored && path.includes('/message/msg_one?')) return {data:{info:{id:'msg_one',role:'user'},parts:[{type:'text',text:'Run tests'}]}} + throw Object.assign(new Error('not found'),{status:404}) + } + const storePath=join(root,'admissions.json') + const api=await createDesktopAPI({request,storePath}) + const prompt={id:'msg_one',prompt:{text:'Run tests'}} + assert.equal((await api.handle('POST','/api/session/ses_one/prompt',prompt)).data.data.id,'msg_one') + const reopened=await createDesktopAPI({request,storePath}) + assert.equal((await reopened.handle('POST','/api/session/ses_one/prompt',prompt)).data.data.id,'msg_one') + assert.equal(submissions,1) +}) +test('an admission probe failure is reconciled without resending the prompt', async t => { + const root=await mkdtemp(join(tmpdir(),'bc-desktop-api-'));t.after(()=>rm(root,{recursive:true,force:true})) + let submissions=0, reads=0 + const request=async path=>{ + if(path==='/session/ses_one')return {data:{directory:'/project'}} + if(path.includes('/prompt_async')){submissions++;return {data:undefined}} + if(++reads===1)throw Object.assign(new Error('temporary read failure'),{status:500}) + return {data:{info:{id:'msg_one',role:'user'},parts:[{type:'text',text:'Run tests'}]}} + } + const api=await createDesktopAPI({request,storePath:join(root,'admissions.json')}) + const result=await api.handle('POST','/api/session/ses_one/prompt',{id:'msg_one',prompt:{text:'Run tests'}}) + assert.equal(result.data.data.id,'msg_one');assert.equal(submissions,1) +}) +test('Desktop provider and agent choices exclude secrets and hidden agents', async t => { + const root=await mkdtemp(join(tmpdir(),'bc-desktop-api-'));t.after(()=>rm(root,{recursive:true,force:true})) + const request=async path=>({data:path.startsWith('/provider')?{connected:['provider_one'],all:[{id:'provider_one',key:'private',models:{model_one:{id:'model_one',name:'Model',api:{key:'private'}}}},{id:'unconfigured',models:{unused:{id:'unused'}}}]}:[{name:'build',permission:{secret:'private'}},{name:'internal',hidden:true}]}) + const api=await createDesktopAPI({request,storePath:join(root,'admissions.json')}) + const models=await api.handle('GET','/api/model') + assert.deepEqual(models.data.data,[{id:'model_one',providerID:'provider_one',name:'Model',enabled:true}]) + assert.equal(JSON.stringify(models).includes('private'),false) + const agents=await api.handle('GET','/api/agent') + assert.deepEqual(agents.data.data.map(a=>a.id),['build']) + assert.equal(JSON.stringify(agents).includes('private'),false) +}) +test('Desktop request decisions verify session ownership before reply',async t=>{ + const root=await mkdtemp(join(tmpdir(),'bc-desktop-api-'));t.after(()=>rm(root,{recursive:true,force:true})) + let replies=0 + const request=async(path,method)=>{if(method==='POST')replies++;return {data:path==='/session/ses_one'?{directory:'/work'}:[{id:'per_one',sessionID:'ses_other'}]}} + const api=await createDesktopAPI({request,storePath:join(root,'admissions.json')}) + await assert.rejects(api.handle('POST','/api/session/ses_one/permission/per_one/reply',{reply:'once'}),/resolved|unavailable/) + assert.equal(replies,0) +}) + +test('a successful async acknowledgement without a stored message stays unresolved',async t=>{ + const root=await mkdtemp(join(tmpdir(),'bc-desktop-api-'));t.after(()=>rm(root,{recursive:true,force:true})) + let submissions=0, reads=0 + const request=async(path)=>{ + if(path==='/session/ses_one')return {data:{directory:'/work'}} + if(path.includes('/prompt_async')){submissions++;return {data:undefined}} + reads++;throw Object.assign(new Error('not stored'),{status:404}) + } + const api=await createDesktopAPI({request,storePath:join(root,'admissions.json')}) + const prompt={id:'msg_one',prompt:{text:'Keep this draft'}} + await assert.rejects(api.handle('POST','/api/session/ses_one/prompt',prompt),/unconfirmed/) + const reopened=await createDesktopAPI({request,storePath:join(root,'admissions.json')}) + await assert.rejects(reopened.handle('POST','/api/session/ses_one/prompt',prompt),/unconfirmed/) + assert.equal(submissions,1);assert.ok(reads>0) +}) +test('confirmed mobile selections are consumed and a later Desktop choice wins',async t=>{ + const root=await mkdtemp(join(tmpdir(),'bc-desktop-api-'));t.after(()=>rm(root,{recursive:true,force:true})) + let current={directory:'/work',agent:'build',model:{id:'desktop_A',providerID:'provider'}} + const prompts=[] + const request=async(path,method,body)=>{ + if(path==='/session/ses_one')return {data:current} + if(path.includes('/prompt_async')){prompts.push(body);return {data:undefined}} + const id=/message\/(msg_[a-z]+)\?/.exec(path)?.[1] + return {data:{info:{id,role:'user'},parts:[{type:'text',text:'Run'}]}} + } + const api=await createDesktopAPI({request,storePath:join(root,'admissions.json')}) + await api.handle('POST','/api/session/ses_one/model',{model:{id:'mobile_A',providerID:'provider'}}) + await api.handle('POST','/api/session/ses_one/prompt',{id:'msg_first',prompt:{text:'Run'}}) + current={...current,agent:'plan',model:{id:'desktop_B',providerID:'provider'}} + await api.handle('POST','/api/session/ses_one/prompt',{id:'msg_second',prompt:{text:'Run'}}) + assert.equal(prompts[0].model.modelID,'mobile_A');assert.equal(prompts[1].model.modelID,'desktop_B');assert.equal(prompts[1].agent,'plan') +}) diff --git a/packages/mobile-connector/test/files.test.mjs b/packages/mobile-connector/test/files.test.mjs new file mode 100644 index 000000000..1074fc0dd --- /dev/null +++ b/packages/mobile-connector/test/files.test.mjs @@ -0,0 +1,23 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { mkdtemp, writeFile, mkdir, symlink, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { projectFile } from '../src/files.mjs' + +test('project reader confines files to the resolved project including symlinks', async t => { + const root = await mkdtemp(join(tmpdir(), 'bc-files-')) + t.after(() => rm(root, { recursive: true, force: true })) + await mkdir(join(root, 'project')) + await writeFile(join(root, 'project', 'app.ts'), 'export const app = true') + await writeFile(join(root, 'secret'), 'private') + await symlink(join(root, 'secret'), join(root, 'project', 'escape')) + assert.equal((await projectFile(join(root, 'project'), 'app.ts')).text, 'export const app = true') + assert.equal((await projectFile(join(root, 'project'), '')).entries.length, 2) + await assert.rejects(projectFile(join(root, 'project'), '../secret'), /outside|Invalid/) + await assert.rejects(projectFile(join(root, 'project'), 'escape'), /outside/) + await writeFile(join(root, 'project', 'binary'), Buffer.from([0, 255])) + assert.equal((await projectFile(join(root, 'project'), 'binary')).binary, true) + await writeFile(join(root, 'project', 'large'), Buffer.alloc(262145, 97)) + assert.equal((await projectFile(join(root, 'project'), 'large')).tooLarge, true) +}) diff --git a/packages/mobile-connector/test/gateway.test.mjs b/packages/mobile-connector/test/gateway.test.mjs new file mode 100644 index 000000000..196eab788 --- /dev/null +++ b/packages/mobile-connector/test/gateway.test.mjs @@ -0,0 +1,332 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { createServer, request } from 'node:http' +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createGateway } from '../src/gateway.mjs' + +async function setup(t, options = {}) { + const seen = [] + let admittedAt = Infinity + const upstream = createServer((req, res) => { + seen.push({ url: req.url, method: req.method, headers: req.headers }) + res.setHeader('set-cookie', 'bc_test=sidecar-private; Domain=.bharatcode.ai; Secure; HttpOnly') + if (options.asyncAdmission && req.url.startsWith('/session/ses_one/prompt_async?')) { + req.resume() + admittedAt = Date.now() + 250 + res.writeHead(204); res.end(); return + } + if (options.asyncAdmission && req.url.startsWith('/session/ses_one/message/msg_async?')) { + res.setHeader('content-type', 'application/json') + if (Date.now() < admittedAt) { res.writeHead(404); res.end('{}'); return } + res.end(JSON.stringify({ info: { id: 'msg_async', role: 'user' }, parts: [{ type: 'text', text: 'One request' }] })); return + } + if (req.url === '/session/ses_one') { res.setHeader('content-type', 'application/json'); res.end(JSON.stringify({ id: 'ses_one', directory: '/work/fixture project' })); return } + if (req.url === '/api/model') { + res.setHeader('content-type', 'application/json') + res.end(JSON.stringify({ data: [{ id: 'model_one', providerID: 'provider_one', name: 'Configured model', enabled: true, request: { headers: { authorization: 'secret-provider-key' } }, api: { settings: { apiKey: 'secret-provider-key' } } }] })) + return + } + if (req.url === '/api/session/ses_changes') { res.setHeader('content-type', 'application/json'); res.end(JSON.stringify({ data: { location: { directory: '/work/storefront' } } })); return } + if (req.url.startsWith('/vcs/diff?')) { res.setHeader('content-type', 'application/json'); res.end(JSON.stringify([{ file: 'address.ts', patch: '@@ -1 +1 @@\n-old\n+new', additions: 1, deletions: 1 }])); return } + if (req.url === '/event?directory=%2Fwork%2Ffixture+project') { + res.writeHead(200, { 'content-type': 'text/event-stream' }) + for (const event of [ + { type: 'message.updated', properties: { sessionID: 'ses_one', info: { id: 'msg_one', role: 'assistant' } } }, + { type: 'message.part.updated', properties: { sessionID: 'ses_one', part: { id: 'txt_one', messageID: 'msg_one', type: 'text', text: '' } } }, + { type: 'message.part.delta', properties: { sessionID: 'ses_one', messageID: 'msg_one', partID: 'txt_one', field: 'text', delta: 'Legacy live response' } }, + ]) res.write(`data: ${JSON.stringify(event)}\n\n`) + return + } + if (req.url === '/api/event' || req.url === '/api/event?directory=%2Fwork%2Ffixture+project') { + res.writeHead(200, { 'content-type': 'text/event-stream' }) + for (const event of [ + { id: 'evt_live', type: 'session.next.text.delta', data: { sessionID: 'ses_one', assistantMessageID: 'msg_one', textID: 'txt_one', delta: 'Hello' } }, + { id: 'evt_other', type: 'session.next.text.delta', data: { sessionID: 'ses_other', delta: 'private' } }, + { id: 'evt_secret', type: 'provider.updated', data: { sessionID: 'ses_one', secret: 'hidden' } }, + ]) res.write(`data: ${JSON.stringify(event)}\n\n`) + return + } + if (req.url.includes('/event')) { + res.writeHead(200, { 'content-type': 'text/event-stream' }) + res.write('data: {"type":"connected"}\n\n') + return + } + res.setHeader('content-type', 'application/json') + res.end(JSON.stringify({ data: [], cursor: {} })) + }) + await new Promise(resolve => upstream.listen(0, '127.0.0.1', resolve)) + t.after(() => { upstream.closeAllConnections(); upstream.close() }) + const directory = await mkdtemp(join(tmpdir(), 'bc-mobile-test-')) + t.after(() => rm(directory, { recursive: true, force: true })) + let currentTime = 1_000_000 + const claims = [] + const gateway = await createGateway({ + upstream: `http://127.0.0.1:${upstream.address().port}`, + username: 'opencode', password: 'private-sidecar-secret', + storePath: join(directory, 'devices.json'), + now: () => currentTime, onPairingRequest: claim => claims.push(claim), + ...options, + }) + const origin = await gateway.listen() + t.after(() => gateway.close()) + const post = (path, body, token) => fetch(origin + path, { + method: 'POST', headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) }, + body: JSON.stringify(body), + }) + async function pair() { + const qr = gateway.issuePairing('https://api.unit.tunnel.bharatcode.ai') + const response = await post('/mobile/v1/claim', { code: qr.code, deviceName: 'Test phone' }) + assert.equal(response.status, 201) + const claim = await response.json() + await gateway.approve(claim.claimID) + const result = await post('/mobile/v1/claim/status', claim) + return { ...(await result.json()), claim, qr } + } + return { gateway, origin, post, pair, seen, claims, directory, expire: () => { currentTime += 300_001 } } +} + +test('scan needs explicit desktop approval, and one-use code cannot be claimed twice', async t => { + const c = await setup(t) + const qr = c.gateway.issuePairing('https://api.unit.tunnel.bharatcode.ai') + const responses = await Promise.all([1, 2].map(() => c.post('/mobile/v1/claim', { code: qr.code, deviceName: 'Phone' }))) + assert.deepEqual(responses.map(r => r.status).sort(), [201, 401]) + const claim = await responses.find(r => r.status === 201).json() + assert.equal((await (await c.post('/mobile/v1/claim/status', claim)).json()).state, 'waiting') + assert.equal(c.claims.length, 1) + assert.equal(c.gateway.devices().length, 0) + await c.gateway.approve(claim.claimID) + const result = await (await c.post('/mobile/v1/claim/status', claim)).json() + assert.equal(result.state, 'approved') + assert.ok(result.token.length >= 40) + assert.equal((await c.post('/mobile/v1/claim/status', claim)).status, 410) +}) + +test('expired pairing codes and pending approvals cannot issue a credential', async t => { + const c = await setup(t) + const qr = c.gateway.issuePairing('https://unit.tunnel.bharatcode.ai') + c.expire() + assert.equal((await c.post('/mobile/v1/claim', { code: qr.code, deviceName: 'Phone' })).status, 401) + const next = c.gateway.issuePairing('https://unit.tunnel.bharatcode.ai') + const claim = await (await c.post('/mobile/v1/claim', { code: next.code, deviceName: 'Phone' })).json() + c.expire() + await assert.rejects(c.gateway.approve(claim.claimID), /expired/) + assert.equal(c.gateway.devices().length, 0) +}) + +test('only HTTPS OpenTunnel origins can be issued and issuing a new QR invalidates the old one', async t => { + const c = await setup(t) + for (const url of ['http://unit.tunnel.bharatcode.ai', 'https://evil.example', 'https://unit.tunnel.bharatcode.ai/path', 'https://user:pass@unit.tunnel.bharatcode.ai']) { + assert.throws(() => c.gateway.issuePairing(url)) + } + const old = c.gateway.issuePairing('https://unit.tunnel.bharatcode.ai') + c.gateway.issuePairing('https://unit.tunnel.bharatcode.ai') + assert.equal((await c.post('/mobile/v1/claim', { code: old.code, deviceName: 'Phone' })).status, 401) +}) + +test('denied claim cannot be approved and a wrong claim secret cannot poll', async t => { + const c = await setup(t) + const qr = c.gateway.issuePairing('https://unit.tunnel.bharatcode.ai') + const claim = await (await c.post('/mobile/v1/claim', { code: qr.code, deviceName: 'Phone' })).json() + assert.equal((await c.post('/mobile/v1/claim/status', { ...claim, claimSecret: 'wrong' })).status, 401) + c.gateway.decline(claim.claimID) + assert.equal((await (await c.post('/mobile/v1/claim/status', claim)).json()).state, 'declined') + await assert.rejects(c.gateway.approve(claim.claimID)) +}) + +test('device credential proxies only allowed routes and never leaks master credentials', async t => { + const c = await setup(t) + assert.equal((await fetch(c.origin + '/api/session')).status, 401) + const { token } = await c.pair() + const response = await fetch(c.origin + '/api/session?limit=30', { headers: { authorization: `Bearer ${token}`, 'x-forwarded-host': 'evil.example', cookie: 'bc_test=browser-private' } }) + assert.equal(response.status, 200) + assert.deepEqual(await response.json(), { data: [], cursor: {} }) + assert.equal(c.seen[0].headers.authorization, 'Basic ' + Buffer.from('opencode:private-sidecar-secret').toString('base64')) + assert.equal(c.seen[0].headers['x-forwarded-host'], undefined) + assert.equal(c.seen[0].headers.cookie, undefined) + assert.equal(response.headers.get('set-cookie'), null) + const metadata = await (await fetch(c.origin + '/mobile/v1/health', { headers: { authorization: `Bearer ${token}` } })).text() + assert.ok(!metadata.includes('private-sidecar-secret')) + for (const path of ['/api/credential', '/api/config', '/api/integration', '/api/session/abc/permission', '/api/fs/file?path=/etc/passwd', '/api/session/abc%2F..%2Fcredential']) { + const denied = await c.post(path, {}, token) + assert.equal(denied.status, 403, path) + } + assert.equal(c.seen.length, 1) +}) + +test('bodyless Desktop interrupts work while nonempty bodies still require JSON', async t => { + const c = await setup(t, {runtime:'desktop'}) + const {token} = await c.pair() + const headers = {authorization:`Bearer ${token}`} + const response = await fetch(c.origin+'/api/session/ses_one/interrupt',{method:'POST',headers}) + assert.equal(response.status,200) + const aborts=c.seen.filter(r=>r.url.startsWith('/session/ses_one/abort?')) + assert.equal(aborts.length,1) + assert.equal(aborts[0].method,'POST') + const before=c.seen.length + const rejected=await fetch(c.origin+'/api/session/ses_one/interrupt',{method:'POST',headers:{...headers,'content-type':'text/plain'},body:'{}'}) + assert.equal(rejected.status,415) + assert.equal(c.seen.length,before) +}) + +test('persisted registry contains verifiers, survives restart and supports individual revocation', async t => { + const c = await setup(t) + const first = await c.pair() + const second = await c.pair() + const content = await readFile(join(c.directory, 'devices.json'), 'utf8') + assert.ok(!content.includes(first.token)) + assert.ok(!content.includes(second.token)) + await c.gateway.revoke(first.device.id) + assert.equal((await fetch(c.origin + '/mobile/v1/health', { headers: { authorization: `Bearer ${first.token}` } })).status, 401) + assert.equal((await fetch(c.origin + '/mobile/v1/health', { headers: { authorization: `Bearer ${second.token}` } })).status, 200) + const reopened = await createGateway({ upstream: 'http://127.0.0.1:1', username: 'opencode', password: 'secret', storePath: join(c.directory, 'devices.json') }) + t.after(() => reopened.close()) + const url = await reopened.listen() + assert.equal((await fetch(url + '/mobile/v1/health', { headers: { authorization: `Bearer ${second.token}` } })).status, 200) +}) + +test('revocation closes an already authenticated live event stream', async t => { + const c = await setup(t) + const { token, device } = await c.pair() + const response = await fetch(c.origin + '/api/session/ses_abc/event?after=0', { headers: { authorization: `Bearer ${token}` } }) + const reader = response.body.getReader() + assert.ok((await reader.read()).value.length > 0) + await c.gateway.revoke(device.id) + const ended = await Promise.race([reader.read().then(r => r.done, () => true), new Promise(resolve => setTimeout(() => resolve(false), 500))]) + assert.equal(ended, true) +}) + +test('requests with hostile browser origins and oversized bodies are rejected before upstream access', async t => { + const c = await setup(t) + const { token } = await c.pair() + assert.equal((await fetch(c.origin + '/api/session', { headers: { authorization: `Bearer ${token}`, origin: 'https://evil.example' } })).status, 403) + assert.equal((await fetch(c.origin + '/api/session', { method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, body: JSON.stringify({ text: 'x'.repeat(1_048_577) }) })).status, 413) + assert.equal(c.seen.length, 0) +}) + +test('model metadata is projected without provider configuration or secrets', async t => { + const c = await setup(t) + const { token } = await c.pair() + const response = await fetch(c.origin + '/api/model', { headers: { authorization: `Bearer ${token}` } }) + assert.deepEqual(await response.json(), { data: [{ id: 'model_one', providerID: 'provider_one', name: 'Configured model', enabled: true }] }) + assert.equal((await fetch(c.origin + '/api/event', { headers: { authorization: `Bearer ${token}` } })).status, 403) +}) + +test('working changes derive their directory from the authenticated session, never a caller path', async t => { + const c = await setup(t) + const { token } = await c.pair() + const headers = { authorization: `Bearer ${token}` } + const result = await fetch(c.origin + '/mobile/v1/session/ses_changes/changes', { headers }) + assert.equal(result.status, 200) + assert.deepEqual((await result.json()).data.map(v => v.file), ['address.ts']) + assert.equal(c.seen[1].url, '/vcs/diff?directory=%2Fwork%2Fstorefront&mode=git&context=3') + assert.equal((await fetch(c.origin + '/mobile/v1/session/ses_changes/changes?directory=/etc', { headers })).status, 403) +}) + +test('revoking a phone while it uploads a prompt prevents later upstream execution', async t => { + const c = await setup(t) + const { token, device } = await c.pair() + const uploading = request(c.origin + '/api/session/ses_demo/prompt', { method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' } }) + const settled = new Promise(resolve => { uploading.on('error', () => resolve()); uploading.on('response', response => { response.resume(); response.on('end', resolve) }) }) + uploading.write('{') + await new Promise(resolve => setTimeout(resolve, 30)) + await c.gateway.revoke(device.id) + uploading.end('"id":"msg_one","prompt":{"text":"do it"}}') + await settled + assert.equal(c.seen.length, 0) +}) + +test('mobile live endpoint projects native events and closes on device revocation', async t => { + const c = await setup(t) + const paired = await c.pair() + const response = await fetch(c.origin + '/mobile/v1/session/ses_one/live', { headers: { authorization: `Bearer ${paired.token}` } }) + assert.equal(response.status, 200) + const reader = response.body.getReader() + let result = '' + while (!result.includes('evt_live')) result += Buffer.from((await reader.read()).value).toString() + assert.ok(result.includes('Hello')) + assert.ok(!result.includes('private')) + assert.ok(!result.includes('hidden')) + assert.equal(c.seen[0].url, '/api/event') + await c.gateway.revoke(paired.device.id) + await assert.rejects(async () => { while (!(await reader.read()).done) {} }) +}) + + test('Desktop live subscription uses the selected session project directory', async t => { + const c = await setup(t, { runtime: 'desktop' }) + const paired = await c.pair() + const response = await fetch(c.origin + '/mobile/v1/session/ses_one/live', { headers: { authorization: `Bearer ${paired.token}` } }) + try { + assert.equal(response.status, 200) + assert.deepEqual(c.seen.map(item => item.url), ['/session/ses_one', '/event?directory=%2Fwork%2Ffixture+project']) + } finally { await response.body.cancel() } +}) + +test('Desktop live delivers compatibility-engine text before history refresh', async t => { + const c = await setup(t, { runtime: 'desktop' }) + const { token } = await c.pair() + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), 750) + let text = '' + try { + const response = await fetch(c.origin + '/mobile/v1/session/ses_one/live', { headers: { authorization: `Bearer ${token}` }, signal: controller.signal }) + for await (const chunk of response.body) { + text += Buffer.from(chunk).toString() + if (text.includes('Legacy live response')) break + } + } catch (error) { if (error.name !== 'AbortError') throw error } + finally { clearTimeout(timer); controller.abort() } + assert.ok(text.includes('Legacy live response'), 'Expected a live compatibility-engine text delta') +}) + +test('Desktop advertises live history recovery without a native replay journal', async t => { + const c = await setup(t, { runtime: 'desktop' }) + const paired = await c.pair() + const response = await fetch(c.origin + '/mobile/v1/health', { headers: { authorization: `Bearer ${paired.token}` } }) + const health = await response.json() + assert.equal(health.capabilities.replay, false) + assert.equal(health.capabilities.live, true) +}) + +test('Desktop accepts a real bodyless 204 async acknowledgement before delayed storage', async t => { + const c = await setup(t, { runtime: 'desktop', asyncAdmission: true }) + const { token } = await c.pair() + const response = await c.post('/api/session/ses_one/prompt', { id: 'msg_async', prompt: { text: 'One request' } }, token) + assert.equal(response.status, 200) + assert.equal(c.seen.filter(item => item.url.includes('/prompt_async')).length, 1) +}) + +test('public link requires an active pairing window and explicit approval; localhost headers never authorize', async t => { + const c = await setup(t) + const qr = c.gateway.issuePairing('https://pc.tunnel.bharatcode.ai') + const response = await c.post('/mobile/v1/claim', { desktopID: qr.desktopID, deviceName: 'Link phone' }) + assert.equal(response.status, 201) + const claim = await response.json() + assert.equal((await (await c.post('/mobile/v1/claim/status', claim)).json()).state, 'waiting') + for (const headers of [{host:'localhost'}, {'x-forwarded-for':'127.0.0.1','x-forwarded-host':'localhost','forwarded':'for=127.0.0.1;host=localhost'}, {authorization:'Bearer ' + 'x'.repeat(43)}]) { + assert.equal((await fetch(c.origin + '/api/session', {headers})).status, 401) + assert.equal((await fetch(c.origin + '/mobile/v1/health', {headers})).status, 401) + } + assert.equal(c.seen.length, 0) + await c.gateway.approve(claim.claimID) + const paired = await (await c.post('/mobile/v1/claim/status', claim)).json() + assert.equal((await fetch(c.origin + '/mobile/v1/health', {headers:{authorization:'Bearer '+paired.token}})).status, 200) + assert.equal((await c.post('/mobile/v1/claim', {desktopID:qr.desktopID,deviceName:'Late phone'})).status, 401) + await c.gateway.revoke(paired.device.id) + assert.equal((await fetch(c.origin + '/mobile/v1/health', {headers:{authorization:'Bearer '+paired.token}})).status, 401) +}) + +test('public link claims are bounded, expire, and a refreshed invitation invalidates older pending claims', async t => { + const c = await setup(t) + const qr = c.gateway.issuePairing('https://pc.tunnel.bharatcode.ai') + const body = {desktopID:qr.desktopID,deviceName:'Phone'} + assert.equal((await c.post('/mobile/v1/claim', {...body, desktopID:'pc_wrong'})).status, 401) + const first = await (await c.post('/mobile/v1/claim', body)).json() + for(let n=1;n<5;n++) assert.equal((await c.post('/mobile/v1/claim', body)).status, 201) + assert.equal((await c.post('/mobile/v1/claim', body)).status, 429) + c.gateway.issuePairing('https://pc.tunnel.bharatcode.ai') + await assert.rejects(c.gateway.approve(first.claimID)) + c.expire() + assert.equal((await c.post('/mobile/v1/claim', body)).status, 401) +}) diff --git a/packages/mobile-connector/test/live.test.mjs b/packages/mobile-connector/test/live.test.mjs new file mode 100644 index 000000000..306f10d86 --- /dev/null +++ b/packages/mobile-connector/test/live.test.mjs @@ -0,0 +1,61 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { projectEvents } from '../src/live.mjs' + +test('live stream selects the conversation and excludes credentials and unrelated activity', async () => { + async function* source() { + const events = [ + { id: 'evt_1', type: 'session.next.text.delta', data: { sessionID: 'ses_one', assistantMessageID: 'msg_one', textID: 'txt_one', delta: 'Hi' } }, + { id: 'evt_2', type: 'session.next.text.delta', data: { sessionID: 'ses_other', delta: 'private' } }, + { id: 'evt_3', type: 'provider.updated', data: { sessionID: 'ses_one', key: 'secret' } }, + { id: 'evt_4', type: 'permission.v2.asked', data: { sessionID: 'ses_one', id: 'per_one' } }, + ] + const bytes = Buffer.from(events.map(event => `data: ${JSON.stringify(event)}\r\n\r\n`).join('')) + yield bytes.subarray(0, 15); yield bytes.subarray(15) + } + const events = [] + for await (const event of projectEvents(source(), 'ses_one')) events.push(event) + assert.deepEqual(events.map(event => event.id), ['evt_1', 'evt_4']) + assert.equal(JSON.stringify(events).includes('secret'), false) +}) +test('live stream bounds unterminated frames', async () => { + async function* source() { yield Buffer.alloc(1_048_577, 97) } + await assert.rejects(async () => { for await (const _ of projectEvents(source(), 'ses_one')) {} }, /too large/) +}) +test('Desktop compatibility events become selected-chat text and request signals', async () => { + async function* source() { + for(const event of [ + {id:'evt_message',type:'message.updated',data:{sessionID:'ses_one',info:{id:'msg_one',role:'assistant'}}}, + {id:'evt_part',type:'message.part.updated',data:{sessionID:'ses_one',part:{id:'prt_one',messageID:'msg_one',type:'text',text:''}}}, + {id:'evt_1',type:'message.part.delta',data:{sessionID:'ses_one',messageID:'msg_one',partID:'prt_one',field:'text',delta:'Continued'}}, + {id:'evt_2',type:'permission.asked',data:{sessionID:'ses_one',id:'per_one',metadata:{private:'not a notification field'}}}, + {id:'evt_3',type:'message.part.delta',data:{sessionID:'ses_other',messageID:'msg_other',field:'text',delta:'Other chat'}}, + ]) yield Buffer.from(`data: ${JSON.stringify(event)}\n\n`) + } + const events=[];for await(const event of projectEvents(source(),'ses_one',{desktop:true}))events.push(event) + assert.deepEqual(events.map(e=>e.type),['session.next.tool.progress','session.next.text.started','session.next.text.delta','permission.v2.asked']) + assert.equal(events[2].data.assistantMessageID,'msg_one') + assert.equal(JSON.stringify(events).includes('private'),false) +}) + +test('attachment after text creation resolves unknown metadata once and streams deltas',async()=>{ + async function* source(){for(let i=0;i<2;i++)yield Buffer.from(`data: ${JSON.stringify({id:'evt_'+i,type:'message.part.delta',data:{sessionID:'ses_one',messageID:'msg_one',partID:'prt_one',field:'text',delta:'live'}})}\n\n`)} + let lookups=0;const events=[] + for await(const event of projectEvents(source(),'ses_one',{desktop:true,resolveMessage:async()=>{lookups++;return {info:{id:'msg_one',role:'assistant'},parts:[{id:'prt_one',type:'text'}]}}}))events.push(event) + assert.equal(lookups,1);assert.deepEqual(events.map(e=>e.type),['session.next.text.delta','session.next.text.delta']) +}) + +test('Desktop text creation starts a part and only the completed snapshot ends it', async () => { + async function* source() { + for (const event of [ + { type: 'message.updated', properties: { sessionID: 'ses_one', info: { id: 'msg_one', role: 'assistant' } } }, + { type: 'message.part.updated', properties: { sessionID: 'ses_one', part: { id: 'txt_one', messageID: 'msg_one', type: 'text', text: '', time: { start: 100 } } } }, + { type: 'message.part.delta', properties: { sessionID: 'ses_one', messageID: 'msg_one', partID: 'txt_one', field: 'text', delta: 'Live answer' } }, + { type: 'message.part.updated', properties: { sessionID: 'ses_one', part: { id: 'txt_one', messageID: 'msg_one', type: 'text', text: 'Live answer', time: { start: 100, end: 200 } } } }, + ]) yield Buffer.from(`data: ${JSON.stringify(event)}\n\n`) + } + const events = [] + for await (const event of projectEvents(source(), 'ses_one', { desktop: true })) events.push(event) + assert.deepEqual(events.map(event => event.type), ['session.next.tool.progress', 'session.next.text.started', 'session.next.text.delta', 'session.next.text.ended']) + assert.equal(events.at(-1).data.text, 'Live answer') +}) diff --git a/packages/mobile-connector/test/policy.test.mjs b/packages/mobile-connector/test/policy.test.mjs new file mode 100644 index 000000000..705b9a7c0 --- /dev/null +++ b/packages/mobile-connector/test/policy.test.mjs @@ -0,0 +1,21 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { allowed, tunnelOrigin } from '../src/policy.mjs' + +test('project directory query encoding is allowed without allowing encoded API path traversal', () => { + for (const directory of ['/work/fixture project', 'C:\\Projects\\Mobile']) { + const query = new URLSearchParams({ 'location[directory]': directory }) + assert.equal(allowed('GET', '/api/question/request?' + query), true) + assert.equal(allowed('GET', '/api/permission/request?' + query), true) + } + for (const path of ['/api/session/ses_one%2Fmessage', '/api/session/%2e%2e/model', '/api/session/ses_one%5cmessage']) { + assert.equal(allowed('GET', path), false) + } + assert.equal(allowed('GET', '/api/question/request?location%5Bdirectory%5D=%00'), false) + assert.equal(allowed('GET', '/api/question/request?unexpected=%2Fwork'), false) +}) + +test('only well-formed self-hosted HTTPS tenant origins are accepted', () => { + assert.equal(tunnelOrigin('https://pc.tunnel.bharatcode.ai:443/'), 'https://pc.tunnel.bharatcode.ai') + for (const value of ['https://pc.opentunnel.xyz', 'https://tunnel.bharatcode.ai', 'https://pc..tunnel.bharatcode.ai', 'https://-pc.tunnel.bharatcode.ai', 'https://pc.tunnel.bharatcode.ai/../', 'https://pc.tunnel.bharatcode.ai#', ' https://pc.tunnel.bharatcode.ai', 'https://pc.tunnel.bharatcode.ai\\@evil.com']) assert.throws(() => tunnelOrigin(value), value) +}) diff --git a/packages/mobile-connector/test/recovery.test.mjs b/packages/mobile-connector/test/recovery.test.mjs new file mode 100644 index 000000000..7a0595858 --- /dev/null +++ b/packages/mobile-connector/test/recovery.test.mjs @@ -0,0 +1,98 @@ +import test from "node:test" +import assert from "node:assert/strict" +import { createServer } from "node:http" +import { once } from "node:events" +import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { createMobileAccess } from "../src/desktop-access.mjs" + +test("corrupt journal recovery pairs a phone and safely handles real HTTP prompts through restart", async t => { + const directory = await mkdtemp(join(tmpdir(), "bc-recovery-http-")) + t.after(() => rm(directory, { recursive: true, force: true })) + const root = join(directory, "mobile-access") + await mkdir(root) + await writeFile(join(root, "admissions.json"), '{"admissions":') + let fresh, forwarded = 0, target + const messages = new Map() + const sidecar = createServer(async (req, res) => { + assert.equal(req.headers.authorization, "Basic " + Buffer.from("opencode:private").toString("base64")) + res.setHeader("content-type", "application/json") + const path = new URL(req.url, "http://localhost").pathname + if (path === "/api/session" && req.method === "POST") { + let raw = "" + for await (const chunk of req) raw += chunk + fresh = JSON.parse(raw).id + res.end(JSON.stringify({ data: { id: fresh } })) + return + } + if (path === "/session/ses_old" || path === "/session/" + fresh) { + res.end(JSON.stringify({ directory: "/fixture", time: { created: Date.now() + 3_600_000 } })) + return + } + if (path.endsWith("/prompt_async")) { + let raw = "" + for await (const chunk of req) raw += chunk + const body = JSON.parse(raw) + forwarded++ + messages.set(path.split("/")[2] + "." + body.messageID, body.parts) + res.writeHead(204).end() + return + } + const match = /^\/session\/(ses_\w+)\/message\/(msg_\w+)$/.exec(path) + const parts = match && messages.get(match[1] + "." + match[2]) + if (parts) res.end(JSON.stringify({ info: { id: match[2], role: "user" }, parts })) + else res.writeHead(404).end("{}") + }) + sidecar.listen(0, "127.0.0.1") + await once(sidecar, "listening") + t.after(() => { sidecar.closeAllConnections(); return new Promise(resolve => sidecar.close(resolve)) }) + const options = { + userDataPath: directory, + getConnection: () => ({ url: `http://127.0.0.1:${sidecar.address().port}`, password: "private" }), + removeTunnel: async () => {}, + tunnelFactory: async options => { + target = options.target + return { origin: "https://pc.tunnel.bharatcode.ai", events: (async function* () {})(), close: async () => {} } + }, + } + let access = await createMobileAccess(options) + t.after(() => access.close()) + await assert.rejects(access.action({ type: "pair" })) + await access.action({ type: "reset" }) + const pair = async () => { + await access.action({ type: "pair" }) + const link = new URL(access.snapshot().pairing) + const post = (path, body) => fetch(target + path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }) + const claim = await (await post("/mobile/v1/claim", { desktopID: link.searchParams.get("desktopID"), deviceName: "Fixture phone" })).json() + await access.action({ type: "approve", id: claim.claimID }) + return (await (await post("/mobile/v1/claim/status", claim)).json()).token + } + let token = await pair() + const send = (session, text = "Only once") => fetch(target + `/api/session/${session}/prompt`, { + method: "POST", headers: { authorization: "Bearer " + token, "content-type": "application/json" }, + body: JSON.stringify({ id: "msg_one", prompt: { text } }), + }) + const blocked = await send("ses_old") + assert.equal(blocked.status, 409) + assert.match(await blocked.text(), /recovered.*new conversation/) + assert.equal(forwarded, 0) + const created = await fetch(target + "/api/session", { + method: "POST", headers: { authorization: "Bearer " + token, "content-type": "application/json" }, + body: JSON.stringify({ location: { directory: "/fixture" } }), + }) + assert.equal(created.status, 200) + fresh = (await created.json()).data.id + const accepted = await send(fresh) + assert.equal(accepted.status, 200, await accepted.text()) + assert.equal(forwarded, 1) + // Reset must not forget the healthy admission, even after restarting access. + await access.action({ type: "reset" }) + await access.close() + access = await createMobileAccess(options) + token = await pair() + assert.equal((await send(fresh)).status, 200) + assert.equal((await send(fresh, "Changed prompt")).status, 409) + assert.equal((await send("ses_old")).status, 409) + assert.equal(forwarded, 1) +}) diff --git a/packages/mobile-connector/test/tunnel.test.mjs b/packages/mobile-connector/test/tunnel.test.mjs new file mode 100644 index 000000000..c547fe3ef --- /dev/null +++ b/packages/mobile-connector/test/tunnel.test.mjs @@ -0,0 +1,36 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { mkdtemp, rm, stat } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Effect } from 'effect' +import { OpenTunnelStorage } from '@opentunnel/client/effect' +import { removeTunnel, connectTunnel } from '../src/tunnel.mjs' + +test('reset removes a pending identity through the explicit API without waiting for a certificate', {timeout:2000}, async t => { + const root = await mkdtemp(join(tmpdir(),'bc-tunnel-')) + t.after(()=>rm(root,{recursive:true,force:true})) + const storage=OpenTunnelStorage.xdg({env:{XDG_DATA_HOME:root}}) + await Effect.runPromise(storage.savePending('bharatcode-mobile',{id:'tun_pending',hostname:'pc.tunnel.bharatcode.ai',token:'pending-private-token',privateKey:'pending-private-key',csr:'pending-csr'})) + const requests=[] + // This is the SDK's external fetch boundary; never mock its storage/provisioning logic. + t.mock.method(globalThis,'fetch',async (input, init)=>{ + const request = input instanceof Request ? input : new Request(input, init) + requests.push({url:request.url,method:request.method,authorization:request.headers.get('authorization')}) + if(request.method==='DELETE') return new Response(null,{status:204}) + return new Response(JSON.stringify({id:'tun_pending',state:{type:'pending'}}),{headers:{'content-type':'application/json'}}) + }) + await removeTunnel({storePath:root}) + assert.deepEqual(requests,[{url:'https://tunnel.bharatcode.ai/api/tunnel/tun_pending',method:'DELETE',authorization:'Bearer pending-private-token'}]) + assert.equal(await Effect.runPromise(storage.loadPending('bharatcode-mobile')),undefined) + assert.equal((await stat(root)).mode & 0o777,0o700) +}) + +test('invalid targets and already-cancelled connections never create a tunnel', async t => { + const root=await mkdtemp(join(tmpdir(),'bc-tunnel-')) + t.after(()=>rm(root,{recursive:true,force:true})) + const intercepted=t.mock.method(globalThis,'fetch',()=>{throw new Error('must not contact API')}) + for(const target of ['http://example.com:1234','http://user:pass@127.0.0.1:1234','http://127.0.0.1:1234/path']) await assert.rejects(connectTunnel({target,storePath:root})) + await assert.rejects(connectTunnel({target:'http://127.0.0.1:1234',storePath:root,signal:AbortSignal.abort()})) + assert.equal(intercepted.mock.callCount(),0) +}) diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index 4c2ff14a2..d3a85ace2 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -2,7 +2,8 @@ import { PermissionV1 } from "@opencode-ai/core/v1/permission" import { afterEach, describe, expect } from "bun:test" import { NodeHttpServer, NodeServices } from "@effect/platform-node" import { SessionV1 } from "@opencode-ai/core/v1/session" -import { mkdir } from "node:fs/promises" +import { mkdir, writeFile } from "node:fs/promises" +import { pathToFileURL } from "node:url" import path from "node:path" import { Cause, Config, Effect, Exit, Layer } from "effect" import { HttpClient, HttpClientRequest, HttpClientResponse, HttpRouter, HttpServer } from "effect/unstable/http" @@ -236,6 +237,102 @@ afterEach(async () => { }) describe("session HttpApi", () => { + it.live("mobile admission recovery uses real Desktop sessions and never duplicates a stored prompt", () => + Effect.gen(function* () { + const llm = yield* TestLLMServer + yield* llm.text("original response", { usage: { input: 1, output: 1 } }) + yield* llm.text("recovered response", { usage: { input: 1, output: 1 } }) + const directory = yield* tmpdirScoped({ git: true, config: testProviderConfig(llm.url) }) + const server = yield* HttpServer.HttpServer + const origin = HttpServer.formatAddress(server.address) + // Load the exact mirrored connector modules without introducing a runtime + // dependency from opencode to the optional Desktop-only connector package. + const connectorPath = path.resolve(import.meta.dir, "../../../mobile-connector/src") + const { createDesktopAPI } = yield* Effect.promise( + () => import(pathToFileURL(path.join(connectorPath, "desktop-api.mjs")).href), + ) + const { resetAdmissions } = yield* Effect.promise( + () => import(pathToFileURL(path.join(connectorPath, "admissions.mjs")).href), + ) + const storePath = path.join(directory, "admissions.json") + let forwarded = 0 + const upstream = async (route: string, method = "GET", body?: unknown, signal?: AbortSignal) => { + if (method === "POST" && new URL(route, origin).pathname.endsWith("/prompt_async")) forwarded++ + const response = await fetch(origin + route, { + method, + signal, + headers: { "x-opencode-directory": directory, "content-type": "application/json" }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }) + if (!response.ok) throw Object.assign(new Error(await response.text()), { status: response.status }) + const text = await response.text() + return { data: text ? JSON.parse(text) : undefined, cursor: response.headers.get("x-next-cursor") ?? undefined } + } + const old = yield* createSession({ title: "before mobile recovery" }).pipe(provideInstanceEffect(directory)) + type RecoveryAPI = { + handle(method: string, route: string, body?: unknown): Promise<{ data: { data?: { id?: string } | unknown[] } }> + } + const open = () => Effect.promise(() => createDesktopAPI({ request: upstream, storePath })) + let api = yield* open() + const prompt = { id: "msg_mobile_original", prompt: { text: "Answer once" } } + yield* Effect.promise(() => + api.handle("POST", `/api/session/${old.id}/model`, { model: { id: "test-model", providerID: "test" } }), + ) + yield* Effect.promise(() => api.handle("POST", `/api/session/${old.id}/prompt`, prompt)) + const completed = (id: string, text: string) => + pollWithTimeout( + Effect.promise(() => api.handle("GET", `/api/session/${id}/message`)).pipe( + Effect.map((result) => (JSON.stringify(result).includes(text) ? true : undefined)), + ), + "Desktop response not projected to Mobile", + "10 seconds", + ) + yield* completed(old.id, "original response") + expect(yield* llm.calls).toBe(1) + expect(forwarded).toBe(1) + yield* Effect.promise(() => writeFile(storePath, '{"admissions":')) + const damaged = yield* open().pipe(Effect.exit) + expect(Exit.isFailure(damaged)).toBe(true) + yield* Effect.promise(() => resetAdmissions(storePath)) + api = yield* open() + yield* Effect.promise(() => api.handle("POST", `/api/session/${old.id}/prompt`, prompt)) + expect(yield* llm.calls).toBe(1) + expect(forwarded).toBe(1) + const unknown = yield* Effect.promise(() => + api.handle("POST", `/api/session/${old.id}/prompt`, { + id: "msg_mobile_unknown", + prompt: { text: "Do not guess delivery" }, + }), + ).pipe(Effect.exit) + expect(Exit.isFailure(unknown)).toBe(true) + if (Exit.isFailure(unknown)) expect(Cause.squash(unknown.cause)).toMatchObject({ status: 409 }) + expect(forwarded).toBe(1) + const fresh = yield* Effect.promise(() => api.handle("POST", "/api/session", { location: { directory } })) + if (!fresh.data.data || Array.isArray(fresh.data.data) || typeof fresh.data.data.id !== "string") + throw new Error("Desktop did not return a fresh session identity") + const id = fresh.data.data.id + expect(id).toMatch(/^ses_[a-f0-9]{32}$/) + // Give the conversation an explicit title so automatic title generation + // cannot consume the model reply intended for the actual user prompt. + yield* Effect.promise(() => + upstream(`/session/${id}?${new URLSearchParams({ directory })}`, "PATCH", { title: "after mobile recovery" }), + ) + yield* Effect.promise(() => + api.handle("POST", `/api/session/${id}/model`, { model: { id: "test-model", providerID: "test" } }), + ) + const newPrompt = { id: "msg_mobile_recovered", prompt: { text: "Answer after recovery" } } + yield* Effect.promise(() => api.handle("POST", `/api/session/${id}/prompt`, newPrompt)) + yield* completed(id, "recovered response") + expect(yield* llm.calls).toBe(2) + expect(forwarded).toBe(2) + yield* Effect.promise(() => resetAdmissions(storePath)) + api = yield* open() + yield* Effect.promise(() => api.handle("POST", `/api/session/${id}/prompt`, newPrompt)) + expect(yield* llm.calls).toBe(2) + expect(forwarded).toBe(2) + }).pipe(Effect.provide(TestLLMServer.layer), Effect.provide(AppNodeBuilder.build(CrossSpawnSpawner.node))), + ) + it.instance( "persists goal pause and clear and pauses active goals on abort", () =>