Merge pull request #666 from jeremymcs/fix/v2.19.0-phase1-quick-wins
fix: v2.20 Phase 1+2 — bugs, security, performance, code quality
This commit is contained in:
commit
a90aa0c8d6
30 changed files with 1771 additions and 496 deletions
646
package-lock.json
generated
646
package-lock.json
generated
|
|
@ -24,7 +24,7 @@
|
|||
"@types/mime-types": "^2.1.4",
|
||||
"ajv": "^8.17.1",
|
||||
"ajv-formats": "^3.0.1",
|
||||
"chalk": "^5.5.0",
|
||||
"chalk": "^5.6.2",
|
||||
"diff": "^8.0.2",
|
||||
"extract-zip": "^2.0.1",
|
||||
"file-type": "^21.1.1",
|
||||
|
|
@ -55,6 +55,7 @@
|
|||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/picomatch": "^4.0.2",
|
||||
"c8": "^11.0.0",
|
||||
"jiti": "^2.6.1",
|
||||
"typescript": "^5.4.0"
|
||||
},
|
||||
|
|
@ -795,6 +796,16 @@
|
|||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@bcoe/v8-coverage": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
|
||||
"integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@borewit/text-codec": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz",
|
||||
|
|
@ -1407,6 +1418,44 @@
|
|||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@istanbuljs/schema": {
|
||||
"version": "0.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz",
|
||||
"integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/resolve-uri": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
"version": "0.3.31",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
||||
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "^3.1.0",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@mariozechner/jiti": {
|
||||
"version": "2.6.5",
|
||||
"resolved": "https://registry.npmjs.org/@mariozechner/jiti/-/jiti-2.6.5.tgz",
|
||||
|
|
@ -2207,6 +2256,13 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/istanbul-lib-coverage": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
|
||||
"integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/mime-types": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-2.1.4.tgz",
|
||||
|
|
@ -2320,6 +2376,22 @@
|
|||
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/ast-types": {
|
||||
"version": "0.13.4",
|
||||
"resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz",
|
||||
|
|
@ -2412,6 +2484,40 @@
|
|||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/c8": {
|
||||
"version": "11.0.0",
|
||||
"resolved": "https://registry.npmjs.org/c8/-/c8-11.0.0.tgz",
|
||||
"integrity": "sha512-e/uRViGHSVIJv7zsaDKM7VRn2390TgHXqUSvYwPHBQaU6L7E9L0n9JbdkwdYPvshDT0KymBmmlwSpms3yBaMNg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@bcoe/v8-coverage": "^1.0.1",
|
||||
"@istanbuljs/schema": "^0.1.3",
|
||||
"find-up": "^5.0.0",
|
||||
"foreground-child": "^3.1.1",
|
||||
"istanbul-lib-coverage": "^3.2.0",
|
||||
"istanbul-lib-report": "^3.0.1",
|
||||
"istanbul-reports": "^3.1.6",
|
||||
"test-exclude": "^8.0.0",
|
||||
"v8-to-istanbul": "^9.0.0",
|
||||
"yargs": "^17.7.2",
|
||||
"yargs-parser": "^21.1.1"
|
||||
},
|
||||
"bin": {
|
||||
"c8": "bin/c8.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"monocart-coverage-reports": "^2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"monocart-coverage-reports": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "5.6.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
|
||||
|
|
@ -2424,6 +2530,86 @@
|
|||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
||||
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.1",
|
||||
"wrap-ansi": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui/node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui/node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-name": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-key": "^3.1.0",
|
||||
"shebang-command": "^2.0.0",
|
||||
"which": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/data-uri-to-buffer": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
|
||||
|
|
@ -2491,6 +2677,13 @@
|
|||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/end-of-stream": {
|
||||
"version": "1.4.5",
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||
|
|
@ -2500,6 +2693,16 @@
|
|||
"once": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/escodegen": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz",
|
||||
|
|
@ -2684,6 +2887,53 @@
|
|||
"url": "https://github.com/sindresorhus/file-type?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/find-up": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
|
||||
"integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"locate-path": "^6.0.0",
|
||||
"path-exists": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/foreground-child": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
||||
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"cross-spawn": "^7.0.6",
|
||||
"signal-exit": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/foreground-child/node_modules/signal-exit": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
||||
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/formdata-polyfill": {
|
||||
"version": "4.0.10",
|
||||
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
|
||||
|
|
@ -2738,6 +2988,16 @@
|
|||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/get-east-asian-width": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz",
|
||||
|
|
@ -2837,6 +3097,16 @@
|
|||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/hosted-git-info": {
|
||||
"version": "9.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.2.tgz",
|
||||
|
|
@ -2849,6 +3119,13 @@
|
|||
"node": "^20.17.0 || >=22.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html-escaper": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
||||
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/http-proxy-agent": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
|
||||
|
|
@ -2913,6 +3190,62 @@
|
|||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/istanbul-lib-coverage": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
|
||||
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/istanbul-lib-report": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
|
||||
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"istanbul-lib-coverage": "^3.0.0",
|
||||
"make-dir": "^4.0.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/istanbul-reports": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
|
||||
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"html-escaper": "^2.0.0",
|
||||
"istanbul-lib-report": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
|
||||
|
|
@ -2983,6 +3316,22 @@
|
|||
"url": "https://liberapay.com/Koromix"
|
||||
}
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||
"integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-locate": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/long": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
|
||||
|
|
@ -2998,6 +3347,22 @@
|
|||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/make-dir": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
|
||||
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"semver": "^7.5.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/marked": {
|
||||
"version": "15.0.12",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz",
|
||||
|
|
@ -3142,6 +3507,38 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/p-limit": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
|
||||
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"yocto-queue": "^0.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-locate": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
|
||||
"integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-limit": "^3.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-retry": {
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz",
|
||||
|
|
@ -3187,6 +3584,16 @@
|
|||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-expression-matcher": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.1.3.tgz",
|
||||
|
|
@ -3202,6 +3609,16 @@
|
|||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-scurry": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
|
||||
|
|
@ -3374,6 +3791,16 @@
|
|||
"once": "^1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
|
|
@ -3468,6 +3895,29 @@
|
|||
"@img/sharp-win32-x64": "0.34.5"
|
||||
}
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"shebang-regex": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/shebang-regex": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/signal-exit": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
|
||||
|
|
@ -3540,6 +3990,44 @@
|
|||
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width/node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width/node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
|
||||
|
|
@ -3583,6 +4071,34 @@
|
|||
"url": "https://github.com/sponsors/Borewit"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/test-exclude": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-8.0.0.tgz",
|
||||
"integrity": "sha512-ZOffsNrXYggvU1mDGHk54I96r26P8SyMjO5slMKSc7+IWmtB/MQKnEC2fP51imB3/pT6YK5cT5E8f+Dd9KdyOQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@istanbuljs/schema": "^0.1.2",
|
||||
"glob": "^13.0.6",
|
||||
"minimatch": "^10.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/token-types": {
|
||||
"version": "6.1.2",
|
||||
"resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz",
|
||||
|
|
@ -3654,6 +4170,21 @@
|
|||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/v8-to-istanbul": {
|
||||
"version": "9.3.0",
|
||||
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
|
||||
"integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@jridgewell/trace-mapping": "^0.3.12",
|
||||
"@types/istanbul-lib-coverage": "^2.0.1",
|
||||
"convert-source-map": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/web-streams-polyfill": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
|
||||
|
|
@ -3663,6 +4194,63 @@
|
|||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"isexe": "^2.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"node-which": "bin/node-which"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi/node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi/node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
|
|
@ -3690,6 +4278,16 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.8.2",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
||||
|
|
@ -3705,6 +4303,35 @@
|
|||
"url": "https://github.com/sponsors/eemeli"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "17.7.2",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
||||
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^8.0.1",
|
||||
"escalade": "^3.1.1",
|
||||
"get-caller-file": "^2.0.5",
|
||||
"require-directory": "^2.1.1",
|
||||
"string-width": "^4.2.3",
|
||||
"y18n": "^5.0.5",
|
||||
"yargs-parser": "^21.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "21.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
|
||||
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/yauzl": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
|
||||
|
|
@ -3715,6 +4342,19 @@
|
|||
"fd-slicer": "~1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/yocto-queue": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/yoctocolors": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz",
|
||||
|
|
@ -3804,12 +4444,14 @@
|
|||
"name": "@gsd/pi-tui",
|
||||
"version": "0.57.1",
|
||||
"dependencies": {
|
||||
"@types/mime-types": "^2.1.4",
|
||||
"chalk": "^5.5.0",
|
||||
"get-east-asian-width": "^1.3.0",
|
||||
"marked": "^15.0.12",
|
||||
"mime-types": "^3.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/mime-types": "^2.1.4"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"koffi": "^2.9.0"
|
||||
}
|
||||
|
|
|
|||
14
package.json
14
package.json
|
|
@ -44,10 +44,11 @@
|
|||
"build:native-pkg": "npm run build -w @gsd/native",
|
||||
"build:pi": "npm run build:native-pkg && npm run build:pi-tui && npm run build:pi-ai && npm run build:pi-agent-core && npm run build:pi-coding-agent",
|
||||
"build": "npm run build:pi && tsc && npm run copy-resources && npm run copy-themes && npm run copy-export-html",
|
||||
"copy-resources": "node -e \"const{cpSync,rmSync}=require('fs');rmSync('dist/resources',{recursive:true,force:true});cpSync('src/resources','dist/resources',{recursive:true,force:true})\"",
|
||||
"copy-themes": "node -e \"const{mkdirSync,cpSync}=require('fs');const{resolve}=require('path');const src=resolve(__dirname,'packages/pi-coding-agent/dist/modes/interactive/theme');mkdirSync('pkg/dist/modes/interactive/theme',{recursive:true});cpSync(src,'pkg/dist/modes/interactive/theme',{recursive:true})\"",
|
||||
"copy-export-html": "node -e \"const{mkdirSync,cpSync}=require('fs');const{resolve}=require('path');const src=resolve(__dirname,'packages/pi-coding-agent/dist/core/export-html');mkdirSync('pkg/dist/core/export-html',{recursive:true});cpSync(src,'pkg/dist/core/export-html',{recursive:true})\"",
|
||||
"copy-resources": "node scripts/copy-resources.cjs",
|
||||
"copy-themes": "node scripts/copy-themes.cjs",
|
||||
"copy-export-html": "node scripts/copy-export-html.cjs",
|
||||
"test:unit": "node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test src/resources/extensions/gsd/tests/*.test.ts src/resources/extensions/gsd/tests/*.test.mjs src/tests/*.test.ts",
|
||||
"test:coverage": "c8 --reporter=text --reporter=lcov --exclude='src/resources/extensions/gsd/tests/**' --exclude='src/tests/**' --exclude='scripts/**' --exclude='native/**' --exclude='node_modules/**' --check-coverage --statements=40 --lines=40 --branches=0 --functions=0 node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test src/resources/extensions/gsd/tests/*.test.ts src/resources/extensions/gsd/tests/*.test.mjs src/tests/*.test.ts",
|
||||
"test:integration": "node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test src/resources/extensions/gsd/tests/*integration*.test.ts src/tests/integration/*.test.ts",
|
||||
"test": "npm run test:unit && npm run test:integration",
|
||||
"test:browser-tools": "node --test src/resources/extensions/browser-tools/tests/browser-tools-unit.test.cjs src/resources/extensions/browser-tools/tests/browser-tools-integration.test.mjs",
|
||||
|
|
@ -60,7 +61,7 @@
|
|||
"pi:uninstall-global": "node scripts/uninstall-pi-global.js",
|
||||
"sync-pkg-version": "node scripts/sync-pkg-version.cjs",
|
||||
"sync-platform-versions": "node native/scripts/sync-platform-versions.cjs",
|
||||
"validate-pack": "bash scripts/validate-pack.sh",
|
||||
"validate-pack": "node scripts/validate-pack.js",
|
||||
"typecheck:extensions": "tsc --noEmit --project tsconfig.extensions.json",
|
||||
"prepublishOnly": "npm run sync-pkg-version && npm run sync-platform-versions && git diff --exit-code || (echo 'ERROR: version sync changed files — commit them before publishing' && exit 1) && npm run build && npm run typecheck:extensions && npm run validate-pack"
|
||||
},
|
||||
|
|
@ -76,7 +77,7 @@
|
|||
"@types/mime-types": "^2.1.4",
|
||||
"ajv": "^8.17.1",
|
||||
"ajv-formats": "^3.0.1",
|
||||
"chalk": "^5.5.0",
|
||||
"chalk": "^5.6.2",
|
||||
"diff": "^8.0.2",
|
||||
"extract-zip": "^2.0.1",
|
||||
"file-type": "^21.1.1",
|
||||
|
|
@ -103,14 +104,15 @@
|
|||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/picomatch": "^4.0.2",
|
||||
"c8": "^11.0.0",
|
||||
"jiti": "^2.6.1",
|
||||
"typescript": "^5.4.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@gsd-build/engine-darwin-arm64": ">=2.10.2",
|
||||
"@gsd-build/engine-darwin-x64": ">=2.10.2",
|
||||
"@gsd-build/engine-linux-x64-gnu": ">=2.10.2",
|
||||
"@gsd-build/engine-linux-arm64-gnu": ">=2.10.2",
|
||||
"@gsd-build/engine-linux-x64-gnu": ">=2.10.2",
|
||||
"@gsd-build/engine-win32-x64-msvc": ">=2.10.2",
|
||||
"fsevents": "~2.3.3",
|
||||
"koffi": "^2.9.0"
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json && npm run copy-assets",
|
||||
"copy-assets": "node -e \"const{mkdirSync,cpSync}=require('fs');mkdirSync('dist/modes/interactive/theme',{recursive:true});cpSync('src/modes/interactive/theme','dist/modes/interactive/theme',{recursive:true,filter:(s)=>!s.endsWith('.ts')});mkdirSync('dist/core/export-html/vendor',{recursive:true});cpSync('src/core/export-html/template.html','dist/core/export-html/template.html');cpSync('src/core/export-html/template.css','dist/core/export-html/template.css');cpSync('src/core/export-html/template.js','dist/core/export-html/template.js');cpSync('src/core/export-html/vendor','dist/core/export-html/vendor',{recursive:true,filter:(s)=>!s.endsWith('.ts')});mkdirSync('dist/core/lsp',{recursive:true});cpSync('src/core/lsp/defaults.json','dist/core/lsp/defaults.json');cpSync('src/core/lsp/lsp.md','dist/core/lsp/lsp.md')\""
|
||||
"copy-assets": "node scripts/copy-assets.cjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mariozechner/jiti": "^2.6.2",
|
||||
|
|
|
|||
24
packages/pi-coding-agent/scripts/copy-assets.cjs
Normal file
24
packages/pi-coding-agent/scripts/copy-assets.cjs
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
#!/usr/bin/env node
|
||||
const { mkdirSync, cpSync } = require('fs');
|
||||
|
||||
// Theme assets
|
||||
mkdirSync('dist/modes/interactive/theme', { recursive: true });
|
||||
cpSync('src/modes/interactive/theme', 'dist/modes/interactive/theme', {
|
||||
recursive: true,
|
||||
filter: (s) => !s.endsWith('.ts'),
|
||||
});
|
||||
|
||||
// Export HTML templates and vendor files
|
||||
mkdirSync('dist/core/export-html/vendor', { recursive: true });
|
||||
cpSync('src/core/export-html/template.html', 'dist/core/export-html/template.html');
|
||||
cpSync('src/core/export-html/template.css', 'dist/core/export-html/template.css');
|
||||
cpSync('src/core/export-html/template.js', 'dist/core/export-html/template.js');
|
||||
cpSync('src/core/export-html/vendor', 'dist/core/export-html/vendor', {
|
||||
recursive: true,
|
||||
filter: (s) => !s.endsWith('.ts'),
|
||||
});
|
||||
|
||||
// LSP defaults
|
||||
mkdirSync('dist/core/lsp', { recursive: true });
|
||||
cpSync('src/core/lsp/defaults.json', 'dist/core/lsp/defaults.json');
|
||||
cpSync('src/core/lsp/lsp.md', 'dist/core/lsp/lsp.md');
|
||||
|
|
@ -6,8 +6,11 @@ export type { SlashCommandInfo, SlashCommandLocation, SlashCommandSource } from
|
|||
export {
|
||||
createExtensionRuntime,
|
||||
discoverAndLoadExtensions,
|
||||
getUntrustedExtensionPaths,
|
||||
isProjectTrusted,
|
||||
loadExtensionFromFactory,
|
||||
loadExtensions,
|
||||
trustProject,
|
||||
} from "./loader.js";
|
||||
export type {
|
||||
ExtensionErrorListener,
|
||||
|
|
|
|||
141
packages/pi-coding-agent/src/core/extensions/loader.test.ts
Normal file
141
packages/pi-coding-agent/src/core/extensions/loader.test.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import { describe, it, beforeEach, afterEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { isProjectTrusted, trustProject, getUntrustedExtensionPaths } from "./project-trust.js";
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeTempDir(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), "loader-test-"));
|
||||
}
|
||||
|
||||
function cleanDir(dir: string): void {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// ─── isProjectTrusted ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("isProjectTrusted", () => {
|
||||
let agentDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
agentDir = makeTempDir();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanDir(agentDir);
|
||||
});
|
||||
|
||||
it("returns false when no trusted-projects.json exists", () => {
|
||||
assert.equal(isProjectTrusted("/some/project", agentDir), false);
|
||||
});
|
||||
|
||||
it("returns false for an untrusted project path", () => {
|
||||
trustProject("/trusted/project", agentDir);
|
||||
assert.equal(isProjectTrusted("/other/project", agentDir), false);
|
||||
});
|
||||
|
||||
it("returns true after trustProject is called for that path", () => {
|
||||
trustProject("/trusted/project", agentDir);
|
||||
assert.equal(isProjectTrusted("/trusted/project", agentDir), true);
|
||||
});
|
||||
|
||||
it("canonicalizes paths before comparison (trailing slash)", () => {
|
||||
trustProject("/my/project/", agentDir);
|
||||
assert.equal(isProjectTrusted("/my/project", agentDir), true);
|
||||
});
|
||||
|
||||
it("returns false when trusted-projects.json is malformed JSON", () => {
|
||||
fs.mkdirSync(agentDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(agentDir, "trusted-projects.json"), "not json");
|
||||
assert.equal(isProjectTrusted("/any/project", agentDir), false);
|
||||
});
|
||||
|
||||
it("returns false when trusted-projects.json contains non-array", () => {
|
||||
fs.mkdirSync(agentDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(agentDir, "trusted-projects.json"), JSON.stringify({ foo: "bar" }));
|
||||
assert.equal(isProjectTrusted("/any/project", agentDir), false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── trustProject ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("trustProject", () => {
|
||||
let agentDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
agentDir = makeTempDir();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanDir(agentDir);
|
||||
});
|
||||
|
||||
it("creates agentDir if it does not exist", () => {
|
||||
const nested = path.join(agentDir, "deeply", "nested");
|
||||
trustProject("/a/project", nested);
|
||||
assert.ok(fs.existsSync(nested));
|
||||
});
|
||||
|
||||
it("persists the trusted path to trusted-projects.json", () => {
|
||||
trustProject("/a/project", agentDir);
|
||||
const content = JSON.parse(fs.readFileSync(path.join(agentDir, "trusted-projects.json"), "utf-8"));
|
||||
assert.ok(Array.isArray(content));
|
||||
assert.ok(content.includes(path.resolve("/a/project")));
|
||||
});
|
||||
|
||||
it("accumulates multiple trusted projects", () => {
|
||||
trustProject("/project/one", agentDir);
|
||||
trustProject("/project/two", agentDir);
|
||||
const content = JSON.parse(fs.readFileSync(path.join(agentDir, "trusted-projects.json"), "utf-8"));
|
||||
assert.equal(content.length, 2);
|
||||
});
|
||||
|
||||
it("does not duplicate already-trusted paths", () => {
|
||||
trustProject("/project/one", agentDir);
|
||||
trustProject("/project/one", agentDir);
|
||||
const content = JSON.parse(fs.readFileSync(path.join(agentDir, "trusted-projects.json"), "utf-8"));
|
||||
assert.equal(content.length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getUntrustedExtensionPaths ───────────────────────────────────────────────
|
||||
|
||||
describe("getUntrustedExtensionPaths", () => {
|
||||
let agentDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
agentDir = makeTempDir();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanDir(agentDir);
|
||||
});
|
||||
|
||||
it("returns all paths when project is not trusted", () => {
|
||||
const paths = ["/proj/.pi/extensions/a.ts", "/proj/.pi/extensions/b.ts"];
|
||||
const result = getUntrustedExtensionPaths("/proj", paths, agentDir);
|
||||
assert.deepEqual(result, paths);
|
||||
});
|
||||
|
||||
it("returns empty array when project is trusted", () => {
|
||||
trustProject("/proj", agentDir);
|
||||
const paths = ["/proj/.pi/extensions/a.ts", "/proj/.pi/extensions/b.ts"];
|
||||
const result = getUntrustedExtensionPaths("/proj", paths, agentDir);
|
||||
assert.deepEqual(result, []);
|
||||
});
|
||||
|
||||
it("returns empty array when extension paths list is empty regardless of trust", () => {
|
||||
const result = getUntrustedExtensionPaths("/proj", [], agentDir);
|
||||
assert.deepEqual(result, []);
|
||||
});
|
||||
|
||||
it("trusting one project does not affect another", () => {
|
||||
trustProject("/project/a", agentDir);
|
||||
const paths = ["/project/b/.pi/extensions/evil.ts"];
|
||||
const result = getUntrustedExtensionPaths("/project/b", paths, agentDir);
|
||||
assert.deepEqual(result, paths);
|
||||
});
|
||||
});
|
||||
|
|
@ -27,6 +27,8 @@ import * as _bundledPiCodingAgent from "../../index.js";
|
|||
import { createEventBus, type EventBus } from "../event-bus.js";
|
||||
import type { ExecOptions } from "../exec.js";
|
||||
import { execCommand } from "../exec.js";
|
||||
import { getUntrustedExtensionPaths } from "./project-trust.js";
|
||||
export { isProjectTrusted, trustProject, getUntrustedExtensionPaths } from "./project-trust.js";
|
||||
import type {
|
||||
Extension,
|
||||
ExtensionAPI,
|
||||
|
|
@ -538,8 +540,19 @@ export async function discoverAndLoadExtensions(
|
|||
};
|
||||
|
||||
// 1. Project-local extensions: cwd/.pi/extensions/
|
||||
// Only loaded when the project path has been explicitly trusted (TOFU model).
|
||||
const localExtDir = path.join(cwd, ".pi", "extensions");
|
||||
addPaths(discoverExtensionsInDir(localExtDir));
|
||||
const localDiscovered = discoverExtensionsInDir(localExtDir);
|
||||
if (localDiscovered.length > 0) {
|
||||
const untrusted = getUntrustedExtensionPaths(cwd, localDiscovered, agentDir);
|
||||
if (untrusted.length > 0) {
|
||||
process.stderr.write(
|
||||
`[pi] Skipping ${untrusted.length} project-local extension(s) in ${localExtDir} — project not trusted. Use trustProject() to enable.\n`,
|
||||
);
|
||||
}
|
||||
const trusted = localDiscovered.filter((p) => !untrusted.includes(p));
|
||||
addPaths(trusted);
|
||||
}
|
||||
|
||||
// 2. Global extensions: agentDir/extensions/
|
||||
const globalExtDir = path.join(agentDir, "extensions");
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
const TRUSTED_PROJECTS_FILE = "trusted-projects.json";
|
||||
|
||||
function getTrustedProjectsPath(agentDir: string): string {
|
||||
return path.join(agentDir, TRUSTED_PROJECTS_FILE);
|
||||
}
|
||||
|
||||
function readTrustedProjects(agentDir: string): Set<string> {
|
||||
const filePath = getTrustedProjectsPath(agentDir);
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, "utf-8");
|
||||
const parsed = JSON.parse(content);
|
||||
if (Array.isArray(parsed)) {
|
||||
return new Set(parsed.filter((p) => typeof p === "string"));
|
||||
}
|
||||
} catch {
|
||||
// File missing or malformed — start with empty set
|
||||
}
|
||||
return new Set();
|
||||
}
|
||||
|
||||
function writeTrustedProjects(agentDir: string, trusted: Set<string>): void {
|
||||
const filePath = getTrustedProjectsPath(agentDir);
|
||||
fs.mkdirSync(agentDir, { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify([...trusted], null, 2), "utf-8");
|
||||
}
|
||||
|
||||
export function isProjectTrusted(projectPath: string, agentDir: string): boolean {
|
||||
const canonical = path.resolve(projectPath);
|
||||
return readTrustedProjects(agentDir).has(canonical);
|
||||
}
|
||||
|
||||
export function trustProject(projectPath: string, agentDir: string): void {
|
||||
const canonical = path.resolve(projectPath);
|
||||
const trusted = readTrustedProjects(agentDir);
|
||||
trusted.add(canonical);
|
||||
writeTrustedProjects(agentDir, trusted);
|
||||
}
|
||||
|
||||
export function getUntrustedExtensionPaths(
|
||||
projectPath: string,
|
||||
extensionPaths: string[],
|
||||
agentDir: string,
|
||||
): string[] {
|
||||
if (isProjectTrusted(projectPath, agentDir)) {
|
||||
return [];
|
||||
}
|
||||
return extensionPaths;
|
||||
}
|
||||
132
packages/pi-coding-agent/src/core/resolve-config-value.test.ts
Normal file
132
packages/pi-coding-agent/src/core/resolve-config-value.test.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import { describe, it, beforeEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
resolveConfigValue,
|
||||
clearConfigValueCache,
|
||||
SAFE_COMMAND_PREFIXES,
|
||||
} from "./resolve-config-value.js";
|
||||
|
||||
beforeEach(() => {
|
||||
clearConfigValueCache();
|
||||
});
|
||||
|
||||
describe("SAFE_COMMAND_PREFIXES", () => {
|
||||
it("exports the allowlist array", () => {
|
||||
assert.ok(Array.isArray(SAFE_COMMAND_PREFIXES));
|
||||
assert.ok(SAFE_COMMAND_PREFIXES.length > 0);
|
||||
});
|
||||
|
||||
it("includes expected credential tools", () => {
|
||||
assert.ok(SAFE_COMMAND_PREFIXES.includes("pass"));
|
||||
assert.ok(SAFE_COMMAND_PREFIXES.includes("op"));
|
||||
assert.ok(SAFE_COMMAND_PREFIXES.includes("aws"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveConfigValue — non-command values", () => {
|
||||
it("returns the literal value when it does not match an env var", () => {
|
||||
const result = resolveConfigValue("my-literal-key");
|
||||
assert.equal(result, "my-literal-key");
|
||||
});
|
||||
|
||||
it("returns the env var value when the config matches an env var name", () => {
|
||||
process.env["TEST_RESOLVE_CONFIG_VAR"] = "env-value";
|
||||
const result = resolveConfigValue("TEST_RESOLVE_CONFIG_VAR");
|
||||
assert.equal(result, "env-value");
|
||||
delete process.env["TEST_RESOLVE_CONFIG_VAR"];
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveConfigValue — command allowlist enforcement", () => {
|
||||
it("blocks a disallowed command and returns undefined", () => {
|
||||
const stderrChunks: string[] = [];
|
||||
const originalWrite = process.stderr.write.bind(process.stderr);
|
||||
process.stderr.write = (chunk: string | Uint8Array, ...args: unknown[]) => {
|
||||
stderrChunks.push(chunk.toString());
|
||||
return true;
|
||||
};
|
||||
|
||||
try {
|
||||
const result = resolveConfigValue("!curl http://evil.com");
|
||||
assert.equal(result, undefined);
|
||||
assert.ok(stderrChunks.some((line) => line.includes("curl")));
|
||||
} finally {
|
||||
process.stderr.write = originalWrite;
|
||||
}
|
||||
});
|
||||
|
||||
it("blocks another disallowed command (rm)", () => {
|
||||
const result = resolveConfigValue("!rm -rf /tmp/test");
|
||||
assert.equal(result, undefined);
|
||||
});
|
||||
|
||||
it("blocks a disallowed command with no arguments", () => {
|
||||
const result = resolveConfigValue("!wget");
|
||||
assert.equal(result, undefined);
|
||||
});
|
||||
|
||||
it("allows a safe command prefix to proceed to execution", () => {
|
||||
// `pass` is unlikely to be installed in CI, so we just verify it does NOT
|
||||
// return undefined due to the allowlist check — it may return undefined if
|
||||
// the binary is absent, but the block path must not be taken.
|
||||
// We confirm by checking no "Blocked" message appears on stderr.
|
||||
const stderrChunks: string[] = [];
|
||||
const originalWrite = process.stderr.write.bind(process.stderr);
|
||||
process.stderr.write = (chunk: string | Uint8Array, ...args: unknown[]) => {
|
||||
stderrChunks.push(chunk.toString());
|
||||
return true;
|
||||
};
|
||||
|
||||
try {
|
||||
resolveConfigValue("!pass show nonexistent-entry-for-test");
|
||||
const blocked = stderrChunks.some((line) =>
|
||||
line.includes("Blocked disallowed command")
|
||||
);
|
||||
assert.equal(blocked, false, "pass should not be blocked by the allowlist");
|
||||
} finally {
|
||||
process.stderr.write = originalWrite;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveConfigValue — caching", () => {
|
||||
it("caches the result of a blocked command", () => {
|
||||
const callCount = { n: 0 };
|
||||
const originalWrite = process.stderr.write.bind(process.stderr);
|
||||
process.stderr.write = (chunk: string | Uint8Array, ...args: unknown[]) => {
|
||||
callCount.n++;
|
||||
return true;
|
||||
};
|
||||
|
||||
try {
|
||||
resolveConfigValue("!curl http://evil.com");
|
||||
resolveConfigValue("!curl http://evil.com");
|
||||
// The block warning should only fire once; the second call hits the cache
|
||||
// before reaching the allowlist check, so stderr count is 1.
|
||||
assert.equal(callCount.n, 1);
|
||||
} finally {
|
||||
process.stderr.write = originalWrite;
|
||||
}
|
||||
});
|
||||
|
||||
it("clearConfigValueCache resets cached entries", () => {
|
||||
const stderrChunks: string[] = [];
|
||||
const originalWrite = process.stderr.write.bind(process.stderr);
|
||||
process.stderr.write = (chunk: string | Uint8Array, ...args: unknown[]) => {
|
||||
stderrChunks.push(chunk.toString());
|
||||
return true;
|
||||
};
|
||||
|
||||
try {
|
||||
resolveConfigValue("!curl http://evil.com");
|
||||
assert.equal(stderrChunks.length, 1);
|
||||
|
||||
clearConfigValueCache();
|
||||
|
||||
resolveConfigValue("!curl http://evil.com");
|
||||
assert.equal(stderrChunks.length, 2);
|
||||
} finally {
|
||||
process.stderr.write = originalWrite;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -8,6 +8,19 @@ import { execSync } from "child_process";
|
|||
// Cache for shell command results (persists for process lifetime)
|
||||
const commandResultCache = new Map<string, string | undefined>();
|
||||
|
||||
export const SAFE_COMMAND_PREFIXES = [
|
||||
"pass",
|
||||
"op",
|
||||
"aws",
|
||||
"gcloud",
|
||||
"vault",
|
||||
"security",
|
||||
"gpg",
|
||||
"bw",
|
||||
"gopass",
|
||||
"lpass",
|
||||
];
|
||||
|
||||
/**
|
||||
* Resolve a config value (API key, header value, etc.) to an actual value.
|
||||
* - If starts with "!", executes the rest as a shell command and uses stdout (cached)
|
||||
|
|
@ -27,6 +40,13 @@ function executeCommand(commandConfig: string): string | undefined {
|
|||
}
|
||||
|
||||
const command = commandConfig.slice(1);
|
||||
const firstToken = command.split(/\s+/)[0];
|
||||
if (!SAFE_COMMAND_PREFIXES.includes(firstToken)) {
|
||||
process.stderr.write(`[resolve-config-value] Blocked disallowed command: "${firstToken}". Allowed: ${SAFE_COMMAND_PREFIXES.join(", ")}\n`);
|
||||
commandResultCache.set(commandConfig, undefined);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let result: string | undefined;
|
||||
try {
|
||||
const output = execSync(command, {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,98 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { describe, it, mock } from "node:test";
|
||||
import { mkdtempSync, rmSync, readFileSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
import { MemoryStorage } from "./storage.js";
|
||||
|
||||
function makeTmpDir(): string {
|
||||
return mkdtempSync(join(tmpdir(), "gsd-memory-storage-test-"));
|
||||
}
|
||||
|
||||
function wait(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
describe("MemoryStorage debounced persistence", () => {
|
||||
it("multiple rapid mutations only trigger one persist write", async () => {
|
||||
const dir = makeTmpDir();
|
||||
const dbPath = join(dir, "test.db");
|
||||
try {
|
||||
const storage = await MemoryStorage.create(dbPath);
|
||||
|
||||
const initialStat = readFileSync(dbPath);
|
||||
const initialMtime = initialStat.length;
|
||||
|
||||
storage.upsertThreads([
|
||||
{ threadId: "t1", filePath: "/a.txt", fileSize: 100, fileMtime: 1000, cwd: "/proj" },
|
||||
]);
|
||||
storage.upsertThreads([
|
||||
{ threadId: "t2", filePath: "/b.txt", fileSize: 200, fileMtime: 2000, cwd: "/proj" },
|
||||
]);
|
||||
storage.upsertThreads([
|
||||
{ threadId: "t3", filePath: "/c.txt", fileSize: 300, fileMtime: 3000, cwd: "/proj" },
|
||||
]);
|
||||
|
||||
const afterMutationsBuf = readFileSync(dbPath);
|
||||
assert.deepEqual(
|
||||
afterMutationsBuf,
|
||||
initialStat,
|
||||
"File should not have been written yet (debounce window has not elapsed)",
|
||||
);
|
||||
|
||||
await wait(700);
|
||||
|
||||
const afterDebounceBuf = readFileSync(dbPath);
|
||||
assert.notDeepEqual(
|
||||
afterDebounceBuf,
|
||||
initialStat,
|
||||
"File should have been written after debounce window elapsed",
|
||||
);
|
||||
|
||||
const stats = storage.getStats();
|
||||
assert.equal(stats.totalThreads, 3);
|
||||
|
||||
storage.close();
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("close() flushes pending changes immediately without waiting for debounce", async () => {
|
||||
const dir = makeTmpDir();
|
||||
const dbPath = join(dir, "test.db");
|
||||
try {
|
||||
const storage = await MemoryStorage.create(dbPath);
|
||||
|
||||
const initialBuf = readFileSync(dbPath);
|
||||
|
||||
storage.upsertThreads([
|
||||
{ threadId: "t1", filePath: "/a.txt", fileSize: 100, fileMtime: 1000, cwd: "/proj" },
|
||||
]);
|
||||
|
||||
const beforeCloseBuf = readFileSync(dbPath);
|
||||
assert.deepEqual(
|
||||
beforeCloseBuf,
|
||||
initialBuf,
|
||||
"File should not have been written yet (debounce window has not elapsed)",
|
||||
);
|
||||
|
||||
storage.close();
|
||||
|
||||
const afterCloseBuf = readFileSync(dbPath);
|
||||
assert.notDeepEqual(
|
||||
afterCloseBuf,
|
||||
initialBuf,
|
||||
"File should have been written immediately on close()",
|
||||
);
|
||||
|
||||
const reopened = await MemoryStorage.create(dbPath);
|
||||
const stats = reopened.getStats();
|
||||
assert.equal(stats.totalThreads, 1, "Data should be persisted and readable after close");
|
||||
reopened.close();
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -46,6 +46,7 @@ export interface JobRow {
|
|||
export class MemoryStorage {
|
||||
private db: SqlJsDatabase;
|
||||
private dbPath: string;
|
||||
private persistTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
private constructor(db: SqlJsDatabase, dbPath: string) {
|
||||
this.db = db;
|
||||
|
|
@ -76,6 +77,16 @@ export class MemoryStorage {
|
|||
writeFileSync(this.dbPath, Buffer.from(data));
|
||||
}
|
||||
|
||||
private schedulePersist(): void {
|
||||
if (this.persistTimer) {
|
||||
clearTimeout(this.persistTimer);
|
||||
}
|
||||
this.persistTimer = setTimeout(() => {
|
||||
this.persistTimer = null;
|
||||
this.persist();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
private initSchema(): void {
|
||||
this.db.run(`
|
||||
CREATE TABLE IF NOT EXISTS threads (
|
||||
|
|
@ -184,7 +195,7 @@ export class MemoryStorage {
|
|||
}
|
||||
}
|
||||
|
||||
this.persist();
|
||||
this.schedulePersist();
|
||||
return { inserted, updated, skipped };
|
||||
}
|
||||
|
||||
|
|
@ -221,7 +232,7 @@ export class MemoryStorage {
|
|||
[token],
|
||||
);
|
||||
|
||||
this.persist();
|
||||
this.schedulePersist();
|
||||
|
||||
return rows.map((r) => ({
|
||||
jobId: r.id,
|
||||
|
|
@ -246,7 +257,7 @@ export class MemoryStorage {
|
|||
"UPDATE threads SET status = 'done', updated_at = datetime('now') WHERE thread_id = ?",
|
||||
[threadId],
|
||||
);
|
||||
this.persist();
|
||||
this.schedulePersist();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -261,7 +272,7 @@ export class MemoryStorage {
|
|||
"UPDATE threads SET status = 'error', error_message = ?, updated_at = datetime('now') WHERE thread_id = ?",
|
||||
[errorMessage, threadId],
|
||||
);
|
||||
this.persist();
|
||||
this.schedulePersist();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -305,7 +316,7 @@ export class MemoryStorage {
|
|||
[jobId, workerId, token, expiresAt],
|
||||
);
|
||||
|
||||
this.persist();
|
||||
this.schedulePersist();
|
||||
return { jobId, ownershipToken: token };
|
||||
}
|
||||
|
||||
|
|
@ -317,7 +328,7 @@ export class MemoryStorage {
|
|||
"UPDATE jobs SET status = 'done', updated_at = datetime('now') WHERE id = ? AND phase = 'stage2'",
|
||||
[jobId],
|
||||
);
|
||||
this.persist();
|
||||
this.schedulePersist();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -406,7 +417,7 @@ export class MemoryStorage {
|
|||
this.db.run("DELETE FROM stage1_outputs");
|
||||
this.db.run("DELETE FROM jobs");
|
||||
this.db.run("DELETE FROM threads");
|
||||
this.persist();
|
||||
this.schedulePersist();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -422,7 +433,7 @@ export class MemoryStorage {
|
|||
[cwd],
|
||||
);
|
||||
this.db.run("DELETE FROM threads WHERE cwd = ?", [cwd]);
|
||||
this.persist();
|
||||
this.schedulePersist();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -453,10 +464,14 @@ export class MemoryStorage {
|
|||
[randomUUID(), t.thread_id],
|
||||
);
|
||||
}
|
||||
this.persist();
|
||||
this.schedulePersist();
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (this.persistTimer) {
|
||||
clearTimeout(this.persistTimer);
|
||||
this.persistTimer = null;
|
||||
}
|
||||
this.persist();
|
||||
this.db.close();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,12 +9,14 @@
|
|||
"build": "tsc -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/mime-types": "^2.1.4",
|
||||
"chalk": "^5.5.0",
|
||||
"get-east-asian-width": "^1.3.0",
|
||||
"marked": "^15.0.12",
|
||||
"mime-types": "^3.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/mime-types": "^2.1.4"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"koffi": "^2.9.0"
|
||||
}
|
||||
|
|
|
|||
6
scripts/copy-export-html.cjs
Normal file
6
scripts/copy-export-html.cjs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
#!/usr/bin/env node
|
||||
const { mkdirSync, cpSync } = require('fs');
|
||||
const { resolve } = require('path');
|
||||
const src = resolve(__dirname, '..', 'packages', 'pi-coding-agent', 'dist', 'core', 'export-html');
|
||||
mkdirSync('pkg/dist/core/export-html', { recursive: true });
|
||||
cpSync(src, 'pkg/dist/core/export-html', { recursive: true });
|
||||
4
scripts/copy-resources.cjs
Normal file
4
scripts/copy-resources.cjs
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
#!/usr/bin/env node
|
||||
const { cpSync, rmSync } = require('fs');
|
||||
rmSync('dist/resources', { recursive: true, force: true });
|
||||
cpSync('src/resources', 'dist/resources', { recursive: true, force: true });
|
||||
6
scripts/copy-themes.cjs
Normal file
6
scripts/copy-themes.cjs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
#!/usr/bin/env node
|
||||
const { mkdirSync, cpSync } = require('fs');
|
||||
const { resolve } = require('path');
|
||||
const src = resolve(__dirname, '..', 'packages', 'pi-coding-agent', 'dist', 'modes', 'interactive', 'theme');
|
||||
mkdirSync('pkg/dist/modes/interactive/theme', { recursive: true });
|
||||
cpSync(src, 'pkg/dist/modes/interactive/theme', { recursive: true });
|
||||
|
|
@ -19,7 +19,7 @@ const procs = [
|
|||
spawn('node', [resolve(__dirname, 'watch-resources.js')], {
|
||||
cwd: root, stdio: 'inherit'
|
||||
}),
|
||||
spawn('npx', ['tsc', '--watch'], {
|
||||
spawn(resolve(root, 'node_modules', '.bin', 'tsc'), ['--watch'], {
|
||||
cwd: root, stdio: 'inherit'
|
||||
})
|
||||
]
|
||||
|
|
|
|||
116
scripts/validate-pack.js
Normal file
116
scripts/validate-pack.js
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
// validate-pack.js — Verify the npm tarball is installable before publishing.
|
||||
//
|
||||
// Usage: npm run validate-pack (or node scripts/validate-pack.js)
|
||||
// Exit 0 = safe to publish, Exit 1 = broken package.
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const ROOT = resolve(__dirname, '..');
|
||||
|
||||
let tarball = null;
|
||||
let installDir = null;
|
||||
|
||||
try {
|
||||
// --- Guard: workspace packages must not have @gsd/* cross-deps ---
|
||||
console.log('==> Checking workspace packages for @gsd/* cross-deps...');
|
||||
const workspaces = ['native', 'pi-agent-core', 'pi-ai', 'pi-coding-agent', 'pi-tui'];
|
||||
let crossFailed = false;
|
||||
|
||||
for (const ws of workspaces) {
|
||||
const pkgPath = join(ROOT, 'packages', ws, 'package.json');
|
||||
if (!existsSync(pkgPath)) continue;
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
||||
const deps = Object.keys(pkg.dependencies || {}).filter(d => d.startsWith('@gsd/'));
|
||||
if (deps.length) {
|
||||
console.log(` LEAKED in ${ws}: ${deps.join(', ')}`);
|
||||
crossFailed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (crossFailed) {
|
||||
console.log('ERROR: Workspace packages have @gsd/* cross-dependencies.');
|
||||
console.log(' These cause 404s when npm resolves them from the registry.');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(' No @gsd/* cross-dependencies.');
|
||||
|
||||
// --- Pack tarball ---
|
||||
console.log('==> Packing tarball...');
|
||||
const packOutput = execSync('npm pack --ignore-scripts', {
|
||||
cwd: ROOT,
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
const tarballName = packOutput.trim().split('\n').pop();
|
||||
tarball = join(ROOT, tarballName);
|
||||
|
||||
if (!existsSync(tarball)) {
|
||||
console.log('ERROR: npm pack produced no tarball');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const stats = execSync(`du -h "${tarball}"`, { encoding: 'utf8' }).split('\t')[0].trim();
|
||||
console.log(`==> Tarball: ${tarballName} (${stats} compressed)`);
|
||||
|
||||
// --- Check critical files using tar listing ---
|
||||
console.log('==> Checking critical files...');
|
||||
const tarList = execSync(`tar tzf "${tarball}"`, { encoding: 'utf8', maxBuffer: 50 * 1024 * 1024 });
|
||||
|
||||
const requiredFiles = [
|
||||
'dist/loader.js',
|
||||
'packages/pi-coding-agent/dist/index.js',
|
||||
'scripts/link-workspace-packages.cjs',
|
||||
];
|
||||
|
||||
let missing = false;
|
||||
for (const required of requiredFiles) {
|
||||
if (!tarList.includes(`package/${required}`)) {
|
||||
console.log(` MISSING: ${required}`);
|
||||
missing = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (missing) {
|
||||
console.log('ERROR: Critical files missing from tarball.');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(' Critical files present.');
|
||||
|
||||
// --- Install test ---
|
||||
console.log('==> Testing install in isolated directory...');
|
||||
installDir = mkdtempSync(join(tmpdir(), 'validate-pack-'));
|
||||
writeFileSync(join(installDir, 'package.json'), JSON.stringify({ name: 'test-install', version: '1.0.0', private: true }, null, 2));
|
||||
|
||||
try {
|
||||
const installOutput = execSync(`npm install "${tarball}"`, {
|
||||
cwd: installDir,
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
console.log(installOutput);
|
||||
console.log('==> Install succeeded.');
|
||||
} catch (err) {
|
||||
console.log('');
|
||||
console.log('ERROR: npm install of tarball failed.');
|
||||
if (err.stdout) console.log(err.stdout);
|
||||
if (err.stderr) console.log(err.stderr);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log('Package is installable. Safe to publish.');
|
||||
process.exit(0);
|
||||
} finally {
|
||||
if (installDir && existsSync(installDir)) {
|
||||
rmSync(installDir, { recursive: true, force: true });
|
||||
}
|
||||
if (tarball && existsSync(tarball)) {
|
||||
rmSync(tarball, { force: true });
|
||||
}
|
||||
}
|
||||
18
src/cli.ts
18
src/cli.ts
|
|
@ -19,6 +19,7 @@ import { getPiDefaultModelAndProvider, migratePiCredentials } from './pi-migrati
|
|||
import { shouldRunOnboarding, runOnboarding } from './onboarding.js'
|
||||
import chalk from 'chalk'
|
||||
import { checkForUpdates } from './update-check.js'
|
||||
import { printHelp } from './help-text.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal CLI arg parser — detects print/subagent mode flags
|
||||
|
|
@ -79,22 +80,7 @@ function parseCliArgs(argv: string[]): CliFlags {
|
|||
process.stdout.write((process.env.GSD_VERSION || '0.0.0') + '\n')
|
||||
process.exit(0)
|
||||
} else if (arg === '--help' || arg === '-h') {
|
||||
process.stdout.write(`GSD v${process.env.GSD_VERSION || '0.0.0'} — Get Shit Done\n\n`)
|
||||
process.stdout.write('Usage: gsd [options] [message...]\n\n')
|
||||
process.stdout.write('Options:\n')
|
||||
process.stdout.write(' --mode <text|json|rpc> Output mode (default: interactive)\n')
|
||||
process.stdout.write(' --print, -p Single-shot print mode\n')
|
||||
process.stdout.write(' --continue, -c Resume the most recent session\n')
|
||||
process.stdout.write(' --model <id> Override model (e.g. claude-opus-4-6)\n')
|
||||
process.stdout.write(' --no-session Disable session persistence\n')
|
||||
process.stdout.write(' --extension <path> Load additional extension\n')
|
||||
process.stdout.write(' --tools <a,b,c> Restrict available tools\n')
|
||||
process.stdout.write(' --list-models [search] List available models and exit\n')
|
||||
process.stdout.write(' --version, -v Print version and exit\n')
|
||||
process.stdout.write(' --help, -h Print this help and exit\n')
|
||||
process.stdout.write('\nSubcommands:\n')
|
||||
process.stdout.write(' config Re-run the setup wizard\n')
|
||||
process.stdout.write(' update Update GSD to the latest version\n')
|
||||
printHelp(process.env.GSD_VERSION || '0.0.0')
|
||||
process.exit(0)
|
||||
} else if (!arg.startsWith('--') && !arg.startsWith('-')) {
|
||||
flags.messages.push(arg)
|
||||
|
|
|
|||
18
src/help-text.ts
Normal file
18
src/help-text.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
export function printHelp(version: string): void {
|
||||
process.stdout.write(`GSD v${version} — Get Shit Done\n\n`)
|
||||
process.stdout.write('Usage: gsd [options] [message...]\n\n')
|
||||
process.stdout.write('Options:\n')
|
||||
process.stdout.write(' --mode <text|json|rpc> Output mode (default: interactive)\n')
|
||||
process.stdout.write(' --print, -p Single-shot print mode\n')
|
||||
process.stdout.write(' --continue, -c Resume the most recent session\n')
|
||||
process.stdout.write(' --model <id> Override model (e.g. claude-opus-4-6)\n')
|
||||
process.stdout.write(' --no-session Disable session persistence\n')
|
||||
process.stdout.write(' --extension <path> Load additional extension\n')
|
||||
process.stdout.write(' --tools <a,b,c> Restrict available tools\n')
|
||||
process.stdout.write(' --list-models [search] List available models and exit\n')
|
||||
process.stdout.write(' --version, -v Print version and exit\n')
|
||||
process.stdout.write(' --help, -h Print this help and exit\n')
|
||||
process.stdout.write('\nSubcommands:\n')
|
||||
process.stdout.write(' config Re-run the setup wizard\n')
|
||||
process.stdout.write(' update Update GSD to the latest version\n')
|
||||
}
|
||||
|
|
@ -28,22 +28,8 @@ if (firstArg === '--help' || firstArg === '-h') {
|
|||
const pkg = JSON.parse(readFileSync(join(gsdRoot, 'package.json'), 'utf-8'))
|
||||
version = pkg.version || version
|
||||
} catch { /* ignore */ }
|
||||
process.stdout.write(`GSD v${version} — Get Shit Done\n\n`)
|
||||
process.stdout.write('Usage: gsd [options] [message...]\n\n')
|
||||
process.stdout.write('Options:\n')
|
||||
process.stdout.write(' --mode <text|json|rpc> Output mode (default: interactive)\n')
|
||||
process.stdout.write(' --print, -p Single-shot print mode\n')
|
||||
process.stdout.write(' --continue, -c Resume the most recent session\n')
|
||||
process.stdout.write(' --model <id> Override model (e.g. claude-opus-4-6)\n')
|
||||
process.stdout.write(' --no-session Disable session persistence\n')
|
||||
process.stdout.write(' --extension <path> Load additional extension\n')
|
||||
process.stdout.write(' --tools <a,b,c> Restrict available tools\n')
|
||||
process.stdout.write(' --list-models [search] List available models and exit\n')
|
||||
process.stdout.write(' --version, -v Print version and exit\n')
|
||||
process.stdout.write(' --help, -h Print this help and exit\n')
|
||||
process.stdout.write('\nSubcommands:\n')
|
||||
process.stdout.write(' config Re-run the setup wizard\n')
|
||||
process.stdout.write(' update Update GSD to the latest version\n')
|
||||
const { printHelp } = await import('./help-text.js')
|
||||
printHelp(version)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -933,32 +933,3 @@ async function runDiscordChannelStep(p: ClackModule, pc: PicoModule, token: stri
|
|||
return channelName ?? null
|
||||
}
|
||||
|
||||
// ─── Env hydration (migrated from wizard.ts) ─────────────────────────────────
|
||||
|
||||
/**
|
||||
* Hydrate process.env from stored auth.json credentials for optional tool keys.
|
||||
* Runs on every launch so extensions see Brave/Context7/Jina keys stored via the
|
||||
* wizard on prior launches.
|
||||
*/
|
||||
export function loadStoredEnvKeys(authStorage: AuthStorage): void {
|
||||
const providers: Array<[string, string]> = [
|
||||
['brave', 'BRAVE_API_KEY'],
|
||||
['brave_answers', 'BRAVE_ANSWERS_KEY'],
|
||||
['context7', 'CONTEXT7_API_KEY'],
|
||||
['jina', 'JINA_API_KEY'],
|
||||
['slack_bot', 'SLACK_BOT_TOKEN'],
|
||||
['discord_bot', 'DISCORD_BOT_TOKEN'],
|
||||
['telegram_bot', 'TELEGRAM_BOT_TOKEN'],
|
||||
['groq', 'GROQ_API_KEY'],
|
||||
['ollama-cloud', 'OLLAMA_API_KEY'],
|
||||
['custom-openai', 'CUSTOM_OPENAI_API_KEY'],
|
||||
]
|
||||
for (const [provider, envVar] of providers) {
|
||||
if (!process.env[envVar]) {
|
||||
const cred = authStorage.get(provider)
|
||||
if (cred?.type === 'api_key' && cred.key) {
|
||||
process.env[envVar] = cred.key
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
205
src/resources/extensions/browser-tools/core.d.ts
vendored
205
src/resources/extensions/browser-tools/core.d.ts
vendored
|
|
@ -1,205 +0,0 @@
|
|||
/**
|
||||
* Type declarations for core.js — runtime-neutral helper logic for browser-tools.
|
||||
*/
|
||||
|
||||
export interface ActionTimeline {
|
||||
limit: number;
|
||||
nextId: number;
|
||||
entries: ActionEntry[];
|
||||
}
|
||||
|
||||
export interface ActionEntry {
|
||||
id: number;
|
||||
tool: string;
|
||||
paramsSummary: string;
|
||||
startedAt: number;
|
||||
finishedAt: number | null;
|
||||
status: string;
|
||||
beforeUrl: string;
|
||||
afterUrl: string;
|
||||
verificationSummary?: string;
|
||||
warningSummary?: string;
|
||||
diffSummary?: string;
|
||||
changed?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ActionPartial {
|
||||
tool: string;
|
||||
paramsSummary?: string;
|
||||
startedAt?: number;
|
||||
beforeUrl?: string;
|
||||
afterUrl?: string;
|
||||
verificationSummary?: string;
|
||||
warningSummary?: string;
|
||||
diffSummary?: string;
|
||||
changed?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ActionUpdates {
|
||||
finishedAt?: number;
|
||||
status?: string;
|
||||
afterUrl?: string;
|
||||
verificationSummary?: string;
|
||||
warningSummary?: string;
|
||||
diffSummary?: string;
|
||||
changed?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface DiffResult {
|
||||
changed: boolean;
|
||||
changes: Array<{ type: string; before: unknown; after: unknown }>;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export interface Threshold {
|
||||
op: string;
|
||||
n: number;
|
||||
}
|
||||
|
||||
export interface PageRegistry {
|
||||
pages: PageEntry[];
|
||||
activePageId: number | null;
|
||||
nextId: number;
|
||||
}
|
||||
|
||||
export interface PageEntry {
|
||||
id: number;
|
||||
page: any;
|
||||
title: string;
|
||||
url: string;
|
||||
opener: number | null;
|
||||
}
|
||||
|
||||
export interface PageListEntry {
|
||||
id: number;
|
||||
title: string;
|
||||
url: string;
|
||||
opener: number | null;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface SnapshotModeConfig {
|
||||
tags: string[];
|
||||
roles: string[];
|
||||
selectors: string[];
|
||||
ariaAttributes: string[];
|
||||
useInteractiveFilter: boolean;
|
||||
visibleOnly?: boolean;
|
||||
containerExpand?: boolean;
|
||||
}
|
||||
|
||||
export interface AssertionCheckResult {
|
||||
name: string;
|
||||
passed: boolean;
|
||||
actual: unknown;
|
||||
expected: unknown;
|
||||
selector?: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export interface AssertionEvaluation {
|
||||
verified: boolean;
|
||||
checks: AssertionCheckResult[];
|
||||
summary: string;
|
||||
agentHint: string;
|
||||
}
|
||||
|
||||
export interface WaitValidationError {
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface BatchStepResult {
|
||||
ok: boolean;
|
||||
stopReason: string | null;
|
||||
failedStepIndex: number | null;
|
||||
stepResults: unknown[];
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export interface FormattedTimeline {
|
||||
entries: Array<{
|
||||
id: number | null;
|
||||
tool: string;
|
||||
status: string;
|
||||
durationMs: number | null;
|
||||
beforeUrl: string;
|
||||
afterUrl: string;
|
||||
line: string;
|
||||
}>;
|
||||
retained: number;
|
||||
totalRecorded: number;
|
||||
bounded: boolean;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export interface FailureHypothesis {
|
||||
hasFailures: boolean;
|
||||
categories: string[];
|
||||
summary: string;
|
||||
signals: Array<{ category: string; source: string; detail: string }>;
|
||||
}
|
||||
|
||||
export interface SessionSummary {
|
||||
counts: {
|
||||
pages: number;
|
||||
actions: { total: number; retained: number; success: number; error: number; running: number };
|
||||
waits: { total: number; success: number; error: number; running: number };
|
||||
assertions: { total: number; passed: number; failed: number; running: number };
|
||||
consoleErrors: number;
|
||||
failedRequests: number;
|
||||
dialogs: number;
|
||||
};
|
||||
activePage: { id: number | null; title: string; url: string } | null;
|
||||
caveats: string[];
|
||||
failureHypothesis: FailureHypothesis;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export function createActionTimeline(limit?: number): ActionTimeline;
|
||||
export function beginAction(timeline: ActionTimeline, partial: ActionPartial): ActionEntry;
|
||||
export function finishAction(timeline: ActionTimeline, actionId: number, updates?: ActionUpdates): ActionEntry | null;
|
||||
export function findAction(timeline: ActionTimeline, actionId: number): ActionEntry | null;
|
||||
export function toActionParamsSummary(params: unknown): string;
|
||||
export function diffCompactStates(before: unknown, after: unknown): DiffResult;
|
||||
export function includesNeedle(haystack: string, needle: string): boolean;
|
||||
export function parseThreshold(value: string | null | undefined): Threshold | null;
|
||||
export function meetsThreshold(count: number, threshold: Threshold): boolean;
|
||||
export function getEntriesSince(
|
||||
entries: Array<{ timestamp?: number }>,
|
||||
sinceActionId: number | undefined,
|
||||
timeline: ActionTimeline,
|
||||
): unknown[];
|
||||
export function evaluateAssertionChecks(args: { checks: unknown[]; state: unknown }): AssertionEvaluation;
|
||||
export function validateWaitParams(params: { condition: string; value?: string; threshold?: string }): WaitValidationError | null;
|
||||
export function createRegionStableScript(selector: string): string;
|
||||
export function createPageRegistry(): PageRegistry;
|
||||
export function registryAddPage(
|
||||
registry: PageRegistry,
|
||||
info: { page: unknown; title?: string; url?: string; opener?: number | null },
|
||||
): PageEntry;
|
||||
export function registryRemovePage(registry: PageRegistry, pageId: number): { removed: PageEntry; newActiveId: number | null };
|
||||
export function registrySetActive(registry: PageRegistry, pageId: number): void;
|
||||
export function registryGetActive(registry: PageRegistry): PageEntry;
|
||||
export function registryGetPage(registry: PageRegistry, pageId: number): PageEntry | null;
|
||||
export function registryListPages(registry: PageRegistry): PageListEntry[];
|
||||
export function createBoundedLogPusher(maxSize: number): (array: unknown[], entry: unknown) => void;
|
||||
export function runBatchSteps(args: {
|
||||
steps: unknown[];
|
||||
executeStep: (step: unknown, index: number) => Promise<{ ok: boolean; [key: string]: unknown }>;
|
||||
stopOnFailure?: boolean;
|
||||
}): Promise<BatchStepResult>;
|
||||
|
||||
export declare const SNAPSHOT_MODES: Record<string, SnapshotModeConfig>;
|
||||
export function getSnapshotModeConfig(mode: string): SnapshotModeConfig | null;
|
||||
export function computeContentHash(text: string): string;
|
||||
export function computeStructuralSignature(tag: string, role: string, childTags: string[]): string;
|
||||
export function matchFingerprint(
|
||||
stored: { contentHash?: string; structuralSignature?: string },
|
||||
candidate: { contentHash?: string; structuralSignature?: string },
|
||||
): boolean;
|
||||
export function formatTimelineEntries(entries?: unknown[], options?: Record<string, unknown>): FormattedTimeline;
|
||||
export function buildFailureHypothesis(session?: Record<string, unknown>): FailureHypothesis;
|
||||
export function summarizeBrowserSession(session?: Record<string, unknown>): SessionSummary;
|
||||
|
|
@ -4,7 +4,171 @@
|
|||
* Kept free of pi-specific imports so it can be exercised with node:test.
|
||||
*/
|
||||
|
||||
export function createActionTimeline(limit = 60) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Interfaces & Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ActionTimeline {
|
||||
limit: number;
|
||||
nextId: number;
|
||||
entries: ActionEntry[];
|
||||
}
|
||||
|
||||
export interface ActionEntry {
|
||||
id: number;
|
||||
tool: string;
|
||||
paramsSummary: string;
|
||||
startedAt: number;
|
||||
finishedAt: number | null;
|
||||
status: string;
|
||||
beforeUrl: string;
|
||||
afterUrl: string;
|
||||
verificationSummary?: string;
|
||||
warningSummary?: string;
|
||||
diffSummary?: string;
|
||||
changed?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ActionPartial {
|
||||
tool: string;
|
||||
paramsSummary?: string;
|
||||
startedAt?: number;
|
||||
beforeUrl?: string;
|
||||
afterUrl?: string;
|
||||
verificationSummary?: string;
|
||||
warningSummary?: string;
|
||||
diffSummary?: string;
|
||||
changed?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ActionUpdates {
|
||||
finishedAt?: number;
|
||||
status?: string;
|
||||
afterUrl?: string;
|
||||
verificationSummary?: string;
|
||||
warningSummary?: string;
|
||||
diffSummary?: string;
|
||||
changed?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface DiffResult {
|
||||
changed: boolean;
|
||||
changes: Array<{ type: string; before: unknown; after: unknown }>;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export interface Threshold {
|
||||
op: string;
|
||||
n: number;
|
||||
}
|
||||
|
||||
export interface PageRegistry {
|
||||
pages: PageEntry[];
|
||||
activePageId: number | null;
|
||||
nextId: number;
|
||||
}
|
||||
|
||||
export interface PageEntry {
|
||||
id: number;
|
||||
page: any;
|
||||
title: string;
|
||||
url: string;
|
||||
opener: number | null;
|
||||
}
|
||||
|
||||
export interface PageListEntry {
|
||||
id: number;
|
||||
title: string;
|
||||
url: string;
|
||||
opener: number | null;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface SnapshotModeConfig {
|
||||
tags: string[];
|
||||
roles: string[];
|
||||
selectors: string[];
|
||||
ariaAttributes: string[];
|
||||
useInteractiveFilter: boolean;
|
||||
visibleOnly?: boolean;
|
||||
containerExpand?: boolean;
|
||||
}
|
||||
|
||||
export interface AssertionCheckResult {
|
||||
name: string;
|
||||
passed: boolean;
|
||||
actual: unknown;
|
||||
expected: unknown;
|
||||
selector?: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export interface AssertionEvaluation {
|
||||
verified: boolean;
|
||||
checks: AssertionCheckResult[];
|
||||
summary: string;
|
||||
agentHint: string;
|
||||
}
|
||||
|
||||
export interface WaitValidationError {
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface BatchStepResult {
|
||||
ok: boolean;
|
||||
stopReason: string | null;
|
||||
failedStepIndex: number | null;
|
||||
stepResults: unknown[];
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export interface FormattedTimeline {
|
||||
entries: Array<{
|
||||
id: number | null;
|
||||
tool: string;
|
||||
status: string;
|
||||
durationMs: number | null;
|
||||
beforeUrl: string;
|
||||
afterUrl: string;
|
||||
line: string;
|
||||
}>;
|
||||
retained: number;
|
||||
totalRecorded: number;
|
||||
bounded: boolean;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export interface FailureHypothesis {
|
||||
hasFailures: boolean;
|
||||
categories: string[];
|
||||
summary: string;
|
||||
signals: Array<{ category: string; source: string; detail: string }>;
|
||||
}
|
||||
|
||||
export interface SessionSummary {
|
||||
counts: {
|
||||
pages: number;
|
||||
actions: { total: number; retained: number; success: number; error: number; running: number };
|
||||
waits: { total: number; success: number; error: number; running: number };
|
||||
assertions: { total: number; passed: number; failed: number; running: number };
|
||||
consoleErrors: number;
|
||||
failedRequests: number;
|
||||
dialogs: number;
|
||||
};
|
||||
activePage: { id: number | null; title: string; url: string } | null;
|
||||
caveats: string[];
|
||||
failureHypothesis: FailureHypothesis;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Action Timeline
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createActionTimeline(limit = 60): ActionTimeline {
|
||||
return {
|
||||
limit,
|
||||
nextId: 1,
|
||||
|
|
@ -12,8 +176,8 @@ export function createActionTimeline(limit = 60) {
|
|||
};
|
||||
}
|
||||
|
||||
export function beginAction(timeline, partial) {
|
||||
const entry = {
|
||||
export function beginAction(timeline: ActionTimeline, partial: ActionPartial): ActionEntry {
|
||||
const entry: ActionEntry = {
|
||||
id: timeline.nextId++,
|
||||
tool: partial.tool,
|
||||
paramsSummary: partial.paramsSummary ?? "",
|
||||
|
|
@ -35,7 +199,7 @@ export function beginAction(timeline, partial) {
|
|||
return entry;
|
||||
}
|
||||
|
||||
export function finishAction(timeline, actionId, updates = {}) {
|
||||
export function finishAction(timeline: ActionTimeline, actionId: number, updates: ActionUpdates = {}): ActionEntry | null {
|
||||
const entry = timeline.entries.find((item) => item.id === actionId);
|
||||
if (!entry) return null;
|
||||
Object.assign(entry, updates, {
|
||||
|
|
@ -51,14 +215,14 @@ export function finishAction(timeline, actionId, updates = {}) {
|
|||
return entry;
|
||||
}
|
||||
|
||||
export function findAction(timeline, actionId) {
|
||||
export function findAction(timeline: ActionTimeline, actionId: number): ActionEntry | null {
|
||||
return timeline.entries.find((item) => item.id === actionId) ?? null;
|
||||
}
|
||||
|
||||
export function toActionParamsSummary(params) {
|
||||
export function toActionParamsSummary(params: unknown): string {
|
||||
if (!params || typeof params !== "object") return "";
|
||||
const entries = [];
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
const entries: string[] = [];
|
||||
for (const [key, value] of Object.entries(params as Record<string, unknown>)) {
|
||||
if (value === undefined || value === null) continue;
|
||||
if (typeof value === "string") {
|
||||
entries.push(`${key}=${JSON.stringify(value.length > 60 ? `${value.slice(0, 57)}...` : value)}`);
|
||||
|
|
@ -77,8 +241,22 @@ export function toActionParamsSummary(params) {
|
|||
return entries.slice(0, 6).join(", ");
|
||||
}
|
||||
|
||||
export function diffCompactStates(before, after) {
|
||||
const changes = [];
|
||||
// ---------------------------------------------------------------------------
|
||||
// Compact State Diffing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface CompactStateForDiff {
|
||||
url?: string;
|
||||
title?: string;
|
||||
focus?: string;
|
||||
dialog?: { count?: number; title?: string };
|
||||
counts?: Record<string, number>;
|
||||
headings?: string[];
|
||||
bodyText?: string;
|
||||
}
|
||||
|
||||
export function diffCompactStates(before: CompactStateForDiff | null | undefined, after: CompactStateForDiff | null | undefined): DiffResult {
|
||||
const changes: Array<{ type: string; before: unknown; after: unknown }> = [];
|
||||
if (!before || !after) {
|
||||
return {
|
||||
changed: false,
|
||||
|
|
@ -159,11 +337,15 @@ export function diffCompactStates(before, after) {
|
|||
return { changed, changes, summary };
|
||||
}
|
||||
|
||||
function normalizeString(value) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// String helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function normalizeString(value: unknown): string {
|
||||
return String(value ?? "").trim();
|
||||
}
|
||||
|
||||
export function includesNeedle(haystack, needle) {
|
||||
export function includesNeedle(haystack: string, needle: string): boolean {
|
||||
return normalizeString(haystack).toLowerCase().includes(normalizeString(needle).toLowerCase());
|
||||
}
|
||||
|
||||
|
|
@ -173,10 +355,8 @@ export function includesNeedle(haystack, needle) {
|
|||
|
||||
/**
|
||||
* Parse a threshold expression like ">=3", "==0", "<5", or bare "3" (defaults to ">=").
|
||||
* @param {string} value
|
||||
* @returns {{ op: string, n: number } | null} — null if malformed
|
||||
*/
|
||||
export function parseThreshold(value) {
|
||||
export function parseThreshold(value: string | null | undefined): Threshold | null {
|
||||
if (value == null) return null;
|
||||
const str = String(value).trim();
|
||||
if (str === "") return null;
|
||||
|
|
@ -189,11 +369,8 @@ export function parseThreshold(value) {
|
|||
|
||||
/**
|
||||
* Evaluate whether a count meets a parsed threshold.
|
||||
* @param {number} count
|
||||
* @param {{ op: string, n: number }} threshold
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function meetsThreshold(count, threshold) {
|
||||
export function meetsThreshold(count: number, threshold: Threshold): boolean {
|
||||
switch (threshold.op) {
|
||||
case ">=": return count >= threshold.n;
|
||||
case "<=": return count <= threshold.n;
|
||||
|
|
@ -207,12 +384,12 @@ export function meetsThreshold(count, threshold) {
|
|||
/**
|
||||
* Filter entries that occurred at or after a given action's start time.
|
||||
* If sinceActionId is missing or the action isn't found, returns all entries.
|
||||
* @param {Array<{ timestamp?: number }>} entries
|
||||
* @param {number | undefined} sinceActionId
|
||||
* @param {{ entries: Array<{ id: number, startedAt: number }> }} timeline
|
||||
* @returns {Array}
|
||||
*/
|
||||
export function getEntriesSince(entries, sinceActionId, timeline) {
|
||||
export function getEntriesSince(
|
||||
entries: Array<{ timestamp?: number }>,
|
||||
sinceActionId: number | undefined,
|
||||
timeline: ActionTimeline,
|
||||
): Array<{ timestamp?: number }> {
|
||||
if (!entries || !Array.isArray(entries)) return [];
|
||||
if (sinceActionId == null || !timeline) return entries;
|
||||
const action = findAction(timeline, sinceActionId);
|
||||
|
|
@ -221,8 +398,34 @@ export function getEntriesSince(entries, sinceActionId, timeline) {
|
|||
return entries.filter((e) => (e.timestamp ?? 0) >= since);
|
||||
}
|
||||
|
||||
export function evaluateAssertionChecks({ checks, state }) {
|
||||
const results = [];
|
||||
// ---------------------------------------------------------------------------
|
||||
// Assertion Evaluation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface AssertionCheckInput {
|
||||
kind: string;
|
||||
selector?: string;
|
||||
value?: string;
|
||||
text?: string;
|
||||
checked?: boolean;
|
||||
sinceActionId?: number;
|
||||
}
|
||||
|
||||
interface AssertionState {
|
||||
url?: string;
|
||||
title?: string;
|
||||
bodyText?: string;
|
||||
focus?: string;
|
||||
selectorStates?: Record<string, { visible?: boolean; value?: string; checked?: boolean | null }>;
|
||||
consoleEntries?: Array<{ type?: string; text?: string; message?: string; timestamp?: number }>;
|
||||
networkEntries?: Array<{ type?: string; url?: string; status?: number; failed?: boolean; timestamp?: number }>;
|
||||
allConsoleEntries?: Array<{ type?: string; text?: string; message?: string; timestamp?: number }>;
|
||||
allNetworkEntries?: Array<{ type?: string; url?: string; status?: number; failed?: boolean; timestamp?: number }>;
|
||||
actionTimeline?: ActionTimeline | null;
|
||||
}
|
||||
|
||||
export function evaluateAssertionChecks({ checks, state }: { checks: AssertionCheckInput[]; state: AssertionState }): AssertionEvaluation {
|
||||
const results: AssertionCheckResult[] = [];
|
||||
const selectorStates = state.selectorStates ?? {};
|
||||
const consoleEntries = state.consoleEntries ?? [];
|
||||
const networkEntries = state.networkEntries ?? [];
|
||||
|
|
@ -233,29 +436,29 @@ export function evaluateAssertionChecks({ checks, state }) {
|
|||
for (const check of checks) {
|
||||
const selectorState = check.selector ? selectorStates[check.selector] ?? null : null;
|
||||
let passed = false;
|
||||
let actual;
|
||||
let expected;
|
||||
let actual: unknown;
|
||||
let expected: unknown;
|
||||
|
||||
switch (check.kind) {
|
||||
case "url_contains":
|
||||
actual = state.url ?? "";
|
||||
expected = check.value ?? "";
|
||||
passed = includesNeedle(actual, expected);
|
||||
passed = includesNeedle(actual as string, expected as string);
|
||||
break;
|
||||
case "title_contains":
|
||||
actual = state.title ?? "";
|
||||
expected = check.value ?? "";
|
||||
passed = includesNeedle(actual, expected);
|
||||
passed = includesNeedle(actual as string, expected as string);
|
||||
break;
|
||||
case "text_visible":
|
||||
actual = state.bodyText ?? "";
|
||||
expected = check.text ?? "";
|
||||
passed = includesNeedle(actual, expected);
|
||||
passed = includesNeedle(actual as string, expected as string);
|
||||
break;
|
||||
case "text_not_visible":
|
||||
actual = state.bodyText ?? "";
|
||||
expected = check.text ?? "";
|
||||
passed = !includesNeedle(actual, expected);
|
||||
passed = !includesNeedle(actual as string, expected as string);
|
||||
break;
|
||||
case "selector_visible":
|
||||
actual = selectorState?.visible ?? false;
|
||||
|
|
@ -275,12 +478,12 @@ export function evaluateAssertionChecks({ checks, state }) {
|
|||
case "value_contains":
|
||||
actual = selectorState?.value ?? "";
|
||||
expected = check.value ?? "";
|
||||
passed = includesNeedle(actual, expected);
|
||||
passed = includesNeedle(actual as string, expected as string);
|
||||
break;
|
||||
case "focused_matches":
|
||||
actual = state.focus ?? "";
|
||||
expected = check.value ?? "";
|
||||
passed = includesNeedle(actual, expected);
|
||||
passed = includesNeedle(actual as string, expected as string);
|
||||
break;
|
||||
case "checked_equals":
|
||||
actual = selectorState?.checked ?? null;
|
||||
|
|
@ -301,8 +504,8 @@ export function evaluateAssertionChecks({ checks, state }) {
|
|||
// --- S02: New structured network/console assertion kinds ---
|
||||
|
||||
case "request_url_seen": {
|
||||
const filtered = getEntriesSince(allNetworkEntries, check.sinceActionId, actionTimeline);
|
||||
const matches = filtered.filter((e) => includesNeedle(e.url ?? "", check.text ?? ""));
|
||||
const filtered = getEntriesSince(allNetworkEntries, check.sinceActionId, actionTimeline!);
|
||||
const matches = (filtered as typeof allNetworkEntries).filter((e) => includesNeedle(e.url ?? "", check.text ?? ""));
|
||||
actual = matches.length > 0;
|
||||
expected = true;
|
||||
passed = actual === true;
|
||||
|
|
@ -310,9 +513,9 @@ export function evaluateAssertionChecks({ checks, state }) {
|
|||
}
|
||||
|
||||
case "response_status": {
|
||||
const filtered = getEntriesSince(allNetworkEntries, check.sinceActionId, actionTimeline);
|
||||
const statusNum = parseInt(check.value, 10);
|
||||
const matches = filtered.filter(
|
||||
const filtered = getEntriesSince(allNetworkEntries, check.sinceActionId, actionTimeline!);
|
||||
const statusNum = parseInt(check.value!, 10);
|
||||
const matches = (filtered as typeof allNetworkEntries).filter(
|
||||
(e) => includesNeedle(e.url ?? "", check.text ?? "") && typeof e.status === "number" && e.status === statusNum
|
||||
);
|
||||
actual = matches.length > 0 ? `found (status=${matches[0].status})` : `not found`;
|
||||
|
|
@ -322,8 +525,8 @@ export function evaluateAssertionChecks({ checks, state }) {
|
|||
}
|
||||
|
||||
case "console_message_matches": {
|
||||
const filtered = getEntriesSince(allConsoleEntries, check.sinceActionId, actionTimeline);
|
||||
const matches = filtered.filter((e) => includesNeedle(e.text ?? "", check.text ?? ""));
|
||||
const filtered = getEntriesSince(allConsoleEntries, check.sinceActionId, actionTimeline!);
|
||||
const matches = (filtered as typeof allConsoleEntries).filter((e) => includesNeedle(e.text ?? "", check.text ?? ""));
|
||||
actual = matches.length > 0;
|
||||
expected = true;
|
||||
passed = actual === true;
|
||||
|
|
@ -331,8 +534,8 @@ export function evaluateAssertionChecks({ checks, state }) {
|
|||
}
|
||||
|
||||
case "network_count": {
|
||||
const filtered = getEntriesSince(allNetworkEntries, check.sinceActionId, actionTimeline);
|
||||
const matches = filtered.filter((e) => includesNeedle(e.url ?? "", check.text ?? ""));
|
||||
const filtered = getEntriesSince(allNetworkEntries, check.sinceActionId, actionTimeline!);
|
||||
const matches = (filtered as typeof allNetworkEntries).filter((e) => includesNeedle(e.url ?? "", check.text ?? ""));
|
||||
const threshold = parseThreshold(check.value);
|
||||
if (!threshold) {
|
||||
actual = `invalid threshold: ${check.value}`;
|
||||
|
|
@ -347,8 +550,8 @@ export function evaluateAssertionChecks({ checks, state }) {
|
|||
}
|
||||
|
||||
case "console_count": {
|
||||
const filtered = getEntriesSince(allConsoleEntries, check.sinceActionId, actionTimeline);
|
||||
const matches = filtered.filter((e) => includesNeedle(e.text ?? "", check.text ?? ""));
|
||||
const filtered = getEntriesSince(allConsoleEntries, check.sinceActionId, actionTimeline!);
|
||||
const matches = (filtered as typeof allConsoleEntries).filter((e) => includesNeedle(e.text ?? "", check.text ?? ""));
|
||||
const threshold = parseThreshold(check.value);
|
||||
if (!threshold) {
|
||||
actual = `invalid threshold: ${check.value}`;
|
||||
|
|
@ -363,8 +566,8 @@ export function evaluateAssertionChecks({ checks, state }) {
|
|||
}
|
||||
|
||||
case "no_console_errors_since": {
|
||||
const filtered = getEntriesSince(allConsoleEntries, check.sinceActionId, actionTimeline);
|
||||
const errors = filtered.filter((e) => e.type === "error" || e.type === "pageerror");
|
||||
const filtered = getEntriesSince(allConsoleEntries, check.sinceActionId, actionTimeline!);
|
||||
const errors = (filtered as typeof allConsoleEntries).filter((e) => e.type === "error" || e.type === "pageerror");
|
||||
actual = errors.length;
|
||||
expected = 0;
|
||||
passed = errors.length === 0;
|
||||
|
|
@ -372,8 +575,8 @@ export function evaluateAssertionChecks({ checks, state }) {
|
|||
}
|
||||
|
||||
case "no_failed_requests_since": {
|
||||
const filtered = getEntriesSince(allNetworkEntries, check.sinceActionId, actionTimeline);
|
||||
const failures = filtered.filter((e) => e.failed || (typeof e.status === "number" && e.status >= 400));
|
||||
const filtered = getEntriesSince(allNetworkEntries, check.sinceActionId, actionTimeline!);
|
||||
const failures = (filtered as typeof allNetworkEntries).filter((e) => e.failed || (typeof e.status === "number" && e.status >= 400));
|
||||
actual = failures.length;
|
||||
expected = 0;
|
||||
passed = failures.length === 0;
|
||||
|
|
@ -417,11 +620,16 @@ export function evaluateAssertionChecks({ checks, state }) {
|
|||
// Wait-condition validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface WaitConditionSpec {
|
||||
needsValue: boolean;
|
||||
valueLabel: string;
|
||||
needsThreshold?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* All recognized wait conditions with their parameter requirements.
|
||||
* Each entry: { needsValue: bool, valueLabel: string, needsThreshold?: bool }
|
||||
*/
|
||||
const WAIT_CONDITIONS = {
|
||||
const WAIT_CONDITIONS: Record<string, WaitConditionSpec> = {
|
||||
// Existing 5 conditions
|
||||
selector_visible: { needsValue: true, valueLabel: "CSS selector" },
|
||||
selector_hidden: { needsValue: true, valueLabel: "CSS selector" },
|
||||
|
|
@ -440,10 +648,8 @@ const WAIT_CONDITIONS = {
|
|||
|
||||
/**
|
||||
* Validate parameters for a browser_wait_for condition.
|
||||
* @param {{ condition: string, value?: string, threshold?: string }} params
|
||||
* @returns {null | { error: string }} — null if valid, structured error otherwise
|
||||
*/
|
||||
export function validateWaitParams(params) {
|
||||
export function validateWaitParams(params: { condition: string; value?: string; threshold?: string }): WaitValidationError | null {
|
||||
const { condition, value, threshold } = params ?? {};
|
||||
|
||||
if (!condition) {
|
||||
|
|
@ -477,14 +683,8 @@ export function validateWaitParams(params) {
|
|||
/**
|
||||
* Generate a JS expression string for page.waitForFunction() that detects
|
||||
* DOM stability by comparing snapshot hashes across polling intervals.
|
||||
*
|
||||
* The script stores a snapshot on a namespaced window key. When the snapshot
|
||||
* matches the previous value, the region is considered stable.
|
||||
*
|
||||
* @param {string} selector — CSS selector for the target element
|
||||
* @returns {string} — self-contained JS function body suitable for waitForFunction
|
||||
*/
|
||||
export function createRegionStableScript(selector) {
|
||||
export function createRegionStableScript(selector: string): string {
|
||||
// Create a stable key from the selector (simple hash to avoid special chars)
|
||||
const safeKey = Array.from(selector).reduce((h, c) => ((h << 5) - h + c.charCodeAt(0)) | 0, 0) >>> 0;
|
||||
const windowKey = `__pw_region_stable_${safeKey}`;
|
||||
|
|
@ -504,40 +704,20 @@ export function createRegionStableScript(selector) {
|
|||
// Page Registry — pure-logic operations for multi-page/tab management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create a fresh page registry.
|
||||
* @returns {{ pages: Array, activePageId: number | null, nextId: number }}
|
||||
*/
|
||||
export function createPageRegistry() {
|
||||
export function createPageRegistry(): PageRegistry {
|
||||
return { pages: [], activePageId: null, nextId: 1 };
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {{ id: number, page: any, title: string, url: string, opener: number | null }} PageEntry
|
||||
*/
|
||||
|
||||
/**
|
||||
* Add a page to the registry. Assigns an auto-incrementing ID.
|
||||
* @param {ReturnType<typeof createPageRegistry>} registry
|
||||
* @param {{ page: any, title?: string, url?: string, opener?: number | null }} info
|
||||
* @returns {PageEntry}
|
||||
*/
|
||||
export function registryAddPage(registry, { page, title = "", url = "", opener = null }) {
|
||||
const entry = { id: registry.nextId++, page, title, url, opener };
|
||||
export function registryAddPage(
|
||||
registry: PageRegistry,
|
||||
{ page, title = "", url = "", opener = null }: { page: unknown; title?: string; url?: string; opener?: number | null },
|
||||
): PageEntry {
|
||||
const entry: PageEntry = { id: registry.nextId++, page, title, url, opener };
|
||||
registry.pages.push(entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a page from the registry by ID.
|
||||
* If the removed page was active, falls back to the opener (if still present)
|
||||
* or the last remaining page.
|
||||
* Orphans any pages whose opener was the removed page (sets their opener to null).
|
||||
* @param {ReturnType<typeof createPageRegistry>} registry
|
||||
* @param {number} pageId
|
||||
* @returns {{ removed: PageEntry, newActiveId: number | null }}
|
||||
*/
|
||||
export function registryRemovePage(registry, pageId) {
|
||||
export function registryRemovePage(registry: PageRegistry, pageId: number): { removed: PageEntry; newActiveId: number | null } {
|
||||
const idx = registry.pages.findIndex((p) => p.id === pageId);
|
||||
if (idx === -1) {
|
||||
const available = registry.pages.map((p) => p.id);
|
||||
|
|
@ -571,12 +751,7 @@ export function registryRemovePage(registry, pageId) {
|
|||
return { removed, newActiveId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the active page by ID. Throws if the page is not in the registry.
|
||||
* @param {ReturnType<typeof createPageRegistry>} registry
|
||||
* @param {number} pageId
|
||||
*/
|
||||
export function registrySetActive(registry, pageId) {
|
||||
export function registrySetActive(registry: PageRegistry, pageId: number): void {
|
||||
const entry = registry.pages.find((p) => p.id === pageId);
|
||||
if (!entry) {
|
||||
const available = registry.pages.map((p) => p.id);
|
||||
|
|
@ -589,12 +764,7 @@ export function registrySetActive(registry, pageId) {
|
|||
registry.activePageId = pageId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the active page entry. Throws if no active page or active page not found.
|
||||
* @param {ReturnType<typeof createPageRegistry>} registry
|
||||
* @returns {PageEntry}
|
||||
*/
|
||||
export function registryGetActive(registry) {
|
||||
export function registryGetActive(registry: PageRegistry): PageEntry {
|
||||
if (registry.activePageId === null) {
|
||||
throw new Error(
|
||||
`registryGetActive: no active page. ` +
|
||||
|
|
@ -613,22 +783,11 @@ export function registryGetActive(registry) {
|
|||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a page entry by ID, or null if not found.
|
||||
* @param {ReturnType<typeof createPageRegistry>} registry
|
||||
* @param {number} pageId
|
||||
* @returns {PageEntry | null}
|
||||
*/
|
||||
export function registryGetPage(registry, pageId) {
|
||||
export function registryGetPage(registry: PageRegistry, pageId: number): PageEntry | null {
|
||||
return registry.pages.find((p) => p.id === pageId) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all pages (without the raw `page` reference).
|
||||
* @param {ReturnType<typeof createPageRegistry>} registry
|
||||
* @returns {Array<{ id: number, title: string, url: string, opener: number | null, isActive: boolean }>}
|
||||
*/
|
||||
export function registryListPages(registry) {
|
||||
export function registryListPages(registry: PageRegistry): PageListEntry[] {
|
||||
return registry.pages.map((entry) => ({
|
||||
id: entry.id,
|
||||
title: entry.title,
|
||||
|
|
@ -642,13 +801,8 @@ export function registryListPages(registry) {
|
|||
// FIFO Bounded Log Pusher
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create a push function that enforces FIFO eviction at push-time.
|
||||
* @param {number} maxSize — maximum number of entries to retain
|
||||
* @returns {(array: Array, entry: any) => void}
|
||||
*/
|
||||
export function createBoundedLogPusher(maxSize) {
|
||||
return function push(array, entry) {
|
||||
export function createBoundedLogPusher(maxSize: number): (array: unknown[], entry: unknown) => void {
|
||||
return function push(array: unknown[], entry: unknown): void {
|
||||
array.push(entry);
|
||||
if (array.length > maxSize) {
|
||||
array.splice(0, array.length - maxSize);
|
||||
|
|
@ -656,10 +810,14 @@ export function createBoundedLogPusher(maxSize) {
|
|||
};
|
||||
}
|
||||
|
||||
export async function runBatchSteps({ steps, executeStep, stopOnFailure = true }) {
|
||||
const results = [];
|
||||
export async function runBatchSteps({ steps, executeStep, stopOnFailure = true }: {
|
||||
steps: unknown[];
|
||||
executeStep: (step: unknown, index: number) => Promise<{ ok: boolean; [key: string]: unknown }>;
|
||||
stopOnFailure?: boolean;
|
||||
}): Promise<BatchStepResult> {
|
||||
const results: unknown[] = [];
|
||||
for (let i = 0; i < steps.length; i += 1) {
|
||||
const step = steps[i];
|
||||
const step = steps[i] as { action: string };
|
||||
const result = await executeStep(step, i);
|
||||
results.push(result);
|
||||
if (result.ok === false && stopOnFailure) {
|
||||
|
|
@ -685,15 +843,7 @@ export async function runBatchSteps({ steps, executeStep, stopOnFailure = true }
|
|||
// Snapshot Modes — semantic element filtering for browser_snapshot_refs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Pre-defined snapshot modes that filter elements by semantic category.
|
||||
* Each mode config defines which elements should be captured.
|
||||
*
|
||||
* Shape: { tags: string[], roles: string[], selectors: string[],
|
||||
* ariaAttributes: string[], useInteractiveFilter: boolean,
|
||||
* visibleOnly?: boolean, containerExpand?: boolean }
|
||||
*/
|
||||
export const SNAPSHOT_MODES = {
|
||||
export const SNAPSHOT_MODES: Record<string, SnapshotModeConfig> = {
|
||||
interactive: {
|
||||
tags: [],
|
||||
roles: [],
|
||||
|
|
@ -748,12 +898,7 @@ export const SNAPSHOT_MODES = {
|
|||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the snapshot mode config by name.
|
||||
* @param {string} mode — mode name (e.g. "form", "dialog", "interactive")
|
||||
* @returns {{ tags: string[], roles: string[], selectors: string[], ariaAttributes: string[], useInteractiveFilter: boolean, visibleOnly?: boolean, containerExpand?: boolean } | null}
|
||||
*/
|
||||
export function getSnapshotModeConfig(mode) {
|
||||
export function getSnapshotModeConfig(mode: string): SnapshotModeConfig | null {
|
||||
return SNAPSHOT_MODES[mode] ?? null;
|
||||
}
|
||||
|
||||
|
|
@ -761,13 +906,7 @@ export function getSnapshotModeConfig(mode) {
|
|||
// Fingerprint functions — structural identity for ref resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Compute a content hash from visible text using djb2.
|
||||
* Caller is expected to pre-truncate to ~200 chars and normalize whitespace.
|
||||
* @param {string} text — visible text content
|
||||
* @returns {string} — hex string hash, or "0" for empty input
|
||||
*/
|
||||
export function computeContentHash(text) {
|
||||
export function computeContentHash(text: string): string {
|
||||
if (!text) return "0";
|
||||
let h = 5381;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
|
|
@ -776,15 +915,7 @@ export function computeContentHash(text) {
|
|||
return (h >>> 0).toString(16);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a structural signature from tag, role, and immediate child tag names.
|
||||
* Uses djb2 hash on the concatenated string `tag|role|child1,child2,...`.
|
||||
* @param {string} tag — element tag name (lowercase)
|
||||
* @param {string} role — ARIA role or empty string
|
||||
* @param {string[]} childTags — array of immediate child tag names (lowercase)
|
||||
* @returns {string} — hex string hash
|
||||
*/
|
||||
export function computeStructuralSignature(tag, role, childTags) {
|
||||
export function computeStructuralSignature(tag: string, role: string, childTags: string[]): string {
|
||||
const input = `${tag}|${role}|${childTags.join(",")}`;
|
||||
let h = 5381;
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
|
|
@ -793,14 +924,10 @@ export function computeStructuralSignature(tag, role, childTags) {
|
|||
return (h >>> 0).toString(16);
|
||||
}
|
||||
|
||||
/**
|
||||
* Match two fingerprint objects by contentHash and structuralSignature.
|
||||
* Returns true only when both fields are present on both objects and both match.
|
||||
* @param {{ contentHash?: string, structuralSignature?: string }} stored
|
||||
* @param {{ contentHash?: string, structuralSignature?: string }} candidate
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function matchFingerprint(stored, candidate) {
|
||||
export function matchFingerprint(
|
||||
stored: { contentHash?: string; structuralSignature?: string },
|
||||
candidate: { contentHash?: string; structuralSignature?: string },
|
||||
): boolean {
|
||||
if (!stored || !candidate) return false;
|
||||
if (!stored.contentHash || !stored.structuralSignature) return false;
|
||||
if (!candidate.contentHash || !candidate.structuralSignature) return false;
|
||||
|
|
@ -808,30 +935,34 @@ export function matchFingerprint(stored, candidate) {
|
|||
stored.structuralSignature === candidate.structuralSignature;
|
||||
}
|
||||
|
||||
function formatDurationMs(entry) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Timeline Formatting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function formatDurationMs(entry: { startedAt?: number; finishedAt?: number | null }): number | null {
|
||||
const startedAt = typeof entry?.startedAt === "number" ? entry.startedAt : null;
|
||||
const finishedAt = typeof entry?.finishedAt === "number" ? entry.finishedAt : null;
|
||||
if (startedAt == null || finishedAt == null || finishedAt < startedAt) return null;
|
||||
return finishedAt - startedAt;
|
||||
}
|
||||
|
||||
function summarizeActionStatus(status) {
|
||||
function summarizeActionStatus(status: string | undefined): string {
|
||||
if (status === "error") return "error";
|
||||
if (status === "running") return "running";
|
||||
return "success";
|
||||
}
|
||||
|
||||
function looksBoundedWarning(value) {
|
||||
function looksBoundedWarning(value: unknown): boolean {
|
||||
return /bounded .*history/i.test(String(value ?? ""));
|
||||
}
|
||||
|
||||
function uniqueStrings(values) {
|
||||
return [...new Set(values.filter(Boolean))];
|
||||
function uniqueStrings(values: (string | undefined)[]): string[] {
|
||||
return [...new Set(values.filter(Boolean))] as string[];
|
||||
}
|
||||
|
||||
export function formatTimelineEntries(entries = [], options = {}) {
|
||||
const retained = options.retained ?? entries.length;
|
||||
const totalRecorded = options.totalRecorded ?? retained;
|
||||
export function formatTimelineEntries(entries: ActionEntry[] = [], options: Record<string, unknown> = {}): FormattedTimeline {
|
||||
const retained = (options.retained as number) ?? entries.length;
|
||||
const totalRecorded = (options.totalRecorded as number) ?? retained;
|
||||
const bounded = totalRecorded > retained;
|
||||
|
||||
if (!entries.length) {
|
||||
|
|
@ -847,7 +978,7 @@ export function formatTimelineEntries(entries = [], options = {}) {
|
|||
const formattedEntries = entries.map((entry) => {
|
||||
const status = summarizeActionStatus(entry.status);
|
||||
const durationMs = formatDurationMs(entry);
|
||||
const parts = [
|
||||
const parts: string[] = [
|
||||
`#${entry.id ?? "?"}`,
|
||||
entry.tool ?? "unknown_tool",
|
||||
status,
|
||||
|
|
@ -884,12 +1015,16 @@ export function formatTimelineEntries(entries = [], options = {}) {
|
|||
};
|
||||
}
|
||||
|
||||
export function buildFailureHypothesis(session = {}) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Failure Hypothesis
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function buildFailureHypothesis(session: Record<string, any> = {}): FailureHypothesis {
|
||||
const timelineEntries = session.actionTimeline?.entries ?? [];
|
||||
const consoleEntries = session.consoleEntries ?? [];
|
||||
const networkEntries = session.networkEntries ?? [];
|
||||
const dialogEntries = session.dialogEntries ?? [];
|
||||
const signals = [];
|
||||
const signals: Array<{ category: string; source: string; detail: string }> = [];
|
||||
|
||||
for (const entry of timelineEntries) {
|
||||
if (entry?.status !== "error") continue;
|
||||
|
|
@ -920,7 +1055,7 @@ export function buildFailureHypothesis(session = {}) {
|
|||
if (entry?.type !== "error" && entry?.type !== "pageerror") continue;
|
||||
signals.push({
|
||||
category: "console",
|
||||
source: entry.type,
|
||||
source: entry.type!,
|
||||
detail: entry.text || "Console error recorded",
|
||||
});
|
||||
}
|
||||
|
|
@ -957,18 +1092,22 @@ export function buildFailureHypothesis(session = {}) {
|
|||
};
|
||||
}
|
||||
|
||||
export function summarizeBrowserSession(session = {}) {
|
||||
const actionTimeline = session.actionTimeline ?? { limit: 0, entries: [] };
|
||||
const actionEntries = actionTimeline.entries ?? [];
|
||||
const retainedActionCount = session.retainedActionCount ?? actionEntries.length;
|
||||
const totalActionCount = session.totalActionCount ?? retainedActionCount;
|
||||
const pages = session.pages ?? [];
|
||||
const consoleEntries = session.consoleEntries ?? [];
|
||||
const networkEntries = session.networkEntries ?? [];
|
||||
const dialogEntries = session.dialogEntries ?? [];
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session Summary
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function summarizeBrowserSession(session: Record<string, any> = {}): SessionSummary {
|
||||
const actionTimeline = session.actionTimeline ?? { limit: 0, entries: [] as ActionEntry[] };
|
||||
const actionEntries: ActionEntry[] = actionTimeline.entries ?? [];
|
||||
const retainedActionCount: number = session.retainedActionCount ?? actionEntries.length;
|
||||
const totalActionCount: number = session.totalActionCount ?? retainedActionCount;
|
||||
const pages: Array<Record<string, any>> = session.pages ?? [];
|
||||
const consoleEntries: Array<Record<string, any>> = session.consoleEntries ?? [];
|
||||
const networkEntries: Array<Record<string, any>> = session.networkEntries ?? [];
|
||||
const dialogEntries: Array<Record<string, any>> = session.dialogEntries ?? [];
|
||||
|
||||
const actionStatusCounts = actionEntries.reduce(
|
||||
(acc, entry) => {
|
||||
(acc: Record<string, number>, entry: ActionEntry) => {
|
||||
const status = summarizeActionStatus(entry.status);
|
||||
acc[status] = (acc[status] ?? 0) + 1;
|
||||
return acc;
|
||||
|
|
@ -976,13 +1115,13 @@ export function summarizeBrowserSession(session = {}) {
|
|||
{ success: 0, error: 0, running: 0 },
|
||||
);
|
||||
|
||||
const waitEntries = actionEntries.filter((entry) => entry.tool === "browser_wait_for");
|
||||
const assertEntries = actionEntries.filter((entry) => entry.tool === "browser_assert");
|
||||
const consoleErrors = consoleEntries.filter((entry) => entry.type === "error" || entry.type === "pageerror");
|
||||
const failedRequests = networkEntries.filter((entry) => entry.failed || (typeof entry.status === "number" && entry.status >= 400));
|
||||
const activePage = pages.find((page) => page.isActive) ?? pages[0] ?? null;
|
||||
const waitEntries = actionEntries.filter((entry: ActionEntry) => entry.tool === "browser_wait_for");
|
||||
const assertEntries = actionEntries.filter((entry: ActionEntry) => entry.tool === "browser_assert");
|
||||
const consoleErrors = consoleEntries.filter((entry: Record<string, any>) => entry.type === "error" || entry.type === "pageerror");
|
||||
const failedRequests = networkEntries.filter((entry: Record<string, any>) => entry.failed || (typeof entry.status === "number" && entry.status >= 400));
|
||||
const activePage = pages.find((page: Record<string, any>) => page.isActive) ?? pages[0] ?? null;
|
||||
|
||||
const caveats = [];
|
||||
const caveats: string[] = [];
|
||||
if (totalActionCount > retainedActionCount) {
|
||||
caveats.push(`Showing ${retainedActionCount} of ${totalActionCount} recorded actions; older actions were discarded due to bounded history.`);
|
||||
}
|
||||
|
|
@ -15,7 +15,7 @@ import { Type } from "@sinclair/typebox";
|
|||
|
||||
import { LRUTTLCache } from "./cache.js";
|
||||
import { fetchSimple, HttpError } from "./http.js";
|
||||
import { extractDomain } from "./url-utils.js";
|
||||
import { extractDomain, isBlockedUrl } from "./url-utils.js";
|
||||
import { formatPageContent, type FormatPageOptions } from "./format.js";
|
||||
import { getOllamaApiKey } from "./provider.js";
|
||||
|
||||
|
|
@ -416,6 +416,14 @@ export function registerFetchPageTool(pi: ExtensionAPI) {
|
|||
};
|
||||
}
|
||||
|
||||
if (isBlockedUrl(url)) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Blocked URL: requests to private/internal addresses are not allowed.` }],
|
||||
isError: true,
|
||||
details: { error: "SSRF blocked", url } satisfies Partial<FetchPageDetails>,
|
||||
};
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Cache lookup (full content cached, offset/truncation applied after)
|
||||
// ------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -1,7 +1,41 @@
|
|||
/**
|
||||
* URL normalization and query utilities.
|
||||
* URL normalization, query utilities, and SSRF protection.
|
||||
*/
|
||||
|
||||
const BLOCKED_HOSTNAMES = new Set([
|
||||
"localhost",
|
||||
"metadata.google.internal",
|
||||
"instance-data",
|
||||
]);
|
||||
|
||||
const PRIVATE_IP_PATTERNS = [
|
||||
/^127\./,
|
||||
/^10\./,
|
||||
/^172\.(1[6-9]|2\d|3[01])\./,
|
||||
/^192\.168\./,
|
||||
/^169\.254\./,
|
||||
/^0\./,
|
||||
/^::1$/,
|
||||
/^fc00:/i,
|
||||
/^fd/i,
|
||||
/^fe80:/i,
|
||||
];
|
||||
|
||||
export function isBlockedUrl(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return true;
|
||||
const hostname = parsed.hostname.toLowerCase();
|
||||
if (BLOCKED_HOSTNAMES.has(hostname)) return true;
|
||||
for (const pattern of PRIVATE_IP_PATTERNS) {
|
||||
if (pattern.test(hostname)) return true;
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** Normalize a search query into a stable cache key. */
|
||||
export function normalizeQuery(query: string): string {
|
||||
return query.trim().toLowerCase().replace(/\s+/g, " ").normalize("NFC");
|
||||
|
|
|
|||
|
|
@ -173,19 +173,21 @@ test("loadStoredEnvKeys hydrates process.env from auth.json", async () => {
|
|||
brave_answers: { type: "api_key", key: "test-answers-key" },
|
||||
context7: { type: "api_key", key: "test-ctx7-key" },
|
||||
tavily: { type: "api_key", key: "test-tavily-key" },
|
||||
telegram_bot: { type: "api_key", key: "test-telegram-key" },
|
||||
"custom-openai": { type: "api_key", key: "test-custom-openai-key" },
|
||||
}));
|
||||
|
||||
// Clear any existing env vars
|
||||
const origBrave = process.env.BRAVE_API_KEY;
|
||||
const origBraveAnswers = process.env.BRAVE_ANSWERS_KEY;
|
||||
const origCtx7 = process.env.CONTEXT7_API_KEY;
|
||||
const origJina = process.env.JINA_API_KEY;
|
||||
const origTavily = process.env.TAVILY_API_KEY;
|
||||
delete process.env.BRAVE_API_KEY;
|
||||
delete process.env.BRAVE_ANSWERS_KEY;
|
||||
delete process.env.CONTEXT7_API_KEY;
|
||||
delete process.env.JINA_API_KEY;
|
||||
delete process.env.TAVILY_API_KEY;
|
||||
const envVarsToRestore = [
|
||||
"BRAVE_API_KEY", "BRAVE_ANSWERS_KEY", "CONTEXT7_API_KEY",
|
||||
"JINA_API_KEY", "TAVILY_API_KEY", "TELEGRAM_BOT_TOKEN",
|
||||
"CUSTOM_OPENAI_API_KEY",
|
||||
];
|
||||
const origValues: Record<string, string | undefined> = {};
|
||||
for (const v of envVarsToRestore) {
|
||||
origValues[v] = process.env[v];
|
||||
delete process.env[v];
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = AuthStorage.create(authPath);
|
||||
|
|
@ -196,13 +198,12 @@ test("loadStoredEnvKeys hydrates process.env from auth.json", async () => {
|
|||
assert.equal(process.env.CONTEXT7_API_KEY, "test-ctx7-key", "CONTEXT7_API_KEY hydrated");
|
||||
assert.equal(process.env.JINA_API_KEY, undefined, "JINA_API_KEY not set (not in auth)");
|
||||
assert.equal(process.env.TAVILY_API_KEY, "test-tavily-key", "TAVILY_API_KEY hydrated");
|
||||
assert.equal(process.env.TELEGRAM_BOT_TOKEN, "test-telegram-key", "TELEGRAM_BOT_TOKEN hydrated");
|
||||
assert.equal(process.env.CUSTOM_OPENAI_API_KEY, "test-custom-openai-key", "CUSTOM_OPENAI_API_KEY hydrated");
|
||||
} finally {
|
||||
// Restore original env
|
||||
if (origBrave) process.env.BRAVE_API_KEY = origBrave; else delete process.env.BRAVE_API_KEY;
|
||||
if (origBraveAnswers) process.env.BRAVE_ANSWERS_KEY = origBraveAnswers; else delete process.env.BRAVE_ANSWERS_KEY;
|
||||
if (origCtx7) process.env.CONTEXT7_API_KEY = origCtx7; else delete process.env.CONTEXT7_API_KEY;
|
||||
if (origJina) process.env.JINA_API_KEY = origJina; else delete process.env.JINA_API_KEY;
|
||||
if (origTavily) process.env.TAVILY_API_KEY = origTavily; else delete process.env.TAVILY_API_KEY;
|
||||
for (const v of envVarsToRestore) {
|
||||
if (origValues[v]) process.env[v] = origValues[v]; else delete process.env[v];
|
||||
}
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
|
|
|||
59
src/tests/url-utils.test.ts
Normal file
59
src/tests/url-utils.test.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { isBlockedUrl } from "../resources/extensions/search-the-web/url-utils.ts";
|
||||
|
||||
describe("isBlockedUrl — SSRF protection", () => {
|
||||
it("blocks localhost", () => {
|
||||
assert.equal(isBlockedUrl("http://localhost/admin"), true);
|
||||
assert.equal(isBlockedUrl("http://localhost:8080/"), true);
|
||||
});
|
||||
|
||||
it("blocks 127.0.0.0/8", () => {
|
||||
assert.equal(isBlockedUrl("http://127.0.0.1/"), true);
|
||||
assert.equal(isBlockedUrl("http://127.0.0.2:3000/path"), true);
|
||||
});
|
||||
|
||||
it("blocks 10.0.0.0/8 (private)", () => {
|
||||
assert.equal(isBlockedUrl("http://10.0.0.1/"), true);
|
||||
assert.equal(isBlockedUrl("http://10.255.255.255/"), true);
|
||||
});
|
||||
|
||||
it("blocks 172.16-31.x.x (private)", () => {
|
||||
assert.equal(isBlockedUrl("http://172.16.0.1/"), true);
|
||||
assert.equal(isBlockedUrl("http://172.31.255.255/"), true);
|
||||
});
|
||||
|
||||
it("blocks 192.168.x.x (private)", () => {
|
||||
assert.equal(isBlockedUrl("http://192.168.1.1/"), true);
|
||||
assert.equal(isBlockedUrl("http://192.168.0.100:9200/"), true);
|
||||
});
|
||||
|
||||
it("blocks 169.254.x.x (link-local / cloud metadata)", () => {
|
||||
assert.equal(isBlockedUrl("http://169.254.169.254/latest/meta-data/"), true);
|
||||
});
|
||||
|
||||
it("blocks cloud metadata hostnames", () => {
|
||||
assert.equal(isBlockedUrl("http://metadata.google.internal/computeMetadata/"), true);
|
||||
});
|
||||
|
||||
it("blocks non-http protocols", () => {
|
||||
assert.equal(isBlockedUrl("file:///etc/passwd"), true);
|
||||
assert.equal(isBlockedUrl("ftp://internal.server/data"), true);
|
||||
});
|
||||
|
||||
it("blocks invalid URLs", () => {
|
||||
assert.equal(isBlockedUrl("not-a-url"), true);
|
||||
assert.equal(isBlockedUrl(""), true);
|
||||
});
|
||||
|
||||
it("allows public URLs", () => {
|
||||
assert.equal(isBlockedUrl("https://example.com"), false);
|
||||
assert.equal(isBlockedUrl("https://api.github.com/repos"), false);
|
||||
assert.equal(isBlockedUrl("http://docs.python.org/3/"), false);
|
||||
});
|
||||
|
||||
it("allows public IPs", () => {
|
||||
assert.equal(isBlockedUrl("http://8.8.8.8/"), false);
|
||||
assert.equal(isBlockedUrl("https://1.1.1.1/"), false);
|
||||
});
|
||||
});
|
||||
|
|
@ -33,7 +33,8 @@ function getCandidateNames(name: string): string[] {
|
|||
|
||||
function isRegularFile(path: string): boolean {
|
||||
try {
|
||||
return lstatSync(path).isFile() || lstatSync(path).isSymbolicLink();
|
||||
const stat = lstatSync(path);
|
||||
return stat.isFile() || stat.isSymbolicLink();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,8 +16,10 @@ export function loadStoredEnvKeys(authStorage: AuthStorage): void {
|
|||
['tavily', 'TAVILY_API_KEY'],
|
||||
['slack_bot', 'SLACK_BOT_TOKEN'],
|
||||
['discord_bot', 'DISCORD_BOT_TOKEN'],
|
||||
['telegram_bot', 'TELEGRAM_BOT_TOKEN'],
|
||||
['groq', 'GROQ_API_KEY'],
|
||||
['ollama-cloud', 'OLLAMA_API_KEY'],
|
||||
['custom-openai', 'CUSTOM_OPENAI_API_KEY'],
|
||||
]
|
||||
for (const [provider, envVar] of providers) {
|
||||
if (!process.env[envVar]) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue