diff --git a/.gitignore b/.gitignore index 5fb0dfa..c00db59 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ # Node node_modules +packages/*/dist package-lock.json bun.lockb diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..27e0686 --- /dev/null +++ b/bun.lock @@ -0,0 +1,150 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "@cortexkit/claustrum", + "devDependencies": { + "@types/bun": "1.3.14", + "typescript": "7.0.2", + }, + }, + "packages/client": { + "name": "@cortexkit/claustrum-client", + "version": "0.1.0", + "dependencies": { + "@cortexkit/subc-client": "^0.8.1", + }, + }, + "packages/opencode": { + "name": "@cortexkit/opencode-claustrum", + "dependencies": { + "@cortexkit/claustrum-client": "workspace:*", + }, + "devDependencies": { + "@opencode-ai/plugin": "1.18.25", + }, + }, + }, + "packages": { + "@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="], + + "@cortexkit/claustrum-client": ["@cortexkit/claustrum-client@workspace:packages/client"], + + "@cortexkit/opencode-claustrum": ["@cortexkit/opencode-claustrum@workspace:packages/opencode"], + + "@cortexkit/subc-client": ["@cortexkit/subc-client@0.8.1", "", {}, "sha512-8U9w3AnSff0QYlLVzcKOuTULakRtVpcGJrquGHxOQQlWZGzXZdCs8nroLMeoM2xhFGwxIh8xFk832w1KG6akAA=="], + + "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="], + + "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w=="], + + "@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4", "", { "os": "linux", "cpu": "arm" }, "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw=="], + + "@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw=="], + + "@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ=="], + + "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="], + + "@opencode-ai/plugin": ["@opencode-ai/plugin@1.18.25", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode-ai/sdk": "1.18.25", "effect": "4.0.0-beta.83", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.4.5", "@opentui/keymap": ">=0.4.5", "@opentui/solid": ">=0.4.5" }, "optionalPeers": ["@opentui/core", "@opentui/keymap", "@opentui/solid"] }, "sha512-Kb34zFqYosFNiMd1IuYiZGjX17z+18Srm7tHZMCz+uMVRTYNkEw1FTrfAK2FLbggwYdgzifGwKMNF1slLT8eLw=="], + + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.18.25", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-GwgwhW+vE8FWSDw730SjzqNhsWXB0uJjbFOiqFkmM+USFuG13HuTlGe6SR2ixt+WXxoD6FV1hILWqsXyqej9hQ=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + + "@types/node": ["@types/node@26.4.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA=="], + + "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], + + "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="], + + "@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="], + + "@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="], + + "@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="], + + "@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="], + + "@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="], + + "@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="], + + "@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="], + + "@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="], + + "@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="], + + "@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="], + + "@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="], + + "@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="], + + "@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="], + + "@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="], + + "@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="], + + "@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="], + + "@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="], + + "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], + + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "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=="], + + "fast-check": ["fast-check@4.9.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg=="], + + "find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="], + + "ini": ["ini@7.0.0", "", {}, "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], + + "kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="], + + "msgpackr": ["msgpackr@2.1.0", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-p/pBCVO63CsvvpkomUnNNag6+n38rULuDA6HHe70o2gtC8ODI52foF/4ko2qQcp6OiErJXTmrZeXmsGGHsIQNQ=="], + + "msgpackr-extract": ["msgpackr-extract@3.0.4", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw=="], + + "multipasta": ["multipasta@0.2.8", "", {}, "sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q=="], + + "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "pure-rand": ["pure-rand@8.4.2", "", {}, "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "toml": ["toml@4.3.0", "", {}, "sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A=="], + + "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], + + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + + "uuid": ["uuid@14.0.2", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + + "zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="], + } +} diff --git a/crates/credentials-core/src/oauth.rs b/crates/credentials-core/src/oauth.rs index f18ecef..fc0a1ca 100644 --- a/crates/credentials-core/src/oauth.rs +++ b/crates/credentials-core/src/oauth.rs @@ -14,6 +14,12 @@ use serde::{Deserialize, Serialize}; +pub const CUSTODY_TOMBSTONE_PREFIX: &str = "claustrum-tombstone:v1:"; + +fn is_custody_tombstone(value: &str) -> bool { + value.starts_with(CUSTODY_TOMBSTONE_PREFIX) +} + /// A canonical OAuth credential: the provider-agnostic fields a refresh exchange /// needs, plus the current tokens. Importers map each source format into this; /// refresh adapters read and update it. @@ -162,6 +168,9 @@ impl OAuthCredential { .filter(|s| !s.is_empty()) .ok_or(ImportError::MissingField("refresh"))?; let access_token = entry.access.unwrap_or_default(); + if is_custody_tombstone(&refresh_token) || is_custody_tombstone(&access_token) { + return Err(ImportError::CustodyTombstone); + } Ok(OAuthCredential { access_token, refresh_token, @@ -320,6 +329,9 @@ pub fn import_api_key( .and_then(|k| k.as_str()) .filter(|s| !s.is_empty()) .ok_or(ImportError::MissingField("key"))?; + if is_custody_tombstone(key) { + return Err(ImportError::CustodyTombstone); + } Ok(key.as_bytes().to_vec()) } other => Err(ImportError::UnknownSource(other.to_string())), @@ -337,6 +349,8 @@ pub enum ImportError { MissingField(&'static str), /// The requested provider key was not present in a multi-provider auth file. ProviderNotFound(String), + /// Claustrum's tombstone is an ownership marker, never importable credential material. + CustodyTombstone, } impl std::fmt::Display for ImportError { @@ -350,6 +364,10 @@ impl std::fmt::Display for ImportError { ImportError::ProviderNotFound(p) => { write!(f, "provider '{p}' not found in auth file") } + ImportError::CustodyTombstone => write!( + f, + "refusing Claustrum tombstone material; run ck auth migrate-opencode or ck auth migrate-opencode --restore" + ), } } } @@ -465,6 +483,19 @@ mod tests { )); } + #[test] + fn import_refuses_claustrum_tombstone_material() { + let api = br#"{"deepseek":{"type":"api","key":"claustrum-tombstone:v1:deepseek"}}"#; + let error = + import_api_key("opencode", api, "deepseek").expect_err("tombstone api key must refuse"); + assert!(error.to_string().contains("migrate-opencode")); + + let oauth = br#"{"anthropic":{"type":"oauth","refresh":"claustrum-tombstone:v1:anthropic","access":"claustrum-tombstone:v1:anthropic","expires":0}}"#; + let error = OAuthCredential::import_provider("opencode", oauth, "anthropic") + .expect_err("tombstone oauth tokens must refuse"); + assert!(error.to_string().contains("migrate-opencode")); + } + #[test] fn imports_antigravity_accounts_store_and_packs_managed_project() { // The version:4 accounts-array store the antigravity plugin writes. diff --git a/crates/credentials-module/src/bin/cli_support/admin_client.rs b/crates/credentials-module/src/bin/cli_support/admin_client.rs index daed559..3fc76d8 100644 --- a/crates/credentials-module/src/bin/cli_support/admin_client.rs +++ b/crates/credentials-module/src/bin/cli_support/admin_client.rs @@ -16,19 +16,16 @@ //! never silently retries or falls back after dispatch; it returns a distinct error //! the CLI surfaces as "verify with list/verify-audit before retrying". -use std::time::Duration; - use credentials_core::admin_auth::{AdminMacKey, TranscriptParts, ADMIN_NONCE_LEN, VAULT_ID_LEN}; use credentials_core::admin_ops::AdminOpBody; use credentials_core::resolver::{self, ResolverConfig}; -use credentials_core::{vault_id_for, MODULE_ID}; +use credentials_core::vault_id_for; use serde_json::{json, Value}; -use subc_protocol::{BindIdentity, Flags, Frame, FrameType, Priority, RouteTarget}; -use subc_transport::{authenticate_client, connection_file, read_frame, write_frame}; +use subc_protocol::FrameType; +use subc_transport::write_frame; use tokio::net::TcpStream; -const CONNECT_TIMEOUT: Duration = Duration::from_secs(3); -const RPC_TIMEOUT: Duration = Duration::from_secs(15); +use crate::route_client; /// The outcome of attempting a route-plane commit. pub enum RouteCommit { @@ -56,10 +53,6 @@ pub fn commit( conn_path: &std::path::Path, op: &AdminOpBody, ) -> RouteCommit { - let conn = match connection_file::read(conn_path) { - Ok(c) => c, - Err(e) => return RouteCommit::NoLiveModule(format!("no subc connection file: {e}")), - }; let vault_id = match vault_id_for(data_dir) { Some(v) => v, None => return RouteCommit::NoLiveModule("cannot derive vault id".into()), @@ -69,7 +62,7 @@ pub fn commit( Err(e) => return RouteCommit::Refused(format!("encoding op: {e}")), }; - run_async(async move { commit_async(&conn, &vault_id, config, &op_bytes).await }) + run_async(async move { commit_async(conn_path, &vault_id, config, &op_bytes).await }) } fn run_async>(fut: F) -> RouteCommit { @@ -84,30 +77,18 @@ fn run_async>(fut: F) -> RouteCommi } async fn commit_async( - conn: &connection_file::ConnectionInfo, + conn_path: &std::path::Path, vault_id: &[u8; VAULT_ID_LEN], config: &ResolverConfig, op_bytes: &[u8], ) -> RouteCommit { - let Some(endpoint) = conn.endpoints.first() else { - return RouteCommit::NoLiveModule("connection file has no endpoint".into()); - }; - let mut stream = match tokio::time::timeout( - CONNECT_TIMEOUT, - TcpStream::connect((endpoint.host.as_str(), endpoint.port)), - ) - .await - { - Ok(Ok(s)) => s, - Ok(Err(e)) => return RouteCommit::NoLiveModule(format!("connect: {e}")), - Err(_) => return RouteCommit::NoLiveModule("connect timed out".into()), + let mut stream = match route_client::connect(conn_path).await { + Ok(stream) => stream, + Err(e) => return RouteCommit::NoLiveModule(e), }; - if let Err(e) = authenticate_client(&mut stream, conn, CONNECT_TIMEOUT).await { - return RouteCommit::NoLiveModule(format!("client handshake: {e}")); - } // The vault module must be catalog-live; otherwise there is no module to admin. - match catalog_has_vault(&mut stream).await { + match route_client::catalog_has_module(&mut stream).await { Ok(true) => {} Ok(false) => { return RouteCommit::NoLiveModule("vault module not in catalog".into()); @@ -117,10 +98,13 @@ async fn commit_async( // Wire v2: route identity is (channel, epoch); every route frame must carry // the epoch the route was opened under, or the daemon's relay drops it. - let (route_channel, route_epoch) = match route_open(&mut stream, &config.data_dir).await { - Ok(pair) => pair, + let route = match route_client::open_route(stream, &config.data_dir, "ck-auth", "admin").await { + Ok(route) => route, Err(e) => return RouteCommit::NoLiveModule(format!("route.open: {e}")), }; + let route_channel = route.channel; + let route_epoch = route.epoch; + let mut stream = route.stream; // admin.challenge: fetch a nonce + the module's key_id (so we resolve the SAME // key) + its vault_id (so we confirm we are talking to the intended vault). @@ -199,7 +183,7 @@ async fn challenge( route_channel: u16, route_epoch: u32, ) -> Result<([u8; ADMIN_NONCE_LEN], String, String), RpcFail> { - let frame = route_request( + let frame = route_client::route_request( route_channel, route_epoch, 100, @@ -208,11 +192,11 @@ async fn challenge( if let Err(e) = write_frame(stream, &frame).await { return Err(RpcFail::Transport(format!("write admin.challenge: {e}"))); } - let resp = read_route_response(stream, 100) + let resp = route_client::read_route_response(stream, 100) .await .map_err(RpcFail::Transport)?; if resp.header.ty == FrameType::Error { - return Err(RpcFail::Refused(error_reason(&resp.body))); + return Err(RpcFail::Refused(route_client::error_reason(&resp.body))); } let value: Value = serde_json::from_slice(&resp.body) .map_err(|e| RpcFail::Transport(format!("decode challenge: {e}")))?; @@ -248,7 +232,7 @@ async fn admin_op( Ok(s) => s.to_string(), Err(_) => return RouteCommit::Refused("op body is not valid utf-8".into()), }; - let frame = route_request( + let frame = route_client::route_request( route_channel, route_epoch, 101, @@ -261,9 +245,9 @@ async fn admin_op( // Failed BEFORE the bytes left us: safe to treat as not-dispatched. return RouteCommit::NoLiveModule(format!("write admin.op: {e}")); } - match read_route_response(stream, 101).await { + match route_client::read_route_response(stream, 101).await { Ok(resp) if resp.header.ty == FrameType::Error => { - RouteCommit::Refused(error_reason(&resp.body)) + RouteCommit::Refused(route_client::error_reason(&resp.body)) } Ok(resp) => match serde_json::from_slice::(&resp.body) { Ok(v) => RouteCommit::Committed(v["result"].clone()), @@ -278,132 +262,6 @@ async fn admin_op( } } -async fn catalog_has_vault(stream: &mut TcpStream) -> Result { - let frame = control_request(1, json!({ "op": "catalog.list" })); - write_frame(stream, &frame) - .await - .map_err(|e| format!("write catalog.list: {e}"))?; - let resp = read_control_response(stream, 1).await?; - let value: Value = serde_json::from_slice(&resp.body).map_err(|e| e.to_string())?; - Ok(value["modules"] - .as_array() - .map(|ms| ms.iter().any(|m| m["module_id"] == MODULE_ID)) - .unwrap_or(false)) -} - -async fn route_open(stream: &mut TcpStream, root: &std::path::Path) -> Result<(u16, u32), String> { - let target = RouteTarget::ManagementSurface { - module_id: MODULE_ID.to_string(), - }; - let identity = BindIdentity { - project_root: root.to_path_buf(), - harness: "ck-auth".to_string(), - session: "admin".to_string(), - }; - let frame = control_request( - 2, - json!({ "op": "route.open", "target": target, "identity": identity }), - ); - write_frame(stream, &frame) - .await - .map_err(|e| format!("write route.open: {e}"))?; - let resp = read_control_response(stream, 2).await?; - if resp.header.ty == FrameType::Error { - return Err(error_reason(&resp.body)); - } - let value: Value = serde_json::from_slice(&resp.body).map_err(|e| e.to_string())?; - let channel = value["route_channel"] - .as_u64() - .map(|c| c as u16) - .ok_or_else(|| "route.open returned no route_channel".to_string())?; - // Wire v2: the daemon names the binding's epoch alongside the channel. - let epoch = value["route_epoch"] - .as_u64() - .map(|e| e as u32) - .ok_or_else(|| "route.open returned no route_epoch".to_string())?; - Ok((channel, epoch)) -} - -fn control_request(corr: u64, body: Value) -> Frame { - // Channel-0 control frames carry the reserved epoch 0 (wire v2 §3.1). - Frame::build( - FrameType::Request, - Flags::new(false, Priority::Passive, false), - 0, - 0, - corr, - serde_json::to_vec(&body).unwrap(), - ) - .unwrap() -} - -fn route_request(channel: u16, epoch: u32, corr: u64, body: Value) -> Frame { - Frame::build( - FrameType::Request, - Flags::new(false, Priority::Interactive, false), - channel, - epoch, - corr, - serde_json::to_vec(&body).unwrap(), - ) - .unwrap() -} - -async fn read_control_response(stream: &mut TcpStream, corr: u64) -> Result { - read_matching(stream, 0, corr).await -} - -async fn read_route_response(stream: &mut TcpStream, corr: u64) -> Result { - // Route responses arrive on the route channel; match by corr only (the channel - // is whatever route.open returned). - tokio::time::timeout(RPC_TIMEOUT, async { - loop { - let frame = read_frame(stream) - .await - .map_err(|e| format!("read: {e}"))? - .ok_or_else(|| "connection closed".to_string())?; - if frame.header.corr == corr - && matches!(frame.header.ty, FrameType::Response | FrameType::Error) - { - return Ok(frame); - } - } - }) - .await - .map_err(|_| "response timed out".to_string())? -} - -async fn read_matching(stream: &mut TcpStream, channel: u16, corr: u64) -> Result { - tokio::time::timeout(RPC_TIMEOUT, async { - loop { - let frame = read_frame(stream) - .await - .map_err(|e| format!("read: {e}"))? - .ok_or_else(|| "connection closed".to_string())?; - if frame.header.channel == channel - && frame.header.corr == corr - && matches!(frame.header.ty, FrameType::Response | FrameType::Error) - { - return Ok(frame); - } - } - }) - .await - .map_err(|_| "response timed out".to_string())? -} - -fn error_reason(body: &[u8]) -> String { - serde_json::from_slice::(body) - .ok() - .and_then(|v| { - v.get("message") - .or_else(|| v.get("detail")) - .and_then(|m| m.as_str()) - .map(String::from) - }) - .unwrap_or_else(|| "module refused the op".to_string()) -} - fn hex(bytes: &[u8]) -> String { use std::fmt::Write; let mut s = String::with_capacity(bytes.len() * 2); diff --git a/crates/credentials-module/src/bin/cli_support/credential_client.rs b/crates/credentials-module/src/bin/cli_support/credential_client.rs new file mode 100644 index 0000000..dced6f7 --- /dev/null +++ b/crates/credentials-module/src/bin/cli_support/credential_client.rs @@ -0,0 +1,155 @@ +use std::{fmt, path::Path}; + +use serde_json::{json, Value}; +use subc_protocol::FrameType; +use subc_transport::write_frame; + +use crate::route_client; + +pub struct ServedCredential { + pub payload: Vec, + pub record_version: u64, + pub expires_at_ms: Option, +} + +impl fmt::Debug for ServedCredential { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ServedCredential") + .field( + "payload", + &format_args!("<{} bytes redacted>", self.payload.len()), + ) + .field("record_version", &self.record_version) + .field("expires_at_ms", &self.expires_at_ms) + .finish() + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CredentialReadError { + NeedsReauth, + NotFound, + RefreshUnsupported, + RefreshFailed, + VaultLocked, + Corrupt, + TtlUnsatisfiable, + Refused, + Transport(String), +} + +impl fmt::Display for CredentialReadError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let message = match self { + Self::NeedsReauth => "credential needs reauthentication", + Self::NotFound => "credential capability was not found", + Self::RefreshUnsupported => "credential refresh is unsupported", + Self::RefreshFailed => "credential refresh failed", + Self::VaultLocked => "credential vault is unavailable", + Self::Corrupt => "credential record is corrupt", + Self::TtlUnsatisfiable => "credential cannot meet the requested lifetime", + Self::Refused => "credential read was refused", + Self::Transport(cause) => return write!(f, "credential route is unavailable: {cause}"), + }; + f.write_str(message) + } +} + +impl std::error::Error for CredentialReadError {} + +pub fn get_online( + connection_file: &Path, + project_root: &Path, + handle: &str, +) -> Result { + std::env::remove_var("SUBC_MODULE_ID"); + std::env::remove_var("SUBC_LAUNCH_NONCE"); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| CredentialReadError::Transport(format!("runtime: {e}")))?; + runtime.block_on(get_online_async(connection_file, project_root, handle)) +} + +async fn get_online_async( + connection_file: &Path, + project_root: &Path, + handle: &str, +) -> Result { + let stream = route_client::connect(connection_file) + .await + .map_err(|e| CredentialReadError::Transport(format!("connect: {e}")))?; + let mut route = route_client::open_route(stream, project_root, "ck-auth", "opencode-read") + .await + .map_err(|e| CredentialReadError::Transport(format!("route.open: {e}")))?; + let frame = route_client::route_request( + route.channel, + route.epoch, + 10, + json!({ + "method": "credential.get", + "params": { "handle": handle, "force_refresh": false, "min_ttl_ms": 0 }, + }), + ); + write_frame(&mut route.stream, &frame) + .await + .map_err(|e| CredentialReadError::Transport(format!("write credential.get: {e}")))?; + let response = route_client::read_route_response(&mut route.stream, 10) + .await + .map_err(|e| CredentialReadError::Transport(format!("read credential.get: {e}")))?; + if response.header.ty == FrameType::Error { + return Err(CredentialReadError::Refused); + } + decode_response(&response.body) +} + +fn decode_response(body: &[u8]) -> Result { + let value: Value = serde_json::from_slice(body) + .map_err(|e| CredentialReadError::Transport(format!("decode response: {e}")))?; + let result = value + .get("result") + .ok_or_else(|| CredentialReadError::Transport("response omitted result".into()))?; + if let Some(error) = result.get("error") { + return Err(map_error(error)); + } + let payload = result + .get("payload") + .and_then(Value::as_array) + .ok_or_else(|| CredentialReadError::Transport("response omitted payload".into()))? + .iter() + .map(|byte| byte.as_u64().and_then(|byte| u8::try_from(byte).ok())) + .collect::>>() + .ok_or_else(|| CredentialReadError::Transport("response payload was invalid".into()))?; + let record_version = result + .get("record_version") + .and_then(Value::as_u64) + .ok_or_else(|| CredentialReadError::Transport("response omitted record version".into()))?; + let expires_at_ms = + match result.get("expires_at_ms") { + None | Some(Value::Null) => None, + Some(value) => Some(value.as_i64().ok_or_else(|| { + CredentialReadError::Transport("response expiry was invalid".into()) + })?), + }; + Ok(ServedCredential { + payload, + record_version, + expires_at_ms, + }) +} + +fn map_error(error: &Value) -> CredentialReadError { + match ( + error.get("class").and_then(Value::as_str), + error.get("code").and_then(Value::as_str), + ) { + (Some("auth_required"), Some("needs_reauth")) => CredentialReadError::NeedsReauth, + (_, Some("not_found")) => CredentialReadError::NotFound, + (_, Some("refresh_unsupported")) => CredentialReadError::RefreshUnsupported, + (_, Some("refresh_failed")) => CredentialReadError::RefreshFailed, + (_, Some("vault_locked")) => CredentialReadError::VaultLocked, + (_, Some("corrupt")) => CredentialReadError::Corrupt, + (_, Some("ttl_unsatisfiable")) => CredentialReadError::TtlUnsatisfiable, + _ => CredentialReadError::Refused, + } +} diff --git a/crates/credentials-module/src/bin/cli_support/opencode-provider-shapes.json b/crates/credentials-module/src/bin/cli_support/opencode-provider-shapes.json new file mode 100644 index 0000000..8b03110 --- /dev/null +++ b/crates/credentials-module/src/bin/cli_support/opencode-provider-shapes.json @@ -0,0 +1,41 @@ +{ + "version": 1, + "derived_from": { + "repo": "anomalyco/opencode", + "commit": "dc4449df0d", + "derivation": "grep `auth?.type === \"api\"` across provider/provider.ts and plugin/, attribute by walking the custom() map keys backwards from each hit, cross-reference the oauth-gate list", + "scope": "providers consuming a type:\"api\" key outside options.fetch in provider/provider.ts and plugin/ as of the named commit; a consumer elsewhere in the tree would be missed — this is the method's edge, not a proof over all of opencode", + "maintained_by": "OpenCode Source seat, delta per base update" + }, + "shape_definitions": { + "api-env": { + "why": "OpenCode copies the key into process.env at provider load; serving it would put material in the environment (opencode#46745)", + "if_forced": "the sentinel lands in process.env (e.g. AWS_BEARER_TOKEN_BEDROCK) and every child process inherits it; requests 401" + }, + "api-discovery": { + "why": "key used for model discovery or a loader closure outside the fetch seam; serving it would put material into provider options, which Provider.Info serializes", + "if_forced": "model discovery authenticates with the sentinel; the provider may appear configured but broken rather than failing plainly" + }, + "api-metadata": { + "why": "provider reads auth.metadata for api entries; a metadata-less tombstone breaks it", + "if_forced": "the provider reads auth.metadata (e.g. azure resourceName) that a tombstone does not carry, so it fails to construct at load — a different symptom from api-env/api-discovery, which construct and then 401 on the sentinel; still availability-only, the sentinel is non-secret" + } + }, + "providers": { + "amazon-bedrock": { "shapes": ["api-env"], "sites": ["provider.ts:324"] }, + "sap-ai-core": { "shapes": ["api-env"], "sites": ["provider.ts:583"] }, + "gitlab": { "shapes": ["api-discovery"], "sites": ["provider.ts:620,675"] }, + "cloudflare-workers-ai": { "shapes": ["api-discovery"], "sites": ["provider.ts:753"] }, + "cloudflare-ai-gateway": { "shapes": ["api-discovery"], "sites": ["provider.ts:800"] }, + "snowflake-cortex": { "shapes": ["api-discovery"], "sites": ["provider.ts:911"] }, + "modal": { "shapes": ["api-discovery"], "sites": ["plugin/modal/modal.ts:9"] }, + "azure": { "shapes": ["api-metadata"], "sites": ["provider.ts:252"] } + }, + "examined_servable": { + "github-copilot": "copilot.ts discovery reads ctx.auth.key but is gated by `if (ctx.auth?.type !== \"oauth\")`; an api-type entry never reaches the key path — deliberately servable" + }, + "maintainer_note": [ + "attribute each hit by walking the custom() map keys backwards, not by eyeballing (first pass put two gitlab sites under sap-ai-core; PRIVATE-TOKEN header was the only tell)", + "cross-reference the oauth-gate list — it is what keeps false entries like copilot out; a delta without both checks stated is refusable" + ] +} diff --git a/crates/credentials-module/src/bin/cli_support/opencode_accounts.rs b/crates/credentials-module/src/bin/cli_support/opencode_accounts.rs new file mode 100644 index 0000000..da9e12b --- /dev/null +++ b/crates/credentials-module/src/bin/cli_support/opencode_accounts.rs @@ -0,0 +1,315 @@ +use std::collections::BTreeMap; +use std::io::Read; +use std::path::PathBuf; + +use super::{ + commit_admin, opencode_files, opencode_migration, request_admin_status, store_op, CliError, + GlobalArgs, +}; +use credentials_core::admin_ops::{AdminAuditOp, AdminOpBody, StoreMode, ADMIN_OP_SCHEMA_V1}; +use credentials_core::oauth::CUSTODY_TOMBSTONE_PREFIX; +use credentials_core::record::{CredentialKind, VaultRecord}; + +pub(crate) fn cmd_opencode_account(global: &GlobalArgs, raw: &[String]) -> Result<(), CliError> { + let subcommand = raw + .first() + .ok_or_else(|| CliError::Usage("opencode-account requires add, remove, or list".into()))?; + match subcommand.as_str() { + "add" => add(global, &raw[1..]), + "remove" => remove(global, &raw[1..]), + "list" => list(global, &raw[1..]), + other => Err(CliError::Usage(format!( + "unknown opencode-account verb '{other}'" + ))), + } +} + +fn add(global: &GlobalArgs, args: &[String]) -> Result<(), CliError> { + let provider = required(args, "--provider")?; + let label = required(args, "--label")?; + validate_label(&label)?; + let key_file = required(args, "--key-file")?; + let before = optional(args, "--before"); + let handle_path = handle_path(args); + + if let Some(shape) = opencode_migration::unsafe_provider_shape(&provider)? { + return Err(CliError::Usage(format!( + "refusing opencode-account add for {provider}: shape={} why={} source={}; this is availability-only (the sentinel is non-secret), but account failover cannot make a provider outside the fetch seam safe; run migrate-opencode --restore {provider}", + shape.shape_names(), + shape.why(), + shape.sites(), + ))); + } + + let mut handles = opencode_migration::read_handles_or_empty(&handle_path)?; + let provider_entry = handles + .providers + .iter() + .find(|entry| entry.provider == provider) + .ok_or_else(|| { + CliError::Usage(format!( + "provider {provider} is not migrated; run migrate-opencode first" + )) + })?; + if !matches!(provider_entry.shape, opencode_files::HandleShape::Api) + || provider_entry.serve.is_empty() + { + return Err(CliError::Usage(format!( + "provider {provider} has an unsupported handle shape; migrate-opencode supports api entries" + ))); + } + let existing_account = provider_entry + .accounts + .iter() + .find(|account| account.label == label) + .cloned(); + let insert_at = before + .as_deref() + .map(|wanted| { + provider_entry + .accounts + .iter() + .position(|account| account.label == wanted) + .ok_or_else(|| { + CliError::Usage(format!( + "--before label '{wanted}' does not exist for provider {provider}" + )) + }) + }) + .transpose()?; + + let material = read_key_material(&key_file)?; + if material.is_empty() { + return Err(CliError::Usage( + "--key-file contains no key material".into(), + )); + } + if material.starts_with(CUSTODY_TOMBSTONE_PREFIX.as_bytes()) { + return Err(CliError::Usage(format!( + "refusing reserved prefix={CUSTODY_TOMBSTONE_PREFIX} in --key-file" + ))); + } + let id = format!("apikey:{provider}:{label}"); + if let Some(account) = existing_account { + if global.subc_conn.is_none() { + return Err(CliError::Usage(format!( + "account label '{label}' already exists for provider {provider}" + ))); + } + if account.credential_id != id { + return Err(CliError::Io( + "existing handle account points at another credential".into(), + )); + } + let existing = opencode_migration::get_material(global, &account.handle)? + .ok_or_else(|| CliError::Io("existing account handle was revoked".into()))?; + if existing != material { + return Err(CliError::Usage(format!( + "existing credential {id} differs; remove the account before replacing it" + ))); + } + opencode_migration::finalize_superseded(global, &mut handles, &provider, &handle_path)?; + println!( + "provider={provider} label={label} credential_id={id} identical handle_file={}", + handle_path.display() + ); + return Ok(()); + } + + let exists = super::parse_inventory(&super::request_admin_status(global)?)? + .iter() + .any(|(_, _, candidate)| candidate == &id); + if exists { + opencode_migration::with_scoped_handle(global, &id, |verification_handle| { + let existing = opencode_migration::get_material(global, verification_handle)? + .ok_or_else(|| { + CliError::Io("fresh capability was revoked before comparison".into()) + })?; + if existing != material { + return Err(CliError::Usage(format!( + "existing credential {id} differs; remove the account before replacing it" + ))); + } + Ok(()) + })?; + opencode_migration::revoke_all_handles(global, &id)?; + } else { + commit_admin( + global, + store_op( + &id, + VaultRecord::new_static(CredentialKind::ApiKey, "opencode", material, None), + AdminAuditOp::Import, + StoreMode::Create, + ), + )?; + } + opencode_migration::mint_then_persist(global, &id, |handle| { + let provider_entry = handles + .providers + .iter_mut() + .find(|entry| entry.provider == provider) + .expect("provider validated before store"); + let account = opencode_files::HandleAccount { + label: label.clone(), + handle: handle.into(), + credential_id: id.clone(), + superseded: Vec::new(), + }; + if let Some(index) = insert_at { + provider_entry.accounts.insert(index, account); + } else { + provider_entry.accounts.push(account); + } + opencode_migration::write_and_verify_handles(&handle_path, &handles) + })?; + opencode_migration::finalize_superseded(global, &mut handles, &provider, &handle_path)?; + println!( + "provider={provider} label={label} credential_id={id} added handle_file={}", + handle_path.display() + ); + Ok(()) +} + +fn remove(global: &GlobalArgs, args: &[String]) -> Result<(), CliError> { + let provider = required(args, "--provider")?; + let label = required(args, "--label")?; + validate_label(&label)?; + let handle_path = handle_path(args); + let mut handles = opencode_migration::read_handles_or_empty(&handle_path)?; + let provider_entry = handles + .providers + .iter() + .find(|entry| entry.provider == provider) + .ok_or_else(|| CliError::Usage(format!("no handle entry for provider {provider}")))?; + if !matches!(provider_entry.shape, opencode_files::HandleShape::Api) { + return Err(CliError::Usage(format!( + "remove for {provider} accepts only api entries" + ))); + } + let account = provider_entry + .accounts + .iter() + .find(|account| account.label == label) + .cloned() + .ok_or_else(|| { + CliError::Usage(format!( + "no account label '{label}' for provider {provider}" + )) + })?; + if provider_entry.accounts.len() == 1 { + return Err(CliError::Usage(format!( + "refusing to remove the last account for {provider}; use migrate-opencode --restore" + ))); + } + + let mut handles_to_revoke = vec![account.handle]; + handles_to_revoke.extend(account.superseded); + for handle in handles_to_revoke { + commit_admin( + global, + AdminOpBody::RevokeHandle { + v: ADMIN_OP_SCHEMA_V1, + handle, + }, + )?; + } + opencode_migration::remove_account(&mut handles, &provider, &label)?; + opencode_migration::write_and_verify_handles(&handle_path, &handles)?; + println!( + "provider={provider} label={label} removed handle_file={}", + handle_path.display() + ); + Ok(()) +} + +fn list(global: &GlobalArgs, args: &[String]) -> Result<(), CliError> { + let provider_filter = optional(args, "--provider"); + let handle_path = handle_path(args); + let handles = opencode_migration::read_handles_or_empty(&handle_path)?; + let status = super::parse_inventory(&request_admin_status(global)?)?; + let metadata: BTreeMap = status + .into_iter() + .map(|(state, version, id)| (id, (state, version))) + .collect(); + + for provider_entry in handles.providers { + if provider_filter + .as_deref() + .is_some_and(|wanted| wanted != provider_entry.provider) + { + continue; + } + for account in provider_entry.accounts { + let (state, version) = metadata.get(&account.credential_id).ok_or_else(|| { + CliError::Io(format!( + "handle account {} points at missing credential {}", + account.label, account.credential_id + )) + })?; + println!( + "provider={} label={} credential_id={} {state} v{version}", + provider_entry.provider, account.label, account.credential_id + ); + } + } + Ok(()) +} + +fn validate_label(label: &str) -> Result<(), CliError> { + if label.contains(':') { + return Err(CliError::Usage("account label must not contain ':'".into())); + } + if label.is_empty() + || label.len() > 64 + || matches!(label, "__proto__" | "constructor" | "prototype") + || !label.bytes().enumerate().all(|(index, byte)| match byte { + b'a'..=b'z' | b'0'..=b'9' => true, + b'.' | b'_' | b'-' => index > 0, + _ => false, + }) + { + return Err(CliError::Usage( + "account label must match [a-z0-9][a-z0-9._-]{0,63}".into(), + )); + } + Ok(()) +} + +fn read_key_material(path: &str) -> Result, CliError> { + if path == "-" { + let mut material = Vec::new(); + std::io::stdin() + .read_to_end(&mut material) + .map_err(|error| CliError::Io(format!("read key material from stdin: {error}")))?; + trim_terminal_newline(&mut material); + return Ok(material); + } + std::fs::read(path).map_err(|error| CliError::Io(format!("read key file {path}: {error}"))) +} + +fn trim_terminal_newline(material: &mut Vec) { + if material.ends_with(b"\r\n") { + material.truncate(material.len() - 2); + } else if material.ends_with(b"\n") { + material.pop(); + } +} + +fn handle_path(args: &[String]) -> PathBuf { + optional(args, "--handle-file") + .map(PathBuf::from) + .unwrap_or_else(opencode_files::default_handle_path) +} + +fn required(args: &[String], flag: &str) -> Result { + optional(args, flag).ok_or_else(|| CliError::Usage(format!("{flag} is required"))) +} + +fn optional(args: &[String], flag: &str) -> Option { + args.iter() + .position(|arg| arg == flag) + .and_then(|index| args.get(index + 1)) + .filter(|value| !value.starts_with("--")) + .cloned() +} diff --git a/crates/credentials-module/src/bin/cli_support/opencode_files.rs b/crates/credentials-module/src/bin/cli_support/opencode_files.rs new file mode 100644 index 0000000..45d92b2 --- /dev/null +++ b/crates/credentials-module/src/bin/cli_support/opencode_files.rs @@ -0,0 +1,1571 @@ +use std::{ + collections::{BTreeMap, BTreeSet}, + fmt, + fs::{self, File, OpenOptions}, + io::Write, + path::{Path, PathBuf}, + sync::{ + atomic::{AtomicBool, AtomicU64, Ordering}, + mpsc, Arc, + }, + thread, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; + +#[cfg(unix)] +use std::os::unix::fs::OpenOptionsExt; + +use ring::rand::{SecureRandom, SystemRandom}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +static TEMP_SEQ: AtomicU64 = AtomicU64::new(0); +const AUTH_FILE_MAX_BYTES: u64 = 1024 * 1024; +const HANDLE_FILE_MAX_BYTES: u64 = 256 * 1024; +const MANIFEST_LOCK_TTL_MS: u64 = 30_000; +const MANIFEST_LOCK_RENEW_EVERY_MS: u64 = 10_000; +const MANIFEST_LOCK_OWNER_KEYS: [&str; 4] = ["tenant", "pid", "claimed_at_ms", "nonce"]; +const MANIFEST_LOCK_STALE_TARGET_PATTERN: &str = r"^\.lock\.stale-\d+-[A-Za-z0-9_-]+$"; +const OPENCODE_CLAUSTRUM_TENANT: &str = "opencode-claustrum"; +type BeforeManifestRename = Arc; + +#[cfg(test)] +static LEASE_LOST_WARNINGS: AtomicU64 = AtomicU64::new(0); + +#[derive(Clone)] +struct ManifestLockOptions { + ttl: Duration, + renew_every: Duration, + retry_min: Duration, + retry_max: Duration, + after_claim: Option>, + before_evict: Option>, + after_evict: Option>, + before_manifest_rename: Option, + /// Test-only fixed clock reading; `None` uses the wall clock. Lets a test + /// pin the TTL-staleness comparison to an exact instant instead of deriving + /// staleness from `now_ms() - TTL - epsilon`, which races the wall clock + /// between fixture setup and the lock attempt under load. + now_override_ms: Option, +} + +impl Default for ManifestLockOptions { + fn default() -> Self { + Self { + ttl: Duration::from_millis(MANIFEST_LOCK_TTL_MS), + renew_every: Duration::from_millis(MANIFEST_LOCK_RENEW_EVERY_MS), + retry_min: Duration::from_millis(25), + retry_max: Duration::from_millis(75), + after_claim: None, + before_evict: None, + after_evict: None, + before_manifest_rename: None, + now_override_ms: None, + } + } +} + +struct ManifestLease { + lock: PathBuf, + nonce: String, + ttl: Duration, + renewal_failed: Arc, + stop_tx: Option>, + renewal: Option>, +} + +impl ManifestLease { + fn stop_renewal(&mut self) { + if let Some(stop_tx) = self.stop_tx.take() { + let _ = stop_tx.send(()); + } + if let Some(renewal) = self.renewal.take() { + let _ = renewal.join(); + } + } + + fn commit(&mut self) -> Result<(), OpenCodeFilesError> { + self.stop_renewal(); + let owner = read_lock_owner(&self.lock.join("owner")).ok(); + let ours_and_fresh = owner.is_some_and(|owner| { + owner.nonce == self.nonce + && current_time_ms().is_ok_and(|now| { + now.saturating_sub(owner.claimed_at_ms) < self.ttl.as_millis() as u64 + }) + }); + if self.renewal_failed.load(Ordering::SeqCst) || !ours_and_fresh { + return Err(OpenCodeFilesError::Invalid( + "manifest lock renewal failed; write aborted".into(), + )); + } + Ok(()) + } +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct ManifestLockOwner { + tenant: String, + pid: u32, + claimed_at_ms: u64, + nonce: String, +} + +#[derive(Debug)] +pub enum OpenCodeFilesError { + Io { + action: &'static str, + source: std::io::Error, + }, + Json(serde_json::Error), + InsecureParent { + path: PathBuf, + reason: &'static str, + }, + Invalid(String), +} + +impl fmt::Display for OpenCodeFilesError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io { action, source } => write!(f, "{action}: {source}"), + Self::Json(source) => write!(f, "JSON: {source}"), + Self::InsecureParent { path, reason } => { + write!( + f, + "parent directory {} is insecure: {reason}", + path.display() + ) + } + Self::Invalid(message) => f.write_str(message), + } + } +} + +impl std::error::Error for OpenCodeFilesError {} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TombstoneFixture { + pub provider: String, + pub entry: Value, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TombstoneFixtures { + pub api: TombstoneFixture, + pub oauth: TombstoneFixture, +} + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HandleFile { + pub version: u64, + pub providers: Vec, +} + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HandleProvider { + pub provider: String, + pub shape: HandleShape, + #[serde(default)] + pub serve: String, + pub accounts: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum HandleShape { + Api, + Oauth, +} + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HandleAccount { + pub label: String, + pub handle: String, + pub credential_id: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub superseded: Vec, +} + +impl fmt::Debug for HandleFile { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("HandleFile") + .field("version", &self.version) + .field("providers", &self.providers) + .finish() + } +} + +impl fmt::Debug for HandleProvider { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("HandleProvider") + .field("provider", &self.provider) + .field("shape", &self.shape) + .field("serve", &self.serve) + .field("accounts", &self.accounts) + .finish() + } +} + +impl fmt::Debug for HandleAccount { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("HandleAccount") + .field("label", &self.label) + .field("handle", &"ckh_[redacted]") + .field("credential_id", &self.credential_id) + .field( + "superseded", + &format_args!("<{} ckh_[redacted]>", self.superseded.len()), + ) + .finish() + } +} + +pub fn default_auth_path() -> PathBuf { + let data_home = std::env::var_os("XDG_DATA_HOME") + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .or_else(|| { + std::env::var_os("HOME") + .filter(|value| !value.is_empty()) + .map(|home| PathBuf::from(home).join(".local/share")) + }) + .unwrap_or_else(|| PathBuf::from(".local/share")); + data_home.join("opencode").join("auth.json") +} + +pub fn default_handle_path() -> PathBuf { + let config_home = std::env::var_os("XDG_CONFIG_HOME") + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .or_else(|| { + std::env::var_os("HOME") + .filter(|value| !value.is_empty()) + .map(|home| PathBuf::from(home).join(".config")) + }) + .unwrap_or_else(|| PathBuf::from(".config")); + config_home.join("cortexkit").join("opencode-handles.json") +} + +pub fn golden_tombstone_fixtures() -> Result { + let golden: Value = serde_json::from_str(include_str!( + "../../../../../packages/opencode/golden/tombstone.json" + )) + .map_err(OpenCodeFilesError::Json)?; + let fixture = |shape: &str| -> Result { + let item = &golden["fixtures"][shape]; + let provider = item["provider"] + .as_str() + .filter(|provider| !provider.is_empty()) + .ok_or_else(|| { + OpenCodeFilesError::Invalid(format!("golden {shape} provider is invalid")) + })? + .to_string(); + let entry = item["entry"].clone(); + validate_auth_entry(&entry)?; + Ok(TombstoneFixture { provider, entry }) + }; + Ok(TombstoneFixtures { + api: fixture("api")?, + oauth: fixture("oauth")?, + }) +} + +pub fn read_auth_entries(path: &Path) -> Result, OpenCodeFilesError> { + validate_secure_file(path)?; + let bytes = read_limited(path, AUTH_FILE_MAX_BYTES, "auth file")?; + let entries: BTreeMap = + serde_json::from_slice(&bytes).map_err(OpenCodeFilesError::Json)?; + for (provider, entry) in &entries { + validate_identifier(provider, "provider")?; + validate_auth_entry(entry)?; + } + Ok(entries) +} + +pub fn write_auth_entry( + path: &Path, + provider: &str, + entry: Value, +) -> Result<(), OpenCodeFilesError> { + validate_identifier(provider, "provider")?; + validate_auth_entry(&entry)?; + let mut entries = if path.exists() { + read_auth_entries(path)? + } else { + BTreeMap::new() + }; + entries.insert(provider.to_string(), entry); + let bytes = serde_json::to_vec(&entries).map_err(OpenCodeFilesError::Json)?; + write_atomic(path, &bytes, false) +} + +pub fn verify_auth_written( + path: &Path, + provider: &str, + expected: &Value, +) -> Result<(), OpenCodeFilesError> { + let entries = read_auth_entries(path)?; + if entries.get(provider) != Some(expected) { + return Err(OpenCodeFilesError::Invalid( + "auth entry did not persist exactly".into(), + )); + } + Ok(()) +} + +pub fn read_handle_file(path: &Path) -> Result { + validate_secure_file(path)?; + let bytes = read_limited(path, HANDLE_FILE_MAX_BYTES, "handle file")?; + let file: HandleFile = serde_json::from_slice(&bytes).map_err(OpenCodeFilesError::Json)?; + validate_handle_file(&file)?; + Ok(file) +} + +pub fn write_handle_file(path: &Path, file: &HandleFile) -> Result<(), OpenCodeFilesError> { + write_handle_file_for_tenant( + path, + OPENCODE_CLAUSTRUM_TENANT, + file, + ManifestLockOptions::default(), + ) +} + +pub(crate) fn write_handle_file_for_tenant_default( + path: &Path, + tenant: &str, + file: &HandleFile, +) -> Result<(), OpenCodeFilesError> { + write_handle_file_for_tenant(path, tenant, file, ManifestLockOptions::default()) +} + +pub fn verify_handle_written(path: &Path, expected: &HandleFile) -> Result<(), OpenCodeFilesError> { + verify_handle_written_for_tenant(path, OPENCODE_CLAUSTRUM_TENANT, expected) +} + +pub(crate) fn verify_handle_written_for_tenant( + path: &Path, + tenant: &str, + expected: &HandleFile, +) -> Result<(), OpenCodeFilesError> { + validate_handle_file(expected)?; + let written = read_handle_file(path)?; + let expected_owned: Vec<_> = expected + .providers + .iter() + .filter(|provider| provider.serve == tenant) + .cloned() + .collect(); + let written_owned: Vec<_> = written + .providers + .iter() + .filter(|provider| provider.serve == tenant) + .cloned() + .collect(); + if written_owned != expected_owned { + return Err(OpenCodeFilesError::Invalid( + "handle file tenant block did not persist exactly".into(), + )); + } + Ok(()) +} + +pub(crate) fn read_secret_file(path: &Path, kind: &str) -> Result, OpenCodeFilesError> { + validate_secure_file(path)?; + read_limited(path, HANDLE_FILE_MAX_BYTES, kind) +} + +pub(crate) fn validate_manifest_label(value: &str) -> Result<(), OpenCodeFilesError> { + validate_identifier(value, "label") +} + +pub struct MintedHandleOutput { + path: PathBuf, + file: Option, + persisted: bool, +} + +impl MintedHandleOutput { + pub fn persist(mut self, handle: &str) -> Result<(), OpenCodeFilesError> { + #[cfg(debug_assertions)] + if std::env::var("CK_OPENCODE_TEST_FAIL_MINT_OUT_WRITE").as_deref() == Ok("1") { + return Err(OpenCodeFilesError::Invalid( + "handle output write interrupted by test seam".into(), + )); + } + let file = self + .file + .as_mut() + .ok_or_else(|| OpenCodeFilesError::Invalid("handle output is closed".into()))?; + file.write_all(format!("{handle}\n").as_bytes()) + .map_err(|source| io_error("write handle output", source))?; + file.sync_all() + .map_err(|source| io_error("sync handle output", source))?; + self.persisted = true; + Ok(()) + } +} + +impl Drop for MintedHandleOutput { + fn drop(&mut self) { + let _ = self.file.take(); + if !self.persisted { + let _ = fs::remove_file(&self.path); + } + } +} + +pub fn create_minted_handle_output(path: &Path) -> Result { + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .ok_or_else(|| OpenCodeFilesError::Invalid("file path has no parent".into()))?; + fs::create_dir_all(parent).map_err(|source| io_error("create parent directory", source))?; + validate_secure_parent(parent)?; + let file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(path) + .map_err(|source| io_error("create handle output", source))?; + Ok(MintedHandleOutput { + path: path.to_path_buf(), + file: Some(file), + persisted: false, + }) +} + +fn write_handle_file_for_tenant( + path: &Path, + tenant: &str, + desired: &HandleFile, + options: ManifestLockOptions, +) -> Result<(), OpenCodeFilesError> { + validate_handle_file(desired)?; + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .ok_or_else(|| OpenCodeFilesError::Invalid("file path has no parent".into()))?; + fs::create_dir_all(parent).map_err(|source| io_error("create parent directory", source))?; + validate_secure_parent(parent)?; + set_mode(parent, 0o700)?; + let before_manifest_rename = options.before_manifest_rename.clone(); + with_manifest_lock_with_options(path, tenant, options, |lease| { + let current = match fs::symlink_metadata(path) { + Ok(_) => read_handle_file(path)?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => HandleFile { + version: 1, + providers: Vec::new(), + }, + Err(error) => return Err(io_error("stat handle file", error)), + }; + let before_foreign: Vec> = current + .providers + .iter() + .filter(|provider| provider.serve != tenant) + .map(serde_json::to_vec) + .collect::>() + .map_err(OpenCodeFilesError::Json)?; + let mut providers: Vec<_> = current + .providers + .into_iter() + .filter(|provider| provider.serve != tenant) + .collect(); + providers.extend( + desired + .providers + .iter() + .filter(|provider| provider.serve == tenant) + .cloned(), + ); + let next = HandleFile { + version: 1, + providers, + }; + validate_handle_file(&next)?; + let bytes = serde_json::to_vec(&next).map_err(OpenCodeFilesError::Json)?; + write_atomic_guarded(path, &bytes, true, || { + if let Some(before_manifest_rename) = &before_manifest_rename { + before_manifest_rename(&lock_path(path)); + } + lease.commit() + })?; + let readback = read_handle_file(path)?; + if readback != next { + return Err(OpenCodeFilesError::Invalid( + "handle file readback did not persist exactly".into(), + )); + } + let after_foreign: Vec> = readback + .providers + .iter() + .filter(|provider| provider.serve != tenant) + .map(serde_json::to_vec) + .collect::>() + .map_err(OpenCodeFilesError::Json)?; + if after_foreign != before_foreign { + return Err(OpenCodeFilesError::Invalid( + "handle file readback changed another tenant block".into(), + )); + } + Ok(()) + }) +} + +fn lock_path(path: &Path) -> PathBuf { + let mut lock = path.as_os_str().to_os_string(); + lock.push(".lock"); + PathBuf::from(lock) +} + +fn current_time_ms() -> Result { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis() as u64) + .map_err(|_| OpenCodeFilesError::Invalid("system clock is before UNIX epoch".into())) +} + +fn resolve_now_ms(options: &ManifestLockOptions) -> Result { + match options.now_override_ms { + Some(fixed) => Ok(fixed), + None => current_time_ms(), + } +} + +fn random_nonce() -> Result { + let mut bytes = [0_u8; 16]; + SystemRandom::new() + .fill(&mut bytes) + .map_err(|_| OpenCodeFilesError::Invalid("generate manifest lock nonce failed".into()))?; + Ok(bytes.iter().map(|byte| format!("{byte:02x}")).collect()) +} + +fn io_error(action: &'static str, source: std::io::Error) -> OpenCodeFilesError { + OpenCodeFilesError::Io { action, source } +} + +fn parse_lock_owner(source: &str) -> Result { + serde_json::from_str(source).map_err(OpenCodeFilesError::Json) +} + +fn read_lock_owner(path: &Path) -> Result { + let source = + fs::read_to_string(path).map_err(|source| io_error("read manifest lock owner", source))?; + parse_lock_owner(&source) +} + +fn write_lock_owner(lock: &Path, owner: &ManifestLockOwner) -> Result<(), OpenCodeFilesError> { + let owner_path = lock.join("owner"); + let temporary = lock.join(format!( + "owner.{}.{}.tmp", + std::process::id(), + random_nonce()? + )); + let result = (|| -> Result<(), OpenCodeFilesError> { + #[cfg(unix)] + let mut file = { + use std::os::unix::fs::OpenOptionsExt; + OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&temporary) + .map_err(|source| io_error("create manifest lock owner", source))? + }; + #[cfg(not(unix))] + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&temporary) + .map_err(|source| io_error("create manifest lock owner", source))?; + set_mode(&temporary, 0o600)?; + serde_json::to_writer(&mut file, owner).map_err(OpenCodeFilesError::Json)?; + file.write_all(b"\n") + .map_err(|source| io_error("write manifest lock owner", source))?; + file.sync_all() + .map_err(|source| io_error("sync manifest lock owner", source))?; + drop(file); + fs::rename(&temporary, &owner_path) + .map_err(|source| io_error("rename manifest lock owner", source)) + })(); + if result.is_err() { + let _ = fs::remove_file(&temporary); + } + result +} + +fn stale_target_matches(value: &str) -> bool { + let Some(rest) = value.strip_prefix(".lock.stale-") else { + return false; + }; + let Some((claimed, random)) = rest.split_once('-') else { + return false; + }; + !claimed.is_empty() + && claimed.bytes().all(|byte| byte.is_ascii_digit()) + && !random.is_empty() + && random + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) +} + +fn warn_lease_lost(path: &Path) { + #[cfg(test)] + LEASE_LOST_WARNINGS.fetch_add(1, Ordering::SeqCst); + eprintln!( + "manifest lock lease lost, not releasing: {}", + path.display() + ); +} + +fn jitter(options: &ManifestLockOptions) -> Duration { + let min = options.retry_min.as_millis() as u64; + let max = options.retry_max.as_millis() as u64; + if max <= min { + return Duration::from_millis(min); + } + let mut bytes = [0_u8; 8]; + if SystemRandom::new().fill(&mut bytes).is_err() { + return Duration::from_millis(min); + } + Duration::from_millis(min + u64::from_le_bytes(bytes) % (max - min + 1)) +} + +fn release_manifest_lock( + path: &Path, + lock: &Path, + nonce: &str, + ttl: Duration, +) -> Result<(), OpenCodeFilesError> { + let owner = match read_lock_owner(&lock.join("owner")) { + Ok(owner) => owner, + Err(_) => { + warn_lease_lost(path); + return Ok(()); + } + }; + let now = current_time_ms()?; + if owner.nonce != nonce || now.saturating_sub(owner.claimed_at_ms) >= ttl.as_millis() as u64 { + warn_lease_lost(path); + return Ok(()); + } + let release = PathBuf::from(format!("{}.release-{nonce}", lock.display())); + if fs::rename(lock, &release).is_err() { + warn_lease_lost(path); + return Ok(()); + } + let moved = read_lock_owner(&release.join("owner")).ok(); + let moved_is_ours = moved.is_some_and(|owner| { + owner.nonce == nonce + && current_time_ms() + .is_ok_and(|now| now.saturating_sub(owner.claimed_at_ms) < ttl.as_millis() as u64) + }); + if !moved_is_ours { + let _ = fs::rename(&release, lock); + warn_lease_lost(path); + return Ok(()); + } + fs::remove_dir_all(&release).map_err(|source| io_error("remove manifest lock", source)) +} + +fn with_manifest_lock_with_options( + path: &Path, + tenant: &str, + options: ManifestLockOptions, + operation: F, +) -> Result +where + F: FnOnce(&mut ManifestLease) -> Result, +{ + let lock = lock_path(path); + let owner_path = lock.join("owner"); + let nonce = random_nonce()?; + let started_at_ms = resolve_now_ms(&options)?; + let deadline = Instant::now() + options.ttl; + loop { + match fs::create_dir(&lock) { + Ok(()) => { + set_mode(&lock, 0o700)?; + let owner = ManifestLockOwner { + tenant: tenant.into(), + pid: std::process::id(), + claimed_at_ms: resolve_now_ms(&options)?, + nonce: nonce.clone(), + }; + if let Err(error) = write_lock_owner(&lock, &owner) { + let _ = fs::remove_dir_all(&lock); + return Err(error); + } + if let Some(after_claim) = &options.after_claim { + after_claim(); + } + break; + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(error) => return Err(io_error("create manifest lock", error)), + } + + if let Ok(observed) = read_lock_owner(&owner_path) { + if started_at_ms.saturating_sub(observed.claimed_at_ms) + >= options.ttl.as_millis() as u64 + { + if let Some(before_evict) = &options.before_evict { + before_evict(); + } + let stale = PathBuf::from(format!( + "{}.stale-{}-{}", + lock.display(), + observed.claimed_at_ms, + random_nonce()? + )); + match fs::rename(&lock, &stale) { + Ok(()) => { + let moved = read_lock_owner(&stale.join("owner")).ok(); + if moved.is_some_and(|owner| { + owner.nonce == observed.nonce + && owner.claimed_at_ms == observed.claimed_at_ms + }) { + if let Some(after_evict) = &options.after_evict { + after_evict(); + } + continue; + } + let _ = fs::rename(&stale, &lock); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(io_error("rename stale manifest lock", error)), + } + } + } + if Instant::now() >= deadline { + return Err(OpenCodeFilesError::Invalid("manifest lock busy".into())); + } + thread::sleep(jitter(&options).min(deadline.saturating_duration_since(Instant::now()))); + } + + let (stop_tx, stop_rx) = mpsc::channel::<()>(); + let renewal_lock = lock.clone(); + let renewal_nonce = nonce.clone(); + let renewal_ttl = options.ttl; + let renewal_every = options.renew_every; + let renewal_failed = Arc::new(AtomicBool::new(false)); + let renewal_failed_thread = Arc::clone(&renewal_failed); + let renewal = thread::spawn(move || loop { + match stop_rx.recv_timeout(renewal_every) { + Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => break, + Err(mpsc::RecvTimeoutError::Timeout) => { + let owner_path = renewal_lock.join("owner"); + let Ok(mut owner) = read_lock_owner(&owner_path) else { + renewal_failed_thread.store(true, Ordering::SeqCst); + break; + }; + let Ok(now) = current_time_ms() else { + renewal_failed_thread.store(true, Ordering::SeqCst); + break; + }; + if owner.nonce != renewal_nonce + || now.saturating_sub(owner.claimed_at_ms) >= renewal_ttl.as_millis() as u64 + { + renewal_failed_thread.store(true, Ordering::SeqCst); + break; + } + owner.claimed_at_ms = now; + if write_lock_owner(&renewal_lock, &owner).is_err() { + renewal_failed_thread.store(true, Ordering::SeqCst); + break; + } + } + } + }); + let mut lease = ManifestLease { + lock: lock.clone(), + nonce: nonce.clone(), + ttl: options.ttl, + renewal_failed, + stop_tx: Some(stop_tx), + renewal: Some(renewal), + }; + let result = operation(&mut lease); + lease.stop_renewal(); + let result = match result { + Ok(_) if lease.renewal_failed.load(Ordering::SeqCst) => Err(OpenCodeFilesError::Invalid( + "manifest lock renewal failed; write aborted".into(), + )), + other => other, + }; + let release = release_manifest_lock(path, &lock, &nonce, options.ttl); + match (result, release) { + (Err(error), _) => Err(error), + (Ok(_), Err(error)) => Err(error), + (Ok(value), Ok(())) => Ok(value), + } +} + +fn validate_auth_entry(entry: &Value) -> Result<(), OpenCodeFilesError> { + let object = entry + .as_object() + .ok_or_else(|| OpenCodeFilesError::Invalid("auth entry must be an object".into()))?; + match object.get("type").and_then(Value::as_str) { + Some("api") | Some("oauth") | Some("wellknown") => Ok(()), + _ => Err(OpenCodeFilesError::Invalid("unknown auth shape".into())), + } +} + +fn validate_handle_file(file: &HandleFile) -> Result<(), OpenCodeFilesError> { + if file.version != 1 { + return Err(OpenCodeFilesError::Invalid( + "handle file must have version 1".into(), + )); + } + let mut provider_ids = BTreeSet::new(); + for (index, provider) in file.providers.iter().enumerate() { + if !identifier_is_valid(&provider.provider) { + return Err(OpenCodeFilesError::Invalid(format!( + "provider {index} has invalid provider" + ))); + } + if !provider_ids.insert(&provider.provider) { + return Err(OpenCodeFilesError::Invalid(format!( + "provider {index} duplicates provider {}", + provider.provider + ))); + } + } + for (index, provider) in file.providers.iter().enumerate() { + match provider.shape { + HandleShape::Api | HandleShape::Oauth => {} + } + if provider.serve.is_empty() { + return Err(OpenCodeFilesError::Invalid(format!( + "provider {index} requires serve" + ))); + } + if provider.accounts.is_empty() { + return Err(OpenCodeFilesError::Invalid(format!( + "provider {index} has invalid accounts" + ))); + } + let mut labels = BTreeSet::new(); + for account in &provider.accounts { + if !identifier_is_valid(&account.label) { + return Err(OpenCodeFilesError::Invalid(format!( + "provider {index} has an invalid account label" + ))); + } + if !labels.insert(&account.label) { + return Err(OpenCodeFilesError::Invalid(format!( + "provider {index} duplicates account label {}", + account.label + ))); + } + if !valid_handle(&account.handle) { + return Err(OpenCodeFilesError::Invalid(format!( + "provider {index} account {} has invalid handle", + account.label + ))); + } + if account.credential_id.is_empty() { + return Err(OpenCodeFilesError::Invalid(format!( + "provider {index} account {} has invalid credential id", + account.label + ))); + } + if account + .superseded + .iter() + .any(|handle| !valid_handle(handle)) + { + return Err(OpenCodeFilesError::Invalid(format!( + "provider {index} account {} has invalid superseded handle", + account.label + ))); + } + } + } + Ok(()) +} + +fn valid_handle(handle: &str) -> bool { + handle.strip_prefix("ckh_").is_some_and(|body| { + body.len() == 43 + && body + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) + }) +} + +fn identifier_is_valid(value: &str) -> bool { + !matches!(value, "__proto__" | "constructor" | "prototype") + && !value.is_empty() + && value.len() <= 64 + && value.bytes().enumerate().all(|(index, byte)| match byte { + b'a'..=b'z' | b'0'..=b'9' => true, + b'.' | b'_' | b'-' => index > 0, + _ => false, + }) +} + +fn validate_identifier(value: &str, kind: &str) -> Result<(), OpenCodeFilesError> { + if identifier_is_valid(value) { + Ok(()) + } else { + Err(OpenCodeFilesError::Invalid(format!( + "{kind} must match [a-z0-9][a-z0-9._-]{{0,63}}" + ))) + } +} + +fn write_atomic(path: &Path, bytes: &[u8], secure_parent: bool) -> Result<(), OpenCodeFilesError> { + write_atomic_guarded(path, bytes, secure_parent, || Ok(())) +} + +fn write_atomic_guarded( + path: &Path, + bytes: &[u8], + secure_parent: bool, + before_rename: F, +) -> Result<(), OpenCodeFilesError> +where + F: FnOnce() -> Result<(), OpenCodeFilesError>, +{ + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .ok_or_else(|| OpenCodeFilesError::Invalid("file path has no parent".into()))?; + fs::create_dir_all(parent).map_err(|source| OpenCodeFilesError::Io { + action: "create parent directory", + source, + })?; + validate_secure_parent(parent)?; + if secure_parent { + set_mode(parent, 0o700)?; + } + let name = path + .file_name() + .ok_or_else(|| OpenCodeFilesError::Invalid("file path has no filename".into()))?; + let temp = parent.join(format!( + ".{}.{}.{}.tmp", + name.to_string_lossy(), + std::process::id(), + TEMP_SEQ.fetch_add(1, Ordering::Relaxed) + )); + let result = (|| -> Result<(), OpenCodeFilesError> { + #[cfg(unix)] + let mut file = { + use std::os::unix::fs::OpenOptionsExt; + OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&temp) + .map_err(|source| OpenCodeFilesError::Io { + action: "create temporary file", + source, + })? + }; + #[cfg(not(unix))] + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&temp) + .map_err(|source| OpenCodeFilesError::Io { + action: "create temporary file", + source, + })?; + set_mode(&temp, 0o600)?; + file.write_all(bytes) + .map_err(|source| OpenCodeFilesError::Io { + action: "write temporary file", + source, + })?; + file.sync_all().map_err(|source| OpenCodeFilesError::Io { + action: "sync temporary file", + source, + })?; + before_rename()?; + fs::rename(&temp, path).map_err(|source| OpenCodeFilesError::Io { + action: "rename temporary file", + source, + })?; + File::open(parent) + .and_then(|directory| directory.sync_all()) + .map_err(|source| OpenCodeFilesError::Io { + action: "sync parent directory", + source, + })?; + Ok(()) + })(); + if result.is_err() { + let _ = fs::remove_file(&temp); + } + result +} + +fn validate_secure_file(path: &Path) -> Result<(), OpenCodeFilesError> { + let metadata = fs::symlink_metadata(path).map_err(|source| OpenCodeFilesError::Io { + action: "stat file", + source, + })?; + if !metadata.file_type().is_file() { + return Err(OpenCodeFilesError::Invalid( + "file must be a regular file".into(), + )); + } + #[cfg(unix)] + { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + if metadata.uid() != current_uid()? { + return Err(OpenCodeFilesError::Invalid( + "file is not owned by the current uid".into(), + )); + } + if metadata.permissions().mode() & 0o777 != 0o600 { + return Err(OpenCodeFilesError::Invalid( + "file mode must be exactly 0600".into(), + )); + } + } + Ok(()) +} + +fn read_limited(path: &Path, max_bytes: u64, kind: &str) -> Result, OpenCodeFilesError> { + let metadata = fs::metadata(path).map_err(|source| OpenCodeFilesError::Io { + action: "stat file for read limit", + source, + })?; + if metadata.len() > max_bytes { + let limit = if max_bytes == AUTH_FILE_MAX_BYTES { + "1 MiB".into() + } else { + format!("{} KiB", max_bytes / 1024) + }; + return Err(OpenCodeFilesError::Invalid(format!( + "{kind} exceeds {limit}", + ))); + } + fs::read(path).map_err(|source| OpenCodeFilesError::Io { + action: "read file", + source, + }) +} + +fn validate_secure_parent(path: &Path) -> Result<(), OpenCodeFilesError> { + let metadata = fs::symlink_metadata(path).map_err(|source| OpenCodeFilesError::Io { + action: "stat parent directory", + source, + })?; + if !metadata.file_type().is_dir() { + return Err(OpenCodeFilesError::Invalid( + "parent directory must be a directory".into(), + )); + } + #[cfg(unix)] + { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + if metadata.uid() != current_uid()? { + return Err(OpenCodeFilesError::InsecureParent { + path: path.into(), + reason: "not owned by the current uid", + }); + } + let mode = metadata.permissions().mode(); + if mode & 0o002 != 0 && mode & 0o1000 == 0 { + return Err(OpenCodeFilesError::InsecureParent { + path: path.into(), + reason: "world-writable without sticky bit", + }); + } + } + Ok(()) +} + +fn set_mode(path: &Path, mode: u32) -> Result<(), OpenCodeFilesError> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(mode)).map_err(|source| { + OpenCodeFilesError::Io { + action: "set file mode", + source, + } + })?; + } + #[cfg(not(unix))] + { + let _ = (path, mode); + } + Ok(()) +} + +#[cfg(unix)] +fn current_uid() -> Result { + std::process::Command::new("/usr/bin/id") + .arg("-u") + .output() + .map_err(|source| OpenCodeFilesError::Io { + action: "determine current uid", + source, + }) + .and_then(|output| { + if !output.status.success() { + return Err(OpenCodeFilesError::Invalid( + "determine current uid failed".into(), + )); + } + String::from_utf8(output.stdout) + .map_err(|_| OpenCodeFilesError::Invalid("current uid was not UTF-8".into()))? + .trim() + .parse() + .map_err(|_| OpenCodeFilesError::Invalid("current uid was invalid".into())) + }) +} + +#[cfg(test)] +mod manifest_lock_tests { + use super::*; + use std::{ + os::unix::fs::{symlink, PermissionsExt}, + sync::{Arc, Barrier, Mutex}, + thread, + time::{Duration, SystemTime, UNIX_EPOCH}, + }; + + static TEST_SERIAL: Mutex<()> = Mutex::new(()); + + struct TempRoot(PathBuf); + + impl TempRoot { + fn new() -> Self { + let path = std::env::temp_dir().join(format!( + "claustrum-manifest-lock-{}-{}", + std::process::id(), + TEMP_SEQ.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir_all(&path).unwrap(); + Self(path) + } + + fn manifest(&self) -> PathBuf { + self.0.join("opencode-handles.json") + } + } + + impl Drop for TempRoot { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis() as u64 + } + + fn handle(letter: char) -> String { + format!("ckh_{}", letter.to_string().repeat(43)) + } + + fn provider(provider: &str, tenant: &str, letter: char) -> HandleProvider { + HandleProvider { + provider: provider.into(), + shape: HandleShape::Api, + serve: tenant.into(), + accounts: vec![HandleAccount { + label: "main".into(), + handle: handle(letter), + credential_id: format!("apikey:{provider}:main"), + superseded: Vec::new(), + }], + } + } + + fn file(provider: HandleProvider) -> HandleFile { + HandleFile { + version: 1, + providers: vec![provider], + } + } + + fn write_fixture_owner(path: &Path, claimed_at_ms: u64, tenant: &str) { + let lock = lock_path(path); + fs::create_dir(&lock).unwrap(); + fs::set_permissions(&lock, fs::Permissions::from_mode(0o700)).unwrap(); + let source = format!( + "{{\"tenant\":\"{tenant}\",\"pid\":41,\"claimed_at_ms\":{claimed_at_ms},\"nonce\":\"0123456789abcdef0123456789abcdef\"}}\n" + ); + fs::write(lock.join("owner"), source).unwrap(); + fs::set_permissions(lock.join("owner"), fs::Permissions::from_mode(0o600)).unwrap(); + } + + #[test] + fn minted_handle_output_is_mode_0600_when_created() { + let root = TempRoot::new(); + let path = root.0.join("minted-handle"); + let output = create_minted_handle_output(&path).expect("create minted handle output"); + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + drop(output); + } + + fn short_options() -> ManifestLockOptions { + ManifestLockOptions { + ttl: Duration::from_millis(40), + renew_every: Duration::from_millis(10), + retry_min: Duration::from_millis(2), + retry_max: Duration::from_millis(3), + ..ManifestLockOptions::default() + } + } + + #[test] + fn concurrent_tenant_writers_preserve_both_provider_blocks() { + let _serial = TEST_SERIAL.lock().unwrap(); + let root = TempRoot::new(); + let path = root.manifest(); + let claimed = Arc::new(Barrier::new(2)); + let release = Arc::new(Barrier::new(2)); + let first_options = ManifestLockOptions { + after_claim: Some({ + let claimed = Arc::clone(&claimed); + let release = Arc::clone(&release); + Arc::new(move || { + claimed.wait(); + release.wait(); + }) + }), + ..ManifestLockOptions::default() + }; + let first_path = path.clone(); + let first = thread::spawn(move || { + write_handle_file_for_tenant( + &first_path, + "anthropic-auth", + &file(provider("anthropic", "anthropic-auth", 'A')), + first_options, + ) + }); + claimed.wait(); + let second_path = path.clone(); + let second = thread::spawn(move || { + write_handle_file_for_tenant( + &second_path, + "openai-auth", + &file(provider("openai", "openai-auth", 'B')), + ManifestLockOptions::default(), + ) + }); + thread::sleep(Duration::from_millis(15)); + release.wait(); + first.join().unwrap().unwrap(); + second.join().unwrap().unwrap(); + + let mut names: Vec<_> = read_handle_file(&path) + .unwrap() + .providers + .into_iter() + .map(|entry| entry.provider) + .collect(); + names.sort(); + assert_eq!(names, ["anthropic", "openai"]); + } + + #[test] + fn stale_owner_is_evicted_by_rename_and_quarantined() { + let _serial = TEST_SERIAL.lock().unwrap(); + let root = TempRoot::new(); + let path = root.manifest(); + let now = now_ms(); + write_fixture_owner(&path, now - MANIFEST_LOCK_TTL_MS - 1, "other-tenant"); + + with_manifest_lock_with_options( + &path, + "opencode-claustrum", + ManifestLockOptions { + now_override_ms: Some(now), + ..ManifestLockOptions::default() + }, + |_| Ok(()), + ) + .unwrap(); + + let stale: Vec<_> = fs::read_dir(&root.0) + .unwrap() + .filter_map(Result::ok) + .map(|entry| entry.file_name().to_string_lossy().into_owned()) + .filter(|name| name.starts_with("opencode-handles.json.lock.stale-")) + .collect(); + assert_eq!(stale.len(), 1); + assert!(stale_target_matches( + stale[0].strip_prefix("opencode-handles.json").unwrap() + )); + } + + #[test] + fn fresh_owner_fails_manifest_lock_busy_after_bounded_window() { + let _serial = TEST_SERIAL.lock().unwrap(); + let root = TempRoot::new(); + let path = root.manifest(); + write_fixture_owner(&path, now_ms(), "other-tenant"); + let started = std::time::Instant::now(); + + let error = + with_manifest_lock_with_options(&path, "opencode-claustrum", short_options(), |_| { + Ok(()) + }) + .unwrap_err(); + + assert!(error.to_string().contains("manifest lock busy")); + assert!(started.elapsed() >= Duration::from_millis(35)); + } + + #[test] + fn owner_exists_while_held_and_lock_is_gone_after_release() { + let _serial = TEST_SERIAL.lock().unwrap(); + let root = TempRoot::new(); + let path = root.manifest(); + let lock = lock_path(&path); + + with_manifest_lock_with_options( + &path, + "opencode-claustrum", + ManifestLockOptions::default(), + |_| { + let owner = read_lock_owner(&lock.join("owner"))?; + assert_eq!(owner.tenant, "opencode-claustrum"); + Ok(()) + }, + ) + .unwrap(); + + assert!(!lock.exists()); + } + + #[test] + fn two_stale_evictors_have_one_winner_and_non_overlapping_holders() { + let _serial = TEST_SERIAL.lock().unwrap(); + let root = TempRoot::new(); + let path = root.manifest(); + let now = now_ms(); + write_fixture_owner(&path, now - MANIFEST_LOCK_TTL_MS - 1, "other-tenant"); + let eviction_barrier = Arc::new(Barrier::new(2)); + let eviction_wins = Arc::new(AtomicU64::new(0)); + let active = Arc::new(AtomicU64::new(0)); + let max_active = Arc::new(AtomicU64::new(0)); + let mut joins = Vec::new(); + for tenant in ["anthropic-auth", "openai-auth"] { + let path = path.clone(); + let barrier = Arc::clone(&eviction_barrier); + let wins = Arc::clone(&eviction_wins); + let active = Arc::clone(&active); + let max_active = Arc::clone(&max_active); + joins.push(thread::spawn(move || { + let options = ManifestLockOptions { + before_evict: Some(Arc::new(move || { + barrier.wait(); + })), + after_evict: Some(Arc::new(move || { + wins.fetch_add(1, Ordering::SeqCst); + })), + now_override_ms: Some(now), + ..ManifestLockOptions::default() + }; + with_manifest_lock_with_options(&path, tenant, options, |_| { + let current = active.fetch_add(1, Ordering::SeqCst) + 1; + max_active.fetch_max(current, Ordering::SeqCst); + thread::sleep(Duration::from_millis(15)); + active.fetch_sub(1, Ordering::SeqCst); + Ok(()) + }) + })); + } + for join in joins { + join.join().unwrap().unwrap(); + } + assert_eq!(eviction_wins.load(Ordering::SeqCst), 1); + assert_eq!(max_active.load(Ordering::SeqCst), 1); + } + + #[test] + fn expired_holder_does_not_release_and_warns() { + let _serial = TEST_SERIAL.lock().unwrap(); + let root = TempRoot::new(); + let path = root.manifest(); + let lock = lock_path(&path); + let before_warnings = LEASE_LOST_WARNINGS.load(Ordering::SeqCst); + let options = ManifestLockOptions { + renew_every: Duration::from_secs(1), + ..short_options() + }; + + with_manifest_lock_with_options(&path, "opencode-claustrum", options, |_| { + let owner_path = lock.join("owner"); + let mut owner = read_lock_owner(&owner_path)?; + owner.claimed_at_ms = now_ms() - 41; + write_lock_owner(&lock, &owner) + }) + .unwrap(); + + assert!(lock.exists()); + assert_eq!( + LEASE_LOST_WARNINGS.load(Ordering::SeqCst), + before_warnings + 1 + ); + } + + #[test] + fn atomic_publication_is_0600_under_umask_022() { + let _serial = TEST_SERIAL.lock().unwrap(); + if let Some(path) = std::env::var_os("CK_MANIFEST_UMASK_CHILD_PATH") { + write_handle_file_for_tenant( + Path::new(&path), + "opencode-claustrum", + &file(provider("deepseek", "opencode-claustrum", 'C')), + ManifestLockOptions::default(), + ) + .unwrap(); + return; + } + let root = TempRoot::new(); + let path = root.manifest(); + let executable = std::env::current_exe().unwrap(); + let status = std::process::Command::new("/bin/sh") + .arg("-c") + .arg("umask 022; exec \"$CK_MANIFEST_TEST_EXE\" atomic_publication_is_0600_under_umask_022 --nocapture") + .env("CK_MANIFEST_TEST_EXE", executable) + .env("CK_MANIFEST_UMASK_CHILD_PATH", &path) + .status() + .unwrap(); + assert!(status.success()); + assert_eq!( + fs::metadata(path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + + #[test] + fn pins_cross_language_constants_and_renewal_bound() { + assert_eq!(MANIFEST_LOCK_TTL_MS, 30_000); + assert_eq!(MANIFEST_LOCK_RENEW_EVERY_MS, 10_000); + assert_eq!( + MANIFEST_LOCK_OWNER_KEYS, + ["tenant", "pid", "claimed_at_ms", "nonce"] + ); + assert_eq!( + MANIFEST_LOCK_STALE_TARGET_PATTERN, + r"^\.lock\.stale-\d+-[A-Za-z0-9_-]+$" + ); + let renew_every = std::hint::black_box(MANIFEST_LOCK_RENEW_EVERY_MS); + assert!(renew_every * 3 <= MANIFEST_LOCK_TTL_MS); + } + + #[test] + fn parses_a_typescript_shaped_owner_fixture() { + let literal = r#"{"tenant":"anthropic-auth","pid":123,"claimed_at_ms":456,"nonce":"MDEyMzQ1Njc4OWFiY2RlZg"}"#; + let owner = parse_lock_owner(literal).unwrap(); + assert_eq!(owner.tenant, "anthropic-auth"); + assert_eq!(owner.pid, 123); + assert_eq!(owner.claimed_at_ms, 456); + assert_eq!(owner.nonce, "MDEyMzQ1Njc4OWFiY2RlZg"); + } + + #[test] + fn dangling_manifest_symlink_is_refused_without_replacement() { + let _serial = TEST_SERIAL.lock().unwrap(); + let root = TempRoot::new(); + let path = root.manifest(); + let target = root.0.join("missing-target.json"); + symlink(&target, &path).unwrap(); + + let error = write_handle_file_for_tenant( + &path, + "opencode-claustrum", + &file(provider("deepseek", "opencode-claustrum", 'D')), + ManifestLockOptions::default(), + ) + .unwrap_err(); + + assert!(error.to_string().contains("regular file")); + assert!(fs::symlink_metadata(&path) + .unwrap() + .file_type() + .is_symlink()); + assert!(!target.exists()); + } + + #[test] + fn renewal_loss_aborts_before_manifest_rename() { + let _serial = TEST_SERIAL.lock().unwrap(); + let root = TempRoot::new(); + let path = root.manifest(); + write_handle_file_for_tenant( + &path, + "opencode-claustrum", + &file(provider("deepseek", "opencode-claustrum", 'D')), + ManifestLockOptions::default(), + ) + .unwrap(); + let before = fs::read(&path).unwrap(); + let options = ManifestLockOptions { + ttl: Duration::from_millis(100), + renew_every: Duration::from_millis(2), + before_manifest_rename: Some(Arc::new(|lock| { + fs::rename(lock, PathBuf::from(format!("{}.vanished", lock.display()))).unwrap(); + thread::sleep(Duration::from_millis(10)); + })), + ..short_options() + }; + + let error = write_handle_file_for_tenant( + &path, + "opencode-claustrum", + &file(provider("deepseek", "opencode-claustrum", 'E')), + options, + ) + .unwrap_err(); + + assert_eq!( + error.to_string(), + "manifest lock renewal failed; write aborted" + ); + assert_eq!(fs::read(&path).unwrap(), before); + } + + #[test] + fn missing_and_unparseable_owner_records_are_busy_without_eviction() { + let _serial = TEST_SERIAL.lock().unwrap(); + for source in [None, Some("{")] { + let root = TempRoot::new(); + let path = root.manifest(); + let lock = lock_path(&path); + fs::create_dir(&lock).unwrap(); + if let Some(source) = source { + fs::write(lock.join("owner"), source).unwrap(); + } + + let error = with_manifest_lock_with_options( + &path, + "opencode-claustrum", + ManifestLockOptions { + ttl: Duration::from_millis(25), + renew_every: Duration::from_millis(8), + retry_min: Duration::from_millis(2), + retry_max: Duration::from_millis(3), + ..ManifestLockOptions::default() + }, + |_| Ok(()), + ) + .unwrap_err(); + + assert!(error.to_string().contains("manifest lock busy")); + assert!(lock.exists()); + assert!(!fs::read_dir(&root.0) + .unwrap() + .filter_map(Result::ok) + .any(|entry| { entry.file_name().to_string_lossy().contains(".lock.stale-") })); + } + } +} diff --git a/crates/credentials-module/src/bin/cli_support/opencode_migration.rs b/crates/credentials-module/src/bin/cli_support/opencode_migration.rs new file mode 100644 index 0000000..505ae1b --- /dev/null +++ b/crates/credentials-module/src/bin/cli_support/opencode_migration.rs @@ -0,0 +1,871 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +use serde::Deserialize; +use serde_json::{json, Value}; + +use super::{ + commit_admin, credential_client, opencode_files, parse_inventory, request_admin_status, + store_op, CliError, GlobalArgs, +}; +use credentials_core::admin_ops::{AdminAuditOp, AdminOpBody, StoreMode, ADMIN_OP_SCHEMA_V1}; +use credentials_core::oauth::CUSTODY_TOMBSTONE_PREFIX as TOMBSTONE_PREFIX; +use credentials_core::record::{CredentialKind, VaultRecord}; + +const ACCOUNT: &str = "main"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum ProviderShape { + Api, + ApiEnv, + ApiDiscovery, + ApiMetadata, +} + +impl ProviderShape { + fn as_str(self) -> &'static str { + match self { + Self::Api => "api", + Self::ApiEnv => "api-env", + Self::ApiDiscovery => "api-discovery", + Self::ApiMetadata => "api-metadata", + } + } +} + +#[derive(Debug, Deserialize)] +struct ProviderShapeDefinition { + why: String, + if_forced: String, +} + +#[derive(Debug, Deserialize)] +struct ProviderShapeEntry { + shapes: Vec, + sites: Vec, +} + +#[derive(Debug, Deserialize)] +struct ProviderShapeTable { + version: u8, + shape_definitions: BTreeMap, + providers: BTreeMap, + examined_servable: BTreeMap, + maintainer_note: Vec, +} + +#[derive(Debug)] +pub(crate) struct UnsafeProviderShape { + shapes: Vec, + why: Vec, + if_forced: Vec, + sites: Vec, +} + +impl UnsafeProviderShape { + pub(crate) fn shape_names(&self) -> String { + self.shapes + .iter() + .map(|shape| shape.as_str()) + .collect::>() + .join(",") + } + + pub(crate) fn why(&self) -> String { + self.why.join(" | ") + } + + pub(crate) fn sites(&self) -> String { + self.sites.join(",") + } + + fn if_forced(&self) -> String { + self.if_forced.join(" | ") + } +} + +fn provider_shape_table() -> Result<&'static ProviderShapeTable, CliError> { + static TABLE: OnceLock> = OnceLock::new(); + TABLE + .get_or_init(|| { + let table: ProviderShapeTable = + serde_json::from_str(include_str!("opencode-provider-shapes.json")) + .map_err(|error| format!("provider shape table is invalid JSON: {error}"))?; + if table.version != 1 { + return Err(format!( + "provider shape table has unsupported version {}", + table.version + )); + } + if !table.examined_servable.contains_key("github-copilot") { + return Err( + "provider shape table must record github-copilot as examined and servable" + .into(), + ); + } + if table.maintainer_note.len() != 2 { + return Err( + "provider shape table must retain both derivation traps for maintainers".into(), + ); + } + for (provider, entry) in &table.providers { + if entry.shapes.is_empty() || entry.sites.is_empty() { + return Err(format!( + "provider shape table has an empty shape or site list for {provider}" + )); + } + for shape in &entry.shapes { + if *shape == ProviderShape::Api || !table.shape_definitions.contains_key(shape) + { + return Err(format!( + "provider shape table has an undefined non-api shape for {provider}" + )); + } + } + } + Ok(table) + }) + .as_ref() + .map_err(|error| CliError::Io(error.clone())) +} + +pub(crate) fn unsafe_provider_shape( + provider: &str, +) -> Result, CliError> { + let Some(entry) = provider_shape_table()?.providers.get(provider) else { + return Ok(None); + }; + if entry.shapes.as_slice() == [ProviderShape::Api] { + return Ok(None); + } + let definitions = &provider_shape_table()?.shape_definitions; + let mut why = Vec::new(); + let mut if_forced = Vec::new(); + for shape in &entry.shapes { + let definition = definitions.get(shape).ok_or_else(|| { + CliError::Io(format!( + "provider shape table is missing {}", + shape.as_str() + )) + })?; + why.push(definition.why.clone()); + if_forced.push(definition.if_forced.clone()); + } + Ok(Some(UnsafeProviderShape { + shapes: entry.shapes.clone(), + why, + if_forced, + sites: entry.sites.clone(), + })) +} + +struct MigrationArgs { + dry_run: bool, + replace: bool, + force_shape: bool, + restore: Option, + auth_file: PathBuf, + handle_file: PathBuf, + providers: Vec, + serve_by: String, +} + +pub fn cmd_migrate_opencode(global: &GlobalArgs, raw: &[String]) -> Result<(), CliError> { + let args = MigrationArgs::parse(raw)?; + if let Some(provider) = &args.restore { + return restore_provider(global, &args, provider); + } + migrate_providers(global, &args) +} + +impl MigrationArgs { + fn parse(raw: &[String]) -> Result { + let mut dry_run = false; + let mut replace = false; + let mut force_shape = false; + let mut restore = None; + let mut auth_file = None; + let mut handle_file = None; + let mut providers = Vec::new(); + let mut serve_by = None; + let mut index = 0; + while index < raw.len() { + match raw[index].as_str() { + "--dry-run" => dry_run = true, + "--replace" => replace = true, + "--force-shape" => force_shape = true, + "--restore" | "--auth-file" | "--handle-file" | "--provider" | "--serve-by" => { + let value = raw + .get(index + 1) + .filter(|value| !value.starts_with("--")) + .ok_or_else(|| CliError::Usage(format!("{} requires a value", raw[index])))? + .clone(); + match raw[index].as_str() { + "--restore" => restore = Some(value), + "--auth-file" => auth_file = Some(PathBuf::from(value)), + "--handle-file" => handle_file = Some(PathBuf::from(value)), + "--provider" => providers.push(value), + "--serve-by" => serve_by = Some(value), + _ => unreachable!(), + } + index += 1; + } + _ => {} + } + index += 1; + } + if restore.is_some() && (dry_run || replace || force_shape || !providers.is_empty()) { + return Err(CliError::Usage( + "--restore is mutually exclusive with --dry-run, --replace, --force-shape, and --provider".into(), + )); + } + Ok(Self { + dry_run, + replace, + force_shape, + restore, + auth_file: auth_file.unwrap_or_else(opencode_files::default_auth_path), + handle_file: handle_file.unwrap_or_else(opencode_files::default_handle_path), + providers, + serve_by: serve_by.unwrap_or_else(|| "opencode-claustrum".into()), + }) + } +} + +fn migrate_providers(global: &GlobalArgs, args: &MigrationArgs) -> Result<(), CliError> { + let auth = opencode_files::read_auth_entries(&args.auth_file).map_err(files_error)?; + let providers = selected_api_providers(&auth, &args.providers)?; + if providers.is_empty() { + println!("no eligible OpenCode api entries"); + return Ok(()); + } + for provider in providers { + if let Some(shape) = unsafe_provider_shape(&provider)? { + if !args.force_shape { + println!( + "provider={provider} refused shape={} why={} source={}; use --force-shape to override. availability-only, sentinel non-secret.", + shape.shape_names(), + shape.why(), + shape.sites(), + ); + continue; + } + println!( + "provider={provider} force_shape shape={} consequence={}; availability-only, sentinel non-secret.", + shape.shape_names(), + shape.if_forced(), + ); + } + let entry = auth.get(&provider).expect("selected provider exists"); + if is_api_tombstone(entry, &provider) { + if args.dry_run { + println!("provider={provider} tombstone=api dry_run pending_revoke_check"); + } else { + let mut handles = read_handles_or_empty(&args.handle_file)?; + let recovered = + finalize_superseded(global, &mut handles, &provider, &args.handle_file)?; + println!( + "provider={provider} tombstone=api {}", + if recovered { "recovered" } else { "identical" } + ); + } + continue; + } + let material = api_material(entry, &provider)?; + if material.starts_with(TOMBSTONE_PREFIX.as_bytes()) { + println!("provider={provider} refused reserved prefix={TOMBSTONE_PREFIX}"); + continue; + } + migrate_one(global, args, &provider, material)?; + } + Ok(()) +} + +fn migrate_one( + global: &GlobalArgs, + args: &MigrationArgs, + provider: &str, + material: Vec, +) -> Result<(), CliError> { + let id = format!("apikey:{provider}:{ACCOUNT}"); + let status = parse_inventory(&request_admin_status(global)?)?; + let exists = status.iter().any(|(_, _, candidate)| candidate == &id); + let mut handles = read_handles_or_empty(&args.handle_file)?; + if !args.dry_run { + finalize_superseded(global, &mut handles, provider, &args.handle_file)?; + } + let known_handle = account_handle(&handles, provider, ACCOUNT, &id)?; + + if args.dry_run { + let verdict = if !exists { + "absent" + } else if known_handle.is_none() { + "requires_capability_mint" + } else { + "requires_capability_read" + }; + println!("provider={provider} credential_id={id} dry_run compare={verdict}"); + return Ok(()); + } + + let mut old_handle = known_handle; + let mut same = false; + if exists { + let handle = match old_handle.as_deref() { + Some(handle) => handle.to_owned(), + None => { + let handle = mint_then_persist(global, &id, |handle| { + update_handle(&mut handles, provider, &args.serve_by, &id, handle, false)?; + write_and_verify_handles(&args.handle_file, &handles)?; + Ok(handle.to_owned()) + })?; + old_handle = Some(handle.clone()); + handle + } + }; + match get_material(global, &handle)? { + Some(existing) => same = existing == material, + None => { + let replacement = mint_then_persist(global, &id, |replacement| { + update_handle( + &mut handles, + provider, + &args.serve_by, + &id, + replacement, + false, + )?; + write_and_verify_handles(&args.handle_file, &handles)?; + Ok(replacement.to_owned()) + })?; + old_handle = Some(replacement.clone()); + same = get_material(global, &replacement)?.ok_or_else(|| { + CliError::Io("a freshly minted capability was revoked".into()) + })? == material; + } + } + if !same && !args.replace { + return Err(CliError::Usage(format!( + "existing credential {id} differs; rerun with --replace" + ))); + } + } + + let outcome = if !exists { + commit_admin( + global, + store_op( + &id, + VaultRecord::new_static(CredentialKind::ApiKey, "opencode", material, None), + AdminAuditOp::Import, + StoreMode::Create, + ), + )?; + "created" + } else if same { + "identical" + } else { + let reread = opencode_files::read_auth_entries(&args.auth_file).map_err(files_error)?; + if reread + .get(provider) + .and_then(|entry| entry.get("key")) + .and_then(Value::as_str) + .map(str::as_bytes) + != Some(material.as_slice()) + { + return Err(CliError::Io( + "OpenCode auth entry changed before replacement".into(), + )); + } + commit_admin( + global, + store_op( + &id, + VaultRecord::new_static(CredentialKind::ApiKey, "opencode", material, None), + AdminAuditOp::Import, + StoreMode::ReplaceUnconditional, + ), + )?; + "replaced" + }; + + let replacement_required = !exists || outcome == "replaced"; + if replacement_required { + mint_then_persist(global, &id, |handle| { + update_handle(&mut handles, provider, &args.serve_by, &id, handle, true)?; + write_and_verify_handles(&args.handle_file, &handles)?; + Ok(()) + })? + } else { + let handle = old_handle + .clone() + .ok_or_else(|| CliError::Io("missing current handle after comparison".into()))?; + let superseded = + update_handle(&mut handles, provider, &args.serve_by, &id, &handle, false)?; + if superseded.as_deref() != Some(handle.as_str()) { + write_and_verify_handles(&args.handle_file, &handles)?; + } + } + + let tombstone = api_tombstone(provider); + opencode_files::write_auth_entry(&args.auth_file, provider, tombstone.clone()) + .map_err(files_error)?; + #[cfg(debug_assertions)] + if std::env::var("CK_OPENCODE_TEST_FAIL_TOMBSTONE_REREAD").as_deref() == Ok("1") { + return Err(CliError::Io( + "OpenCode files: auth entry did not persist exactly; re-run converges from the written tombstone" + .into(), + )); + } + opencode_files::verify_auth_written(&args.auth_file, provider, &tombstone) + .map_err(files_error)?; + finalize_superseded(global, &mut handles, provider, &args.handle_file)?; + println!( + "provider={provider} credential_id={id} {outcome} handle_file={} tombstone=api", + args.handle_file.display() + ); + Ok(()) +} + +fn restore_provider( + global: &GlobalArgs, + args: &MigrationArgs, + provider: &str, +) -> Result<(), CliError> { + let mut handles = read_handles_or_empty(&args.handle_file)?; + let provider_index = handles + .providers + .iter() + .position(|item| item.provider == provider) + .ok_or_else(|| CliError::Usage(format!("no handle entry for provider {provider}")))?; + if !matches!( + handles.providers[provider_index].shape, + opencode_files::HandleShape::Api + ) { + return Err(CliError::Usage(format!( + "restore for {provider} accepts only api entries" + ))); + } + let accounts = handles.providers[provider_index].accounts.clone(); + for account in accounts { + let mut handle = account.handle.clone(); + let material = match get_material(global, &handle) { + Ok(Some(material)) => material, + Ok(None) => { + handle = mint_then_persist(global, &account.credential_id, |handle| { + update_specific_handle(&mut handles, provider, &account.label, handle)?; + write_and_verify_handles(&args.handle_file, &handles)?; + Ok(handle.to_owned()) + })?; + get_material(global, &handle)? + .ok_or_else(|| CliError::Io("a freshly minted capability was revoked".into()))? + } + Err(CliError::Io(message)) if message == "credential needs reauthentication" => { + return Err(CliError::Io(format!( + "refusing restore for {}: vault record needs re-authentication", + account.credential_id + ))); + } + Err(error) => return Err(error), + }; + let key = String::from_utf8(material) + .map_err(|_| CliError::Io("api credential material was not UTF-8".into()))?; + let entry = json!({"type": "api", "key": key}); + opencode_files::write_auth_entry(&args.auth_file, provider, entry.clone()) + .map_err(files_error)?; + opencode_files::verify_auth_written(&args.auth_file, provider, &entry) + .map_err(files_error)?; + commit_admin( + global, + AdminOpBody::RevokeHandle { + v: ADMIN_OP_SCHEMA_V1, + handle, + }, + )?; + remove_account(&mut handles, provider, &account.label)?; + write_and_verify_handles(&args.handle_file, &handles)?; + } + println!( + "provider={provider} restored handle_file={}", + args.handle_file.display() + ); + Ok(()) +} + +fn selected_api_providers( + auth: &BTreeMap, + filters: &[String], +) -> Result, CliError> { + let candidates: Vec = if filters.is_empty() { + auth.iter() + .filter(|(_, entry)| entry.get("type").and_then(Value::as_str) == Some("api")) + .map(|(provider, _)| provider.clone()) + .collect() + } else { + let mut seen = BTreeSet::new(); + filters + .iter() + .filter(|provider| seen.insert((*provider).clone())) + .filter_map(|provider| auth.get(provider).map(|entry| (provider, entry))) + .filter(|(_, entry)| entry.get("type").and_then(Value::as_str) == Some("api")) + .map(|(provider, _)| provider.clone()) + .collect() + }; + for provider in filters { + if !auth.contains_key(provider) { + return Err(CliError::Usage(format!( + "OpenCode auth has no provider {provider}" + ))); + } + } + Ok(candidates) +} + +fn api_material(entry: &Value, provider: &str) -> Result, CliError> { + entry + .get("key") + .and_then(Value::as_str) + .filter(|key| !key.is_empty()) + .map(|key| key.as_bytes().to_vec()) + .ok_or_else(|| CliError::Usage(format!("OpenCode api entry for {provider} has no key"))) +} + +fn api_tombstone(provider: &str) -> Value { + json!({"type": "api", "key": format!("{TOMBSTONE_PREFIX}{provider}")}) +} + +pub(crate) fn is_api_tombstone(entry: &Value, provider: &str) -> bool { + entry == &api_tombstone(provider) +} + +pub(crate) fn read_handles_or_empty(path: &Path) -> Result { + if path.exists() { + opencode_files::read_handle_file(path).map_err(files_error) + } else { + Ok(opencode_files::HandleFile { + version: 1, + providers: Vec::new(), + }) + } +} + +fn account_handle( + handles: &opencode_files::HandleFile, + provider: &str, + label: &str, + credential_id: &str, +) -> Result, CliError> { + let Some(provider) = handles + .providers + .iter() + .find(|item| item.provider == provider) + else { + return Ok(None); + }; + if !matches!(provider.shape, opencode_files::HandleShape::Api) || provider.serve.is_empty() { + return Err(CliError::Io( + "handle provider is not an api custody entry".into(), + )); + } + match provider + .accounts + .iter() + .find(|account| account.label == label) + { + Some(account) if account.credential_id != credential_id => Err(CliError::Io( + "handle account credential id does not match the api main record".into(), + )), + Some(account) => Ok(Some(account.handle.clone())), + None => Ok(None), + } +} + +pub(crate) fn finalize_superseded( + global: &GlobalArgs, + handles: &mut opencode_files::HandleFile, + provider: &str, + handle_path: &Path, +) -> Result { + let pending: Vec = handles + .providers + .iter() + .find(|item| item.provider == provider) + .map(|item| { + item.accounts + .iter() + .flat_map(|account| account.superseded.iter().cloned()) + .collect() + }) + .unwrap_or_default(); + if pending.is_empty() { + return Ok(false); + } + for handle in pending { + commit_admin( + global, + AdminOpBody::RevokeHandle { + v: ADMIN_OP_SCHEMA_V1, + handle, + }, + )?; + } + let item = handles + .providers + .iter_mut() + .find(|item| item.provider == provider) + .ok_or_else(|| { + CliError::Io("handle provider disappeared before superseded revoke".into()) + })?; + for account in &mut item.accounts { + account.superseded.clear(); + } + write_and_verify_handles(handle_path, handles)?; + Ok(true) +} + +fn update_handle( + handles: &mut opencode_files::HandleFile, + provider: &str, + serve_by: &str, + credential_id: &str, + handle: &str, + retain_superseded: bool, +) -> Result, CliError> { + if let Some(item) = handles + .providers + .iter_mut() + .find(|item| item.provider == provider) + { + if !matches!(item.shape, opencode_files::HandleShape::Api) { + return Err(CliError::Io("provider handle shape is not api".into())); + } + item.serve = serve_by.into(); + if let Some(account) = item + .accounts + .iter_mut() + .find(|account| account.label == ACCOUNT) + { + if account.credential_id != credential_id { + return Err(CliError::Io( + "main handle account points at another credential".into(), + )); + } + let old = std::mem::replace(&mut account.handle, handle.into()); + if retain_superseded && old != handle && !account.superseded.contains(&old) { + account.superseded.push(old.clone()); + } + return Ok(Some(old)); + } + item.accounts.push(opencode_files::HandleAccount { + label: ACCOUNT.into(), + handle: handle.into(), + credential_id: credential_id.into(), + superseded: Vec::new(), + }); + return Ok(None); + } + handles.providers.push(opencode_files::HandleProvider { + provider: provider.into(), + shape: opencode_files::HandleShape::Api, + serve: serve_by.into(), + accounts: vec![opencode_files::HandleAccount { + label: ACCOUNT.into(), + handle: handle.into(), + credential_id: credential_id.into(), + superseded: Vec::new(), + }], + }); + Ok(None) +} + +fn update_specific_handle( + handles: &mut opencode_files::HandleFile, + provider: &str, + label: &str, + handle: &str, +) -> Result<(), CliError> { + let account = handles + .providers + .iter_mut() + .find(|item| item.provider == provider) + .and_then(|item| { + item.accounts + .iter_mut() + .find(|account| account.label == label) + }) + .ok_or_else(|| CliError::Io("handle account disappeared before restore".into()))?; + account.handle = handle.into(); + Ok(()) +} + +pub(crate) fn remove_account( + handles: &mut opencode_files::HandleFile, + provider: &str, + label: &str, +) -> Result<(), CliError> { + let index = handles + .providers + .iter() + .position(|item| item.provider == provider) + .ok_or_else(|| CliError::Io("handle provider disappeared before restore".into()))?; + let accounts = &mut handles.providers[index].accounts; + let account = accounts + .iter() + .position(|account| account.label == label) + .ok_or_else(|| CliError::Io("handle account disappeared before restore".into()))?; + accounts.remove(account); + if handles.providers[index].accounts.is_empty() { + handles.providers.remove(index); + } + Ok(()) +} + +pub(crate) fn write_and_verify_handles( + path: &Path, + handles: &opencode_files::HandleFile, +) -> Result<(), CliError> { + #[cfg(debug_assertions)] + if std::env::var("CK_OPENCODE_TEST_FAIL_HANDLE_WRITE").as_deref() == Ok("1") { + return Err(CliError::Io( + "OpenCode files: handle file write interrupted; re-run converges from the stored credential" + .into(), + )); + } + opencode_files::write_handle_file(path, handles).map_err(files_error)?; + opencode_files::verify_handle_written(path, handles).map_err(files_error) +} + +pub(crate) fn write_and_verify_handles_for_tenant( + path: &Path, + tenant: &str, + handles: &opencode_files::HandleFile, +) -> Result<(), CliError> { + #[cfg(debug_assertions)] + if std::env::var("CK_OPENCODE_TEST_FAIL_PLUGIN_MANIFEST_WRITE").as_deref() == Ok("1") { + return Err(CliError::Io( + "OpenCode files: tenant manifest write interrupted; re-run converges from the stored credential" + .into(), + )); + } + opencode_files::write_handle_file_for_tenant_default(path, tenant, handles) + .map_err(files_error)?; + opencode_files::verify_handle_written_for_tenant(path, tenant, handles).map_err(files_error) +} + +pub(crate) fn mint_handle(global: &GlobalArgs, id: &str) -> Result { + commit_admin( + global, + AdminOpBody::MintHandle { + v: ADMIN_OP_SCHEMA_V1, + id: id.into(), + }, + )? + .get("handle") + .and_then(Value::as_str) + .filter(|handle| !handle.is_empty()) + .map(Into::into) + .ok_or_else(|| CliError::Io("mint did not return a handle".into())) +} + +/// Mints a handle for `id` and runs `persist` with it. This is for handles meant to outlive the +/// call: if `persist` fails, the handle is revoked before the error propagates, so a failed file +/// write never strands a live bearer capability. Use `with_scoped_handle` when the handle is only +/// needed during the closure and must die before it returns. If the revoke also fails, the returned +/// error names the credential id and the two commands that close the window (`ck auth audit` shows +/// the mint; `ck auth revoke-all-handles ` revokes it). The `superseded` journal covers the +/// replace-then-crash case; this covers mint-then-crash, whose journal precondition is what failed. +pub(crate) fn mint_then_persist( + global: &GlobalArgs, + id: &str, + persist: impl FnOnce(&str) -> Result, +) -> Result { + let handle = mint_handle(global, id)?; + match persist(&handle) { + Ok(value) => Ok(value), + Err(persist_error) => match revoke_handle(global, &handle) { + Ok(()) => Err(CliError::Io(format!( + "failed to persist minted handle for credential {id}: {persist_error}; minted handle was revoked; retry the operation" + ))), + Err(revoke_error) => Err(CliError::Io(format!( + "failed to persist minted handle for credential {id}: {persist_error}; cleanup also failed: {revoke_error}; run `ck auth audit` to find the mint, then `ck auth revoke-all-handles {id}` to revoke it" + ))), + }, + } +} + +/// Mints a handle for a single operation and revokes it on every return path. +pub(crate) fn with_scoped_handle( + global: &GlobalArgs, + id: &str, + operation: impl FnOnce(&str) -> Result, +) -> Result { + let handle = mint_handle(global, id)?; + match operation(&handle) { + Ok(value) => revoke_handle(global, &handle).map_err(|revoke_error| { + CliError::Io(format!( + "verification handle cleanup failed for credential {id}: {revoke_error}; run `ck auth audit` to find the mint, then `ck auth revoke-all-handles {id}` to revoke it" + )) + }) + .map(|()| value), + Err(operation_error) => match revoke_handle(global, &handle) { + Ok(()) => Err(operation_error), + Err(revoke_error) => Err(CliError::Io(format!( + "operation using verification handle for credential {id} failed: {operation_error}; cleanup also failed: {revoke_error}; run `ck auth audit` to find the mint, then `ck auth revoke-all-handles {id}` to revoke it" + ))), + }, + } +} + +pub(crate) fn revoke_handle(global: &GlobalArgs, handle: &str) -> Result<(), CliError> { + #[cfg(debug_assertions)] + if std::env::var("CK_OPENCODE_TEST_FAIL_REVOKE").as_deref() == Ok("1") { + return Err(CliError::Io( + "OpenCode test seam: handle revoke interrupted".into(), + )); + } + commit_admin( + global, + AdminOpBody::RevokeHandle { + v: ADMIN_OP_SCHEMA_V1, + handle: handle.into(), + }, + )?; + Ok(()) +} + +pub(crate) fn revoke_all_handles(global: &GlobalArgs, id: &str) -> Result<(), CliError> { + commit_admin( + global, + AdminOpBody::RevokeAllHandles { + v: ADMIN_OP_SCHEMA_V1, + id: id.into(), + }, + )?; + Ok(()) +} + +pub(crate) fn get_material(global: &GlobalArgs, handle: &str) -> Result>, CliError> { + #[cfg(debug_assertions)] + if std::env::var("CK_OPENCODE_TEST_FAIL_GET_MATERIAL").as_deref() == Ok("1") { + return Err(CliError::Io( + "OpenCode test seam: material read interrupted".into(), + )); + } + let connection = global.subc_conn.as_deref().ok_or_else(|| { + CliError::Usage("migrate-opencode needs --subc for capability reads".into()) + })?; + match credential_client::get_online(connection, &global.data_dir, handle) { + Ok(credential) => Ok(Some(credential.payload)), + Err(credential_client::CredentialReadError::NotFound) => Ok(None), + Err(credential_client::CredentialReadError::NeedsReauth) => { + Err(CliError::Io("credential needs reauthentication".into())) + } + Err(error) => Err(CliError::Io(error.to_string())), + } +} + +fn files_error(error: opencode_files::OpenCodeFilesError) -> CliError { + CliError::Io(format!("OpenCode files: {error}")) +} diff --git a/crates/credentials-module/src/bin/cli_support/opencode_plugin_migration.rs b/crates/credentials-module/src/bin/cli_support/opencode_plugin_migration.rs new file mode 100644 index 0000000..03f5a07 --- /dev/null +++ b/crates/credentials-module/src/bin/cli_support/opencode_plugin_migration.rs @@ -0,0 +1,459 @@ +use std::{ + collections::{BTreeMap, BTreeSet}, + path::{Path, PathBuf}, + time::{SystemTime, UNIX_EPOCH}, +}; + +use serde::Deserialize; +use serde_json::json; + +use super::{ + commit_admin, opencode_files, opencode_migration, parse_inventory, request_admin_status, + store_op, CliError, GlobalArgs, +}; +use credentials_core::{ + admin_ops::{AdminAuditOp, StoreMode}, + credential_id::{default_refresh_adapter, AuthMethod}, + oauth::{OAuthCredential, CUSTODY_TOMBSTONE_PREFIX}, + record::{RecordIdentity, VaultRecord}, +}; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct PluginExport { + version: u8, + provider: String, + serve: String, + accounts: Vec, +} + +#[derive(Clone, Deserialize)] +#[serde(deny_unknown_fields)] +struct PluginAccount { + label: String, + kind: String, + access: String, + refresh: String, + expires_ms: i64, + #[serde(default)] + account_id: Option, + #[serde(default)] + email: Option, +} + +struct PluginMigrationArgs { + serve: String, + provider: String, + from: PathBuf, + replace: bool, + skip_existing: bool, + dry_run: bool, + allow_expired: bool, +} + +pub(crate) fn cmd_migrate_plugin(global: &GlobalArgs, raw: &[String]) -> Result<(), CliError> { + let args = PluginMigrationArgs::parse(raw)?; + let export = read_export(&args.from)?; + let adapter = validate_export(&args, &export)?; + + let mut handles = + opencode_migration::read_handles_or_empty(&opencode_files::default_handle_path())?; + validate_manifest_block(&handles, &args, &export)?; + let status = parse_inventory(&request_admin_status(global)?)?; + let versions: BTreeMap<_, _> = status + .into_iter() + .map(|(_, version, id)| (id, version)) + .collect(); + validate_existing(&versions, &args, &export)?; + + if args.dry_run { + for account in &export.accounts { + let id = credential_id(&args.provider, &account.label); + let exists = versions.contains_key(&id); + let action = if exists { + if args.replace { + "replace" + } else { + "skip" + } + } else { + "import" + }; + println!( + "{}: credential_id={} exists={} action={} identity_present={}", + account.label, + id, + exists, + action, + account.account_id.is_some() || account.email.is_some(), + ); + } + println!( + "summary: dry-run {} account(s), no writes", + export.accounts.len() + ); + return Ok(()); + } + + let handle_path = opencode_files::default_handle_path(); + let mut imported = 0; + let mut replaced = 0; + let mut skipped = 0; + for account in &export.accounts { + let id = credential_id(&args.provider, &account.label); + let exists = versions.contains_key(&id); + if exists && args.skip_existing { + println!("{}: skipped (exists)", account.label); + skipped += 1; + continue; + } + + let record = oauth_record(&args.provider, account, &adapter)?; + let new_version = versions.get(&id).copied().unwrap_or(0) + 1; + let outcome = if exists { "replaced" } else { "imported" }; + commit_admin( + global, + store_op( + &id, + record, + AdminAuditOp::Import, + if exists { + StoreMode::ReplaceUnconditional + } else { + StoreMode::Create + }, + ), + )?; + opencode_migration::mint_then_persist(global, &id, |handle| { + insert_manifest_account(&mut handles, &args, account, &id, handle)?; + opencode_migration::write_and_verify_handles_for_tenant( + &handle_path, + &args.serve, + &handles, + ) + })?; + finalize_replaced_handle(global, &mut handles, &args, &handle_path)?; + println!( + "{}: {outcome} {id} v{new_version}, handle written", + account.label + ); + if exists { + replaced += 1; + } else { + imported += 1; + } + } + println!( + "summary: imported={imported} replaced={replaced} skipped={skipped} total={}", + export.accounts.len() + ); + Ok(()) +} + +impl PluginMigrationArgs { + fn parse(raw: &[String]) -> Result { + let serve = value(raw, "--serve")?; + let provider = value(raw, "--provider")?; + let from = PathBuf::from(value(raw, "--from")?); + let replace = flag(raw, "--replace"); + let skip_existing = flag(raw, "--skip-existing"); + if replace && skip_existing { + return Err(CliError::Usage( + "--replace and --skip-existing are mutually exclusive".into(), + )); + } + Ok(Self { + serve, + provider, + from, + replace, + skip_existing, + dry_run: flag(raw, "--dry-run"), + allow_expired: flag(raw, "--allow-expired"), + }) + } +} + +fn read_export(path: &Path) -> Result { + let bytes = opencode_files::read_secret_file(path, "plugin export").map_err(|_| { + CliError::Usage("plugin export must be a regular 0600 file no larger than 256 KiB".into()) + })?; + serde_json::from_slice(&bytes).map_err(|_| CliError::Usage("plugin export is invalid".into())) +} + +fn validate_export(args: &PluginMigrationArgs, export: &PluginExport) -> Result { + if export.version != 1 { + return Err(CliError::Usage( + "refusing plugin export: version must be 1".into(), + )); + } + if export.provider != args.provider { + return Err(CliError::Usage( + "refusing plugin export: provider does not match --provider".into(), + )); + } + if export.serve != args.serve { + return Err(CliError::Usage( + "refusing plugin export: serve does not match --serve".into(), + )); + } + opencode_files::validate_manifest_label(&args.serve).map_err(|_| { + CliError::Usage("refusing plugin export: serve is not a valid tenant label".into()) + })?; + opencode_files::validate_manifest_label(&args.provider).map_err(|_| { + CliError::Usage("refusing plugin export: provider is not a valid label".into()) + })?; + let adapter = + default_refresh_adapter(Some(AuthMethod::Oauth), &args.provider).ok_or_else(|| { + CliError::Usage(format!( + "refusing plugin export: no refresh adapter for provider '{}'", + args.provider + )) + })?; + let now = now_ms()?; + let mut labels = BTreeSet::new(); + for account in &export.accounts { + if account.kind != "oauth" { + return Err(CliError::Usage( + "refusing plugin export: account kind must be oauth".into(), + )); + } + opencode_files::validate_manifest_label(&account.label).map_err(|_| { + CliError::Usage(format!( + "refusing plugin export: label '{}' is invalid", + account.label + )) + })?; + if account.label == "main" { + return Err(CliError::Usage( + "refusing plugin export: label 'main' must not enter through migrate-plugin".into(), + )); + } + if !labels.insert(&account.label) { + return Err(CliError::Usage(format!( + "refusing plugin export: duplicate label '{}'", + account.label + ))); + } + if account.access.starts_with(CUSTODY_TOMBSTONE_PREFIX) + || account.refresh.starts_with(CUSTODY_TOMBSTONE_PREFIX) + { + return Err(CliError::Usage( + "refusing plugin export: reserved tombstone prefix".into(), + )); + } + if account.expires_ms < now && !args.allow_expired { + return Err(CliError::Usage(format!( + "refusing plugin export: label '{}' is expired; rerun with --allow-expired", + account.label + ))); + } + if account.expires_ms < now { + eprintln!( + "{}: expired export accepted under --allow-expired", + account.label + ); + } + } + Ok(adapter) +} + +fn validate_manifest_block( + handles: &opencode_files::HandleFile, + args: &PluginMigrationArgs, + export: &PluginExport, +) -> Result<(), CliError> { + let Some(block) = handles + .providers + .iter() + .find(|block| block.provider == args.provider) + else { + return Ok(()); + }; + if block.serve != args.serve { + return Err(CliError::Usage(format!( + "manifest provider '{}' belongs to tenant '{}'", + args.provider, block.serve + ))); + } + if !matches!(block.shape, opencode_files::HandleShape::Oauth) { + return Err(CliError::Usage(format!( + "manifest provider '{}' is not an oauth block", + args.provider + ))); + } + for account in &export.accounts { + if block + .accounts + .iter() + .any(|entry| entry.label == account.label) + && !args.replace + && !args.skip_existing + { + return Err(CliError::Usage(format!( + "manifest account '{}' already exists; rerun with --replace or --skip-existing", + account.label + ))); + } + } + Ok(()) +} + +fn validate_existing( + versions: &BTreeMap, + args: &PluginMigrationArgs, + export: &PluginExport, +) -> Result<(), CliError> { + for account in &export.accounts { + let id = credential_id(&args.provider, &account.label); + if versions.contains_key(&id) && !args.replace && !args.skip_existing { + return Err(CliError::Usage(format!( + "existing credential {id}; rerun with --replace or --skip-existing" + ))); + } + } + Ok(()) +} + +fn oauth_record( + provider: &str, + account: &PluginAccount, + adapter: &str, +) -> Result { + let raw = serde_json::to_vec(&json!({ + provider: { + "type": "oauth", + "access": account.access, + "refresh": account.refresh, + "expires": account.expires_ms, + } + })) + .map_err(|_| CliError::Usage("plugin export is invalid".into()))?; + let oauth = OAuthCredential::import_provider("opencode", &raw, provider) + .map_err(|_| CliError::Usage("plugin export is invalid".into()))?; + let record = VaultRecord::new_oauth( + "opencode", + adapter, + oauth.clone(), + oauth.access_token.into_bytes(), + ); + if account.account_id.is_some() || account.email.is_some() { + Ok(record.with_identity(RecordIdentity { + account_id: account.account_id.clone(), + email: account.email.clone(), + org_name: None, + })) + } else { + Ok(record) + } +} + +fn insert_manifest_account( + handles: &mut opencode_files::HandleFile, + args: &PluginMigrationArgs, + account: &PluginAccount, + credential_id: &str, + handle: &str, +) -> Result<(), CliError> { + if let Some(block) = handles + .providers + .iter_mut() + .find(|block| block.provider == args.provider) + { + let entry = block + .accounts + .iter_mut() + .find(|entry| entry.label == account.label); + if let Some(entry) = entry { + if !args.replace { + return Err(CliError::Usage(format!( + "manifest account '{}' already exists; rerun with --replace", + account.label + ))); + } + if entry.credential_id != credential_id { + return Err(CliError::Io( + "manifest account points at another credential".into(), + )); + } + let old = std::mem::replace(&mut entry.handle, handle.into()); + if old != handle && !entry.superseded.contains(&old) { + entry.superseded.push(old); + } + return Ok(()); + } + block.accounts.push(opencode_files::HandleAccount { + label: account.label.clone(), + handle: handle.into(), + credential_id: credential_id.into(), + superseded: Vec::new(), + }); + return Ok(()); + } + handles.providers.push(opencode_files::HandleProvider { + provider: args.provider.clone(), + shape: opencode_files::HandleShape::Oauth, + serve: args.serve.clone(), + accounts: vec![opencode_files::HandleAccount { + label: account.label.clone(), + handle: handle.into(), + credential_id: credential_id.into(), + superseded: Vec::new(), + }], + }); + Ok(()) +} + +fn finalize_replaced_handle( + global: &GlobalArgs, + handles: &mut opencode_files::HandleFile, + args: &PluginMigrationArgs, + path: &Path, +) -> Result<(), CliError> { + let pending: Vec = handles + .providers + .iter() + .find(|block| block.provider == args.provider) + .into_iter() + .flat_map(|block| block.accounts.iter()) + .flat_map(|account| account.superseded.iter().cloned()) + .collect(); + for handle in pending { + opencode_migration::revoke_handle(global, &handle)?; + } + if let Some(block) = handles + .providers + .iter_mut() + .find(|block| block.provider == args.provider) + { + for account in &mut block.accounts { + account.superseded.clear(); + } + opencode_migration::write_and_verify_handles_for_tenant(path, &args.serve, handles)?; + } + Ok(()) +} + +fn credential_id(provider: &str, label: &str) -> String { + format!("oauth:{provider}:{label}") +} + +fn now_ms() -> Result { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis() as i64) + .map_err(|_| CliError::Io("system clock is before UNIX epoch".into())) +} + +fn value(raw: &[String], flag_name: &str) -> Result { + raw.iter() + .position(|arg| arg == flag_name) + .and_then(|index| raw.get(index + 1)) + .filter(|value| !value.starts_with("--")) + .cloned() + .ok_or_else(|| CliError::Usage(format!("{flag_name} requires a value"))) +} + +fn flag(raw: &[String], wanted: &str) -> bool { + raw.iter().any(|arg| arg == wanted) +} diff --git a/crates/credentials-module/src/bin/cli_support/route_client.rs b/crates/credentials-module/src/bin/cli_support/route_client.rs new file mode 100644 index 0000000..02868bb --- /dev/null +++ b/crates/credentials-module/src/bin/cli_support/route_client.rs @@ -0,0 +1,166 @@ +use std::{path::Path, time::Duration}; + +use credentials_core::MODULE_ID; +use serde_json::{json, Value}; +use subc_protocol::{BindIdentity, Flags, Frame, FrameType, Priority, RouteTarget}; +use subc_transport::{authenticate_client, connection_file, read_frame, write_frame}; +use tokio::net::TcpStream; + +const CONNECT_TIMEOUT: Duration = Duration::from_secs(3); +const RPC_TIMEOUT: Duration = Duration::from_secs(15); + +pub struct OpenRoute { + pub stream: TcpStream, + pub channel: u16, + pub epoch: u32, +} + +pub async fn connect(connection_file_path: &Path) -> Result { + let conn = connection_file::read_for_client(connection_file_path) + .map_err(|e| format!("no subc connection file: {e}"))?; + let endpoint = conn + .endpoints + .first() + .ok_or_else(|| "connection file has no endpoint".to_string())?; + let mut stream = match tokio::time::timeout( + CONNECT_TIMEOUT, + TcpStream::connect((endpoint.host.as_str(), endpoint.port)), + ) + .await + { + Ok(Ok(stream)) => stream, + Ok(Err(e)) => return Err(format!("connect: {e}")), + Err(_) => return Err("connect timed out".into()), + }; + authenticate_client(&mut stream, &conn, CONNECT_TIMEOUT) + .await + .map_err(|e| format!("client handshake: {e}"))?; + Ok(stream) +} + +pub async fn catalog_has_module(stream: &mut TcpStream) -> Result { + let frame = control_request(1, json!({ "op": "catalog.list" })); + write_frame(stream, &frame) + .await + .map_err(|e| format!("write catalog.list: {e}"))?; + let response = read_control_response(stream, 1).await?; + let value: Value = serde_json::from_slice(&response.body).map_err(|e| e.to_string())?; + Ok(value["modules"] + .as_array() + .map(|modules| { + modules + .iter() + .any(|module| module["module_id"] == MODULE_ID) + }) + .unwrap_or(false)) +} + +pub async fn open_route( + stream: TcpStream, + project_root: &Path, + harness: &str, + session: &str, +) -> Result { + let mut stream = stream; + let target = RouteTarget::ManagementSurface { + module_id: MODULE_ID.to_string(), + }; + let identity = BindIdentity { + project_root: project_root.to_path_buf(), + harness: harness.to_string(), + session: session.to_string(), + }; + let frame = control_request( + 2, + json!({ "op": "route.open", "target": target, "identity": identity }), + ); + write_frame(&mut stream, &frame) + .await + .map_err(|e| format!("write route.open: {e}"))?; + let response = read_control_response(&mut stream, 2).await?; + if response.header.ty == FrameType::Error { + return Err(error_reason(&response.body)); + } + let value: Value = serde_json::from_slice(&response.body).map_err(|e| e.to_string())?; + let channel = value["route_channel"] + .as_u64() + .and_then(|channel| u16::try_from(channel).ok()) + .ok_or_else(|| "route.open returned no route_channel".to_string())?; + let epoch = value["route_epoch"] + .as_u64() + .and_then(|epoch| u32::try_from(epoch).ok()) + .ok_or_else(|| "route.open returned no route_epoch".to_string())?; + Ok(OpenRoute { + stream, + channel, + epoch, + }) +} + +pub fn control_request(corr: u64, body: Value) -> Frame { + Frame::build( + FrameType::Request, + Flags::new(false, Priority::Passive, false), + 0, + 0, + corr, + serde_json::to_vec(&body).expect("JSON control request"), + ) + .expect("valid control frame") +} + +pub fn route_request(channel: u16, epoch: u32, corr: u64, body: Value) -> Frame { + Frame::build( + FrameType::Request, + Flags::new(false, Priority::Interactive, false), + channel, + epoch, + corr, + serde_json::to_vec(&body).expect("JSON route request"), + ) + .expect("valid route frame") +} + +pub async fn read_control_response(stream: &mut TcpStream, corr: u64) -> Result { + read_matching(stream, Some(0), corr).await +} + +pub async fn read_route_response(stream: &mut TcpStream, corr: u64) -> Result { + read_matching(stream, None, corr).await +} + +async fn read_matching( + stream: &mut TcpStream, + required_channel: Option, + corr: u64, +) -> Result { + tokio::time::timeout(RPC_TIMEOUT, async { + loop { + let frame = read_frame(stream) + .await + .map_err(|e| format!("read: {e}"))? + .ok_or_else(|| "connection closed".to_string())?; + if required_channel.is_none_or(|channel| frame.header.channel == channel) + && frame.header.corr == corr + && matches!(frame.header.ty, FrameType::Response | FrameType::Error) + { + return Ok(frame); + } + } + }) + .await + .map_err(|_| "response timed out".to_string())? +} + +pub fn error_reason(body: &[u8]) -> String { + serde_json::from_slice::(body) + .ok() + .and_then(|value| { + value + .get("message") + .or_else(|| value.get("detail")) + .and_then(Value::as_str) + .map(String::from) + }) + .unwrap_or_else(|| "module refused the operation".to_string()) +} diff --git a/crates/credentials-module/src/bin/credentials_cli.rs b/crates/credentials-module/src/bin/credentials_cli.rs index 2ecdd39..8a0da9f 100644 --- a/crates/credentials-module/src/bin/credentials_cli.rs +++ b/crates/credentials-module/src/bin/credentials_cli.rs @@ -10,8 +10,8 @@ //! `open_sqlite`: if the daemon is running it holds the lease, so the CLI's acquire //! fails and the operator is told to stop the daemon, making "while the daemon is //! stopped" a structural precondition rather than an honor-system one. A plain route -//! consumer (transport key only, no master key, no lease) cannot reach this path at -//! all — there is no running-vault admin surface. +//! consumer (transport key only, no master key, no lease) cannot reach these writes; +//! secret reads remain capability-gated on the consumer route. //! //! Every write goes through the epoch-fenced path and appends an audit-chain entry //! (flagged as an admin write) atomically with the mutation. Bootstrap (first run) @@ -24,6 +24,7 @@ //! `--payload-file` bytes exactly, and do not accept `--expires-ms`. //! mint-signing-key --id signing:[:] [--replace] //! import --source opencode|pi|antigravity --id --json +//! migrate-opencode [--dry-run] [--replace] [--force-shape] [--restore ] //! invalidate --id //! rotate-master-key //! mint-handle --id print a fresh handle (once) @@ -44,12 +45,26 @@ use std::process::ExitCode; mod admin_client; #[path = "cli_support/api_key_login.rs"] mod api_key_login; +#[allow(dead_code)] +#[path = "cli_support/credential_client.rs"] +mod credential_client; #[path = "cli_support/google_login.rs"] mod google_login; #[path = "cli_support/login_listener.rs"] mod login_listener; +#[path = "cli_support/opencode_accounts.rs"] +mod opencode_accounts; +#[allow(dead_code)] +#[path = "cli_support/opencode_files.rs"] +mod opencode_files; +#[path = "cli_support/opencode_migration.rs"] +mod opencode_migration; +#[path = "cli_support/opencode_plugin_migration.rs"] +mod opencode_plugin_migration; #[path = "cli_support/provider_login.rs"] mod provider_login; +#[path = "cli_support/route_client.rs"] +mod route_client; use base64::Engine; use cortexkit_store::{open_sqlite, Isolation, StorageBackend, StorageDescriptor, StoreError}; @@ -247,6 +262,9 @@ fn run() -> Result<(), CliError> { "put" => cmd_put(&global, &args), "mint-signing-key" => cmd_mint_signing_key(&global, &args), "import" => cmd_import(&global, &args), + "migrate-opencode" => opencode_migration::cmd_migrate_opencode(&global, &args), + "migrate-plugin" => opencode_plugin_migration::cmd_migrate_plugin(&global, &args), + "opencode-account" => opencode_accounts::cmd_opencode_account(&global, &args), "login" => cmd_login(&global, &args), "invalidate" => cmd_invalidate(&global, &args), "reactivate" => cmd_reactivate(&global, &args), @@ -322,8 +340,24 @@ fn reject_unknown_args(command: &str, args: &[String]) -> Result<(), CliError> { ], "mint-signing-key" => &["--id"], "import" => &["--source", "--provider", "--id", "--json", "--adapter"], + "migrate-opencode" => &[ + "--restore", + "--auth-file", + "--handle-file", + "--provider", + "--serve-by", + ], + "migrate-plugin" => &["--serve", "--provider", "--from"], + "opencode-account" => &[ + "--provider", + "--label", + "--key-file", + "--before", + "--handle-file", + ], "login" => &["--provider", "--id", "--payload-file", "--account"], - "invalidate" | "reactivate" | "mint-handle" | "revoke-all-handles" | "remove" => &["--id"], + "invalidate" | "reactivate" | "revoke-all-handles" | "remove" => &["--id"], + "mint-handle" => &["--id", "--out"], "logout" => &["--provider", "--id"], "revoke-handle" => &["--handle"], "grant" | "revoke-grant" => &["--principal", "--prefix", "--operation"], @@ -339,11 +373,22 @@ fn reject_unknown_args(command: &str, args: &[String]) -> Result<(), CliError> { "mint-signing-key" => &["--replace"], "import" => &["--replace"], "login" => &["--replace", "--no-listener", "--device"], + "migrate-opencode" => &["--dry-run", "--replace", "--force-shape"], + "migrate-plugin" => &[ + "--dry-run", + "--replace", + "--skip-existing", + "--allow-expired", + ], _ => &[], }; let mut i = 0; while i < args.len() { let arg = &args[i]; + if command == "opencode-account" && matches!(arg.as_str(), "add" | "remove" | "list") { + i += 1; + continue; + } if bool_flags.contains(&arg.as_str()) { i += 1; continue; @@ -380,6 +425,8 @@ fn usage_short() -> String { put ingest an api key, session cookie, or opaque secret\n\ mint-signing-key generate and custody a new Ed25519 signing key\n\ import import from opencode/pi/gemini-cli/antigravity\n\ + migrate-opencode custody OpenCode api auth entries idempotently\n\ + opencode-account add/remove/list labeled OpenCode api accounts\n\ mint-handle mint a capability handle for a credential\n\ revoke-handle revoke one capability handle\n\ revoke-all-handles revoke every handle for a credential\n\ @@ -504,11 +551,57 @@ fn help_verb(verb: &str) -> String { --adapter overrides the method-derived refresh adapter;\n\ --replace overwrites an existing id (fix a wrong-source import; keeps handles)." } + "migrate-opencode" => { + "ck auth migrate-opencode [--dry-run] [--replace] [--force-shape] [--restore ]\n\ + \x20 [--auth-file ] [--handle-file ] [--provider ]...\n\ + \x20 [--serve-by ]\n\ + \n\ + Move OpenCode api entries into the vault as apikey::main, write a\n\ + capability handle file, then replace the auth entry with a provider tombstone.\n\ + Re-running identical material is a no-op; different material refuses unless\n\ + --replace is explicit. --provider is repeatable and preserves the requested\n\ + provider order. OAuth and wellknown entries are skipped by default.\n\ + \n\ + --dry-run prints non-secret compare verdicts and stops before every write.\n\ + Providers whose api key leaves the generic fetch seam are refused with source\n\ + citations. --force-shape overrides that availability-only refusal and prints the\n\ + concrete sentinel consequence.\n\ + --restore safely writes an api entry back, revokes recorded handles,\n\ + and removes that provider from the handle file. --restore cannot combine with\n\ + --dry-run or --replace. The default --serve-by is opencode-claustrum." + } + "migrate-plugin" => { + "ck auth migrate-plugin --serve --provider --from \n\ + \x20 [--replace | --skip-existing] [--dry-run] [--allow-expired]\n\ + \n\ + Import one tenant plugin's normalized OAuth export into oauth::