diff --git a/package.json b/package.json index a3cfeb8d4..730057dd1 100644 --- a/package.json +++ b/package.json @@ -382,6 +382,7 @@ "opusscript": "^0.1.1", "osc-progress": "^0.3.0", "pdfjs-dist": "^5.5.207", + "pg": "^8.20.0", "playwright-core": "1.58.2", "qrcode-terminal": "^0.12.0", "sharp": "^0.34.5", @@ -400,6 +401,7 @@ "@types/express": "^5.0.6", "@types/markdown-it": "^14.1.2", "@types/node": "^25.5.0", + "@types/pg": "^8.18.0", "@types/qrcode-terminal": "^0.12.2", "@types/ws": "^8.18.1", "@typescript/native-preview": "7.0.0-dev.20260312.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ac32d145c..78f623cef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -163,6 +163,9 @@ importers: pdfjs-dist: specifier: ^5.5.207 version: 5.5.207 + pg: + specifier: ^8.20.0 + version: 8.20.0 playwright-core: specifier: 1.58.2 version: 1.58.2 @@ -212,6 +215,9 @@ importers: '@types/node': specifier: ^25.5.0 version: 25.5.0 + '@types/pg': + specifier: ^8.18.0 + version: 8.18.0 '@types/qrcode-terminal': specifier: ^0.12.2 version: 0.12.2 @@ -651,10 +657,6 @@ packages: resolution: {integrity: sha512-t8cl+bPLlHZQD2Sw1a4hSLUybqJZU71+m8znkyeU8CHntFqEp2mMbuLKdHKaAYQ1fAApXMsvzenCAkDzNeeJlw==} engines: {node: '>=20.0.0'} - '@aws-sdk/client-bedrock@3.1007.0': - resolution: {integrity: sha512-49hH8o6ALKkCiBUgg20HkwxNamP1yYA/n8Si73Z438EqhZGpCfScP3FfxVhrfD5o+4bV4Whi9BTzPKCa/PfUww==} - engines: {node: '>=20.0.0'} - '@aws-sdk/client-bedrock@3.1008.0': resolution: {integrity: sha512-mzxO/DplpZZT7AIZUCG7Q78OlaeHeDybYz+ZlWZPaXFjGDJwUv1E3SKskmaaQvTsMeieie0WX7gzueYrCx4YfQ==} engines: {node: '>=20.0.0'} @@ -711,10 +713,6 @@ packages: resolution: {integrity: sha512-dFqh7nfX43B8dO1aPQHOcjC0SnCJ83H3F+1LoCh3X1P7E7N09I+0/taID0asU6GCddfDExqnEvQtDdkuMe5tKQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-ini@3.972.18': - resolution: {integrity: sha512-vthIAXJISZnj2576HeyLBj4WTeX+I7PwWeRkbOa0mVX39K13SCGxCgOFuKj2ytm9qTlLOmXe4cdEnroteFtJfw==} - engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-ini@3.972.19': resolution: {integrity: sha512-pVJVjWqVrPqjpFq7o0mCmeZu1Y0c94OCHSYgivdCD2wfmYVtBbwQErakruhgOD8pcMcx9SCqRw1pzHKR7OGBcA==} engines: {node: '>=20.0.0'} @@ -727,10 +725,6 @@ packages: resolution: {integrity: sha512-gf2E5b7LpKb+JX2oQsRIDxdRZjBFZt2olCGlWCdb3vBERbXIPgm2t1R5mEnwd4j0UEO/Tbg5zN2KJbHXttJqwA==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-login@3.972.18': - resolution: {integrity: sha512-kINzc5BBxdYBkPZ0/i1AMPMOk5b5QaFNbYMElVw5QTX13AKj6jcxnv/YNl9oW9mg+Y08ti19hh01HhyEAxsSJQ==} - engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-login@3.972.19': resolution: {integrity: sha512-jOXdZ1o+CywQKr6gyxgxuUmnGwTTnY2Kxs1PM7fI6AYtDWDnmW/yKXayNqkF8KjP1unflqMWKVbVt5VgmE3L0g==} engines: {node: '>=20.0.0'} @@ -743,10 +737,6 @@ packages: resolution: {integrity: sha512-ZDJa2gd1xiPg/nBDGhUlat02O8obaDEnICBAVS8qieZ0+nDfaB0Z3ec6gjZj27OqFTjnB/Q5a0GwQwb7rMVViw==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-node@3.972.19': - resolution: {integrity: sha512-yDWQ9dFTr+IMxwanFe7+tbN5++q8psZBjlUwOiCXn1EzANoBgtqBwcpYcHaMGtn0Wlfj4NuXdf2JaEx1lz5RaQ==} - engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-node@3.972.20': resolution: {integrity: sha512-0xHca2BnPY0kzjDYPH7vk8YbfdBPpWVS67rtqQMalYDQUCBYS37cZ55K6TuFxCoIyNZgSCFrVKr9PXC5BVvQQw==} engines: {node: '>=20.0.0'} @@ -771,10 +761,6 @@ packages: resolution: {integrity: sha512-wGtte+48xnhnhHMl/MsxzacBPs5A+7JJedjiP452IkHY7vsbYKcvQBqFye8LwdTJVeHtBHv+JFeTscnwepoWGg==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-sso@3.972.18': - resolution: {integrity: sha512-YHYEfj5S2aqInRt5ub8nDOX8vAxgMvd84wm2Y3WVNfFa/53vOv9T7WOAqXI25qjj3uEcV46xxfqdDQk04h5XQA==} - engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-sso@3.972.19': resolution: {integrity: sha512-kVjQsEU3b///q7EZGrUzol9wzwJFKbEzqJKSq82A9ShrUTEO7FNylTtby3sPV19ndADZh1H3FB3+5ZrvKtEEeg==} engines: {node: '>=20.0.0'} @@ -787,10 +773,6 @@ packages: resolution: {integrity: sha512-8aiVJh6fTdl8gcyL+sVNcNwTtWpmoFa1Sh7xlj6Z7L/cZ/tYMEBHq44wTYG8Kt0z/PpGNopD89nbj3FHl9QmTA==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-web-identity@3.972.18': - resolution: {integrity: sha512-OqlEQpJ+J3T5B96qtC1zLLwkBloechP+fezKbCH0sbd2cCc0Ra55XpxWpk/hRj69xAOYtHvoC4orx6eTa4zU7g==} - engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-web-identity@3.972.19': resolution: {integrity: sha512-BV1BlTFdG4w4tAihxN7iXDBoNcNewXD4q8uZlNQiUrnqxwGWUhKHODIQVSPlQGxXClEj+63m+cqZskw+ESmeZg==} engines: {node: '>=20.0.0'} @@ -875,10 +857,6 @@ packages: resolution: {integrity: sha512-MlGWA8uPaOs5AiTZ5JLM4uuWDm9EEAnm9cqwvqQIc6kEgel/8s1BaOWm9QgUcfc9K8qd7KkC3n43yDbeXOA2tg==} engines: {node: '>=20.0.0'} - '@aws-sdk/nested-clients@3.996.8': - resolution: {integrity: sha512-6HlLm8ciMW8VzfB80kfIx16PBA9lOa9Dl+dmCBi78JDhvGlx3I7Rorwi5PpVRkL31RprXnYna3yBf6UKkD/PqA==} - engines: {node: '>=20.0.0'} - '@aws-sdk/nested-clients@3.996.9': resolution: {integrity: sha512-+RpVtpmQbbtzFOKhMlsRcXM/3f1Z49qTOHaA8gEpHOYruERmog6f2AUtf/oTRLCWjR9H2b3roqryV/hI7QMW8w==} engines: {node: '>=20.0.0'} @@ -903,14 +881,6 @@ packages: resolution: {integrity: sha512-j9BwZZId9sFp+4GPhf6KrwO8Tben2sXibZA8D1vv2I1zBdvkUHcBA2g4pkqIpTRalMTLC0NPkBPX0gERxfy/iA==} engines: {node: '>=20.0.0'} - '@aws-sdk/token-providers@3.1005.0': - resolution: {integrity: sha512-vMxd+ivKqSxU9bHx5vmAlFKDAkjGotFU56IOkDa5DaTu1WWwbcse0yFHEm9I537oVvodaiwMl3VBwgHfzQ2rvw==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/token-providers@3.1007.0': - resolution: {integrity: sha512-kKvVyr53vvVc5k6RbvI6jhafxufxO2SkEw8QeEzJqwOXH/IMY7Cm0IyhnBGdqj80iiIIiIM2jGe7Fn3TIdwdrw==} - engines: {node: '>=20.0.0'} - '@aws-sdk/token-providers@3.1008.0': resolution: {integrity: sha512-TulwlHQBWcJs668kNUDMZHN51DeLrDsYT59Ux4a/nbvr025gM6HjKJJ3LvnZccam7OS/ZKUVkWomCneRQKJbBg==} engines: {node: '>=20.0.0'} @@ -979,15 +949,6 @@ packages: aws-crt: optional: true - '@aws-sdk/util-user-agent-node@3.973.5': - resolution: {integrity: sha512-Dyy38O4GeMk7UQ48RupfHif//gqnOPbq/zlvRssc11E2mClT+aUfc3VS2yD8oLtzqO3RsqQ9I3gOBB4/+HjPOw==} - engines: {node: '>=20.0.0'} - peerDependencies: - aws-crt: '>=1.0.0' - peerDependenciesMeta: - aws-crt: - optional: true - '@aws-sdk/util-user-agent-node@3.973.6': resolution: {integrity: sha512-iF7G0prk7AvmOK64FcLvc/fW+Ty1H+vttajL7PvJFReU8urMxfYmynTTuFKDTA76Wgpq3FzTPKwabMQIXQHiXQ==} engines: {node: '>=20.0.0'} @@ -3586,6 +3547,9 @@ packages: '@types/node@25.5.0': resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==} + '@types/pg@8.18.0': + resolution: {integrity: sha512-gT+oueVQkqnj6ajGJXblFR4iavIXWsGAFCk3dP4Kki5+a9R4NMt0JARdk6s8cUKcfUoqP5dAtDSLU8xYUTFV+Q==} + '@types/qrcode-terminal@0.12.2': resolution: {integrity: sha512-v+RcIEJ+Uhd6ygSQ0u5YYY7ZM+la7GgPbs0V/7l/kFs2uO4S8BcIUEMoP7za4DNIqNnUD5npf0A/7kBhrCKG5Q==} @@ -5845,6 +5809,40 @@ packages: performance-now@2.1.0: resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==} + pg-cloudflare@1.3.0: + resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==} + + pg-connection-string@2.12.0: + resolution: {integrity: sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.13.0: + resolution: {integrity: sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.13.0: + resolution: {integrity: sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.20.0: + resolution: {integrity: sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -5892,6 +5890,22 @@ packages: resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} engines: {node: ^10 || ^12 || >=14} + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + postgres@3.4.8: resolution: {integrity: sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg==} engines: {node: '>=12'} @@ -6667,10 +6681,6 @@ packages: undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} - undici@7.22.0: - resolution: {integrity: sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==} - engines: {node: '>=20.18.1'} - undici@7.24.0: resolution: {integrity: sha512-jxytwMHhsbdpBXxLAcuu0fzlQeXCNnWdDyRHpvWsUl8vd98UwYdl9YTyn8/HcpcJPC3pwUveefsa3zTxyD/ERg==} engines: {node: '>=20.18.1'} @@ -6928,6 +6938,10 @@ packages: xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -7120,51 +7134,6 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/client-bedrock@3.1007.0': - dependencies: - '@aws-crypto/sha256-browser': 5.2.0 - '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.973.19 - '@aws-sdk/credential-provider-node': 3.972.19 - '@aws-sdk/middleware-host-header': 3.972.7 - '@aws-sdk/middleware-logger': 3.972.7 - '@aws-sdk/middleware-recursion-detection': 3.972.7 - '@aws-sdk/middleware-user-agent': 3.972.20 - '@aws-sdk/region-config-resolver': 3.972.7 - '@aws-sdk/token-providers': 3.1007.0 - '@aws-sdk/types': 3.973.5 - '@aws-sdk/util-endpoints': 3.996.4 - '@aws-sdk/util-user-agent-browser': 3.972.7 - '@aws-sdk/util-user-agent-node': 3.973.5 - '@smithy/config-resolver': 4.4.10 - '@smithy/core': 3.23.9 - '@smithy/fetch-http-handler': 5.3.13 - '@smithy/hash-node': 4.2.11 - '@smithy/invalid-dependency': 4.2.11 - '@smithy/middleware-content-length': 4.2.11 - '@smithy/middleware-endpoint': 4.4.23 - '@smithy/middleware-retry': 4.4.40 - '@smithy/middleware-serde': 4.2.12 - '@smithy/middleware-stack': 4.2.11 - '@smithy/node-config-provider': 4.3.11 - '@smithy/node-http-handler': 4.4.14 - '@smithy/protocol-http': 5.3.11 - '@smithy/smithy-client': 4.12.3 - '@smithy/types': 4.13.0 - '@smithy/url-parser': 4.2.11 - '@smithy/util-base64': 4.3.2 - '@smithy/util-body-length-browser': 4.2.2 - '@smithy/util-body-length-node': 4.2.3 - '@smithy/util-defaults-mode-browser': 4.3.39 - '@smithy/util-defaults-mode-node': 4.2.42 - '@smithy/util-endpoints': 3.3.2 - '@smithy/util-middleware': 4.2.11 - '@smithy/util-retry': 4.2.11 - '@smithy/util-utf8': 4.2.2 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - '@aws-sdk/client-bedrock@3.1008.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 @@ -7424,25 +7393,6 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-provider-ini@3.972.18': - dependencies: - '@aws-sdk/core': 3.973.19 - '@aws-sdk/credential-provider-env': 3.972.17 - '@aws-sdk/credential-provider-http': 3.972.19 - '@aws-sdk/credential-provider-login': 3.972.18 - '@aws-sdk/credential-provider-process': 3.972.17 - '@aws-sdk/credential-provider-sso': 3.972.18 - '@aws-sdk/credential-provider-web-identity': 3.972.18 - '@aws-sdk/nested-clients': 3.996.8 - '@aws-sdk/types': 3.973.5 - '@smithy/credential-provider-imds': 4.2.11 - '@smithy/property-provider': 4.2.11 - '@smithy/shared-ini-file-loader': 4.4.6 - '@smithy/types': 4.13.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - '@aws-sdk/credential-provider-ini@3.972.19': dependencies: '@aws-sdk/core': 3.973.19 @@ -7488,19 +7438,6 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-provider-login@3.972.18': - dependencies: - '@aws-sdk/core': 3.973.19 - '@aws-sdk/nested-clients': 3.996.8 - '@aws-sdk/types': 3.973.5 - '@smithy/property-provider': 4.2.11 - '@smithy/protocol-http': 5.3.11 - '@smithy/shared-ini-file-loader': 4.4.6 - '@smithy/types': 4.13.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - '@aws-sdk/credential-provider-login@3.972.19': dependencies: '@aws-sdk/core': 3.973.19 @@ -7548,23 +7485,6 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-provider-node@3.972.19': - dependencies: - '@aws-sdk/credential-provider-env': 3.972.17 - '@aws-sdk/credential-provider-http': 3.972.19 - '@aws-sdk/credential-provider-ini': 3.972.18 - '@aws-sdk/credential-provider-process': 3.972.17 - '@aws-sdk/credential-provider-sso': 3.972.18 - '@aws-sdk/credential-provider-web-identity': 3.972.18 - '@aws-sdk/types': 3.973.5 - '@smithy/credential-provider-imds': 4.2.11 - '@smithy/property-provider': 4.2.11 - '@smithy/shared-ini-file-loader': 4.4.6 - '@smithy/types': 4.13.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - '@aws-sdk/credential-provider-node@3.972.20': dependencies: '@aws-sdk/credential-provider-env': 3.972.17 @@ -7635,19 +7555,6 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-provider-sso@3.972.18': - dependencies: - '@aws-sdk/core': 3.973.19 - '@aws-sdk/nested-clients': 3.996.8 - '@aws-sdk/token-providers': 3.1005.0 - '@aws-sdk/types': 3.973.5 - '@smithy/property-provider': 4.2.11 - '@smithy/shared-ini-file-loader': 4.4.6 - '@smithy/types': 4.13.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - '@aws-sdk/credential-provider-sso@3.972.19': dependencies: '@aws-sdk/core': 3.973.19 @@ -7685,18 +7592,6 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-provider-web-identity@3.972.18': - dependencies: - '@aws-sdk/core': 3.973.19 - '@aws-sdk/nested-clients': 3.996.8 - '@aws-sdk/types': 3.973.5 - '@smithy/property-provider': 4.2.11 - '@smithy/shared-ini-file-loader': 4.4.6 - '@smithy/types': 4.13.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - '@aws-sdk/credential-provider-web-identity@3.972.19': dependencies: '@aws-sdk/core': 3.973.19 @@ -7961,49 +7856,6 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/nested-clients@3.996.8': - dependencies: - '@aws-crypto/sha256-browser': 5.2.0 - '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.973.19 - '@aws-sdk/middleware-host-header': 3.972.7 - '@aws-sdk/middleware-logger': 3.972.7 - '@aws-sdk/middleware-recursion-detection': 3.972.7 - '@aws-sdk/middleware-user-agent': 3.972.20 - '@aws-sdk/region-config-resolver': 3.972.7 - '@aws-sdk/types': 3.973.5 - '@aws-sdk/util-endpoints': 3.996.4 - '@aws-sdk/util-user-agent-browser': 3.972.7 - '@aws-sdk/util-user-agent-node': 3.973.5 - '@smithy/config-resolver': 4.4.10 - '@smithy/core': 3.23.9 - '@smithy/fetch-http-handler': 5.3.13 - '@smithy/hash-node': 4.2.11 - '@smithy/invalid-dependency': 4.2.11 - '@smithy/middleware-content-length': 4.2.11 - '@smithy/middleware-endpoint': 4.4.23 - '@smithy/middleware-retry': 4.4.40 - '@smithy/middleware-serde': 4.2.12 - '@smithy/middleware-stack': 4.2.11 - '@smithy/node-config-provider': 4.3.11 - '@smithy/node-http-handler': 4.4.14 - '@smithy/protocol-http': 5.3.11 - '@smithy/smithy-client': 4.12.3 - '@smithy/types': 4.13.0 - '@smithy/url-parser': 4.2.11 - '@smithy/util-base64': 4.3.2 - '@smithy/util-body-length-browser': 4.2.2 - '@smithy/util-body-length-node': 4.2.3 - '@smithy/util-defaults-mode-browser': 4.3.39 - '@smithy/util-defaults-mode-node': 4.2.42 - '@smithy/util-endpoints': 3.3.2 - '@smithy/util-middleware': 4.2.11 - '@smithy/util-retry': 4.2.11 - '@smithy/util-utf8': 4.2.2 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - '@aws-sdk/nested-clients@3.996.9': dependencies: '@aws-crypto/sha256-browser': 5.2.0 @@ -8095,30 +7947,6 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/token-providers@3.1005.0': - dependencies: - '@aws-sdk/core': 3.973.19 - '@aws-sdk/nested-clients': 3.996.8 - '@aws-sdk/types': 3.973.5 - '@smithy/property-provider': 4.2.11 - '@smithy/shared-ini-file-loader': 4.4.6 - '@smithy/types': 4.13.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/token-providers@3.1007.0': - dependencies: - '@aws-sdk/core': 3.973.19 - '@aws-sdk/nested-clients': 3.996.8 - '@aws-sdk/types': 3.973.5 - '@smithy/property-provider': 4.2.11 - '@smithy/shared-ini-file-loader': 4.4.6 - '@smithy/types': 4.13.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - '@aws-sdk/token-providers@3.1008.0': dependencies: '@aws-sdk/core': 3.973.19 @@ -8225,14 +8053,6 @@ snapshots: '@smithy/types': 4.13.0 tslib: 2.8.1 - '@aws-sdk/util-user-agent-node@3.973.5': - dependencies: - '@aws-sdk/middleware-user-agent': 3.972.20 - '@aws-sdk/types': 3.973.5 - '@smithy/node-config-provider': 4.3.11 - '@smithy/types': 4.13.0 - tslib: 2.8.1 - '@aws-sdk/util-user-agent-node@3.973.6': dependencies: '@aws-sdk/middleware-user-agent': 3.972.20 @@ -11167,6 +10987,12 @@ snapshots: dependencies: undici-types: 7.18.2 + '@types/pg@8.18.0': + dependencies: + '@types/node': 25.5.0 + pg-protocol: 1.13.0 + pg-types: 2.2.0 + '@types/qrcode-terminal@0.12.2': {} '@types/qs@6.14.0': {} @@ -13500,7 +13326,7 @@ snapshots: openclaw@2026.3.11(@discordjs/opus@0.10.0)(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(node-llama-cpp@3.16.2(typescript@5.9.3)): dependencies: '@agentclientprotocol/sdk': 0.16.1(zod@4.3.6) - '@aws-sdk/client-bedrock': 3.1007.0 + '@aws-sdk/client-bedrock': 3.1008.0 '@buape/carbon': 0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.12.7)(opusscript@0.1.1) '@clack/prompts': 1.1.0 '@discordjs/voice': 0.19.1(@discordjs/opus@0.10.0)(opusscript@0.1.1) @@ -13551,7 +13377,7 @@ snapshots: sqlite-vec: 0.1.7-alpha.2 tar: 7.5.11 tslog: 4.10.2 - undici: 7.22.0 + undici: 7.24.0 ws: 8.19.0 yaml: 2.8.2 zod: 4.3.6 @@ -13756,6 +13582,41 @@ snapshots: performance-now@2.1.0: {} + pg-cloudflare@1.3.0: + optional: true + + pg-connection-string@2.12.0: {} + + pg-int8@1.0.1: {} + + pg-pool@3.13.0(pg@8.20.0): + dependencies: + pg: 8.20.0 + + pg-protocol@1.13.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.20.0: + dependencies: + pg-connection-string: 2.12.0 + pg-pool: 3.13.0(pg@8.20.0) + pg-protocol: 1.13.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.3.0 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -13806,6 +13667,16 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postgres-array@2.0.0: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + postgres@3.4.8: {} pretty-bytes@6.1.1: {} @@ -14725,8 +14596,6 @@ snapshots: undici-types@7.18.2: {} - undici@7.22.0: {} - undici@7.24.0: {} unist-util-is@6.0.1: @@ -14925,6 +14794,8 @@ snapshots: xmlchars@2.2.0: {} + xtend@4.0.2: {} + y18n@5.0.8: {} yallist@4.0.0: {} diff --git a/scripts/claw-broker/.env.example b/scripts/claw-broker/.env.example new file mode 100644 index 000000000..c2a3a0ad7 --- /dev/null +++ b/scripts/claw-broker/.env.example @@ -0,0 +1,11 @@ +CLAW_BROKER_BIND=127.0.0.1 +CLAW_BROKER_PORT=8787 +CLAW_BROKER_TOKEN=change-me +CLAW_BROKER_CMD_TIMEOUT_MS=120000 +CLAW_BROKER_MAX_SUMMARY_CHARS=2000 + +PGHOST=147.45.189.234 +PGPORT=5432 +PGDATABASE=default_db +PGUSER=gen_user +PGPASSWORD=change-me diff --git a/scripts/claw-broker/README.md b/scripts/claw-broker/README.md new file mode 100644 index 000000000..ff0b4764a --- /dev/null +++ b/scripts/claw-broker/README.md @@ -0,0 +1,43 @@ +# Claw Broker (MVP) + +Minimal privileged broker for claw.approvals.execute. + +## API + +- POST /v1/execute +- Bearer token via CLAW_BROKER_TOKEN + +Request fields: + +- executionId +- approvalRequestId +- approvalGrantId +- exactCommand +- targetHost +- targetUser +- requestedBy +- channel +- chatId +- humanUserId +- sessionId + +Response fields: + +- executionId +- status +- exitCode +- stdoutSummary +- stderrSummary +- startedAt +- finishedAt + +## Validation + +Broker re-checks in Postgres before execution: + +- request/grant exist +- status allows execution +- once grant atomic consume +- command exact match +- scope match (targetHost, targetUser, channel, chatId, humanUserId, sessionId) +- dangerous shell policy diff --git a/scripts/claw-broker/broker.mjs b/scripts/claw-broker/broker.mjs new file mode 100644 index 000000000..3ca508ea1 --- /dev/null +++ b/scripts/claw-broker/broker.mjs @@ -0,0 +1,437 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import http from "node:http"; +import pg from "pg"; + +const { Pool } = pg; + +const MAX_SUMMARY_CHARS = Number(process.env.CLAW_BROKER_MAX_SUMMARY_CHARS ?? "2000"); +const CMD_TIMEOUT_MS = Number(process.env.CLAW_BROKER_CMD_TIMEOUT_MS ?? "120000"); +const BIND_HOST = process.env.CLAW_BROKER_BIND ?? "127.0.0.1"; +const BIND_PORT = Number(process.env.CLAW_BROKER_PORT ?? "8787"); +const REQUIRED_TOKEN = (process.env.CLAW_BROKER_TOKEN ?? "").trim(); + +function env(name, fallback = undefined) { + return process.env[`CLAW_${name}`] ?? process.env[name] ?? fallback; +} + +function requiredEnv(name) { + const value = env(name, ""); + if (!value || !String(value).trim()) { + throw new Error(`missing env: ${name} (or CLAW_${name})`); + } + return String(value); +} + +const pool = new Pool({ + host: requiredEnv("PGHOST"), + port: Number(env("PGPORT", "5432")), + user: requiredEnv("PGUSER"), + password: requiredEnv("PGPASSWORD"), + database: requiredEnv("PGDATABASE"), + max: 10, +}); + +if (!REQUIRED_TOKEN) { + throw new Error("missing CLAW_BROKER_TOKEN"); +} + +function json(res, code, body) { + const payload = JSON.stringify(body); + res.writeHead(code, { + "content-type": "application/json; charset=utf-8", + "content-length": Buffer.byteLength(payload), + }); + res.end(payload); +} + +function normalizeCommand(input) { + return String(input).trim().replace(/\s+/g, " "); +} + +function hasDangerousShellConstruct(command) { + const source = String(command).toLowerCase(); + const checks = [ + /\bbash\s+-c\b/, + /\bsh\s+-c\b/, + /\bsudo\s+su\b/, + /\bsudo\s+-i\b/, + /&&/, + /\|\|/, + /;/, + /\|/, + />/, + / r.test(source)); +} + +function summarize(text) { + const value = String(text ?? ""); + return value.length <= MAX_SUMMARY_CHARS ? value : `${value.slice(0, MAX_SUMMARY_CHARS)}…`; +} + +async function insertAudit(client, args) { + await client.query( + `INSERT INTO claw_audit_events ( + event_type, request_id, grant_id, execution_id, + actor_type, actor_id, target_host, target_user, + command_snapshot, status, exit_code, stdout_summary, stderr_summary, metadata + ) VALUES ( + $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14::jsonb + )`, + [ + args.eventType, + args.requestId ?? null, + args.grantId ?? null, + args.executionId ?? null, + args.actorType, + args.actorId, + args.targetHost ?? null, + args.targetUser ?? null, + args.commandSnapshot ?? null, + args.status ?? null, + args.exitCode ?? null, + args.stdoutSummary ?? null, + args.stderrSummary ?? null, + JSON.stringify(args.metadata ?? {}), + ], + ); +} + +function requireString(body, key) { + const value = body?.[key]; + if (typeof value !== "string" || value.trim().length === 0) { + throw new Error(`${key} is required`); + } + return value.trim(); +} + +async function verifyAndMarkStarted(body) { + const executionId = requireString(body, "executionId"); + const approvalRequestId = requireString(body, "approvalRequestId"); + const approvalGrantId = requireString(body, "approvalGrantId"); + const exactCommand = requireString(body, "exactCommand"); + const targetHost = requireString(body, "targetHost"); + const targetUser = requireString(body, "targetUser"); + const requestedBy = requireString(body, "requestedBy"); + const channel = requireString(body, "channel"); + const chatId = requireString(body, "chatId"); + const humanUserId = requireString(body, "humanUserId"); + const sessionId = requireString(body, "sessionId"); + + if (hasDangerousShellConstruct(exactCommand)) { + throw new Error("dangerous shell policy violation"); + } + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + const reqRes = await client.query( + `SELECT * FROM claw_approval_requests WHERE id = $1 FOR UPDATE`, + [approvalRequestId], + ); + if (reqRes.rowCount === 0) { + throw new Error("approval request not found"); + } + const request = reqRes.rows[0]; + + if (!["approved_once", "approved_always"].includes(String(request.status))) { + throw new Error(`request status does not allow execution: ${request.status}`); + } + + const grantRes = await client.query( + `SELECT * FROM claw_approval_grants WHERE id = $1 AND request_id = $2 FOR UPDATE`, + [approvalGrantId, approvalRequestId], + ); + if (grantRes.rowCount === 0) { + throw new Error("approval grant not found"); + } + const grant = grantRes.rows[0]; + + const dbExact = String(request.exact_command); + if (normalizeCommand(dbExact) !== normalizeCommand(exactCommand)) { + throw new Error("exact command mismatch"); + } + if (normalizeCommand(String(grant.exact_command)) !== normalizeCommand(exactCommand)) { + throw new Error("grant command mismatch"); + } + + const scopeChecks = [ + [String(request.target_host), targetHost, "targetHost"], + [String(request.target_user), targetUser, "targetUser"], + [String(request.channel), channel, "channel"], + [String(request.chat_id), chatId, "chatId"], + [String(request.human_user_id), humanUserId, "humanUserId"], + [String(request.session_id), sessionId, "sessionId"], + [String(grant.target_host), targetHost, "grant.targetHost"], + [String(grant.target_user), targetUser, "grant.targetUser"], + [String(grant.channel), channel, "grant.channel"], + [String(grant.chat_id), chatId, "grant.chatId"], + [String(grant.human_user_id), humanUserId, "grant.humanUserId"], + [String(grant.session_id), sessionId, "grant.sessionId"], + ]; + for (const [db, incoming, label] of scopeChecks) { + if (db !== incoming) { + throw new Error(`scope mismatch: ${label}`); + } + } + + if (hasDangerousShellConstruct(String(request.exact_command))) { + throw new Error("dangerous shell policy violation (request)"); + } + + if (String(grant.grant_type) === "once") { + const consumeRes = await client.query( + `UPDATE claw_approval_grants + SET used_at = now() + WHERE id = $1 + AND grant_type = 'once' + AND used_at IS NULL + AND revoked_at IS NULL + AND expires_at > now() + RETURNING id`, + [approvalGrantId], + ); + if (consumeRes.rowCount === 0) { + throw new Error("once grant expired/revoked/already used"); + } + await insertAudit(client, { + eventType: "grant_consumed", + actorType: "broker", + actorId: requestedBy, + requestId: approvalRequestId, + grantId: approvalGrantId, + executionId, + targetHost, + targetUser, + commandSnapshot: exactCommand, + status: "grant_consumed", + }); + } + + await client.query( + `UPDATE claw_approval_requests SET execution_id = $2, updated_at = now() WHERE id = $1`, + [approvalRequestId, executionId], + ); + + await insertAudit(client, { + eventType: "execution_started", + actorType: "broker", + actorId: requestedBy, + requestId: approvalRequestId, + grantId: approvalGrantId, + executionId, + targetHost, + targetUser, + commandSnapshot: exactCommand, + status: "execution_started", + }); + + await client.query("COMMIT"); + return { + executionId, + approvalRequestId, + approvalGrantId, + exactCommand, + targetHost, + targetUser, + requestedBy, + cwd: request.cwd ? String(request.cwd) : undefined, + }; + } catch (err) { + await client.query("ROLLBACK"); + throw err; + } finally { + client.release(); + } +} + +async function runCommand(command, cwd) { + return await new Promise((resolve) => { + const child = spawn("bash", ["-lc", command], { + cwd: cwd || undefined, + env: process.env, + stdio: ["ignore", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + child.kill("SIGKILL"); + }, CMD_TIMEOUT_MS); + + child.stdout.on("data", (chunk) => { + stdout += chunk.toString("utf8"); + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString("utf8"); + }); + child.on("close", (code) => { + clearTimeout(timer); + resolve({ + exitCode: timedOut ? 124 : Number(code ?? 1), + stdout, + stderr: timedOut ? `${stderr}\nCommand timed out.` : stderr, + }); + }); + }); +} + +async function finalizeExecution({ + executionId, + approvalRequestId, + approvalGrantId, + exactCommand, + targetHost, + targetUser, + requestedBy, + ok, + exitCode, + stdoutSummary, + stderrSummary, +}) { + const client = await pool.connect(); + try { + await client.query("BEGIN"); + const finalStatus = ok ? "executed" : "execution_failed"; + const lastError = ok ? null : stderrSummary; + await client.query( + `UPDATE claw_approval_requests + SET status = $2::claw_approval_status, + executed_at = now(), + updated_at = now(), + last_error = $3 + WHERE id = $1`, + [approvalRequestId, finalStatus, lastError], + ); + await insertAudit(client, { + eventType: ok ? "execution_succeeded" : "execution_failed", + actorType: "broker", + actorId: requestedBy, + requestId: approvalRequestId, + grantId: approvalGrantId, + executionId, + targetHost, + targetUser, + commandSnapshot: exactCommand, + status: ok ? "executed" : "execution_failed", + exitCode, + stdoutSummary, + stderrSummary, + }); + await client.query("COMMIT"); + } catch (err) { + await client.query("ROLLBACK"); + throw err; + } finally { + client.release(); + } +} + +async function handleExecute(body) { + const startedAt = new Date().toISOString(); + const validated = await verifyAndMarkStarted(body); + const run = await runCommand(validated.exactCommand, validated.cwd); + const ok = run.exitCode === 0; + const stdoutSummary = summarize(run.stdout); + const stderrSummary = summarize(run.stderr); + + await finalizeExecution({ + executionId: validated.executionId, + approvalRequestId: validated.approvalRequestId, + approvalGrantId: validated.approvalGrantId, + exactCommand: validated.exactCommand, + targetHost: validated.targetHost, + targetUser: validated.targetUser, + requestedBy: validated.requestedBy, + ok, + exitCode: run.exitCode, + stdoutSummary, + stderrSummary, + }); + + const finishedAt = new Date().toISOString(); + return { + ok, + executionId: validated.executionId, + status: ok ? "executed" : "execution_failed", + exitCode: run.exitCode, + stdoutSummary, + stderrSummary, + startedAt, + finishedAt, + }; +} + +function getBearerToken(req) { + const raw = String(req.headers.authorization ?? ""); + if (!raw.toLowerCase().startsWith("bearer ")) { + return ""; + } + return raw.slice(7).trim(); +} + +const server = http.createServer((req, res) => { + const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "127.0.0.1"}`); + + if (req.method === "GET" && url.pathname === "/healthz") { + json(res, 200, { ok: true, service: "claw-broker" }); + return; + } + + if (req.method !== "POST" || url.pathname !== "/v1/execute") { + json(res, 404, { ok: false, error: "not_found" }); + return; + } + + const token = getBearerToken(req); + if (!token || token !== REQUIRED_TOKEN) { + json(res, 401, { ok: false, error: "unauthorized" }); + return; + } + + let raw = ""; + req.on("data", (chunk) => { + raw += chunk.toString("utf8"); + if (raw.length > 1_000_000) { + req.destroy(); + } + }); + req.on("end", async () => { + const fallbackExecutionId = randomUUID(); + try { + const body = raw.length ? JSON.parse(raw) : {}; + if (!body.executionId) { + body.executionId = fallbackExecutionId; + } + const result = await handleExecute(body); + json(res, 200, result); + } catch (err) { + console.error("[claw-broker] execute error:", err); + const nowIso = new Date().toISOString(); + json(res, 400, { + ok: false, + executionId: fallbackExecutionId, + status: "execution_failed", + exitCode: 1, + stdoutSummary: "", + stderrSummary: String(err), + startedAt: nowIso, + finishedAt: nowIso, + }); + } + }); +}); + +server.listen(BIND_PORT, BIND_HOST, () => { + process.stdout.write(`claw-broker listening on http://${BIND_HOST}:${BIND_PORT}\n`); +}); diff --git a/scripts/claw-broker/claw-broker.service b/scripts/claw-broker/claw-broker.service new file mode 100644 index 000000000..800ffb158 --- /dev/null +++ b/scripts/claw-broker/claw-broker.service @@ -0,0 +1,20 @@ +[Unit] +Description=OpenClaw Privileged Broker (MVP) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=root +WorkingDirectory=/home/negodiy/claw-broker +EnvironmentFile=/home/negodiy/claw-broker/.env +ExecStart=/usr/bin/node /home/negodiy/claw-broker/broker.mjs +Restart=always +RestartSec=2 +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=full +ProtectHome=no + +[Install] +WantedBy=multi-user.target diff --git a/scripts/claw-broker/package.json b/scripts/claw-broker/package.json new file mode 100644 index 000000000..859262310 --- /dev/null +++ b/scripts/claw-broker/package.json @@ -0,0 +1,13 @@ +{ + "name": "claw-broker", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "broker.mjs", + "scripts": { + "start": "node broker.mjs" + }, + "dependencies": { + "pg": "^8.20.0" + } +} diff --git a/src/gateway/claw-approvals-store.ts b/src/gateway/claw-approvals-store.ts new file mode 100644 index 000000000..ed59a9fff --- /dev/null +++ b/src/gateway/claw-approvals-store.ts @@ -0,0 +1,682 @@ +import { randomUUID } from "node:crypto"; +import { Pool, type PoolClient } from "pg"; + +export type ClawApprovalStatus = + | "pending" + | "approved_once" + | "approved_always" + | "rejected" + | "expired" + | "executed" + | "execution_failed"; + +export type ClawRiskLevel = "low" | "medium" | "high"; + +export type ClawApprovalRequestRow = { + id: string; + createdAt: string; + updatedAt: string; + requestedByAgent: string; + sessionId: string; + channel: string; + chatId: string; + humanUserId: string; + targetHost: string; + targetUser: string; + cwd: string | null; + humanSummary: string; + reason: string; + exactCommand: string; + normalizedCommand: string; + riskLevel: ClawRiskLevel; + rollbackHint: string | null; + requiresPrivilege: boolean; + dangerousFlags: Record; + status: ClawApprovalStatus; + statusReason: string | null; + approvedBy: string | null; + approvedAt: string | null; + rejectedBy: string | null; + rejectedAt: string | null; + expiredAt: string | null; + executedAt: string | null; + executionId: string | null; + lastError: string | null; +}; + +export type CreateClawApprovalRequestInput = { + requestedByAgent: string; + sessionId: string; + channel: string; + chatId: string; + humanUserId: string; + targetHost: string; + targetUser: string; + cwd?: string | null; + humanSummary: string; + reason: string; + exactCommand: string; + riskLevel: ClawRiskLevel; + rollbackHint?: string | null; + dangerousFlags?: Record; +}; + +export type ClawApproveInput = { + id: string; + actorId: string; +}; + +export type ClawApproveOnceInput = ClawApproveInput & { + ttlSeconds: number; +}; + +export type ClawRejectInput = ClawApproveInput & { + reason?: string | null; +}; + +export type ClawExecuteInput = { + id: string; + grantId: string; + actorId: string; +}; + +export type BrokerExecutePayload = { + executionId: string; + approvalRequestId: string; + approvalGrantId: string; + exactCommand: string; + targetHost: string; + targetUser: string; + requestedBy: string; + channel: string; + chatId: string; + humanUserId: string; + sessionId: string; +}; + +export type BrokerExecuteResult = { + executionId: string; + status: string; + ok: boolean; + exitCode: number; + stdoutSummary?: string; + stderrSummary?: string; + startedAt: string; + finishedAt: string; +}; + +let pool: Pool | null = null; + +function resolveEnv(name: string): string | undefined { + return process.env[`CLAW_${name}`] ?? process.env[name]; +} + +function requireEnv(name: string): string { + const v = resolveEnv(name); + if (!v || !v.trim()) { + throw new Error(`missing required environment variable: ${name} (or CLAW_${name})`); + } + return v; +} + +function getPool(): Pool { + if (pool) { + return pool; + } + + const host = requireEnv("PGHOST"); + const portRaw = resolveEnv("PGPORT") ?? "5432"; + const user = requireEnv("PGUSER"); + const password = requireEnv("PGPASSWORD"); + const database = requireEnv("PGDATABASE"); + + const port = Number(portRaw); + if (!Number.isFinite(port) || port <= 0) { + throw new Error(`invalid PGPORT: ${portRaw}`); + } + + pool = new Pool({ + host, + port, + user, + password, + database, + max: 10, + }); + + return pool; +} + +export function normalizeCommand(input: string): string { + return input.trim().replace(/\s+/g, " "); +} + +export function hasDangerousShellConstruct(command: string): boolean { + const source = command.toLowerCase(); + const checks: RegExp[] = [ + /\bbash\s+-c\b/, + /\bsh\s+-c\b/, + /\bsudo\s+su\b/, + /\bsudo\s+-i\b/, + /&&/, + /\|\|/, + /;/, + /\|/, + />/, + / r.test(source)); +} + +function mapRequestRow(row: Record): ClawApprovalRequestRow { + return { + id: String(row.id), + createdAt: String(row.created_at), + updatedAt: String(row.updated_at), + requestedByAgent: String(row.requested_by_agent), + sessionId: String(row.session_id), + channel: String(row.channel), + chatId: String(row.chat_id), + humanUserId: String(row.human_user_id), + targetHost: String(row.target_host), + targetUser: String(row.target_user), + cwd: (row.cwd as string | null) ?? null, + humanSummary: String(row.human_summary), + reason: String(row.reason), + exactCommand: String(row.exact_command), + normalizedCommand: String(row.normalized_command), + riskLevel: String(row.risk_level) as ClawRiskLevel, + rollbackHint: (row.rollback_hint as string | null) ?? null, + requiresPrivilege: Boolean(row.requires_privilege), + dangerousFlags: (row.dangerous_flags as Record | null) ?? {}, + status: String(row.status) as ClawApprovalStatus, + statusReason: (row.status_reason as string | null) ?? null, + approvedBy: (row.approved_by as string | null) ?? null, + approvedAt: (row.approved_at as string | null) ?? null, + rejectedBy: (row.rejected_by as string | null) ?? null, + rejectedAt: (row.rejected_at as string | null) ?? null, + expiredAt: (row.expired_at as string | null) ?? null, + executedAt: (row.executed_at as string | null) ?? null, + executionId: (row.execution_id as string | null) ?? null, + lastError: (row.last_error as string | null) ?? null, + }; +} + +async function insertAudit( + client: PoolClient, + args: { + eventType: string; + actorType: "agent" | "human" | "backend" | "broker" | "system"; + actorId: string; + requestId?: string; + grantId?: string; + executionId?: string; + targetHost?: string; + targetUser?: string; + commandSnapshot?: string; + status?: string; + exitCode?: number; + stdoutSummary?: string; + stderrSummary?: string; + metadata?: Record; + }, +): Promise { + await client.query( + `INSERT INTO claw_audit_events ( + event_type, + request_id, + grant_id, + execution_id, + actor_type, + actor_id, + target_host, + target_user, + command_snapshot, + status, + exit_code, + stdout_summary, + stderr_summary, + metadata + ) VALUES ( + $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14::jsonb + )`, + [ + args.eventType, + args.requestId ?? null, + args.grantId ?? null, + args.executionId ?? null, + args.actorType, + args.actorId, + args.targetHost ?? null, + args.targetUser ?? null, + args.commandSnapshot ?? null, + args.status ?? null, + args.exitCode ?? null, + args.stdoutSummary ?? null, + args.stderrSummary ?? null, + JSON.stringify(args.metadata ?? {}), + ], + ); +} + +export class ClawApprovalsStore { + async createApprovalRequest( + input: CreateClawApprovalRequestInput, + ): Promise { + const normalizedCommand = normalizeCommand(input.exactCommand); + const dangerousFlags = { + hasDangerousShell: hasDangerousShellConstruct(input.exactCommand), + ...input.dangerousFlags, + }; + + const db = getPool(); + const client = await db.connect(); + try { + await client.query("BEGIN"); + const res = await client.query( + `INSERT INTO claw_approval_requests ( + requested_by_agent, session_id, channel, chat_id, human_user_id, + target_host, target_user, cwd, + human_summary, reason, exact_command, normalized_command, + risk_level, rollback_hint, requires_privilege, dangerous_flags, status + ) VALUES ( + $1,$2,$3,$4,$5, + $6,$7,$8, + $9,$10,$11,$12, + $13,$14,TRUE,$15::jsonb,'pending' + ) RETURNING *`, + [ + input.requestedByAgent, + input.sessionId, + input.channel, + input.chatId, + input.humanUserId, + input.targetHost, + input.targetUser, + input.cwd ?? null, + input.humanSummary, + input.reason, + input.exactCommand, + normalizedCommand, + input.riskLevel, + input.rollbackHint ?? null, + JSON.stringify(dangerousFlags), + ], + ); + + const row = mapRequestRow(res.rows[0]); + await insertAudit(client, { + eventType: "request_created", + actorType: "agent", + actorId: input.requestedByAgent, + requestId: row.id, + targetHost: row.targetHost, + targetUser: row.targetUser, + commandSnapshot: row.exactCommand, + status: row.status, + }); + await client.query("COMMIT"); + return row; + } catch (err) { + await client.query("ROLLBACK"); + throw err; + } finally { + client.release(); + } + } + + async listApprovalRequests(status?: string): Promise { + const db = getPool(); + const res = await db.query( + status && status.trim().length > 0 + ? `SELECT * FROM claw_approval_requests WHERE status = $1 ORDER BY created_at DESC LIMIT 200` + : `SELECT * FROM claw_approval_requests ORDER BY created_at DESC LIMIT 200`, + status && status.trim().length > 0 ? [status.trim()] : [], + ); + return res.rows.map(mapRequestRow); + } + + async getApprovalRequest(id: string): Promise { + const db = getPool(); + const res = await db.query(`SELECT * FROM claw_approval_requests WHERE id = $1 LIMIT 1`, [id]); + if (res.rowCount === 0) { + return null; + } + return mapRequestRow(res.rows[0]); + } + + async approveOnce( + input: ClawApproveOnceInput, + ): Promise<{ request: ClawApprovalRequestRow; grantId: string; expiresAt: string }> { + const db = getPool(); + const client = await db.connect(); + try { + await client.query("BEGIN"); + const reqRes = await client.query( + `SELECT * FROM claw_approval_requests WHERE id = $1 FOR UPDATE`, + [input.id], + ); + if (reqRes.rowCount === 0) { + throw new Error("approval request not found"); + } + const req = mapRequestRow(reqRes.rows[0]); + if (req.status !== "pending") { + throw new Error(`cannot approve_once from status=${req.status}`); + } + + const ttl = Math.max(120, Math.min(300, Math.trunc(input.ttlSeconds || 180))); + const grantRes = await client.query( + `INSERT INTO claw_approval_grants ( + request_id, grant_type, match_type, + target_host, target_user, channel, chat_id, human_user_id, session_id, + exact_command, normalized_command, approved_by, expires_at + ) VALUES ( + $1,'once','exact',$2,$3,$4,$5,$6,$7,$8,$9,$10, now() + ($11 || ' seconds')::interval + ) RETURNING id, expires_at`, + [ + req.id, + req.targetHost, + req.targetUser, + req.channel, + req.chatId, + req.humanUserId, + req.sessionId, + req.exactCommand, + req.normalizedCommand, + input.actorId, + String(ttl), + ], + ); + + await client.query( + `UPDATE claw_approval_requests + SET status='approved_once', approved_by=$2, approved_at=now(), updated_at=now() + WHERE id = $1`, + [req.id, input.actorId], + ); + + await insertAudit(client, { + eventType: "request_approved_once", + actorType: "human", + actorId: input.actorId, + requestId: req.id, + grantId: String(grantRes.rows[0].id), + targetHost: req.targetHost, + targetUser: req.targetUser, + commandSnapshot: req.exactCommand, + status: "approved_once", + }); + + await client.query("COMMIT"); + const next = await this.getApprovalRequest(req.id); + if (!next) { + throw new Error("approval request not found after approve_once"); + } + return { + request: next, + grantId: String(grantRes.rows[0].id), + expiresAt: String(grantRes.rows[0].expires_at), + }; + } catch (err) { + await client.query("ROLLBACK"); + throw err; + } finally { + client.release(); + } + } + + async approveAlways( + input: ClawApproveInput, + ): Promise<{ request: ClawApprovalRequestRow; grantId: string; allowRuleId: string }> { + const db = getPool(); + const client = await db.connect(); + try { + await client.query("BEGIN"); + const reqRes = await client.query( + `SELECT * FROM claw_approval_requests WHERE id = $1 FOR UPDATE`, + [input.id], + ); + if (reqRes.rowCount === 0) { + throw new Error("approval request not found"); + } + const req = mapRequestRow(reqRes.rows[0]); + if (req.status !== "pending") { + throw new Error(`cannot approve_always from status=${req.status}`); + } + if (hasDangerousShellConstruct(req.exactCommand)) { + throw new Error("always allow is forbidden for dangerous shell constructs"); + } + + const grantRes = await client.query( + `INSERT INTO claw_approval_grants ( + request_id, grant_type, match_type, + target_host, target_user, channel, chat_id, human_user_id, session_id, + exact_command, normalized_command, approved_by + ) VALUES ( + $1,'always','exact',$2,$3,$4,$5,$6,$7,$8,$9,$10 + ) RETURNING id`, + [ + req.id, + req.targetHost, + req.targetUser, + req.channel, + req.chatId, + req.humanUserId, + req.sessionId, + req.exactCommand, + req.normalizedCommand, + input.actorId, + ], + ); + + const ruleRes = await client.query( + `INSERT INTO claw_allow_rules ( + created_by, source_request_id, + target_host, target_user, channel, chat_id, human_user_id, + command_pattern_type, command_pattern, normalized_pattern, enabled + ) VALUES ( + $1,$2,$3,$4,$5,$6,$7,'exact',$8,$9,TRUE + ) + ON CONFLICT ON CONSTRAINT uq_claw_allow_rules_active_exact + DO UPDATE SET updated_at=now(), enabled=TRUE + RETURNING id`, + [ + input.actorId, + req.id, + req.targetHost, + req.targetUser, + req.channel, + req.chatId, + req.humanUserId, + req.exactCommand, + req.normalizedCommand, + ], + ); + + await client.query( + `UPDATE claw_approval_requests + SET status='approved_always', approved_by=$2, approved_at=now(), updated_at=now() + WHERE id = $1`, + [req.id, input.actorId], + ); + + await insertAudit(client, { + eventType: "request_approved_always", + actorType: "human", + actorId: input.actorId, + requestId: req.id, + grantId: String(grantRes.rows[0].id), + targetHost: req.targetHost, + targetUser: req.targetUser, + commandSnapshot: req.exactCommand, + status: "approved_always", + metadata: { allowRuleId: String(ruleRes.rows[0].id) }, + }); + + await client.query("COMMIT"); + const next = await this.getApprovalRequest(req.id); + if (!next) { + throw new Error("approval request not found after approve_always"); + } + return { + request: next, + grantId: String(grantRes.rows[0].id), + allowRuleId: String(ruleRes.rows[0].id), + }; + } catch (err) { + await client.query("ROLLBACK"); + throw err; + } finally { + client.release(); + } + } + + async reject(input: ClawRejectInput): Promise { + const db = getPool(); + const client = await db.connect(); + try { + await client.query("BEGIN"); + const reqRes = await client.query( + `SELECT * FROM claw_approval_requests WHERE id = $1 FOR UPDATE`, + [input.id], + ); + if (reqRes.rowCount === 0) { + throw new Error("approval request not found"); + } + const req = mapRequestRow(reqRes.rows[0]); + if (req.status !== "pending") { + throw new Error(`cannot reject from status=${req.status}`); + } + + await client.query( + `UPDATE claw_approval_requests + SET status='rejected', rejected_by=$2, rejected_at=now(), status_reason=$3, updated_at=now() + WHERE id = $1`, + [req.id, input.actorId, input.reason ?? null], + ); + + await insertAudit(client, { + eventType: "request_rejected", + actorType: "human", + actorId: input.actorId, + requestId: req.id, + targetHost: req.targetHost, + targetUser: req.targetUser, + commandSnapshot: req.exactCommand, + status: "rejected", + metadata: { reason: input.reason ?? null }, + }); + + await client.query("COMMIT"); + const next = await this.getApprovalRequest(req.id); + if (!next) { + throw new Error("approval request not found after reject"); + } + return next; + } catch (err) { + await client.query("ROLLBACK"); + throw err; + } finally { + client.release(); + } + } + + async executeApproved( + input: ClawExecuteInput, + runBroker: (payload: BrokerExecutePayload) => Promise, + ): Promise<{ + request: ClawApprovalRequestRow; + executionId: string; + broker: BrokerExecuteResult; + }> { + const db = getPool(); + const client = await db.connect(); + let request: ClawApprovalRequestRow | null = null; + try { + await client.query("BEGIN"); + + const reqRes = await client.query( + `SELECT * FROM claw_approval_requests WHERE id = $1 FOR UPDATE`, + [input.id], + ); + if (reqRes.rowCount === 0) { + throw new Error("approval request not found"); + } + request = mapRequestRow(reqRes.rows[0]); + if (request.status !== "approved_once" && request.status !== "approved_always") { + throw new Error(`cannot execute from status=${request.status}`); + } + + const grantRes = await client.query( + `SELECT * FROM claw_approval_grants WHERE id = $1 AND request_id = $2 FOR UPDATE`, + [input.grantId, request.id], + ); + if (grantRes.rowCount === 0) { + throw new Error("grant not found"); + } + const grant = grantRes.rows[0] as Record; + + const exactCommand = String(grant.exact_command); + if (normalizeCommand(exactCommand) !== request.normalizedCommand) { + throw new Error("grant command mismatch with request"); + } + + await client.query("COMMIT"); + } catch (err) { + await client.query("ROLLBACK"); + throw err; + } finally { + client.release(); + } + + if (!request) { + throw new Error("request resolution failed"); + } + + const broker = await runBroker({ + executionId: randomUUID(), + approvalRequestId: request.id, + approvalGrantId: input.grantId, + exactCommand: request.exactCommand, + targetHost: request.targetHost, + targetUser: request.targetUser, + requestedBy: input.actorId, + channel: request.channel, + chatId: request.chatId, + humanUserId: request.humanUserId, + sessionId: request.sessionId, + }); + + const latest = await this.getApprovalRequest(request.id); + if (!latest) { + throw new Error("approval request not found after execution"); + } + + return { + request: latest, + executionId: broker.executionId, + broker, + }; + } + + async getAuditTrail(requestId: string): Promise[]> { + const db = getPool(); + const res = await db.query( + `SELECT * FROM claw_audit_events WHERE request_id = $1 ORDER BY occurred_at ASC, id ASC`, + [requestId], + ); + return res.rows as Record[]; + } +} + +let singleton: ClawApprovalsStore | null = null; + +export function getClawApprovalsStore(): ClawApprovalsStore { + if (!singleton) { + singleton = new ClawApprovalsStore(); + } + return singleton; +} diff --git a/src/gateway/method-scopes.ts b/src/gateway/method-scopes.ts index f4f572592..143743cf8 100644 --- a/src/gateway/method-scopes.ts +++ b/src/gateway/method-scopes.ts @@ -34,6 +34,14 @@ const METHOD_SCOPE_GROUPS: Record = { "exec.approval.request", "exec.approval.waitDecision", "exec.approval.resolve", + "claw.approvals.create", + "claw.approvals.list", + "claw.approvals.get", + "claw.approvals.approveOnce", + "claw.approvals.approveAlways", + "claw.approvals.reject", + "claw.approvals.execute", + "claw.approvals.audit", ], [PAIRING_SCOPE]: [ "node.pair.request", diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index f6f052f8c..5446a80ca 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -9,6 +9,7 @@ import { agentsHandlers } from "./server-methods/agents.js"; import { browserHandlers } from "./server-methods/browser.js"; import { channelsHandlers } from "./server-methods/channels.js"; import { chatHandlers } from "./server-methods/chat.js"; +import { clawApprovalsHandlers } from "./server-methods/claw-approvals.js"; import { configHandlers } from "./server-methods/config.js"; import { connectHandlers } from "./server-methods/connect.js"; import { cronHandlers } from "./server-methods/cron.js"; @@ -76,6 +77,7 @@ export const coreGatewayHandlers: GatewayRequestHandlers = { ...deviceHandlers, ...doctorHandlers, ...execApprovalsHandlers, + ...clawApprovalsHandlers, ...webHandlers, ...modelsHandlers, ...configHandlers, diff --git a/src/gateway/server-methods/claw-approvals.ts b/src/gateway/server-methods/claw-approvals.ts new file mode 100644 index 000000000..be510a2d5 --- /dev/null +++ b/src/gateway/server-methods/claw-approvals.ts @@ -0,0 +1,330 @@ +import { + getClawApprovalsStore, + type BrokerExecutePayload, + type BrokerExecuteResult, + type ClawRiskLevel, +} from "../claw-approvals-store.js"; +import { ErrorCodes, errorShape } from "../protocol/index.js"; +import type { GatewayRequestHandlers } from "./types.js"; + +type JsonMap = Record; + +function asObject(value: unknown): JsonMap | null { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonMap) : null; +} + +function getRequiredString(params: JsonMap, key: string): string { + const value = params[key]; + if (typeof value !== "string" || value.trim().length === 0) { + throw new Error(`${key} is required`); + } + return value.trim(); +} + +function getOptionalString(params: JsonMap, key: string): string | null { + const value = params[key]; + if (value == null) { + return null; + } + if (typeof value !== "string") { + throw new Error(`${key} must be a string`); + } + return value.trim(); +} + +function getRiskLevel(params: JsonMap): ClawRiskLevel { + const value = params.riskLevel; + if (value === "low" || value === "medium" || value === "high") { + return value; + } + throw new Error("riskLevel must be low|medium|high"); +} + +async function invokeBroker(payload: BrokerExecutePayload): Promise { + const brokerUrl = process.env.CLAW_BROKER_URL ?? "http://127.0.0.1:8787/v1/execute"; + const brokerToken = process.env.CLAW_BROKER_TOKEN?.trim(); + + const headers: Record = { + "content-type": "application/json", + }; + if (brokerToken) { + headers.authorization = `Bearer ${brokerToken}`; + } + + const res = await fetch(brokerUrl, { + method: "POST", + headers, + body: JSON.stringify(payload), + }); + + let body: JsonMap | null = null; + try { + body = (await res.json()) as JsonMap; + } catch { + body = null; + } + + if (!res.ok) { + const nowIso = new Date().toISOString(); + return { + executionId: payload.executionId, + status: "broker_error", + ok: false, + exitCode: 1, + stderrSummary: `broker error ${res.status}`, + startedAt: nowIso, + finishedAt: nowIso, + }; + } + + const ok = body?.ok === true; + const exitCodeRaw = body?.exitCode; + const exitCode = typeof exitCodeRaw === "number" ? exitCodeRaw : ok ? 0 : 1; + const startedAt = + typeof body?.startedAt === "string" && body.startedAt.length > 0 + ? body.startedAt + : new Date().toISOString(); + const finishedAt = + typeof body?.finishedAt === "string" && body.finishedAt.length > 0 + ? body.finishedAt + : startedAt; + return { + executionId: + typeof body?.executionId === "string" && body.executionId.length > 0 + ? body.executionId + : payload.executionId, + status: typeof body?.status === "string" ? body.status : ok ? "executed" : "execution_failed", + ok, + exitCode, + stdoutSummary: typeof body?.stdoutSummary === "string" ? body.stdoutSummary : "", + stderrSummary: typeof body?.stderrSummary === "string" ? body.stderrSummary : "", + startedAt, + finishedAt, + }; +} + +export const clawApprovalsHandlers: GatewayRequestHandlers = { + "claw.approvals.create": async ({ params, respond }) => { + try { + const p = asObject(params); + if (!p) { + throw new Error("params object is required"); + } + const store = getClawApprovalsStore(); + const request = await store.createApprovalRequest({ + requestedByAgent: getRequiredString(p, "requestedByAgent"), + sessionId: getRequiredString(p, "sessionId"), + channel: getRequiredString(p, "channel"), + chatId: getRequiredString(p, "chatId"), + humanUserId: getRequiredString(p, "humanUserId"), + targetHost: getRequiredString(p, "targetHost"), + targetUser: getRequiredString(p, "targetUser"), + cwd: getOptionalString(p, "cwd"), + humanSummary: getRequiredString(p, "humanSummary"), + reason: getRequiredString(p, "reason"), + exactCommand: getRequiredString(p, "exactCommand"), + riskLevel: getRiskLevel(p), + rollbackHint: getOptionalString(p, "rollbackHint"), + dangerousFlags: asObject(p.dangerousFlags) as Record | undefined, + }); + respond(true, { request }, undefined); + } catch (err) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `claw.approvals.create failed: ${String(err)}`), + ); + } + }, + + "claw.approvals.list": async ({ params, respond }) => { + try { + const p = asObject(params) ?? {}; + const status = getOptionalString(p, "status"); + const store = getClawApprovalsStore(); + const requests = await store.listApprovalRequests(status ?? undefined); + respond(true, { requests }, undefined); + } catch (err) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `claw.approvals.list failed: ${String(err)}`), + ); + } + }, + + "claw.approvals.get": async ({ params, respond }) => { + try { + const p = asObject(params); + if (!p) { + throw new Error("params object is required"); + } + const id = getRequiredString(p, "id"); + const store = getClawApprovalsStore(); + const request = await store.getApprovalRequest(id); + if (!request) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "request not found")); + return; + } + respond(true, { request }, undefined); + } catch (err) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `claw.approvals.get failed: ${String(err)}`), + ); + } + }, + + "claw.approvals.approveOnce": async ({ params, respond }) => { + try { + const p = asObject(params); + if (!p) { + throw new Error("params object is required"); + } + const ttlRaw = p.ttlSeconds; + const ttlSeconds = typeof ttlRaw === "number" && Number.isFinite(ttlRaw) ? ttlRaw : 180; + const store = getClawApprovalsStore(); + const result = await store.approveOnce({ + id: getRequiredString(p, "id"), + actorId: getRequiredString(p, "actorId"), + ttlSeconds, + }); + respond( + true, + { + request: result.request, + grant: { + grantId: result.grantId, + grantType: "once", + expiresAt: result.expiresAt, + }, + }, + undefined, + ); + } catch (err) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `claw.approvals.approveOnce failed: ${String(err)}`), + ); + } + }, + + "claw.approvals.approveAlways": async ({ params, respond }) => { + try { + const p = asObject(params); + if (!p) { + throw new Error("params object is required"); + } + const store = getClawApprovalsStore(); + const result = await store.approveAlways({ + id: getRequiredString(p, "id"), + actorId: getRequiredString(p, "actorId"), + }); + respond( + true, + { + request: result.request, + grant: { + grantId: result.grantId, + grantType: "always", + }, + allowRuleId: result.allowRuleId, + }, + undefined, + ); + } catch (err) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `claw.approvals.approveAlways failed: ${String(err)}`, + ), + ); + } + }, + + "claw.approvals.reject": async ({ params, respond }) => { + try { + const p = asObject(params); + if (!p) { + throw new Error("params object is required"); + } + const store = getClawApprovalsStore(); + const request = await store.reject({ + id: getRequiredString(p, "id"), + actorId: getRequiredString(p, "actorId"), + reason: getOptionalString(p, "reason"), + }); + respond(true, { request }, undefined); + } catch (err) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `claw.approvals.reject failed: ${String(err)}`), + ); + } + }, + + "claw.approvals.execute": async ({ params, respond }) => { + try { + const p = asObject(params); + if (!p) { + throw new Error("params object is required"); + } + const store = getClawApprovalsStore(); + const result = await store.executeApproved( + { + id: getRequiredString(p, "id"), + grantId: getRequiredString(p, "grantId"), + actorId: getRequiredString(p, "actorId"), + }, + invokeBroker, + ); + respond( + true, + { + request: result.request, + execution: { + executionId: result.executionId, + ok: result.broker.ok, + status: result.broker.status, + exitCode: result.broker.exitCode, + stdoutSummary: result.broker.stdoutSummary ?? "", + stderrSummary: result.broker.stderrSummary ?? "", + startedAt: result.broker.startedAt, + finishedAt: result.broker.finishedAt, + }, + }, + undefined, + ); + } catch (err) { + respond( + false, + undefined, + errorShape(ErrorCodes.UNAVAILABLE, `claw.approvals.execute failed: ${String(err)}`), + ); + } + }, + + "claw.approvals.audit": async ({ params, respond }) => { + try { + const p = asObject(params); + if (!p) { + throw new Error("params object is required"); + } + const id = getRequiredString(p, "id"); + const store = getClawApprovalsStore(); + const events = await store.getAuditTrail(id); + respond(true, { events }, undefined); + } catch (err) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `claw.approvals.audit failed: ${String(err)}`), + ); + } + }, +};