diff --git a/bun.lock b/bun.lock index 42bc127..ed405b4 100644 --- a/bun.lock +++ b/bun.lock @@ -140,8 +140,14 @@ "name": "@humanlayer/fold-codex", "version": "0.0.0", "dependencies": { + "@aws-sdk/credential-providers": "catalog:", + "@aws/bedrock-token-generator": "catalog:", "@humanlayer/effect-ai-openai": "workspace:*", "@humanlayer/fold-core": "workspace:*", + "@smithy/config-resolver": "catalog:", + "@smithy/node-config-provider": "catalog:", + "@smithy/types": "catalog:", + "smol-toml": "catalog:", }, "devDependencies": { "@effect/platform-node": "catalog:", @@ -244,6 +250,8 @@ }, }, "catalog": { + "@aws-sdk/credential-providers": "3.1126.0", + "@aws/bedrock-token-generator": "1.1.0", "@effect/platform-node": "4.0.0-rc.112", "@effect/vitest": "4.0.0-rc.112", "@kitlangton/terminal-control": "0.3.1", @@ -251,11 +259,15 @@ "@opentui/keymap": "0.4.3", "@opentui/solid": "0.4.3", "@silvia-odwyer/photon-node": "0.3.4", + "@smithy/config-resolver": "4.7.2", + "@smithy/node-config-provider": "4.6.2", + "@smithy/types": "4.18.0", "@types/bun": "1.3.14", "@types/node": "26.1.0", "@types/turndown": "5.0.6", "@vitest/coverage-v8": "4.1.9", "effect": "4.0.0-rc.112", + "smol-toml": "1.8.0", "solid-js": "1.9.12", "turndown": "7.2.4", "typescript": "7.0.2", @@ -265,6 +277,44 @@ "packages": { "@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="], + "@aws-sdk/core": ["@aws-sdk/core@3.977.9", "", { "dependencies": { "@aws-sdk/types": "^3.974.5", "@aws-sdk/xml-builder": "^3.972.40", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.33.3", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.17.2", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-reqPFEQrZxDZpeGj4PFMepBeR5LGYHRqq/L0motTzgFkCRBA4rFdaVXDSLYyGHhxVz7sT2PDnPN9CluGSfgyJA=="], + + "@aws-sdk/credential-provider-cognito-identity": ["@aws-sdk/credential-provider-cognito-identity@3.972.69", "", { "dependencies": { "@aws-sdk/nested-clients": "^3.997.44", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-vpsh9VWmQVC/nsdzf72F2yUMBOFT1aq+hoLseYV03zjVXVHkjqSNJskFRKPFxc3MRFrHNbSSmOwsbYE15u9ecw=="], + + "@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.70", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-H404B7dJl2mCrBqahDEYsanB0xhdDp6tXnXcTUnXmmpy2Q3J0Ho0bUajZ2jr/RdwzCyS59Gi8xXIFwPLGBl6Uw=="], + + "@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.72", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-X98zYOrVOeuosCX+6ktf29FC2N2GHPLia7qv6mzPzTc+RPAuHWCDS++Z6JK7eGYqb/v6uaW7bAXaOvDBfol+0w=="], + + "@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.973.15", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/credential-provider-env": "^3.972.70", "@aws-sdk/credential-provider-http": "^3.972.72", "@aws-sdk/credential-provider-login": "^3.972.77", "@aws-sdk/credential-provider-process": "^3.972.70", "@aws-sdk/credential-provider-sso": "^3.973.14", "@aws-sdk/credential-provider-web-identity": "^3.972.76", "@aws-sdk/nested-clients": "^3.997.44", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/credential-provider-imds": "^4.4.16", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-Rykg6s5ceBuynMOGWgoowO4N+27JfnqXAnVaSunZl0hOO1XodSrxGNz6sCEbnmS0lAfQZDKyb3fbr46gSuv6Sg=="], + + "@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.77", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/nested-clients": "^3.997.44", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-Jb59xfEISoN5mmbnA+HYqdtrSX3CgCtJoof+V5D8/TgUI56W63GEEd5Y58WijU3Ou6+WEgaLD1feVzaRXV5IDQ=="], + + "@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.82", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.70", "@aws-sdk/credential-provider-http": "^3.972.72", "@aws-sdk/credential-provider-ini": "^3.973.15", "@aws-sdk/credential-provider-process": "^3.972.70", "@aws-sdk/credential-provider-sso": "^3.973.14", "@aws-sdk/credential-provider-web-identity": "^3.972.76", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/credential-provider-imds": "^4.4.16", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-znDkEOGXB8W3kG1LJUKP3foBZY/9qLM0eil/DxWXSp37XsdsRLQHE/d/OaCGGVgKpA6znR38h/+INk8do1FjiA=="], + + "@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.70", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-2ry03fGRJr4sV3jI+ocjj5JqALnFD6ymM5KiNCDZMvq8bX2GSbE0vji4aM43TVCl2nXqqLRZaUxdq/KeWRAY4Q=="], + + "@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.973.14", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/nested-clients": "^3.997.44", "@aws-sdk/token-providers": "3.1116.0", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-jkhg/8ocAAoc0RFyLMhCw+/zZh7gystQgd4F4hznNa8P4Cc501PQmxd+jGLiMHodPJ+7Zv/3znM62gZojyasmA=="], + + "@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.76", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/nested-clients": "^3.997.44", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-d3AGyVu759PGr35mEB2s22xxlNEA5rpdxtSPJthfPFJvoQ8dt357iVPECqWfUxXp1toJAvKmbtcIYVGigaGsCA=="], + + "@aws-sdk/credential-providers": ["@aws-sdk/credential-providers@3.1126.0", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/credential-provider-cognito-identity": "^3.972.69", "@aws-sdk/credential-provider-env": "^3.972.70", "@aws-sdk/credential-provider-http": "^3.972.72", "@aws-sdk/credential-provider-ini": "^3.973.15", "@aws-sdk/credential-provider-login": "^3.972.77", "@aws-sdk/credential-provider-node": "^3.972.82", "@aws-sdk/credential-provider-process": "^3.972.70", "@aws-sdk/credential-provider-sso": "^3.973.14", "@aws-sdk/credential-provider-web-identity": "^3.972.76", "@aws-sdk/nested-clients": "^3.997.44", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/credential-provider-imds": "^4.4.16", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-OWu4fqH48e6yUceFs1r0fgC88gpuD/MaFggKoRXXTrjT2VicQQSO9tqlCWd2qmQNAXbwfV8/HGeRm6l9P4FSRA=="], + + "@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.44", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/signature-v4-multi-region": "^3.996.46", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-NhEgryjlBF9w38ZXqGymQV28IhkYa1mKhlbYnqIis57AYwWGVYfUPgg/qC2rLRqOUfblxx++irvju10kVTa8Vw=="], + + "@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.46", "", { "dependencies": { "@aws-sdk/types": "^3.974.5", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-L+2xZTye/2T96f3lwCws0Zw6GG2JHZW9e8FpVgGBeeExSKyeoZ6CWRpBml/7DNiK/O26jrgPM9F+Ay8VkgzUWQ=="], + + "@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1116.0", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/nested-clients": "^3.997.44", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-ygIivKqh8aHzNkucOCXHyIBgBpLPfrSI0mCqXF+vLBsPTUKqj0VSqAY0GFPe7lQl4HntjOcQ+KSyS7oUV2C54Q=="], + + "@aws-sdk/types": ["@aws-sdk/types@3.974.5", "", { "dependencies": { "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ=="], + + "@aws-sdk/util-format-url": ["@aws-sdk/util-format-url@3.972.46", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "tslib": "^2.6.2" } }, "sha512-NeLdr/PaZVJhvtkUc+rKbn/i8hdYQL53ZfTZ43vd5YUsj53r2ZTPxac0n6+fZR0it04y7Z7aLDlG3jnkY0LmrQ=="], + + "@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.40", "", { "dependencies": { "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA=="], + + "@aws/bedrock-token-generator": ["@aws/bedrock-token-generator@1.1.0", "", { "dependencies": { "@aws-sdk/credential-providers": "^3.525.0", "@aws-sdk/util-format-url": ">=3.525.0", "@smithy/config-resolver": "^4.1.4", "@smithy/hash-node": ">=2.1.3", "@smithy/invalid-dependency": "^4.0.4", "@smithy/node-config-provider": "^4.1.3", "@smithy/protocol-http": ">=3.2.1", "@smithy/signature-v4": ">=2.1.3", "@smithy/types": ">=2.11.0" } }, "sha512-i+DkWnfdA4j4sffy9dI4k3OGoOWqN8CTGdtO4IZ3c0kpKYFr6KyqzqLQmoRNrF3ACFcWj6u+J6cbBQ97j9wx5w=="], + + "@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.3.0", "", {}, "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ=="], + "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], @@ -575,6 +625,28 @@ "@silvia-odwyer/photon-node": ["@silvia-odwyer/photon-node@0.3.4", "", {}, "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA=="], + "@smithy/config-resolver": ["@smithy/config-resolver@4.7.2", "", { "dependencies": { "@smithy/core": "^3.33.2", "tslib": "^2.6.2" } }, "sha512-Y1XfSefHIOub9762qm3ShafdlEE/Va8h3kLUeMq765fNeWeNLcOP2YUPr86H1SlyGwZTOqQ67RlBZPZ3k9Djgg=="], + + "@smithy/core": ["@smithy/core@3.33.3", "", { "dependencies": { "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg=="], + + "@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.5.2", "", { "dependencies": { "@smithy/core": "^3.33.2", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg=="], + + "@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.8.0", "", { "dependencies": { "@smithy/core": "^3.33.3", "@smithy/types": "^4.18.0", "tslib": "^2.6.2" } }, "sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA=="], + + "@smithy/hash-node": ["@smithy/hash-node@4.5.2", "", { "dependencies": { "@smithy/core": "^3.33.2", "tslib": "^2.6.2" } }, "sha512-OcD8fGClTkP0BWHVEAgUp1RZyCw8cKfqTPQ+DgrSF5jvR8zKkw2Aud79L4G/1Fu3QKLcsHExxRIPQCcKx7+xkg=="], + + "@smithy/invalid-dependency": ["@smithy/invalid-dependency@4.5.2", "", { "dependencies": { "@smithy/core": "^3.33.2", "tslib": "^2.6.2" } }, "sha512-VONOgtCxIXtwXrLVZPUdxOELYpkFzNizkpbQE5CrJ/OEh12Osx+LVXsLEAF+JcvVqPODmUoOUaovlOjCQHTOow=="], + + "@smithy/node-config-provider": ["@smithy/node-config-provider@4.6.2", "", { "dependencies": { "@smithy/core": "^3.33.2", "tslib": "^2.6.2" } }, "sha512-zMrXu/O5tPa7GLtra8L4wFG6DACcXT9QV4Ay+WEAjUhXm1dVq7c/q9Qv9gkJZNLY8hmQKg08778kDcxpKNMqOA=="], + + "@smithy/node-http-handler": ["@smithy/node-http-handler@4.12.1", "", { "dependencies": { "@smithy/core": "^3.33.3", "@smithy/types": "^4.18.0", "tslib": "^2.6.2" } }, "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw=="], + + "@smithy/protocol-http": ["@smithy/protocol-http@5.6.2", "", { "dependencies": { "@smithy/core": "^3.33.2", "tslib": "^2.6.2" } }, "sha512-Asd04MaxODN6FNY8EPTeCAM4kPNi3jDUAjZU0Y4F9rHvpLUrrUo7KLcxFgSthywFr6dZfIyDLIJda6jxmVTk5w=="], + + "@smithy/signature-v4": ["@smithy/signature-v4@5.7.3", "", { "dependencies": { "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow=="], + + "@smithy/types": ["@smithy/types@4.18.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-CgB6HHWer/vrKps24ulRIbpcpb7K4xAU7SkZ7YHzBPlwHsvsrCJFEXK421s+cJzX+ZrqtA/TuU5w1HzI7k9N8A=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], @@ -669,6 +741,8 @@ "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], + "bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="], + "brace-expansion": ["brace-expansion@2.1.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA=="], "browserslist": ["browserslist@4.28.6", "", { "dependencies": { "baseline-browser-mapping": "^2.10.42", "caniuse-lite": "^1.0.30001803", "electron-to-chromium": "^1.5.389", "node-releases": "^2.0.51", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw=="], @@ -871,6 +945,8 @@ "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + "smol-toml": ["smol-toml@1.8.0", "", {}, "sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ=="], + "solid-js": ["solid-js@1.9.12", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.0", "seroval-plugins": "~1.5.0" } }, "sha512-QzKaSJq2/iDrWR1As6MHZQ8fQkdOBf8GReYb7L5iKwMGceg7HxDcaOHk0at66tNgn9U2U7dXo8ZZpLIAmGMzgw=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], diff --git a/package.json b/package.json index b432a5f..5400919 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,8 @@ "packages/*" ], "catalog": { + "@aws/bedrock-token-generator": "1.1.0", + "@aws-sdk/credential-providers": "3.1126.0", "@kitlangton/terminal-control": "0.3.1", "@opentui/core": "0.4.3", "@opentui/keymap": "0.4.3", @@ -44,7 +46,11 @@ "@vitest/coverage-v8": "4.1.9", "typescript": "7.0.2", "@types/bun": "1.3.14", - "@types/node": "26.1.0" + "@types/node": "26.1.0", + "@smithy/config-resolver": "4.7.2", + "@smithy/node-config-provider": "4.6.2", + "@smithy/types": "4.18.0", + "smol-toml": "1.8.0" } }, "devDependencies": { diff --git a/packages/fold-codex/package.json b/packages/fold-codex/package.json index f008d7d..62d2cb0 100644 --- a/packages/fold-codex/package.json +++ b/packages/fold-codex/package.json @@ -15,8 +15,14 @@ "test:watch": "bun vitest" }, "dependencies": { + "@aws/bedrock-token-generator": "catalog:", + "@aws-sdk/credential-providers": "catalog:", "@humanlayer/effect-ai-openai": "workspace:*", - "@humanlayer/fold-core": "workspace:*" + "@humanlayer/fold-core": "workspace:*", + "@smithy/config-resolver": "catalog:", + "@smithy/node-config-provider": "catalog:", + "@smithy/types": "catalog:", + "smol-toml": "catalog:" }, "peerDependencies": { "@effect/platform-node": "catalog:", diff --git a/packages/fold-codex/src/BedrockAuth.ts b/packages/fold-codex/src/BedrockAuth.ts new file mode 100644 index 0000000..60e12c9 --- /dev/null +++ b/packages/fold-codex/src/BedrockAuth.ts @@ -0,0 +1,210 @@ +import { fromNodeProviderChain } from '@aws-sdk/credential-providers' +/** In-memory Bedrock bearer-token lifecycle and request authentication recovery. */ +import { getToken as generateAwsBedrockToken } from '@aws/bedrock-token-generator' +import type { AwsCredentialIdentity, AwsCredentialIdentityProvider } from '@smithy/types' +import { Clock, Effect, Predicate, Schema, Semaphore } from 'effect' +import { HttpClient, HttpClientError, HttpClientRequest } from 'effect/unstable/http' +import type { HttpClientResponse } from 'effect/unstable/http' + +export const BEDROCK_TOKEN_LIFETIME_SECONDS = 12 * 60 * 60 +export const BEDROCK_TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000 + +export class BedrockAuthError extends Schema.TaggedError()('BedrockAuthError', { + reason: Schema.Literals(['CredentialsUnavailable', 'TokenGenerationFailed']), + message: Schema.String, +}) {} + +export type BedrockAuthService = { + readonly getToken: Effect.Effect + readonly invalidate: Effect.Effect +} + +export type MakeBedrockAuthOptions = { + readonly profile?: string + readonly region: string + readonly credentialProviderFactory?: (profile?: string) => AwsCredentialIdentityProvider + readonly generateToken?: (input: { + readonly credentials: AwsCredentialIdentity + readonly region: string + readonly expiresInSeconds: number + }) => Promise + readonly tokenLifetimeSeconds?: number + readonly refreshBufferMs?: number +} + +type BedrockAuthState = { + generation: number + credentialProvider: AwsCredentialIdentityProvider | undefined + token: string | undefined + refreshAt: number | undefined +} + +const sanitizedAuthError = (reason: BedrockAuthError['reason']): BedrockAuthError => + new BedrockAuthError({ + reason, + message: + reason === 'CredentialsUnavailable' + ? 'AWS credentials are unavailable or expired. Refresh the AWS profile with your normal login command and retry.' + : 'Amazon Bedrock authentication token generation failed. Refresh AWS credentials and retry.', + }) + +/** Build one lazily refreshing auth instance for a model runtime. Generated keys are never persisted. */ +export const makeBedrockAuth = (options: MakeBedrockAuthOptions): Effect.Effect => { + const credentialProviderFactory = + options.credentialProviderFactory ?? + ((profile?: string) => + fromNodeProviderChain(profile === undefined ? { ignoreCache: true } : { profile, ignoreCache: true })) + const generateToken = options.generateToken ?? generateAwsBedrockToken + const tokenLifetimeSeconds = options.tokenLifetimeSeconds ?? BEDROCK_TOKEN_LIFETIME_SECONDS + const refreshBufferMs = options.refreshBufferMs ?? BEDROCK_TOKEN_REFRESH_BUFFER_MS + const semaphore = Semaphore.makeUnsafe(1) + const state: BedrockAuthState = { + generation: 0, + credentialProvider: undefined, + token: undefined, + refreshAt: undefined, + } + + const invalidate = Effect.sync(() => { + state.generation += 1 + state.credentialProvider = undefined + state.token = undefined + state.refreshAt = undefined + }) + + const refreshToken: Effect.Effect = Effect.suspend(() => + Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis + if (state.token !== undefined && state.refreshAt !== undefined && now < state.refreshAt) return state.token + const refreshGeneration = state.generation + + const provider = + state.credentialProvider ?? + (yield* Effect.try({ + try: () => credentialProviderFactory(options.profile), + catch: () => sanitizedAuthError('CredentialsUnavailable'), + })) + state.credentialProvider = provider + const credentials = yield* Effect.tryPromise({ + try: () => provider(), + catch: () => sanitizedAuthError('CredentialsUnavailable'), + }) + const token = yield* Effect.tryPromise({ + try: () => + generateToken({ credentials, region: options.region, expiresInSeconds: tokenLifetimeSeconds }), + catch: () => sanitizedAuthError('TokenGenerationFailed'), + }) + if (refreshGeneration !== state.generation) return yield* refreshToken + + const configuredExpiry = now + tokenLifetimeSeconds * 1000 + const credentialExpiry = credentials.expiration?.getTime() + const effectiveExpiry = + credentialExpiry === undefined ? configuredExpiry : Math.min(configuredExpiry, credentialExpiry) + const remainingLifetime = Math.max(0, effectiveExpiry - now) + state.token = token + state.refreshAt = + remainingLifetime <= refreshBufferMs + ? now + Math.floor(remainingLifetime / 2) + : effectiveExpiry - refreshBufferMs + return token + }), + ) + + const getToken = Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis + if (state.token !== undefined && state.refreshAt !== undefined && now < state.refreshAt) return state.token + return yield* semaphore.withPermit(refreshToken) + }).pipe(Effect.withSpan('fold.bedrockAuth.getToken')) + + return Effect.succeed({ getToken, invalidate: invalidate.pipe(Effect.withSpan('fold.bedrockAuth.invalidate')) }) +} + +const MAXIMUM_AUTH_ERROR_BODY_BYTES = 16_384 + +const webResponseSource = (response: unknown): Response | undefined => { + if (Predicate.hasProperty(response, 'source') && response.source instanceof Response) return response.source + return Predicate.hasProperty(response, 'original') ? webResponseSource(response.original) : undefined +} + +const readResponsePrefix = async (response: Response, maximumBytes: number): Promise => { + const body = response.clone().body + if (body === null) return '' + const reader = body.getReader() + const decoder = new TextDecoder() + let result = '' + let bytesRead = 0 + try { + while (bytesRead < maximumBytes) { + const { done, value } = await reader.read() + if (done) break + const remaining = maximumBytes - bytesRead + const chunk = value.byteLength > remaining ? value.subarray(0, remaining) : value + bytesRead += chunk.byteLength + result += decoder.decode(chunk, { stream: bytesRead < maximumBytes }) + } + result += decoder.decode() + return result + } finally { + if (bytesRead >= maximumBytes) void reader.cancel().catch(() => undefined) + reader.releaseLock() + } +} + +const isRecoverableAuthResponse = (response: HttpClientResponse.HttpClientResponse): Effect.Effect => { + if (response.status === 401) return Effect.succeed(true) + if (response.status !== 403) return Effect.succeed(false) + const source = webResponseSource(response) + if (source === undefined) return Effect.succeed(false) + return Effect.tryPromise(() => readResponsePrefix(source, MAXIMUM_AUTH_ERROR_BODY_BYTES)).pipe( + Effect.map( + (body) => + body.includes('ExpiredToken') || + body.includes('UnrecognizedClientException') || + body.includes('InvalidClientTokenId'), + ), + Effect.catch(() => Effect.succeed(false)), + ) +} + +const toHttpAuthError = ( + request: HttpClientRequest.HttpClientRequest, + cause: BedrockAuthError, +): HttpClientError.HttpClientError => + new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ + request, + cause, + description: cause.message, + }), + }) + +/** + * Authenticate each request with the current token. A qualifying auth response clears both token and + * AWS provider state, then retries the original request exactly once with a rebuilt authorization header. + */ +export const withBedrockAuth = (client: HttpClient.HttpClient, auth: BedrockAuthService): HttpClient.HttpClient => { + const executeAttempt = ( + request: HttpClientRequest.HttpClientRequest, + allowAuthRecovery: boolean, + ): Effect.Effect => + auth.getToken.pipe( + Effect.map((token) => request.pipe(HttpClientRequest.bearerToken(token))), + Effect.mapError((cause) => toHttpAuthError(request, cause)), + Effect.flatMap((authenticatedRequest) => client.execute(authenticatedRequest)), + Effect.flatMap((response) => { + if (!allowAuthRecovery) return Effect.succeed(response) + return isRecoverableAuthResponse(response).pipe( + Effect.flatMap((recoverable) => + recoverable + ? auth.invalidate.pipe(Effect.andThen(executeAttempt(request, false))) + : Effect.succeed(response), + ), + ) + }), + ) + + return HttpClient.makeWith( + Effect.flatMap((request: HttpClientRequest.HttpClientRequest) => executeAttempt(request, true)), + (request) => Effect.succeed(request), + ) +} diff --git a/packages/fold-codex/src/BedrockAuthStore.ts b/packages/fold-codex/src/BedrockAuthStore.ts new file mode 100644 index 0000000..70a67dd --- /dev/null +++ b/packages/fold-codex/src/BedrockAuthStore.ts @@ -0,0 +1,134 @@ +/** File-backed configuration for the sibling `codex_bedrock` auth entry. */ +import { dirname } from 'node:path' + +import { Effect, FileSystem, Option, Schema } from 'effect' + +import { defaultAuthStorePath } from './AuthStore' + +/** Durable, non-secret AWS profile selection used by Fold Codex. */ +export class CodexBedrockAuthData extends Schema.Class('fold/CodexBedrockAuthData')({ + type: Schema.Literal('aws-profile'), + active: Schema.optional(Schema.Boolean), + profile: Schema.optional(Schema.String), + region: Schema.optional(Schema.String), + model: Schema.optional(Schema.String), + baseUrl: Schema.optional(Schema.String), +}) {} + +/** Persistence or decoding failure for the Bedrock configuration entry. */ +export class CodexBedrockAuthStoreError extends Schema.TaggedError()( + 'CodexBedrockAuthStoreError', + { + reason: Schema.Literals(['InvalidDocument', 'InvalidEntry', 'ReadFailed', 'WriteFailed']), + message: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) {} + +export type CodexBedrockAuthStore = { + readonly path: string + readonly load: Effect.Effect, CodexBedrockAuthStoreError> + readonly save: ( + configuration: CodexBedrockAuthData, + ) => Effect.Effect + readonly clear: Effect.Effect +} + +export type MakeCodexBedrockAuthStoreOptions = { + readonly path?: string +} + +const AuthDocument = Schema.Record(Schema.String, Schema.Unknown) +const decodeDocument = Schema.decodeUnknownOption(Schema.fromJsonString(AuthDocument)) +const decodeConfiguration = Schema.decodeUnknownOption(CodexBedrockAuthData) + +const encodeConfiguration = (configuration: CodexBedrockAuthData): Record => { + const encoded: Record = { type: configuration.type } + if (configuration.active !== undefined) encoded['active'] = configuration.active + if (configuration.profile !== undefined) encoded['profile'] = configuration.profile + if (configuration.region !== undefined) encoded['region'] = configuration.region + if (configuration.model !== undefined) encoded['model'] = configuration.model + if (configuration.baseUrl !== undefined) encoded['baseUrl'] = configuration.baseUrl + return encoded +} + +/** Build a store targeting `codex_bedrock` in the same auth document as Codex OAuth. */ +export const makeCodexBedrockAuthStore = ( + options?: MakeCodexBedrockAuthStoreOptions, +): Effect.Effect => + Effect.map(FileSystem.FileSystem, (fs) => { + const path = options?.path ?? defaultAuthStorePath() + + const readDocument: Effect.Effect, CodexBedrockAuthStoreError> = fs + .readFileString(path) + .pipe( + Effect.map((content) => content), + Effect.catchReasons('PlatformError', { NotFound: () => Effect.succeed(null) }), + Effect.mapError( + (cause) => + new CodexBedrockAuthStoreError({ + reason: 'ReadFailed', + message: `Failed to read the auth store at ${path}`, + cause, + }), + ), + Effect.flatMap((content) => { + if (content === null) return Effect.succeed>({}) + const document = decodeDocument(content) + return Option.isSome(document) + ? Effect.succeed(document.value) + : Effect.fail( + new CodexBedrockAuthStoreError({ + reason: 'InvalidDocument', + message: `Auth store ${path} is not a valid JSON object`, + }), + ) + }), + ) + + const writeDocument = (document: Record): Effect.Effect => + Effect.gen(function* () { + yield* fs.makeDirectory(dirname(path), { recursive: true }) + yield* fs.writeFileString(path, `${JSON.stringify(document, null, 2)}\n`, { mode: 0o600 }) + yield* fs.chmod(path, 0o600) + }).pipe( + Effect.mapError( + (cause) => + new CodexBedrockAuthStoreError({ + reason: 'WriteFailed', + message: `Failed to write the auth store at ${path}`, + cause, + }), + ), + ) + + const load = Effect.gen(function* () { + const document = yield* readDocument + const entry = document['codex_bedrock'] + if (entry === undefined) return Option.none() + const configuration = decodeConfiguration(entry) + if (Option.isNone(configuration)) { + return yield* new CodexBedrockAuthStoreError({ + reason: 'InvalidEntry', + message: `Invalid "codex_bedrock" entry in ${path}`, + }) + } + return configuration + }).pipe(Effect.withSpan('fold.codexBedrockAuthStore.load')) + + const save = (configuration: CodexBedrockAuthData) => + Effect.gen(function* () { + const document = yield* readDocument + yield* writeDocument({ ...document, codex_bedrock: encodeConfiguration(configuration) }) + return configuration + }).pipe(Effect.withSpan('fold.codexBedrockAuthStore.save')) + + const clear = Effect.gen(function* () { + const document = yield* readDocument + if (document['codex_bedrock'] === undefined) return + const { codex_bedrock: _removed, ...rest } = document + yield* writeDocument(rest) + }).pipe(Effect.withSpan('fold.codexBedrockAuthStore.clear')) + + return { path, load, save, clear } + }) diff --git a/packages/fold-codex/src/CodexConfig.ts b/packages/fold-codex/src/CodexConfig.ts new file mode 100644 index 0000000..84e176a --- /dev/null +++ b/packages/fold-codex/src/CodexConfig.ts @@ -0,0 +1,100 @@ +/** Minimal reader for the Amazon Bedrock fields supported in Codex CLI config.toml. */ +import { homedir } from 'node:os' +import { join } from 'node:path' + +import { Effect, FileSystem, Option, Schema } from 'effect' +import { parse } from 'smol-toml' + +export type CodexAmazonBedrockConfig = { + readonly provider: 'amazon-bedrock' | 'amazon-bedrock-runtime' + readonly profile?: string + readonly region?: string + readonly model?: string + readonly baseUrl?: string +} + +export class CodexConfigError extends Schema.TaggedError()('CodexConfigError', { + reason: Schema.Literals(['Malformed', 'ReadFailed']), + message: Schema.String, +}) {} + +const AwsConfig = Schema.Struct({ + profile: Schema.optionalKey(Schema.String), + region: Schema.optionalKey(Schema.String), +}) +const ProviderConfig = Schema.Struct({ + base_url: Schema.optionalKey(Schema.String), + aws: Schema.optionalKey(AwsConfig), +}) +const RootConfig = Schema.Struct({ + model_provider: Schema.optionalKey(Schema.String), + model: Schema.optionalKey(Schema.String), + model_providers: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)), +}) +const decodeRootConfig = Schema.decodeUnknownOption(RootConfig) +const decodeProviderConfig = Schema.decodeUnknownOption(ProviderConfig) + +export type ReadCodexConfigOptions = { + readonly codexHome?: string +} + +/** Read supported Bedrock configuration, returning none when the file or selection is absent. */ +export const readCodexAmazonBedrockConfig = ( + options?: ReadCodexConfigOptions, +): Effect.Effect, CodexConfigError, FileSystem.FileSystem> => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const codexHome = options?.codexHome ?? process.env['CODEX_HOME'] ?? join(homedir(), '.codex') + const configPath = join(codexHome, 'config.toml') + const contents = yield* fs.readFileString(configPath).pipe( + Effect.map((content) => content), + Effect.catchReasons('PlatformError', { NotFound: () => Effect.succeed(null) }), + Effect.mapError( + () => + new CodexConfigError({ + reason: 'ReadFailed', + message: `Failed to read Codex config at ${configPath}`, + }), + ), + ) + if (contents === null) return Option.none() + + const parsed = yield* Effect.try({ + try: () => parse(contents), + catch: () => + new CodexConfigError({ reason: 'Malformed', message: `Malformed Codex config at ${configPath}` }), + }) + const root = decodeRootConfig(parsed) + if (Option.isNone(root)) { + return yield* new CodexConfigError({ + reason: 'Malformed', + message: `Malformed Codex config at ${configPath}`, + }) + } + if (root.value.model_provider !== 'amazon-bedrock' && root.value.model_provider !== 'amazon-bedrock-runtime') { + return Option.none() + } + + const providerValue = root.value.model_providers?.[root.value.model_provider] + const provider = providerValue === undefined ? Option.none() : decodeProviderConfig(providerValue) + if (providerValue !== undefined && Option.isNone(provider)) { + return yield* new CodexConfigError({ + reason: 'Malformed', + message: `Malformed Codex config at ${configPath}`, + }) + } + + const aws = Option.isSome(provider) ? provider.value.aws : undefined + const result: { + provider: 'amazon-bedrock' | 'amazon-bedrock-runtime' + profile?: string + region?: string + model?: string + baseUrl?: string + } = { provider: root.value.model_provider } + if (aws?.profile !== undefined) result.profile = aws.profile + if (aws?.region !== undefined) result.region = aws.region + if (root.value.model !== undefined) result.model = root.value.model + if (Option.isSome(provider) && provider.value.base_url !== undefined) result.baseUrl = provider.value.base_url + return Option.some(result) + }).pipe(Effect.withSpan('fold.codexConfig.read')) diff --git a/packages/fold-codex/src/CodexConnection.ts b/packages/fold-codex/src/CodexConnection.ts new file mode 100644 index 0000000..2f88e5c --- /dev/null +++ b/packages/fold-codex/src/CodexConnection.ts @@ -0,0 +1,193 @@ +/** Codex connection precedence and Bedrock endpoint/model resolution. */ +import { NODE_REGION_CONFIG_FILE_OPTIONS, NODE_REGION_CONFIG_OPTIONS } from '@smithy/config-resolver' +import { loadConfig } from '@smithy/node-config-provider' +import { Effect, Option, Schema } from 'effect' +import type { FileSystem } from 'effect' + +import { CodexBedrockAuthData, makeCodexBedrockAuthStore } from './BedrockAuthStore' +import type { CodexBedrockAuthStore } from './BedrockAuthStore' +import { readCodexAmazonBedrockConfig } from './CodexConfig' + +/** A caller-selected Codex authentication mode. */ +export type CodexConnection = + | { readonly type: 'chatgpt' } + | { + readonly type: 'bedrock' + readonly profile?: string + readonly region?: string + readonly model?: string + readonly baseUrl?: string + } + +export type ResolvedCodexConnection = + | { readonly type: 'chatgpt' } + | { + readonly type: 'bedrock' + readonly profile?: string + readonly region: string + readonly model: string + readonly baseUrl: string + } + +export class CodexConnectionError extends Schema.TaggedError()('CodexConnectionError', { + reason: Schema.Literals(['InvalidConfiguration', 'UnsupportedProvider', 'RegionUnavailable']), + message: Schema.String, +}) {} + +export type ResolveCodexConnectionOptions = { + readonly connection?: CodexConnection + readonly logicalModel: string + readonly codexHome?: string + readonly bedrockStore?: CodexBedrockAuthStore + /** Test/embedding seam for the AWS region chain. */ + readonly resolveRegion?: (profile?: string) => Effect.Effect +} + +const bedrockWireModel = (model: string): string => (model.startsWith('openai.') ? model : `openai.${model}`) + +const defaultResolveRegion = (profile?: string): Effect.Effect => { + const provider = loadConfig( + NODE_REGION_CONFIG_OPTIONS, + profile === undefined ? NODE_REGION_CONFIG_FILE_OPTIONS : { ...NODE_REGION_CONFIG_FILE_OPTIONS, profile }, + ) + return Effect.tryPromise({ + try: () => provider(), + catch: () => + new CodexConnectionError({ + reason: 'RegionUnavailable', + message: 'AWS region is unavailable. Configure a region in HumanLayer, Codex, or your AWS profile.', + }), + }) +} + +const validateAndNormalizeBaseUrl = (value: string): Effect.Effect => + Effect.try({ + try: () => { + const url = new URL(value) + const hostname = url.hostname.toLowerCase() + const ipv4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(hostname) + const loopback = + hostname === 'localhost' || + hostname.endsWith('.localhost') || + hostname === '[::1]' || + (ipv4 !== null && Number(ipv4[1]) === 127 && ipv4.slice(1).every((part) => Number(part) <= 255)) + if ( + (url.protocol !== 'https:' && !(loopback && url.protocol === 'http:')) || + url.username || + url.password + ) { + throw new Error('unsafe URL') + } + if (url.search !== '' || url.hash !== '') throw new Error('query and fragment are unsupported') + const normalizedPath = url.pathname.replace(/\/+$/, '') + const basePath = normalizedPath.endsWith('/responses') + ? normalizedPath.slice(0, -'/responses'.length) + : normalizedPath + url.pathname = basePath || '/' + return url.toString().replace(/\/$/, '') + }, + catch: () => + new CodexConnectionError({ + reason: 'InvalidConfiguration', + message: + 'The Amazon Bedrock base URL must be HTTPS without credentials, a query string, or a fragment.', + }), + }) + +const resolveBedrock = ( + connection: Exclude, + logicalModel: string, + resolveRegion: (profile?: string) => Effect.Effect, +): Effect.Effect => + Effect.gen(function* () { + const region = connection.region ?? (yield* resolveRegion(connection.profile)) + if (region.trim() === '') { + return yield* new CodexConnectionError({ + reason: 'RegionUnavailable', + message: 'AWS region is unavailable. Configure a region in HumanLayer, Codex, or your AWS profile.', + }) + } + const defaultBaseUrl = `https://bedrock-mantle.${region}.api.aws/openai/v1` + const baseUrl = yield* validateAndNormalizeBaseUrl(connection.baseUrl ?? defaultBaseUrl) + const resolved: { + type: 'bedrock' + profile?: string + region: string + model: string + baseUrl: string + } = { + type: 'bedrock', + region, + model: bedrockWireModel(connection.model ?? logicalModel), + baseUrl, + } + if (connection.profile !== undefined) resolved.profile = connection.profile + return resolved + }) + +const toStoredConnection = ( + stored: CodexBedrockAuthData, + fallback?: { + readonly profile?: string + readonly region?: string + readonly model?: string + readonly baseUrl?: string + }, +): Exclude => { + const profile = stored.profile ?? fallback?.profile + const region = stored.region ?? fallback?.region + const model = stored.model ?? fallback?.model + const baseUrl = stored.baseUrl ?? fallback?.baseUrl + const connection: { type: 'bedrock'; profile?: string; region?: string; model?: string; baseUrl?: string } = { + type: 'bedrock', + } + if (profile !== undefined) connection.profile = profile + if (region !== undefined) connection.region = region + if (model !== undefined) connection.model = model + if (baseUrl !== undefined) connection.baseUrl = baseUrl + return connection +} + +/** Resolve explicit options, HumanLayer selection, then Codex CLI fallback, in that order. */ +export const resolveCodexConnection = ( + options: ResolveCodexConnectionOptions, +): Effect.Effect => + Effect.gen(function* () { + const resolveRegion = options.resolveRegion ?? defaultResolveRegion + if (options.connection?.type === 'chatgpt') return { type: 'chatgpt' } as const + if (options.connection?.type === 'bedrock') { + return yield* resolveBedrock(options.connection, options.logicalModel, resolveRegion) + } + + const store = options.bedrockStore ?? (yield* makeCodexBedrockAuthStore()) + const stored = yield* store.load.pipe( + Effect.mapError( + (error) => new CodexConnectionError({ reason: 'InvalidConfiguration', message: error.message }), + ), + ) + if (Option.isSome(stored) && stored.value.active === true) { + return yield* resolveBedrock(toStoredConnection(stored.value), options.logicalModel, resolveRegion) + } + if (Option.isSome(stored) && stored.value.active === false) return { type: 'chatgpt' } as const + + const codexConfig = yield* readCodexAmazonBedrockConfig( + options.codexHome === undefined ? {} : { codexHome: options.codexHome }, + ).pipe( + Effect.mapError( + (error) => new CodexConnectionError({ reason: 'InvalidConfiguration', message: error.message }), + ), + ) + if (Option.isNone(codexConfig)) return { type: 'chatgpt' } as const + if (codexConfig.value.provider === 'amazon-bedrock-runtime') { + return yield* new CodexConnectionError({ + reason: 'UnsupportedProvider', + message: 'Codex provider "amazon-bedrock-runtime" is not supported; use "amazon-bedrock".', + }) + } + + const fallback = codexConfig.value + const connection = Option.isSome(stored) + ? toStoredConnection(stored.value, fallback) + : toStoredConnection(new CodexBedrockAuthData({ type: 'aws-profile' }), fallback) + return yield* resolveBedrock(connection, options.logicalModel, resolveRegion) + }).pipe(Effect.withSpan('fold.codexConnection.resolve')) diff --git a/packages/fold-codex/src/CodexModel.ts b/packages/fold-codex/src/CodexModel.ts index eaf6ac8..8d8cbd7 100644 --- a/packages/fold-codex/src/CodexModel.ts +++ b/packages/fold-codex/src/CodexModel.ts @@ -26,8 +26,13 @@ import { FetchHttpClient, HttpClient } from 'effect/unstable/http' import type { HttpClientResponse } from 'effect/unstable/http' import type { CodexAuthStore } from './AuthStore' +import { makeBedrockAuth, withBedrockAuth } from './BedrockAuth' +import { makeCodexBedrockAuthStore } from './BedrockAuthStore' +import type { CodexBedrockAuthStore } from './BedrockAuthStore' import type { CodexIdentityOptions } from './CodexAuth' import { makeCodexAuth, withCodexAuth } from './CodexAuth' +import type { CodexConnection } from './CodexConnection' +import { resolveCodexConnection } from './CodexConnection' import type { CodexHardeningOptions, CodexRetryOptions, StreamRetryInfo } from './Hardening' import { CODEX_ERROR_MODULE, @@ -226,6 +231,10 @@ export type CodexModelOptions = { readonly apiUrl?: string /** Credential store override. Defaults to the `codex` entry of `~/.fold/auth.json`. */ readonly store?: CodexAuthStore + /** Explicit connection mode. When omitted, Fold auth and Codex config files are resolved. */ + readonly connection?: CodexConnection + /** Codex configuration directory. Defaults to `$CODEX_HOME`, then `~/.codex`. */ + readonly codexHome?: string /** Identity headers (`originator`/`User-Agent`/`session_id`) sent on model requests. */ readonly identity?: CodexIdentityOptions /** Maximum transport retry attempts. Provider response retries use {@link CodexHardeningOptions.firstEventTimeoutRetries}. */ @@ -245,28 +254,53 @@ export const makeCodexLanguageModel = ( options: CodexModelOptions, ): Effect.Effect => Effect.gen(function* () { + const logicalModel = options.model ?? DEFAULT_CODEX_MODEL_ID + const connectionOptions: { + logicalModel: string + connection?: CodexConnection + codexHome?: string + bedrockStore?: CodexBedrockAuthStore + } = { + logicalModel, + } + if (options.connection !== undefined) connectionOptions.connection = options.connection + if (options.codexHome !== undefined) connectionOptions.codexHome = options.codexHome + if (options.store !== undefined) { + connectionOptions.bedrockStore = yield* makeCodexBedrockAuthStore({ path: options.store.path }) + } + const connection = yield* resolveCodexConnection(connectionOptions).pipe(Effect.orDie) const httpContext = yield* Layer.build(FetchHttpClient.layer) const baseClient = Context.get(httpContext, HttpClient.HttpClient) - const authOptions: { store?: CodexAuthStore } = {} - if (options.store !== undefined) authOptions.store = options.store - const auth = yield* makeCodexAuth(authOptions).pipe(Effect.provideService(HttpClient.HttpClient, baseClient)) - // retryTransient sits below the auth wrapper: transport retries reuse the injected headers and never // re-enter (or retry) the auth path itself. Status responses are mapped to AiError above this seam, // where the first-event retry can honor a provider Retry-After rather than retrying a 429 immediately. - const modelClient = withCodexAuth( - baseClient.pipe( - HttpClient.retryTransient({ - retryOn: 'errors-only', - times: options.requestRetryTimes ?? DEFAULT_REQUEST_RETRY_TIMES, - }), - ), - auth, - options.identity, + const retryingClient = baseClient.pipe( + HttpClient.retryTransient({ + retryOn: 'errors-only', + times: options.requestRetryTimes ?? DEFAULT_REQUEST_RETRY_TIMES, + }), ) - - const clientContext = yield* Layer.build(OpenAiClient.layer({ apiUrl: options.apiUrl ?? CODEX_API_URL })).pipe( + const modelClient = + connection.type === 'bedrock' + ? withBedrockAuth( + retryingClient, + yield* makeBedrockAuth( + connection.profile === undefined + ? { region: connection.region } + : { profile: connection.profile, region: connection.region }, + ), + ) + : withCodexAuth( + retryingClient, + yield* makeCodexAuth(options.store === undefined ? {} : { store: options.store }).pipe( + Effect.provideService(HttpClient.HttpClient, baseClient), + ), + options.identity, + ) + + const apiUrl = connection.type === 'bedrock' ? connection.baseUrl : (options.apiUrl ?? CODEX_API_URL) + const clientContext = yield* Layer.build(OpenAiClient.layer({ apiUrl })).pipe( Effect.provideService(HttpClient.HttpClient, modelClient), ) const stockClient = Context.get(clientContext, OpenAiClient.OpenAiClient) @@ -282,7 +316,7 @@ export const makeCodexLanguageModel = ( }) return yield* OpenAiLanguageModel.make({ - model: options.model ?? DEFAULT_CODEX_MODEL_ID, + model: connection.type === 'bedrock' ? connection.model : logicalModel, config: { // The ChatGPT backend does no server-side response storage (clanka parity). store: false, diff --git a/packages/fold-codex/src/index.ts b/packages/fold-codex/src/index.ts index a7bd5b5..2b4d61c 100644 --- a/packages/fold-codex/src/index.ts +++ b/packages/fold-codex/src/index.ts @@ -1,5 +1,9 @@ export * from './AuthStore' +export * from './BedrockAuth' +export * from './BedrockAuthStore' export * from './CodexAuth' +export * from './CodexConfig' +export * from './CodexConnection' export * from './CodexModel' export * from './Hardening' export * from './OAuthFlows' diff --git a/packages/fold-codex/test/BedrockAuth.vi.test.ts b/packages/fold-codex/test/BedrockAuth.vi.test.ts new file mode 100644 index 0000000..0b01635 --- /dev/null +++ b/packages/fold-codex/test/BedrockAuth.vi.test.ts @@ -0,0 +1,206 @@ +import { describe, expect, it } from '@effect/vitest' +import type { AwsCredentialIdentityProvider } from '@smithy/types' +import { Effect, Fiber, Option, Stream } from 'effect' +import { TestClock } from 'effect/testing' +import { HttpClient, HttpClientRequest, HttpClientResponse } from 'effect/unstable/http' + +import { makeBedrockAuth, withBedrockAuth } from '../src/index' + +const credentials = + (expiration?: Date): AwsCredentialIdentityProvider => + async () => { + const identity: { accessKeyId: string; secretAccessKey: string; expiration?: Date } = { + accessKeyId: 'test-access-key', + secretAccessKey: 'test-secret-key', + } + if (expiration !== undefined) identity.expiration = expiration + return identity + } + +describe('BedrockAuth', () => { + it.effect('generates once and reuses the cached token before refreshAt', () => + Effect.gen(function* () { + let generations = 0 + const auth = yield* makeBedrockAuth({ + region: 'us-east-1', + credentialProviderFactory: () => credentials(), + generateToken: async () => `token-${++generations}`, + }) + expect(yield* auth.getToken).toBe('token-1') + expect(yield* auth.getToken).toBe('token-1') + expect(generations).toBe(1) + }), + ) + + it.effect('refreshes after the buffered lifetime and honors earlier AWS credential expiry', () => + Effect.gen(function* () { + let generations = 0 + const auth = yield* makeBedrockAuth({ + region: 'us-east-1', + credentialProviderFactory: () => credentials(new Date(10 * 60 * 1000)), + generateToken: async () => `token-${++generations}`, + }) + expect(yield* auth.getToken).toBe('token-1') + yield* TestClock.adjust(299_000) + expect(yield* auth.getToken).toBe('token-1') + yield* TestClock.adjust('1 second') + expect(yield* auth.getToken).toBe('token-2') + expect(generations).toBe(2) + }), + ) + + it.effect('single-flights concurrent token generation', () => + Effect.gen(function* () { + let generations = 0 + const auth = yield* makeBedrockAuth({ + region: 'us-east-1', + credentialProviderFactory: () => credentials(), + generateToken: async () => `token-${++generations}`, + }) + const tokens = yield* Effect.all([auth.getToken, auth.getToken], { concurrency: 2 }) + expect(tokens).toEqual(['token-1', 'token-1']) + expect(generations).toBe(1) + }), + ) + + it.effect('refresh failures expose no AWS credential values', () => + Effect.gen(function* () { + const auth = yield* makeBedrockAuth({ + region: 'us-east-1', + credentialProviderFactory: () => async () => { + throw new Error('test-access-key test-secret-key') + }, + generateToken: async () => 'unused', + }) + const error = yield* auth.getToken.pipe(Effect.flip) + const rendered = JSON.stringify(error) + expect(rendered).not.toContain('test-access-key') + expect(rendered).not.toContain('test-secret-key') + expect(error.reason).toBe('CredentialsUnavailable') + }), + ) + + it.effect('invalidation during refresh cannot republish the pre-invalidation token or provider', () => + Effect.gen(function* () { + let providers = 0 + let generations = 0 + let resolveFirstToken: ((token: string) => void) | undefined + let signalFirstGeneration: (() => void) | undefined + const firstGenerationStarted = new Promise((resolve) => { + signalFirstGeneration = resolve + }) + const firstToken = new Promise((resolve) => { + resolveFirstToken = resolve + }) + const auth = yield* makeBedrockAuth({ + region: 'us-east-1', + credentialProviderFactory: () => { + providers += 1 + return credentials() + }, + generateToken: () => { + generations += 1 + if (generations === 1) { + signalFirstGeneration?.() + return firstToken + } + return Promise.resolve('fresh-token') + }, + }) + + const tokenFiber = yield* auth.getToken.pipe(Effect.forkChild) + yield* Effect.promise(() => firstGenerationStarted) + yield* auth.invalidate + yield* Effect.sync(() => resolveFirstToken?.('stale-token')) + + expect(yield* Fiber.join(tokenFiber)).toBe('fresh-token') + expect(yield* auth.getToken).toBe('fresh-token') + expect(generations).toBe(2) + expect(providers).toBe(2) + }), + ) +}) + +describe('withBedrockAuth', () => { + it.effect('invalidates, rebuilds headers, and retries one qualifying auth response', () => + Effect.gen(function* () { + const authorizations: Array = [] + const chatGptHeaders: Array = [] + let requests = 0 + const client = HttpClient.make((request) => { + authorizations.push(request.headers.authorization) + chatGptHeaders.push(request.headers['chatgpt-account-id']) + requests += 1 + return Effect.succeed( + HttpClientResponse.fromWeb(request, new Response('', { status: requests === 1 ? 401 : 200 })), + ) + }) + + let providers = 0 + let generations = 0 + const auth = yield* makeBedrockAuth({ + region: 'us-east-1', + credentialProviderFactory: () => { + providers += 1 + return credentials() + }, + generateToken: async () => `token-${++generations}`, + }) + const response = yield* withBedrockAuth(client, auth).execute(HttpClientRequest.get('https://example.test')) + + expect(response.status).toBe(200) + expect(authorizations).toEqual(['Bearer token-1', 'Bearer token-2']) + expect(chatGptHeaders).toEqual([undefined, undefined]) + expect(providers).toBe(2) + expect(requests).toBe(2) + }), + ) + + it.effect('returns a second authentication failure without retrying again', () => + Effect.gen(function* () { + let requests = 0 + const client = HttpClient.make((request) => { + requests += 1 + return Effect.succeed( + HttpClientResponse.fromWeb(request, new Response('{"code":"ExpiredToken"}', { status: 403 })), + ) + }) + let generations = 0 + const auth = yield* makeBedrockAuth({ + region: 'us-east-1', + credentialProviderFactory: () => credentials(), + generateToken: async () => `token-${++generations}`, + }) + const response = yield* withBedrockAuth(client, auth).execute(HttpClientRequest.get('https://example.test')) + expect(response.status).toBe(403) + expect(requests).toBe(2) + expect(generations).toBe(2) + }), + ) + + it.effect('bounds 403 inspection without consuming or awaiting the original non-terminating body', () => + Effect.gen(function* () { + let pulls = 0 + const endlessBody = new ReadableStream({ + pull: (controller) => { + pulls += 1 + if (pulls === 1) controller.enqueue(new Uint8Array(16_384).fill(65)) + return pulls === 1 ? undefined : new Promise(() => undefined) + }, + }) + const client = HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, new Response(endlessBody, { status: 403 }))), + ) + const auth = yield* makeBedrockAuth({ + region: 'us-east-1', + credentialProviderFactory: () => credentials(), + generateToken: async () => 'token', + }) + + const response = yield* withBedrockAuth(client, auth).execute(HttpClientRequest.get('https://example.test')) + expect(response.status).toBe(403) + const firstOriginalChunk = yield* Stream.runHead(response.stream) + expect(Option.isSome(firstOriginalChunk) && firstOriginalChunk.value.byteLength).toBe(16_384) + }), + ) +}) diff --git a/packages/fold-codex/test/BedrockAuthStore.vi.test.ts b/packages/fold-codex/test/BedrockAuthStore.vi.test.ts new file mode 100644 index 0000000..47e16c2 --- /dev/null +++ b/packages/fold-codex/test/BedrockAuthStore.vi.test.ts @@ -0,0 +1,94 @@ +import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' +import { describe, expect, it } from '@effect/vitest' +import { Effect, FileSystem, Option, PlatformError, Schema } from 'effect' + +import { CodexBedrockAuthData, CodexTokenData, makeCodexAuthStore, makeCodexBedrockAuthStore } from '../src/index' + +const tempStorePath = (): string => join(mkdtempSync(join(tmpdir(), 'fold-bedrock-store-')), 'auth.json') +const AuthDocument = Schema.Record(Schema.String, Schema.Unknown) +const readDocument = (path: string) => Schema.decodeUnknownEffect(AuthDocument)(JSON.parse(readFileSync(path, 'utf8'))) + +describe('CodexBedrockAuthStore', () => { + it.effect('save/load/clear preserves codex and unrelated providers', () => + Effect.gen(function* () { + const path = tempStorePath() + const oauthStore = yield* makeCodexAuthStore({ path }) + const bedrockStore = yield* makeCodexBedrockAuthStore({ path }) + yield* oauthStore.save( + new CodexTokenData({ type: 'oauth', access: 'a', refresh: 'r', expires: 123, accountId: 'acct' }), + ) + const before = yield* readDocument(path) + yield* bedrockStore.save( + new CodexBedrockAuthData({ + type: 'aws-profile', + active: true, + profile: 'work', + region: 'us-east-1', + }), + ) + const afterSave = yield* readDocument(path) + expect(afterSave['codex']).toEqual(before['codex']) + + const loaded = yield* bedrockStore.load + expect(Option.isSome(loaded) && loaded.value.profile).toBe('work') + yield* bedrockStore.clear + const afterClear = yield* readDocument(path) + expect(afterClear['codex_bedrock']).toBeUndefined() + expect(afterClear['codex']).toEqual(before['codex']) + }).pipe(Effect.provide(NodeFileSystem.layer)), + ) + + it.effect('invalid entries fail without clobbering the auth document', () => + Effect.gen(function* () { + const path = tempStorePath() + const contents = JSON.stringify({ codex: { type: 'oauth' }, codex_bedrock: { active: true } }) + writeFileSync(path, contents) + const store = yield* makeCodexBedrockAuthStore({ path }) + const error = yield* store.load.pipe(Effect.flip) + expect(error.reason).toBe('InvalidEntry') + expect(readFileSync(path, 'utf8')).toBe(contents) + }).pipe(Effect.provide(NodeFileSystem.layer)), + ) + + it.effect('malformed documents cannot be replaced by save', () => + Effect.gen(function* () { + const path = tempStorePath() + const contents = 'not valid json {' + writeFileSync(path, contents) + const store = yield* makeCodexBedrockAuthStore({ path }) + const error = yield* store + .save(new CodexBedrockAuthData({ type: 'aws-profile', active: true, region: 'us-east-1' })) + .pipe(Effect.flip) + expect(error.reason).toBe('InvalidDocument') + expect(readFileSync(path, 'utf8')).toBe(contents) + }).pipe(Effect.provide(NodeFileSystem.layer)), + ) + + it.effect('non-not-found read failures propagate and prevent writes', () => + Effect.gen(function* () { + let writes = 0 + const permissionDenied = PlatformError.systemError({ + _tag: 'PermissionDenied', + module: 'FileSystem', + method: 'readFileString', + pathOrDescriptor: '/protected/auth.json', + }) + const fileSystem = FileSystem.makeNoop({ + readFileString: () => Effect.fail(permissionDenied), + writeFileString: () => Effect.sync(() => void (writes += 1)), + }) + const store = yield* makeCodexBedrockAuthStore({ path: '/protected/auth.json' }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + ) + const error = yield* store + .save(new CodexBedrockAuthData({ type: 'aws-profile', active: true, region: 'us-east-1' })) + .pipe(Effect.flip) + expect(error.reason).toBe('ReadFailed') + expect(writes).toBe(0) + }), + ) +}) diff --git a/packages/fold-codex/test/CodexAlternateStore.vi.test.ts b/packages/fold-codex/test/CodexAlternateStore.vi.test.ts new file mode 100644 index 0000000..0c8b3a3 --- /dev/null +++ b/packages/fold-codex/test/CodexAlternateStore.vi.test.ts @@ -0,0 +1,61 @@ +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { expect, it } from '@effect/vitest' +import { Effect, Option } from 'effect' +import { FetchHttpClient } from 'effect/unstable/http' + +import { CodexTokenData, makeCodexLanguageModel } from '../src/index' +import type { CodexAuthStore } from '../src/index' +import { type CapturedFetchRequest, makeCapturingFetch, runImageReadInference } from './SessionModelPathTestHarness' + +const terminalSse = `data: ${JSON.stringify({ + type: 'response.completed', + response: { id: 'resp_alternate_store', model: 'gpt-5.5', created_at: 1, output: [] }, + sequence_number: 1, +})}\n\n` + +it.effect('OAuth store overrides resolve codex_bedrock from the same auth document', () => { + const root = mkdtempSync(join(tmpdir(), 'fold-codex-alternate-store-')) + const authPath = join(root, 'alternate-auth.json') + const codexHome = join(root, 'codex') + mkdirSync(codexHome) + writeFileSync(authPath, JSON.stringify({ codex_bedrock: { type: 'aws-profile', active: false } })) + writeFileSync( + join(codexHome, 'config.toml'), + 'model_provider = "amazon-bedrock"\n[model_providers.amazon-bedrock.aws]\nregion = "us-east-1"\n', + ) + + const token = new CodexTokenData({ + type: 'oauth', + access: 'alternate-access-token', + refresh: 'unused-refresh-token', + expires: Number.MAX_SAFE_INTEGER, + }) + const alternateStore: CodexAuthStore = { + path: authPath, + load: Effect.succeed(Option.some(token)), + save: (updated) => Effect.succeed(updated), + clear: Effect.void, + } + const requests: Array = [] + const capturingFetch = makeCapturingFetch( + requests, + () => new Response(terminalSse, { status: 200, headers: { 'content-type': 'text/event-stream' } }), + ) + + return Effect.gen(function* () { + const model = yield* makeCodexLanguageModel({ + model: 'gpt-5.5', + apiUrl: 'https://chatgpt.alternate.test/backend-api/codex', + store: alternateStore, + codexHome, + requestRetryTimes: 0, + }) + yield* runImageReadInference(model) + expect(requests).toHaveLength(1) + expect(requests[0]?.url).toBe('https://chatgpt.alternate.test/backend-api/codex/responses') + expect(requests[0]?.authorization).toBe('Bearer alternate-access-token') + }).pipe(Effect.provideService(FetchHttpClient.Fetch, capturingFetch)) +}) diff --git a/packages/fold-codex/test/CodexConnection.vi.test.ts b/packages/fold-codex/test/CodexConnection.vi.test.ts new file mode 100644 index 0000000..ca8f910 --- /dev/null +++ b/packages/fold-codex/test/CodexConnection.vi.test.ts @@ -0,0 +1,172 @@ +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' +import { describe, expect, it } from '@effect/vitest' +import { Effect, FileSystem, PlatformError } from 'effect' + +import { + CodexBedrockAuthData, + makeCodexBedrockAuthStore, + readCodexAmazonBedrockConfig, + resolveCodexConnection, +} from '../src/index' + +const fixture = () => { + const root = mkdtempSync(join(tmpdir(), 'fold-codex-connection-')) + const authPath = join(root, 'auth.json') + const codexHome = join(root, 'codex') + mkdirSync(codexHome) + return { authPath, codexHome } +} + +const region = () => Effect.succeed('us-west-2') + +describe('resolveCodexConnection', () => { + it.effect('explicit Bedrock wins and maps the regional endpoint and wire model', () => + resolveCodexConnection({ + logicalModel: 'gpt-5.6-sol', + connection: { type: 'bedrock', profile: 'work' }, + resolveRegion: region, + }).pipe( + Effect.map((resolved) => { + expect(resolved).toEqual({ + type: 'bedrock', + profile: 'work', + region: 'us-west-2', + model: 'openai.gpt-5.6-sol', + baseUrl: 'https://bedrock-mantle.us-west-2.api.aws/openai/v1', + }) + return resolved + }), + Effect.provide(NodeFileSystem.layer), + ), + ) + + it.effect('keeps a custom Responses base URL in base form', () => + resolveCodexConnection({ + logicalModel: 'gpt-5.6-sol', + connection: { + type: 'bedrock', + region: 'us-east-1', + baseUrl: 'https://bedrock.example.test/openai/v1/', + }, + }).pipe( + Effect.map((resolved) => { + expect(resolved.type === 'bedrock' && resolved.baseUrl).toBe('https://bedrock.example.test/openai/v1') + }), + Effect.provide(NodeFileSystem.layer), + ), + ) + + it.effect('normalizes a full Responses endpoint to the client base URL', () => + resolveCodexConnection({ + logicalModel: 'gpt-5.6-sol', + connection: { + type: 'bedrock', + region: 'us-east-1', + baseUrl: 'https://bedrock.example.test/openai/v1/responses/', + }, + }).pipe( + Effect.map((resolved) => { + expect(resolved.type === 'bedrock' && resolved.baseUrl).toBe('https://bedrock.example.test/openai/v1') + }), + Effect.provide(NodeFileSystem.layer), + ), + ) + + it.effect('active true selects saved Bedrock configuration', () => + Effect.gen(function* () { + const { authPath, codexHome } = fixture() + const store = yield* makeCodexBedrockAuthStore({ path: authPath }) + yield* store.save( + new CodexBedrockAuthData({ + type: 'aws-profile', + active: true, + region: 'eu-west-1', + model: 'openai.saved', + }), + ) + const resolved = yield* resolveCodexConnection({ logicalModel: 'logical', codexHome, bedrockStore: store }) + expect(resolved.type).toBe('bedrock') + if (resolved.type === 'bedrock') expect(resolved.model).toBe('openai.saved') + }).pipe(Effect.provide(NodeFileSystem.layer)), + ) + + it.effect('active false forces ChatGPT even when Codex TOML selects Bedrock', () => + Effect.gen(function* () { + const { authPath, codexHome } = fixture() + writeFileSync( + join(codexHome, 'config.toml'), + 'model_provider = "amazon-bedrock"\n[model_providers.amazon-bedrock.aws]\nregion = "us-east-1"\n', + ) + const store = yield* makeCodexBedrockAuthStore({ path: authPath }) + yield* store.save(new CodexBedrockAuthData({ type: 'aws-profile', active: false })) + const resolved = yield* resolveCodexConnection({ logicalModel: 'logical', codexHome, bedrockStore: store }) + expect(resolved).toEqual({ type: 'chatgpt' }) + }).pipe(Effect.provide(NodeFileSystem.layer)), + ) + + it.effect('Codex TOML selects Bedrock when there is no active marker', () => + Effect.gen(function* () { + const { authPath, codexHome } = fixture() + writeFileSync( + join(codexHome, 'config.toml'), + [ + 'model_provider = "amazon-bedrock"', + 'model = "openai.from-config"', + '[model_providers.amazon-bedrock]', + 'base_url = "https://bedrock.example.test/openai/v1"', + '[model_providers.amazon-bedrock.aws]', + 'profile = "work"', + 'region = "us-east-1"', + ].join('\n'), + ) + const store = yield* makeCodexBedrockAuthStore({ path: authPath }) + const resolved = yield* resolveCodexConnection({ logicalModel: 'logical', codexHome, bedrockStore: store }) + expect(resolved).toEqual({ + type: 'bedrock', + profile: 'work', + region: 'us-east-1', + model: 'openai.from-config', + baseUrl: 'https://bedrock.example.test/openai/v1', + }) + }).pipe(Effect.provide(NodeFileSystem.layer)), + ) + + it.effect('missing config falls back to ChatGPT and explicit invalid Bedrock fails closed', () => + Effect.gen(function* () { + const { authPath, codexHome } = fixture() + const store = yield* makeCodexBedrockAuthStore({ path: authPath }) + expect(yield* resolveCodexConnection({ logicalModel: 'logical', codexHome, bedrockStore: store })).toEqual({ + type: 'chatgpt', + }) + const error = yield* resolveCodexConnection({ + logicalModel: 'logical', + connection: { type: 'bedrock', baseUrl: 'http://not-loopback.test' }, + resolveRegion: region, + }).pipe(Effect.flip) + expect(error.reason).toBe('InvalidConfiguration') + }).pipe(Effect.provide(NodeFileSystem.layer)), + ) + + it.effect('non-not-found Codex config read failures propagate instead of selecting ChatGPT', () => + Effect.gen(function* () { + const permissionDenied = PlatformError.systemError({ + _tag: 'PermissionDenied', + module: 'FileSystem', + method: 'readFileString', + pathOrDescriptor: '/protected/config.toml', + }) + const fileSystem = FileSystem.makeNoop({ + readFileString: () => Effect.fail(permissionDenied), + }) + const error = yield* readCodexAmazonBedrockConfig({ codexHome: '/protected' }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.flip, + ) + expect(error.reason).toBe('ReadFailed') + }), + ) +})