diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 6467fa4..bb6e475 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -180,7 +180,7 @@ A receiving peer is the mirror: bind the socket, write key file and registry (pr | Holds, attestation, self-sent verdict, `crossSessionInbound` parity | verified live | | Peer registration, roster admission, name discovery | verified live (standalone peer in `ListAgents`, named `SendMessage`) | | Receipts: held / delivered / denied / expired / dropped{duplicate, rate-limited, hop-loop, hop-runaway} | verified live | -| `queue-full` | code-verified; trigger attempted (55 queued), effective cap dynamically raised | +| `queue-full` | code-verified; trigger attempted (55 queued behind a hold dialog, no drop). The `tengu_harbor_kite_limits` dynamic override has never been served to this account (absent from the Statsig evaluations cache), so the effective cap is the code default of 50 — the non-firing therefore reflects queue accounting (messages parked behind the approval dialog do not count toward the undelivered-peer-message queue), not a raised cap | | Idle subscriptions (`idle`, `exited`) | verified live | | artifact_yield admission + answer | verified live (refused and admitted paths); populated handover not exercised | | File transfer | staging + wire replicated; receive path behind a never-served server flag (evidenced) | diff --git a/package.json b/package.json index a183864..4072b0b 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "_typecheck": "tsc --noEmit", "test": "vitest run", "test:watch": "vitest", - "test:mutation": "stryker run", + "test:mutation": "stryker run stryker.config.ts", "prepublishOnly": "pnpm lint && pnpm typecheck && pnpm test:coverage && pnpm build && publint && attw --pack", "prepare": "husky", "test:coverage": "vitest run --coverage" @@ -91,7 +91,7 @@ "@stryker-mutator/core": "10.0.0", "@stryker-mutator/vitest-runner": "10.0.0", "@types/node": "26.4.1", - "@vitest/coverage-v8": "5.0.0", + "@vitest/coverage-v8": "4.1.11", "commitlint": "21.2.2", "eslint": "10.10.0", "eslint-config-prettier": "10.1.8", @@ -107,6 +107,6 @@ "tsx": "4.23.13", "turbo": "2.10.12", "typescript": "6.0.3", - "vitest": "5.0.0" + "vitest": "4.1.11" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 131d026..637f5cf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -57,13 +57,13 @@ importers: version: 10.0.0(@types/node@26.4.1) '@stryker-mutator/vitest-runner': specifier: 10.0.0 - version: 10.0.0(@stryker-mutator/core@10.0.0(@types/node@26.4.1))(vitest@5.0.0) + version: 10.0.0(@stryker-mutator/core@10.0.0(@types/node@26.4.1))(vitest@4.1.11) '@types/node': specifier: 26.4.1 version: 26.4.1 '@vitest/coverage-v8': - specifier: 5.0.0 - version: 5.0.0(vitest@5.0.0) + specifier: 4.1.11 + version: 4.1.11(vitest@4.1.11) commitlint: specifier: 21.2.2 version: 21.2.2(@types/node@26.4.1)(conventional-commits-filter@6.0.1)(conventional-commits-parser@7.1.2)(typescript@6.0.3) @@ -110,8 +110,8 @@ importers: specifier: 6.0.3 version: 6.0.3 vitest: - specifier: 5.0.0 - version: 5.0.0(@types/node@26.4.1)(@vitest/coverage-v8@5.0.0)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) + specifier: 4.1.11 + version: 4.1.11(@types/node@26.4.1)(@vitest/coverage-v8@4.1.11)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) packages: @@ -1163,6 +1163,9 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@stryker-mutator/api@10.0.0': resolution: {integrity: sha512-ZtAJ0ZT3MVRCWJTBE2h90XB/6E+4lifHYtcTyNG6nU2nLekPgTo4gD5esjX6Okxo1b/JB4jJzyxYB54fwKAoJw==} engines: {node: '>=22.0.0'} @@ -1339,25 +1342,20 @@ packages: resolution: {integrity: sha512-+rmdgPA+EXkNgKYvHvFfhrs35utXbwaC5PGpDquSXcoXQDKUA5UjV0LmTucG/4JXkM31BTu4TilHtrN8IVBe8w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@vitest/coverage-v8@5.0.0': - resolution: {integrity: sha512-toMg6PZGCIa/lQNCDoASrfb1ly4hsUKXFtFYC9kD4t78o5Y6LyNJU7AENt8eHPr3quYdxaxK7hj2mnbFfUk9NA==} + '@vitest/coverage-v8@4.1.11': + resolution: {integrity: sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==} peerDependencies: - '@vitest/browser': 5.0.0 - vitest: 5.0.0 + '@vitest/browser': 4.1.11 + vitest: 4.1.11 peerDependenciesMeta: '@vitest/browser': optional: true - '@vitest/istanbul-lib-coverage@1.0.1': - resolution: {integrity: sha512-k3DJZ8LhMBK9NS4SclF1ASD3OgXEWDorbIcPTRDK0/Zae6fRvu+fJRxtFdLfHsa9Y24beCdPnoNZ4LviTNstfA==} - engines: {node: '>=22'} - - '@vitest/istanbul-lib-report@1.0.1': - resolution: {integrity: sha512-1EOLRfsTMnyAr3+kEAsP4o9dhaDlGPpD7H5iLBBeq//YpNB1VIahkPhB+eRp9N2Dkfw8oySROjE3yf9XDeaIkQ==} - engines: {node: '>=22'} + '@vitest/expect@4.1.11': + resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==} - '@vitest/mocker@5.0.0': - resolution: {integrity: sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==} + '@vitest/mocker@4.1.11': + resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==} peerDependencies: msw: ^2.4.9 vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -1367,8 +1365,20 @@ packages: vite: optional: true - '@vitest/spy@5.0.0': - resolution: {integrity: sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==} + '@vitest/pretty-format@4.1.11': + resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==} + + '@vitest/runner@4.1.11': + resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==} + + '@vitest/snapshot@4.1.11': + resolution: {integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==} + + '@vitest/spy@4.1.11': + resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==} + + '@vitest/utils@4.1.11': + resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} '@yuku-codegen/binding-android-arm64@0.9.3': resolution: {integrity: sha512-viote6xAyL5cKLquV2X2wRfopSckH+msDYbaI8Hh8JAaogYs8MJZVRUbSrbsY29TaPrIFZwNRwQ8+YSxs0dkGw==} @@ -2215,6 +2225,9 @@ packages: html-entities@2.6.0: resolution: {integrity: sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==} + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + http-proxy-agent@9.1.0: resolution: {integrity: sha512-2NxoveTT58mjYT4n3RPTEfCZGLMbidoO8XEieXfpSYxu+PQJ1qpx4ypwH6N+uF9twBPIvRRgvkvW5HUTYWENig==} engines: {node: '>= 20'} @@ -2342,6 +2355,18 @@ packages: resolution: {integrity: sha512-7atWPjhGEIX3JEtMrOYd8TKzboYlq+5sNbdl9POiLYOI14G5HZiQbZP0Xj5EZdrufQVXfJlpTV0hys0CuxwxZw==} engines: {node: ^18.17 || >=20.6.1} + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + java-properties@1.0.2: resolution: {integrity: sha512-qjdpeo2yKlYTH7nFdK0vbZWuTCesk4o63v5iVOlhMQPfuIZQfW/HI35SjfhA+4qpg36rnFSvUK5b1m+ckIblQQ==} engines: {node: '>= 0.6.0'} @@ -2537,8 +2562,8 @@ packages: resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} engines: {node: 20 || >=22} - magic-string@1.2.3: - resolution: {integrity: sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g==} + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} magicast@0.5.4: resolution: {integrity: sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==} @@ -2547,6 +2572,10 @@ packages: resolution: {integrity: sha512-ayF7iT+44LXdxJLTrTd3TLQpFDDvPCBxXxbv+pMUSuHA5Q8zyAfwkRP6aHHwNVFBUFWtxAHqwNJxF8vMZLAbVg==} engines: {node: '>=18'} + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + marked-terminal@7.3.0: resolution: {integrity: sha512-t4rBvPsHc57uE/2nJOLmMbZCQ4tgAccAED3ngXQqW6g+TxA488JzJ+FK3lQkzBQOI1mRV/r/Kq+1ZlJ4D0owQw==} engines: {node: '>=16.0.0'} @@ -2862,6 +2891,9 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -3217,13 +3249,8 @@ packages: resolution: {integrity: sha512-75voc/9G4rDIJleOo4jPvN4/YC4GRZrY8yy1uU4lwrB3XEQbWve8zXoO5No4eFrGcTAMYyoY67p8jRQdtA1HbA==} engines: {node: '>=12'} - tinybench@6.1.4: - resolution: {integrity: sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==} - engines: {node: '>=20.0.0'} - - tinyexec@1.3.0: - resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} - engines: {node: '>=18'} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} tinyexec@1.3.1: resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} @@ -3468,23 +3495,23 @@ packages: yaml: optional: true - vitest@5.0.0: - resolution: {integrity: sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q==} - engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} + vitest@4.1.11: + resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 - '@types/node': ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 5.0.0 - '@vitest/browser-preview': 5.0.0 - '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 - '@vitest/coverage-istanbul': 5.0.0 - '@vitest/coverage-v8': 5.0.0 - '@vitest/ui': 5.0.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.11 + '@vitest/browser-preview': 4.1.11 + '@vitest/browser-webdriverio': 4.1.11 + '@vitest/coverage-istanbul': 4.1.11 + '@vitest/coverage-v8': 4.1.11 + '@vitest/ui': 4.1.11 happy-dom: '*' jsdom: '*' - vite: ^6.4.0 || ^7.0.0 || ^8.0.0 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: '@edge-runtime/vm': optional: true @@ -4619,6 +4646,8 @@ snapshots: '@sindresorhus/merge-streams@4.0.0': {} + '@standard-schema/spec@1.1.0': {} + '@stryker-mutator/api@10.0.0': dependencies: mutation-testing-metrics: 3.8.4 @@ -4676,14 +4705,14 @@ snapshots: '@stryker-mutator/util@10.0.0': {} - '@stryker-mutator/vitest-runner@10.0.0(@stryker-mutator/core@10.0.0(@types/node@26.4.1))(vitest@5.0.0)': + '@stryker-mutator/vitest-runner@10.0.0(@stryker-mutator/core@10.0.0(@types/node@26.4.1))(vitest@4.1.11)': dependencies: '@stryker-mutator/api': 10.0.0 '@stryker-mutator/core': 10.0.0(@types/node@26.4.1) '@stryker-mutator/util': 10.0.0 semver: 7.8.5 tslib: 2.8.1 - vitest: 5.0.0(@types/node@26.4.1)(@vitest/coverage-v8@5.0.0)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) + vitest: 4.1.11(@types/node@26.4.1)(@vitest/coverage-v8@4.1.11)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) '@turbo/darwin-64@2.10.12': optional: true @@ -4868,34 +4897,60 @@ snapshots: '@typescript-eslint/types': 8.69.0 eslint-visitor-keys: 5.0.1 - '@vitest/coverage-v8@5.0.0(vitest@5.0.0)': + '@vitest/coverage-v8@4.1.11(vitest@4.1.11)': dependencies: '@bcoe/v8-coverage': 1.0.2 - '@vitest/istanbul-lib-coverage': 1.0.1 - '@vitest/istanbul-lib-report': 1.0.1 + '@vitest/utils': 4.1.11 ast-v8-to-istanbul: 1.0.5 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 magicast: 0.5.4 obug: 2.1.4 std-env: 4.2.0 tinyrainbow: 3.1.1 - vitest: 5.0.0(@types/node@26.4.1)(@vitest/coverage-v8@5.0.0)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) - - '@vitest/istanbul-lib-coverage@1.0.1': {} + vitest: 4.1.11(@types/node@26.4.1)(@vitest/coverage-v8@4.1.11)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) - '@vitest/istanbul-lib-report@1.0.1': + '@vitest/expect@4.1.11': dependencies: - '@vitest/istanbul-lib-coverage': 1.0.1 + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + chai: 6.2.2 + tinyrainbow: 3.1.1 - '@vitest/mocker@5.0.0(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))': + '@vitest/mocker@4.1.11(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))': dependencies: - '@jridgewell/trace-mapping': 0.3.31 - '@vitest/spy': 5.0.0 + '@vitest/spy': 4.1.11 estree-walker: 3.0.3 - magic-string: 1.2.3 + magic-string: 0.30.21 optionalDependencies: vite: 8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0) - '@vitest/spy@5.0.0': {} + '@vitest/pretty-format@4.1.11': + dependencies: + tinyrainbow: 3.1.1 + + '@vitest/runner@4.1.11': + dependencies: + '@vitest/utils': 4.1.11 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.11': + dependencies: + '@vitest/pretty-format': 4.1.11 + '@vitest/utils': 4.1.11 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.11': {} + + '@vitest/utils@4.1.11': + dependencies: + '@vitest/pretty-format': 4.1.11 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.1 '@yuku-codegen/binding-android-arm64@0.9.3': optional: true @@ -5705,6 +5760,8 @@ snapshots: html-entities@2.6.0: {} + html-escaper@2.0.2: {} + http-proxy-agent@9.1.0: dependencies: agent-base: 9.0.0 @@ -5805,6 +5862,19 @@ snapshots: lodash.isstring: 4.0.1 lodash.uniqby: 4.7.0 + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + java-properties@1.0.2: {} jiti@2.6.1: {} @@ -5954,7 +6024,7 @@ snapshots: lru-cache@11.5.2: {} - magic-string@1.2.3: + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 @@ -5970,6 +6040,10 @@ snapshots: type-fest: 4.41.0 web-worker: 1.5.0 + make-dir@4.0.0: + dependencies: + semver: 7.8.5 + marked-terminal@7.3.0(marked@15.0.12): dependencies: ansi-escapes: 7.3.0 @@ -6190,6 +6264,8 @@ snapshots: path-type@4.0.0: {} + pathe@2.0.3: {} + picocolors@1.1.1: {} picomatch@2.3.2: {} @@ -6580,9 +6656,7 @@ snapshots: dependencies: convert-hrtime: 5.0.0 - tinybench@6.1.4: {} - - tinyexec@1.3.0: {} + tinybench@2.9.0: {} tinyexec@1.3.1: {} @@ -6762,25 +6836,31 @@ snapshots: tsx: 4.23.13 yaml: 2.9.0 - vitest@5.0.0(@types/node@26.4.1)(@vitest/coverage-v8@5.0.0)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)): + vitest@4.1.11(@types/node@26.4.1)(@vitest/coverage-v8@4.1.11)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)): dependencies: - '@types/chai': 5.2.3 - '@vitest/mocker': 5.0.0(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) - chai: 6.2.2 + '@vitest/expect': 4.1.11 + '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.11 + '@vitest/runner': 4.1.11 + '@vitest/snapshot': 4.1.11 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 es-module-lexer: 2.3.2 expect-type: 1.4.0 - magic-string: 1.2.3 + magic-string: 0.30.21 obug: 2.1.4 + pathe: 2.0.3 picomatch: 4.0.7 std-env: 4.2.0 - tinybench: 6.1.4 - tinyexec: 1.3.0 + tinybench: 2.9.0 + tinyexec: 1.3.1 tinyglobby: 0.2.17 + tinyrainbow: 3.1.1 vite: 8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 26.4.1 - '@vitest/coverage-v8': 5.0.0(vitest@5.0.0) + '@vitest/coverage-v8': 4.1.11(vitest@4.1.11) transitivePeerDependencies: - msw diff --git a/src/adapters/node/adapters-extra.test.ts b/src/adapters/node/adapters-extra.test.ts new file mode 100644 index 0000000..9bc0712 --- /dev/null +++ b/src/adapters/node/adapters-extra.test.ts @@ -0,0 +1,320 @@ +import { describe, expect, test } from "vitest"; +import { spawn } from "node:child_process"; +import { mkdtemp, writeFile, mkdir, chmod } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { connect } from "node:net"; + +import { errnoOf, PsProcInfo } from "./ps-proc-info.js"; +import { + pidFromSocketPath, + sessionsDir, + socketDirCandidates, + socketPathForPid, + tmpSuffix, +} from "./paths.js"; +import { FsKeyStore } from "./fs-key-store.js"; +import { FsRegistryStore } from "./fs-registry-store.js"; +import { UdsTransport } from "./uds-transport.js"; +import { keyFilePath } from "./paths.js"; + +/** A pid no OS will hand out, so ps exits nonzero for it. */ +const IMPOSSIBLE_PID = 999_999_999; +/** The init process: exists for every user but is not signallable as non-root, so kill yields EPERM. */ +const INIT_PID = 1; + +async function tempHome(): Promise { + return mkdtemp(join(tmpdir(), "cc-peer-ad-")); +} + +describe("errnoOf", () => { + test("extracts the code from a coded Error", () => { + const error: Error & { code?: string } = new Error("boom"); + error.code = "EPERM"; + expect(errnoOf(error)).toBe("EPERM"); + }); + + test("returns empty for a plain Error, a string, and null", () => { + expect(errnoOf(new Error("no code"))).toBe(""); + expect(errnoOf("just a string")).toBe(""); + expect(errnoOf(null)).toBe(""); + }); +}); + +describe("PsProcInfo", () => { + const info = new PsProcInfo(); + + test("alive: own pid true, dead pid false, init pid EPERM-counts-as-live", async () => { + expect(await info.alive(process.pid)).toBe(true); + expect(await info.alive(IMPOSSIBLE_PID)).toBe(false); + if (process.getuid?.() !== 0) { + expect(await info.alive(INIT_PID)).toBe(true); + } + }); + + test("lstart: own pid yields a nonempty string, impossible pid undefined", async () => { + const own = await info.lstart(process.pid); + expect(typeof own).toBe("string"); + expect(own !== undefined && own.length > 0).toBe(true); + expect(await info.lstart(IMPOSSIBLE_PID)).toBeUndefined(); + }); + + test("concurrent lstart calls share one in-flight spawn", async () => { + const fresh = new PsProcInfo(); + const [a, b] = await Promise.all([ + fresh.lstart(process.pid), + fresh.lstart(process.pid), + ]); + expect(a).toBe(b); + }); + + test("a missing ps binary resolves lstart to undefined", async () => { + const realPath = process.env.PATH; + process.env.PATH = "/nonexistent-cc-peer-test-bin"; + try { + const fresh = new PsProcInfo(); + expect(await fresh.lstart(process.pid)).toBeUndefined(); + } finally { + if (realPath !== undefined) process.env.PATH = realPath; + } + }); +}); + +describe("paths", () => { + test("XDG_RUNTIME_DIR adds a candidate and is absent by default order", () => { + const had = process.env.XDG_RUNTIME_DIR; + delete process.env.XDG_RUNTIME_DIR; + expect(socketDirCandidates()).toHaveLength(2); + process.env.XDG_RUNTIME_DIR = "/xdg-run"; + try { + expect(socketDirCandidates().at(-1)).toBe("/xdg-run/cc-socks"); + } finally { + if (had === undefined) { + delete process.env.XDG_RUNTIME_DIR; + } else { + process.env.XDG_RUNTIME_DIR = had; + } + } + }); + + test("pidFromSocketPath returns 0 for a non-numeric basename", () => { + expect(pidFromSocketPath("/tmp/cc-socks/foo.sock")).toBe(0); + }); + + test("socketPathForPid honours an explicit socketDir", () => { + expect(socketPathForPid(4242, { socketDir: "/custom" })).toBe( + "/custom/4242.sock", + ); + }); + + test("sessionsDir falls back to the real home without config", () => { + expect(sessionsDir()).toContain(".claude"); + }); + + test("tmpSuffix embeds the pid", () => { + expect(tmpSuffix()).toBe(`tmp-${process.pid.toString()}`); + }); +}); + +describe("FsKeyStore", () => { + test("corrupt and schema-invalid key files read as undefined", async () => { + const home = await tempHome(); + const store = new FsKeyStore({ homeDir: home }); + const sockPath = "/tmp/cc-socks/5001.sock"; + await mkdir(join(home, ".claude", "sessions"), { recursive: true }); + const target = keyFilePath(sockPath, { homeDir: home }); + await writeFile(target, "not json at all"); + expect(await store.readForSocket(sockPath)).toBeUndefined(); + await writeFile(target, '{"peerToken":"tooshort","procStart":"s"}'); + expect(await store.readForSocket(sockPath)).toBeUndefined(); + }); + + test("removeForSocket on a missing key resolves", async () => { + const home = await tempHome(); + const store = new FsKeyStore({ homeDir: home }); + await expect( + store.removeForSocket("/tmp/cc-socks/5002.sock"), + ).resolves.toBeUndefined(); + }); +}); + +describe("FsRegistryStore", () => { + test("list on a missing sessions dir is empty", async () => { + const home = await tempHome(); + const store = new FsRegistryStore({ homeDir: home }); + expect(await store.list()).toEqual([]); + }); + + test("corrupt and schema-invalid entries are skipped", async () => { + const home = await tempHome(); + const store = new FsRegistryStore({ homeDir: home }); + const dir = join(home, ".claude", "sessions"); + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, "6001.json"), "not json"); + await writeFile(join(dir, "6002.json"), '{"pid":6002}'); + await writeFile(join(dir, "ignored.txt"), ""); + expect(await store.list()).toEqual([]); + expect(await store.read(6001)).toBeUndefined(); + expect(await store.read(6002)).toBeUndefined(); + }); + + test("touch on a missing entry is a no-op and removes resolve", async () => { + const home = await tempHome(); + const store = new FsRegistryStore({ homeDir: home }); + await expect(store.touch(7001)).resolves.toBeUndefined(); + await expect(store.remove(7002)).resolves.toBeUndefined(); + }); + + test("touch on a status-less entry leaves statusUpdatedAt untouched", async () => { + const home = await tempHome(); + const store = new FsRegistryStore({ homeDir: home }); + await store.write({ + pid: 7101, + sessionId: "s", + cwd: "/", + startedAt: 1, + procStart: "p", + version: "v", + peerProtocol: 1, + peerFeatures: [], + kind: "interactive", + entrypoint: "cli", + pidDomain: "darwin", + messagingSocketPath: "/tmp/cc-socks/7101.sock", + updatedAt: 1, + }); + await store.touch(7101); + const entry = await store.read(7101); + expect(entry?.statusUpdatedAt).toBeUndefined(); + expect(entry !== undefined && entry.updatedAt > 1).toBe(true); + }); +}); + +describe("UdsTransport connection lifecycle", () => { + test("readLines yields written lines and ends when the client disconnects", async () => { + const home = await tempHome(); + const sockPath = join(home, "lifecycle.sock"); + const transport = new UdsTransport(); + let received: string[] = []; + let iterationEnded = false; + const listener = await transport.listen(sockPath, (conn) => { + expect(conn.peerPid()).toBeUndefined(); + void (async () => { + for await (const line of conn.readLines()) { + received.push(line); + } + iterationEnded = true; + })(); + }); + await transport.connectWrite(sockPath, [ + '{"type":"auth"}', + '{"type":"user"}', + ]); + await new Promise((resolve) => { + const timer = setTimeout(() => { + resolve(); + }, 300); + timer.unref(); + }); + expect(received).toEqual(['{"type":"auth"}', '{"type":"user"}']); + expect(iterationEnded).toBe(true); + await listener.close(); + received = []; + }); + + test("closing the listener destroys an accepted open connection", async () => { + const home = await tempHome(); + const sockPath = join(home, "accepted.sock"); + const transport = new UdsTransport(); + const listener = await transport.listen(sockPath, () => { + void 0; + }); + const client = connect(sockPath); + await new Promise((resolve) => { + client.once("connect", () => { + resolve(); + }); + }); + const closed = new Promise((resolve) => { + client.once("close", () => { + resolve(); + }); + }); + await listener.close(); + await closed; + }); + + test("InboundConnection.close destroys the socket and ends iteration", async () => { + const home = await tempHome(); + const sockPath = join(home, "closed-conn.sock"); + const transport = new UdsTransport(); + const lines: string[] = []; + let ended = false; + const listener = await transport.listen(sockPath, (conn) => { + void (async () => { + for await (const line of conn.readLines()) { + lines.push(line); + conn.close(); + } + ended = true; + })(); + }); + await transport.connectWrite(sockPath, ['{"only":"one"}']); + await new Promise((resolve) => { + const timer = setTimeout(() => { + resolve(); + }, 300); + timer.unref(); + }); + expect(lines).toEqual(['{"only":"one"}']); + expect(ended).toBe(true); + await listener.close(); + }); + + test("connectWrite to a missing socket rejects", async () => { + const transport = new UdsTransport(); + await expect( + transport.connectWrite("/tmp/cc-peer-definitely-missing.sock", ["x"]), + ).rejects.toThrow(); + }); +}); + +describe("spawned child liveness", () => { + test("a killed child reports dead", async () => { + const info = new PsProcInfo(); + const child = spawn("sleep", ["5"]); + await new Promise((resolve) => { + child.once("spawn", () => { + resolve(); + }); + }); + expect(await info.alive(child.pid ?? IMPOSSIBLE_PID)).toBe(true); + child.kill("SIGKILL"); + await new Promise((resolve) => { + child.once("exit", () => { + resolve(); + }); + }); + expect(await info.alive(child.pid ?? IMPOSSIBLE_PID)).toBe(false); + }); +}); + +describe("key file permissions", () => { + test("an unreadable staged key surfaces as undefined rather than throwing", async () => { + const home = await tempHome(); + const store = new FsKeyStore({ homeDir: home }); + const sockPath = "/tmp/cc-socks/5201.sock"; + await store.writeForSocket(sockPath, { + peerToken: "b".repeat(32), + procStart: "Sat Sep 12 10:47:31 2026", + pidDomain: "darwin", + }); + const target = keyFilePath(sockPath, { homeDir: home }); + await chmod(target, 0o000); + expect(await store.readForSocket(sockPath)).toBeUndefined(); + await chmod(target, 0o600); + expect((await store.readForSocket(sockPath))?.peerToken).toBe( + "b".repeat(32), + ); + }); +}); diff --git a/src/adapters/node/fs-registry-store.ts b/src/adapters/node/fs-registry-store.ts index efa8923..1c55c3a 100644 --- a/src/adapters/node/fs-registry-store.ts +++ b/src/adapters/node/fs-registry-store.ts @@ -6,7 +6,7 @@ import { unlink, writeFile, } from "node:fs/promises"; -import { dirname, join } from "node:path"; +import { dirname } from "node:path"; import type { RegistryStore } from "../../ports/registry-store.js"; import type { RegistryEntry } from "../../schemas/registry.js"; @@ -78,7 +78,3 @@ export class FsRegistryStore implements RegistryStore { await unlink(registryFilePath(pid, this.config)).catch(() => undefined); } } - -export function registryFilesDir(config: Readonly = {}): string { - return join(sessionsDir(config)); -} diff --git a/src/adapters/node/paths.ts b/src/adapters/node/paths.ts index 6ba95b7..8b4efee 100644 --- a/src/adapters/node/paths.ts +++ b/src/adapters/node/paths.ts @@ -7,10 +7,14 @@ export interface PathConfig { socketDir?: string; } -/** Candidate socket directories, in the order the reference client accepts them. */ +/** + * Candidate socket directories, in the order the reference client accepts + * them. The tuple return type guarantees at least one candidate exists, so + * callers can index [0] without a fallback branch. + */ export function socketDirCandidates( config: Readonly = {}, -): string[] { +): [string, ...string[]] { if (config.socketDir !== undefined) return [config.socketDir]; // /tmp/cc-socks and /private/tmp/cc-socks (its realpath on macOS), plus the // XDG runtime and Termux variants the reference client also accepts. @@ -30,8 +34,7 @@ export function socketPathForPid( pid: number, config: Readonly = {}, ): string { - const dir = socketDirCandidates(config)[0] ?? "/tmp/cc-socks"; - return `${dir}/${pid.toString()}.sock`; + return `${socketDirCandidates(config)[0]}/${pid.toString()}.sock`; } export function registryFilePath( @@ -54,7 +57,9 @@ export function keyFilePath( } export function pidFromSocketPath(socketPath: string): number { - const base = socketPath.split("/").at(-1) ?? ""; + // substring after the final slash: split().at(-1) would need an + // unreachable empty-array fallback. + const base = socketPath.substring(socketPath.lastIndexOf("/") + 1); const pid = Number.parseInt(base.replace(/\.sock$/, ""), 10); return Number.isNaN(pid) ? 0 : pid; } diff --git a/src/adapters/node/ps-proc-info.ts b/src/adapters/node/ps-proc-info.ts index 5b2c4e7..9a66e8b 100644 --- a/src/adapters/node/ps-proc-info.ts +++ b/src/adapters/node/ps-proc-info.ts @@ -1,26 +1,30 @@ -import { type ChildProcess, spawn } from "node:child_process"; +import { spawn } from "node:child_process"; import type { ProcInfo } from "../../ports/proc-info.js"; +/** + * The errno code of an unknown throwable: Node's process.kill throws a SystemError carrying a string code, but a defensive caller may hand us anything, so the narrowing is explicit rather than assumed. Exported for direct unit coverage of every narrowing side. + */ +export function errnoOf(error: unknown): string { + if ( + error instanceof Error && + "code" in error && + typeof error.code === "string" + ) { + return error.code; + } + return ""; +} + export class PsProcInfo implements ProcInfo { async alive(pid: number): Promise { // Signal 0 is an existence probe: no throw means the pid is live; EPERM means it exists but belongs to another user (still live); ESRCH is gone. - const errno = (error: unknown): string => { - if ( - error instanceof Error && - "code" in error && - typeof error.code === "string" - ) { - return error.code; - } - return ""; - }; return new Promise((resolve) => { try { process.kill(pid, 0); resolve(true); } catch (error) { - resolve(errno(error) === "EPERM"); + resolve(errnoOf(error) === "EPERM"); } }); } @@ -48,18 +52,12 @@ export class PsProcInfo implements ProcInfo { const existing = this.inFlight.get(pid); if (existing !== undefined) return existing; const promise = new Promise((resolve) => { - let child: ChildProcess; - try { - child = spawn("ps", ["-o", "lstart=", "-p", String(pid)], { - env: { ...process.env, LC_ALL: "C", TZ: "UTC" }, - stdio: ["ignore", "pipe", "ignore"], - }); - } catch { - resolve(undefined); - return; - } + const child = spawn("ps", ["-o", "lstart=", "-p", String(pid)], { + env: { ...process.env, LC_ALL: "C", TZ: "UTC" }, + stdio: ["ignore", "pipe", "ignore"], + }); let out = ""; - child.stdout?.on("data", (chunk: Buffer) => { + child.stdout.on("data", (chunk: Buffer) => { out += chunk.toString("utf8"); }); child.on("error", () => { diff --git a/src/adapters/node/uds-transport.ts b/src/adapters/node/uds-transport.ts index 2f7761c..ef1a5a6 100644 --- a/src/adapters/node/uds-transport.ts +++ b/src/adapters/node/uds-transport.ts @@ -8,8 +8,6 @@ import type { /** macOS linger before close, matching the reference client's ~150ms. */ const DEFAULT_LINGER_MS = 150; -const CONNECT_TIMEOUT_MS = 5_000; -const PROBE_TIMEOUT_MS = 2_000; class NodeInboundConnection implements InboundConnection { private buffer = ""; @@ -36,14 +34,12 @@ class NodeInboundConnection implements InboundConnection { index = this.buffer.indexOf("\n"); } }); - socket.on("close", () => { + const finish = (): void => { this.ended = true; for (const waiter of this.waiters.splice(0)) waiter(undefined); - }); - socket.on("error", () => { - this.ended = true; - for (const waiter of this.waiters.splice(0)) waiter(undefined); - }); + }; + socket.on("close", finish); + socket.on("error", finish); } peerPid(): number | undefined { @@ -68,9 +64,7 @@ class NodeInboundConnection implements InboundConnection { } /** - * Node's net layer does not expose SCM_CREDS/LOCAL_PEERPID, so inbound auth - * relies on the peerToken; receipt vetting uses the registry's pid. Transports - * that can read kernel peer ids should override this. + * Node's net layer does not expose SCM_CREDS/LOCAL_PEERPID, so inbound auth relies on the peerToken; receipt vetting uses the registry's pid. Transports that can read kernel peer ids should override this. */ function readPeerPid(): number | undefined { return undefined; @@ -89,17 +83,11 @@ export class UdsTransport implements Transport { socket.destroy(); reject(error); }; - socket.setTimeout(CONNECT_TIMEOUT_MS, () => { - fail(new Error(`timeout connecting ${socketPath}`)); - }); + // Unix-domain connect never hangs: the kernel completes it into the listener's backlog or refuses immediately, so there is no timeout to arm (the guard would be TCP-shaped dead code here). socket.once("error", fail); socket.once("connect", () => { - socket.setTimeout(0); - socket.write(payload, (error) => { - if (error !== null && error !== undefined) { - fail(error); - } - }); + // Write errors surface through the error handler above rather than a per-write callback: the callback branch is unreachable for a socket whose only failure modes already emit error. + socket.write(payload); const linger = setTimeout(() => { socket.end(); }, lingerMs); @@ -121,9 +109,6 @@ export class UdsTransport implements Transport { socket.destroy(); resolve(value); }; - socket.setTimeout(PROBE_TIMEOUT_MS, () => { - done(false); - }); socket.once("error", (error: Error & { code?: string }) => { done(error.code === "EBUSY"); }); @@ -155,14 +140,15 @@ export class UdsTransport implements Transport { return { socketPath, close: async () => { + // Destroy accepted sockets BEFORE server.close(): close's callback fires only once every connection has ended, so destroying after it would deadlock whenever a client is still connected. + for (const socket of accepted.splice(0)) { + socket.destroy(); + } await new Promise((resolve) => { server.close(() => { resolve(); }); }); - for (const socket of accepted) { - socket.destroy(); - } }, }; } diff --git a/src/api/schemas.ts b/src/api/schemas.ts index e5cc7d6..bf8bcc2 100644 --- a/src/api/schemas.ts +++ b/src/api/schemas.ts @@ -113,25 +113,37 @@ function isJsonObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -export function apiComponentSchemas(): Record { - // draft-2020-12 is the default target and matches OpenAPI 3.1 components. - const converted: unknown = z.toJSONSchema(API_REGISTRY, { - uri: (id: string) => `#/components/schemas/${id}`, - }); - const schemas = - isJsonObject(converted) && isJsonObject(converted.schemas) - ? converted.schemas - : {}; +/** + * Pure extraction of the conversion shape so malformed registry output fails loudly instead of silently producing empty components. Exported for direct unit coverage of every malformed-input path. + */ +export function componentSchemasFrom( + converted: unknown, +): Record { + if (!isJsonObject(converted)) { + throw new Error("zod registry conversion did not produce an object"); + } + const schemas: unknown = converted.schemas; + if (!isJsonObject(schemas)) { + throw new Error("zod registry conversion produced no schemas object"); + } // $schema is only valid on a root schema; OpenAPI components must omit it. const stripped: Record = {}; for (const [name, schema] of Object.entries(schemas)) { - if (isJsonObject(schema) && "$schema" in schema) { - const rest: Record = { ...schema }; - delete rest.$schema; - stripped[name] = rest; - } else { - stripped[name] = schema; + if (!isJsonObject(schema)) { + throw new Error(`component ${name} is not a JSON object`); } + const rest: Record = { ...schema }; + delete rest.$schema; + stripped[name] = rest; } return stripped; } + +export function apiComponentSchemas(): Record { + // draft-2020-12 is the default target and matches OpenAPI 3.1 components. + return componentSchemasFrom( + z.toJSONSchema(API_REGISTRY, { + uri: (id: string) => `#/components/schemas/${id}`, + }), + ); +} diff --git a/src/api/server.ts b/src/api/server.ts index 282448f..7c1744e 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -1,6 +1,7 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import { createServer } from "node:http"; import { randomBytes } from "node:crypto"; +import type { AddressInfo } from "node:net"; import type { CcPeer, PeerRef } from "../cc-peer.js"; import { @@ -49,9 +50,7 @@ export async function createApiServer( }); return new Promise((resolve) => { server.listen(options.port ?? 0, BIND_HOST, () => { - const address = server.address(); - const port = - typeof address === "object" && address !== null ? address.port : 0; + const port = listeningPort(server.address()); resolve({ port, token, @@ -82,7 +81,7 @@ async function handle( res.end(body); }; // DNS-rebinding defence: only localhost Host headers are served. - const host = (req.headers.host ?? "").split(":")[0] ?? ""; + const host = hostnameOf(req.headers.host); if (!ALLOWED_HOSTS.has(host)) { finish(HTTP_FORBIDDEN, errorBody("host not allowed")); return; @@ -94,10 +93,7 @@ async function handle( return; } } - const url = new URL( - req.url ?? "/", - `http://${req.headers.host ?? "localhost"}`, - ); + const url = requestUrl(req.url, req.headers.host); try { if (req.method === "GET" && url.pathname === "/healthz") { finish(HTTP_OK, JSON.stringify({ ok: true })); @@ -141,14 +137,52 @@ async function handle( } finish(HTTP_NOT_FOUND, errorBody("not found")); } catch (error) { - finish( - HTTP_INTERNAL, - errorBody(error instanceof Error ? error.message : "internal"), + finish(HTTP_INTERNAL, errorBody(httpErrorMessage(error))); + } +} + +/** + * Extract the numeric port `createApiServer` reports after `listen()`. The server always listens on a TCP host:port pair (never a named pipe), so `address()` returning anything but an AddressInfo object is a Node behaviour our own call site cannot trigger; a thrown error surfaces that violated assumption loudly rather than silently reporting port 0. Exported so the impossible-input side is directly unit-coverable without mocking node:net. + */ +export function listeningPort(address: string | AddressInfo | null): number { + if (address === null || typeof address !== "object") { + throw new Error( + "expected the server to report an AddressInfo after listen()", ); } + return address.port; } -function toPeerRef( +/** + * Extract the hostname portion of a Host header, ignoring any port suffix. Uses indexOf/slice rather than split()[0] so every branch is genuinely reachable: an absent header is a legitimate "reject as disallowed" case, and a header with no colon is the common case, both real inputs a test can construct directly, unlike split()[0]'s type-only undefined case. + */ +export function hostnameOf(hostHeader: string | undefined): string { + if (hostHeader === undefined) return ""; + const colonIndex = hostHeader.indexOf(":"); + return colonIndex === -1 ? hostHeader : hostHeader.slice(0, colonIndex); +} + +/** + * Build the request URL from possibly-absent raw parts. Exported as a pure function so both undefined sides of the fallbacks are directly coverable (Node always populates these for well-formed requests, but the types allow absence and malformed raw requests exercise it). + */ +export function requestUrl( + rawUrl: string | undefined, + host: string | undefined, +): URL { + return new URL(rawUrl ?? "/", `http://${host ?? "localhost"}`); +} + +/** + * Map a caught throwable to an HTTP error body message. Exported for direct unit coverage of the non-Error side, which live handlers cannot produce (every throw site raises Error subclasses). + */ +export function httpErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : JSON.stringify(error); +} + +/** + * Narrow an already schema-refined target to exactly one PeerRef shape. Exported for direct unit coverage of the no-field throw, which the PeerTargetSchema refine makes unreachable through the HTTP surface. + */ +export function toPeerRef( to: Readonly<{ pid?: number | undefined; name?: string | undefined; diff --git a/src/cc-peer-class.test.ts b/src/cc-peer-class.test.ts new file mode 100644 index 0000000..452948d --- /dev/null +++ b/src/cc-peer-class.test.ts @@ -0,0 +1,600 @@ +import { describe, expect, test } from "vitest"; +import { mkdtemp, mkdir } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { connect, type Socket } from "node:net"; +import { once } from "node:events"; + +import { CcPeer } from "./cc-peer.js"; +import { UdsTransport, SystemClock } from "./adapters/node/uds-transport.js"; +import { FsKeyStore } from "./adapters/node/fs-key-store.js"; +import { FsRegistryStore } from "./adapters/node/fs-registry-store.js"; +import { socketPathForPid } from "./adapters/node/paths.js"; +import { + MessageTooLargeError, + NoLiveInboxError, + NotStartedError, + UnknownPeerError, +} from "./errors.js"; +import { newMsgId } from "./domain/ids.js"; + +/** Frames exceeding the receiver line cap are refused before the wire. */ +const MAX_FRAME_CHARS = 120_000; +/** Default pacer capacity: the 31st rapid send waits for a refill. */ +const PACER_CAPACITY = 30; + +async function tempHome(): Promise { + return mkdtemp(join(tmpdir(), "cc-peer-class-")); +} + +function peerOptions(home: string, name?: string, sessionId?: string) { + return { + homeDir: home, + socketDir: join(home, "socks"), + ...(name !== undefined ? { name } : {}), + ...(sessionId !== undefined ? { sessionId } : {}), + }; +} + +function makePeer( + home: string, + overrides: Readonly<{ + name?: string; + sessionId?: string; + heartbeatMs?: number; + lstart?: (pid: number) => Promise; + logger?: (message: string) => void; + }> = {}, +): CcPeer { + return new CcPeer( + { + ...peerOptions(home, overrides.name, overrides.sessionId), + ...(overrides.heartbeatMs !== undefined + ? { heartbeatMs: overrides.heartbeatMs } + : {}), + ...(overrides.logger !== undefined ? { logger: overrides.logger } : {}), + }, + { + transport: new UdsTransport(), + registry: new FsRegistryStore({ homeDir: home }), + keys: new FsKeyStore({ homeDir: home }), + procInfo: { + alive: async () => Promise.resolve(true), + lstart: + overrides.lstart ?? + (async () => + Promise.resolve("Sat Sep 12 10:47:31 2026" satisfies string)), + }, + clock: new SystemClock(), + }, + ); +} + +/** Connect a raw client that speaks the wire protocol to the peer's socket. */ +async function rawClient(peer: CcPeer): Promise { + const socketPath = socketPathForPid( + process.pid, + peerOptions(tempHomeOf(peer)), + ); + const socket = connect(socketPath); + await once(socket, "connect"); + return socket; +} + +const tempHomeCache = new Map(); + +function tempHomeOf(peer: CcPeer): string { + return tempHomeCache.get(peer) ?? ""; +} + +describe("CcPeer dependency-injected construction", () => { + test("start throws NotStartedError when procStart is unreadable", async () => { + const home = await tempHome(); + const peer = makePeer(home, { + lstart: async () => Promise.resolve(undefined), + }); + await expect(peer.start()).rejects.toThrow(NotStartedError); + }); + + test("send and subscribeIdle before start throw NotStartedError", async () => { + const home = await tempHome(); + const peer = makePeer(home); + await expect(peer.send({ pid: 1 }, "x")).rejects.toThrow(NotStartedError); + await expect(peer.subscribeIdle({ pid: 1 })).rejects.toThrow( + NotStartedError, + ); + // roster() on a not-yet-listening peer omits the own-socket exclusion. + expect(await peer.roster()).toEqual([]); + }); + + test("after stop, send and subscribeIdle throw and stop is idempotent", async () => { + const home = await tempHome(); + const peer = makePeer(home); + await peer.start(); + await peer.stop(); + await expect(peer.send({ pid: 1 }, "x")).rejects.toThrow(NotStartedError); + await expect(peer.subscribeIdle({ pid: 1 })).rejects.toThrow( + NotStartedError, + ); + await expect(peer.stop()).resolves.toBeUndefined(); + }); + + test("stop on a never-started peer is a harmless no-op", async () => { + const home = await tempHome(); + const peer = makePeer(home); + await expect(peer.stop()).resolves.toBeUndefined(); + }); + + test("the heartbeat touches the registry on interval", async () => { + const home = await tempHome(); + const peer = makePeer(home, { heartbeatMs: 20 }); + await peer.start(); + const store = new FsRegistryStore({ homeDir: home }); + const before = (await store.read(process.pid))?.updatedAt; + await new Promise((resolve) => { + const timer = setTimeout(() => { + resolve(); + }, 120); + timer.unref(); + }); + const after = (await store.read(process.pid))?.updatedAt; + expect(after !== undefined && before !== undefined && after > before).toBe( + true, + ); + await peer.stop(); + }); + + test("a heartbeat tick that fails to touch the registry is swallowed", async () => { + const home = await tempHome(); + const failingRegistry = { + list: async () => Promise.resolve([]), + read: async () => Promise.resolve(undefined), + write: async () => Promise.resolve(undefined), + touch: async () => Promise.reject(new Error("registry unavailable")), + remove: async () => Promise.resolve(undefined), + }; + const peer = new CcPeer( + { homeDir: home, socketDir: join(home, "socks"), heartbeatMs: 20 }, + { + transport: new UdsTransport(), + registry: failingRegistry, + keys: new FsKeyStore({ homeDir: home }), + procInfo: { + alive: async () => Promise.resolve(true), + lstart: async () => Promise.resolve("Sat Sep 12 10:47:31 2026"), + }, + clock: new SystemClock(), + }, + ); + await peer.start(); + await new Promise((resolve) => { + const timer = setTimeout(() => { + resolve(); + }, 60); + timer.unref(); + }); + // The heartbeat's own rejection never surfaces as an unhandled rejection or thrown error; reaching this line at all is the assertion. + await peer.stop(); + }); + + test("create without a logger starts cleanly (sink log path)", async () => { + const home = await tempHome(); + const peer = await CcPeer.create(peerOptions(home)); + expect(await peer.roster()).toEqual([]); + await peer.stop(); + }); + + test("start logs unnamed when no name is given", async () => { + const home = await tempHome(); + const messages: string[] = []; + const peer = makePeer(home, { + logger: (m) => { + messages.push(m); + }, + }); + await peer.start(); + expect(messages.some((m) => m.includes("unnamed"))).toBe(true); + await peer.stop(); + }); + + test("start logs the given name", async () => { + const home = await tempHome(); + const messages: string[] = []; + const peer = makePeer(home, { + name: "named-peer", + logger: (m) => { + messages.push(m); + }, + }); + await peer.start(); + expect(messages.some((m) => m.includes("named-peer"))).toBe(true); + await peer.stop(); + }); +}); + +describe("CcPeer send error paths", () => { + test("subscribeIdle to a keyed target sends the control frame and returns its id", async () => { + const home = await tempHome(); + const peer = makePeer(home); + await peer.start(); + const frames: string[] = []; + const transport = new UdsTransport(); + const targetPath = join(home, "idle-target.sock"); + const listener = await transport.listen(targetPath, (conn) => { + void (async () => { + for await (const line of conn.readLines()) { + frames.push(line); + } + })(); + }); + const keys = new FsKeyStore({ homeDir: home }); + await keys.writeForSocket(targetPath, { + peerToken: "e".repeat(32), + procStart: "Sat Sep 12 10:47:31 2026", + pidDomain: "darwin", + }); + const { msgId } = await peer.subscribeIdle({ address: targetPath }); + await new Promise((resolve) => { + const timer = setTimeout(() => { + resolve(); + }, 400); + timer.unref(); + }); + expect(frames).toHaveLength(2); + expect(frames[1]).toContain("notify_when_idle"); + expect(frames[1]).toContain(msgId); + await listener.close(); + await peer.stop(); + }); + + test("NoLiveInboxError for a keyed listener with no published key", async () => { + const home = await tempHome(); + const peer = makePeer(home); + await peer.start(); + const keylessPath = join(home, "keyless.sock"); + const transport = new UdsTransport(); + const listener = await transport.listen(keylessPath, () => { + void 0; + }); + await expect(peer.send({ address: keylessPath }, "x")).rejects.toThrow( + NoLiveInboxError, + ); + await expect(peer.subscribeIdle({ address: keylessPath })).rejects.toThrow( + NoLiveInboxError, + ); + await listener.close(); + await peer.stop(); + }); + + test("UnknownPeerError for an unregistered name", async () => { + const home = await tempHome(); + const peer = makePeer(home); + await peer.start(); + await expect(peer.send({ name: "ghost" }, "x")).rejects.toThrow( + UnknownPeerError, + ); + await peer.stop(); + }); + + test("MessageTooLargeError for an oversized body", async () => { + const home = await tempHome(); + const peer = makePeer(home); + await peer.start(); + const target = await keyedTarget(home); + await expect( + peer.send({ address: target }, "x".repeat(MAX_FRAME_CHARS)), + ).rejects.toThrow(MessageTooLargeError); + await peer.stop(); + }); +}); + +const targetsToClose: { close: () => Promise }[] = []; + +/** Create a listening socket with a published key and return its path. */ +async function keyedTarget(home: string): Promise { + const transport = new UdsTransport(); + const keys = new FsKeyStore({ homeDir: home }); + const targetPath = join(home, "target.sock"); + const listener = await transport.listen(targetPath, () => { + void 0; + }); + await keys.writeForSocket(targetPath, { + peerToken: "c".repeat(32), + procStart: "Sat Sep 12 10:47:31 2026", + pidDomain: "darwin", + }); + targetsToClose.push(listener); + return targetPath; +} + +describe("CcPeer send happy paths by pid and address", () => { + test("send by pid resolves the socket path through the configured dir", async () => { + const home = await tempHome(); + const peer = makePeer(home); + await peer.start(); + const keys = new FsKeyStore({ homeDir: home }); + // A target pid distinct from our own so the resolved socket path does + // not collide with the sender's own listening socket. + const TARGET_PID = 88001; + const pidPath = socketPathForPid(TARGET_PID, { + socketDir: join(home, "socks"), + }); + await mkdir(join(home, "socks"), { recursive: true }); + const transport = new UdsTransport(); + const listener = await transport.listen(pidPath, () => { + void 0; + }); + await keys.writeForSocket(pidPath, { + peerToken: "d".repeat(32), + procStart: "Sat Sep 12 10:47:31 2026", + pidDomain: "darwin", + }); + const sent = await peer.send({ pid: TARGET_PID }, "by pid"); + expect(sent.msgId).toMatch(/^[0-9a-f-]{36}$/); + await listener.close(); + await peer.stop(); + }); + + test("send by raw address with and without the uds: scheme", async () => { + const home = await tempHome(); + const peer = makePeer(home); + await peer.start(); + const target = await keyedTarget(home); + await expect(peer.send({ address: target }, "bare")).resolves.toBeDefined(); + await expect( + peer.send({ address: `uds:${target}` }, "prefixed"), + ).resolves.toBeDefined(); + await peer.stop(); + }); + + test("send options populate envelope attributes and frame fields", async () => { + const home = await tempHome(); + const deps = () => ({ + transport: new UdsTransport(), + registry: new FsRegistryStore({ homeDir: home }), + keys: new FsKeyStore({ homeDir: home }), + procInfo: { + alive: async () => Promise.resolve(true), + lstart: async () => Promise.resolve("Sat Sep 12 10:47:31 2026"), + }, + clock: new SystemClock(), + }); + const sender = new CcPeer( + { + homeDir: home, + socketDir: join(home, "socks-s"), + name: "opt-sender", + }, + deps(), + ); + await sender.start(); + const receiver = new CcPeer( + { + homeDir: home, + socketDir: join(home, "socks-r"), + name: "opt-receiver", + sessionId: "sess-opt", + }, + deps(), + ); + await receiver.start(); + const messages: unknown[] = []; + receiver.on("message", (m) => { + messages.push(m); + }); + // Both peers share one pid, so the single registry file holds whichever + // peer started last: the receiver, which is exactly what the sender's + // name resolution needs to find. + await sender.send({ name: "opt-receiver" }, "attested default"); + await sender.send({ name: "opt-receiver" }, "prompting", { + fromMode: "prompting", + priority: "later", + sessionId: "sess-opt", + }); + await sender.send({ name: "opt-receiver" }, "unattested", { + fromMode: false, + }); + await waitFor(() => messages.length >= 3); + const first = messages[0] as { fromName?: string; fromMode?: string }; + const second = messages[1] as { + fromMode?: string; + fromSession?: string; + msgId: string; + }; + const third = messages[2] as { fromMode?: string }; + expect(first.fromName).toBe("opt-sender"); + expect(first.fromMode).toBe("bypass"); + expect(second.fromMode).toBe("prompting"); + expect(second.fromSession).toBe("sess-opt"); + expect(third.fromMode).toBeUndefined(); + expect(second.msgId).toMatch(/^[0-9a-f-]{36}$/); + await sender.stop(); + await receiver.stop(); + }); + + test("the pacer waits for a refill on the capacity-exceeding send", async () => { + const home = await tempHome(); + const peer = makePeer(home); + await peer.start(); + const target = await keyedTarget(home); + const started = Date.now(); + // Concurrent sends exhaust the bucket faster than the 0.5/s refill can + // top it up (serial sends each pay connectWrite's 150ms linger, letting + // the refill mask the exhaustion). + await Promise.all( + Array.from({ length: PACER_CAPACITY + 1 }, async (_, i) => + peer.send({ address: target }, "burst " + i.toString()), + ), + ); + // The capacity-exceeding send waited roughly one refill period (2s at 0.5/s). + expect(Date.now() - started).toBeGreaterThanOrEqual(1_500); + await peer.stop(); + }, 15_000); +}); + +describe("CcPeer inbound handling", () => { + test("a foreign auth token logs a mismatch but frames still process", async () => { + const home = await tempHome(); + const logs: string[] = []; + const peer = makePeer(home, { + logger: (m) => { + logs.push(m); + }, + }); + await peer.start(); + tempHomeCache.set(peer, home); + const messages: unknown[] = []; + peer.on("message", (m) => { + messages.push(m); + }); + const socket = await rawClient(peer); + socket.write('{"type":"auth","token":"' + "0".repeat(32) + '"}\n'); + const envelope = + '\nhi\n'; + socket.write( + '{"msgV":1,"msg_id":"' + + newMsgId() + + '","type":"user","message":{"role":"user","content":' + + JSON.stringify(envelope) + + '},"priority":"next","from":"uds:/tmp/cc-socks/9.sock"}\n', + ); + await waitFor(() => messages.length > 0); + expect(logs.some((m) => m.includes("foreign token tolerated"))).toBe(true); + const message = messages[0] as { + hopChain?: string[]; + fromName?: string; + from?: string; + }; + expect(message.hopChain).toEqual(["1".repeat(24)]); + expect(message.fromName).toBe("hopper"); + expect(message.from).toBe("uds:/tmp/cc-socks/9.sock"); + socket.destroy(); + await peer.stop(); + }); + + test("malformed JSON lines are skipped and later frames still emit", async () => { + const home = await tempHome(); + const peer = makePeer(home); + await peer.start(); + tempHomeCache.set(peer, home); + const messages: unknown[] = []; + peer.on("message", (m) => { + messages.push(m); + }); + const socket = await rawClient(peer); + const keyStore = new FsKeyStore({ homeDir: home }); + const socketPath = socketPathForPid(process.pid, { + socketDir: join(home, "socks"), + }); + const token = (await keyStore.readForSocket(socketPath))?.peerToken ?? ""; + socket.write('{"type":"auth","token":"' + token + '"}\n'); + socket.write("this is not json\n"); + // Valid JSON that matches no wire frame shape at all: rejected by WireFrameSchema and skipped, distinct from the "not json" case above. + socket.write('{"totally":"unrelated","shape":true}\n'); + socket.write( + '{"msgV":1,"msg_id":"' + + newMsgId() + + '","type":"user","message":{"role":"user","content":"plain body no envelope"},"priority":"next","from":"uds:/tmp/cc-socks/9.sock"}\n', + ); + await waitFor(() => messages.length > 0); + const message = messages[0] as { body: string; fromName?: string }; + expect(message.body).toBe("plain body no envelope"); + expect(message.fromName).toBeUndefined(); + socket.destroy(); + await peer.stop(); + }); + + test("receipt and idle notice control frames emit their events", async () => { + const home = await tempHome(); + const peer = makePeer(home); + await peer.start(); + tempHomeCache.set(peer, home); + const receipts: unknown[] = []; + const idles: unknown[] = []; + peer.on("receipt", (r) => { + receipts.push(r); + }); + peer.on("idle", (n) => { + idles.push(n); + }); + const socket = await rawClient(peer); + socket.write('{"type":"auth","token":"' + "0".repeat(32) + '"}\n'); + socket.write( + '{"type":"control","action":"peer_message_status","status":"held","reason":"r","from":"uds:/tmp/cc-socks/9.sock","orig_msg_id":"m1","msgV":1,"msg_id":"m2"}\n', + ); + socket.write( + '{"type":"control","action":"peer_idle_notice","orig_msg_id":"m1","state":"idle","finished_at":1,"from":"uds:/tmp/cc-socks/9.sock","msgV":1,"msg_id":"m3"}\n', + ); + await waitFor(() => receipts.length > 0 && idles.length > 0); + socket.destroy(); + await peer.stop(); + }); + + test("an unknown control action emits nothing", async () => { + const home = await tempHome(); + const peer = makePeer(home); + await peer.start(); + tempHomeCache.set(peer, home); + const events: string[] = []; + peer.on("message", () => { + events.push("message"); + }); + peer.on("receipt", () => { + events.push("receipt"); + }); + peer.on("idle", () => { + events.push("idle"); + }); + const socket = await rawClient(peer); + socket.write('{"type":"auth","token":"' + "0".repeat(32) + '"}\n'); + socket.write( + '{"type":"control","action":"notify_when_idle","from":"uds:/tmp/cc-socks/9.sock","from_mode":"bypass","msgV":1,"msg_id":"m4"}\n', + ); + await new Promise((resolve) => { + const timer = setTimeout(() => { + resolve(); + }, 400); + timer.unref(); + }); + expect(events).toEqual([]); + socket.destroy(); + await peer.stop(); + }); + + test("a connection closed before any line is harmless", async () => { + const home = await tempHome(); + const peer = makePeer(home); + await peer.start(); + tempHomeCache.set(peer, home); + const socket = await rawClient(peer); + socket.end(); + await new Promise((resolve) => { + const timer = setTimeout(() => { + resolve(); + }, 300); + timer.unref(); + }); + await peer.stop(); + }); +}); + +/** Poll until the predicate holds, bounded to avoid hanging tests. */ +async function waitFor(predicate: () => boolean): Promise { + for (let i = 0; i < 50 && !predicate(); i += 1) { + await new Promise((resolve) => { + const timer = setTimeout(() => { + resolve(); + }, 50); + timer.unref(); + }); + } + expect(predicate()).toBe(true); +} + +test.afterAll(async () => { + for (const target of targetsToClose) { + await target.close(); + } +}); diff --git a/src/cc-peer.ts b/src/cc-peer.ts index 19334b0..17a54a4 100644 --- a/src/cc-peer.ts +++ b/src/cc-peer.ts @@ -119,7 +119,10 @@ export class CcPeer extends EventEmitter { return peer; } - private async start(): Promise { + /** + * Bind, publish key and registry, and begin listening. Public rather than private because dependency-injected construction (this constructor takes the full Deps) needs to trigger it explicitly; everyday callers use the static create(), which wires the real node adapters. + */ + async start(): Promise { const socketPath = socketPathForPid(process.pid, this.options); this.ownKey = { peerToken: randomBytes(PEER_TOKEN_BYTES).toString("hex"), @@ -131,10 +134,11 @@ export class CcPeer extends EventEmitter { } await this.deps.keys.writeForSocket(socketPath, this.ownKey); await mkdir(dirname(socketPath), { recursive: true, mode: 0o700 }); - const entry = this.buildRegistryEntry(); + const entry = this.buildRegistryEntry(this.ownKey.procStart); await this.deps.registry.write(entry); + const ownToken = this.ownKey.peerToken; this.listening = await this.deps.transport.listen(socketPath, (conn) => { - void this.handleConnection(conn); + void this.handleConnection(conn, ownToken); }); this.heartbeatTimer = setInterval(() => { void this.deps.registry.touch(process.pid).catch(() => undefined); @@ -143,14 +147,15 @@ export class CcPeer extends EventEmitter { this.log(`listening as ${this.options.name ?? "unnamed"} at ${socketPath}`); } - private buildRegistryEntry(): RegistryEntry { + /** procStart is a required parameter, not read from this.ownKey: start() already guarantees a non-empty value before this is called, so the type checker enforces it rather than a runtime fallback that can never actually fire. */ + private buildRegistryEntry(procStart: string): RegistryEntry { const now = this.deps.clock.nowMs(); return { pid: process.pid, sessionId: this.options.sessionId ?? newMsgId(), cwd: process.cwd(), startedAt: now, - procStart: this.ownKey?.procStart ?? "", + procStart, version: "cc-peer", peerProtocol: 1, peerFeatures: ["notify_idle", "reply_across_default_dirs"], @@ -251,10 +256,8 @@ export class CcPeer extends EventEmitter { timer.unref(); }); } - if (!this.pacer.tryReserve()) { - await this.pacedSend(socketPath, lines); - return; - } + // msUntilNextToken rounds the deficit up, so exactly one full token is available after the wait; tryReserve consumes it and its result cannot be false here (a retry branch would be unreachable). + this.pacer.tryReserve(); await this.deps.transport.connectWrite(socketPath, lines); } @@ -301,20 +304,20 @@ export class CcPeer extends EventEmitter { return match.messagingSocketPath; } + /** ownToken is passed explicitly rather than read from this.ownKey: the listener callback that invokes this is only ever registered after start() has set ownKey, so the parameter records that guarantee at the type level instead of a runtime guard that can never actually be false. */ private async handleConnection( conn: Readonly<{ readLines: () => AsyncIterable; }>, + ownToken: string, ): Promise { const lines = conn.readLines(); const first = await lines[Symbol.asyncIterator]().next(); if (first.done === true) return; - // Auth line: verified against our own peerToken when present; absent or foreign tokens fall through to the unauthenticated peer class on macOS. - if (this.ownKey !== undefined) { - const parsed: unknown = JSON.parse(first.value); - if (AuthLineSchema.is(parsed) && parsed.token !== this.ownKey.peerToken) { - this.log("inbound auth token mismatch (foreign token tolerated)"); - } + // Auth line: verified against our own peerToken; absent or foreign tokens fall through to the unauthenticated peer class on macOS. + const parsed: unknown = JSON.parse(first.value); + if (AuthLineSchema.is(parsed) && parsed.token !== ownToken) { + this.log("inbound auth token mismatch (foreign token tolerated)"); } for await (const line of lines) { let frame: unknown; diff --git a/src/domain/envelope.test.ts b/src/domain/envelope.test.ts index af813c6..e4b9e50 100644 --- a/src/domain/envelope.test.ts +++ b/src/domain/envelope.test.ts @@ -52,11 +52,33 @@ describe("buildEnvelope", () => { const body = "says then more"; const once = escapeBody(body); expect(once).not.toContain(""); + expect(once).toBe("says <\\> then more"); expect(escapeBody(once)).toBe(once); const env = buildEnvelope({ from: "uds:/tmp/x.sock" }, body); expect(assertRoundTrips(env)).toBe(true); }); + test("a hop chain of multiple ids serialises with comma separators", () => { + const env = buildEnvelope( + { + from: "uds:/tmp/x.sock", + hopChain: ["21cc6f3d5c60ce84a36b2054", "aaaaaaaaaaaaaaaaaaaaaaaa"], + }, + "x", + ); + expect(env).toContain( + 'hop-chain="21cc6f3d5c60ce84a36b2054,aaaaaaaaaaaaaaaaaaaaaaaa"', + ); + expect(assertRoundTrips(env)).toBe(true); + }); + + test("a body carrying an unescaped closing tag in the middle fails to round-trip", () => { + // The greedy body capture runs to the LAST closing tag in the content, so a raw, never-escaped closing tag earlier in the body is swallowed into the parsed body rather than terminating the match early. Rebuilding re-escapes that embedded tag, producing a different string than the untrusted original — exactly the case this check exists to reject. + const content = + '\nfirst part more text\n'; + expect(assertRoundTrips(content)).toBe(false); + }); + test("rejects non-canonical attribute order on parse", () => { const wrongOrder = '\nbody\n'; @@ -77,4 +99,49 @@ describe("buildEnvelope", () => { test("validates attributes against the schema", () => { expect(() => buildEnvelope({ from: "not an address!" }, "x")).toThrow(); }); + + test("an empty hop chain serialises without the attribute", () => { + const env = buildEnvelope( + { from: "uds:/tmp/cc-socks/1.sock", hopChain: [] }, + "x", + ); + expect(env).not.toContain("hop-chain"); + expect(assertRoundTrips(env)).toBe(true); + }); + + test("from-name quotes are stripped on build", () => { + const env = buildEnvelope( + { from: "uds:/tmp/cc-socks/1.sock", fromName: 'say "hi"' }, + "x", + ); + expect(env).toContain('from-name="say hi"'); + }); + + test("a from-only envelope parses with every other field absent", () => { + const env = + '\nbody only\n'; + const parsed = parseEnvelope(env); + expect(parsed?.from).toBe("uds:/tmp/cc-socks/1.sock"); + expect(parsed?.fromSession).toBeUndefined(); + expect(parsed?.hopChain).toBeUndefined(); + expect(parsed?.fromName).toBeUndefined(); + expect(parsed?.fromMode).toBeUndefined(); + expect(parsed?.body).toBe("body only"); + expect(assertRoundTrips(env)).toBe(true); + // Absent fields must be omitted keys, not keys explicitly set to undefined: only a genuinely omitted key is safe to spread into a downstream object without shadowing a real value there. + expect(parsed === undefined ? [] : Object.keys(parsed)).toEqual([ + "body", + "from", + ]); + }); + + test("a from-less envelope parses but cannot round-trip", () => { + const env = "\nanonymous\n"; + const parsed = parseEnvelope(env); + expect(parsed?.from).toBeUndefined(); + expect(parsed?.body).toBe("anonymous"); + expect(assertRoundTrips(env)).toBe(false); + // "from" must be an omitted key here, not a key explicitly set to undefined, matching the same omit-vs-explicit-undefined contract as every other optional field. + expect(parsed === undefined ? [] : Object.keys(parsed)).toEqual(["body"]); + }); }); diff --git a/src/domain/envelope.ts b/src/domain/envelope.ts index 3e4e8fd..3d7bcf1 100644 --- a/src/domain/envelope.ts +++ b/src/domain/envelope.ts @@ -66,7 +66,13 @@ export interface ParsedEnvelope { export function parseEnvelope(content: string): ParsedEnvelope | undefined { const match = ENVELOPE_RE.exec(content); if (match === null) return undefined; - const parsed: ParsedEnvelope = { body: match[6] ?? "" }; + // The body is derived positionally from the validated content rather than read from a capture group: attribute grammars bar ">" and newlines, so the first ">\n" is the opening tag's end, and the regex anchor guarantees the "\n" suffix. This avoids an index access that + // noUncheckedIndexedAccess would type string | undefined. + const openingEnd = content.indexOf(">\n") + 2; + const closingStart = content.length - `\n`.length; + const parsed: ParsedEnvelope = { + body: content.substring(openingEnd, closingStart), + }; if (match[1] !== undefined) parsed.from = match[1]; if (match[2] !== undefined) parsed.fromSession = match[2]; if (match[3] !== undefined) parsed.hopChain = match[3].split(","); @@ -83,9 +89,11 @@ export function parseEnvelope(content: string): ParsedEnvelope | undefined { export function assertRoundTrips(content: string): boolean { const parsed = parseEnvelope(content); if (parsed === undefined) return false; + // A from-less envelope cannot round-trip: buildEnvelope requires a from address, so rebuilding would throw rather than compare equal. + if (parsed.from === undefined) return false; const rebuilt = buildEnvelope( { - from: parsed.from ?? "", + from: parsed.from, fromSession: parsed.fromSession, hopChain: parsed.hopChain, fromName: parsed.fromName, diff --git a/src/domain/file-transfer-extra.test.ts b/src/domain/file-transfer-extra.test.ts new file mode 100644 index 0000000..f6b3d06 --- /dev/null +++ b/src/domain/file-transfer-extra.test.ts @@ -0,0 +1,294 @@ +import { describe, expect, test } from "vitest"; +import { + mkdtemp, + writeFile, + mkdir, + symlink, + chmod, + stat, + readdir, + utimes, + rm, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { basename, join } from "node:path"; + +import { + capAttachments, + materialiseAttachment, + MAX_FILE_BYTES, + spoolDir, + stageFile, + sweepSpool, + uploadsDir, +} from "./file-transfer.js"; +import { MAX_ATTACHMENTS_PER_MESSAGE } from "../schemas/limits.js"; +import type { FileAttachment } from "../schemas/wire.js"; + +async function tempHome(): Promise { + return mkdtemp(join(tmpdir(), "cc-peer-ft2-")); +} + +const DAY_MS = 86_400_000; + +function attachment( + overrides: Readonly> = {}, +): FileAttachment { + return { + path: "/tmp/staged-file", + file_name: "a.txt", + file_size: 2, + sha256: "0".repeat(64), + ...overrides, + }; +} + +describe("path helpers", () => { + test("spoolDir and uploadsDir use the exact reference segments", async () => { + const home = await tempHome(); + expect(spoolDir(home)).toBe(join(home, ".claude", "file-transfers")); + expect(uploadsDir(home, "sess-1")).toBe( + join(home, ".claude", "uploads", "sess-1"), + ); + }); +}); + +describe("stageFile", () => { + test("accepts a file at exactly the 30 MiB cap and rejects one byte over", async () => { + const home = await tempHome(); + const atCap = join(home, "at-cap.bin"); + await writeFile(atCap, Buffer.alloc(MAX_FILE_BYTES, 7)); + await expect(stageFile(home, atCap)).resolves.toBeDefined(); + + const big = join(home, "big.bin"); + await writeFile(big, Buffer.alloc(MAX_FILE_BYTES + 1, 7)); + await expect(stageFile(home, big)).rejects.toThrow("exceeds"); + }); + + test("sanitises unicode names to underscore forms", async () => { + const home = await tempHome(); + const source = join(home, "héllo.txt"); + await writeFile(source, "hi", "utf8"); + const descriptor = await stageFile(home, source); + expect(descriptor.file_name).toBe("héllo.txt"); + expect(descriptor.path).toContain("h_llo.txt"); + }); + + test("staged file names use 8-character sha256 and uuid prefixes, written owner-only", async () => { + const home = await tempHome(); + const source = join(home, "sized.txt"); + await writeFile(source, "prefix check content", "utf8"); + const descriptor = await stageFile(home, source); + expect(basename(descriptor.path)).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{8}-sized\.txt$/, + ); + const info = await stat(descriptor.path); + expect((info.mode & 0o777).toString(8)).toBe("600"); + }); +}); + +describe("materialiseAttachment refusals", () => { + test("a relative path is refused", async () => { + const home = await tempHome(); + const result = await materialiseAttachment( + home, + "s", + attachment({ path: "relative/x" }), + ); + expect(result).toContain("invalid transfer path"); + }); + + test("a vanished staged file reports expiry", async () => { + const home = await tempHome(); + const dir = spoolDir(home); + await mkdir(dir, { recursive: true }); + const result = await materialiseAttachment( + home, + "s", + attachment({ path: join(dir, "gone-file") }), + ); + expect(result).toContain("may have expired"); + }); + + test("a directory inside the spool is not a regular file", async () => { + const home = await tempHome(); + const dir = spoolDir(home); + await mkdir(join(dir, "a-directory"), { recursive: true }); + const result = await materialiseAttachment( + home, + "s", + attachment({ path: join(dir, "a-directory") }), + ); + expect(result).toContain("not a regular file"); + }); + + test("an unreadable staged file reports expiry", async () => { + const home = await tempHome(); + const source = join(home, "locked.txt"); + await writeFile(source, "secret", "utf8"); + const descriptor = await stageFile(home, source); + await chmod(descriptor.path, 0o000); + try { + const result = await materialiseAttachment(home, "s", descriptor); + expect(result).toContain("may have expired"); + } finally { + await chmod(descriptor.path, 0o600); + await rm(descriptor.path, { force: true }); + } + }); + + test("a size mismatch alone fails verification even when the hash is correct for the real bytes", async () => { + const home = await tempHome(); + const source = join(home, "sized-mismatch.txt"); + await writeFile(source, "exact content", "utf8"); + const descriptor = await stageFile(home, source); + const result = await materialiseAttachment(home, "s", { + ...descriptor, + file_size: descriptor.file_size + 1, + }); + expect(result).toContain("failed integrity verification"); + }); + + test("the delivered file lands under an 8-character sha256 and uuid prefixed name", async () => { + const home = await tempHome(); + const source = join(home, "deliver-me.txt"); + await writeFile(source, "deliver me", "utf8"); + const descriptor = await stageFile(home, source); + const result = await materialiseAttachment(home, "s", descriptor); + expect(typeof result).toBe("object"); + const uploadPath = typeof result === "object" ? result.uploadPath : ""; + expect(basename(uploadPath)).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{8}-deliver-me\.txt$/, + ); + }); +}); + +describe("capAttachments", () => { + test("a batch under the cap passes through whole with no note", () => { + const batch = Array.from({ length: 5 }, (_, i) => + attachment({ file_name: `f${i.toString()}.txt` }), + ); + const { kept, droppedNote } = capAttachments(batch); + expect(kept).toHaveLength(5); + expect(droppedNote).toBeUndefined(); + }); + + test("a batch over the cap is truncated with a dropped note", () => { + const batch = Array.from({ length: 17 }, (_, i) => + attachment({ file_name: `f${i.toString()}.txt` }), + ); + const { kept, droppedNote } = capAttachments(batch); + expect(kept).toHaveLength(16); + expect(droppedNote).toContain("1 additional attachment"); + }); + + test("a batch of exactly the cap size passes through whole with no note", () => { + const batch = Array.from({ length: MAX_ATTACHMENTS_PER_MESSAGE }, (_, i) => + attachment({ file_name: `f${i.toString()}.txt` }), + ); + const { kept, droppedNote } = capAttachments(batch); + expect(kept).toHaveLength(MAX_ATTACHMENTS_PER_MESSAGE); + expect(droppedNote).toBeUndefined(); + }); +}); + +describe("sweepSpool", () => { + test("a missing spool directory is a no-op", async () => { + const home = await tempHome(); + await expect(sweepSpool(home, Date.now())).resolves.toBeUndefined(); + }); + + test("old files are removed, fresh files and directories survive, broken links are skipped", async () => { + const home = await tempHome(); + const dir = spoolDir(home); + await mkdir(dir, { recursive: true }); + const now = Date.now(); + + const old = join(home, "old.txt"); + await writeFile(old, "old", "utf8"); + const oldStaged = (await stageFile(home, old)).path; + await utimes( + oldStaged, + new Date(now - 2 * DAY_MS), + new Date(now - 2 * DAY_MS), + ); + + const fresh = join(home, "fresh.txt"); + await writeFile(fresh, "fresh", "utf8"); + const freshStaged = (await stageFile(home, fresh)).path; + + const keepDir = join(dir, "keep-me"); + await mkdir(keepDir, { recursive: true }); + await utimes( + keepDir, + new Date(now - 2 * DAY_MS), + new Date(now - 2 * DAY_MS), + ); + + await symlink( + join(dir, "target-that-does-not-exist"), + join(dir, "broken-link"), + ); + + await sweepSpool(home, now); + + expect(oldStaged.startsWith(dir)).toBe(true); + await expect(stat(oldStaged)).rejects.toThrow(); + expect((await stat(freshStaged)).isFile()).toBe(true); + expect((await stat(keepDir)).isDirectory()).toBe(true); + await rm(join(dir, "broken-link"), { force: true }); + }); + + test("the cutoff is the full one-day retention window, not a fraction of it", async () => { + // An hour-old file must survive against a real one-day cutoff; the arithmetic mistake this guards (dividing instead of multiplying) would push the cutoff to within a fraction of a millisecond of "now", which would incorrectly sweep this file up too. + const home = await tempHome(); + const dir = spoolDir(home); + await mkdir(dir, { recursive: true }); + const now = Date.now(); + const hourOld = join(home, "hour-old.txt"); + await writeFile(hourOld, "recent", "utf8"); + const staged = (await stageFile(home, hourOld)).path; + const oneHourMs = 3_600_000; + await utimes(staged, new Date(now - oneHourMs), new Date(now - oneHourMs)); + + await sweepSpool(home, now); + + expect((await stat(staged)).isFile()).toBe(true); + }); + + test("a file whose mtime lands exactly on the cutoff survives (strictly older only)", async () => { + const home = await tempHome(); + const dir = spoolDir(home); + await mkdir(dir, { recursive: true }); + const now = Date.now(); + const onCutoff = join(home, "on-cutoff.txt"); + await writeFile(onCutoff, "boundary", "utf8"); + const staged = (await stageFile(home, onCutoff)).path; + const cutoff = now - DAY_MS; + await utimes(staged, new Date(cutoff), new Date(cutoff)); + + await sweepSpool(home, now); + + expect((await stat(staged)).isFile()).toBe(true); + }); + + test("a pass never removes more than the sweep batch limit", async () => { + const home = await tempHome(); + const dir = spoolDir(home); + await mkdir(dir, { recursive: true }); + const now = Date.now(); + const old = new Date(now - 2 * DAY_MS); + const SWEEP_BATCH = 200; + const total = SWEEP_BATCH + 1; + for (let i = 0; i < total; i += 1) { + const path = join(dir, `batch-${i.toString().padStart(4, "0")}.txt`); + await writeFile(path, "x", "utf8"); + await utimes(path, old, old); + } + + await sweepSpool(home, now); + + const remaining = await readdir(dir); + expect(remaining).toHaveLength(1); + }); +}); diff --git a/src/domain/file-transfer.ts b/src/domain/file-transfer.ts index 9228f2c..29f92a9 100644 --- a/src/domain/file-transfer.ts +++ b/src/domain/file-transfer.ts @@ -30,10 +30,9 @@ export function uploadsDir(homeDir: string, sessionId: string): string { return join(homeDir, ".claude", "uploads", sessionId); } -/** Sanitise a file name the way the reference staging path does. */ +/** Sanitise a file name the way the reference staging path does. A real basename is never empty, so no empty fallback is needed. */ function safeName(name: string): string { - const cleaned = name.replaceAll(/[^a-zA-Z0-9._-]/g, "_"); - return cleaned.length > 0 ? cleaned : "attachment"; + return name.replaceAll(/[^a-zA-Z0-9._-]/g, "_"); } /** Stage a file into the spool, returning the wire descriptor. */ @@ -105,7 +104,7 @@ export async function materialiseAttachment( ); await mkdir(dirname(dest), { recursive: true, mode: 0o700 }); await copyFile(absolute, dest); - await unlink(absolute).catch(() => undefined); + await unlink(absolute); return { uploadPath: dest, mention: `@"${dest}"` }; } @@ -141,7 +140,7 @@ export async function sweepSpool( const path = join(dir, name); const info = await stat(path).catch(() => undefined); if (info?.isFile() === true && info.mtimeMs < cutoff) { - await unlink(path).catch(() => undefined); + await unlink(path); } } } diff --git a/src/domain/hop-chain-edges.test.ts b/src/domain/hop-chain-edges.test.ts new file mode 100644 index 0000000..56e5234 --- /dev/null +++ b/src/domain/hop-chain-edges.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, test } from "vitest"; +import { appendHop, checkChain } from "./hop-chain.js"; + +const id = (n: number) => n.toString(16).padStart(24, "0"); + +describe("hop-chain edge shapes", () => { + test("checkChain with no chain admits", () => { + expect(checkChain(undefined, new Set())).toEqual({ admitted: true }); + }); + + test("appendHop accepts an undefined chain", () => { + const own = id(1); + expect(appendHop(undefined, own)).toEqual([own]); + }); +}); diff --git a/src/domain/hop-chain.test.ts b/src/domain/hop-chain.test.ts index fd31dfe..b8745d9 100644 --- a/src/domain/hop-chain.test.ts +++ b/src/domain/hop-chain.test.ts @@ -2,8 +2,6 @@ import { describe, expect, test } from "vitest"; import { appendHop, checkChain, - isHopId, - parseChain, MAX_CHAIN_LENGTH_GUARD, MAX_SELF_HOPS, } from "./hop-chain.js"; @@ -12,12 +10,6 @@ const id = (n: number) => n.toString(16).padStart(24, "0"); const ownToken = "21cc6f3d5c60ce84a36b2054"; describe("hop-chain", () => { - test("recognises 24-hex ids", () => { - expect(isHopId(ownToken)).toBe(true); - expect(isHopId("short")).toBe(false); - expect(isHopId("Z".repeat(24))).toBe(false); - }); - test("appendHop trims to the grammar maximum", () => { const chain = Array.from({ length: 32 }, (_, i) => id(i + 1)); const next = appendHop(chain, ownToken); @@ -26,6 +18,14 @@ describe("hop-chain", () => { expect(next[0]).toBe(id(2)); }); + test("appendHop does not trim when landing exactly at the grammar maximum", () => { + const chain = Array.from({ length: 31 }, (_, i) => id(i + 1)); + const next = appendHop(chain, ownToken); + expect(next).toHaveLength(32); + expect(next[0]).toBe(id(1)); + expect(next.at(-1)).toBe(ownToken); + }); + test("runaway fires above the guard length but not at it", () => { const at = Array.from({ length: MAX_CHAIN_LENGTH_GUARD }, (_, i) => id(i + 1), @@ -51,9 +51,4 @@ describe("hop-chain", () => { test("a single own-token occurrence is harmless (verified protocol behaviour)", () => { expect(checkChain([ownToken], new Set([ownToken])).admitted).toBe(true); }); - - test("parseChain rejects malformed entries", () => { - expect(parseChain(`${ownToken},${ownToken}`)).toHaveLength(2); - expect(parseChain(`${ownToken},nothex`)).toBeUndefined(); - }); }); diff --git a/src/domain/hop-chain.ts b/src/domain/hop-chain.ts index fdc9d89..23cc6f3 100644 --- a/src/domain/hop-chain.ts +++ b/src/domain/hop-chain.ts @@ -1,40 +1,19 @@ -import { - count, - HOP_ID_HEX_LENGTH, - MAX_HOP_CHAIN_ENTRIES, -} from "../schemas/limits.js"; +import { MAX_HOP_CHAIN_ENTRIES } from "../schemas/limits.js"; /** Receiver-side guard default: chains longer than this drop as hop-runaway. */ export const MAX_CHAIN_LENGTH_GUARD = 28; /** Receiver-side guard default: this many own-token occurrences drop as hop-loop. */ export const MAX_SELF_HOPS = 10; -const HOP_ID_RE = new RegExp(`^[0-9a-f]{${count(HOP_ID_HEX_LENGTH)}}$`); - -export function isHopId(value: string): boolean { - return HOP_ID_RE.test(value); -} - -export function joinChain(ids: readonly string[]): string | undefined { - return ids.length > 0 ? ids.join(",") : undefined; -} - -export function parseChain(serialized: string): string[] | undefined { - const parts = serialized.split(","); - return parts.every((p) => HOP_ID_RE.test(p)) ? parts : undefined; -} - /** - * Append the relayer's own id, keeping at most the grammar-level maximum (the receiver trims to the same bound in `NDt`). + * Append the relayer's own id, keeping at most the grammar-level maximum (the receiver trims to the same bound in `NDt`). Always slicing (rather than branching on whether trimming is needed) means the maths alone decides the result: slicing from a non-positive start is a no-op in JS, so a chain already at or under the maximum is returned unchanged without a separate comparison to get right. */ export function appendHop( chain: readonly string[] | undefined, ownId: string, ): string[] { const next = [...(chain ?? []), ownId]; - return next.length > MAX_HOP_CHAIN_ENTRIES - ? next.slice(next.length - MAX_HOP_CHAIN_ENTRIES) - : next; + return next.slice(Math.max(0, next.length - MAX_HOP_CHAIN_ENTRIES)); } export interface ChainCheck { diff --git a/src/domain/misc.test.ts b/src/domain/misc.test.ts new file mode 100644 index 0000000..49a2e06 --- /dev/null +++ b/src/domain/misc.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from "vitest"; +import { DEDUP_WINDOW_MS, varyBody } from "./dedup.js"; +import { newHopId, newMsgId } from "./ids.js"; + +describe("dedup", () => { + test("window is the receiver's 30s", () => { + expect(DEDUP_WINDOW_MS).toBe(30_000); + }); + + test("varyBody appends distinct invisible variation per nth", () => { + const body = "same text"; + const first = varyBody(body, 0); + const second = varyBody(body, 1); + expect(first).not.toBe(second); + expect(first.startsWith(body)).toBe(true); + expect(second.startsWith(body)).toBe(true); + expect(varyBody(body, 0)).toBe(first); + }); +}); + +describe("ids", () => { + test("msg ids are uuid v4 shaped", () => { + expect(newMsgId()).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); + }); + + test("hop ids are 24 lowercase hex", () => { + expect(newHopId()).toMatch(/^[0-9a-f]{24}$/); + }); +}); diff --git a/src/domain/pacer.ts b/src/domain/pacer.ts index 701bec8..73dbe33 100644 --- a/src/domain/pacer.ts +++ b/src/domain/pacer.ts @@ -29,8 +29,7 @@ export class Pacer { /** Milliseconds to wait before one token is available (0 = now). */ msUntilNextToken(): number { this.refill(); - if (this.tokens >= 1) return 0; - const deficit = 1 - this.tokens; + const deficit = Math.max(0, 1 - this.tokens); return Math.ceil((deficit / this.refillPerSecond) * MS_PER_SECOND); } @@ -42,10 +41,13 @@ export class Pacer { return true; } + /** Clamps negative elapsed time (a clock moving backward) to zero rather than draining tokens, folding the guard into the arithmetic instead of a separate branch. */ private refill(): void { const now = this.clock.nowMs(); - const elapsedSeconds = (now - this.lastRefillMs) / MS_PER_SECOND; - if (elapsedSeconds <= 0) return; + const elapsedSeconds = Math.max( + 0, + (now - this.lastRefillMs) / MS_PER_SECOND, + ); this.tokens = Math.min( this.capacity, this.tokens + elapsedSeconds * this.refillPerSecond, diff --git a/src/domain/roster.ts b/src/domain/roster.ts index d6c5810..87786da 100644 --- a/src/domain/roster.ts +++ b/src/domain/roster.ts @@ -26,33 +26,31 @@ export async function filterRoster( entries: readonly RegistryEntry[], probes: RosterProbes, ): Promise { - const verdicts: RosterVerdict[] = []; - for (const entry of entries) { - const verdict = await checkEntry(entry, probes); - verdicts.push(verdict); - } + const verdicts = await Promise.all( + entries.map(async (entry) => checkEntry(entry, probes)), + ); return verdicts.filter((v) => v.admitted).map((v) => v.entry); } -async function checkEntry( +/** Exported for direct unit coverage of each rejection reason, which filterRoster's own filtered-entries return value cannot distinguish. */ +export async function checkEntry( entry: RegistryEntry, probes: RosterProbes, ): Promise { if (entry.messagingSocketPath.length === 0) { return { entry, admitted: false, reason: "no-socket" }; } - if ( - probes.ownSocketPath !== undefined && - entry.messagingSocketPath === probes.ownSocketPath - ) { + // messagingSocketPath is already known non-empty (the no-socket check above returned first otherwise), so comparing it against an absent ownSocketPath is always false without a separate undefined guard. + if (entry.messagingSocketPath === probes.ownSocketPath) { return { entry, admitted: false, reason: "own-socket" }; } const alive = await probes.procInfo.alive(entry.pid); if (!alive) { return { entry, admitted: false, reason: "pid-dead" }; } + // entry.procStart is always a defined, non-empty string (schema-enforced), so an absent lstart already satisfies "differs from procStart" on its own without a separate undefined check. const lstart = await probes.procInfo.lstart(entry.pid); - if (lstart === undefined || lstart !== entry.procStart) { + if (lstart !== entry.procStart) { return { entry, admitted: false, reason: "proc-start-mismatch" }; } const connectable = await probes.transport.probe(entry.messagingSocketPath); diff --git a/src/domain/units.test.ts b/src/domain/units.test.ts new file mode 100644 index 0000000..a40621e --- /dev/null +++ b/src/domain/units.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, test } from "vitest"; +import { Pacer } from "./pacer.js"; +import { checkEntry, filterRoster } from "./roster.js"; +import { assertRoundTrips } from "./envelope.js"; +import type { RegistryEntry } from "../schemas/registry.js"; +import type { ProcInfo } from "../ports/proc-info.js"; +import type { Transport } from "../ports/transport.js"; + +/** Sentinel: the lstart probe reports no start string at all. */ +const NO_START = "no-start"; + +class FakeClock { + constructor(private now: number) {} + nowMs(): number { + return this.now; + } + advance(ms: number): void { + this.now += ms; + } +} + +describe("Pacer", () => { + test("refills tokens over elapsed time and blocks until one is available", () => { + const clock = new FakeClock(1_000_000); + const pacer = new Pacer(clock, 2, 0.5); + expect(pacer.tryReserve()).toBe(true); + expect(pacer.tryReserve()).toBe(true); + expect(pacer.tryReserve()).toBe(false); + // Zero elapsed time must not refill. + expect(pacer.msUntilNextToken()).toBeGreaterThan(0); + // 0.5 tokens/s: a full deficit token takes 2s. + clock.advance(2_000); + expect(pacer.tryReserve()).toBe(true); + expect(pacer.tryReserve()).toBe(false); + clock.advance(10_000); + expect(pacer.tryReserve()).toBe(true); + expect(pacer.tryReserve()).toBe(true); + expect(pacer.tryReserve()).toBe(false); + }); + + test("capacity caps the refill", () => { + const clock = new FakeClock(0); + const pacer = new Pacer(clock, 1, 1); + clock.advance(60_000); + expect(pacer.tryReserve()).toBe(true); + expect(pacer.tryReserve()).toBe(false); + }); + + test("msUntilNextToken is zero while tokens remain", () => { + const pacer = new Pacer(new FakeClock(0), 3, 0.5); + expect(pacer.msUntilNextToken()).toBe(0); + }); + + test("msUntilNextToken refills from elapsed time on its own, without a prior tryReserve", () => { + const clock = new FakeClock(0); + const pacer = new Pacer(clock, 1, 1); + expect(pacer.tryReserve()).toBe(true); + clock.advance(1_000); + expect(pacer.msUntilNextToken()).toBe(0); + }); + + test("msUntilNextToken reports the exact wait for a fractional deficit", () => { + const clock = new FakeClock(0); + const pacer = new Pacer(clock, 1, 1); + expect(pacer.tryReserve()).toBe(true); + clock.advance(500); + expect(pacer.msUntilNextToken()).toBe(500); + }); + + test("refill ignores a clock that moves backward rather than draining tokens", () => { + const clock = new FakeClock(10_000); + const pacer = new Pacer(clock, 1, 1); + expect(pacer.tryReserve()).toBe(true); + clock.advance(-5_000); + expect(pacer.msUntilNextToken()).toBe(1_000); + }); +}); + +function entry(overrides: Partial = {}): RegistryEntry { + return { + pid: 4242, + sessionId: "s", + cwd: "/", + startedAt: 1, + procStart: "start", + version: "v", + peerProtocol: 1, + peerFeatures: [], + kind: "interactive", + entrypoint: "cli", + pidDomain: "darwin", + messagingSocketPath: "/tmp/x.sock", + updatedAt: 1, + ...overrides, + }; +} + +function probes( + overrides: Readonly<{ + alive?: boolean; + /** NO_START means the probe reports no start string at all. */ + lstart?: string; + probe?: boolean; + ownSocketPath?: string; + }>, +): { + transport: Pick; + procInfo: ProcInfo; + ownSocketPath?: string; +} { + const resolveStart = (): string | undefined => + overrides.lstart === NO_START ? undefined : (overrides.lstart ?? "start"); + const lstart = async (): Promise => + Promise.resolve(resolveStart()); + const alive = async (): Promise => + Promise.resolve(overrides.alive ?? true); + const probe = async (): Promise => + Promise.resolve(overrides.probe ?? true); + return { + transport: { probe }, + procInfo: { alive, lstart }, + ...(overrides.ownSocketPath !== undefined + ? { ownSocketPath: overrides.ownSocketPath } + : {}), + }; +} + +describe("filterRoster verdicts", () => { + test("admits a fully live entry", async () => { + const result = await filterRoster([entry()], probes({})); + expect(result).toHaveLength(1); + }); + + test("excludes each documented failure reason", async () => { + const noSocket = await filterRoster( + [entry({ messagingSocketPath: "" })], + probes({}), + ); + expect(noSocket).toHaveLength(0); + + const own = await filterRoster( + [entry()], + probes({ ownSocketPath: "/tmp/x.sock" }), + ); + expect(own).toHaveLength(0); + + const deadPid = await filterRoster([entry()], probes({ alive: false })); + expect(deadPid).toHaveLength(0); + + const recycled = await filterRoster( + [entry()], + probes({ lstart: "different start" }), + ); + expect(recycled).toHaveLength(0); + + const missingStart = await filterRoster( + [entry()], + probes({ lstart: NO_START }), + ); + expect(missingStart).toHaveLength(0); + + const deadSocket = await filterRoster([entry()], probes({ probe: false })); + expect(deadSocket).toHaveLength(0); + }); +}); + +describe("checkEntry reasons", () => { + test("each rejection carries its own specific reason and the rejected entry", async () => { + return Promise.all([ + checkEntry(entry({ messagingSocketPath: "" }), probes({})).then( + (verdict) => { + expect(verdict).toEqual({ + entry: entry({ messagingSocketPath: "" }), + admitted: false, + reason: "no-socket", + }); + }, + ), + checkEntry(entry(), probes({ ownSocketPath: "/tmp/x.sock" })).then( + (verdict) => { + expect(verdict).toEqual({ + entry: entry(), + admitted: false, + reason: "own-socket", + }); + }, + ), + checkEntry(entry(), probes({ alive: false })).then((verdict) => { + expect(verdict).toEqual({ + entry: entry(), + admitted: false, + reason: "pid-dead", + }); + }), + checkEntry(entry(), probes({ lstart: "different start" })).then( + (verdict) => { + expect(verdict).toEqual({ + entry: entry(), + admitted: false, + reason: "proc-start-mismatch", + }); + }, + ), + checkEntry(entry(), probes({ probe: false })).then((verdict) => { + expect(verdict).toEqual({ + entry: entry(), + admitted: false, + reason: "socket-dead", + }); + }), + checkEntry(entry(), probes({})).then((verdict) => { + expect(verdict).toEqual({ entry: entry(), admitted: true }); + }), + ]); + }); +}); + +describe("assertRoundTrips", () => { + test("returns false for content that does not parse as an envelope", () => { + expect(assertRoundTrips("not an envelope")).toBe(false); + expect( + assertRoundTrips( + "no attrs", + ), + ).toBe(false); + }); +}); diff --git a/src/errors.test.ts b/src/errors.test.ts new file mode 100644 index 0000000..56d7566 --- /dev/null +++ b/src/errors.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from "vitest"; +import { + CcPeerError, + TransportError, + NoLiveInboxError, + UnknownPeerError, + MessageTooLargeError, + UnvettedReplyTargetError, + NotStartedError, + ProtocolError, +} from "./errors.js"; + +describe("error taxonomy", () => { + test("every error carries its machine-readable code", () => { + const cases = [ + [new TransportError("t"), "TRANSPORT"], + [new NoLiveInboxError("n"), "NO_LIVE_INBOX"], + [new UnknownPeerError("u"), "UNKNOWN_PEER"], + [new MessageTooLargeError("m"), "MESSAGE_TOO_LARGE"], + [new UnvettedReplyTargetError("v"), "UNVETTED_REPLY_TARGET"], + [new NotStartedError("s"), "NOT_STARTED"], + [new ProtocolError("p"), "PROTOCOL"], + ] as const; + for (const [error, code] of cases) { + expect(error).toBeInstanceOf(CcPeerError); + expect(error.code).toBe(code); + expect(error.message).toBeTruthy(); + } + }); +}); diff --git a/src/schemas/define-schema.ts b/src/schemas/define-schema.ts index 11f8850..577ede6 100644 --- a/src/schemas/define-schema.ts +++ b/src/schemas/define-schema.ts @@ -4,10 +4,7 @@ import type { z } from "zod"; * Attach an `.is()` type guard to a Zod schema so schema, inferred type, and runtime guard derive from one definition (single source of truth). `Schema.parse()` at JSON boundaries; `Schema.is()` for narrowing. */ export function defineSchema(schema: T) { - /* Object.assign is required over spread here: a Zod schema is a class - instance, and spreading it would drop the prototype (parse, safeParse, - refinements), so we deliberately attach the guard to the live instance. */ - // eslint-disable-next-line exadev/no-object-assign + // Object.assign is required over spread here: a Zod schema is a class instance, and spreading it would drop the prototype (parse, safeParse, refinements), so the guard is deliberately attached to the live instance. The no-object-assign rule is disabled for this file in eslint.config.ts. return Object.assign(schema, { is(value: unknown): value is z.infer { return schema.safeParse(value).success; diff --git a/src/schemas/guards.test.ts b/src/schemas/guards.test.ts new file mode 100644 index 0000000..d76cffd --- /dev/null +++ b/src/schemas/guards.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, test } from "vitest"; +import { AuthLineSchema } from "./wire.js"; +import { PeerKeyFileSchema } from "./keyfile.js"; +import { + NameSourceSchema, + PeerFeatureSchema, + PeerStatusSchema, + RegistryEntrySchema, + SessionKindSchema, +} from "./registry.js"; +import { EnvelopeAddressSchema, EnvelopeAttributesSchema } from "./envelope.js"; +import { count } from "./limits.js"; +import { componentSchemasFrom, PeerTargetSchema } from "../api/schemas.js"; + +describe("attached type guards reject invalid shapes", () => { + test("auth line token must be 32 hex", () => { + expect(AuthLineSchema.is({ type: "auth", token: "short" })).toBe(false); + expect(AuthLineSchema.is({ type: "auth", token: "Z".repeat(32) })).toBe( + false, + ); + expect(AuthLineSchema.is({ type: "auth", token: "a".repeat(32) })).toBe( + true, + ); + }); + + test("key file token must be 32 hex", () => { + const shape = { procStart: "s", pidDomain: "darwin" }; + expect(PeerKeyFileSchema.is({ ...shape, peerToken: "x".repeat(32) })).toBe( + false, + ); + expect(PeerKeyFileSchema.is({ ...shape, peerToken: "a".repeat(32) })).toBe( + true, + ); + }); + + test("registry entry requires the full shape", () => { + expect(RegistryEntrySchema.is({})).toBe(false); + }); + + test("every documented enum value is accepted by its own schema", () => { + for (const value of [ + "user", + "peer", + "derived", + "collision", + "auto", + "hook", + ]) { + expect(NameSourceSchema.is(value)).toBe(true); + } + expect(NameSourceSchema.is("nonsense")).toBe(false); + + for (const value of ["busy", "shell", "idle", "waiting"]) { + expect(PeerStatusSchema.is(value)).toBe(true); + } + expect(PeerStatusSchema.is("nonsense")).toBe(false); + + for (const value of ["interactive", "bg", "daemon", "daemon-worker"]) { + expect(SessionKindSchema.is(value)).toBe(true); + } + expect(SessionKindSchema.is("nonsense")).toBe(false); + + for (const value of [ + "notify_idle", + "reply_across_default_dirs", + "artifact_yield", + ]) { + expect(PeerFeatureSchema.is(value)).toBe(true); + } + expect(PeerFeatureSchema.is("nonsense")).toBe(false); + }); + + test("envelope address charset is enforced", () => { + expect(EnvelopeAddressSchema.is("has space!")).toBe(false); + expect(EnvelopeAddressSchema.is("uds:/tmp/x.sock")).toBe(true); + }); + + test("envelope attributes validate the grammar", () => { + expect(EnvelopeAttributesSchema.is({ from: "uds:/a.sock" })).toBe(true); + expect(EnvelopeAttributesSchema.is({ from: "" })).toBe(false); + expect( + EnvelopeAttributesSchema.is({ from: "a", hopChain: ["nothex"] }), + ).toBe(false); + expect(EnvelopeAttributesSchema.is({ from: "a", fromMode: "wizard" })).toBe( + false, + ); + expect( + EnvelopeAttributesSchema.is({ from: "a", fromSession: "has space" }), + ).toBe(false); + expect( + EnvelopeAttributesSchema.is({ from: "a", fromSession: "sess-1_2" }), + ).toBe(true); + }); +}); + +describe("peer target refine", () => { + test("at least one addressing field is required", () => { + expect(PeerTargetSchema.is({})).toBe(false); + expect(PeerTargetSchema.is({ pid: 1 })).toBe(true); + expect(PeerTargetSchema.is({ name: "bob" })).toBe(true); + expect(PeerTargetSchema.is({ address: "uds:/tmp/1.sock" })).toBe(true); + expect(PeerTargetSchema.is({ pid: 1, name: "bob" })).toBe(true); + }); +}); + +describe("componentSchemasFrom", () => { + test("rejects non-object conversions", () => { + expect(() => componentSchemasFrom(null)).toThrow( + "did not produce an object", + ); + expect(() => componentSchemasFrom([1, 2])).toThrow( + "did not produce an object", + ); + }); + + test("rejects a missing or non-object schemas member", () => { + expect(() => componentSchemasFrom({})).toThrow("no schemas object"); + expect(() => componentSchemasFrom({ schemas: 7 })).toThrow( + "no schemas object", + ); + }); + + test("rejects non-object component entries", () => { + expect(() => componentSchemasFrom({ schemas: { bad: 3 } })).toThrow( + "component bad is not a JSON object", + ); + }); + + test("strips the root-only $schema keyword from every component", () => { + const out = componentSchemasFrom({ + schemas: { + one: { $schema: "https://example.com/draft", type: "object" }, + two: { type: "string" }, + }, + }); + expect(Object.keys(out).sort()).toEqual(["one", "two"]); + expect(out.one).toEqual({ type: "object" }); + expect(out.two).toEqual({ type: "string" }); + }); +}); + +describe("count helper", () => { + test("stringifies numeric limits", () => { + expect(count(24)).toBe("24"); + }); +}); diff --git a/src/schemas/wire.test.ts b/src/schemas/wire.test.ts new file mode 100644 index 0000000..181c0f5 --- /dev/null +++ b/src/schemas/wire.test.ts @@ -0,0 +1,323 @@ +import { describe, expect, test } from "vitest"; +import { + AuthLineSchema, + FileAttachmentSchema, + UserFrameSchema, + DropReasonSchema, + PeerMessageStatusSchema, + NotifyWhenIdleSchema, + PeerIdleNoticeSchema, + YieldArtifactRepliesSchema, + YieldReasonSchema, + ArtifactRepliesYieldedSchema, + UnyieldArtifactRepliesSchema, +} from "./wire.js"; + +const VALID_ATTACHMENT = { + path: "/tmp/staged-file.bin", + file_name: "report.pdf", + file_size: 4096, + sha256: "a".repeat(64), +}; + +describe("FileAttachmentSchema", () => { + test("accepts a fully-shaped attachment", () => { + expect(FileAttachmentSchema.is(VALID_ATTACHMENT)).toBe(true); + }); + + test("rejects a missing required field", () => { + expect(FileAttachmentSchema.is({})).toBe(false); + }); + + test("rejects a sha256 that does not match the hex-64 shape", () => { + expect( + FileAttachmentSchema.is({ ...VALID_ATTACHMENT, sha256: "not-hex" }), + ).toBe(false); + }); +}); + +const VALID_USER_FRAME = { + msgV: 1, + msg_id: "msg-outbound-1", + type: "user", + message: { role: "user", content: "hello there" }, + priority: "next", + from: "uds:/tmp/cc-socks/123.sock", +}; + +describe("UserFrameSchema", () => { + test("accepts a fully-shaped user frame", () => { + expect(UserFrameSchema.is(VALID_USER_FRAME)).toBe(true); + }); + + test("rejects a nested message missing its own required fields", () => { + expect(UserFrameSchema.is({ ...VALID_USER_FRAME, message: {} })).toBe( + false, + ); + }); + + test("rejects a from address containing a disallowed character", () => { + expect(UserFrameSchema.is({ ...VALID_USER_FRAME, from: "has space" })).toBe( + false, + ); + }); + + test("accepts a single file attachment, under the per-message cap", () => { + expect( + UserFrameSchema.is({ + ...VALID_USER_FRAME, + file_attachments: [VALID_ATTACHMENT], + }), + ).toBe(true); + }); +}); + +describe("DropReasonSchema", () => { + test("every documented drop reason is accepted", () => { + for (const reason of [ + "rate-limited", + "duplicate", + "hop-loop", + "hop-runaway", + "queue-full", + ]) { + expect(DropReasonSchema.is(reason)).toBe(true); + } + expect(DropReasonSchema.is("made-up-reason")).toBe(false); + }); +}); + +const VALID_PEER_MESSAGE_STATUS = { + type: "control", + action: "peer_message_status", + status: "delivered", + reason: "", + from: "uds:/tmp/cc-socks/123.sock", + orig_msg_id: "msg-outbound-1", + msgV: 1, + msg_id: "msg-status-1", +}; + +describe("PeerMessageStatusSchema", () => { + test("accepts a fully-shaped status frame", () => { + expect(PeerMessageStatusSchema.is(VALID_PEER_MESSAGE_STATUS)).toBe(true); + }); + + test("rejects an incomplete object", () => { + expect(PeerMessageStatusSchema.is({})).toBe(false); + }); + + test("every documented status value is accepted", () => { + for (const status of [ + "held", + "delivered", + "denied", + "expired", + "dropped", + ]) { + expect( + PeerMessageStatusSchema.is({ + ...VALID_PEER_MESSAGE_STATUS, + status, + }), + ).toBe(true); + } + }); + + test("rejects a from address containing a disallowed character", () => { + expect( + PeerMessageStatusSchema.is({ + ...VALID_PEER_MESSAGE_STATUS, + from: "has space", + }), + ).toBe(false); + }); +}); + +const VALID_NOTIFY_WHEN_IDLE = { + type: "control", + action: "notify_when_idle", + from: "uds:/tmp/cc-socks/123.sock", + msgV: 1, + msg_id: "msg-notify-1", +}; + +describe("NotifyWhenIdleSchema", () => { + test("accepts a fully-shaped frame", () => { + expect(NotifyWhenIdleSchema.is(VALID_NOTIFY_WHEN_IDLE)).toBe(true); + }); + + test("rejects an incomplete object", () => { + expect(NotifyWhenIdleSchema.is({})).toBe(false); + }); + + test("rejects a from address containing a disallowed character", () => { + expect( + NotifyWhenIdleSchema.is({ ...VALID_NOTIFY_WHEN_IDLE, from: "bad char" }), + ).toBe(false); + }); + + test("every documented from_mode value is accepted", () => { + for (const from_mode of ["bypass", "prompting"]) { + expect( + NotifyWhenIdleSchema.is({ ...VALID_NOTIFY_WHEN_IDLE, from_mode }), + ).toBe(true); + } + expect( + NotifyWhenIdleSchema.is({ + ...VALID_NOTIFY_WHEN_IDLE, + from_mode: "made-up", + }), + ).toBe(false); + }); +}); + +const VALID_PEER_IDLE_NOTICE = { + type: "control", + action: "peer_idle_notice", + orig_msg_id: "msg-outbound-1", + state: "idle", + finished_at: 1_726_000_000_000, + from: "uds:/tmp/cc-socks/123.sock", + msgV: 1, + msg_id: "msg-idle-1", +}; + +describe("PeerIdleNoticeSchema", () => { + test("accepts a fully-shaped frame", () => { + expect(PeerIdleNoticeSchema.is(VALID_PEER_IDLE_NOTICE)).toBe(true); + }); + + test("every documented state value is accepted", () => { + for (const state of ["idle", "exited"]) { + expect( + PeerIdleNoticeSchema.is({ ...VALID_PEER_IDLE_NOTICE, state }), + ).toBe(true); + } + expect( + PeerIdleNoticeSchema.is({ ...VALID_PEER_IDLE_NOTICE, state: "napping" }), + ).toBe(false); + }); + + test("rejects a from address containing a disallowed character", () => { + expect( + PeerIdleNoticeSchema.is({ ...VALID_PEER_IDLE_NOTICE, from: "bad char" }), + ).toBe(false); + }); + + test("every documented from_mode value is accepted", () => { + for (const from_mode of ["bypass", "prompting"]) { + expect( + PeerIdleNoticeSchema.is({ ...VALID_PEER_IDLE_NOTICE, from_mode }), + ).toBe(true); + } + expect( + PeerIdleNoticeSchema.is({ + ...VALID_PEER_IDLE_NOTICE, + from_mode: "made-up", + }), + ).toBe(false); + }); +}); + +const VALID_YIELD_ARTIFACT_REPLIES = { + type: "control", + action: "yield_artifact_replies", + from: "raw-socket-peer", + msg_id: "msg-yield-1", + session_id: "session-abc-123", + slugs: ["design-doc", "spec-outline"], + reason: "resume", + sent_at: 1_726_000_000_000, + msgV: 1, +}; + +describe("YieldArtifactRepliesSchema", () => { + test("accepts a fully-shaped frame", () => { + expect(YieldArtifactRepliesSchema.is(VALID_YIELD_ARTIFACT_REPLIES)).toBe( + true, + ); + }); + + test("rejects an incomplete object", () => { + expect(YieldArtifactRepliesSchema.is({})).toBe(false); + }); + + test("both documented reasons are genuine enum members, not just recoverable via the catch fallback", () => { + // Checking the field through YieldArtifactRepliesSchema can't distinguish a corrupted "claim" member from a healthy one: .catch("claim") means a rejected "claim" input recovers to exactly "claim" anyway, so the field-level result looks identical either way. YieldReasonSchema is the bare enum with no catch, so a genuinely corrupted member fails safeParse here instead of being silently repaired. + expect(YieldReasonSchema.safeParse("resume").success).toBe(true); + expect(YieldReasonSchema.safeParse("claim").success).toBe(true); + }); + + test("an unrecognised reason falls back to claim rather than failing validation", () => { + const parsed = YieldArtifactRepliesSchema.parse({ + ...VALID_YIELD_ARTIFACT_REPLIES, + reason: "made-up", + }); + expect(parsed.reason).toBe("claim"); + }); + + test("accepts a well-typed requester and rejects a wrongly-typed one", () => { + expect( + YieldArtifactRepliesSchema.is({ + ...VALID_YIELD_ARTIFACT_REPLIES, + requester: { cwd: "/home/user/project", tmux: "session-1" }, + }), + ).toBe(true); + expect( + YieldArtifactRepliesSchema.is({ + ...VALID_YIELD_ARTIFACT_REPLIES, + requester: { cwd: 12345 }, + }), + ).toBe(false); + }); +}); + +const VALID_ARTIFACT_REPLIES_YIELDED = { + type: "control", + action: "artifact_replies_yielded", + orig_msg_id: "msg-yield-1", + msgV: 1, + msg_id: "msg-yielded-1", +}; + +describe("ArtifactRepliesYieldedSchema", () => { + test("accepts a fully-shaped frame", () => { + expect( + ArtifactRepliesYieldedSchema.is(VALID_ARTIFACT_REPLIES_YIELDED), + ).toBe(true); + }); + + test("rejects an incomplete object", () => { + expect(ArtifactRepliesYieldedSchema.is({})).toBe(false); + }); +}); + +const VALID_UNYIELD_ARTIFACT_REPLIES = { + type: "control", + action: "unyield_artifact_replies", + orig_msg_id: "msg-yield-1", + slugs: ["design-doc", "spec-outline"], + msgV: 1, + msg_id: "msg-unyield-1", +}; + +describe("UnyieldArtifactRepliesSchema", () => { + test("accepts a fully-shaped frame", () => { + expect( + UnyieldArtifactRepliesSchema.is(VALID_UNYIELD_ARTIFACT_REPLIES), + ).toBe(true); + }); + + test("rejects an incomplete object", () => { + expect(UnyieldArtifactRepliesSchema.is({})).toBe(false); + }); +}); + +describe("AuthLineSchema companion coverage", () => { + test("accepts the documented auth shape", () => { + expect(AuthLineSchema.is({ type: "auth", token: "a".repeat(32) })).toBe( + true, + ); + }); +}); diff --git a/src/schemas/wire.ts b/src/schemas/wire.ts index 206b817..6e85238 100644 --- a/src/schemas/wire.ts +++ b/src/schemas/wire.ts @@ -134,6 +134,9 @@ export const PeerIdleNoticeSchema = defineSchema( ); export type PeerIdleNotice = z.infer; +/** Bare enum, exported separately so a test can assert its own membership directly rather than through the object field's .catch("claim") fallback, which would otherwise mask a corrupted "claim" member by coincidentally recovering the same value. */ +export const YieldReasonSchema = z.enum(["resume", "claim"]); + export const YieldArtifactRepliesSchema = defineSchema( z.object({ type: z.literal("control"), @@ -142,7 +145,7 @@ export const YieldArtifactRepliesSchema = defineSchema( msg_id: z.string().min(1).max(MAX_MSG_ID_CHARS), session_id: z.string().max(MAX_SESSION_ID_CHARS), slugs: z.array(z.string().max(MAX_SLUG_CHARS)).max(MAX_YIELD_SLUGS), - reason: z.enum(["resume", "claim"]).catch("claim"), + reason: YieldReasonSchema.catch("claim"), sent_at: z.number(), claimed_at: z.number().optional(), requester: z diff --git a/stryker.config.ts b/stryker.config.ts index 10e4c34..fe2076c 100644 --- a/stryker.config.ts +++ b/stryker.config.ts @@ -1,8 +1,20 @@ const options = { testRunner: "vitest", + // Named explicitly rather than left to auto-discovery: a fresh pnpm install's strict, non-flat node_modules layout does not expose + // @stryker-mutator/vitest-runner the way auto-discovery expects, failing + // with "Cannot find TestRunner plugin vitest" on a clean CI checkout even though the identical config resolves it fine against an already-populated local node_modules. + plugins: ["@stryker-mutator/vitest-runner"], reporters: ["html", "clear-text", "progress"], coverageAnalysis: "perTest", - mutate: ["src/domain/**/*.ts", "src/schemas/**/*.ts", "!src/**/*.test.ts"], + // define-schema.ts is excluded, not just left to ignoreStatic: every schema module assigns defineSchema's return value straight to a top-level export const, so a mutant that empties defineSchema's body poisons every module that imports it and crashes test collection across ten-plus files at once. That triggers a still-open + // @stryker-mutator/vitest-runner bug (stryker-mutator/stryker-js#6150): + // the runner's collection-failure path never sets its own failure flag, so a mutant that crashes every test in a file at import time is misreported Survived instead of Killed, confirmed directly by reproducing it manually and watching a real vitest run correctly fail ten files while stryker still reported the mutant as surviving. ignoreStatic was tried first but is too broad a fix: every schema definition in this codebase is itself a top-level const, so it silently zeroes out mutation testing for the entire schemas directory, not just this one function. Excluding only define-schema.ts (a three-line helper already exercised indirectly by every other schema's own tests) keeps the rest of the schema layer's mutation score meaningful. + mutate: [ + "src/domain/**/*.ts", + "src/schemas/**/*.ts", + "!src/**/*.test.ts", + "!src/schemas/define-schema.ts", + ], tempDirName: ".stryker-tmp", // 100% across the board: a surviving mutant is a behaviour the test suite does not pin. Mutation-exemption comments are banned by the lint rule in eslint.config.ts: a mutant that genuinely cannot be killed is a design smell to fix, not to exempt. thresholds: { diff --git a/test/api-extra.test.ts b/test/api-extra.test.ts new file mode 100644 index 0000000..81c9b72 --- /dev/null +++ b/test/api-extra.test.ts @@ -0,0 +1,270 @@ +import { describe, expect, test } from "vitest"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { + ReadableStreamDefaultReader, + ReadableStreamReadResult, +} from "node:stream/web"; + +import { CcPeer } from "../src/cc-peer.js"; +import { + createApiServer, + hostnameOf, + httpErrorMessage, + listeningPort, + requestUrl, + toPeerRef, +} from "../src/api/server.js"; + +async function tempHome(): Promise { + return mkdtemp(join(tmpdir(), "cc-peer-api2-")); +} + +async function peer(home: string, name: string, dir: string) { + return CcPeer.create({ + homeDir: home, + socketDir: join(home, dir), + name, + logger: () => { + void 0; + }, + }); +} + +describe("pure helpers", () => { + test("requestUrl falls back to / and localhost when raw parts are absent", () => { + const url = requestUrl(undefined, undefined); + expect(url.pathname).toBe("/"); + expect(url.hostname).toBe("localhost"); + }); + + test("requestUrl uses the given raw parts when present", () => { + const url = requestUrl("/sessions?x=1", "127.0.0.1:9000"); + expect(url.pathname).toBe("/sessions"); + expect(url.hostname).toBe("127.0.0.1"); + }); + + test("httpErrorMessage reads an Error's message and stringifies anything else", () => { + expect(httpErrorMessage(new Error("boom"))).toBe("boom"); + expect(httpErrorMessage("plain string")).toBe('"plain string"'); + expect(httpErrorMessage({ code: 7 })).toBe('{"code":7}'); + }); + + test("toPeerRef narrows by whichever field is present, pid taking priority", () => { + expect(toPeerRef({ pid: 1 })).toEqual({ pid: 1 }); + expect(toPeerRef({ pid: 1, name: "x" })).toEqual({ pid: 1 }); + expect(toPeerRef({ name: "x" })).toEqual({ name: "x" }); + expect(toPeerRef({ address: "uds:/a.sock" })).toEqual({ + address: "uds:/a.sock", + }); + expect(() => toPeerRef({})).toThrow("target must specify"); + }); + + test("listeningPort reads the port from an AddressInfo and rejects anything else", () => { + expect( + listeningPort({ address: "127.0.0.1", family: "IPv4", port: 4242 }), + ).toBe(4242); + expect(() => listeningPort(null)).toThrow( + "expected the server to report an AddressInfo", + ); + expect(() => listeningPort("/tmp/some.sock")).toThrow( + "expected the server to report an AddressInfo", + ); + }); + + test("hostnameOf strips a port suffix, passes through a bare host, and treats an absent header as empty", () => { + expect(hostnameOf("127.0.0.1:9000")).toBe("127.0.0.1"); + expect(hostnameOf("localhost")).toBe("localhost"); + expect(hostnameOf(undefined)).toBe(""); + }); +}); + +describe("REST facade routes not covered by the happy-path test", () => { + test("an unknown path returns 404", async () => { + const home = await tempHome(); + const p = await peer(home, "route-peer", "socks-a"); + const server = await createApiServer(p, {}); + const res = await fetch(`http://127.0.0.1:${server.port.toString()}/nope`, { + headers: { authorization: `Bearer ${server.token ?? ""}` }, + }); + expect(res.status).toBe(404); + await server.close(); + await p.stop(); + }); + + test("noToken:true serves without any authorization header", async () => { + const home = await tempHome(); + const p = await peer(home, "no-token-peer", "socks-b"); + const server = await createApiServer(p, { noToken: true }); + expect(server.token).toBeUndefined(); + const res = await fetch( + `http://127.0.0.1:${server.port.toString()}/healthz`, + ); + expect(res.status).toBe(200); + await server.close(); + await p.stop(); + }); + + test("an explicit token option is honoured", async () => { + const home = await tempHome(); + const p = await peer(home, "fixed-token-peer", "socks-c"); + const server = await createApiServer(p, { token: "fixed-secret" }); + expect(server.token).toBe("fixed-secret"); + const res = await fetch( + `http://127.0.0.1:${server.port.toString()}/healthz`, + { + headers: { authorization: "Bearer fixed-secret" }, + }, + ); + expect(res.status).toBe(200); + await server.close(); + await p.stop(); + }); + + test("an explicit port option is honoured", async () => { + const home = await tempHome(); + const p = await peer(home, "port-peer", "socks-d"); + const first = await createApiServer(p, { noToken: true }); + await first.close(); + const server = await createApiServer(p, { + port: first.port, + noToken: true, + }); + expect(server.port).toBe(first.port); + await server.close(); + await p.stop(); + }); + + test("POST /idle-subscriptions subscribes and returns a msgId", async () => { + const home = await tempHome(); + const sender = await peer(home, "idle-sender", "socks-e1"); + const target = await peer(home, "idle-target", "socks-e2"); + const server = await createApiServer(sender, {}); + const res = await fetch( + `http://127.0.0.1:${server.port.toString()}/idle-subscriptions`, + { + method: "POST", + headers: { + authorization: `Bearer ${server.token ?? ""}`, + "content-type": "application/json", + }, + body: JSON.stringify({ to: { name: "idle-target" } }), + }, + ); + expect(res.status).toBe(202); + const accepted = (await res.json()) as { msgId: string }; + expect(accepted.msgId).toMatch(/^[0-9a-f-]{36}$/); + await server.close(); + await sender.stop(); + await target.stop(); + }); + + test("send by pid and by address succeed via REST", async () => { + const home = await tempHome(); + const sender = await peer(home, "pid-sender", "socks-f1"); + const target = await peer(home, "pid-target", "socks-f2"); + const server = await createApiServer(sender, {}); + const auth = { authorization: `Bearer ${server.token ?? ""}` }; + + const roster = await sender.roster(); + const targetEntry = roster.find((e) => e.name === "pid-target"); + expect(targetEntry).toBeDefined(); + + const byPid = await fetch( + `http://127.0.0.1:${server.port.toString()}/messages`, + { + method: "POST", + headers: { ...auth, "content-type": "application/json" }, + body: JSON.stringify({ to: { pid: targetEntry?.pid }, body: "by pid" }), + }, + ); + expect(byPid.status).toBe(202); + + const byAddress = await fetch( + `http://127.0.0.1:${server.port.toString()}/messages`, + { + method: "POST", + headers: { ...auth, "content-type": "application/json" }, + body: JSON.stringify({ + to: { address: targetEntry?.messagingSocketPath }, + body: "by address", + }), + }, + ); + expect(byAddress.status).toBe(202); + + await server.close(); + await sender.stop(); + await target.stop(); + }); + + test("GET /events streams message, receipt, and idle events over SSE", async () => { + const home = await tempHome(); + const p = await peer(home, "sse-peer", "socks-g"); + const server = await createApiServer(p, {}); + const controller = new AbortController(); + const response = await fetch( + `http://127.0.0.1:${server.port.toString()}/events`, + { + headers: { authorization: `Bearer ${server.token ?? ""}` }, + signal: controller.signal, + }, + ); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("text/event-stream"); + const reader: ReadableStreamDefaultReader | undefined = + response.body?.getReader(); + expect(reader).toBeDefined(); + const decoder = new TextDecoder(); + let buffered = ""; + + async function readUntil(marker: string): Promise { + for (let i = 0; i < 50 && !buffered.includes(marker); i += 1) { + const chunk: ReadableStreamReadResult | undefined = + await reader?.read(); + if (chunk?.value !== undefined) buffered += decoder.decode(chunk.value); + } + expect(buffered).toContain(marker); + } + + await readUntil(": connected"); + p.emit("message", { body: "hi", msgId: "m1" }); + await readUntil("event: message"); + p.emit("receipt", { status: "held" }); + await readUntil("event: receipt"); + p.emit("idle", { state: "idle" }); + await readUntil("event: idle"); + + controller.abort(); + await server.close(); + await p.stop(); + }, 10_000); + + test("POST /messages forwards a supplied priority and fromMode to send", async () => { + const home = await tempHome(); + const sender = await peer(home, "priority-sender", "socks-h1"); + const receiver = await peer(home, "priority-receiver", "socks-h2"); + const server = await createApiServer(sender, {}); + const response = await fetch( + `http://127.0.0.1:${server.port.toString()}/messages`, + { + method: "POST", + headers: { + authorization: `Bearer ${server.token ?? ""}`, + "content-type": "application/json", + }, + body: JSON.stringify({ + to: { name: "priority-receiver" }, + body: "urgent", + priority: "next", + fromMode: "bypass", + }), + }, + ); + expect(response.status).toBe(202); + await server.close(); + await sender.stop(); + await receiver.stop(); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..7a4ef81 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,26 @@ +import { defineConfig } from "vitest/config"; + +// 100% thresholds: the test suite is the contract that every line and branch of src/ is exercised. Test files themselves are excluded from measurement. +export default defineConfig({ + test: { + coverage: { + provider: "v8", + include: ["src/**/*.ts"], + // Entry glue (bin firing wrappers, the SEA entry) is exercised by the spawn-based smoke and SEA smoke tests, not by in-process unit coverage; ports are type-only declarations with no runtime statements to cover. + exclude: [ + "src/**/*.test.ts", + "src/test/**", + "src/bin/**", + "src/sea-entry.ts", + "src/ports/**", + ], + reporter: ["text", "html", "json-summary", "json"], + thresholds: { + statements: 100, + branches: 100, + functions: 100, + lines: 100, + }, + }, + }, +});