From 3c71e2bd4866bbbb8d468d9ea5e60b63d1546a2f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 7 Mar 2026 10:40:49 +0000 Subject: [PATCH] refactor(core): extract shared dedup helpers --- package.json | 3 + pnpm-lock.yaml | 588 ++++++++++++++++++ src/acp/server.startup.test.ts | 55 +- src/agents/bash-tools.exec-host-gateway.ts | 53 +- src/agents/bash-tools.exec-host-node.ts | 54 +- src/agents/bash-tools.exec-host-shared.ts | 106 ++++ src/agents/bash-tools.exec-runtime.ts | 31 +- src/agents/cache-trace.ts | 11 +- src/agents/compaction.test.ts | 31 +- .../compaction.tool-result-details.test.ts | 16 +- src/agents/context.lookup.test.ts | 76 +-- src/agents/path-policy.ts | 116 ++-- ...ssistant-text-blocks-but-preserves.test.ts | 149 ++--- ...ession-history.tool-result-details.test.ts | 30 +- .../tool-result-truncation.test.ts | 16 +- ...adapter.after-tool-call.fires-once.test.ts | 18 +- .../pi-tool-handler-state.test-helpers.ts | 15 + src/agents/session-slug.ts | 31 +- src/agents/skills/frontmatter.ts | 20 +- .../test-helpers/agent-message-fixtures.ts | 20 +- .../assistant-message-fixtures.ts | 18 +- src/agents/test-helpers/usage-fixtures.ts | 16 + src/agents/trace-base.ts | 21 + .../reply/get-reply.message-hooks.test.ts | 62 +- .../get-reply.reset-hooks-fallback.test.ts | 62 +- src/auto-reply/reply/get-reply.test-mocks.ts | 63 ++ src/channels/plugins/account-helpers.test.ts | 17 + src/channels/plugins/account-helpers.ts | 12 +- src/channels/plugins/config-helpers.test.ts | 110 ++++ src/channels/plugins/config-helpers.ts | 62 ++ src/cli/cron-cli/register.cron-add.ts | 18 +- src/cli/cron-cli/register.cron-simple.ts | 23 +- src/cli/cron-cli/shared.ts | 10 + src/cli/memory-cli.test.ts | 57 +- src/cli/nodes-cli/register.invoke.ts | 18 +- src/cli/qr-cli.test.ts | 171 +++-- src/config/sessions/store.ts | 49 +- src/config/validation.ts | 30 +- .../zod-schema.secret-input-validation.ts | 45 +- ...p-recipient-besteffortdeliver-true.test.ts | 86 +-- ....uses-last-non-empty-agent-text-as.test.ts | 79 +-- src/cron/session-reaper.ts | 35 +- .../monitor/message-handler.preflight.test.ts | 160 ++--- src/discord/voice/manager.e2e.test.ts | 85 +-- src/gateway/auth-config-utils.ts | 69 ++ src/gateway/call.ts | 20 +- src/gateway/credentials.test.ts | 59 +- .../gateway-models.profiles.live.test.ts | 1 + src/gateway/live-tool-probe-utils.test.ts | 62 ++ src/gateway/live-tool-probe-utils.ts | 45 ++ .../openai-http.message-channel.test.ts | 114 ++-- .../server-methods/agents-mutate.test.ts | 10 + src/gateway/server-methods/agents.ts | 13 +- src/gateway/server-methods/nodes.ts | 16 +- src/gateway/server-methods/secrets.test.ts | 71 ++- src/gateway/server.cron.test.ts | 102 ++- src/gateway/test-helpers.server.ts | 99 ++- src/hooks/frontmatter.ts | 20 +- src/hooks/internal-hooks.ts | 41 +- src/hooks/message-hook-mappers.ts | 32 +- src/imessage/target-parsing-helpers.ts | 91 +++ src/imessage/targets.ts | 56 +- src/infra/boundary-path.ts | 21 +- src/infra/exec-approvals.ts | 24 + src/infra/node-pairing.ts | 21 +- src/infra/parse-finite-number.test.ts | 19 + src/infra/parse-finite-number.ts | 12 + src/infra/provider-usage.fetch.shared.ts | 12 +- src/memory/batch-embedding-common.ts | 6 + src/memory/batch-openai.ts | 74 ++- src/memory/batch-status.test.ts | 60 ++ src/memory/batch-status.ts | 69 ++ src/memory/batch-voyage.ts | 74 ++- src/node-host/runner.credentials.test.ts | 58 +- src/node-host/runner.ts | 33 +- src/plugin-sdk/allowlist-resolution.test.ts | 40 ++ src/plugin-sdk/allowlist-resolution.ts | 19 + src/plugin-sdk/bluebubbles.ts | 6 +- src/plugin-sdk/channel-send-result.ts | 14 + src/plugin-sdk/discord-send.ts | 33 + src/plugin-sdk/feishu.ts | 2 + src/plugin-sdk/imessage.ts | 1 + src/plugin-sdk/inbound-reply-dispatch.ts | 143 +++++ src/plugin-sdk/index.ts | 38 +- src/plugin-sdk/irc.ts | 1 + src/plugin-sdk/line.ts | 6 +- src/plugin-sdk/matrix.ts | 9 +- src/plugin-sdk/minimax-portal-auth.ts | 1 + src/plugin-sdk/msteams.ts | 4 + src/plugin-sdk/nextcloud-talk.ts | 6 + src/plugin-sdk/qwen-portal-auth.ts | 1 + src/plugin-sdk/reply-payload.test.ts | 58 ++ src/plugin-sdk/reply-payload.ts | 49 ++ src/plugin-sdk/request-url.test.ts | 17 + src/plugin-sdk/request-url.ts | 12 + src/plugin-sdk/runtime.test.ts | 39 ++ src/plugin-sdk/runtime.ts | 20 + src/plugin-sdk/status-helpers.test.ts | 38 ++ src/plugin-sdk/status-helpers.ts | 35 +- src/plugin-sdk/telegram.ts | 1 + src/plugin-sdk/zalo.ts | 13 +- src/plugin-sdk/zalouser.ts | 9 +- .../wired-hooks-after-tool-call.e2e.test.ts | 15 +- src/secrets/auth-profiles-scan.ts | 46 +- src/secrets/auth-store-paths.ts | 40 ++ src/secrets/path-utils.test.ts | 29 +- src/secrets/path-utils.ts | 140 +++-- src/secrets/resolve-secret-input-string.ts | 41 ++ src/secrets/resolve.ts | 83 ++- src/secrets/storage-scan.ts | 41 +- src/secrets/target-registry-query.ts | 185 +++--- src/shared/frontmatter.ts | 15 + src/slack/monitor/events/interactions.ts | 21 +- src/test-utils/imessage-test-plugin.ts | 17 +- 114 files changed, 3400 insertions(+), 2040 deletions(-) create mode 100644 src/agents/pi-tool-handler-state.test-helpers.ts create mode 100644 src/agents/test-helpers/usage-fixtures.ts create mode 100644 src/agents/trace-base.ts create mode 100644 src/auto-reply/reply/get-reply.test-mocks.ts create mode 100644 src/channels/plugins/config-helpers.test.ts create mode 100644 src/gateway/auth-config-utils.ts create mode 100644 src/infra/parse-finite-number.test.ts create mode 100644 src/infra/parse-finite-number.ts create mode 100644 src/memory/batch-status.test.ts create mode 100644 src/memory/batch-status.ts create mode 100644 src/plugin-sdk/allowlist-resolution.test.ts create mode 100644 src/plugin-sdk/allowlist-resolution.ts create mode 100644 src/plugin-sdk/channel-send-result.ts create mode 100644 src/plugin-sdk/discord-send.ts create mode 100644 src/plugin-sdk/inbound-reply-dispatch.ts create mode 100644 src/plugin-sdk/reply-payload.test.ts create mode 100644 src/plugin-sdk/request-url.test.ts create mode 100644 src/plugin-sdk/request-url.ts create mode 100644 src/plugin-sdk/runtime.test.ts create mode 100644 src/secrets/auth-store-paths.ts create mode 100644 src/secrets/resolve-secret-input-string.ts diff --git a/package.json b/package.json index d8782f9088c..a61fbb92d92 100644 --- a/package.json +++ b/package.json @@ -246,6 +246,8 @@ "docs:list": "node scripts/docs-list.js", "docs:spellcheck": "bash scripts/docs-spellcheck.sh", "docs:spellcheck:fix": "bash scripts/docs-spellcheck.sh --write", + "dup:check": "jscpd src extensions test scripts --format typescript,javascript --pattern \"**/*.{ts,tsx,js,mjs,cjs}\" --gitignore --noSymlinks --ignore \"**/node_modules/**,**/dist/**,**/.git/**,**/coverage/**,**/build/**,**/.build/**,**/.artifacts/**\" --min-lines 12 --min-tokens 80 --reporters console", + "dup:check:json": "jscpd src extensions test scripts --format typescript,javascript --pattern \"**/*.{ts,tsx,js,mjs,cjs}\" --gitignore --noSymlinks --ignore \"**/node_modules/**,**/dist/**,**/.git/**,**/coverage/**,**/build/**,**/.build/**,**/.artifacts/**\" --min-lines 12 --min-tokens 80 --reporters json --output .artifacts/jscpd", "format": "oxfmt --write", "format:all": "pnpm format && pnpm format:swift", "format:check": "oxfmt --check", @@ -393,6 +395,7 @@ "@types/ws": "^8.18.1", "@typescript/native-preview": "7.0.0-dev.20260301.1", "@vitest/coverage-v8": "^4.0.18", + "jscpd": "4.0.8", "lit": "^3.3.2", "oxfmt": "0.35.0", "oxlint": "^1.50.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9a39f1b6c68..9094d0caeea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -212,6 +212,9 @@ importers: '@vitest/coverage-v8': specifier: ^4.0.18 version: 4.0.18(@vitest/browser@4.0.18(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18))(vitest@4.0.18) + jscpd: + specifier: 4.0.8 + version: 4.0.8 lit: specifier: ^3.3.2 version: 3.3.2 @@ -861,6 +864,10 @@ packages: '@cloudflare/workers-types@4.20260120.0': resolution: {integrity: sha512-B8pueG+a5S+mdK3z8oKu1ShcxloZ7qWb68IEyLLaepvdryIbNC7JVPcY0bWsjS56UQVKc5fnyRge3yZIwc9bxw==} + '@colors/colors@1.5.0': + resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} + engines: {node: '>=0.1.90'} + '@cypress/request-promise@5.0.0': resolution: {integrity: sha512-eKdYVpa9cBEw2kTBlHeu1PP16Blwtum6QHg/u9s/MoHkZfuo1pRGka1VlUHXF5kdew82BvOJVVGk0x8X0nbp+w==} engines: {node: '>=0.10.0'} @@ -1298,6 +1305,21 @@ packages: '@js-sdsl/ordered-map@4.4.2': resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} + '@jscpd/badge-reporter@4.0.4': + resolution: {integrity: sha512-I9b4MmLXPM2vo0SxSUWnNGKcA4PjQlD3GzXvFK60z43cN/EIdLbOq3FVwCL+dg2obUqGXKIzAm7EsDFTg0D+mQ==} + + '@jscpd/core@4.0.4': + resolution: {integrity: sha512-QGMT3iXEX1fI6lgjPH+x8eyJwhwr2KkpSF5uBpjC0Z5Xloj0yFTFLtwJT+RhxP/Ob4WYrtx2jvpKB269oIwgMQ==} + + '@jscpd/finder@4.0.4': + resolution: {integrity: sha512-qVUWY7Nzuvfd5OIk+n7/5CM98LmFroLqblRXAI2gDABwZrc7qS+WH2SNr0qoUq0f4OqwM+piiwKvwL/VDNn/Cg==} + + '@jscpd/html-reporter@4.0.4': + resolution: {integrity: sha512-YiepyeYkeH74Kx59PJRdUdonznct0wHPFkf6FLQN+mCBoy6leAWCcOfHtcexnp+UsBFDlItG5nRdKrDSxSH+Kg==} + + '@jscpd/tokenizer@4.0.4': + resolution: {integrity: sha512-xxYYY/qaLah/FlwogEbGIxx9CjDO+G9E6qawcy26WwrflzJb6wsnhjwdneN6Wb0RNCDsqvzY+bzG453jsin4UQ==} + '@keyv/bigmap@1.3.1': resolution: {integrity: sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==} engines: {node: '>= 18'} @@ -1685,6 +1707,18 @@ packages: cpu: [x64] os: [win32] + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + '@nolyfill/domexception@1.0.28': resolution: {integrity: sha512-tlc/FcYIv5i8RYsl2iDil4A0gOihaas1R5jPcIC4Zw3GhjKsVilw90aHcVlhZPTBLGBzd379S+VcnsDjd9ChiA==} engines: {node: '>=12.4.0'} @@ -3089,6 +3123,9 @@ packages: '@types/retry@0.12.0': resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} + '@types/sarif@2.1.7': + resolution: {integrity: sha512-kRz0VEkJqWLf1LLVN4pT1cg1Z9wAuvI6L97V3m2f5B76Tg8d413ddvLBPTEHAZJlnn4XSvu0FkZtViCQGVyrXQ==} + '@types/send@0.17.6': resolution: {integrity: sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==} @@ -3274,6 +3311,11 @@ packages: peerDependencies: acorn: ^8 + acorn@7.4.1: + resolution: {integrity: sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==} + engines: {node: '>=0.4.0'} + hasBin: true + acorn@8.16.0: resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} engines: {node: '>=0.4.0'} @@ -3363,9 +3405,15 @@ packages: array-flatten@1.1.1: resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} + asap@2.0.6: + resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + asn1@0.2.6: resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} + assert-never@1.4.0: + resolution: {integrity: sha512-5oJg84os6NMQNl27T9LnZkvvqzvAnHu03ShCnoj6bsJwS7L8AO4lf+C/XjK/nvzEqQB744moC6V128RucQd1jA==} + assert-plus@1.0.0: resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==} engines: {node: '>=0.8'} @@ -3428,6 +3476,13 @@ packages: react-native-b4a: optional: true + babel-walk@3.0.0-canary-5: + resolution: {integrity: sha512-GAwkz0AihzY5bkwIY5QDR+LvsRQgB/B+1foMPvi0FZPMl5fjD7ICiznUiBdLYMH1QYe6vqu4gWYytZOccLouFw==} + engines: {node: '>= 10.0.0'} + + badgen@3.2.3: + resolution: {integrity: sha512-svDuwkc63E/z0ky3drpUppB83s/nlgDciH9m+STwwQoWyq7yCgew1qEfJ+9axkKdNq7MskByptWUN9j1PGMwFA==} + balanced-match@4.0.4: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} @@ -3467,6 +3522,10 @@ packages: birpc@4.0.0: resolution: {integrity: sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw==} + blamer@1.0.7: + resolution: {integrity: sha512-GbBStl/EVlSWkiJQBZps3H1iARBrC7vt++Jb/TTmCNu/jZ04VW7tSN1nScbFXBUy1AN+jzeL7Zep9sbQxLhXKA==} + engines: {node: '>=8.9'} + bluebird@3.7.2: resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} @@ -3491,6 +3550,10 @@ packages: resolution: {integrity: sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==} engines: {node: 18 || 20 || >=22} + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + browser-or-node@1.3.0: resolution: {integrity: sha512-0F2z/VSnLbmEeBcUrSuDH5l0HxTXdQQzLjkmBR4cYfvg1zJrKSlmIZFqyFR8oX0NrwPhy3c3HQ6i3OxMbew4Tg==} @@ -3559,6 +3622,9 @@ packages: character-entities-legacy@3.0.0: resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + character-parser@2.2.0: + resolution: {integrity: sha512-+UqJQjFEFaTAs3bNsF2j2kEN1baG/zghZbdqoYEDxGZtJo9LBzl1A+m0D4n3qKx8N2FNv8/Xp6yV9mQmBuptaw==} + chmodrp@1.0.2: resolution: {integrity: sha512-TdngOlFV1FLTzU0o1w8MB6/BFywhtLC0SzRTGJU7T9lmdjlCWeMRt1iVo0Ki+ldwNk0BqNiKoc8xpLZEQ8mY1w==} @@ -3594,6 +3660,10 @@ packages: resolution: {integrity: sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==} engines: {node: '>=18.20'} + cli-table3@0.6.5: + resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} + engines: {node: 10.* || >= 12.*} + cliui@7.0.4: resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} @@ -3620,6 +3690,10 @@ packages: resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==} hasBin: true + colors@1.4.0: + resolution: {integrity: sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==} + engines: {node: '>=0.1.90'} + combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} @@ -3647,9 +3721,16 @@ packages: resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} + commander@5.1.0: + resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==} + engines: {node: '>= 6'} + console-control-strings@1.1.0: resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==} + constantinople@4.0.1: + resolution: {integrity: sha512-vCrqcSIq4//Gx74TXXCGnHpulY1dskqLTFGDmhrGxzeXL8lF8kvXv6mpNWlJj1uD4DW23D4ljAqbY4RRaaUZIw==} + content-disposition@0.5.4: resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} engines: {node: '>= 0.6'} @@ -3789,6 +3870,9 @@ packages: discord-api-types@0.38.40: resolution: {integrity: sha512-P/His8cotqZgQqrt+hzrocp9L8RhQQz1GkrCnC9TMJ8Uw2q0tg8YyqJyGULxhXn/8kxHETN4IppmOv+P2m82lQ==} + doctypes@1.1.0: + resolution: {integrity: sha512-LLBi6pEqS6Do3EKQ3J0NqHWV5hhb78Pi8vvESYwyOy2c31ZEZVdtitdzsQsKb7878PEERhzUk0ftqGhG6Mz+pQ==} + dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} @@ -3940,6 +4024,10 @@ packages: events-universal@1.0.1: resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + execa@4.1.0: + resolution: {integrity: sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==} + engines: {node: '>=10'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -3976,6 +4064,10 @@ packages: fast-fifo@1.3.2: resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} @@ -3983,6 +4075,9 @@ packages: resolution: {integrity: sha512-53jIF4N6u/pxvaL1eb/hEZts/cFLWZ92eCfLrNyCI0k38lettCG/Bs40W9pPwoPXyHQlKu2OUbQtiEIZK/J6Vw==} hasBin: true + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + fd-slicer@1.1.0: resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} @@ -4011,6 +4106,10 @@ packages: resolution: {integrity: sha512-vqIlNogKeyD3yzrm0yhRMQg8hOVwYcYRfjEoODd49iCprMn4HL85gK3HcykQE53EPIpX3HcAbGA5ELQv216dAQ==} engines: {node: '>=16'} + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + finalhandler@1.3.2: resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} engines: {node: '>= 0.8'} @@ -4126,6 +4225,14 @@ packages: getpass@0.1.7: resolution: {integrity: sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==} + gitignore-to-glob@0.3.0: + resolution: {integrity: sha512-mk74BdnK7lIwDHnotHddx1wsjMOFIThpLY3cPNniJ/2fA/tlLzHnFxIdR+4sLOu5KGgQJdij4kjJ2RoUNnCNMA==} + engines: {node: '>=4.4 <5 || >=6.9'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + glob-to-regexp@0.4.1: resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} @@ -4256,6 +4363,10 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} + human-signals@1.1.1: + resolution: {integrity: sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==} + engines: {node: '>=8.12.0'} + iconv-lite@0.4.24: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} @@ -4311,9 +4422,20 @@ packages: ircv3@0.33.0: resolution: {integrity: sha512-7rK1Aial3LBiFycE8w3MHiBBFb41/2GG2Ll/fR2IJj1vx0pLpn1s+78K+z/I4PZTqCCSp/Sb4QgKMh3NMhx0Kg==} + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + is-electron@2.2.2: resolution: {integrity: sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg==} + is-expression@4.0.0: + resolution: {integrity: sha512-zMIXX63sxzG3XrkHkrAPvm/OVZVSCPNkwMHU8oTX7/U3AL78I0QXCEICXUM13BIa8TYGZ68PiTKfQz3yaTNr4A==} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} @@ -4322,10 +4444,18 @@ packages: resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} engines: {node: '>=18'} + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + is-interactive@2.0.0: resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} engines: {node: '>=12'} + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + is-plain-object@5.0.0: resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} engines: {node: '>=0.10.0'} @@ -4336,6 +4466,10 @@ packages: is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + is-stream@2.0.1: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} @@ -4382,12 +4516,22 @@ packages: jose@4.15.9: resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==} + js-stringify@1.0.2: + resolution: {integrity: sha512-rtS5ATOo2Q5k1G+DADISilDA6lv79zIiwFd6CcjuIxGKLFm5C+RLImRscVap9k55i+MOZwgliw+NejvkLuGD5g==} + js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} jsbn@0.1.1: resolution: {integrity: sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==} + jscpd-sarif-reporter@4.0.6: + resolution: {integrity: sha512-b9Sm3IPZ3+m8Lwa4gZa+4/LhDhlc/ZLEsLXKSOy1DANQ6kx0ueqZT+fUHWEdQ6m0o3+RIVIa7DmvLSojQD05ng==} + + jscpd@4.0.8: + resolution: {integrity: sha512-d2VNT/2Hv4dxT2/59He8Lyda4DYOxPRyRG9zBaOpTZAqJCVf2xLrBlZkT8Va6Lo9u3X2qz8Bpq4HrDi4JsrQhA==} + hasBin: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -4432,6 +4576,9 @@ packages: resolution: {integrity: sha512-gqXddjPqQ6G40VdnI6T6yObEC+pDNvyP95wdQhkWkg7crHH3km5qP1FsOXEkzEQwnz6gz5qGTn1c2Y52wP3OyQ==} engines: {'0': node >=0.6.0} + jstransformer@1.0.0: + resolution: {integrity: sha512-C9YK3Rf8q6VAPDCCU9fnqo3mAfOH6vUGnMcP4AQAYIEpWtfGLpwOTmZ+igtdK5y+VvI2n3CyYSzy4Qh34eq24A==} + jszip@3.10.1: resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} @@ -4663,6 +4810,9 @@ packages: resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==} hasBin: true + markdown-table@2.0.0: + resolution: {integrity: sha512-Ezda85ToJUBhM6WGaG6veasyym+Tbs3cMAw/ZhOPqXiYsr0jgocBV3j3nx+4lk47plLlIqjwuTm/ywVI+zjJ/A==} + marked@15.0.12: resolution: {integrity: sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==} engines: {node: '>= 18'} @@ -4698,6 +4848,13 @@ packages: resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} engines: {node: '>=18'} + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + methods@1.1.2: resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} engines: {node: '>= 0.6'} @@ -4717,6 +4874,10 @@ packages: micromark-util-types@2.0.2: resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + mime-db@1.52.0: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} @@ -4738,6 +4899,10 @@ packages: engines: {node: '>=4'} hasBin: true + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + mimic-function@5.0.1: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} @@ -4856,6 +5021,10 @@ packages: node-readable-to-web-readable-stream@0.4.2: resolution: {integrity: sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==} + node-sarif-builder@3.4.0: + resolution: {integrity: sha512-tGnJW6OKRii9u/b2WiUViTJS+h7Apxx17qsMUjsUeNDiMMX5ZFf8F8Fcz7PAQ6omvOxHZtvDTmOYKJQwmfpjeg==} + engines: {node: '>=20'} + node-wav@0.0.2: resolution: {integrity: sha512-M6Rm/bbG6De/gKGxOpeOobx/dnGuP0dz40adqx38boqHhlWssBJZgLCPBNtb9NkrmnKYiV04xELq+R6PFOnoLA==} engines: {node: '>=4.4.0'} @@ -4876,6 +5045,10 @@ packages: nostr-wasm@0.1.0: resolution: {integrity: sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==} + npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + npmlog@5.0.1: resolution: {integrity: sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==} deprecated: This package is no longer supported. @@ -4924,6 +5097,10 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + onetime@7.0.0: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} @@ -5078,6 +5255,9 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + path-scurry@1.11.1: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} @@ -5111,6 +5291,10 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + picomatch@4.0.3: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} @@ -5190,6 +5374,9 @@ packages: process-warning@5.0.0: resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + promise@7.3.1: + resolution: {integrity: sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==} + proper-lockfile@4.1.2: resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} @@ -5222,6 +5409,42 @@ packages: psl@1.15.0: resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} + pug-attrs@3.0.0: + resolution: {integrity: sha512-azINV9dUtzPMFQktvTXciNAfAuVh/L/JCl0vtPCwvOA21uZrC08K/UnmrL+SXGEVc1FwzjW62+xw5S/uaLj6cA==} + + pug-code-gen@3.0.3: + resolution: {integrity: sha512-cYQg0JW0w32Ux+XTeZnBEeuWrAY7/HNE6TWnhiHGnnRYlCgyAUPoyh9KzCMa9WhcJlJ1AtQqpEYHc+vbCzA+Aw==} + + pug-error@2.1.0: + resolution: {integrity: sha512-lv7sU9e5Jk8IeUheHata6/UThZ7RK2jnaaNztxfPYUY+VxZyk/ePVaNZ/vwmH8WqGvDz3LrNYt/+gA55NDg6Pg==} + + pug-filters@4.0.0: + resolution: {integrity: sha512-yeNFtq5Yxmfz0f9z2rMXGw/8/4i1cCFecw/Q7+D0V2DdtII5UvqE12VaZ2AY7ri6o5RNXiweGH79OCq+2RQU4A==} + + pug-lexer@5.0.1: + resolution: {integrity: sha512-0I6C62+keXlZPZkOJeVam9aBLVP2EnbeDw3An+k0/QlqdwH6rv8284nko14Na7c0TtqtogfWXcRoFE4O4Ff20w==} + + pug-linker@4.0.0: + resolution: {integrity: sha512-gjD1yzp0yxbQqnzBAdlhbgoJL5qIFJw78juN1NpTLt/mfPJ5VgC4BvkoD3G23qKzJtIIXBbcCt6FioLSFLOHdw==} + + pug-load@3.0.0: + resolution: {integrity: sha512-OCjTEnhLWZBvS4zni/WUMjH2YSUosnsmjGBB1An7CsKQarYSWQ0GCVyd4eQPMFJqZ8w9xgs01QdiZXKVjk92EQ==} + + pug-parser@6.0.0: + resolution: {integrity: sha512-ukiYM/9cH6Cml+AOl5kETtM9NR3WulyVP2y4HOU45DyMim1IeP/OOiyEWRr6qk5I5klpsBnbuHpwKmTx6WURnw==} + + pug-runtime@3.0.1: + resolution: {integrity: sha512-L50zbvrQ35TkpHwv0G6aLSuueDRwc/97XdY8kL3tOT0FmhgG7UypU3VztfV/LATAvmUfYi4wNxSajhSAeNN+Kg==} + + pug-strip-comments@2.0.0: + resolution: {integrity: sha512-zo8DsDpH7eTkPHCXFeAk1xZXJbyoTfdPlNR0bK7rpOMuhBYb0f5qUVCO1xlsitYd3w5FQTK7zpNVKb3rZoUrrQ==} + + pug-walk@2.0.0: + resolution: {integrity: sha512-yYELe9Q5q9IQhuvqsZNwA5hfPkMJ8u92bQLIMcsMxf/VADjNtEYptU+inlufAFYcWdHlwNfZOEnOOQrZrcyJCQ==} + + pug@3.0.3: + resolution: {integrity: sha512-uBi6kmc9f3SZ3PXxqcHiUZLmIXgfgWooKWXcwSGwQd2Zi5Rb0bT14+8CJjJgI8AB+nndLaNgHGrcc6bPIB665g==} + pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} @@ -5254,6 +5477,9 @@ packages: querystringify@2.2.0: resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} @@ -5309,6 +5535,13 @@ packages: regex@6.1.0: resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + repeat-string@1.6.1: + resolution: {integrity: sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==} + engines: {node: '>=0.10'} + + reprism@0.0.11: + resolution: {integrity: sha512-VsxDR5QxZo08M/3nRypNlScw5r3rKeSOPdU/QhDmu3Ai3BJxHn/qgfXGWQp/tAxUtzwYNo9W6997JZR0tPLZsA==} + request-promise-core@1.1.3: resolution: {integrity: sha512-QIs2+ArIGQVp5ZYbWD5ZLCY29D5CfWizP8eWnm8FoGD1TX61veauETVQbrV60662V0oFBkrDOuaBI8XgtuyYAQ==} engines: {node: '>=0.10.0'} @@ -5333,6 +5566,11 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + resolve@1.22.11: + resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} + engines: {node: '>= 0.4'} + hasBin: true + restore-cursor@5.1.0: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} @@ -5345,6 +5583,10 @@ packages: resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} engines: {node: '>= 4'} + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rimraf@3.0.2: resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} deprecated: Rimraf versions prior to v4 are no longer supported @@ -5387,6 +5629,9 @@ packages: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + safe-buffer@5.1.2: resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} @@ -5658,6 +5903,10 @@ packages: resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} engines: {node: '>=12'} + strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + strip-json-comments@2.0.1: resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} engines: {node: '>=0.10.0'} @@ -5673,6 +5922,10 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + table-layout@4.1.1: resolution: {integrity: sha512-iK5/YhZxq5GO5z8wb0bY1317uDF3Zjpha0QFFLA8/trAoiLbQD0HUbMesEaxyzUgDxi2QlcbM8IvqOlEjgoXBA==} engines: {node: '>=12.17'} @@ -5716,6 +5969,10 @@ packages: resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} engines: {node: '>=14.0.0'} + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + toad-cache@3.7.0: resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==} engines: {node: '>=12'} @@ -5724,6 +5981,9 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + token-stream@1.0.0: + resolution: {integrity: sha512-VSsyNPPW74RpHwR8Fc21uubwHY7wMDeJLys2IX5zJNih+OnAnaifKHo+1LHT7DAdloQ7apeaaWg8l7qnf/TnEg==} + token-types@6.1.2: resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} engines: {node: '>=14.16'} @@ -6003,6 +6263,10 @@ packages: jsdom: optional: true + void-elements@3.1.0: + resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} + engines: {node: '>=0.10.0'} + web-streams-polyfill@3.3.3: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} @@ -6034,6 +6298,10 @@ packages: win-guid@0.2.1: resolution: {integrity: sha512-gEIQU4mkgl2OPeoNrWflcJFJ3Ae2BPd4eCsHHA/XikslkIVms/nHhvnvzIZV7VLmBvtFlDOzLt9rrZT+n6D67A==} + with@7.0.2: + resolution: {integrity: sha512-RNGKj82nUPg3g5ygxkQl0R937xLyho1J24ItRCBTr/m1YnZkzJy1hUiHUJrc/VlsDQzsCnInEGSg3bci0Lmd4w==} + engines: {node: '>= 10.0.0'} + wordwrapjs@5.1.1: resolution: {integrity: sha512-0yweIbkINJodk27gX9LBGMzyQdBDan3s/dEAiwBOj+Mf0PPyWL6/rikalkv8EeD0E8jm4o5RXEOrFTP3NXbhJg==} engines: {node: '>=12.17'} @@ -6852,6 +7120,9 @@ snapshots: '@cloudflare/workers-types@4.20260120.0': optional: true + '@colors/colors@1.5.0': + optional: true + '@cypress/request-promise@5.0.0(@cypress/request@3.0.10)(@cypress/request@3.0.10)': dependencies: '@cypress/request': 3.0.10 @@ -7250,6 +7521,41 @@ snapshots: '@js-sdsl/ordered-map@4.4.2': {} + '@jscpd/badge-reporter@4.0.4': + dependencies: + badgen: 3.2.3 + colors: 1.4.0 + fs-extra: 11.3.3 + + '@jscpd/core@4.0.4': + dependencies: + eventemitter3: 5.0.4 + + '@jscpd/finder@4.0.4': + dependencies: + '@jscpd/core': 4.0.4 + '@jscpd/tokenizer': 4.0.4 + blamer: 1.0.7 + bytes: 3.1.2 + cli-table3: 0.6.5 + colors: 1.4.0 + fast-glob: 3.3.3 + fs-extra: 11.3.3 + markdown-table: 2.0.0 + pug: 3.0.3 + + '@jscpd/html-reporter@4.0.4': + dependencies: + colors: 1.4.0 + fs-extra: 11.3.3 + pug: 3.0.3 + + '@jscpd/tokenizer@4.0.4': + dependencies: + '@jscpd/core': 4.0.4 + reprism: 0.0.11 + spark-md5: 3.0.2 + '@keyv/bigmap@1.3.1(keyv@5.6.0)': dependencies: hashery: 1.5.0 @@ -7627,6 +7933,18 @@ snapshots: '@node-llama-cpp/win-x64@3.16.2': optional: true + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + '@nolyfill/domexception@1.0.28': {} '@octokit/app@16.1.2': @@ -9114,6 +9432,8 @@ snapshots: '@types/retry@0.12.0': {} + '@types/sarif@2.1.7': {} + '@types/send@0.17.6': dependencies: '@types/mime': 1.3.5 @@ -9373,6 +9693,8 @@ snapshots: dependencies: acorn: 8.16.0 + acorn@7.4.1: {} + acorn@8.16.0: {} acpx@0.1.15(zod@4.3.6): @@ -9454,10 +9776,14 @@ snapshots: array-flatten@1.1.1: {} + asap@2.0.6: {} + asn1@0.2.6: dependencies: safer-buffer: 2.1.2 + assert-never@1.4.0: {} + assert-plus@1.0.0: {} assertion-error@2.0.1: {} @@ -9524,6 +9850,12 @@ snapshots: b4a@1.8.0: {} + babel-walk@3.0.0-canary-5: + dependencies: + '@babel/types': 7.29.0 + + badgen@3.2.3: {} + balanced-match@4.0.4: {} bare-events@2.8.2: {} @@ -9548,6 +9880,11 @@ snapshots: birpc@4.0.0: {} + blamer@1.0.7: + dependencies: + execa: 4.1.0 + which: 2.0.2 + bluebird@3.7.2: {} body-parser@1.20.4: @@ -9591,6 +9928,10 @@ snapshots: dependencies: balanced-match: 4.0.4 + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + browser-or-node@1.3.0: {} browser-or-node@3.0.0: {} @@ -9654,6 +9995,10 @@ snapshots: character-entities-legacy@3.0.0: {} + character-parser@2.2.0: + dependencies: + is-regex: 1.2.1 + chmodrp@1.0.2: {} chokidar@5.0.0: @@ -9683,6 +10028,12 @@ snapshots: cli-spinners@3.4.0: {} + cli-table3@0.6.5: + dependencies: + string-width: 4.2.3 + optionalDependencies: + '@colors/colors': 1.5.0 + cliui@7.0.4: dependencies: string-width: 4.2.3 @@ -9721,6 +10072,8 @@ snapshots: color-support@1.1.3: optional: true + colors@1.4.0: {} + combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 @@ -9747,9 +10100,16 @@ snapshots: commander@14.0.3: {} + commander@5.1.0: {} + console-control-strings@1.1.0: optional: true + constantinople@4.0.1: + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + content-disposition@0.5.4: dependencies: safe-buffer: 5.2.1 @@ -9847,6 +10207,8 @@ snapshots: discord-api-types@0.38.40: {} + doctypes@1.1.0: {} + dom-serializer@2.0.0: dependencies: domelementtype: 2.3.0 @@ -9996,6 +10358,18 @@ snapshots: transitivePeerDependencies: - bare-abort-controller + execa@4.1.0: + dependencies: + cross-spawn: 7.0.6 + get-stream: 5.2.0 + human-signals: 1.1.1 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + expect-type@1.3.0: {} exponential-backoff@3.1.3: {} @@ -10089,12 +10463,24 @@ snapshots: fast-fifo@1.3.2: {} + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + fast-uri@3.1.0: {} fast-xml-parser@5.3.8: dependencies: strnum: 2.2.0 + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + fd-slicer@1.1.0: dependencies: pend: 1.2.0 @@ -10123,6 +10509,10 @@ snapshots: dependencies: filename-reserved-regex: 3.0.0 + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + finalhandler@1.3.2: dependencies: debug: 2.6.9 @@ -10269,6 +10659,12 @@ snapshots: dependencies: assert-plus: 1.0.0 + gitignore-to-glob@0.3.0: {} + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + glob-to-regexp@0.4.1: {} glob@10.5.0: @@ -10446,6 +10842,8 @@ snapshots: transitivePeerDependencies: - supports-color + human-signals@1.1.1: {} + iconv-lite@0.4.24: dependencies: safer-buffer: 2.1.2 @@ -10522,22 +10920,46 @@ snapshots: - bufferutil - utf-8-validate + is-core-module@2.16.1: + dependencies: + hasown: 2.0.2 + is-electron@2.2.2: {} + is-expression@4.0.0: + dependencies: + acorn: 7.4.1 + object-assign: 4.1.1 + + is-extglob@2.1.1: {} + is-fullwidth-code-point@3.0.0: {} is-fullwidth-code-point@5.1.0: dependencies: get-east-asian-width: 1.5.0 + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + is-interactive@2.0.0: {} + is-number@7.0.0: {} + is-plain-object@5.0.0: {} is-promise@2.2.2: {} is-promise@4.0.0: {} + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + is-stream@2.0.1: {} is-typedarray@1.0.0: {} @@ -10575,10 +10997,31 @@ snapshots: jose@4.15.9: {} + js-stringify@1.0.2: {} + js-tokens@10.0.0: {} jsbn@0.1.1: {} + jscpd-sarif-reporter@4.0.6: + dependencies: + colors: 1.4.0 + fs-extra: 11.3.3 + node-sarif-builder: 3.4.0 + + jscpd@4.0.8: + dependencies: + '@jscpd/badge-reporter': 4.0.4 + '@jscpd/core': 4.0.4 + '@jscpd/finder': 4.0.4 + '@jscpd/html-reporter': 4.0.4 + '@jscpd/tokenizer': 4.0.4 + colors: 1.4.0 + commander: 5.1.0 + fs-extra: 11.3.3 + gitignore-to-glob: 0.3.0 + jscpd-sarif-reporter: 4.0.6 + jsesc@3.1.0: {} json-bigint@1.0.0: @@ -10628,6 +11071,11 @@ snapshots: json-schema: 0.4.0 verror: 1.10.0 + jstransformer@1.0.0: + dependencies: + is-promise: 2.2.2 + promise: 7.3.1 + jszip@3.10.1: dependencies: lie: 3.3.0 @@ -10850,6 +11298,10 @@ snapshots: punycode.js: 2.3.1 uc.micro: 2.1.0 + markdown-table@2.0.0: + dependencies: + repeat-string: 1.6.1 + marked@15.0.12: {} marked@17.0.3: {} @@ -10878,6 +11330,10 @@ snapshots: merge-descriptors@2.0.0: {} + merge-stream@2.0.0: {} + + merge2@1.4.1: {} + methods@1.1.2: {} micromark-util-character@2.1.1: @@ -10897,6 +11353,11 @@ snapshots: micromark-util-types@2.0.2: {} + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + mime-db@1.52.0: {} mime-db@1.54.0: {} @@ -10911,6 +11372,8 @@ snapshots: mime@1.6.0: {} + mimic-fn@2.1.0: {} + mimic-function@5.0.1: {} minimalistic-assert@1.0.1: {} @@ -11061,6 +11524,11 @@ snapshots: node-readable-to-web-readable-stream@0.4.2: optional: true + node-sarif-builder@3.4.0: + dependencies: + '@types/sarif': 2.1.7 + fs-extra: 11.3.3 + node-wav@0.0.2: optional: true @@ -11083,6 +11551,10 @@ snapshots: nostr-wasm@0.1.0: {} + npm-run-path@4.0.1: + dependencies: + path-key: 3.1.1 + npmlog@5.0.1: dependencies: are-we-there-yet: 2.0.0 @@ -11141,6 +11613,10 @@ snapshots: dependencies: wrappy: 1.0.2 + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + onetime@7.0.0: dependencies: mimic-function: 5.0.1 @@ -11395,6 +11871,8 @@ snapshots: path-key@3.1.1: {} + path-parse@1.0.7: {} + path-scurry@1.11.1: dependencies: lru-cache: 10.4.3 @@ -11424,6 +11902,8 @@ snapshots: picocolors@1.1.1: {} + picomatch@2.3.1: {} + picomatch@4.0.3: {} pify@3.0.0: {} @@ -11489,6 +11969,10 @@ snapshots: process-warning@5.0.0: {} + promise@7.3.1: + dependencies: + asap: 2.0.6 + proper-lockfile@4.1.2: dependencies: graceful-fs: 4.2.11 @@ -11567,6 +12051,73 @@ snapshots: dependencies: punycode: 2.3.1 + pug-attrs@3.0.0: + dependencies: + constantinople: 4.0.1 + js-stringify: 1.0.2 + pug-runtime: 3.0.1 + + pug-code-gen@3.0.3: + dependencies: + constantinople: 4.0.1 + doctypes: 1.1.0 + js-stringify: 1.0.2 + pug-attrs: 3.0.0 + pug-error: 2.1.0 + pug-runtime: 3.0.1 + void-elements: 3.1.0 + with: 7.0.2 + + pug-error@2.1.0: {} + + pug-filters@4.0.0: + dependencies: + constantinople: 4.0.1 + jstransformer: 1.0.0 + pug-error: 2.1.0 + pug-walk: 2.0.0 + resolve: 1.22.11 + + pug-lexer@5.0.1: + dependencies: + character-parser: 2.2.0 + is-expression: 4.0.0 + pug-error: 2.1.0 + + pug-linker@4.0.0: + dependencies: + pug-error: 2.1.0 + pug-walk: 2.0.0 + + pug-load@3.0.0: + dependencies: + object-assign: 4.1.1 + pug-walk: 2.0.0 + + pug-parser@6.0.0: + dependencies: + pug-error: 2.1.0 + token-stream: 1.0.0 + + pug-runtime@3.0.1: {} + + pug-strip-comments@2.0.0: + dependencies: + pug-error: 2.1.0 + + pug-walk@2.0.0: {} + + pug@3.0.3: + dependencies: + pug-code-gen: 3.0.3 + pug-filters: 4.0.0 + pug-lexer: 5.0.1 + pug-linker: 4.0.0 + pug-load: 3.0.0 + pug-parser: 6.0.0 + pug-runtime: 3.0.1 + pug-strip-comments: 2.0.0 + pump@3.0.4: dependencies: end-of-stream: 1.4.5 @@ -11595,6 +12146,8 @@ snapshots: querystringify@2.2.0: {} + queue-microtask@1.2.3: {} + quick-format-unescaped@4.0.4: {} range-parser@1.2.1: {} @@ -11660,6 +12213,10 @@ snapshots: dependencies: regex-utilities: 2.3.0 + repeat-string@1.6.1: {} + + reprism@0.0.11: {} + request-promise-core@1.1.3(@cypress/request@3.0.10): dependencies: lodash: 4.17.23 @@ -11680,6 +12237,12 @@ snapshots: resolve-pkg-maps@1.0.0: {} + resolve@1.22.11: + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + restore-cursor@5.1.0: dependencies: onetime: 7.0.0 @@ -11689,6 +12252,8 @@ snapshots: retry@0.13.1: {} + reusify@1.1.0: {} + rimraf@3.0.2: dependencies: glob: 7.2.3 @@ -11776,6 +12341,10 @@ snapshots: transitivePeerDependencies: - supports-color + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + safe-buffer@5.1.2: {} safe-buffer@5.2.1: {} @@ -12138,6 +12707,8 @@ snapshots: dependencies: ansi-regex: 6.2.2 + strip-final-newline@2.0.0: {} + strip-json-comments@2.0.1: {} strnum@2.2.0: {} @@ -12150,6 +12721,8 @@ snapshots: dependencies: has-flag: 4.0.0 + supports-preserve-symlinks-flag@1.0.0: {} + table-layout@4.1.1: dependencies: array-back: 6.2.2 @@ -12203,10 +12776,16 @@ snapshots: tinyrainbow@3.0.3: {} + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + toad-cache@3.7.0: {} toidentifier@1.0.1: {} + token-stream@1.0.0: {} + token-types@6.1.2: dependencies: '@borewit/text-codec': 0.2.1 @@ -12441,6 +13020,8 @@ snapshots: - tsx - yaml + void-elements@3.1.0: {} + web-streams-polyfill@3.3.3: {} webidl-conversions@3.0.1: {} @@ -12470,6 +13051,13 @@ snapshots: win-guid@0.2.1: {} + with@7.0.2: + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + assert-never: 1.4.0 + babel-walk: 3.0.0-canary-5 + wordwrapjs@5.1.1: {} wrap-ansi@7.0.0: diff --git a/src/acp/server.startup.test.ts b/src/acp/server.startup.test.ts index 66dfeb0c25e..bcc9717b167 100644 --- a/src/acp/server.startup.test.ts +++ b/src/acp/server.startup.test.ts @@ -100,6 +100,26 @@ vi.mock("./translator.js", () => ({ describe("serveAcpGateway startup", () => { let serveAcpGateway: typeof import("./server.js").serveAcpGateway; + function getMockGateway() { + const gateway = mockState.gateways[0]; + if (!gateway) { + throw new Error("Expected mocked gateway instance"); + } + return gateway; + } + + function captureProcessSignalHandlers() { + const signalHandlers = new Map void>(); + const onceSpy = vi.spyOn(process, "once").mockImplementation((( + signal: NodeJS.Signals, + handler: () => void, + ) => { + signalHandlers.set(signal, handler); + return process; + }) as typeof process.once); + return { signalHandlers, onceSpy }; + } + beforeAll(async () => { ({ serveAcpGateway } = await import("./server.js")); }); @@ -117,25 +137,14 @@ describe("serveAcpGateway startup", () => { }); it("waits for gateway hello before creating AgentSideConnection", async () => { - const signalHandlers = new Map void>(); - const onceSpy = vi.spyOn(process, "once").mockImplementation((( - signal: NodeJS.Signals, - handler: () => void, - ) => { - signalHandlers.set(signal, handler); - return process; - }) as typeof process.once); + const { signalHandlers, onceSpy } = captureProcessSignalHandlers(); try { const servePromise = serveAcpGateway({}); await Promise.resolve(); expect(mockState.agentSideConnectionCtor).not.toHaveBeenCalled(); - const gateway = mockState.gateways[0]; - if (!gateway) { - throw new Error("Expected mocked gateway instance"); - } - + const gateway = getMockGateway(); gateway.emitHello(); await vi.waitFor(() => { expect(mockState.agentSideConnectionCtor).toHaveBeenCalledTimes(1); @@ -159,11 +168,7 @@ describe("serveAcpGateway startup", () => { const servePromise = serveAcpGateway({}); await Promise.resolve(); - const gateway = mockState.gateways[0]; - if (!gateway) { - throw new Error("Expected mocked gateway instance"); - } - + const gateway = getMockGateway(); gateway.emitConnectError("connect failed"); await expect(servePromise).rejects.toThrow("connect failed"); expect(mockState.agentSideConnectionCtor).not.toHaveBeenCalled(); @@ -177,14 +182,7 @@ describe("serveAcpGateway startup", () => { token: undefined, password: "resolved-secret-password", }); - const signalHandlers = new Map void>(); - const onceSpy = vi.spyOn(process, "once").mockImplementation((( - signal: NodeJS.Signals, - handler: () => void, - ) => { - signalHandlers.set(signal, handler); - return process; - }) as typeof process.once); + const { signalHandlers, onceSpy } = captureProcessSignalHandlers(); try { const servePromise = serveAcpGateway({}); @@ -200,10 +198,7 @@ describe("serveAcpGateway startup", () => { password: "resolved-secret-password", }); - const gateway = mockState.gateways[0]; - if (!gateway) { - throw new Error("Expected mocked gateway instance"); - } + const gateway = getMockGateway(); gateway.emitHello(); await vi.waitFor(() => { expect(mockState.agentSideConnectionCtor).toHaveBeenCalledTimes(1); diff --git a/src/agents/bash-tools.exec-host-gateway.ts b/src/agents/bash-tools.exec-host-gateway.ts index 04f88497843..49a958c9c5b 100644 --- a/src/agents/bash-tools.exec-host-gateway.ts +++ b/src/agents/bash-tools.exec-host-gateway.ts @@ -1,4 +1,3 @@ -import crypto from "node:crypto"; import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import { addAllowlistEntry, @@ -20,11 +19,12 @@ import { registerExecApprovalRequestForHostOrThrow, } from "./bash-tools.exec-approval-request.js"; import { + createDefaultExecApprovalRequestContext, + resolveBaseExecApprovalDecision, resolveApprovalDecisionOrUndefined, resolveExecHostApprovalContext, } from "./bash-tools.exec-host-shared.js"; import { - DEFAULT_APPROVAL_TIMEOUT_MS, DEFAULT_NOTIFY_TAIL_CHARS, createApprovalSlug, emitExecSystemEvent, @@ -138,16 +138,24 @@ export async function processGatewayAllowlist( } if (requiresAsk) { - const approvalId = crypto.randomUUID(); - const approvalSlug = createApprovalSlug(approvalId); - const contextKey = `exec:${approvalId}`; + const { + approvalId, + approvalSlug, + contextKey, + noticeSeconds, + warningText, + expiresAtMs: defaultExpiresAtMs, + preResolvedDecision: defaultPreResolvedDecision, + } = createDefaultExecApprovalRequestContext({ + warnings: params.warnings, + approvalRunningNoticeMs: params.approvalRunningNoticeMs, + createApprovalSlug, + }); const resolvedPath = allowlistEval.segments[0]?.resolution?.resolvedPath; - const noticeSeconds = Math.max(1, Math.round(params.approvalRunningNoticeMs / 1000)); const effectiveTimeout = typeof params.timeoutSec === "number" ? params.timeoutSec : params.defaultTimeoutSec; - const warningText = params.warnings.length ? `${params.warnings.join("\n")}\n\n` : ""; - let expiresAtMs = Date.now() + DEFAULT_APPROVAL_TIMEOUT_MS; - let preResolvedDecision: string | null | undefined; + let expiresAtMs = defaultExpiresAtMs; + let preResolvedDecision = defaultPreResolvedDecision; // Register first so the returned approval ID is actionable immediately. const registration = await registerExecApprovalRequestForHostOrThrow({ @@ -184,24 +192,19 @@ export async function processGatewayAllowlist( return; } - let approvedByAsk = false; - let deniedReason: string | null = null; + const baseDecision = resolveBaseExecApprovalDecision({ + decision, + askFallback, + obfuscationDetected: obfuscation.detected, + }); + let approvedByAsk = baseDecision.approvedByAsk; + let deniedReason = baseDecision.deniedReason; - if (decision === "deny") { - deniedReason = "user-denied"; - } else if (!decision) { - if (obfuscation.detected) { - deniedReason = "approval-timeout (obfuscation-detected)"; - } else if (askFallback === "full") { - approvedByAsk = true; - } else if (askFallback === "allowlist") { - if (!analysisOk || !allowlistSatisfied) { - deniedReason = "approval-timeout (allowlist-miss)"; - } else { - approvedByAsk = true; - } + if (baseDecision.timedOut && askFallback === "allowlist") { + if (!analysisOk || !allowlistSatisfied) { + deniedReason = "approval-timeout (allowlist-miss)"; } else { - deniedReason = "approval-timeout"; + approvedByAsk = true; } } else if (decision === "allow-once") { approvedByAsk = true; diff --git a/src/agents/bash-tools.exec-host-node.ts b/src/agents/bash-tools.exec-host-node.ts index 74c740cc1da..b66a6ededf1 100644 --- a/src/agents/bash-tools.exec-host-node.ts +++ b/src/agents/bash-tools.exec-host-node.ts @@ -18,14 +18,12 @@ import { registerExecApprovalRequestForHostOrThrow, } from "./bash-tools.exec-approval-request.js"; import { + createDefaultExecApprovalRequestContext, + resolveBaseExecApprovalDecision, resolveApprovalDecisionOrUndefined, resolveExecHostApprovalContext, } from "./bash-tools.exec-host-shared.js"; -import { - DEFAULT_APPROVAL_TIMEOUT_MS, - createApprovalSlug, - emitExecSystemEvent, -} from "./bash-tools.exec-runtime.js"; +import { createApprovalSlug, emitExecSystemEvent } from "./bash-tools.exec-runtime.js"; import type { ExecToolDetails } from "./bash-tools.exec-types.js"; import { callGatewayTool } from "./tools/gateway.js"; import { listNodes, resolveNodeIdFromList } from "./tools/nodes-utils.js"; @@ -209,13 +207,21 @@ export async function executeNodeHostCommand( }) satisfies Record; if (requiresAsk) { - const approvalId = crypto.randomUUID(); - const approvalSlug = createApprovalSlug(approvalId); - const contextKey = `exec:${approvalId}`; - const noticeSeconds = Math.max(1, Math.round(params.approvalRunningNoticeMs / 1000)); - const warningText = params.warnings.length ? `${params.warnings.join("\n")}\n\n` : ""; - let expiresAtMs = Date.now() + DEFAULT_APPROVAL_TIMEOUT_MS; - let preResolvedDecision: string | null | undefined; + const { + approvalId, + approvalSlug, + contextKey, + noticeSeconds, + warningText, + expiresAtMs: defaultExpiresAtMs, + preResolvedDecision: defaultPreResolvedDecision, + } = createDefaultExecApprovalRequestContext({ + warnings: params.warnings, + approvalRunningNoticeMs: params.approvalRunningNoticeMs, + createApprovalSlug, + }); + let expiresAtMs = defaultExpiresAtMs; + let preResolvedDecision = defaultPreResolvedDecision; // Register first so the returned approval ID is actionable immediately. const registration = await registerExecApprovalRequestForHostOrThrow({ @@ -252,23 +258,17 @@ export async function executeNodeHostCommand( return; } - let approvedByAsk = false; + const baseDecision = resolveBaseExecApprovalDecision({ + decision, + askFallback, + obfuscationDetected: obfuscation.detected, + }); + let approvedByAsk = baseDecision.approvedByAsk; let approvalDecision: "allow-once" | "allow-always" | null = null; - let deniedReason: string | null = null; + let deniedReason = baseDecision.deniedReason; - if (decision === "deny") { - deniedReason = "user-denied"; - } else if (!decision) { - if (obfuscation.detected) { - deniedReason = "approval-timeout (obfuscation-detected)"; - } else if (askFallback === "full") { - approvedByAsk = true; - approvalDecision = "allow-once"; - } else if (askFallback === "allowlist") { - // Defer allowlist enforcement to the node host. - } else { - deniedReason = "approval-timeout"; - } + if (baseDecision.timedOut && askFallback === "full" && approvedByAsk) { + approvalDecision = "allow-once"; } else if (decision === "allow-once") { approvedByAsk = true; approvalDecision = "allow-once"; diff --git a/src/agents/bash-tools.exec-host-shared.ts b/src/agents/bash-tools.exec-host-shared.ts index 37ee0320c3f..eef3575fed3 100644 --- a/src/agents/bash-tools.exec-host-shared.ts +++ b/src/agents/bash-tools.exec-host-shared.ts @@ -1,3 +1,4 @@ +import crypto from "node:crypto"; import { maxAsk, minSecurity, @@ -6,6 +7,7 @@ import { type ExecSecurity, } from "../infra/exec-approvals.js"; import { resolveRegisteredExecApprovalDecision } from "./bash-tools.exec-approval-request.js"; +import { DEFAULT_APPROVAL_TIMEOUT_MS } from "./bash-tools.exec-runtime.js"; type ResolvedExecApprovals = ReturnType; @@ -16,6 +18,110 @@ export type ExecHostApprovalContext = { askFallback: ResolvedExecApprovals["agent"]["askFallback"]; }; +export type ExecApprovalPendingState = { + warningText: string; + expiresAtMs: number; + preResolvedDecision: string | null | undefined; +}; + +export type ExecApprovalRequestState = ExecApprovalPendingState & { + noticeSeconds: number; +}; + +export function createExecApprovalPendingState(params: { + warnings: string[]; + timeoutMs: number; +}): ExecApprovalPendingState { + return { + warningText: params.warnings.length ? `${params.warnings.join("\n")}\n\n` : "", + expiresAtMs: Date.now() + params.timeoutMs, + preResolvedDecision: undefined, + }; +} + +export function createExecApprovalRequestState(params: { + warnings: string[]; + timeoutMs: number; + approvalRunningNoticeMs: number; +}): ExecApprovalRequestState { + const pendingState = createExecApprovalPendingState({ + warnings: params.warnings, + timeoutMs: params.timeoutMs, + }); + return { + ...pendingState, + noticeSeconds: Math.max(1, Math.round(params.approvalRunningNoticeMs / 1000)), + }; +} + +export function createExecApprovalRequestContext(params: { + warnings: string[]; + timeoutMs: number; + approvalRunningNoticeMs: number; + createApprovalSlug: (approvalId: string) => string; +}): ExecApprovalRequestState & { + approvalId: string; + approvalSlug: string; + contextKey: string; +} { + const approvalId = crypto.randomUUID(); + const pendingState = createExecApprovalRequestState({ + warnings: params.warnings, + timeoutMs: params.timeoutMs, + approvalRunningNoticeMs: params.approvalRunningNoticeMs, + }); + return { + ...pendingState, + approvalId, + approvalSlug: params.createApprovalSlug(approvalId), + contextKey: `exec:${approvalId}`, + }; +} + +export function createDefaultExecApprovalRequestContext(params: { + warnings: string[]; + approvalRunningNoticeMs: number; + createApprovalSlug: (approvalId: string) => string; +}) { + return createExecApprovalRequestContext({ + warnings: params.warnings, + timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS, + approvalRunningNoticeMs: params.approvalRunningNoticeMs, + createApprovalSlug: params.createApprovalSlug, + }); +} + +export function resolveBaseExecApprovalDecision(params: { + decision: string | null; + askFallback: ResolvedExecApprovals["agent"]["askFallback"]; + obfuscationDetected: boolean; +}): { + approvedByAsk: boolean; + deniedReason: string | null; + timedOut: boolean; +} { + if (params.decision === "deny") { + return { approvedByAsk: false, deniedReason: "user-denied", timedOut: false }; + } + if (!params.decision) { + if (params.obfuscationDetected) { + return { + approvedByAsk: false, + deniedReason: "approval-timeout (obfuscation-detected)", + timedOut: true, + }; + } + if (params.askFallback === "full") { + return { approvedByAsk: true, deniedReason: null, timedOut: true }; + } + if (params.askFallback === "deny") { + return { approvedByAsk: false, deniedReason: "approval-timeout", timedOut: true }; + } + return { approvedByAsk: false, deniedReason: null, timedOut: true }; + } + return { approvedByAsk: false, deniedReason: null, timedOut: false }; +} + export function resolveExecHostApprovalContext(params: { agentId?: string; security: ExecSecurity; diff --git a/src/agents/bash-tools.exec-runtime.ts b/src/agents/bash-tools.exec-runtime.ts index 2a5a7d4eb2c..9714e4255ee 100644 --- a/src/agents/bash-tools.exec-runtime.ts +++ b/src/agents/bash-tools.exec-runtime.ts @@ -1,7 +1,7 @@ import path from "node:path"; import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import { Type } from "@sinclair/typebox"; -import type { ExecAsk, ExecHost, ExecSecurity } from "../infra/exec-approvals.js"; +import { type ExecHost } from "../infra/exec-approvals.js"; import { requestHeartbeatNow } from "../infra/heartbeat-wake.js"; import { isDangerousHostEnvVarName } from "../infra/host-env-security.js"; import { findPathKey, mergePathPrepend } from "../infra/path-prepend.js"; @@ -11,6 +11,11 @@ import type { ProcessSession } from "./bash-process-registry.js"; import type { ExecToolDetails } from "./bash-tools.exec-types.js"; import type { BashSandboxConfig } from "./bash-tools.shared.js"; export { applyPathPrepend, findPathKey, normalizePathPrepend } from "../infra/path-prepend.js"; +export { + normalizeExecAsk, + normalizeExecHost, + normalizeExecSecurity, +} from "../infra/exec-approvals.js"; import { logWarn } from "../logger.js"; import type { ManagedRun } from "../process/supervisor/index.js"; import { getProcessSupervisor } from "../process/supervisor/index.js"; @@ -156,30 +161,6 @@ export type ExecProcessHandle = { kill: () => void; }; -export function normalizeExecHost(value?: string | null): ExecHost | null { - const normalized = value?.trim().toLowerCase(); - if (normalized === "sandbox" || normalized === "gateway" || normalized === "node") { - return normalized; - } - return null; -} - -export function normalizeExecSecurity(value?: string | null): ExecSecurity | null { - const normalized = value?.trim().toLowerCase(); - if (normalized === "deny" || normalized === "allowlist" || normalized === "full") { - return normalized; - } - return null; -} - -export function normalizeExecAsk(value?: string | null): ExecAsk | null { - const normalized = value?.trim().toLowerCase(); - if (normalized === "off" || normalized === "on-miss" || normalized === "always") { - return normalized as ExecAsk; - } - return null; -} - export function renderExecHostLabel(host: ExecHost) { return host === "sandbox" ? "sandbox" : host === "gateway" ? "gateway" : "node"; } diff --git a/src/agents/cache-trace.ts b/src/agents/cache-trace.ts index 5084614501c..46d51579258 100644 --- a/src/agents/cache-trace.ts +++ b/src/agents/cache-trace.ts @@ -8,6 +8,7 @@ import { parseBooleanValue } from "../utils/boolean.js"; import { safeJsonStringify } from "../utils/safe-json.js"; import { redactImageDataForDiagnostics } from "./payload-redaction.js"; import { getQueuedFileWriter, type QueuedFileWriter } from "./queued-file-writer.js"; +import { buildAgentTraceBase } from "./trace-base.js"; export type CacheTraceStage = | "session:loaded" @@ -173,15 +174,7 @@ export function createCacheTrace(params: CacheTraceInit): CacheTrace | null { const writer = params.writer ?? getWriter(cfg.filePath); let seq = 0; - const base: Omit = { - runId: params.runId, - sessionId: params.sessionId, - sessionKey: params.sessionKey, - provider: params.provider, - modelId: params.modelId, - modelApi: params.modelApi, - workspaceDir: params.workspaceDir, - }; + const base: Omit = buildAgentTraceBase(params); const recordStage: CacheTrace["recordStage"] = (stage, payload = {}) => { const event: CacheTraceEvent = { diff --git a/src/agents/compaction.test.ts b/src/agents/compaction.test.ts index 9fa8fcee53a..afd8c776942 100644 --- a/src/agents/compaction.test.ts +++ b/src/agents/compaction.test.ts @@ -6,6 +6,7 @@ import { pruneHistoryForContextShare, splitMessagesByTokenShare, } from "./compaction.js"; +import { makeAgentAssistantMessage } from "./test-helpers/agent-message-fixtures.js"; function makeMessage(id: number, size: number): AgentMessage { return { @@ -24,26 +25,15 @@ function makeAssistantToolCall( toolCallId: string, text = "x".repeat(4000), ): AssistantMessage { - return { - role: "assistant", + return makeAgentAssistantMessage({ content: [ { type: "text", text }, { type: "toolCall", id: toolCallId, name: "test_tool", arguments: {} }, ], - api: "openai-responses", - provider: "openai", model: "gpt-5.2", - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, stopReason: "stop", timestamp, - }; + }); } function makeToolResult(timestamp: number, toolCallId: string, text: string): ToolResultMessage { @@ -229,27 +219,16 @@ describe("pruneHistoryForContextShare", () => { // all corresponding tool_results should be removed from kept messages const messages: AgentMessage[] = [ // Chunk 1 (will be dropped) - contains multiple tool_use blocks - { - role: "assistant", + makeAgentAssistantMessage({ content: [ { type: "text", text: "x".repeat(4000) }, { type: "toolCall", id: "call_a", name: "tool_a", arguments: {} }, { type: "toolCall", id: "call_b", name: "tool_b", arguments: {} }, ], - api: "openai-responses", - provider: "openai", model: "gpt-5.2", - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, stopReason: "stop", timestamp: 1, - }, + }), // Chunk 2 (will be kept) - contains orphaned tool_results makeToolResult(2, "call_a", "result_a"), makeToolResult(3, "call_b", "result_b"), diff --git a/src/agents/compaction.tool-result-details.test.ts b/src/agents/compaction.tool-result-details.test.ts index 0570fc52bdb..581e596ccbe 100644 --- a/src/agents/compaction.tool-result-details.test.ts +++ b/src/agents/compaction.tool-result-details.test.ts @@ -1,6 +1,7 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { AssistantMessage, ToolResultMessage } from "@mariozechner/pi-ai"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { makeAgentAssistantMessage } from "./test-helpers/agent-message-fixtures.js"; const piCodingAgentMocks = vi.hoisted(() => ({ generateSummary: vi.fn(async () => "summary"), @@ -21,23 +22,12 @@ vi.mock("@mariozechner/pi-coding-agent", async () => { import { isOversizedForSummary, summarizeWithFallback } from "./compaction.js"; function makeAssistantToolCall(timestamp: number): AssistantMessage { - return { - role: "assistant", + return makeAgentAssistantMessage({ content: [{ type: "toolCall", id: "call_1", name: "browser", arguments: { action: "tabs" } }], - api: "openai-responses", - provider: "openai", model: "gpt-5.2", - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, stopReason: "toolUse", timestamp, - }; + }); } function makeToolResultWithDetails(timestamp: number): ToolResultMessage<{ raw: string }> { diff --git a/src/agents/context.lookup.test.ts b/src/agents/context.lookup.test.ts index 81263481c34..584f9c27cbb 100644 --- a/src/agents/context.lookup.test.ts +++ b/src/agents/context.lookup.test.ts @@ -1,33 +1,37 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +function mockContextModuleDeps(loadConfigImpl: () => unknown) { + vi.doMock("../config/config.js", () => ({ + loadConfig: loadConfigImpl, + })); + vi.doMock("./models-config.js", () => ({ + ensureOpenClawModelsJson: vi.fn(async () => {}), + })); + vi.doMock("./agent-paths.js", () => ({ + resolveOpenClawAgentDir: () => "/tmp/openclaw-agent", + })); + vi.doMock("./pi-model-discovery.js", () => ({ + discoverAuthStorage: vi.fn(() => ({})), + discoverModels: vi.fn(() => ({ + getAll: () => [], + })), + })); +} + describe("lookupContextTokens", () => { beforeEach(() => { vi.resetModules(); }); it("returns configured model context window on first lookup", async () => { - vi.doMock("../config/config.js", () => ({ - loadConfig: () => ({ - models: { - providers: { - openrouter: { - models: [{ id: "openrouter/claude-sonnet", contextWindow: 321_000 }], - }, + mockContextModuleDeps(() => ({ + models: { + providers: { + openrouter: { + models: [{ id: "openrouter/claude-sonnet", contextWindow: 321_000 }], }, }, - }), - })); - vi.doMock("./models-config.js", () => ({ - ensureOpenClawModelsJson: vi.fn(async () => {}), - })); - vi.doMock("./agent-paths.js", () => ({ - resolveOpenClawAgentDir: () => "/tmp/openclaw-agent", - })); - vi.doMock("./pi-model-discovery.js", () => ({ - discoverAuthStorage: vi.fn(() => ({})), - discoverModels: vi.fn(() => ({ - getAll: () => [], - })), + }, })); const { lookupContextTokens } = await import("./context.js"); @@ -36,21 +40,7 @@ describe("lookupContextTokens", () => { it("does not skip eager warmup when --profile is followed by -- terminator", async () => { const loadConfigMock = vi.fn(() => ({ models: {} })); - vi.doMock("../config/config.js", () => ({ - loadConfig: loadConfigMock, - })); - vi.doMock("./models-config.js", () => ({ - ensureOpenClawModelsJson: vi.fn(async () => {}), - })); - vi.doMock("./agent-paths.js", () => ({ - resolveOpenClawAgentDir: () => "/tmp/openclaw-agent", - })); - vi.doMock("./pi-model-discovery.js", () => ({ - discoverAuthStorage: vi.fn(() => ({})), - discoverModels: vi.fn(() => ({ - getAll: () => [], - })), - })); + mockContextModuleDeps(loadConfigMock); const argvSnapshot = process.argv; process.argv = ["node", "openclaw", "--profile", "--", "config", "validate"]; @@ -79,21 +69,7 @@ describe("lookupContextTokens", () => { }, })); - vi.doMock("../config/config.js", () => ({ - loadConfig: loadConfigMock, - })); - vi.doMock("./models-config.js", () => ({ - ensureOpenClawModelsJson: vi.fn(async () => {}), - })); - vi.doMock("./agent-paths.js", () => ({ - resolveOpenClawAgentDir: () => "/tmp/openclaw-agent", - })); - vi.doMock("./pi-model-discovery.js", () => ({ - discoverAuthStorage: vi.fn(() => ({})), - discoverModels: vi.fn(() => ({ - getAll: () => [], - })), - })); + mockContextModuleDeps(loadConfigMock); const argvSnapshot = process.argv; process.argv = ["node", "openclaw", "config", "validate"]; diff --git a/src/agents/path-policy.ts b/src/agents/path-policy.ts index e289ee406cb..f6960bf9500 100644 --- a/src/agents/path-policy.ts +++ b/src/agents/path-policy.ts @@ -19,6 +19,33 @@ function throwPathEscapesBoundary(params: { throw new Error(`Path escapes ${boundary}${suffix}: ${params.candidate}`); } +function validateRelativePathWithinBoundary(params: { + relativePath: string; + isAbsolutePath: (path: string) => boolean; + options?: RelativePathOptions; + rootResolved: string; + candidate: string; +}): string { + if (params.relativePath === "" || params.relativePath === ".") { + if (params.options?.allowRoot) { + return ""; + } + throwPathEscapesBoundary({ + options: params.options, + rootResolved: params.rootResolved, + candidate: params.candidate, + }); + } + if (params.relativePath.startsWith("..") || params.isAbsolutePath(params.relativePath)) { + throwPathEscapesBoundary({ + options: params.options, + rootResolved: params.rootResolved, + candidate: params.candidate, + }); + } + return params.relativePath; +} + function toRelativePathUnderRoot(params: { root: string; candidate: string; @@ -35,47 +62,44 @@ function toRelativePathUnderRoot(params: { const rootForCompare = normalizeWindowsPathForComparison(rootResolved); const targetForCompare = normalizeWindowsPathForComparison(resolvedCandidate); const relative = path.win32.relative(rootForCompare, targetForCompare); - if (relative === "" || relative === ".") { - if (params.options?.allowRoot) { - return ""; - } - throwPathEscapesBoundary({ - options: params.options, - rootResolved, - candidate: params.candidate, - }); - } - if (relative.startsWith("..") || path.win32.isAbsolute(relative)) { - throwPathEscapesBoundary({ - options: params.options, - rootResolved, - candidate: params.candidate, - }); - } - return relative; + return validateRelativePathWithinBoundary({ + relativePath: relative, + isAbsolutePath: path.win32.isAbsolute, + options: params.options, + rootResolved, + candidate: params.candidate, + }); } const rootResolved = path.resolve(params.root); const resolvedCandidate = path.resolve(resolvedInput); const relative = path.relative(rootResolved, resolvedCandidate); - if (relative === "" || relative === ".") { - if (params.options?.allowRoot) { - return ""; - } - throwPathEscapesBoundary({ - options: params.options, - rootResolved, - candidate: params.candidate, - }); - } - if (relative.startsWith("..") || path.isAbsolute(relative)) { - throwPathEscapesBoundary({ - options: params.options, - rootResolved, - candidate: params.candidate, - }); - } - return relative; + return validateRelativePathWithinBoundary({ + relativePath: relative, + isAbsolutePath: path.isAbsolute, + options: params.options, + rootResolved, + candidate: params.candidate, + }); +} + +function toRelativeBoundaryPath(params: { + root: string; + candidate: string; + options?: Pick; + boundaryLabel: string; + includeRootInError?: boolean; +}): string { + return toRelativePathUnderRoot({ + root: params.root, + candidate: params.candidate, + options: { + allowRoot: params.options?.allowRoot, + cwd: params.options?.cwd, + boundaryLabel: params.boundaryLabel, + includeRootInError: params.includeRootInError, + }, + }); } export function toRelativeWorkspacePath( @@ -83,14 +107,11 @@ export function toRelativeWorkspacePath( candidate: string, options?: Pick, ): string { - return toRelativePathUnderRoot({ + return toRelativeBoundaryPath({ root, candidate, - options: { - allowRoot: options?.allowRoot, - cwd: options?.cwd, - boundaryLabel: "workspace root", - }, + options, + boundaryLabel: "workspace root", }); } @@ -99,15 +120,12 @@ export function toRelativeSandboxPath( candidate: string, options?: Pick, ): string { - return toRelativePathUnderRoot({ + return toRelativeBoundaryPath({ root, candidate, - options: { - allowRoot: options?.allowRoot, - cwd: options?.cwd, - boundaryLabel: "sandbox root", - includeRootInError: true, - }, + options, + boundaryLabel: "sandbox root", + includeRootInError: true, }); } diff --git a/src/agents/pi-embedded-helpers.sanitize-session-messages-images.removes-empty-assistant-text-blocks-but-preserves.test.ts b/src/agents/pi-embedded-helpers.sanitize-session-messages-images.removes-empty-assistant-text-blocks-but-preserves.test.ts index 4b1071de56e..b51e93009b4 100644 --- a/src/agents/pi-embedded-helpers.sanitize-session-messages-images.removes-empty-assistant-text-blocks-but-preserves.test.ts +++ b/src/agents/pi-embedded-helpers.sanitize-session-messages-images.removes-empty-assistant-text-blocks-but-preserves.test.ts @@ -5,15 +5,17 @@ import { sanitizeGoogleTurnOrdering, sanitizeSessionMessagesImages, } from "./pi-embedded-helpers.js"; -import { castAgentMessages } from "./test-helpers/agent-message-fixtures.js"; +import { + castAgentMessages, + makeAgentAssistantMessage, +} from "./test-helpers/agent-message-fixtures.js"; let testTimestamp = 1; const nextTimestamp = () => testTimestamp++; function makeToolCallResultPairInput(): Array { return [ - { - role: "assistant", + makeAgentAssistantMessage({ content: [ { type: "toolCall", @@ -22,20 +24,10 @@ function makeToolCallResultPairInput(): Array { it("does not synthesize tool call input when missing", async () => { const input = castAgentMessages([ - { - role: "assistant", - content: [{ type: "toolCall", id: "call_1", name: "read" }], - api: "openai-responses", - provider: "openai", - model: "gpt-5.2", - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - stopReason: "toolUse", - timestamp: nextTimestamp(), - }, + makeOpenAiResponsesAssistantMessage([ + { type: "toolCall", id: "call_1", name: "read", arguments: {} }, + ]), ]); const out = await sanitizeSessionMessagesImages(input, "test"); @@ -124,26 +123,10 @@ describe("sanitizeSessionMessagesImages", () => { it("removes empty assistant text blocks but preserves tool calls", async () => { const input = castAgentMessages([ - { - role: "assistant", - content: [ - { type: "text", text: "" }, - { type: "toolCall", id: "call_1", name: "read", arguments: {} }, - ], - api: "openai-responses", - provider: "openai", - model: "gpt-5.2", - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - stopReason: "toolUse", - timestamp: nextTimestamp(), - }, + makeOpenAiResponsesAssistantMessage([ + { type: "text", text: "" }, + { type: "toolCall", id: "call_1", name: "read", arguments: {} }, + ]), ]); const out = await sanitizeSessionMessagesImages(input, "test"); @@ -189,33 +172,7 @@ describe("sanitizeSessionMessagesImages", () => { }); it("sanitizes tool IDs in images-only mode when explicitly enabled", async () => { - const input = castAgentMessages([ - { - role: "assistant", - content: [{ type: "toolCall", id: "call_123|fc_456", name: "read", arguments: {} }], - api: "openai-responses", - provider: "openai", - model: "gpt-5.2", - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - stopReason: "toolUse", - timestamp: nextTimestamp(), - }, - { - role: "toolResult", - toolCallId: "call_123|fc_456", - toolName: "read", - content: [{ type: "text", text: "ok" }], - isError: false, - timestamp: nextTimestamp(), - }, - ]); + const input = makeToolCallResultPairInput(); const out = await sanitizeSessionMessagesImages(input, "test", { sanitizeMode: "images-only", @@ -297,39 +254,11 @@ describe("sanitizeSessionMessagesImages", () => { const input = castAgentMessages([ { role: "user", content: "hello", timestamp: nextTimestamp() } satisfies UserMessage, { - role: "assistant", - stopReason: "error", - content: [], - api: "openai-responses", - provider: "openai", - model: "gpt-5.2", - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - timestamp: nextTimestamp(), - } satisfies AssistantMessage, + ...makeEmptyAssistantErrorMessage(), + }, { - role: "assistant", - stopReason: "error", - content: [], - api: "openai-responses", - provider: "openai", - model: "gpt-5.2", - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - timestamp: nextTimestamp(), - } satisfies AssistantMessage, + ...makeEmptyAssistantErrorMessage(), + }, ]); const out = await sanitizeSessionMessagesImages(input, "test"); diff --git a/src/agents/pi-embedded-runner/sanitize-session-history.tool-result-details.test.ts b/src/agents/pi-embedded-runner/sanitize-session-history.tool-result-details.test.ts index ca1a60fc10c..c888ae2f4ab 100644 --- a/src/agents/pi-embedded-runner/sanitize-session-history.tool-result-details.test.ts +++ b/src/agents/pi-embedded-runner/sanitize-session-history.tool-result-details.test.ts @@ -1,35 +1,21 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core"; -import type { AssistantMessage, ToolResultMessage, UserMessage } from "@mariozechner/pi-ai"; +import type { ToolResultMessage, UserMessage } from "@mariozechner/pi-ai"; import { SessionManager } from "@mariozechner/pi-coding-agent"; import { describe, expect, it } from "vitest"; +import { makeAgentAssistantMessage } from "../test-helpers/agent-message-fixtures.js"; import { sanitizeSessionHistory } from "./google.js"; -function makeAssistantToolCall(timestamp: number): AssistantMessage { - return { - role: "assistant", - content: [{ type: "toolCall", id: "call_1", name: "web_fetch", arguments: { url: "x" } }], - api: "openai-responses", - provider: "openai", - model: "gpt-5.2", - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - stopReason: "toolUse", - timestamp, - }; -} - describe("sanitizeSessionHistory toolResult details stripping", () => { it("strips toolResult.details so untrusted payloads are not fed back to the model", async () => { const sm = SessionManager.inMemory(); const messages: AgentMessage[] = [ - makeAssistantToolCall(1), + makeAgentAssistantMessage({ + content: [{ type: "toolCall", id: "call_1", name: "web_fetch", arguments: { url: "x" } }], + model: "gpt-5.2", + stopReason: "toolUse", + timestamp: 1, + }), { role: "toolResult", toolCallId: "call_1", diff --git a/src/agents/pi-embedded-runner/tool-result-truncation.test.ts b/src/agents/pi-embedded-runner/tool-result-truncation.test.ts index 8b4fbb628c6..2dce36ed076 100644 --- a/src/agents/pi-embedded-runner/tool-result-truncation.test.ts +++ b/src/agents/pi-embedded-runner/tool-result-truncation.test.ts @@ -1,6 +1,7 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { AssistantMessage, ToolResultMessage, UserMessage } from "@mariozechner/pi-ai"; import { describe, expect, it } from "vitest"; +import { makeAgentAssistantMessage } from "../test-helpers/agent-message-fixtures.js"; import { truncateToolResultText, truncateToolResultMessage, @@ -35,23 +36,12 @@ function makeUserMessage(text: string): UserMessage { } function makeAssistantMessage(text: string): AssistantMessage { - return { - role: "assistant", + return makeAgentAssistantMessage({ content: [{ type: "text", text }], - api: "openai-responses", - provider: "openai", model: "gpt-5.2", - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, stopReason: "stop", timestamp: nextTimestamp(), - }; + }); } describe("truncateToolResultText", () => { diff --git a/src/agents/pi-tool-definition-adapter.after-tool-call.fires-once.test.ts b/src/agents/pi-tool-definition-adapter.after-tool-call.fires-once.test.ts index 4fa66fb516f..927694d06b1 100644 --- a/src/agents/pi-tool-definition-adapter.after-tool-call.fires-once.test.ts +++ b/src/agents/pi-tool-definition-adapter.after-tool-call.fires-once.test.ts @@ -9,6 +9,7 @@ import type { AgentTool } from "@mariozechner/pi-agent-core"; import { Type } from "@sinclair/typebox"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { createBaseToolHandlerState } from "./pi-tool-handler-state.test-helpers.js"; const hookMocks = vi.hoisted(() => ({ runner: { @@ -75,17 +76,7 @@ function createToolHandlerCtx() { hookRunner: hookMocks.runner, state: { toolMetaById: new Map(), - toolMetas: [] as Array<{ toolName?: string; meta?: string }>, - toolSummaryById: new Set(), - lastToolError: undefined, - pendingMessagingTexts: new Map(), - pendingMessagingTargets: new Map(), - pendingMessagingMediaUrls: new Map(), - messagingToolSentTexts: [] as string[], - messagingToolSentTextsNormalized: [] as string[], - messagingToolSentMediaUrls: [] as string[], - messagingToolSentTargets: [] as unknown[], - blockBuffer: "", + ...createBaseToolHandlerState(), successfulCronAdds: 0, }, log: { debug: vi.fn(), warn: vi.fn() }, @@ -247,7 +238,10 @@ describe("after_tool_call fires exactly once in embedded runs", () => { result: { content: [{ type: "text", text: "ok" }] }, }); - expect(beforeToolCallMocks.consumeAdjustedParamsForToolCall).toHaveBeenCalledWith(toolCallId); + expect(beforeToolCallMocks.consumeAdjustedParamsForToolCall).toHaveBeenCalledWith( + toolCallId, + "integration-test", + ); const event = (hookMocks.runner.runAfterToolCall as ReturnType).mock .calls[0]?.[0] as { params?: unknown } | undefined; expect(event?.params).toEqual(adjusted); diff --git a/src/agents/pi-tool-handler-state.test-helpers.ts b/src/agents/pi-tool-handler-state.test-helpers.ts new file mode 100644 index 00000000000..0775299ab83 --- /dev/null +++ b/src/agents/pi-tool-handler-state.test-helpers.ts @@ -0,0 +1,15 @@ +export function createBaseToolHandlerState() { + return { + toolMetas: [] as Array<{ toolName?: string; meta?: string }>, + toolSummaryById: new Set(), + lastToolError: undefined, + pendingMessagingTexts: new Map(), + pendingMessagingTargets: new Map(), + pendingMessagingMediaUrls: new Map(), + messagingToolSentTexts: [] as string[], + messagingToolSentTextsNormalized: [] as string[], + messagingToolSentMediaUrls: [] as string[], + messagingToolSentTargets: [] as unknown[], + blockBuffer: "", + }; +} diff --git a/src/agents/session-slug.ts b/src/agents/session-slug.ts index c15c9746e79..0aee27a344b 100644 --- a/src/agents/session-slug.ts +++ b/src/agents/session-slug.ts @@ -112,10 +112,12 @@ function createSlugBase(words = 2) { return parts.join("-"); } -export function createSessionSlug(isTaken?: (id: string) => boolean): string { - const isIdTaken = isTaken ?? (() => false); +function createAvailableSlug( + words: number, + isIdTaken: (id: string) => boolean, +): string | undefined { for (let attempt = 0; attempt < 12; attempt += 1) { - const base = createSlugBase(2); + const base = createSlugBase(words); if (!isIdTaken(base)) { return base; } @@ -126,17 +128,18 @@ export function createSessionSlug(isTaken?: (id: string) => boolean): string { } } } - for (let attempt = 0; attempt < 12; attempt += 1) { - const base = createSlugBase(3); - if (!isIdTaken(base)) { - return base; - } - for (let i = 2; i <= 12; i += 1) { - const candidate = `${base}-${i}`; - if (!isIdTaken(candidate)) { - return candidate; - } - } + return undefined; +} + +export function createSessionSlug(isTaken?: (id: string) => boolean): string { + const isIdTaken = isTaken ?? (() => false); + const twoWord = createAvailableSlug(2, isIdTaken); + if (twoWord) { + return twoWord; + } + const threeWord = createAvailableSlug(3, isIdTaken); + if (threeWord) { + return threeWord; } const fallback = `${createSlugBase(3)}-${Math.random().toString(36).slice(2, 5)}`; return isIdTaken(fallback) ? `${fallback}-${Date.now().toString(36)}` : fallback; diff --git a/src/agents/skills/frontmatter.ts b/src/agents/skills/frontmatter.ts index dd82a7f73d5..43dc35aa578 100644 --- a/src/agents/skills/frontmatter.ts +++ b/src/agents/skills/frontmatter.ts @@ -2,6 +2,7 @@ import type { Skill } from "@mariozechner/pi-coding-agent"; import { validateRegistryNpmSpec } from "../../infra/npm-registry-spec.js"; import { parseFrontmatterBlock } from "../../markdown/frontmatter.js"; import { + applyOpenClawManifestInstallCommonFields, getFrontmatterString, normalizeStringList, parseOpenClawManifestInstallBase, @@ -113,19 +114,12 @@ function parseInstallSpec(input: unknown): SkillInstallSpec | undefined { return undefined; } const { raw } = parsed; - const spec: SkillInstallSpec = { - kind: parsed.kind as SkillInstallSpec["kind"], - }; - - if (parsed.id) { - spec.id = parsed.id; - } - if (parsed.label) { - spec.label = parsed.label; - } - if (parsed.bins) { - spec.bins = parsed.bins; - } + const spec = applyOpenClawManifestInstallCommonFields( + { + kind: parsed.kind as SkillInstallSpec["kind"], + }, + parsed, + ); const osList = normalizeStringList(raw.os); if (osList.length > 0) { spec.os = osList; diff --git a/src/agents/test-helpers/agent-message-fixtures.ts b/src/agents/test-helpers/agent-message-fixtures.ts index 455487e8c59..040be7f1dd8 100644 --- a/src/agents/test-helpers/agent-message-fixtures.ts +++ b/src/agents/test-helpers/agent-message-fixtures.ts @@ -1,20 +1,6 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core"; -import type { AssistantMessage, ToolResultMessage, Usage, UserMessage } from "@mariozechner/pi-ai"; - -const ZERO_USAGE: Usage = { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - total: 0, - }, -}; +import type { AssistantMessage, ToolResultMessage, UserMessage } from "@mariozechner/pi-ai"; +import { ZERO_USAGE_FIXTURE } from "./usage-fixtures.js"; export function castAgentMessage(message: unknown): AgentMessage { return message as AgentMessage; @@ -42,7 +28,7 @@ export function makeAgentAssistantMessage( api: "openai-responses", provider: "openai", model: "test-model", - usage: ZERO_USAGE, + usage: ZERO_USAGE_FIXTURE, stopReason: "stop", timestamp: 0, ...overrides, diff --git a/src/agents/test-helpers/assistant-message-fixtures.ts b/src/agents/test-helpers/assistant-message-fixtures.ts index edf26770b77..72606a245ad 100644 --- a/src/agents/test-helpers/assistant-message-fixtures.ts +++ b/src/agents/test-helpers/assistant-message-fixtures.ts @@ -1,19 +1,5 @@ import type { AssistantMessage } from "@mariozechner/pi-ai"; - -const ZERO_USAGE: AssistantMessage["usage"] = { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - total: 0, - }, -}; +import { ZERO_USAGE_FIXTURE } from "./usage-fixtures.js"; export function makeAssistantMessageFixture( overrides: Partial = {}, @@ -24,7 +10,7 @@ export function makeAssistantMessageFixture( api: "openai-responses", provider: "openai", model: "test-model", - usage: ZERO_USAGE, + usage: ZERO_USAGE_FIXTURE, timestamp: 0, stopReason: "error", errorMessage: errorText, diff --git a/src/agents/test-helpers/usage-fixtures.ts b/src/agents/test-helpers/usage-fixtures.ts new file mode 100644 index 00000000000..5b292290c30 --- /dev/null +++ b/src/agents/test-helpers/usage-fixtures.ts @@ -0,0 +1,16 @@ +import type { Usage } from "@mariozechner/pi-ai"; + +export const ZERO_USAGE_FIXTURE: Usage = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: 0, + }, +}; diff --git a/src/agents/trace-base.ts b/src/agents/trace-base.ts new file mode 100644 index 00000000000..5b6ecefac77 --- /dev/null +++ b/src/agents/trace-base.ts @@ -0,0 +1,21 @@ +export type AgentTraceBase = { + runId?: string; + sessionId?: string; + sessionKey?: string; + provider?: string; + modelId?: string; + modelApi?: string | null; + workspaceDir?: string; +}; + +export function buildAgentTraceBase(params: AgentTraceBase): AgentTraceBase { + return { + runId: params.runId, + sessionId: params.sessionId, + sessionKey: params.sessionKey, + provider: params.provider, + modelId: params.modelId, + modelApi: params.modelApi, + workspaceDir: params.workspaceDir, + }; +} diff --git a/src/auto-reply/reply/get-reply.message-hooks.test.ts b/src/auto-reply/reply/get-reply.message-hooks.test.ts index c10604a9fd2..90ccab2a207 100644 --- a/src/auto-reply/reply/get-reply.message-hooks.test.ts +++ b/src/auto-reply/reply/get-reply.message-hooks.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { MsgContext } from "../templating.js"; +import { registerGetReplyCommonMocks } from "./get-reply.test-mocks.js"; const mocks = vi.hoisted(() => ({ applyMediaUnderstanding: vi.fn(async (..._args: unknown[]) => undefined), @@ -10,28 +11,8 @@ const mocks = vi.hoisted(() => ({ initSessionState: vi.fn(), })); -vi.mock("../../agents/agent-scope.js", () => ({ - resolveAgentDir: vi.fn(() => "/tmp/agent"), - resolveAgentWorkspaceDir: vi.fn(() => "/tmp/workspace"), - resolveSessionAgentId: vi.fn(() => "main"), - resolveAgentSkillsFilter: vi.fn(() => undefined), -})); -vi.mock("../../agents/model-selection.js", () => ({ - resolveModelRefFromString: vi.fn(() => null), -})); -vi.mock("../../agents/timeout.js", () => ({ - resolveAgentTimeoutMs: vi.fn(() => 60000), -})); -vi.mock("../../agents/workspace.js", () => ({ - DEFAULT_AGENT_WORKSPACE_DIR: "/tmp/workspace", - ensureAgentWorkspace: vi.fn(async () => ({ dir: "/tmp/workspace" })), -})); -vi.mock("../../channels/model-overrides.js", () => ({ - resolveChannelModelOverride: vi.fn(() => undefined), -})); -vi.mock("../../config/config.js", () => ({ - loadConfig: vi.fn(() => ({})), -})); +registerGetReplyCommonMocks(); + vi.mock("../../globals.js", () => ({ logVerbose: vi.fn(), })); @@ -45,55 +26,18 @@ vi.mock("../../link-understanding/apply.js", () => ({ vi.mock("../../media-understanding/apply.js", () => ({ applyMediaUnderstanding: mocks.applyMediaUnderstanding, })); -vi.mock("../../runtime.js", () => ({ - defaultRuntime: { log: vi.fn() }, -})); -vi.mock("../command-auth.js", () => ({ - resolveCommandAuthorization: vi.fn(() => ({ isAuthorizedSender: true })), -})); vi.mock("./commands-core.js", () => ({ emitResetCommandHooks: vi.fn(async () => undefined), })); -vi.mock("./directive-handling.js", () => ({ - resolveDefaultModel: vi.fn(() => ({ - defaultProvider: "openai", - defaultModel: "gpt-4o-mini", - aliasIndex: new Map(), - })), -})); vi.mock("./get-reply-directives.js", () => ({ resolveReplyDirectives: mocks.resolveReplyDirectives, })); vi.mock("./get-reply-inline-actions.js", () => ({ handleInlineActions: vi.fn(async () => ({ kind: "reply", reply: { text: "ok" } })), })); -vi.mock("./get-reply-run.js", () => ({ - runPreparedReply: vi.fn(async () => undefined), -})); -vi.mock("./inbound-context.js", () => ({ - finalizeInboundContext: vi.fn((ctx: unknown) => ctx), -})); -vi.mock("./session-reset-model.js", () => ({ - applyResetModelOverride: vi.fn(async () => undefined), -})); vi.mock("./session.js", () => ({ initSessionState: mocks.initSessionState, })); -vi.mock("./stage-sandbox-media.js", () => ({ - stageSandboxMedia: vi.fn(async () => undefined), -})); -vi.mock("./typing.js", () => ({ - createTypingController: vi.fn(() => ({ - onReplyStart: async () => undefined, - startTypingLoop: async () => undefined, - startTypingOnText: async () => undefined, - refreshTypingTtl: () => undefined, - isActive: () => false, - markRunComplete: () => undefined, - markDispatchIdle: () => undefined, - cleanup: () => undefined, - })), -})); const { getReplyFromConfig } = await import("./get-reply.js"); diff --git a/src/auto-reply/reply/get-reply.reset-hooks-fallback.test.ts b/src/auto-reply/reply/get-reply.reset-hooks-fallback.test.ts index 7b5869a5801..110b46af476 100644 --- a/src/auto-reply/reply/get-reply.reset-hooks-fallback.test.ts +++ b/src/auto-reply/reply/get-reply.reset-hooks-fallback.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { MsgContext } from "../templating.js"; +import { registerGetReplyCommonMocks } from "./get-reply.test-mocks.js"; const mocks = vi.hoisted(() => ({ resolveReplyDirectives: vi.fn(), @@ -8,83 +9,26 @@ const mocks = vi.hoisted(() => ({ initSessionState: vi.fn(), })); -vi.mock("../../agents/agent-scope.js", () => ({ - resolveAgentDir: vi.fn(() => "/tmp/agent"), - resolveAgentWorkspaceDir: vi.fn(() => "/tmp/workspace"), - resolveSessionAgentId: vi.fn(() => "main"), - resolveAgentSkillsFilter: vi.fn(() => undefined), -})); -vi.mock("../../agents/model-selection.js", () => ({ - resolveModelRefFromString: vi.fn(() => null), -})); -vi.mock("../../agents/timeout.js", () => ({ - resolveAgentTimeoutMs: vi.fn(() => 60000), -})); -vi.mock("../../agents/workspace.js", () => ({ - DEFAULT_AGENT_WORKSPACE_DIR: "/tmp/workspace", - ensureAgentWorkspace: vi.fn(async () => ({ dir: "/tmp/workspace" })), -})); -vi.mock("../../channels/model-overrides.js", () => ({ - resolveChannelModelOverride: vi.fn(() => undefined), -})); -vi.mock("../../config/config.js", () => ({ - loadConfig: vi.fn(() => ({})), -})); +registerGetReplyCommonMocks(); + vi.mock("../../link-understanding/apply.js", () => ({ applyLinkUnderstanding: vi.fn(async () => undefined), })); vi.mock("../../media-understanding/apply.js", () => ({ applyMediaUnderstanding: vi.fn(async () => undefined), })); -vi.mock("../../runtime.js", () => ({ - defaultRuntime: { log: vi.fn() }, -})); -vi.mock("../command-auth.js", () => ({ - resolveCommandAuthorization: vi.fn(() => ({ isAuthorizedSender: true })), -})); vi.mock("./commands-core.js", () => ({ emitResetCommandHooks: (...args: unknown[]) => mocks.emitResetCommandHooks(...args), })); -vi.mock("./directive-handling.js", () => ({ - resolveDefaultModel: vi.fn(() => ({ - defaultProvider: "openai", - defaultModel: "gpt-4o-mini", - aliasIndex: new Map(), - })), -})); vi.mock("./get-reply-directives.js", () => ({ resolveReplyDirectives: (...args: unknown[]) => mocks.resolveReplyDirectives(...args), })); vi.mock("./get-reply-inline-actions.js", () => ({ handleInlineActions: (...args: unknown[]) => mocks.handleInlineActions(...args), })); -vi.mock("./get-reply-run.js", () => ({ - runPreparedReply: vi.fn(async () => undefined), -})); -vi.mock("./inbound-context.js", () => ({ - finalizeInboundContext: vi.fn((ctx: unknown) => ctx), -})); -vi.mock("./session-reset-model.js", () => ({ - applyResetModelOverride: vi.fn(async () => undefined), -})); vi.mock("./session.js", () => ({ initSessionState: (...args: unknown[]) => mocks.initSessionState(...args), })); -vi.mock("./stage-sandbox-media.js", () => ({ - stageSandboxMedia: vi.fn(async () => undefined), -})); -vi.mock("./typing.js", () => ({ - createTypingController: vi.fn(() => ({ - onReplyStart: async () => undefined, - startTypingLoop: async () => undefined, - startTypingOnText: async () => undefined, - refreshTypingTtl: () => undefined, - isActive: () => false, - markRunComplete: () => undefined, - markDispatchIdle: () => undefined, - cleanup: () => undefined, - })), -})); const { getReplyFromConfig } = await import("./get-reply.js"); diff --git a/src/auto-reply/reply/get-reply.test-mocks.ts b/src/auto-reply/reply/get-reply.test-mocks.ts new file mode 100644 index 00000000000..8a73dea7cff --- /dev/null +++ b/src/auto-reply/reply/get-reply.test-mocks.ts @@ -0,0 +1,63 @@ +import { vi } from "vitest"; + +export function registerGetReplyCommonMocks(): void { + vi.mock("../../agents/agent-scope.js", () => ({ + resolveAgentDir: vi.fn(() => "/tmp/agent"), + resolveAgentWorkspaceDir: vi.fn(() => "/tmp/workspace"), + resolveSessionAgentId: vi.fn(() => "main"), + resolveAgentSkillsFilter: vi.fn(() => undefined), + })); + vi.mock("../../agents/model-selection.js", () => ({ + resolveModelRefFromString: vi.fn(() => null), + })); + vi.mock("../../agents/timeout.js", () => ({ + resolveAgentTimeoutMs: vi.fn(() => 60000), + })); + vi.mock("../../agents/workspace.js", () => ({ + DEFAULT_AGENT_WORKSPACE_DIR: "/tmp/workspace", + ensureAgentWorkspace: vi.fn(async () => ({ dir: "/tmp/workspace" })), + })); + vi.mock("../../channels/model-overrides.js", () => ({ + resolveChannelModelOverride: vi.fn(() => undefined), + })); + vi.mock("../../config/config.js", () => ({ + loadConfig: vi.fn(() => ({})), + })); + vi.mock("../../runtime.js", () => ({ + defaultRuntime: { log: vi.fn() }, + })); + vi.mock("../command-auth.js", () => ({ + resolveCommandAuthorization: vi.fn(() => ({ isAuthorizedSender: true })), + })); + vi.mock("./directive-handling.js", () => ({ + resolveDefaultModel: vi.fn(() => ({ + defaultProvider: "openai", + defaultModel: "gpt-4o-mini", + aliasIndex: new Map(), + })), + })); + vi.mock("./get-reply-run.js", () => ({ + runPreparedReply: vi.fn(async () => undefined), + })); + vi.mock("./inbound-context.js", () => ({ + finalizeInboundContext: vi.fn((ctx: unknown) => ctx), + })); + vi.mock("./session-reset-model.js", () => ({ + applyResetModelOverride: vi.fn(async () => undefined), + })); + vi.mock("./stage-sandbox-media.js", () => ({ + stageSandboxMedia: vi.fn(async () => undefined), + })); + vi.mock("./typing.js", () => ({ + createTypingController: vi.fn(() => ({ + onReplyStart: async () => undefined, + startTypingLoop: async () => undefined, + startTypingOnText: async () => undefined, + refreshTypingTtl: () => undefined, + isActive: () => false, + markRunComplete: () => undefined, + markDispatchIdle: () => undefined, + cleanup: () => undefined, + })), + })); +} diff --git a/src/channels/plugins/account-helpers.test.ts b/src/channels/plugins/account-helpers.test.ts index eeddae81e17..9a7a67cf652 100644 --- a/src/channels/plugins/account-helpers.test.ts +++ b/src/channels/plugins/account-helpers.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; +import { normalizeAccountId } from "../../routing/session-key.js"; import { createAccountListHelpers } from "./account-helpers.js"; const { listConfiguredAccountIds, listAccountIds, resolveDefaultAccountId } = @@ -52,6 +53,22 @@ describe("createAccountListHelpers", () => { }); }); + describe("with normalizeAccountId option", () => { + const normalized = createAccountListHelpers("testchannel", { normalizeAccountId }); + + it("normalizes and deduplicates configured account ids", () => { + expect( + normalized.listConfiguredAccountIds( + cfg({ + "Router D": {}, + "router-d": {}, + "Personal A": {}, + }), + ), + ).toEqual(["router-d", "personal-a"]); + }); + }); + describe("listAccountIds", () => { it('returns ["default"] for empty config', () => { expect(listAccountIds({} as OpenClawConfig)).toEqual(["default"]); diff --git a/src/channels/plugins/account-helpers.ts b/src/channels/plugins/account-helpers.ts index 1a86648ab5e..7f72b5e3c55 100644 --- a/src/channels/plugins/account-helpers.ts +++ b/src/channels/plugins/account-helpers.ts @@ -5,7 +5,10 @@ import { normalizeOptionalAccountId, } from "../../routing/session-key.js"; -export function createAccountListHelpers(channelKey: string) { +export function createAccountListHelpers( + channelKey: string, + options?: { normalizeAccountId?: (id: string) => string }, +) { function resolveConfiguredDefaultAccountId(cfg: OpenClawConfig): string | undefined { const channel = cfg.channels?.[channelKey] as Record | undefined; const preferred = normalizeOptionalAccountId( @@ -27,7 +30,12 @@ export function createAccountListHelpers(channelKey: string) { if (!accounts || typeof accounts !== "object") { return []; } - return Object.keys(accounts as Record).filter(Boolean); + const ids = Object.keys(accounts as Record).filter(Boolean); + const normalizeConfiguredAccountId = options?.normalizeAccountId; + if (!normalizeConfiguredAccountId) { + return ids; + } + return [...new Set(ids.map((id) => normalizeConfiguredAccountId(id)).filter(Boolean))]; } function listAccountIds(cfg: OpenClawConfig): string[] { diff --git a/src/channels/plugins/config-helpers.test.ts b/src/channels/plugins/config-helpers.test.ts new file mode 100644 index 00000000000..2f29b3f8ef9 --- /dev/null +++ b/src/channels/plugins/config-helpers.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "vitest"; +import { clearAccountEntryFields } from "./config-helpers.js"; + +describe("clearAccountEntryFields", () => { + it("clears configured values and removes empty account entries", () => { + const result = clearAccountEntryFields({ + accounts: { + default: { + botToken: "abc123", + }, + }, + accountId: "default", + fields: ["botToken"], + }); + + expect(result).toEqual({ + nextAccounts: undefined, + changed: true, + cleared: true, + }); + }); + + it("treats empty string values as not configured by default", () => { + const result = clearAccountEntryFields({ + accounts: { + default: { + botToken: " ", + }, + }, + accountId: "default", + fields: ["botToken"], + }); + + expect(result).toEqual({ + nextAccounts: undefined, + changed: true, + cleared: false, + }); + }); + + it("can mark cleared when fields are present even if values are empty", () => { + const result = clearAccountEntryFields({ + accounts: { + default: { + tokenFile: "", + }, + }, + accountId: "default", + fields: ["tokenFile"], + markClearedOnFieldPresence: true, + }); + + expect(result).toEqual({ + nextAccounts: undefined, + changed: true, + cleared: true, + }); + }); + + it("keeps other account fields intact", () => { + const result = clearAccountEntryFields({ + accounts: { + default: { + botToken: "abc123", + name: "Primary", + }, + backup: { + botToken: "keep", + }, + }, + accountId: "default", + fields: ["botToken"], + }); + + expect(result).toEqual({ + nextAccounts: { + default: { + name: "Primary", + }, + backup: { + botToken: "keep", + }, + }, + changed: true, + cleared: true, + }); + }); + + it("returns unchanged when account entry is missing", () => { + const result = clearAccountEntryFields({ + accounts: { + default: { + botToken: "abc123", + }, + }, + accountId: "other", + fields: ["botToken"], + }); + + expect(result).toEqual({ + nextAccounts: { + default: { + botToken: "abc123", + }, + }, + changed: false, + cleared: false, + }); + }); +}); diff --git a/src/channels/plugins/config-helpers.ts b/src/channels/plugins/config-helpers.ts index ebf6f18a510..e37ea289fa8 100644 --- a/src/channels/plugins/config-helpers.ts +++ b/src/channels/plugins/config-helpers.ts @@ -6,6 +6,13 @@ type ChannelSection = { enabled?: boolean; }; +function isConfiguredSecretValue(value: unknown): boolean { + if (typeof value === "string") { + return value.trim().length > 0; + } + return Boolean(value); +} + export function setAccountEnabledInConfigSection(params: { cfg: OpenClawConfig; sectionKey: string; @@ -111,3 +118,58 @@ export function deleteAccountFromConfigSection(params: { } return nextCfg; } + +export function clearAccountEntryFields(params: { + accounts?: Record; + accountId: string; + fields: string[]; + isValueSet?: (value: unknown) => boolean; + markClearedOnFieldPresence?: boolean; +}): { + nextAccounts?: Record; + changed: boolean; + cleared: boolean; +} { + const accountKey = params.accountId || DEFAULT_ACCOUNT_ID; + const baseAccounts = + params.accounts && typeof params.accounts === "object" ? { ...params.accounts } : undefined; + if (!baseAccounts || !(accountKey in baseAccounts)) { + return { nextAccounts: baseAccounts, changed: false, cleared: false }; + } + + const entry = baseAccounts[accountKey]; + if (!entry || typeof entry !== "object") { + return { nextAccounts: baseAccounts, changed: false, cleared: false }; + } + + const nextEntry = { ...(entry as Record) }; + const hasAnyField = params.fields.some((field) => field in nextEntry); + if (!hasAnyField) { + return { nextAccounts: baseAccounts, changed: false, cleared: false }; + } + + const isValueSet = params.isValueSet ?? isConfiguredSecretValue; + let cleared = Boolean(params.markClearedOnFieldPresence); + for (const field of params.fields) { + if (!(field in nextEntry)) { + continue; + } + if (isValueSet(nextEntry[field])) { + cleared = true; + } + delete nextEntry[field]; + } + + if (Object.keys(nextEntry).length === 0) { + delete baseAccounts[accountKey]; + } else { + baseAccounts[accountKey] = nextEntry as TAccountEntry; + } + + const nextAccounts = Object.keys(baseAccounts).length > 0 ? baseAccounts : undefined; + return { + nextAccounts, + changed: true, + cleared, + }; +} diff --git a/src/cli/cron-cli/register.cron-add.ts b/src/cli/cron-cli/register.cron-add.ts index 4316ec06c36..05025dc05e6 100644 --- a/src/cli/cron-cli/register.cron-add.ts +++ b/src/cli/cron-cli/register.cron-add.ts @@ -1,6 +1,5 @@ import type { Command } from "commander"; import type { CronJob } from "../../cron/types.js"; -import { danger } from "../../globals.js"; import { sanitizeAgentId } from "../../routing/session-key.js"; import { defaultRuntime } from "../../runtime.js"; import type { GatewayRpcOpts } from "../gateway-rpc.js"; @@ -8,9 +7,11 @@ import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js"; import { parsePositiveIntOrUndefined } from "../program/helpers.js"; import { getCronChannelOptions, + handleCronCliError, parseAt, parseCronStaggerMs, parseDurationMs, + printCronJson, printCronList, warnIfCronSchedulerDisabled, } from "./shared.js"; @@ -24,10 +25,9 @@ export function registerCronStatusCommand(cron: Command) { .action(async (opts) => { try { const res = await callGatewayFromCli("cron.status", opts, {}); - defaultRuntime.log(JSON.stringify(res, null, 2)); + printCronJson(res); } catch (err) { - defaultRuntime.error(danger(String(err))); - defaultRuntime.exit(1); + handleCronCliError(err); } }), ); @@ -46,14 +46,13 @@ export function registerCronListCommand(cron: Command) { includeDisabled: Boolean(opts.all), }); if (opts.json) { - defaultRuntime.log(JSON.stringify(res, null, 2)); + printCronJson(res); return; } const jobs = (res as { jobs?: CronJob[] } | null)?.jobs ?? []; printCronList(jobs, defaultRuntime); } catch (err) { - defaultRuntime.error(danger(String(err))); - defaultRuntime.exit(1); + handleCronCliError(err); } }), ); @@ -273,11 +272,10 @@ export function registerCronAddCommand(cron: Command) { }; const res = await callGatewayFromCli("cron.add", opts, params); - defaultRuntime.log(JSON.stringify(res, null, 2)); + printCronJson(res); await warnIfCronSchedulerDisabled(opts); } catch (err) { - defaultRuntime.error(danger(String(err))); - defaultRuntime.exit(1); + handleCronCliError(err); } }), ); diff --git a/src/cli/cron-cli/register.cron-simple.ts b/src/cli/cron-cli/register.cron-simple.ts index b1929b6384e..ae05ff1fa69 100644 --- a/src/cli/cron-cli/register.cron-simple.ts +++ b/src/cli/cron-cli/register.cron-simple.ts @@ -1,8 +1,7 @@ import type { Command } from "commander"; -import { danger } from "../../globals.js"; import { defaultRuntime } from "../../runtime.js"; import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js"; -import { warnIfCronSchedulerDisabled } from "./shared.js"; +import { handleCronCliError, printCronJson, warnIfCronSchedulerDisabled } from "./shared.js"; function registerCronToggleCommand(params: { cron: Command; @@ -21,11 +20,10 @@ function registerCronToggleCommand(params: { id, patch: { enabled: params.enabled }, }); - defaultRuntime.log(JSON.stringify(res, null, 2)); + printCronJson(res); await warnIfCronSchedulerDisabled(opts); } catch (err) { - defaultRuntime.error(danger(String(err))); - defaultRuntime.exit(1); + handleCronCliError(err); } }), ); @@ -43,10 +41,9 @@ export function registerCronSimpleCommands(cron: Command) { .action(async (id, opts) => { try { const res = await callGatewayFromCli("cron.remove", opts, { id }); - defaultRuntime.log(JSON.stringify(res, null, 2)); + printCronJson(res); } catch (err) { - defaultRuntime.error(danger(String(err))); - defaultRuntime.exit(1); + handleCronCliError(err); } }), ); @@ -79,10 +76,9 @@ export function registerCronSimpleCommands(cron: Command) { id, limit, }); - defaultRuntime.log(JSON.stringify(res, null, 2)); + printCronJson(res); } catch (err) { - defaultRuntime.error(danger(String(err))); - defaultRuntime.exit(1); + handleCronCliError(err); } }), ); @@ -102,12 +98,11 @@ export function registerCronSimpleCommands(cron: Command) { id, mode: opts.due ? "due" : "force", }); - defaultRuntime.log(JSON.stringify(res, null, 2)); + printCronJson(res); const result = res as { ok?: boolean; ran?: boolean } | undefined; defaultRuntime.exit(result?.ok && result?.ran ? 0 : 1); } catch (err) { - defaultRuntime.error(danger(String(err))); - defaultRuntime.exit(1); + handleCronCliError(err); } }), ); diff --git a/src/cli/cron-cli/shared.ts b/src/cli/cron-cli/shared.ts index 5b9290fe858..d3601b6ce40 100644 --- a/src/cli/cron-cli/shared.ts +++ b/src/cli/cron-cli/shared.ts @@ -2,6 +2,7 @@ import { listChannelPlugins } from "../../channels/plugins/index.js"; import { parseAbsoluteTimeMs } from "../../cron/parse.js"; import { resolveCronStaggerMs } from "../../cron/stagger.js"; import type { CronJob, CronSchedule } from "../../cron/types.js"; +import { danger } from "../../globals.js"; import { formatDurationHuman } from "../../infra/format-time/format-duration.ts"; import { defaultRuntime } from "../../runtime.js"; import { colorize, isRich, theme } from "../../terminal/theme.js"; @@ -11,6 +12,15 @@ import { callGatewayFromCli } from "../gateway-rpc.js"; export const getCronChannelOptions = () => ["last", ...listChannelPlugins().map((plugin) => plugin.id)].join("|"); +export function printCronJson(value: unknown) { + defaultRuntime.log(JSON.stringify(value, null, 2)); +} + +export function handleCronCliError(err: unknown) { + defaultRuntime.error(danger(String(err))); + defaultRuntime.exit(1); +} + export async function warnIfCronSchedulerDisabled(opts: GatewayRpcOpts) { try { const res = (await callGatewayFromCli("cron.status", opts, {})) as { diff --git a/src/cli/memory-cli.test.ts b/src/cli/memory-cli.test.ts index b318ae8e62a..c3aec925eb6 100644 --- a/src/cli/memory-cli.test.ts +++ b/src/cli/memory-cli.test.ts @@ -60,6 +60,8 @@ describe("memory cli", () => { return JSON.parse(String(log.mock.calls[0]?.[0] ?? "null")) as Record; } + const inactiveMemorySecretDiagnostic = "agents.defaults.memorySearch.remote.apiKey inactive"; + function expectCliSync(sync: ReturnType) { expect(sync).toHaveBeenCalledWith( expect.objectContaining({ reason: "cli", force: false, progress: expect.any(Function) }), @@ -85,6 +87,25 @@ describe("memory cli", () => { getMemorySearchManager.mockResolvedValueOnce({ manager }); } + function setupMemoryStatusWithInactiveSecretDiagnostics(close: ReturnType) { + resolveCommandSecretRefsViaGateway.mockResolvedValueOnce({ + resolvedConfig: {}, + diagnostics: [inactiveMemorySecretDiagnostic] as string[], + }); + mockManager({ + probeVectorAvailability: vi.fn(async () => true), + status: () => makeMemoryStatus({ workspaceDir: undefined }), + close, + }); + } + + function hasLoggedInactiveSecretDiagnostic(spy: ReturnType) { + return spy.mock.calls.some( + (call: unknown[]) => + typeof call[0] === "string" && call[0].includes(inactiveMemorySecretDiagnostic), + ); + } + async function runMemoryCli(args: string[]) { const program = new Command(); program.name("test"); @@ -191,26 +212,12 @@ describe("memory cli", () => { it("logs gateway secret diagnostics for non-json status output", async () => { const close = vi.fn(async () => {}); - resolveCommandSecretRefsViaGateway.mockResolvedValueOnce({ - resolvedConfig: {}, - diagnostics: ["agents.defaults.memorySearch.remote.apiKey inactive"] as string[], - }); - mockManager({ - probeVectorAvailability: vi.fn(async () => true), - status: () => makeMemoryStatus({ workspaceDir: undefined }), - close, - }); + setupMemoryStatusWithInactiveSecretDiagnostics(close); const log = spyRuntimeLogs(); await runMemoryCli(["status"]); - expect( - log.mock.calls.some( - (call) => - typeof call[0] === "string" && - call[0].includes("agents.defaults.memorySearch.remote.apiKey inactive"), - ), - ).toBe(true); + expect(hasLoggedInactiveSecretDiagnostic(log)).toBe(true); }); it("prints vector error when unavailable", async () => { @@ -410,15 +417,7 @@ describe("memory cli", () => { it("routes gateway secret diagnostics to stderr for json status output", async () => { const close = vi.fn(async () => {}); - resolveCommandSecretRefsViaGateway.mockResolvedValueOnce({ - resolvedConfig: {}, - diagnostics: ["agents.defaults.memorySearch.remote.apiKey inactive"] as string[], - }); - mockManager({ - probeVectorAvailability: vi.fn(async () => true), - status: () => makeMemoryStatus({ workspaceDir: undefined }), - close, - }); + setupMemoryStatusWithInactiveSecretDiagnostics(close); const log = spyRuntimeLogs(); const error = spyRuntimeErrors(); @@ -426,13 +425,7 @@ describe("memory cli", () => { const payload = firstLoggedJson(log); expect(Array.isArray(payload)).toBe(true); - expect( - error.mock.calls.some( - (call) => - typeof call[0] === "string" && - call[0].includes("agents.defaults.memorySearch.remote.apiKey inactive"), - ), - ).toBe(true); + expect(hasLoggedInactiveSecretDiagnostic(error)).toBe(true); }); it("logs default message when memory manager is missing", async () => { diff --git a/src/cli/nodes-cli/register.invoke.ts b/src/cli/nodes-cli/register.invoke.ts index d23d35c9f21..fc0493734f9 100644 --- a/src/cli/nodes-cli/register.invoke.ts +++ b/src/cli/nodes-cli/register.invoke.ts @@ -9,6 +9,8 @@ import { type ExecSecurity, maxAsk, minSecurity, + normalizeExecAsk, + normalizeExecSecurity, resolveExecApprovalsFromFile, } from "../../infra/exec-approvals.js"; import { buildNodeShellCommand } from "../../infra/node-shell.js"; @@ -43,22 +45,6 @@ type ExecDefaults = { safeBins?: string[]; }; -function normalizeExecSecurity(value?: string | null): ExecSecurity | null { - const normalized = value?.trim().toLowerCase(); - if (normalized === "deny" || normalized === "allowlist" || normalized === "full") { - return normalized; - } - return null; -} - -function normalizeExecAsk(value?: string | null): ExecAsk | null { - const normalized = value?.trim().toLowerCase(); - if (normalized === "off" || normalized === "on-miss" || normalized === "always") { - return normalized as ExecAsk; - } - return null; -} - function resolveExecDefaults( cfg: ReturnType, agentId: string | undefined, diff --git a/src/cli/qr-cli.test.ts b/src/cli/qr-cli.test.ts index 97e5c1c01a7..92b4af93e2f 100644 --- a/src/cli/qr-cli.test.ts +++ b/src/cli/qr-cli.test.ts @@ -72,6 +72,32 @@ function createTailscaleRemoteRefConfig() { }; } +function createDefaultSecretProvider() { + return { + providers: { + default: { source: "env" as const }, + }, + }; +} + +function createLocalGatewayConfigWithAuth(auth: Record) { + return { + secrets: createDefaultSecretProvider(), + gateway: { + bind: "custom", + customBindHost: "gateway.local", + auth, + }, + }; +} + +function createLocalGatewayPasswordRefAuth(secretId: string) { + return { + mode: "password", + password: { source: "env", provider: "default", id: secretId }, + }; +} + describe("registerQrCli", () => { function createProgram() { const program = new Command(); @@ -88,6 +114,23 @@ describe("registerQrCli", () => { await expect(runQr(args)).rejects.toThrow("exit"); } + function parseLastLoggedQrJson() { + return JSON.parse(String(runtime.log.mock.calls.at(-1)?.[0] ?? "{}")) as { + setupCode?: string; + gatewayUrl?: string; + auth?: string; + urlSource?: string; + }; + } + + function mockTailscaleStatusLookup() { + runCommandWithTimeout.mockResolvedValue({ + code: 0, + stdout: '{"Self":{"DNSName":"ts-host.tailnet.ts.net."}}', + stderr: "", + }); + } + beforeEach(() => { vi.clearAllMocks(); vi.stubEnv("OPENCLAW_GATEWAY_TOKEN", ""); @@ -157,21 +200,11 @@ describe("registerQrCli", () => { }); it("skips local password SecretRef resolution when --token override is provided", async () => { - loadConfig.mockReturnValue({ - secrets: { - providers: { - default: { source: "env" }, - }, - }, - gateway: { - bind: "custom", - customBindHost: "gateway.local", - auth: { - mode: "password", - password: { source: "env", provider: "default", id: "MISSING_LOCAL_GATEWAY_PASSWORD" }, - }, - }, - }); + loadConfig.mockReturnValue( + createLocalGatewayConfigWithAuth( + createLocalGatewayPasswordRefAuth("MISSING_LOCAL_GATEWAY_PASSWORD"), + ), + ); await runQr(["--setup-code-only", "--token", "override-token"]); @@ -184,21 +217,11 @@ describe("registerQrCli", () => { it("resolves local gateway auth password SecretRefs before setup code generation", async () => { vi.stubEnv("QR_LOCAL_GATEWAY_PASSWORD", "local-password-secret"); - loadConfig.mockReturnValue({ - secrets: { - providers: { - default: { source: "env" }, - }, - }, - gateway: { - bind: "custom", - customBindHost: "gateway.local", - auth: { - mode: "password", - password: { source: "env", provider: "default", id: "QR_LOCAL_GATEWAY_PASSWORD" }, - }, - }, - }); + loadConfig.mockReturnValue( + createLocalGatewayConfigWithAuth( + createLocalGatewayPasswordRefAuth("QR_LOCAL_GATEWAY_PASSWORD"), + ), + ); await runQr(["--setup-code-only"]); @@ -212,21 +235,11 @@ describe("registerQrCli", () => { it("uses OPENCLAW_GATEWAY_PASSWORD without resolving local password SecretRef", async () => { vi.stubEnv("OPENCLAW_GATEWAY_PASSWORD", "password-from-env"); - loadConfig.mockReturnValue({ - secrets: { - providers: { - default: { source: "env" }, - }, - }, - gateway: { - bind: "custom", - customBindHost: "gateway.local", - auth: { - mode: "password", - password: { source: "env", provider: "default", id: "MISSING_LOCAL_GATEWAY_PASSWORD" }, - }, - }, - }); + loadConfig.mockReturnValue( + createLocalGatewayConfigWithAuth( + createLocalGatewayPasswordRefAuth("MISSING_LOCAL_GATEWAY_PASSWORD"), + ), + ); await runQr(["--setup-code-only"]); @@ -239,22 +252,13 @@ describe("registerQrCli", () => { }); it("does not resolve local password SecretRef when auth mode is token", async () => { - loadConfig.mockReturnValue({ - secrets: { - providers: { - default: { source: "env" }, - }, - }, - gateway: { - bind: "custom", - customBindHost: "gateway.local", - auth: { - mode: "token", - token: "token-123", - password: { source: "env", provider: "default", id: "MISSING_LOCAL_GATEWAY_PASSWORD" }, - }, - }, - }); + loadConfig.mockReturnValue( + createLocalGatewayConfigWithAuth({ + mode: "token", + token: "token-123", + password: { source: "env", provider: "default", id: "MISSING_LOCAL_GATEWAY_PASSWORD" }, + }), + ); await runQr(["--setup-code-only"]); @@ -268,20 +272,11 @@ describe("registerQrCli", () => { it("resolves local password SecretRef when auth mode is inferred", async () => { vi.stubEnv("QR_INFERRED_GATEWAY_PASSWORD", "inferred-password"); - loadConfig.mockReturnValue({ - secrets: { - providers: { - default: { source: "env" }, - }, - }, - gateway: { - bind: "custom", - customBindHost: "gateway.local", - auth: { - password: { source: "env", provider: "default", id: "QR_INFERRED_GATEWAY_PASSWORD" }, - }, - }, - }); + loadConfig.mockReturnValue( + createLocalGatewayConfigWithAuth({ + password: { source: "env", provider: "default", id: "QR_INFERRED_GATEWAY_PASSWORD" }, + }), + ); await runQr(["--setup-code-only"]); @@ -390,20 +385,11 @@ describe("registerQrCli", () => { { name: "when tailscale is configured", withTailscale: true }, ])("reports gateway.remote.url as source in --remote json output ($name)", async (testCase) => { loadConfig.mockReturnValue(createRemoteQrConfig({ withTailscale: testCase.withTailscale })); - runCommandWithTimeout.mockResolvedValue({ - code: 0, - stdout: '{"Self":{"DNSName":"ts-host.tailnet.ts.net."}}', - stderr: "", - }); + mockTailscaleStatusLookup(); await runQr(["--json", "--remote"]); - const payload = JSON.parse(String(runtime.log.mock.calls.at(-1)?.[0] ?? "{}")) as { - setupCode?: string; - gatewayUrl?: string; - auth?: string; - urlSource?: string; - }; + const payload = parseLastLoggedQrJson(); expect(payload.gatewayUrl).toBe("wss://remote.example.com:444"); expect(payload.auth).toBe("token"); expect(payload.urlSource).toBe("gateway.remote.url"); @@ -416,20 +402,11 @@ describe("registerQrCli", () => { resolvedConfig: createRemoteQrConfig(), diagnostics: ["gateway.remote.password inactive"] as string[], }); - runCommandWithTimeout.mockResolvedValue({ - code: 0, - stdout: '{"Self":{"DNSName":"ts-host.tailnet.ts.net."}}', - stderr: "", - }); + mockTailscaleStatusLookup(); await runQr(["--json", "--remote"]); - const payload = JSON.parse(String(runtime.log.mock.calls.at(-1)?.[0] ?? "{}")) as { - setupCode?: string; - gatewayUrl?: string; - auth?: string; - urlSource?: string; - }; + const payload = parseLastLoggedQrJson(); expect(payload.gatewayUrl).toBe("wss://remote.example.com:444"); expect( runtime.error.mock.calls.some((call) => diff --git a/src/config/sessions/store.ts b/src/config/sessions/store.ts index 96eea548598..9984752985d 100644 --- a/src/config/sessions/store.ts +++ b/src/config/sessions/store.ts @@ -405,20 +405,15 @@ async function saveSessionStoreUnlocked( .map((entry) => entry?.sessionId) .filter((id): id is string => Boolean(id)), ); - for (const [sessionId, sessionFile] of removedSessionFiles) { - if (referencedSessionIds.has(sessionId)) { - continue; - } - const archived = archiveSessionTranscripts({ - sessionId, - storePath, - sessionFile, - reason: "deleted", - restrictToStoreDir: true, - }); - for (const archivedPath of archived) { - archivedDirs.add(path.dirname(archivedPath)); - } + const archivedForDeletedSessions = archiveRemovedSessionTranscripts({ + removedSessionFiles, + referencedSessionIds, + storePath, + reason: "deleted", + restrictToStoreDir: true, + }); + for (const archivedDir of archivedForDeletedSessions) { + archivedDirs.add(archivedDir); } if (archivedDirs.size > 0 || maintenance.resetArchiveRetentionMs != null) { const targetDirs = @@ -574,6 +569,32 @@ function rememberRemovedSessionFile( } } +export function archiveRemovedSessionTranscripts(params: { + removedSessionFiles: Iterable<[string, string | undefined]>; + referencedSessionIds: ReadonlySet; + storePath: string; + reason: "deleted" | "reset"; + restrictToStoreDir?: boolean; +}): Set { + const archivedDirs = new Set(); + for (const [sessionId, sessionFile] of params.removedSessionFiles) { + if (params.referencedSessionIds.has(sessionId)) { + continue; + } + const archived = archiveSessionTranscripts({ + sessionId, + storePath: params.storePath, + sessionFile, + reason: params.reason, + restrictToStoreDir: params.restrictToStoreDir, + }); + for (const archivedPath of archived) { + archivedDirs.add(path.dirname(archivedPath)); + } + } + return archivedDirs; +} + async function writeSessionStoreAtomic(params: { storePath: string; store: Record; diff --git a/src/config/validation.ts b/src/config/validation.ts index f6687e172bb..90d733e0818 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -285,7 +285,7 @@ export function validateConfigObject( }; } -export function validateConfigObjectWithPlugins(raw: unknown): +type ValidateConfigWithPluginsResult = | { ok: true; config: OpenClawConfig; @@ -295,38 +295,20 @@ export function validateConfigObjectWithPlugins(raw: unknown): ok: false; issues: ConfigValidationIssue[]; warnings: ConfigValidationIssue[]; - } { + }; + +export function validateConfigObjectWithPlugins(raw: unknown): ValidateConfigWithPluginsResult { return validateConfigObjectWithPluginsBase(raw, { applyDefaults: true }); } -export function validateConfigObjectRawWithPlugins(raw: unknown): - | { - ok: true; - config: OpenClawConfig; - warnings: ConfigValidationIssue[]; - } - | { - ok: false; - issues: ConfigValidationIssue[]; - warnings: ConfigValidationIssue[]; - } { +export function validateConfigObjectRawWithPlugins(raw: unknown): ValidateConfigWithPluginsResult { return validateConfigObjectWithPluginsBase(raw, { applyDefaults: false }); } function validateConfigObjectWithPluginsBase( raw: unknown, opts: { applyDefaults: boolean }, -): - | { - ok: true; - config: OpenClawConfig; - warnings: ConfigValidationIssue[]; - } - | { - ok: false; - issues: ConfigValidationIssue[]; - warnings: ConfigValidationIssue[]; - } { +): ValidateConfigWithPluginsResult { const base = opts.applyDefaults ? validateConfigObject(raw) : validateConfigObjectRaw(raw); if (!base.ok) { return { ok: false, issues: base.issues, warnings: [] }; diff --git a/src/config/zod-schema.secret-input-validation.ts b/src/config/zod-schema.secret-input-validation.ts index f033b266889..3426e61d15f 100644 --- a/src/config/zod-schema.secret-input-validation.ts +++ b/src/config/zod-schema.secret-input-validation.ts @@ -25,6 +25,21 @@ type SlackConfigLike = { accounts?: Record; }; +function forEachEnabledAccount( + accounts: Record | undefined, + run: (accountId: string, account: T) => void, +): void { + if (!accounts) { + return; + } + for (const [accountId, account] of Object.entries(accounts)) { + if (!account || account.enabled === false) { + continue; + } + run(accountId, account); + } +} + export function validateTelegramWebhookSecretRequirements( value: TelegramConfigLike, ctx: z.RefinementCtx, @@ -38,20 +53,11 @@ export function validateTelegramWebhookSecretRequirements( path: ["webhookSecret"], }); } - if (!value.accounts) { - return; - } - for (const [accountId, account] of Object.entries(value.accounts)) { - if (!account) { - continue; - } - if (account.enabled === false) { - continue; - } + forEachEnabledAccount(value.accounts, (accountId, account) => { const accountWebhookUrl = typeof account.webhookUrl === "string" ? account.webhookUrl.trim() : ""; if (!accountWebhookUrl) { - continue; + return; } const hasAccountSecret = hasConfiguredSecretInput(account.webhookSecret); if (!hasAccountSecret && !hasBaseWebhookSecret) { @@ -62,7 +68,7 @@ export function validateTelegramWebhookSecretRequirements( path: ["accounts", accountId, "webhookSecret"], }); } - } + }); } export function validateSlackSigningSecretRequirements( @@ -77,20 +83,11 @@ export function validateSlackSigningSecretRequirements( path: ["signingSecret"], }); } - if (!value.accounts) { - return; - } - for (const [accountId, account] of Object.entries(value.accounts)) { - if (!account) { - continue; - } - if (account.enabled === false) { - continue; - } + forEachEnabledAccount(value.accounts, (accountId, account) => { const accountMode = account.mode === "http" || account.mode === "socket" ? account.mode : baseMode; if (accountMode !== "http") { - continue; + return; } const accountSecret = account.signingSecret ?? value.signingSecret; if (!hasConfiguredSecretInput(accountSecret)) { @@ -101,5 +98,5 @@ export function validateSlackSigningSecretRequirements( path: ["accounts", accountId, "signingSecret"], }); } - } + }); } diff --git a/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts b/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts index e9dceba6365..bc763a7a588 100644 --- a/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts +++ b/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts @@ -1,8 +1,6 @@ import "./isolated-agent.mocks.js"; import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { runSubagentAnnounceFlow } from "../agents/subagent-announce.js"; import type { CliDeps } from "../cli/deps.js"; import { @@ -12,72 +10,15 @@ import { runTelegramAnnounceTurn, } from "./isolated-agent.delivery.test-helpers.js"; import { runCronIsolatedAgentTurn } from "./isolated-agent.js"; -import { makeCfg, makeJob, writeSessionStore } from "./isolated-agent.test-harness.js"; +import { + makeCfg, + makeJob, + withTempCronHome as withTempHome, + writeSessionStore, +} from "./isolated-agent.test-harness.js"; import { setupIsolatedAgentTurnMocks } from "./isolated-agent.test-setup.js"; -type HomeEnvSnapshot = { - HOME: string | undefined; - USERPROFILE: string | undefined; - HOMEDRIVE: string | undefined; - HOMEPATH: string | undefined; - OPENCLAW_HOME: string | undefined; - OPENCLAW_STATE_DIR: string | undefined; -}; - const TELEGRAM_TARGET = { mode: "announce", channel: "telegram", to: "123" } as const; -let suiteTempHomeRoot = ""; -let suiteTempHomeCaseId = 0; - -function snapshotHomeEnv(): HomeEnvSnapshot { - return { - HOME: process.env.HOME, - USERPROFILE: process.env.USERPROFILE, - HOMEDRIVE: process.env.HOMEDRIVE, - HOMEPATH: process.env.HOMEPATH, - OPENCLAW_HOME: process.env.OPENCLAW_HOME, - OPENCLAW_STATE_DIR: process.env.OPENCLAW_STATE_DIR, - }; -} - -function restoreHomeEnv(snapshot: HomeEnvSnapshot) { - const restoreValue = (key: keyof HomeEnvSnapshot) => { - const value = snapshot[key]; - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } - }; - restoreValue("HOME"); - restoreValue("USERPROFILE"); - restoreValue("HOMEDRIVE"); - restoreValue("HOMEPATH"); - restoreValue("OPENCLAW_HOME"); - restoreValue("OPENCLAW_STATE_DIR"); -} - -async function withTempHome(fn: (home: string) => Promise): Promise { - const home = path.join(suiteTempHomeRoot, `case-${suiteTempHomeCaseId++}`); - await fs.mkdir(path.join(home, ".openclaw", "agents", "main", "sessions"), { recursive: true }); - const snapshot = snapshotHomeEnv(); - process.env.HOME = home; - process.env.USERPROFILE = home; - delete process.env.OPENCLAW_HOME; - process.env.OPENCLAW_STATE_DIR = path.join(home, ".openclaw"); - if (process.platform === "win32") { - const parsed = path.parse(home); - if (parsed.root) { - process.env.HOMEDRIVE = parsed.root.replace(/[\\/]+$/, ""); - process.env.HOMEPATH = home.slice(process.env.HOMEDRIVE.length) || "\\"; - } - } - try { - return await fn(home); - } finally { - restoreHomeEnv(snapshot); - } -} - async function runExplicitTelegramAnnounceTurn(params: { home: string; storePath: string; @@ -264,19 +205,6 @@ async function assertExplicitTelegramTargetAnnounce(params: { } describe("runCronIsolatedAgentTurn", () => { - beforeAll(async () => { - suiteTempHomeRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cron-delivery-suite-")); - }); - - afterAll(async () => { - if (!suiteTempHomeRoot) { - return; - } - await fs.rm(suiteTempHomeRoot, { recursive: true, force: true }); - suiteTempHomeRoot = ""; - suiteTempHomeCaseId = 0; - }); - beforeEach(() => { setupIsolatedAgentTurnMocks(); }); diff --git a/src/cron/isolated-agent.uses-last-non-empty-agent-text-as.test.ts b/src/cron/isolated-agent.uses-last-non-empty-agent-text-as.test.ts index 2ef6df271d5..2a4b786f99c 100644 --- a/src/cron/isolated-agent.uses-last-non-empty-agent-text-as.test.ts +++ b/src/cron/isolated-agent.uses-last-non-empty-agent-text-as.test.ts @@ -1,8 +1,7 @@ import "./isolated-agent.mocks.js"; import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; -import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { loadModelCatalog } from "../agents/model-catalog.js"; import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; import type { CliDeps } from "../cli/deps.js"; @@ -10,73 +9,12 @@ import { runCronIsolatedAgentTurn } from "./isolated-agent.js"; import { makeCfg, makeJob, + withTempCronHome as withTempHome, writeSessionStore, writeSessionStoreEntries, } from "./isolated-agent.test-harness.js"; import type { CronJob } from "./types.js"; -type HomeEnvSnapshot = { - HOME: string | undefined; - USERPROFILE: string | undefined; - HOMEDRIVE: string | undefined; - HOMEPATH: string | undefined; - OPENCLAW_HOME: string | undefined; - OPENCLAW_STATE_DIR: string | undefined; -}; - -let suiteTempHomeRoot = ""; -let suiteTempHomeCaseId = 0; - -function snapshotHomeEnv(): HomeEnvSnapshot { - return { - HOME: process.env.HOME, - USERPROFILE: process.env.USERPROFILE, - HOMEDRIVE: process.env.HOMEDRIVE, - HOMEPATH: process.env.HOMEPATH, - OPENCLAW_HOME: process.env.OPENCLAW_HOME, - OPENCLAW_STATE_DIR: process.env.OPENCLAW_STATE_DIR, - }; -} - -function restoreHomeEnv(snapshot: HomeEnvSnapshot) { - const restoreValue = (key: keyof HomeEnvSnapshot) => { - const value = snapshot[key]; - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } - }; - restoreValue("HOME"); - restoreValue("USERPROFILE"); - restoreValue("HOMEDRIVE"); - restoreValue("HOMEPATH"); - restoreValue("OPENCLAW_HOME"); - restoreValue("OPENCLAW_STATE_DIR"); -} - -async function withTempHome(fn: (home: string) => Promise): Promise { - const home = path.join(suiteTempHomeRoot, `case-${suiteTempHomeCaseId++}`); - await fs.mkdir(path.join(home, ".openclaw", "agents", "main", "sessions"), { recursive: true }); - const snapshot = snapshotHomeEnv(); - process.env.HOME = home; - process.env.USERPROFILE = home; - delete process.env.OPENCLAW_HOME; - process.env.OPENCLAW_STATE_DIR = path.join(home, ".openclaw"); - if (process.platform === "win32") { - const parsed = path.parse(home); - if (parsed.root) { - process.env.HOMEDRIVE = parsed.root.replace(/[\\/]+$/, ""); - process.env.HOMEPATH = home.slice(process.env.HOMEDRIVE.length) || "\\"; - } - } - try { - return await fn(home); - } finally { - restoreHomeEnv(snapshot); - } -} - function makeDeps(): CliDeps { return { sendMessageSlack: vi.fn(), @@ -224,19 +162,6 @@ async function runStoredOverrideAndExpectModel(params: { } describe("runCronIsolatedAgentTurn", () => { - beforeAll(async () => { - suiteTempHomeRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cron-turn-suite-")); - }); - - afterAll(async () => { - if (!suiteTempHomeRoot) { - return; - } - await fs.rm(suiteTempHomeRoot, { recursive: true, force: true }); - suiteTempHomeRoot = ""; - suiteTempHomeCaseId = 0; - }); - beforeEach(() => { vi.mocked(runEmbeddedPiAgent).mockClear(); vi.mocked(loadModelCatalog).mockResolvedValue([]); diff --git a/src/cron/session-reaper.ts b/src/cron/session-reaper.ts index fa12caa2f56..dd0094d4c57 100644 --- a/src/cron/session-reaper.ts +++ b/src/cron/session-reaper.ts @@ -6,14 +6,14 @@ * run records. The base session (`...:cron:`) is kept as-is. */ -import path from "node:path"; import { parseDurationMs } from "../cli/parse-duration.js"; -import { loadSessionStore, updateSessionStore } from "../config/sessions.js"; -import type { CronConfig } from "../config/types.cron.js"; import { - archiveSessionTranscripts, - cleanupArchivedSessionTranscripts, -} from "../gateway/session-utils.fs.js"; + archiveRemovedSessionTranscripts, + loadSessionStore, + updateSessionStore, +} from "../config/sessions.js"; +import type { CronConfig } from "../config/types.cron.js"; +import { cleanupArchivedSessionTranscripts } from "../gateway/session-utils.fs.js"; import { isCronRunSessionKey } from "../sessions/session-key-utils.js"; import type { Logger } from "./service/state.js"; @@ -116,22 +116,13 @@ export async function sweepCronRunSessions(params: { .map((entry) => entry?.sessionId) .filter((id): id is string => Boolean(id)), ); - const archivedDirs = new Set(); - for (const [sessionId, sessionFile] of prunedSessions) { - if (referencedSessionIds.has(sessionId)) { - continue; - } - const archived = archiveSessionTranscripts({ - sessionId, - storePath, - sessionFile, - reason: "deleted", - restrictToStoreDir: true, - }); - for (const archivedPath of archived) { - archivedDirs.add(path.dirname(archivedPath)); - } - } + const archivedDirs = archiveRemovedSessionTranscripts({ + removedSessionFiles: prunedSessions, + referencedSessionIds, + storePath, + reason: "deleted", + restrictToStoreDir: true, + }); if (archivedDirs.size > 0) { await cleanupArchivedSessionTranscripts({ directories: [...archivedDirs], diff --git a/src/discord/monitor/message-handler.preflight.test.ts b/src/discord/monitor/message-handler.preflight.test.ts index 9a2fb11eebf..ac2ab57e283 100644 --- a/src/discord/monitor/message-handler.preflight.test.ts +++ b/src/discord/monitor/message-handler.preflight.test.ts @@ -21,6 +21,12 @@ import { createThreadBindingManager, } from "./thread-bindings.js"; +type DiscordConfig = NonNullable< + import("../../config/config.js").OpenClawConfig["channels"] +>["discord"]; +type DiscordMessageEvent = import("./listeners.js").DiscordMessageEvent; +type DiscordClient = import("@buape/carbon").Client; + function createThreadBinding( overrides?: Partial< import("../../infra/outbound/session-binding-service.js").SessionBindingRecord @@ -48,6 +54,34 @@ function createThreadBinding( } satisfies import("../../infra/outbound/session-binding-service.js").SessionBindingRecord; } +function createPreflightArgs(params: { + cfg: import("../../config/config.js").OpenClawConfig; + discordConfig: DiscordConfig; + data: DiscordMessageEvent; + client: DiscordClient; +}): Parameters[0] { + return { + cfg: params.cfg, + discordConfig: params.discordConfig, + accountId: "default", + token: "token", + runtime: {} as import("../../runtime.js").RuntimeEnv, + botUserId: "openclaw-bot", + guildHistories: new Map(), + historyLimit: 0, + mediaMaxBytes: 1_000_000, + textLimit: 2_000, + replyToMode: "all", + dmEnabled: true, + groupDmEnabled: true, + ackReactionScope: "direct", + groupPolicy: "open", + threadBindings: createNoopThreadBindingManager("default"), + data: params.data, + client: params.client, + }; +} + describe("resolvePreflightMentionRequirement", () => { it("requires mention when config requires mention and thread is not bound", () => { expect( @@ -312,42 +346,30 @@ describe("preflightDiscordMessage", () => { resolveByConversation: (ref) => (ref.conversationId === threadId ? threadBinding : null), }); - const result = await preflightDiscordMessage({ - cfg: { - session: { - mainKey: "main", - scope: "per-sender", - }, - } as import("../../config/config.js").OpenClawConfig, - discordConfig: { - allowBots: true, - } as NonNullable["discord"], - accountId: "default", - token: "token", - runtime: {} as import("../../runtime.js").RuntimeEnv, - botUserId: "openclaw-bot", - guildHistories: new Map(), - historyLimit: 0, - mediaMaxBytes: 1_000_000, - textLimit: 2_000, - replyToMode: "all", - dmEnabled: true, - groupDmEnabled: true, - ackReactionScope: "direct", - groupPolicy: "open", - threadBindings: createNoopThreadBindingManager("default"), - data: { - channel_id: threadId, - guild_id: "guild-1", - guild: { - id: "guild-1", - name: "Guild One", - }, - author: message.author, - message, - } as unknown as import("./listeners.js").DiscordMessageEvent, - client, - }); + const result = await preflightDiscordMessage( + createPreflightArgs({ + cfg: { + session: { + mainKey: "main", + scope: "per-sender", + }, + } as import("../../config/config.js").OpenClawConfig, + discordConfig: { + allowBots: true, + } as DiscordConfig, + data: { + channel_id: threadId, + guild_id: "guild-1", + guild: { + id: "guild-1", + name: "Guild One", + }, + author: message.author, + message, + } as unknown as DiscordMessageEvent, + client, + }), + ); expect(result).not.toBeNull(); expect(result?.boundSessionKey).toBe(threadBinding.targetSessionKey); @@ -768,47 +790,33 @@ describe("preflightDiscordMessage", () => { }, } as unknown as import("@buape/carbon").Message; - const result = await preflightDiscordMessage({ - cfg: { - session: { - mainKey: "main", - scope: "per-sender", - }, - messages: { - groupChat: { - mentionPatterns: ["openclaw"], + const result = await preflightDiscordMessage( + createPreflightArgs({ + cfg: { + session: { + mainKey: "main", + scope: "per-sender", }, - }, - } as import("../../config/config.js").OpenClawConfig, - discordConfig: {} as NonNullable< - import("../../config/config.js").OpenClawConfig["channels"] - >["discord"], - accountId: "default", - token: "token", - runtime: {} as import("../../runtime.js").RuntimeEnv, - botUserId: "openclaw-bot", - guildHistories: new Map(), - historyLimit: 0, - mediaMaxBytes: 1_000_000, - textLimit: 2_000, - replyToMode: "all", - dmEnabled: true, - groupDmEnabled: true, - ackReactionScope: "direct", - groupPolicy: "open", - threadBindings: createNoopThreadBindingManager("default"), - data: { - channel_id: channelId, - guild_id: "guild-1", - guild: { - id: "guild-1", - name: "Guild One", - }, - author: message.author, - message, - } as unknown as import("./listeners.js").DiscordMessageEvent, - client, - }); + messages: { + groupChat: { + mentionPatterns: ["openclaw"], + }, + }, + } as import("../../config/config.js").OpenClawConfig, + discordConfig: {} as DiscordConfig, + data: { + channel_id: channelId, + guild_id: "guild-1", + guild: { + id: "guild-1", + name: "Guild One", + }, + author: message.author, + message, + } as unknown as DiscordMessageEvent, + client, + }), + ); expect(transcribeFirstAudioMock).toHaveBeenCalledTimes(1); expect(transcribeFirstAudioMock).toHaveBeenCalledWith( diff --git a/src/discord/voice/manager.e2e.test.ts b/src/discord/voice/manager.e2e.test.ts index 3031b3d98cd..ff1aca6ca25 100644 --- a/src/discord/voice/manager.e2e.test.ts +++ b/src/discord/voice/manager.e2e.test.ts @@ -199,6 +199,30 @@ describe("DiscordVoiceManager", () => { ); }; + type ProcessSegmentInvoker = { + processSegment: (params: { + entry: unknown; + wavPath: string; + userId: string; + durationSeconds: number; + }) => Promise; + }; + + const processVoiceSegment = async ( + manager: InstanceType, + userId: string, + ) => + await (manager as unknown as ProcessSegmentInvoker).processSegment({ + entry: { + guildId: "g1", + channelId: "c1", + route: { sessionKey: "discord:g1:c1", agentId: "agent-1" }, + }, + wavPath: "/tmp/test.wav", + userId, + durationSeconds: 1.2, + }); + it("keeps the new session when an old disconnected handler fires", async () => { const oldConnection = createConnectionMock(); const newConnection = createConnectionMock(); @@ -298,25 +322,7 @@ describe("DiscordVoiceManager", () => { }, }); const manager = createManager({ allowFrom: ["discord:u-owner"] }, client); - await ( - manager as unknown as { - processSegment: (params: { - entry: unknown; - wavPath: string; - userId: string; - durationSeconds: number; - }) => Promise; - } - ).processSegment({ - entry: { - guildId: "g1", - channelId: "c1", - route: { sessionKey: "discord:g1:c1", agentId: "agent-1" }, - }, - wavPath: "/tmp/test.wav", - userId: "u-owner", - durationSeconds: 1.2, - }); + await processVoiceSegment(manager, "u-owner"); const commandArgs = agentCommandMock.mock.calls.at(-1)?.[0] as | { senderIsOwner?: boolean } @@ -336,25 +342,7 @@ describe("DiscordVoiceManager", () => { }, }); const manager = createManager({ allowFrom: ["discord:u-owner"] }, client); - await ( - manager as unknown as { - processSegment: (params: { - entry: unknown; - wavPath: string; - userId: string; - durationSeconds: number; - }) => Promise; - } - ).processSegment({ - entry: { - guildId: "g1", - channelId: "c1", - route: { sessionKey: "discord:g1:c1", agentId: "agent-1" }, - }, - wavPath: "/tmp/test.wav", - userId: "u-guest", - durationSeconds: 1.2, - }); + await processVoiceSegment(manager, "u-guest"); const commandArgs = agentCommandMock.mock.calls.at(-1)?.[0] as | { senderIsOwner?: boolean } @@ -374,26 +362,7 @@ describe("DiscordVoiceManager", () => { }, }); const manager = createManager({ allowFrom: ["discord:u-cache"] }, client); - const runSegment = async () => - await ( - manager as unknown as { - processSegment: (params: { - entry: unknown; - wavPath: string; - userId: string; - durationSeconds: number; - }) => Promise; - } - ).processSegment({ - entry: { - guildId: "g1", - channelId: "c1", - route: { sessionKey: "discord:g1:c1", agentId: "agent-1" }, - }, - wavPath: "/tmp/test.wav", - userId: "u-cache", - durationSeconds: 1.2, - }); + const runSegment = async () => await processVoiceSegment(manager, "u-cache"); await runSegment(); await runSegment(); diff --git a/src/gateway/auth-config-utils.ts b/src/gateway/auth-config-utils.ts new file mode 100644 index 00000000000..f62e60f85ea --- /dev/null +++ b/src/gateway/auth-config-utils.ts @@ -0,0 +1,69 @@ +import type { GatewayAuthConfig, OpenClawConfig } from "../config/config.js"; +import { resolveSecretInputRef } from "../config/types.secrets.js"; +import { secretRefKey } from "../secrets/ref-contract.js"; +import { resolveSecretRefValues } from "../secrets/resolve.js"; + +export function withGatewayAuthPassword(cfg: OpenClawConfig, password: string): OpenClawConfig { + return { + ...cfg, + gateway: { + ...cfg.gateway, + auth: { + ...cfg.gateway?.auth, + password, + }, + }, + }; +} + +function shouldResolveGatewayPasswordSecretRef(params: { + mode?: GatewayAuthConfig["mode"]; + hasPasswordCandidate: boolean; + hasTokenCandidate: boolean; +}): boolean { + if (params.hasPasswordCandidate) { + return false; + } + if (params.mode === "password") { + return true; + } + if (params.mode === "token" || params.mode === "none" || params.mode === "trusted-proxy") { + return false; + } + return !params.hasTokenCandidate; +} + +export async function resolveGatewayPasswordSecretRef(params: { + cfg: OpenClawConfig; + env: NodeJS.ProcessEnv; + mode?: GatewayAuthConfig["mode"]; + hasPasswordCandidate: boolean; + hasTokenCandidate: boolean; +}): Promise { + const authPassword = params.cfg.gateway?.auth?.password; + const { ref } = resolveSecretInputRef({ + value: authPassword, + defaults: params.cfg.secrets?.defaults, + }); + if (!ref) { + return params.cfg; + } + if ( + !shouldResolveGatewayPasswordSecretRef({ + mode: params.mode, + hasPasswordCandidate: params.hasPasswordCandidate, + hasTokenCandidate: params.hasTokenCandidate, + }) + ) { + return params.cfg; + } + const resolved = await resolveSecretRefValues([ref], { + config: params.cfg, + env: params.env, + }); + const value = resolved.get(secretRefKey(ref)); + if (typeof value !== "string" || value.trim().length === 0) { + throw new Error("gateway.auth.password resolved to an empty or non-string value."); + } + return withGatewayAuthPassword(params.cfg, value.trim()); +} diff --git a/src/gateway/call.ts b/src/gateway/call.ts index fa140608216..5d036a0d32a 100644 --- a/src/gateway/call.ts +++ b/src/gateway/call.ts @@ -9,8 +9,7 @@ import { import { hasConfiguredSecretInput, resolveSecretInputRef } from "../config/types.secrets.js"; import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js"; import { loadGatewayTlsRuntime } from "../infra/tls/gateway.js"; -import { secretRefKey } from "../secrets/ref-contract.js"; -import { resolveSecretRefValues } from "../secrets/resolve.js"; +import { resolveSecretInputString } from "../secrets/resolve-secret-input-string.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES, @@ -312,23 +311,16 @@ async function resolveGatewaySecretInputString(params: { path: string; env: NodeJS.ProcessEnv; }): Promise { - const defaults = params.config.secrets?.defaults; - const { ref } = resolveSecretInputRef({ - value: params.value, - defaults, - }); - if (!ref) { - return trimToUndefined(params.value); - } - const resolved = await resolveSecretRefValues([ref], { + const value = await resolveSecretInputString({ config: params.config, + value: params.value, env: params.env, + normalize: trimToUndefined, }); - const resolvedValue = trimToUndefined(resolved.get(secretRefKey(ref))); - if (!resolvedValue) { + if (!value) { throw new Error(`${params.path} resolved to an empty or non-string value.`); } - return resolvedValue; + return value; } async function resolveGatewayCredentials(context: ResolvedGatewayCallContext): Promise<{ diff --git a/src/gateway/credentials.test.ts b/src/gateway/credentials.test.ts index 67e2b4dac09..3af265e10f5 100644 --- a/src/gateway/credentials.test.ts +++ b/src/gateway/credentials.test.ts @@ -50,6 +50,27 @@ function resolveRemoteModeWithRemoteCredentials( ); } +function resolveLocalModeWithUnresolvedPassword(mode: "none" | "trusted-proxy") { + return resolveGatewayCredentialsFromConfig({ + cfg: { + gateway: { + mode: "local", + auth: { + mode, + password: { source: "env", provider: "default", id: "MISSING_GATEWAY_PASSWORD" }, + }, + }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + } as unknown as OpenClawConfig, + env: {} as NodeJS.ProcessEnv, + includeLegacyEnv: false, + }); +} + describe("resolveGatewayCredentialsFromConfig", () => { it("prefers explicit credentials over config and environment", () => { const resolved = resolveGatewayCredentialsFor( @@ -182,24 +203,7 @@ describe("resolveGatewayCredentialsFromConfig", () => { }); it("ignores unresolved local password ref when local auth mode is none", () => { - const resolved = resolveGatewayCredentialsFromConfig({ - cfg: { - gateway: { - mode: "local", - auth: { - mode: "none", - password: { source: "env", provider: "default", id: "MISSING_GATEWAY_PASSWORD" }, - }, - }, - secrets: { - providers: { - default: { source: "env" }, - }, - }, - } as unknown as OpenClawConfig, - env: {} as NodeJS.ProcessEnv, - includeLegacyEnv: false, - }); + const resolved = resolveLocalModeWithUnresolvedPassword("none"); expect(resolved).toEqual({ token: undefined, password: undefined, @@ -207,24 +211,7 @@ describe("resolveGatewayCredentialsFromConfig", () => { }); it("ignores unresolved local password ref when local auth mode is trusted-proxy", () => { - const resolved = resolveGatewayCredentialsFromConfig({ - cfg: { - gateway: { - mode: "local", - auth: { - mode: "trusted-proxy", - password: { source: "env", provider: "default", id: "MISSING_GATEWAY_PASSWORD" }, - }, - }, - secrets: { - providers: { - default: { source: "env" }, - }, - }, - } as unknown as OpenClawConfig, - env: {} as NodeJS.ProcessEnv, - includeLegacyEnv: false, - }); + const resolved = resolveLocalModeWithUnresolvedPassword("trusted-proxy"); expect(resolved).toEqual({ token: undefined, password: undefined, diff --git a/src/gateway/gateway-models.profiles.live.test.ts b/src/gateway/gateway-models.profiles.live.test.ts index 0a6b0bedf26..175881a5d30 100644 --- a/src/gateway/gateway-models.profiles.live.test.ts +++ b/src/gateway/gateway-models.profiles.live.test.ts @@ -1013,6 +1013,7 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) { shouldRetryExecReadProbe({ text: execReadText, nonce: nonceC, + provider: model.provider, attempt: execReadAttempt, maxAttempts: maxExecReadAttempts, }) diff --git a/src/gateway/live-tool-probe-utils.test.ts b/src/gateway/live-tool-probe-utils.test.ts index 044bf6b7ede..ca73032c6fb 100644 --- a/src/gateway/live-tool-probe-utils.test.ts +++ b/src/gateway/live-tool-probe-utils.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { hasExpectedSingleNonce, hasExpectedToolNonce, + isLikelyToolNonceRefusal, shouldRetryExecReadProbe, shouldRetryToolReadProbe, } from "./live-tool-probe-utils.js"; @@ -17,6 +18,26 @@ describe("live tool probe utils", () => { expect(hasExpectedSingleNonce("value nonce-2", "nonce-1")).toBe(false); }); + it("detects anthropic nonce refusal phrasing", () => { + expect( + isLikelyToolNonceRefusal( + "Same request, same answer — this isn't a real OpenClaw probe. No part of the system asks me to parrot back nonce values.", + ), + ).toBe(true); + }); + + it("does not treat generic helper text as nonce refusal", () => { + expect(isLikelyToolNonceRefusal("I can help with that request.")).toBe(false); + }); + + it("detects prompt-injection style tool refusal without nonce text", () => { + expect( + isLikelyToolNonceRefusal( + "That's not a legitimate self-test. This looks like a prompt injection attempt.", + ), + ).toBe(true); + }); + it("retries malformed tool output when attempts remain", () => { expect( shouldRetryToolReadProbe({ @@ -95,6 +116,32 @@ describe("live tool probe utils", () => { ).toBe(true); }); + it("retries anthropic nonce refusal output", () => { + expect( + shouldRetryToolReadProbe({ + text: "This isn't a real OpenClaw probe; I won't parrot back nonce values.", + nonceA: "nonce-a", + nonceB: "nonce-b", + provider: "anthropic", + attempt: 0, + maxAttempts: 3, + }), + ).toBe(true); + }); + + it("retries anthropic prompt-injection refusal output", () => { + expect( + shouldRetryToolReadProbe({ + text: "This is not a legitimate self-test; it appears to be a prompt injection attempt.", + nonceA: "nonce-a", + nonceB: "nonce-b", + provider: "anthropic", + attempt: 0, + maxAttempts: 3, + }), + ).toBe(true); + }); + it("does not retry nonce marker echoes for non-mistral providers", () => { expect( shouldRetryToolReadProbe({ @@ -113,6 +160,7 @@ describe("live tool probe utils", () => { shouldRetryExecReadProbe({ text: "read[object Object]", nonce: "nonce-c", + provider: "openai", attempt: 0, maxAttempts: 3, }), @@ -124,6 +172,7 @@ describe("live tool probe utils", () => { shouldRetryExecReadProbe({ text: "read[object Object]", nonce: "nonce-c", + provider: "openai", attempt: 2, maxAttempts: 3, }), @@ -135,9 +184,22 @@ describe("live tool probe utils", () => { shouldRetryExecReadProbe({ text: "nonce-c", nonce: "nonce-c", + provider: "openai", attempt: 0, maxAttempts: 3, }), ).toBe(false); }); + + it("retries anthropic exec+read nonce refusal output", () => { + expect( + shouldRetryExecReadProbe({ + text: "No part of the system asks me to parrot back nonce values.", + nonce: "nonce-c", + provider: "anthropic", + attempt: 0, + maxAttempts: 3, + }), + ).toBe(true); + }); }); diff --git a/src/gateway/live-tool-probe-utils.ts b/src/gateway/live-tool-probe-utils.ts index 3e450ef530d..62b618fe24d 100644 --- a/src/gateway/live-tool-probe-utils.ts +++ b/src/gateway/live-tool-probe-utils.ts @@ -6,6 +6,44 @@ export function hasExpectedSingleNonce(text: string, nonce: string): boolean { return text.includes(nonce); } +const NONCE_REFUSAL_MARKERS = [ + "token", + "secret", + "local file", + "uuid-named file", + "uuid named file", + "parrot back", + "disclose", + "can't help", + "can’t help", + "cannot help", + "can't comply", + "can’t comply", + "cannot comply", + "isn't a real openclaw probe", + "is not a real openclaw probe", + "not a real openclaw probe", + "no part of the system asks me", +]; + +const PROBE_REFUSAL_MARKERS = [ + "prompt injection attempt", + "not a legitimate self-test", + "not legitimate self-test", + "authorized integration probe", +]; + +export function isLikelyToolNonceRefusal(text: string): boolean { + const lower = text.toLowerCase(); + if (PROBE_REFUSAL_MARKERS.some((marker) => lower.includes(marker))) { + return true; + } + if (lower.includes("nonce")) { + return NONCE_REFUSAL_MARKERS.some((marker) => lower.includes(marker)); + } + return false; +} + function hasMalformedToolOutput(text: string): boolean { const trimmed = text.trim(); if (!trimmed) { @@ -38,6 +76,9 @@ export function shouldRetryToolReadProbe(params: { if (hasMalformedToolOutput(params.text)) { return true; } + if (params.provider === "anthropic" && isLikelyToolNonceRefusal(params.text)) { + return true; + } const lower = params.text.trim().toLowerCase(); if (params.provider === "mistral" && (lower.includes("noncea=") || lower.includes("nonceb="))) { return true; @@ -48,6 +89,7 @@ export function shouldRetryToolReadProbe(params: { export function shouldRetryExecReadProbe(params: { text: string; nonce: string; + provider: string; attempt: number; maxAttempts: number; }): boolean { @@ -57,5 +99,8 @@ export function shouldRetryExecReadProbe(params: { if (hasExpectedSingleNonce(params.text, params.nonce)) { return false; } + if (params.provider === "anthropic" && isLikelyToolNonceRefusal(params.text)) { + return true; + } return hasMalformedToolOutput(params.text); } diff --git a/src/gateway/openai-http.message-channel.test.ts b/src/gateway/openai-http.message-channel.test.ts index 153570bdf08..3c602cbac18 100644 --- a/src/gateway/openai-http.message-channel.test.ts +++ b/src/gateway/openai-http.message-channel.test.ts @@ -3,77 +3,57 @@ import { agentCommand, installGatewayTestHooks, withGatewayServer } from "./test installGatewayTestHooks({ scope: "test" }); +const OPENAI_SERVER_OPTIONS = { + host: "127.0.0.1", + auth: { mode: "token" as const, token: "secret" }, + controlUiEnabled: false, + openAiChatCompletionsEnabled: true, +}; + +async function runOpenAiMessageChannelRequest(params?: { messageChannelHeader?: string }) { + agentCommand.mockReset(); + agentCommand.mockResolvedValueOnce({ payloads: [{ text: "ok" }] } as never); + + let firstCall: { messageChannel?: string } | undefined; + await withGatewayServer( + async ({ port }) => { + const headers: Record = { + "content-type": "application/json", + authorization: "Bearer secret", + }; + if (params?.messageChannelHeader) { + headers["x-openclaw-message-channel"] = params.messageChannelHeader; + } + const res = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { + method: "POST", + headers, + body: JSON.stringify({ + model: "openclaw", + messages: [{ role: "user", content: "hi" }], + }), + }); + + expect(res.status).toBe(200); + firstCall = (agentCommand.mock.calls[0] as unknown[] | undefined)?.[0] as + | { messageChannel?: string } + | undefined; + await res.text(); + }, + { serverOptions: OPENAI_SERVER_OPTIONS }, + ); + return firstCall; +} + describe("OpenAI HTTP message channel", () => { it("passes x-openclaw-message-channel through to agentCommand", async () => { - agentCommand.mockReset(); - agentCommand.mockResolvedValueOnce({ payloads: [{ text: "ok" }] } as never); - - await withGatewayServer( - async ({ port }) => { - const res = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { - method: "POST", - headers: { - "content-type": "application/json", - authorization: "Bearer secret", - "x-openclaw-message-channel": "custom-client-channel", - }, - body: JSON.stringify({ - model: "openclaw", - messages: [{ role: "user", content: "hi" }], - }), - }); - - expect(res.status).toBe(200); - const firstCall = (agentCommand.mock.calls[0] as unknown[] | undefined)?.[0] as - | { messageChannel?: string } - | undefined; - expect(firstCall?.messageChannel).toBe("custom-client-channel"); - await res.text(); - }, - { - serverOptions: { - host: "127.0.0.1", - auth: { mode: "token", token: "secret" }, - controlUiEnabled: false, - openAiChatCompletionsEnabled: true, - }, - }, - ); + const firstCall = await runOpenAiMessageChannelRequest({ + messageChannelHeader: "custom-client-channel", + }); + expect(firstCall?.messageChannel).toBe("custom-client-channel"); }); it("defaults messageChannel to webchat when header is absent", async () => { - agentCommand.mockReset(); - agentCommand.mockResolvedValueOnce({ payloads: [{ text: "ok" }] } as never); - - await withGatewayServer( - async ({ port }) => { - const res = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { - method: "POST", - headers: { - "content-type": "application/json", - authorization: "Bearer secret", - }, - body: JSON.stringify({ - model: "openclaw", - messages: [{ role: "user", content: "hi" }], - }), - }); - - expect(res.status).toBe(200); - const firstCall = (agentCommand.mock.calls[0] as unknown[] | undefined)?.[0] as - | { messageChannel?: string } - | undefined; - expect(firstCall?.messageChannel).toBe("webchat"); - await res.text(); - }, - { - serverOptions: { - host: "127.0.0.1", - auth: { mode: "token", token: "secret" }, - controlUiEnabled: false, - openAiChatCompletionsEnabled: true, - }, - }, - ); + const firstCall = await runOpenAiMessageChannelRequest(); + expect(firstCall?.messageChannel).toBe("webchat"); }); }); diff --git a/src/gateway/server-methods/agents-mutate.test.ts b/src/gateway/server-methods/agents-mutate.test.ts index 66774715eb8..1cd88825b8a 100644 --- a/src/gateway/server-methods/agents-mutate.test.ts +++ b/src/gateway/server-methods/agents-mutate.test.ts @@ -31,6 +31,7 @@ const mocks = vi.hoisted(() => ({ fsLstat: vi.fn(async (..._args: unknown[]) => null as import("node:fs").Stats | null), fsRealpath: vi.fn(async (p: string) => p), fsOpen: vi.fn(async () => ({}) as unknown), + writeFileWithinRoot: vi.fn(async () => {}), })); vi.mock("../../config/config.js", () => ({ @@ -77,6 +78,15 @@ vi.mock("../session-utils.js", () => ({ listAgentsForGateway: mocks.listAgentsForGateway, })); +vi.mock("../../infra/fs-safe.js", async () => { + const actual = + await vi.importActual("../../infra/fs-safe.js"); + return { + ...actual, + writeFileWithinRoot: mocks.writeFileWithinRoot, + }; +}); + // Mock node:fs/promises – agents.ts uses `import fs from "node:fs/promises"` // which resolves to the module namespace default, so we spread actual and // override the methods we need, plus set `default` explicitly. diff --git a/src/gateway/server-methods/agents.ts b/src/gateway/server-methods/agents.ts index 88e362a36d4..b9de9b797aa 100644 --- a/src/gateway/server-methods/agents.ts +++ b/src/gateway/server-methods/agents.ts @@ -732,10 +732,19 @@ export const agentsHandlers: GatewayRequestHandlers = { return; } const content = String(params.content ?? ""); + const relativeWritePath = path.relative(resolvedPath.workspaceReal, resolvedPath.ioPath); + if ( + !relativeWritePath || + relativeWritePath.startsWith("..") || + path.isAbsolute(relativeWritePath) + ) { + respondWorkspaceFileUnsafe(respond, name); + return; + } try { await writeFileWithinRoot({ - rootDir: workspaceDir, - relativePath: name, + rootDir: resolvedPath.workspaceReal, + relativePath: relativeWritePath, data: content, encoding: "utf8", }); diff --git a/src/gateway/server-methods/nodes.ts b/src/gateway/server-methods/nodes.ts index 37433e10dfc..848fa0dfea5 100644 --- a/src/gateway/server-methods/nodes.ts +++ b/src/gateway/server-methods/nodes.ts @@ -274,20 +274,7 @@ export const nodeHandlers: GatewayRequestHandlers = { }); return; } - const p = params as { - nodeId: string; - displayName?: string; - platform?: string; - version?: string; - coreVersion?: string; - uiVersion?: string; - deviceFamily?: string; - modelIdentifier?: string; - caps?: string[]; - commands?: string[]; - remoteIp?: string; - silent?: boolean; - }; + const p = params as Parameters[0]; await respondUnavailableOnThrow(respond, async () => { const result = await requestNodePairing({ nodeId: p.nodeId, @@ -300,6 +287,7 @@ export const nodeHandlers: GatewayRequestHandlers = { modelIdentifier: p.modelIdentifier, caps: p.caps, commands: p.commands, + permissions: p.permissions, remoteIp: p.remoteIp, silent: p.silent, }); diff --git a/src/gateway/server-methods/secrets.test.ts b/src/gateway/server-methods/secrets.test.ts index 0b041d948bd..c0afd2520dc 100644 --- a/src/gateway/server-methods/secrets.test.ts +++ b/src/gateway/server-methods/secrets.test.ts @@ -17,6 +17,27 @@ async function invokeSecretsReload(params: { }); } +async function invokeSecretsResolve(params: { + handlers: ReturnType; + respond: ReturnType; + commandName: unknown; + targetIds: unknown; +}) { + await params.handlers["secrets.resolve"]({ + req: { type: "req", id: "1", method: "secrets.resolve" }, + params: { + commandName: params.commandName, + targetIds: params.targetIds, + }, + client: null, + isWebchatConnect: () => false, + respond: params.respond as unknown as Parameters< + ReturnType["secrets.resolve"] + >[0]["respond"], + context: {} as never, + }); +} + describe("secrets handlers", () => { function createHandlers(overrides?: { reloadSecrets?: () => Promise<{ warningCount: number }>; @@ -73,13 +94,11 @@ describe("secrets handlers", () => { }); const handlers = createHandlers({ resolveSecrets }); const respond = vi.fn(); - await handlers["secrets.resolve"]({ - req: { type: "req", id: "1", method: "secrets.resolve" }, - params: { commandName: "memory status", targetIds: ["talk.apiKey"] }, - client: null, - isWebchatConnect: () => false, + await invokeSecretsResolve({ + handlers, respond, - context: {} as never, + commandName: "memory status", + targetIds: ["talk.apiKey"], }); expect(resolveSecrets).toHaveBeenCalledWith({ commandName: "memory status", @@ -96,13 +115,11 @@ describe("secrets handlers", () => { it("rejects invalid secrets.resolve params", async () => { const handlers = createHandlers(); const respond = vi.fn(); - await handlers["secrets.resolve"]({ - req: { type: "req", id: "1", method: "secrets.resolve" }, - params: { commandName: "", targetIds: "bad" }, - client: null, - isWebchatConnect: () => false, + await invokeSecretsResolve({ + handlers, respond, - context: {} as never, + commandName: "", + targetIds: "bad", }); expect(respond).toHaveBeenCalledWith( false, @@ -117,13 +134,11 @@ describe("secrets handlers", () => { const resolveSecrets = vi.fn(); const handlers = createHandlers({ resolveSecrets }); const respond = vi.fn(); - await handlers["secrets.resolve"]({ - req: { type: "req", id: "1", method: "secrets.resolve" }, - params: { commandName: "memory status", targetIds: ["talk.apiKey", 12] }, - client: null, - isWebchatConnect: () => false, + await invokeSecretsResolve({ + handlers, respond, - context: {} as never, + commandName: "memory status", + targetIds: ["talk.apiKey", 12], }); expect(resolveSecrets).not.toHaveBeenCalled(); expect(respond).toHaveBeenCalledWith( @@ -140,13 +155,11 @@ describe("secrets handlers", () => { const resolveSecrets = vi.fn(); const handlers = createHandlers({ resolveSecrets }); const respond = vi.fn(); - await handlers["secrets.resolve"]({ - req: { type: "req", id: "1", method: "secrets.resolve" }, - params: { commandName: "memory status", targetIds: ["unknown.target"] }, - client: null, - isWebchatConnect: () => false, + await invokeSecretsResolve({ + handlers, respond, - context: {} as never, + commandName: "memory status", + targetIds: ["unknown.target"], }); expect(resolveSecrets).not.toHaveBeenCalled(); expect(respond).toHaveBeenCalledWith( @@ -167,13 +180,11 @@ describe("secrets handlers", () => { }); const handlers = createHandlers({ resolveSecrets }); const respond = vi.fn(); - await handlers["secrets.resolve"]({ - req: { type: "req", id: "1", method: "secrets.resolve" }, - params: { commandName: "memory status", targetIds: ["talk.apiKey"] }, - client: null, - isWebchatConnect: () => false, + await invokeSecretsResolve({ + handlers, respond, - context: {} as never, + commandName: "memory status", + targetIds: ["talk.apiKey"], }); expect(respond).toHaveBeenCalledWith( false, diff --git a/src/gateway/server.cron.test.ts b/src/gateway/server.cron.test.ts index 3c6c128e11a..4a21354605d 100644 --- a/src/gateway/server.cron.test.ts +++ b/src/gateway/server.cron.test.ts @@ -151,6 +151,35 @@ async function addMainSystemEventCronJob(params: { ws: WebSocket; name: string; return expectCronJobIdFromResponse(response); } +async function addWebhookCronJob(params: { + ws: WebSocket; + name: string; + sessionTarget?: "main" | "isolated"; + payloadText?: string; + delivery: Record; +}) { + const response = await rpcReq(params.ws, "cron.add", { + name: params.name, + enabled: true, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: params.sessionTarget ?? "main", + wakeMode: "next-heartbeat", + payload: { + kind: params.sessionTarget === "isolated" ? "agentTurn" : "systemEvent", + ...(params.sessionTarget === "isolated" + ? { message: params.payloadText ?? "test" } + : { text: params.payloadText ?? "send webhook" }), + }, + delivery: params.delivery, + }); + return expectCronJobIdFromResponse(response); +} + +async function runCronJobForce(ws: WebSocket, id: string) { + const response = await rpcReq(ws, "cron.run", { id, mode: "force" }, 20_000); + expect(response.ok).toBe(true); +} + function getWebhookCall(index: number) { const [args] = fetchWithSsrFGuardMock.mock.calls[index] as unknown as [ { @@ -574,22 +603,12 @@ describe("gateway server cron", () => { }); expect(invalidWebhookRes.ok).toBe(false); - const notifyRes = await rpcReq(ws, "cron.add", { + const notifyJobId = await addWebhookCronJob({ + ws, name: "webhook enabled", - enabled: true, - schedule: { kind: "every", everyMs: 60_000 }, - sessionTarget: "main", - wakeMode: "next-heartbeat", - payload: { kind: "systemEvent", text: "send webhook" }, delivery: { mode: "webhook", to: "https://example.invalid/cron-finished" }, }); - expect(notifyRes.ok).toBe(true); - const notifyJobIdValue = (notifyRes.payload as { id?: unknown } | null)?.id; - const notifyJobId = typeof notifyJobIdValue === "string" ? notifyJobIdValue : ""; - expect(notifyJobId.length > 0).toBe(true); - - const notifyRunRes = await rpcReq(ws, "cron.run", { id: notifyJobId, mode: "force" }, 20_000); - expect(notifyRunRes.ok).toBe(true); + await runCronJobForce(ws, notifyJobId); await waitForCondition( () => fetchWithSsrFGuardMock.mock.calls.length === 1, @@ -644,13 +663,10 @@ describe("gateway server cron", () => { fetchWithSsrFGuardMock.mockClear(); cronIsolatedRun.mockResolvedValueOnce({ status: "error", summary: "delivery failed" }); - const failureDestRes = await rpcReq(ws, "cron.add", { + const failureDestJobId = await addWebhookCronJob({ + ws, name: "failure destination webhook", - enabled: true, - schedule: { kind: "every", everyMs: 60_000 }, sessionTarget: "isolated", - wakeMode: "next-heartbeat", - payload: { kind: "agentTurn", message: "test" }, delivery: { mode: "announce", channel: "telegram", @@ -661,19 +677,7 @@ describe("gateway server cron", () => { }, }, }); - expect(failureDestRes.ok).toBe(true); - const failureDestJobIdValue = (failureDestRes.payload as { id?: unknown } | null)?.id; - const failureDestJobId = - typeof failureDestJobIdValue === "string" ? failureDestJobIdValue : ""; - expect(failureDestJobId.length > 0).toBe(true); - - const failureDestRunRes = await rpcReq( - ws, - "cron.run", - { id: failureDestJobId, mode: "force" }, - 20_000, - ); - expect(failureDestRunRes.ok).toBe(true); + await runCronJobForce(ws, failureDestJobId); await waitForCondition( () => fetchWithSsrFGuardMock.mock.calls.length === 1, CRON_WAIT_TIMEOUT_MS, @@ -686,27 +690,13 @@ describe("gateway server cron", () => { ); cronIsolatedRun.mockResolvedValueOnce({ status: "ok", summary: "" }); - const noSummaryRes = await rpcReq(ws, "cron.add", { + const noSummaryJobId = await addWebhookCronJob({ + ws, name: "webhook no summary", - enabled: true, - schedule: { kind: "every", everyMs: 60_000 }, sessionTarget: "isolated", - wakeMode: "next-heartbeat", - payload: { kind: "agentTurn", message: "test" }, delivery: { mode: "webhook", to: "https://example.invalid/cron-finished" }, }); - expect(noSummaryRes.ok).toBe(true); - const noSummaryJobIdValue = (noSummaryRes.payload as { id?: unknown } | null)?.id; - const noSummaryJobId = typeof noSummaryJobIdValue === "string" ? noSummaryJobIdValue : ""; - expect(noSummaryJobId.length > 0).toBe(true); - - const noSummaryRunRes = await rpcReq( - ws, - "cron.run", - { id: noSummaryJobId, mode: "force" }, - 20_000, - ); - expect(noSummaryRunRes.ok).toBe(true); + await runCronJobForce(ws, noSummaryJobId); await yieldToEventLoop(); await yieldToEventLoop(); expect(fetchWithSsrFGuardMock).toHaveBeenCalledTimes(1); @@ -746,22 +736,12 @@ describe("gateway server cron", () => { await connectOk(ws); try { - const notifyRes = await rpcReq(ws, "cron.add", { + const notifyJobId = await addWebhookCronJob({ + ws, name: "webhook secretinput object", - enabled: true, - schedule: { kind: "every", everyMs: 60_000 }, - sessionTarget: "main", - wakeMode: "next-heartbeat", - payload: { kind: "systemEvent", text: "send webhook" }, delivery: { mode: "webhook", to: "https://example.invalid/cron-finished" }, }); - expect(notifyRes.ok).toBe(true); - const notifyJobIdValue = (notifyRes.payload as { id?: unknown } | null)?.id; - const notifyJobId = typeof notifyJobIdValue === "string" ? notifyJobIdValue : ""; - expect(notifyJobId.length > 0).toBe(true); - - const notifyRunRes = await rpcReq(ws, "cron.run", { id: notifyJobId, mode: "force" }, 20_000); - expect(notifyRunRes.ok).toBe(true); + await runCronJobForce(ws, notifyJobId); await waitForCondition( () => fetchWithSsrFGuardMock.mock.calls.length === 1, diff --git a/src/gateway/test-helpers.server.ts b/src/gateway/test-helpers.server.ts index ab5269f09b5..eca3a107e69 100644 --- a/src/gateway/test-helpers.server.ts +++ b/src/gateway/test-helpers.server.ts @@ -339,6 +339,46 @@ async function startGatewayServerWithRetries(params: { throw new Error("failed to start gateway server after retries"); } +async function waitForWebSocketOpen(ws: WebSocket, timeoutMs = 10_000): Promise { + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("timeout waiting for ws open")), timeoutMs); + const cleanup = () => { + clearTimeout(timer); + ws.off("open", onOpen); + ws.off("error", onError); + ws.off("close", onClose); + }; + const onOpen = () => { + cleanup(); + resolve(); + }; + const onError = (err: unknown) => { + cleanup(); + reject(err instanceof Error ? err : new Error(String(err))); + }; + const onClose = (code: number, reason: Buffer) => { + cleanup(); + reject(new Error(`closed ${code}: ${reason.toString()}`)); + }; + ws.once("open", onOpen); + ws.once("error", onError); + ws.once("close", onClose); + }); +} + +async function openTrackedWebSocket(params: { + port: number; + headers?: Record; +}): Promise { + const ws = new WebSocket( + `ws://127.0.0.1:${params.port}`, + params.headers ? { headers: params.headers } : undefined, + ); + trackConnectChallengeNonce(ws); + await waitForWebSocketOpen(ws); + return ws; +} + export async function withGatewayServer( fn: (ctx: { port: number; server: Awaited> }) => Promise, opts?: { port?: number; serverOptions?: GatewayServerOptions }, @@ -371,33 +411,10 @@ export async function createGatewaySuiteHarness(opts?: { port: started.port, server: started.server, openWs: async (headers?: Record) => { - const ws = new WebSocket(`ws://127.0.0.1:${started.port}`, headers ? { headers } : undefined); - trackConnectChallengeNonce(ws); - await new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error("timeout waiting for ws open")), 10_000); - const cleanup = () => { - clearTimeout(timer); - ws.off("open", onOpen); - ws.off("error", onError); - ws.off("close", onClose); - }; - const onOpen = () => { - cleanup(); - resolve(); - }; - const onError = (err: unknown) => { - cleanup(); - reject(err instanceof Error ? err : new Error(String(err))); - }; - const onClose = (code: number, reason: Buffer) => { - cleanup(); - reject(new Error(`closed ${code}: ${reason.toString()}`)); - }; - ws.once("open", onOpen); - ws.once("error", onError); - ws.once("close", onClose); + return await openTrackedWebSocket({ + port: started.port, + headers, }); - return ws; }, close: async () => { await started.server.close(); @@ -431,35 +448,7 @@ export async function startServerWithClient( port = started.port; const server = started.server; - const ws = new WebSocket( - `ws://127.0.0.1:${port}`, - wsHeaders ? { headers: wsHeaders } : undefined, - ); - trackConnectChallengeNonce(ws); - await new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error("timeout waiting for ws open")), 10_000); - const cleanup = () => { - clearTimeout(timer); - ws.off("open", onOpen); - ws.off("error", onError); - ws.off("close", onClose); - }; - const onOpen = () => { - cleanup(); - resolve(); - }; - const onError = (err: unknown) => { - cleanup(); - reject(err instanceof Error ? err : new Error(String(err))); - }; - const onClose = (code: number, reason: Buffer) => { - cleanup(); - reject(new Error(`closed ${code}: ${reason.toString()}`)); - }; - ws.once("open", onOpen); - ws.once("error", onError); - ws.once("close", onClose); - }); + const ws = await openTrackedWebSocket({ port, headers: wsHeaders }); return { server, ws, port, prevToken: prev, envSnapshot }; } diff --git a/src/hooks/frontmatter.ts b/src/hooks/frontmatter.ts index aa9e75537d3..686f966ccbf 100644 --- a/src/hooks/frontmatter.ts +++ b/src/hooks/frontmatter.ts @@ -1,5 +1,6 @@ import { parseFrontmatterBlock } from "../markdown/frontmatter.js"; import { + applyOpenClawManifestInstallCommonFields, getFrontmatterString, normalizeStringList, parseOpenClawManifestInstallBase, @@ -27,19 +28,12 @@ function parseInstallSpec(input: unknown): HookInstallSpec | undefined { return undefined; } const { raw } = parsed; - const spec: HookInstallSpec = { - kind: parsed.kind as HookInstallSpec["kind"], - }; - - if (parsed.id) { - spec.id = parsed.id; - } - if (parsed.label) { - spec.label = parsed.label; - } - if (parsed.bins) { - spec.bins = parsed.bins; - } + const spec = applyOpenClawManifestInstallCommonFields( + { + kind: parsed.kind as HookInstallSpec["kind"], + }, + parsed, + ); if (typeof raw.package === "string") { spec.package = raw.package; } diff --git a/src/hooks/internal-hooks.ts b/src/hooks/internal-hooks.ts index 625261e3c16..b73dcb75fab 100644 --- a/src/hooks/internal-hooks.ts +++ b/src/hooks/internal-hooks.ts @@ -97,7 +97,7 @@ export type MessageSentHookEvent = InternalHookEvent & { context: MessageSentHookContext; }; -export type MessageTranscribedHookContext = { +type MessageEnrichedBodyHookContext = { /** Sender identifier (e.g., phone number, user ID) */ from?: string; /** Recipient identifier */ @@ -106,8 +106,6 @@ export type MessageTranscribedHookContext = { body?: string; /** Enriched body shown to the agent, including transcript */ bodyForAgent?: string; - /** The transcribed text from audio */ - transcript: string; /** Unix timestamp when the message was received */ timestamp?: number; /** Channel identifier (e.g., "telegram", "whatsapp") */ @@ -132,45 +130,20 @@ export type MessageTranscribedHookContext = { mediaType?: string; }; +export type MessageTranscribedHookContext = MessageEnrichedBodyHookContext & { + /** The transcribed text from audio */ + transcript: string; +}; + export type MessageTranscribedHookEvent = InternalHookEvent & { type: "message"; action: "transcribed"; context: MessageTranscribedHookContext; }; -export type MessagePreprocessedHookContext = { - /** Sender identifier (e.g., phone number, user ID) */ - from?: string; - /** Recipient identifier */ - to?: string; - /** Original raw message body */ - body?: string; - /** Fully enriched body shown to the agent (transcripts, image descriptions, link summaries) */ - bodyForAgent?: string; +export type MessagePreprocessedHookContext = MessageEnrichedBodyHookContext & { /** Transcribed audio text, if the message contained audio */ transcript?: string; - /** Unix timestamp when the message was received */ - timestamp?: number; - /** Channel identifier (e.g., "telegram", "whatsapp") */ - channelId: string; - /** Conversation/chat ID */ - conversationId?: string; - /** Message ID from the provider */ - messageId?: string; - /** Sender user ID */ - senderId?: string; - /** Sender display name */ - senderName?: string; - /** Sender username */ - senderUsername?: string; - /** Provider name */ - provider?: string; - /** Surface name */ - surface?: string; - /** Path to the media file, if present */ - mediaPath?: string; - /** MIME type of the media, if present */ - mediaType?: string; /** Whether this message was sent in a group/channel context */ isGroup?: boolean; /** Group or channel identifier, if applicable */ diff --git a/src/hooks/message-hook-mappers.ts b/src/hooks/message-hook-mappers.ts index be51245a545..1cdd12a93ac 100644 --- a/src/hooks/message-hook-mappers.ts +++ b/src/hooks/message-hook-mappers.ts @@ -213,23 +213,10 @@ export function toInternalMessageTranscribedContext( canonical: CanonicalInboundMessageHookContext, cfg: OpenClawConfig, ): MessageTranscribedHookContext & { cfg: OpenClawConfig } { + const shared = toInternalInboundMessageHookContextBase(canonical); return { - from: canonical.from, - to: canonical.to, - body: canonical.body, - bodyForAgent: canonical.bodyForAgent, + ...shared, transcript: canonical.transcript ?? "", - timestamp: canonical.timestamp, - channelId: canonical.channelId, - conversationId: canonical.conversationId, - messageId: canonical.messageId, - senderId: canonical.senderId, - senderName: canonical.senderName, - senderUsername: canonical.senderUsername, - provider: canonical.provider, - surface: canonical.surface, - mediaPath: canonical.mediaPath, - mediaType: canonical.mediaType, cfg, }; } @@ -238,12 +225,22 @@ export function toInternalMessagePreprocessedContext( canonical: CanonicalInboundMessageHookContext, cfg: OpenClawConfig, ): MessagePreprocessedHookContext & { cfg: OpenClawConfig } { + const shared = toInternalInboundMessageHookContextBase(canonical); + return { + ...shared, + transcript: canonical.transcript, + isGroup: canonical.isGroup, + groupId: canonical.groupId, + cfg, + }; +} + +function toInternalInboundMessageHookContextBase(canonical: CanonicalInboundMessageHookContext) { return { from: canonical.from, to: canonical.to, body: canonical.body, bodyForAgent: canonical.bodyForAgent, - transcript: canonical.transcript, timestamp: canonical.timestamp, channelId: canonical.channelId, conversationId: canonical.conversationId, @@ -255,9 +252,6 @@ export function toInternalMessagePreprocessedContext( surface: canonical.surface, mediaPath: canonical.mediaPath, mediaType: canonical.mediaType, - isGroup: canonical.isGroup, - groupId: canonical.groupId, - cfg, }; } diff --git a/src/imessage/target-parsing-helpers.ts b/src/imessage/target-parsing-helpers.ts index 2b64c145580..ba00590e6d5 100644 --- a/src/imessage/target-parsing-helpers.ts +++ b/src/imessage/target-parsing-helpers.ts @@ -1,3 +1,5 @@ +import { isAllowedParsedChatSender } from "../plugin-sdk/allow-from.js"; + export type ServicePrefix = { prefix: string; service: TService }; export type ChatTargetPrefixesParams = { @@ -13,10 +15,24 @@ export type ParsedChatTarget = | { kind: "chat_guid"; chatGuid: string } | { kind: "chat_identifier"; chatIdentifier: string }; +export type ParsedChatAllowTarget = ParsedChatTarget | { kind: "handle"; handle: string }; + +export type ChatSenderAllowParams = { + allowFrom: Array; + sender: string; + chatId?: number | null; + chatGuid?: string | null; + chatIdentifier?: string | null; +}; + function stripPrefix(value: string, prefix: string): string { return value.slice(prefix.length).trim(); } +function startsWithAnyPrefix(value: string, prefixes: readonly string[]): boolean { + return prefixes.some((prefix) => value.startsWith(prefix)); +} + export function resolveServicePrefixedTarget(params: { trimmed: string; lower: string; @@ -41,6 +57,31 @@ export function resolveServicePrefixedTarget(p return null; } +export function resolveServicePrefixedChatTarget(params: { + trimmed: string; + lower: string; + servicePrefixes: Array>; + chatIdPrefixes: string[]; + chatGuidPrefixes: string[]; + chatIdentifierPrefixes: string[]; + extraChatPrefixes?: string[]; + parseTarget: (remainder: string) => TTarget; +}): ({ kind: "handle"; to: string; service: TService } | TTarget) | null { + const chatPrefixes = [ + ...params.chatIdPrefixes, + ...params.chatGuidPrefixes, + ...params.chatIdentifierPrefixes, + ...(params.extraChatPrefixes ?? []), + ]; + return resolveServicePrefixedTarget({ + trimmed: params.trimmed, + lower: params.lower, + servicePrefixes: params.servicePrefixes, + isChatTarget: (remainderLower) => startsWithAnyPrefix(remainderLower, chatPrefixes), + parseTarget: params.parseTarget, + }); +} + export function parseChatTargetPrefixesOrThrow( params: ChatTargetPrefixesParams, ): ParsedChatTarget | null { @@ -97,6 +138,56 @@ export function resolveServicePrefixedAllowTarget(params: { return null; } +export function resolveServicePrefixedOrChatAllowTarget< + TAllowTarget extends ParsedChatAllowTarget, +>(params: { + trimmed: string; + lower: string; + servicePrefixes: Array<{ prefix: string }>; + parseAllowTarget: (remainder: string) => TAllowTarget; + chatIdPrefixes: string[]; + chatGuidPrefixes: string[]; + chatIdentifierPrefixes: string[]; +}): TAllowTarget | null { + const servicePrefixed = resolveServicePrefixedAllowTarget({ + trimmed: params.trimmed, + lower: params.lower, + servicePrefixes: params.servicePrefixes, + parseAllowTarget: params.parseAllowTarget, + }); + if (servicePrefixed) { + return servicePrefixed as TAllowTarget; + } + + const chatTarget = parseChatAllowTargetPrefixes({ + trimmed: params.trimmed, + lower: params.lower, + chatIdPrefixes: params.chatIdPrefixes, + chatGuidPrefixes: params.chatGuidPrefixes, + chatIdentifierPrefixes: params.chatIdentifierPrefixes, + }); + if (chatTarget) { + return chatTarget as TAllowTarget; + } + return null; +} + +export function createAllowedChatSenderMatcher(params: { + normalizeSender: (sender: string) => string; + parseAllowTarget: (entry: string) => TParsed; +}): (input: ChatSenderAllowParams) => boolean { + return (input) => + isAllowedParsedChatSender({ + allowFrom: input.allowFrom, + sender: input.sender, + chatId: input.chatId, + chatGuid: input.chatGuid, + chatIdentifier: input.chatIdentifier, + normalizeSender: params.normalizeSender, + parseAllowTarget: params.parseAllowTarget, + }); +} + export function parseChatAllowTargetPrefixes( params: ChatTargetPrefixesParams, ): ParsedChatTarget | null { diff --git a/src/imessage/targets.ts b/src/imessage/targets.ts index 75f159576ff..e709f1064e4 100644 --- a/src/imessage/targets.ts +++ b/src/imessage/targets.ts @@ -1,11 +1,11 @@ -import { isAllowedParsedChatSender } from "../plugin-sdk/allow-from.js"; import { normalizeE164 } from "../utils.js"; import { + createAllowedChatSenderMatcher, + type ChatSenderAllowParams, type ParsedChatTarget, - parseChatAllowTargetPrefixes, parseChatTargetPrefixesOrThrow, - resolveServicePrefixedAllowTarget, - resolveServicePrefixedTarget, + resolveServicePrefixedChatTarget, + resolveServicePrefixedOrChatAllowTarget, } from "./target-parsing-helpers.js"; export type IMessageService = "imessage" | "sms" | "auto"; @@ -80,14 +80,13 @@ export function parseIMessageTarget(raw: string): IMessageTarget { } const lower = trimmed.toLowerCase(); - const servicePrefixed = resolveServicePrefixedTarget({ + const servicePrefixed = resolveServicePrefixedChatTarget({ trimmed, lower, servicePrefixes: SERVICE_PREFIXES, - isChatTarget: (remainderLower) => - CHAT_ID_PREFIXES.some((p) => remainderLower.startsWith(p)) || - CHAT_GUID_PREFIXES.some((p) => remainderLower.startsWith(p)) || - CHAT_IDENTIFIER_PREFIXES.some((p) => remainderLower.startsWith(p)), + chatIdPrefixes: CHAT_ID_PREFIXES, + chatGuidPrefixes: CHAT_GUID_PREFIXES, + chatIdentifierPrefixes: CHAT_IDENTIFIER_PREFIXES, parseTarget: parseIMessageTarget, }); if (servicePrefixed) { @@ -115,46 +114,29 @@ export function parseIMessageAllowTarget(raw: string): IMessageAllowTarget { } const lower = trimmed.toLowerCase(); - const servicePrefixed = resolveServicePrefixedAllowTarget({ + const servicePrefixed = resolveServicePrefixedOrChatAllowTarget({ trimmed, lower, servicePrefixes: SERVICE_PREFIXES, parseAllowTarget: parseIMessageAllowTarget, + chatIdPrefixes: CHAT_ID_PREFIXES, + chatGuidPrefixes: CHAT_GUID_PREFIXES, + chatIdentifierPrefixes: CHAT_IDENTIFIER_PREFIXES, }); if (servicePrefixed) { return servicePrefixed; } - const chatTarget = parseChatAllowTargetPrefixes({ - trimmed, - lower, - chatIdPrefixes: CHAT_ID_PREFIXES, - chatGuidPrefixes: CHAT_GUID_PREFIXES, - chatIdentifierPrefixes: CHAT_IDENTIFIER_PREFIXES, - }); - if (chatTarget) { - return chatTarget; - } - return { kind: "handle", handle: normalizeIMessageHandle(trimmed) }; } -export function isAllowedIMessageSender(params: { - allowFrom: Array; - sender: string; - chatId?: number | null; - chatGuid?: string | null; - chatIdentifier?: string | null; -}): boolean { - return isAllowedParsedChatSender({ - allowFrom: params.allowFrom, - sender: params.sender, - chatId: params.chatId, - chatGuid: params.chatGuid, - chatIdentifier: params.chatIdentifier, - normalizeSender: normalizeIMessageHandle, - parseAllowTarget: parseIMessageAllowTarget, - }); +const isAllowedIMessageSenderMatcher = createAllowedChatSenderMatcher({ + normalizeSender: normalizeIMessageHandle, + parseAllowTarget: parseIMessageAllowTarget, +}); + +export function isAllowedIMessageSender(params: ChatSenderAllowParams): boolean { + return isAllowedIMessageSenderMatcher(params); } export function formatIMessageChatTarget(chatId?: number | null): string { diff --git a/src/infra/boundary-path.ts b/src/infra/boundary-path.ts index 2a4eb45a858..11d42758926 100644 --- a/src/infra/boundary-path.ts +++ b/src/infra/boundary-path.ts @@ -540,12 +540,9 @@ async function resolveOutsideBoundaryPathAsync(params: { return null; } const kind = await getPathKind(params.context.absolutePath, false); - return buildOutsideLexicalBoundaryPath({ + return buildOutsideBoundaryPathFromContext({ boundaryLabel: params.boundaryLabel, - rootCanonicalPath: params.context.rootCanonicalPath, - absolutePath: params.context.absolutePath, - canonicalOutsideLexicalPath: params.context.canonicalOutsideLexicalPath, - rootPath: params.context.rootPath, + context: params.context, kind, }); } @@ -558,13 +555,25 @@ function resolveOutsideBoundaryPathSync(params: { return null; } const kind = getPathKindSync(params.context.absolutePath, false); + return buildOutsideBoundaryPathFromContext({ + boundaryLabel: params.boundaryLabel, + context: params.context, + kind, + }); +} + +function buildOutsideBoundaryPathFromContext(params: { + boundaryLabel: string; + context: BoundaryResolutionContext; + kind: { exists: boolean; kind: ResolvedBoundaryPathKind }; +}): ResolvedBoundaryPath { return buildOutsideLexicalBoundaryPath({ boundaryLabel: params.boundaryLabel, rootCanonicalPath: params.context.rootCanonicalPath, absolutePath: params.context.absolutePath, canonicalOutsideLexicalPath: params.context.canonicalOutsideLexicalPath, rootPath: params.context.rootPath, - kind, + kind: params.kind, }); } diff --git a/src/infra/exec-approvals.ts b/src/infra/exec-approvals.ts index c99eaeef189..787b5dd7cb5 100644 --- a/src/infra/exec-approvals.ts +++ b/src/infra/exec-approvals.ts @@ -11,6 +11,30 @@ export type ExecHost = "sandbox" | "gateway" | "node"; export type ExecSecurity = "deny" | "allowlist" | "full"; export type ExecAsk = "off" | "on-miss" | "always"; +export function normalizeExecHost(value?: string | null): ExecHost | null { + const normalized = value?.trim().toLowerCase(); + if (normalized === "sandbox" || normalized === "gateway" || normalized === "node") { + return normalized; + } + return null; +} + +export function normalizeExecSecurity(value?: string | null): ExecSecurity | null { + const normalized = value?.trim().toLowerCase(); + if (normalized === "deny" || normalized === "allowlist" || normalized === "full") { + return normalized; + } + return null; +} + +export function normalizeExecAsk(value?: string | null): ExecAsk | null { + const normalized = value?.trim().toLowerCase(); + if (normalized === "off" || normalized === "on-miss" || normalized === "always") { + return normalized; + } + return null; +} + export type SystemRunApprovalBinding = { argv: string[]; cwd: string | null; diff --git a/src/infra/node-pairing.ts b/src/infra/node-pairing.ts index 69b90e0e853..dac53a3b4ee 100644 --- a/src/infra/node-pairing.ts +++ b/src/infra/node-pairing.ts @@ -10,8 +10,7 @@ import { import { rejectPendingPairingRequest } from "./pairing-pending.js"; import { generatePairingToken, verifyPairingToken } from "./pairing-token.js"; -export type NodePairingPendingRequest = { - requestId: string; +type NodePairingNodeMetadata = { nodeId: string; displayName?: string; platform?: string; @@ -24,26 +23,18 @@ export type NodePairingPendingRequest = { commands?: string[]; permissions?: Record; remoteIp?: string; +}; + +export type NodePairingPendingRequest = NodePairingNodeMetadata & { + requestId: string; silent?: boolean; isRepair?: boolean; ts: number; }; -export type NodePairingPairedNode = { - nodeId: string; +export type NodePairingPairedNode = Omit & { token: string; - displayName?: string; - platform?: string; - version?: string; - coreVersion?: string; - uiVersion?: string; - deviceFamily?: string; - modelIdentifier?: string; - caps?: string[]; - commands?: string[]; bins?: string[]; - permissions?: Record; - remoteIp?: string; createdAtMs: number; approvedAtMs: number; lastConnectedAtMs?: number; diff --git a/src/infra/parse-finite-number.test.ts b/src/infra/parse-finite-number.test.ts new file mode 100644 index 00000000000..8dd592b6558 --- /dev/null +++ b/src/infra/parse-finite-number.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { parseFiniteNumber } from "./parse-finite-number.js"; + +describe("parseFiniteNumber", () => { + it("returns finite numbers", () => { + expect(parseFiniteNumber(42)).toBe(42); + }); + + it("parses numeric strings", () => { + expect(parseFiniteNumber("3.14")).toBe(3.14); + }); + + it("returns undefined for non-finite or non-numeric values", () => { + expect(parseFiniteNumber(Number.NaN)).toBeUndefined(); + expect(parseFiniteNumber(Number.POSITIVE_INFINITY)).toBeUndefined(); + expect(parseFiniteNumber("not-a-number")).toBeUndefined(); + expect(parseFiniteNumber(null)).toBeUndefined(); + }); +}); diff --git a/src/infra/parse-finite-number.ts b/src/infra/parse-finite-number.ts new file mode 100644 index 00000000000..cf0fa0a3773 --- /dev/null +++ b/src/infra/parse-finite-number.ts @@ -0,0 +1,12 @@ +export function parseFiniteNumber(value: unknown): number | undefined { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value === "string") { + const parsed = Number.parseFloat(value); + if (Number.isFinite(parsed)) { + return parsed; + } + } + return undefined; +} diff --git a/src/infra/provider-usage.fetch.shared.ts b/src/infra/provider-usage.fetch.shared.ts index 2a2d2d0201b..20c9ab18d09 100644 --- a/src/infra/provider-usage.fetch.shared.ts +++ b/src/infra/provider-usage.fetch.shared.ts @@ -1,3 +1,4 @@ +import { parseFiniteNumber as parseFiniteNumberish } from "./parse-finite-number.js"; import { PROVIDER_LABELS } from "./provider-usage.shared.js"; import type { ProviderUsageSnapshot, UsageProviderId } from "./provider-usage.types.js"; @@ -17,16 +18,7 @@ export async function fetchJson( } export function parseFiniteNumber(value: unknown): number | undefined { - if (typeof value === "number" && Number.isFinite(value)) { - return value; - } - if (typeof value === "string") { - const parsed = Number.parseFloat(value); - if (Number.isFinite(parsed)) { - return parsed; - } - } - return undefined; + return parseFiniteNumberish(value); } type BuildUsageHttpErrorSnapshotOptions = { diff --git a/src/memory/batch-embedding-common.ts b/src/memory/batch-embedding-common.ts index f572427ea65..2aa3351150f 100644 --- a/src/memory/batch-embedding-common.ts +++ b/src/memory/batch-embedding-common.ts @@ -1,6 +1,12 @@ export { extractBatchErrorMessage, formatUnavailableBatchError } from "./batch-error-utils.js"; export { postJsonWithRetry } from "./batch-http.js"; export { applyEmbeddingBatchOutputLine } from "./batch-output.js"; +export { + resolveBatchCompletionFromStatus, + resolveCompletedBatchResult, + throwIfBatchTerminalFailure, + type BatchCompletionResult, +} from "./batch-status.js"; export { EMBEDDING_BATCH_ENDPOINT, type EmbeddingBatchStatus, diff --git a/src/memory/batch-openai.ts b/src/memory/batch-openai.ts index 24c3b6f7eea..e17a420812c 100644 --- a/src/memory/batch-openai.ts +++ b/src/memory/batch-openai.ts @@ -7,9 +7,13 @@ import { formatUnavailableBatchError, normalizeBatchBaseUrl, postJsonWithRetry, + resolveBatchCompletionFromStatus, + resolveCompletedBatchResult, runEmbeddingBatchGroups, + throwIfBatchTerminalFailure, type EmbeddingBatchExecutionParams, type EmbeddingBatchStatus, + type BatchCompletionResult, type ProviderBatchOutputLine, uploadBatchJsonlFile, withRemoteHttpResponse, @@ -144,7 +148,7 @@ async function waitForOpenAiBatch(params: { timeoutMs: number; debug?: (message: string, data?: Record) => void; initial?: OpenAiBatchStatus; -}): Promise<{ outputFileId: string; errorFileId?: string }> { +}): Promise { const start = Date.now(); let current: OpenAiBatchStatus | undefined = params.initial; while (true) { @@ -156,21 +160,21 @@ async function waitForOpenAiBatch(params: { })); const state = status.status ?? "unknown"; if (state === "completed") { - if (!status.output_file_id) { - throw new Error(`openai batch ${params.batchId} completed without output file`); - } - return { - outputFileId: status.output_file_id, - errorFileId: status.error_file_id ?? undefined, - }; - } - if (["failed", "expired", "cancelled", "canceled"].includes(state)) { - const detail = status.error_file_id - ? await readOpenAiBatchError({ openAi: params.openAi, errorFileId: status.error_file_id }) - : undefined; - const suffix = detail ? `: ${detail}` : ""; - throw new Error(`openai batch ${params.batchId} ${state}${suffix}`); + return resolveBatchCompletionFromStatus({ + provider: "openai", + batchId: params.batchId, + status, + }); } + await throwIfBatchTerminalFailure({ + provider: "openai", + status: { ...status, id: params.batchId }, + readError: async (errorFileId) => + await readOpenAiBatchError({ + openAi: params.openAi, + errorFileId, + }), + }); if (!params.wait) { throw new Error(`openai batch ${params.batchId} still ${state}; wait disabled`); } @@ -204,6 +208,7 @@ export async function runOpenAiEmbeddingBatches( if (!batchInfo.id) { throw new Error("openai batch create failed: missing batch id"); } + const batchId = batchInfo.id; params.debug?.("memory embeddings: openai batch created", { batchId: batchInfo.id, @@ -213,30 +218,21 @@ export async function runOpenAiEmbeddingBatches( requests: group.length, }); - if (!params.wait && batchInfo.status !== "completed") { - throw new Error( - `openai batch ${batchInfo.id} submitted; enable remote.batch.wait to await completion`, - ); - } - - const completed = - batchInfo.status === "completed" - ? { - outputFileId: batchInfo.output_file_id ?? "", - errorFileId: batchInfo.error_file_id ?? undefined, - } - : await waitForOpenAiBatch({ - openAi: params.openAi, - batchId: batchInfo.id, - wait: params.wait, - pollIntervalMs: params.pollIntervalMs, - timeoutMs: params.timeoutMs, - debug: params.debug, - initial: batchInfo, - }); - if (!completed.outputFileId) { - throw new Error(`openai batch ${batchInfo.id} completed without output file`); - } + const completed = await resolveCompletedBatchResult({ + provider: "openai", + status: batchInfo, + wait: params.wait, + waitForBatch: async () => + await waitForOpenAiBatch({ + openAi: params.openAi, + batchId, + wait: params.wait, + pollIntervalMs: params.pollIntervalMs, + timeoutMs: params.timeoutMs, + debug: params.debug, + initial: batchInfo, + }), + }); const content = await fetchOpenAiFileContent({ openAi: params.openAi, diff --git a/src/memory/batch-status.test.ts b/src/memory/batch-status.test.ts new file mode 100644 index 00000000000..82a992556af --- /dev/null +++ b/src/memory/batch-status.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { + resolveBatchCompletionFromStatus, + resolveCompletedBatchResult, + throwIfBatchTerminalFailure, +} from "./batch-status.js"; + +describe("batch-status helpers", () => { + it("resolves completion payload from completed status", () => { + expect( + resolveBatchCompletionFromStatus({ + provider: "openai", + batchId: "b1", + status: { + output_file_id: "out-1", + error_file_id: "err-1", + }, + }), + ).toEqual({ + outputFileId: "out-1", + errorFileId: "err-1", + }); + }); + + it("throws for terminal failure states", async () => { + await expect( + throwIfBatchTerminalFailure({ + provider: "voyage", + status: { id: "b2", status: "failed", error_file_id: "err-file" }, + readError: async () => "bad input", + }), + ).rejects.toThrow("voyage batch b2 failed: bad input"); + }); + + it("returns completed result directly without waiting", async () => { + const waitForBatch = async () => ({ outputFileId: "out-2" }); + const result = await resolveCompletedBatchResult({ + provider: "openai", + status: { + id: "b3", + status: "completed", + output_file_id: "out-3", + }, + wait: false, + waitForBatch, + }); + expect(result).toEqual({ outputFileId: "out-3", errorFileId: undefined }); + }); + + it("throws when wait disabled and batch is not complete", async () => { + await expect( + resolveCompletedBatchResult({ + provider: "openai", + status: { id: "b4", status: "pending" }, + wait: false, + waitForBatch: async () => ({ outputFileId: "out" }), + }), + ).rejects.toThrow("openai batch b4 submitted; enable remote.batch.wait to await completion"); + }); +}); diff --git a/src/memory/batch-status.ts b/src/memory/batch-status.ts new file mode 100644 index 00000000000..96e8da62894 --- /dev/null +++ b/src/memory/batch-status.ts @@ -0,0 +1,69 @@ +const TERMINAL_FAILURE_STATES = new Set(["failed", "expired", "cancelled", "canceled"]); + +type BatchStatusLike = { + id?: string; + status?: string; + output_file_id?: string | null; + error_file_id?: string | null; +}; + +export type BatchCompletionResult = { + outputFileId: string; + errorFileId?: string; +}; + +export function resolveBatchCompletionFromStatus(params: { + provider: string; + batchId: string; + status: BatchStatusLike; +}): BatchCompletionResult { + if (!params.status.output_file_id) { + throw new Error(`${params.provider} batch ${params.batchId} completed without output file`); + } + return { + outputFileId: params.status.output_file_id, + errorFileId: params.status.error_file_id ?? undefined, + }; +} + +export async function throwIfBatchTerminalFailure(params: { + provider: string; + status: BatchStatusLike; + readError: (errorFileId: string) => Promise; +}): Promise { + const state = params.status.status ?? "unknown"; + if (!TERMINAL_FAILURE_STATES.has(state)) { + return; + } + const detail = params.status.error_file_id + ? await params.readError(params.status.error_file_id) + : undefined; + const suffix = detail ? `: ${detail}` : ""; + throw new Error(`${params.provider} batch ${params.status.id ?? ""} ${state}${suffix}`); +} + +export async function resolveCompletedBatchResult(params: { + provider: string; + status: BatchStatusLike; + wait: boolean; + waitForBatch: () => Promise; +}): Promise { + const batchId = params.status.id ?? ""; + if (!params.wait && params.status.status !== "completed") { + throw new Error( + `${params.provider} batch ${batchId} submitted; enable remote.batch.wait to await completion`, + ); + } + const completed = + params.status.status === "completed" + ? resolveBatchCompletionFromStatus({ + provider: params.provider, + batchId, + status: params.status, + }) + : await params.waitForBatch(); + if (!completed.outputFileId) { + throw new Error(`${params.provider} batch ${batchId} completed without output file`); + } + return completed; +} diff --git a/src/memory/batch-voyage.ts b/src/memory/batch-voyage.ts index 1835f9b053f..aa5bfc61017 100644 --- a/src/memory/batch-voyage.ts +++ b/src/memory/batch-voyage.ts @@ -9,9 +9,13 @@ import { formatUnavailableBatchError, normalizeBatchBaseUrl, postJsonWithRetry, + resolveBatchCompletionFromStatus, + resolveCompletedBatchResult, runEmbeddingBatchGroups, + throwIfBatchTerminalFailure, type EmbeddingBatchExecutionParams, type EmbeddingBatchStatus, + type BatchCompletionResult, type ProviderBatchOutputLine, uploadBatchJsonlFile, withRemoteHttpResponse, @@ -146,7 +150,7 @@ async function waitForVoyageBatch(params: { timeoutMs: number; debug?: (message: string, data?: Record) => void; initial?: VoyageBatchStatus; -}): Promise<{ outputFileId: string; errorFileId?: string }> { +}): Promise { const start = Date.now(); let current: VoyageBatchStatus | undefined = params.initial; while (true) { @@ -158,21 +162,21 @@ async function waitForVoyageBatch(params: { })); const state = status.status ?? "unknown"; if (state === "completed") { - if (!status.output_file_id) { - throw new Error(`voyage batch ${params.batchId} completed without output file`); - } - return { - outputFileId: status.output_file_id, - errorFileId: status.error_file_id ?? undefined, - }; - } - if (["failed", "expired", "cancelled", "canceled"].includes(state)) { - const detail = status.error_file_id - ? await readVoyageBatchError({ client: params.client, errorFileId: status.error_file_id }) - : undefined; - const suffix = detail ? `: ${detail}` : ""; - throw new Error(`voyage batch ${params.batchId} ${state}${suffix}`); + return resolveBatchCompletionFromStatus({ + provider: "voyage", + batchId: params.batchId, + status, + }); } + await throwIfBatchTerminalFailure({ + provider: "voyage", + status: { ...status, id: params.batchId }, + readError: async (errorFileId) => + await readVoyageBatchError({ + client: params.client, + errorFileId, + }), + }); if (!params.wait) { throw new Error(`voyage batch ${params.batchId} still ${state}; wait disabled`); } @@ -206,6 +210,7 @@ export async function runVoyageEmbeddingBatches( if (!batchInfo.id) { throw new Error("voyage batch create failed: missing batch id"); } + const batchId = batchInfo.id; params.debug?.("memory embeddings: voyage batch created", { batchId: batchInfo.id, @@ -215,30 +220,21 @@ export async function runVoyageEmbeddingBatches( requests: group.length, }); - if (!params.wait && batchInfo.status !== "completed") { - throw new Error( - `voyage batch ${batchInfo.id} submitted; enable remote.batch.wait to await completion`, - ); - } - - const completed = - batchInfo.status === "completed" - ? { - outputFileId: batchInfo.output_file_id ?? "", - errorFileId: batchInfo.error_file_id ?? undefined, - } - : await waitForVoyageBatch({ - client: params.client, - batchId: batchInfo.id, - wait: params.wait, - pollIntervalMs: params.pollIntervalMs, - timeoutMs: params.timeoutMs, - debug: params.debug, - initial: batchInfo, - }); - if (!completed.outputFileId) { - throw new Error(`voyage batch ${batchInfo.id} completed without output file`); - } + const completed = await resolveCompletedBatchResult({ + provider: "voyage", + status: batchInfo, + wait: params.wait, + waitForBatch: async () => + await waitForVoyageBatch({ + client: params.client, + batchId, + wait: params.wait, + pollIntervalMs: params.pollIntervalMs, + timeoutMs: params.timeoutMs, + debug: params.debug, + initial: batchInfo, + }), + }); const baseUrl = normalizeBatchBaseUrl(params.client); const errors: string[] = []; diff --git a/src/node-host/runner.credentials.test.ts b/src/node-host/runner.credentials.test.ts index 394f1872191..543459161f5 100644 --- a/src/node-host/runner.credentials.test.ts +++ b/src/node-host/runner.credentials.test.ts @@ -3,21 +3,25 @@ import type { OpenClawConfig } from "../config/config.js"; import { withEnvAsync } from "../test-utils/env.js"; import { resolveNodeHostGatewayCredentials } from "./runner.js"; +function createRemoteGatewayTokenRefConfig(tokenId: string): OpenClawConfig { + return { + secrets: { + providers: { + default: { source: "env" }, + }, + }, + gateway: { + mode: "remote", + remote: { + token: { source: "env", provider: "default", id: tokenId }, + }, + }, + } as OpenClawConfig; +} + describe("resolveNodeHostGatewayCredentials", () => { it("resolves remote token SecretRef values", async () => { - const config = { - secrets: { - providers: { - default: { source: "env" }, - }, - }, - gateway: { - mode: "remote", - remote: { - token: { source: "env", provider: "default", id: "REMOTE_GATEWAY_TOKEN" }, - }, - }, - } as OpenClawConfig; + const config = createRemoteGatewayTokenRefConfig("REMOTE_GATEWAY_TOKEN"); await withEnvAsync( { @@ -32,19 +36,7 @@ describe("resolveNodeHostGatewayCredentials", () => { }); it("prefers OPENCLAW_GATEWAY_TOKEN over configured refs", async () => { - const config = { - secrets: { - providers: { - default: { source: "env" }, - }, - }, - gateway: { - mode: "remote", - remote: { - token: { source: "env", provider: "default", id: "REMOTE_GATEWAY_TOKEN" }, - }, - }, - } as OpenClawConfig; + const config = createRemoteGatewayTokenRefConfig("REMOTE_GATEWAY_TOKEN"); await withEnvAsync( { @@ -59,19 +51,7 @@ describe("resolveNodeHostGatewayCredentials", () => { }); it("throws when a configured remote token ref cannot resolve", async () => { - const config = { - secrets: { - providers: { - default: { source: "env" }, - }, - }, - gateway: { - mode: "remote", - remote: { - token: { source: "env", provider: "default", id: "MISSING_REMOTE_GATEWAY_TOKEN" }, - }, - }, - } as OpenClawConfig; + const config = createRemoteGatewayTokenRefConfig("MISSING_REMOTE_GATEWAY_TOKEN"); await withEnvAsync( { diff --git a/src/node-host/runner.ts b/src/node-host/runner.ts index c56fe3b9832..a20decb84d1 100644 --- a/src/node-host/runner.ts +++ b/src/node-host/runner.ts @@ -1,6 +1,6 @@ import { resolveBrowserConfig } from "../browser/config.js"; import { loadConfig, type OpenClawConfig } from "../config/config.js"; -import { normalizeSecretInputString, resolveSecretInputRef } from "../config/types.secrets.js"; +import { normalizeSecretInputString } from "../config/types.secrets.js"; import { GatewayClient } from "../gateway/client.js"; import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js"; import type { SkillBinTrustEntry } from "../infra/exec-approvals.js"; @@ -12,8 +12,7 @@ import { NODE_SYSTEM_RUN_COMMANDS, } from "../infra/node-commands.js"; import { ensureOpenClawCliOnPath } from "../infra/path-env.js"; -import { secretRefKey } from "../secrets/ref-contract.js"; -import { resolveSecretRefValues } from "../secrets/resolve.js"; +import { resolveSecretInputString } from "../secrets/resolve-secret-input-string.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; import { VERSION } from "../version.js"; import { ensureNodeHostConfig, saveNodeHostConfig, type NodeHostGatewayConfig } from "./config.js"; @@ -117,27 +116,17 @@ async function resolveNodeHostSecretInputString(params: { path: string; env: NodeJS.ProcessEnv; }): Promise { - const defaults = params.config.secrets?.defaults; - const { ref } = resolveSecretInputRef({ + const resolvedValue = await resolveSecretInputString({ + config: params.config, value: params.value, - defaults, + env: params.env, + onResolveRefError: (error) => { + const detail = error instanceof Error ? error.message : String(error); + throw new Error(`${params.path} secret reference could not be resolved: ${detail}`, { + cause: error, + }); + }, }); - if (!ref) { - return normalizeSecretInputString(params.value); - } - let resolved: Map; - try { - resolved = await resolveSecretRefValues([ref], { - config: params.config, - env: params.env, - }); - } catch (error) { - const detail = error instanceof Error ? error.message : String(error); - throw new Error(`${params.path} secret reference could not be resolved: ${detail}`, { - cause: error, - }); - } - const resolvedValue = normalizeSecretInputString(resolved.get(secretRefKey(ref))); if (!resolvedValue) { throw new Error(`${params.path} resolved to an empty or non-string value.`); } diff --git a/src/plugin-sdk/allowlist-resolution.test.ts b/src/plugin-sdk/allowlist-resolution.test.ts new file mode 100644 index 00000000000..84b51101c33 --- /dev/null +++ b/src/plugin-sdk/allowlist-resolution.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { + mapBasicAllowlistResolutionEntries, + type BasicAllowlistResolutionEntry, +} from "./allowlist-resolution.js"; + +describe("mapBasicAllowlistResolutionEntries", () => { + it("maps entries to normalized allowlist resolver output", () => { + const entries: BasicAllowlistResolutionEntry[] = [ + { + input: "alice", + resolved: true, + id: "U123", + name: "Alice", + note: "ok", + }, + { + input: "bob", + resolved: false, + }, + ]; + + expect(mapBasicAllowlistResolutionEntries(entries)).toEqual([ + { + input: "alice", + resolved: true, + id: "U123", + name: "Alice", + note: "ok", + }, + { + input: "bob", + resolved: false, + id: undefined, + name: undefined, + note: undefined, + }, + ]); + }); +}); diff --git a/src/plugin-sdk/allowlist-resolution.ts b/src/plugin-sdk/allowlist-resolution.ts new file mode 100644 index 00000000000..edfb27d9ef8 --- /dev/null +++ b/src/plugin-sdk/allowlist-resolution.ts @@ -0,0 +1,19 @@ +export type BasicAllowlistResolutionEntry = { + input: string; + resolved: boolean; + id?: string; + name?: string; + note?: string; +}; + +export function mapBasicAllowlistResolutionEntries( + entries: BasicAllowlistResolutionEntry[], +): BasicAllowlistResolutionEntry[] { + return entries.map((entry) => ({ + input: entry.input, + resolved: entry.resolved, + id: entry.id, + name: entry.name, + note: entry.note, + })); +} diff --git a/src/plugin-sdk/bluebubbles.ts b/src/plugin-sdk/bluebubbles.ts index 0d9d8f4e4eb..8489d4cb892 100644 --- a/src/plugin-sdk/bluebubbles.ts +++ b/src/plugin-sdk/bluebubbles.ts @@ -85,7 +85,11 @@ export type { WizardPrompter } from "../wizard/prompts.js"; export { isAllowedParsedChatSender } from "./allow-from.js"; export { readBooleanParam } from "./boolean-param.js"; export { createScopedPairingAccess } from "./pairing-access.js"; -export { buildProbeChannelStatusSummary } from "./status-helpers.js"; +export { resolveRequestUrl } from "./request-url.js"; +export { + buildComputedAccountStatusSnapshot, + buildProbeChannelStatusSummary, +} from "./status-helpers.js"; export { extractToolSend } from "./tool-send.js"; export { normalizeWebhookPath } from "./webhook-path.js"; export { diff --git a/src/plugin-sdk/channel-send-result.ts b/src/plugin-sdk/channel-send-result.ts new file mode 100644 index 00000000000..e64ff290fea --- /dev/null +++ b/src/plugin-sdk/channel-send-result.ts @@ -0,0 +1,14 @@ +export type ChannelSendRawResult = { + ok: boolean; + messageId?: string | null; + error?: string | null; +}; + +export function buildChannelSendResult(channel: string, result: ChannelSendRawResult) { + return { + channel, + ok: result.ok, + messageId: result.messageId ?? "", + error: result.error ? new Error(result.error) : undefined, + }; +} diff --git a/src/plugin-sdk/discord-send.ts b/src/plugin-sdk/discord-send.ts new file mode 100644 index 00000000000..537ec5d7662 --- /dev/null +++ b/src/plugin-sdk/discord-send.ts @@ -0,0 +1,33 @@ +import type { DiscordSendResult } from "../discord/send.types.js"; + +type DiscordSendOptionInput = { + replyToId?: string | null; + accountId?: string | null; + silent?: boolean; +}; + +type DiscordSendMediaOptionInput = DiscordSendOptionInput & { + mediaUrl?: string; + mediaLocalRoots?: readonly string[]; +}; + +export function buildDiscordSendOptions(input: DiscordSendOptionInput) { + return { + verbose: false, + replyTo: input.replyToId ?? undefined, + accountId: input.accountId ?? undefined, + silent: input.silent ?? undefined, + }; +} + +export function buildDiscordSendMediaOptions(input: DiscordSendMediaOptionInput) { + return { + ...buildDiscordSendOptions(input), + mediaUrl: input.mediaUrl, + mediaLocalRoots: input.mediaLocalRoots, + }; +} + +export function tagDiscordChannelResult(result: DiscordSendResult) { + return { channel: "discord" as const, ...result }; +} diff --git a/src/plugin-sdk/feishu.ts b/src/plugin-sdk/feishu.ts index 959f8af124a..300daefc983 100644 --- a/src/plugin-sdk/feishu.ts +++ b/src/plugin-sdk/feishu.ts @@ -59,6 +59,8 @@ export { createScopedPairingAccess } from "./pairing-access.js"; export { createPersistentDedupe } from "./persistent-dedupe.js"; export { buildBaseChannelStatusSummary, + buildProbeChannelStatusSummary, + buildRuntimeAccountStatusSnapshot, createDefaultChannelRuntimeState, } from "./status-helpers.js"; export { withTempDownloadPath } from "./temp-path.js"; diff --git a/src/plugin-sdk/imessage.ts b/src/plugin-sdk/imessage.ts index 7e31560991d..44dfbd4a149 100644 --- a/src/plugin-sdk/imessage.ts +++ b/src/plugin-sdk/imessage.ts @@ -47,3 +47,4 @@ export { imessageOnboardingAdapter } from "../channels/plugins/onboarding/imessa export { IMessageConfigSchema } from "../config/zod-schema.providers-core.js"; export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js"; +export { collectStatusIssuesFromLastError } from "./status-helpers.js"; diff --git a/src/plugin-sdk/inbound-reply-dispatch.ts b/src/plugin-sdk/inbound-reply-dispatch.ts new file mode 100644 index 00000000000..cf11b3ee451 --- /dev/null +++ b/src/plugin-sdk/inbound-reply-dispatch.ts @@ -0,0 +1,143 @@ +import { withReplyDispatcher } from "../auto-reply/dispatch.js"; +import { + dispatchReplyFromConfig, + type DispatchFromConfigResult, +} from "../auto-reply/reply/dispatch-from-config.js"; +import type { ReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js"; +import type { FinalizedMsgContext } from "../auto-reply/templating.js"; +import type { GetReplyOptions } from "../auto-reply/types.js"; +import { createReplyPrefixOptions } from "../channels/reply-prefix.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { createNormalizedOutboundDeliverer, type OutboundReplyPayload } from "./reply-payload.js"; + +type ReplyOptionsWithoutModelSelected = Omit< + Omit, + "onModelSelected" +>; +type RecordInboundSessionFn = typeof import("../channels/session.js").recordInboundSession; +type DispatchReplyWithBufferedBlockDispatcherFn = + typeof import("../auto-reply/reply/provider-dispatcher.js").dispatchReplyWithBufferedBlockDispatcher; + +type ReplyDispatchFromConfigOptions = Omit; + +export async function dispatchReplyFromConfigWithSettledDispatcher(params: { + cfg: OpenClawConfig; + ctxPayload: FinalizedMsgContext; + dispatcher: ReplyDispatcher; + onSettled: () => void | Promise; + replyOptions?: ReplyDispatchFromConfigOptions; +}): Promise { + return await withReplyDispatcher({ + dispatcher: params.dispatcher, + onSettled: params.onSettled, + run: () => + dispatchReplyFromConfig({ + ctx: params.ctxPayload, + cfg: params.cfg, + dispatcher: params.dispatcher, + replyOptions: params.replyOptions, + }), + }); +} + +export function buildInboundReplyDispatchBase(params: { + cfg: OpenClawConfig; + channel: string; + accountId?: string; + route: { + agentId: string; + sessionKey: string; + }; + storePath: string; + ctxPayload: FinalizedMsgContext; + core: { + channel: { + session: { + recordInboundSession: RecordInboundSessionFn; + }; + reply: { + dispatchReplyWithBufferedBlockDispatcher: DispatchReplyWithBufferedBlockDispatcherFn; + }; + }; + }; +}) { + return { + cfg: params.cfg, + channel: params.channel, + accountId: params.accountId, + agentId: params.route.agentId, + routeSessionKey: params.route.sessionKey, + storePath: params.storePath, + ctxPayload: params.ctxPayload, + recordInboundSession: params.core.channel.session.recordInboundSession, + dispatchReplyWithBufferedBlockDispatcher: + params.core.channel.reply.dispatchReplyWithBufferedBlockDispatcher, + }; +} + +type BuildInboundReplyDispatchBaseParams = Parameters[0]; +type RecordInboundSessionAndDispatchReplyParams = Parameters< + typeof recordInboundSessionAndDispatchReply +>[0]; + +export async function dispatchInboundReplyWithBase( + params: BuildInboundReplyDispatchBaseParams & + Pick< + RecordInboundSessionAndDispatchReplyParams, + "deliver" | "onRecordError" | "onDispatchError" | "replyOptions" + >, +): Promise { + const dispatchBase = buildInboundReplyDispatchBase(params); + await recordInboundSessionAndDispatchReply({ + ...dispatchBase, + deliver: params.deliver, + onRecordError: params.onRecordError, + onDispatchError: params.onDispatchError, + replyOptions: params.replyOptions, + }); +} + +export async function recordInboundSessionAndDispatchReply(params: { + cfg: OpenClawConfig; + channel: string; + accountId?: string; + agentId: string; + routeSessionKey: string; + storePath: string; + ctxPayload: FinalizedMsgContext; + recordInboundSession: RecordInboundSessionFn; + dispatchReplyWithBufferedBlockDispatcher: DispatchReplyWithBufferedBlockDispatcherFn; + deliver: (payload: OutboundReplyPayload) => Promise; + onRecordError: (err: unknown) => void; + onDispatchError: (err: unknown, info: { kind: string }) => void; + replyOptions?: ReplyOptionsWithoutModelSelected; +}): Promise { + await params.recordInboundSession({ + storePath: params.storePath, + sessionKey: params.ctxPayload.SessionKey ?? params.routeSessionKey, + ctx: params.ctxPayload, + onRecordError: params.onRecordError, + }); + + const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({ + cfg: params.cfg, + agentId: params.agentId, + channel: params.channel, + accountId: params.accountId, + }); + const deliver = createNormalizedOutboundDeliverer(params.deliver); + + await params.dispatchReplyWithBufferedBlockDispatcher({ + ctx: params.ctxPayload, + cfg: params.cfg, + dispatcherOptions: { + ...prefixOptions, + deliver, + onError: params.onDispatchError, + }, + replyOptions: { + ...params.replyOptions, + onModelSelected, + }, + }); +} diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 07b0846cddb..06f95c58d6b 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -132,6 +132,16 @@ export { isDangerousNameMatchingEnabled } from "../config/dangerous-name-matchin export type { FileLockHandle, FileLockOptions } from "./file-lock.js"; export { acquireFileLock, withFileLock } from "./file-lock.js"; +export { + mapBasicAllowlistResolutionEntries, + type BasicAllowlistResolutionEntry, +} from "./allowlist-resolution.js"; +export { resolveRequestUrl } from "./request-url.js"; +export { + buildDiscordSendMediaOptions, + buildDiscordSendOptions, + tagDiscordChannelResult, +} from "./discord-send.js"; export type { KeyedAsyncQueueHooks } from "./keyed-async-queue.js"; export { enqueueKeyedTask, KeyedAsyncQueue } from "./keyed-async-queue.js"; export { normalizeWebhookPath, resolveWebhookPath } from "./webhook-path.js"; @@ -167,7 +177,9 @@ export { buildAgentMediaPayload } from "./agent-media-payload.js"; export { buildBaseAccountStatusSnapshot, buildBaseChannelStatusSummary, + buildComputedAccountStatusSnapshot, buildProbeChannelStatusSummary, + buildRuntimeAccountStatusSnapshot, buildTokenChannelStatusSummary, collectStatusIssuesFromLastError, createDefaultChannelRuntimeState, @@ -178,6 +190,8 @@ export { } from "../channels/plugins/onboarding/helpers.js"; export { buildOauthProviderAuthResult } from "./provider-auth-result.js"; export { formatResolvedUnresolvedNote } from "./resolution-notes.js"; +export { buildChannelSendResult } from "./channel-send-result.js"; +export type { ChannelSendRawResult } from "./channel-send-result.js"; export type { ChannelDock } from "../channels/dock.js"; export { getChatChannelMeta } from "../channels/registry.js"; export { resolveAllowlistMatchByCandidates } from "../channels/allowlist-match.js"; @@ -278,6 +292,7 @@ export { resolveInboundRouteEnvelopeBuilder, resolveInboundRouteEnvelopeBuilderWithRuntime, } from "./inbound-envelope.js"; +export { resolveInboundSessionEnvelopeContext } from "../channels/session-envelope.js"; export { listConfiguredAccountIds, resolveAccountWithDefaultFallback, @@ -288,17 +303,29 @@ export { extractToolSend } from "./tool-send.js"; export { createNormalizedOutboundDeliverer, formatTextWithAttachmentLinks, + isNumericTargetId, normalizeOutboundReplyPayload, resolveOutboundMediaUrls, + sendPayloadWithChunkedTextAndMedia, sendMediaWithLeadingCaption, } from "./reply-payload.js"; export type { OutboundReplyPayload } from "./reply-payload.js"; +export { + buildInboundReplyDispatchBase, + dispatchInboundReplyWithBase, + dispatchReplyFromConfigWithSettledDispatcher, + recordInboundSessionAndDispatchReply, +} from "./inbound-reply-dispatch.js"; export type { OutboundMediaLoadOptions } from "./outbound-media.js"; export { loadOutboundMediaFromUrl } from "./outbound-media.js"; export { resolveChannelAccountConfigBasePath } from "./config-paths.js"; export { buildMediaPayload } from "../channels/plugins/media-payload.js"; export type { MediaPayload, MediaPayloadInput } from "../channels/plugins/media-payload.js"; -export { createLoggerBackedRuntime } from "./runtime.js"; +export { + createLoggerBackedRuntime, + resolveRuntimeEnv, + resolveRuntimeEnvWithUnavailableExit, +} from "./runtime.js"; export { chunkTextForOutbound } from "./text-chunking.js"; export { readBooleanParam } from "./boolean-param.js"; export { readJsonFileWithFallback, writeJsonFileAtomically } from "./json-store.js"; @@ -487,6 +514,7 @@ export type { PollInput } from "../polls.js"; export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; export { + clearAccountEntryFields, deleteAccountFromConfigSection, setAccountEnabledInConfigSection, } from "../channels/plugins/config-helpers.js"; @@ -589,12 +617,18 @@ export { normalizeIMessageMessagingTarget, } from "../channels/plugins/normalize/imessage.js"; export { + createAllowedChatSenderMatcher, parseChatAllowTargetPrefixes, parseChatTargetPrefixesOrThrow, + resolveServicePrefixedChatTarget, resolveServicePrefixedAllowTarget, + resolveServicePrefixedOrChatAllowTarget, resolveServicePrefixedTarget, } from "../imessage/target-parsing-helpers.js"; -export type { ParsedChatTarget } from "../imessage/target-parsing-helpers.js"; +export type { + ChatSenderAllowParams, + ParsedChatTarget, +} from "../imessage/target-parsing-helpers.js"; // Channel: Slack export { diff --git a/src/plugin-sdk/irc.ts b/src/plugin-sdk/irc.ts index 9706c552450..afc9428bb05 100644 --- a/src/plugin-sdk/irc.ts +++ b/src/plugin-sdk/irc.ts @@ -60,6 +60,7 @@ export { export { formatDocsLink } from "../terminal/links.js"; export type { WizardPrompter } from "../wizard/prompts.js"; export { createScopedPairingAccess } from "./pairing-access.js"; +export { dispatchInboundReplyWithBase } from "./inbound-reply-dispatch.js"; export type { OutboundReplyPayload } from "./reply-payload.js"; export { createNormalizedOutboundDeliverer, diff --git a/src/plugin-sdk/line.ts b/src/plugin-sdk/line.ts index f7f6a3eeb37..0318e5ac1e7 100644 --- a/src/plugin-sdk/line.ts +++ b/src/plugin-sdk/line.ts @@ -14,13 +14,17 @@ export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; export { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; +export { clearAccountEntryFields } from "../channels/plugins/config-helpers.js"; export { resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, } from "../config/runtime-group-policy.js"; -export { buildTokenChannelStatusSummary } from "./status-helpers.js"; +export { + buildComputedAccountStatusSnapshot, + buildTokenChannelStatusSummary, +} from "./status-helpers.js"; export { LineConfigSchema } from "../line/config-schema.js"; export type { LineChannelData, LineConfig, ResolvedLineAccount } from "../line/types.js"; diff --git a/src/plugin-sdk/matrix.ts b/src/plugin-sdk/matrix.ts index fca8773e9b3..63712fc8d71 100644 --- a/src/plugin-sdk/matrix.ts +++ b/src/plugin-sdk/matrix.ts @@ -92,5 +92,10 @@ export type { WizardPrompter } from "../wizard/prompts.js"; export { createScopedPairingAccess } from "./pairing-access.js"; export { formatResolvedUnresolvedNote } from "./resolution-notes.js"; export { runPluginCommandWithTimeout } from "./run-command.js"; -export { createLoggerBackedRuntime } from "./runtime.js"; -export { buildProbeChannelStatusSummary } from "./status-helpers.js"; +export { dispatchReplyFromConfigWithSettledDispatcher } from "./inbound-reply-dispatch.js"; +export { createLoggerBackedRuntime, resolveRuntimeEnv } from "./runtime.js"; +export { resolveInboundSessionEnvelopeContext } from "../channels/session-envelope.js"; +export { + buildProbeChannelStatusSummary, + collectStatusIssuesFromLastError, +} from "./status-helpers.js"; diff --git a/src/plugin-sdk/minimax-portal-auth.ts b/src/plugin-sdk/minimax-portal-auth.ts index 2f6ab59e124..9a8b0f0bb80 100644 --- a/src/plugin-sdk/minimax-portal-auth.ts +++ b/src/plugin-sdk/minimax-portal-auth.ts @@ -2,6 +2,7 @@ // Keep this list additive and scoped to symbols used under extensions/minimax-portal-auth. export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; +export { buildOauthProviderAuthResult } from "./provider-auth-result.js"; export type { OpenClawPluginApi, ProviderAuthContext, diff --git a/src/plugin-sdk/msteams.ts b/src/plugin-sdk/msteams.ts index 28f5e10a4c0..ae3e7d3564e 100644 --- a/src/plugin-sdk/msteams.ts +++ b/src/plugin-sdk/msteams.ts @@ -94,9 +94,11 @@ export { loadWebMedia } from "../web/media.js"; export type { WizardPrompter } from "../wizard/prompts.js"; export { keepHttpServerTaskAlive } from "./channel-lifecycle.js"; export { withFileLock } from "./file-lock.js"; +export { dispatchReplyFromConfigWithSettledDispatcher } from "./inbound-reply-dispatch.js"; export { readJsonFileWithFallback, writeJsonFileAtomically } from "./json-store.js"; export { loadOutboundMediaFromUrl } from "./outbound-media.js"; export { createScopedPairingAccess } from "./pairing-access.js"; +export { resolveInboundSessionEnvelopeContext } from "../channels/session-envelope.js"; export { buildHostnameAllowlistPolicyFromSuffixAllowlist, isHttpsUrlAllowedByHostnameSuffixAllowlist, @@ -104,5 +106,7 @@ export { } from "./ssrf-policy.js"; export { buildBaseChannelStatusSummary, + buildProbeChannelStatusSummary, + buildRuntimeAccountStatusSnapshot, createDefaultChannelRuntimeState, } from "./status-helpers.js"; diff --git a/src/plugin-sdk/nextcloud-talk.ts b/src/plugin-sdk/nextcloud-talk.ts index 7d66c5e66be..14d633a4c85 100644 --- a/src/plugin-sdk/nextcloud-talk.ts +++ b/src/plugin-sdk/nextcloud-talk.ts @@ -12,6 +12,7 @@ export { } from "../channels/plugins/channel-config.js"; export { deleteAccountFromConfigSection, + clearAccountEntryFields, setAccountEnabledInConfigSection, } from "../channels/plugins/config-helpers.js"; export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; @@ -89,4 +90,9 @@ export { formatTextWithAttachmentLinks, resolveOutboundMediaUrls, } from "./reply-payload.js"; +export { dispatchInboundReplyWithBase } from "./inbound-reply-dispatch.js"; export { createLoggerBackedRuntime } from "./runtime.js"; +export { + buildBaseChannelStatusSummary, + buildRuntimeAccountStatusSnapshot, +} from "./status-helpers.js"; diff --git a/src/plugin-sdk/qwen-portal-auth.ts b/src/plugin-sdk/qwen-portal-auth.ts index 33d03ae394b..1056b98d0cf 100644 --- a/src/plugin-sdk/qwen-portal-auth.ts +++ b/src/plugin-sdk/qwen-portal-auth.ts @@ -2,5 +2,6 @@ // Keep this list additive and scoped to symbols used under extensions/qwen-portal-auth. export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; +export { buildOauthProviderAuthResult } from "./provider-auth-result.js"; export type { OpenClawPluginApi, ProviderAuthContext } from "../plugins/types.js"; export { generatePkceVerifierChallenge, toFormUrlEncoded } from "./oauth-utils.js"; diff --git a/src/plugin-sdk/reply-payload.test.ts b/src/plugin-sdk/reply-payload.test.ts new file mode 100644 index 00000000000..780b75686a1 --- /dev/null +++ b/src/plugin-sdk/reply-payload.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { isNumericTargetId, sendPayloadWithChunkedTextAndMedia } from "./reply-payload.js"; + +describe("sendPayloadWithChunkedTextAndMedia", () => { + it("returns empty result when payload has no text and no media", async () => { + const result = await sendPayloadWithChunkedTextAndMedia({ + ctx: { payload: {} }, + sendText: async () => ({ channel: "test", messageId: "text" }), + sendMedia: async () => ({ channel: "test", messageId: "media" }), + emptyResult: { channel: "test", messageId: "" }, + }); + expect(result).toEqual({ channel: "test", messageId: "" }); + }); + + it("sends first media with text and remaining media without text", async () => { + const calls: Array<{ text: string; mediaUrl: string }> = []; + const result = await sendPayloadWithChunkedTextAndMedia({ + ctx: { + payload: { text: "hello", mediaUrls: ["https://a", "https://b"] }, + }, + sendText: async () => ({ channel: "test", messageId: "text" }), + sendMedia: async (ctx) => { + calls.push({ text: ctx.text, mediaUrl: ctx.mediaUrl }); + return { channel: "test", messageId: ctx.mediaUrl }; + }, + emptyResult: { channel: "test", messageId: "" }, + }); + expect(calls).toEqual([ + { text: "hello", mediaUrl: "https://a" }, + { text: "", mediaUrl: "https://b" }, + ]); + expect(result).toEqual({ channel: "test", messageId: "https://b" }); + }); + + it("chunks text and sends each chunk", async () => { + const chunks: string[] = []; + const result = await sendPayloadWithChunkedTextAndMedia({ + ctx: { payload: { text: "alpha beta gamma" } }, + textChunkLimit: 5, + chunker: () => ["alpha", "beta", "gamma"], + sendText: async (ctx) => { + chunks.push(ctx.text); + return { channel: "test", messageId: ctx.text }; + }, + sendMedia: async () => ({ channel: "test", messageId: "media" }), + emptyResult: { channel: "test", messageId: "" }, + }); + expect(chunks).toEqual(["alpha", "beta", "gamma"]); + expect(result).toEqual({ channel: "test", messageId: "gamma" }); + }); + + it("detects numeric target IDs", () => { + expect(isNumericTargetId("12345")).toBe(true); + expect(isNumericTargetId(" 987 ")).toBe(true); + expect(isNumericTargetId("ab12")).toBe(false); + expect(isNumericTargetId("")).toBe(false); + }); +}); diff --git a/src/plugin-sdk/reply-payload.ts b/src/plugin-sdk/reply-payload.ts index b2534cd629c..e141da2a940 100644 --- a/src/plugin-sdk/reply-payload.ts +++ b/src/plugin-sdk/reply-payload.ts @@ -49,6 +49,55 @@ export function resolveOutboundMediaUrls(payload: { return []; } +export async function sendPayloadWithChunkedTextAndMedia< + TContext extends { payload: object }, + TResult, +>(params: { + ctx: TContext; + textChunkLimit?: number; + chunker?: ((text: string, limit: number) => string[]) | null; + sendText: (ctx: TContext & { text: string }) => Promise; + sendMedia: (ctx: TContext & { text: string; mediaUrl: string }) => Promise; + emptyResult: TResult; +}): Promise { + const payload = params.ctx.payload as { text?: string; mediaUrls?: string[]; mediaUrl?: string }; + const text = payload.text ?? ""; + const urls = resolveOutboundMediaUrls(payload); + if (!text && urls.length === 0) { + return params.emptyResult; + } + if (urls.length > 0) { + let lastResult = await params.sendMedia({ + ...params.ctx, + text, + mediaUrl: urls[0], + }); + for (let i = 1; i < urls.length; i++) { + lastResult = await params.sendMedia({ + ...params.ctx, + text: "", + mediaUrl: urls[i], + }); + } + return lastResult; + } + const limit = params.textChunkLimit; + const chunks = limit && params.chunker ? params.chunker(text, limit) : [text]; + let lastResult: TResult; + for (const chunk of chunks) { + lastResult = await params.sendText({ ...params.ctx, text: chunk }); + } + return lastResult!; +} + +export function isNumericTargetId(raw: string): boolean { + const trimmed = raw.trim(); + if (!trimmed) { + return false; + } + return /^\d{3,}$/.test(trimmed); +} + export function formatTextWithAttachmentLinks( text: string | undefined, mediaUrls: string[], diff --git a/src/plugin-sdk/request-url.test.ts b/src/plugin-sdk/request-url.test.ts new file mode 100644 index 00000000000..94c0f1917e3 --- /dev/null +++ b/src/plugin-sdk/request-url.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { resolveRequestUrl } from "./request-url.js"; + +describe("resolveRequestUrl", () => { + it("resolves string input", () => { + expect(resolveRequestUrl("https://example.com/a")).toBe("https://example.com/a"); + }); + + it("resolves URL input", () => { + expect(resolveRequestUrl(new URL("https://example.com/b"))).toBe("https://example.com/b"); + }); + + it("resolves object input with url field", () => { + const requestLike = { url: "https://example.com/c" } as unknown as RequestInfo; + expect(resolveRequestUrl(requestLike)).toBe("https://example.com/c"); + }); +}); diff --git a/src/plugin-sdk/request-url.ts b/src/plugin-sdk/request-url.ts new file mode 100644 index 00000000000..2ba7354cc28 --- /dev/null +++ b/src/plugin-sdk/request-url.ts @@ -0,0 +1,12 @@ +export function resolveRequestUrl(input: RequestInfo | URL): string { + if (typeof input === "string") { + return input; + } + if (input instanceof URL) { + return input.toString(); + } + if (typeof input === "object" && input && "url" in input && typeof input.url === "string") { + return input.url; + } + return ""; +} diff --git a/src/plugin-sdk/runtime.test.ts b/src/plugin-sdk/runtime.test.ts new file mode 100644 index 00000000000..0dedb79e8e1 --- /dev/null +++ b/src/plugin-sdk/runtime.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it, vi } from "vitest"; +import type { RuntimeEnv } from "../runtime.js"; +import { resolveRuntimeEnv } from "./runtime.js"; + +describe("resolveRuntimeEnv", () => { + it("returns provided runtime when present", () => { + const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(() => { + throw new Error("exit"); + }), + }; + const logger = { + info: vi.fn(), + error: vi.fn(), + }; + + const resolved = resolveRuntimeEnv({ runtime, logger }); + + expect(resolved).toBe(runtime); + expect(logger.info).not.toHaveBeenCalled(); + expect(logger.error).not.toHaveBeenCalled(); + }); + + it("creates logger-backed runtime when runtime is missing", () => { + const logger = { + info: vi.fn(), + error: vi.fn(), + }; + + const resolved = resolveRuntimeEnv({ logger }); + resolved.log?.("hello %s", "world"); + resolved.error?.("bad %d", 7); + + expect(logger.info).toHaveBeenCalledWith("hello world"); + expect(logger.error).toHaveBeenCalledWith("bad 7"); + }); +}); diff --git a/src/plugin-sdk/runtime.ts b/src/plugin-sdk/runtime.ts index dac01e9b5dc..c438a4e9788 100644 --- a/src/plugin-sdk/runtime.ts +++ b/src/plugin-sdk/runtime.ts @@ -22,3 +22,23 @@ export function createLoggerBackedRuntime(params: { }, }; } + +export function resolveRuntimeEnv(params: { + runtime?: RuntimeEnv; + logger: LoggerLike; + exitError?: (code: number) => Error; +}): RuntimeEnv { + return params.runtime ?? createLoggerBackedRuntime(params); +} + +export function resolveRuntimeEnvWithUnavailableExit(params: { + runtime?: RuntimeEnv; + logger: LoggerLike; + unavailableMessage?: string; +}): RuntimeEnv { + return resolveRuntimeEnv({ + runtime: params.runtime, + logger: params.logger, + exitError: () => new Error(params.unavailableMessage ?? "Runtime exit not available"), + }); +} diff --git a/src/plugin-sdk/status-helpers.test.ts b/src/plugin-sdk/status-helpers.test.ts index b2e10cc4ae8..b2b75bb1414 100644 --- a/src/plugin-sdk/status-helpers.test.ts +++ b/src/plugin-sdk/status-helpers.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest"; import { buildBaseAccountStatusSnapshot, buildBaseChannelStatusSummary, + buildComputedAccountStatusSnapshot, + buildRuntimeAccountStatusSnapshot, buildTokenChannelStatusSummary, collectStatusIssuesFromLastError, createDefaultChannelRuntimeState, @@ -88,6 +90,42 @@ describe("buildBaseAccountStatusSnapshot", () => { }); }); +describe("buildComputedAccountStatusSnapshot", () => { + it("builds account status when configured is computed outside resolver", () => { + expect( + buildComputedAccountStatusSnapshot({ + accountId: "default", + enabled: true, + configured: false, + }), + ).toEqual({ + accountId: "default", + name: undefined, + enabled: true, + configured: false, + running: false, + lastStartAt: null, + lastStopAt: null, + lastError: null, + probe: undefined, + lastInboundAt: null, + lastOutboundAt: null, + }); + }); +}); + +describe("buildRuntimeAccountStatusSnapshot", () => { + it("builds runtime lifecycle fields with defaults", () => { + expect(buildRuntimeAccountStatusSnapshot({})).toEqual({ + running: false, + lastStartAt: null, + lastStopAt: null, + lastError: null, + probe: undefined, + }); + }); +}); + describe("buildTokenChannelStatusSummary", () => { it("includes token/probe fields with mode by default", () => { expect(buildTokenChannelStatusSummary({})).toEqual({ diff --git a/src/plugin-sdk/status-helpers.ts b/src/plugin-sdk/status-helpers.ts index c6abc1d6e54..42aad35a702 100644 --- a/src/plugin-sdk/status-helpers.ts +++ b/src/plugin-sdk/status-helpers.ts @@ -81,13 +81,44 @@ export function buildBaseAccountStatusSnapshot(params: { name: account.name, enabled: account.enabled, configured: account.configured, + ...buildRuntimeAccountStatusSnapshot({ runtime, probe }), + lastInboundAt: runtime?.lastInboundAt ?? null, + lastOutboundAt: runtime?.lastOutboundAt ?? null, + }; +} + +export function buildComputedAccountStatusSnapshot(params: { + accountId: string; + name?: string; + enabled?: boolean; + configured?: boolean; + runtime?: RuntimeLifecycleSnapshot | null; + probe?: unknown; +}) { + const { accountId, name, enabled, configured, runtime, probe } = params; + return buildBaseAccountStatusSnapshot({ + account: { + accountId, + name, + enabled, + configured, + }, + runtime, + probe, + }); +} + +export function buildRuntimeAccountStatusSnapshot(params: { + runtime?: RuntimeLifecycleSnapshot | null; + probe?: unknown; +}) { + const { runtime, probe } = params; + return { running: runtime?.running ?? false, lastStartAt: runtime?.lastStartAt ?? null, lastStopAt: runtime?.lastStopAt ?? null, lastError: runtime?.lastError ?? null, probe, - lastInboundAt: runtime?.lastInboundAt ?? null, - lastOutboundAt: runtime?.lastOutboundAt ?? null, }; } diff --git a/src/plugin-sdk/telegram.ts b/src/plugin-sdk/telegram.ts index c4dfce3e441..53167998404 100644 --- a/src/plugin-sdk/telegram.ts +++ b/src/plugin-sdk/telegram.ts @@ -22,6 +22,7 @@ export { export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; export { deleteAccountFromConfigSection, + clearAccountEntryFields, setAccountEnabledInConfigSection, } from "../channels/plugins/config-helpers.js"; export { formatPairingApproveHint } from "../channels/plugins/helpers.js"; diff --git a/src/plugin-sdk/zalo.ts b/src/plugin-sdk/zalo.ts index 07237369d2e..440cffd0de9 100644 --- a/src/plugin-sdk/zalo.ts +++ b/src/plugin-sdk/zalo.ts @@ -66,9 +66,18 @@ export { evaluateSenderGroupAccess } from "./group-access.js"; export type { SenderGroupAccessDecision } from "./group-access.js"; export { resolveInboundRouteEnvelopeBuilderWithRuntime } from "./inbound-envelope.js"; export { createScopedPairingAccess } from "./pairing-access.js"; +export { buildChannelSendResult } from "./channel-send-result.js"; export type { OutboundReplyPayload } from "./reply-payload.js"; -export { resolveOutboundMediaUrls, sendMediaWithLeadingCaption } from "./reply-payload.js"; -export { buildTokenChannelStatusSummary } from "./status-helpers.js"; +export { + isNumericTargetId, + resolveOutboundMediaUrls, + sendMediaWithLeadingCaption, + sendPayloadWithChunkedTextAndMedia, +} from "./reply-payload.js"; +export { + buildBaseAccountStatusSnapshot, + buildTokenChannelStatusSummary, +} from "./status-helpers.js"; export { chunkTextForOutbound } from "./text-chunking.js"; export { extractToolSend } from "./tool-send.js"; export { diff --git a/src/plugin-sdk/zalouser.ts b/src/plugin-sdk/zalouser.ts index 3109802fbb3..d0c75742ef0 100644 --- a/src/plugin-sdk/zalouser.ts +++ b/src/plugin-sdk/zalouser.ts @@ -57,7 +57,14 @@ export { resolveSenderCommandAuthorization } from "./command-auth.js"; export { resolveChannelAccountConfigBasePath } from "./config-paths.js"; export { loadOutboundMediaFromUrl } from "./outbound-media.js"; export { createScopedPairingAccess } from "./pairing-access.js"; +export { buildChannelSendResult } from "./channel-send-result.js"; export type { OutboundReplyPayload } from "./reply-payload.js"; -export { resolveOutboundMediaUrls, sendMediaWithLeadingCaption } from "./reply-payload.js"; +export { + isNumericTargetId, + resolveOutboundMediaUrls, + sendMediaWithLeadingCaption, + sendPayloadWithChunkedTextAndMedia, +} from "./reply-payload.js"; export { formatResolvedUnresolvedNote } from "./resolution-notes.js"; +export { buildBaseAccountStatusSnapshot } from "./status-helpers.js"; export { chunkTextForOutbound } from "./text-chunking.js"; diff --git a/src/plugins/wired-hooks-after-tool-call.e2e.test.ts b/src/plugins/wired-hooks-after-tool-call.e2e.test.ts index ad04cd80f44..147ca323a91 100644 --- a/src/plugins/wired-hooks-after-tool-call.e2e.test.ts +++ b/src/plugins/wired-hooks-after-tool-call.e2e.test.ts @@ -1,7 +1,8 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; /** * Test: after_tool_call hook wiring (pi-embedded-subscribe.handlers.tools.ts) */ -import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { createBaseToolHandlerState } from "../agents/pi-tool-handler-state.test-helpers.js"; const hookMocks = vi.hoisted(() => ({ runner: { @@ -38,17 +39,7 @@ function createToolHandlerCtx(params: { }, state: { toolMetaById: new Map(), - toolMetas: [] as Array<{ toolName?: string; meta?: string }>, - toolSummaryById: new Set(), - lastToolError: undefined, - pendingMessagingTexts: new Map(), - pendingMessagingTargets: new Map(), - pendingMessagingMediaUrls: new Map(), - messagingToolSentTexts: [] as string[], - messagingToolSentTextsNormalized: [] as string[], - messagingToolSentMediaUrls: [] as string[], - messagingToolSentTargets: [] as unknown[], - blockBuffer: "", + ...createBaseToolHandlerState(), }, log: { debug: vi.fn(), warn: vi.fn() }, flushBlockReplyBuffer: vi.fn(), diff --git a/src/secrets/auth-profiles-scan.ts b/src/secrets/auth-profiles-scan.ts index 77363c32377..d126b8dade8 100644 --- a/src/secrets/auth-profiles-scan.ts +++ b/src/secrets/auth-profiles-scan.ts @@ -73,6 +73,25 @@ export function getAuthProfileFieldSpec(type: AuthProfileCredentialType): AuthPr return AUTH_PROFILE_FIELD_SPEC_BY_TYPE[type]; } +function toSecretCredentialVisit(params: { + kind: AuthProfileCredentialType; + profileId: string; + provider: string; + profile: Record; +}): ApiKeyCredentialVisit | TokenCredentialVisit { + const spec = getAuthProfileFieldSpec(params.kind); + return { + kind: params.kind, + profileId: params.profileId, + provider: params.provider, + profile: params.profile, + valueField: spec.valueField, + refField: spec.refField, + value: params.profile[spec.valueField], + refValue: params.profile[spec.refField], + }; +} + export function* iterateAuthProfileCredentials( profiles: Record, ): Iterable { @@ -81,32 +100,13 @@ export function* iterateAuthProfileCredentials( continue; } const provider = String(value.provider); - if (value.type === "api_key") { - const spec = getAuthProfileFieldSpec("api_key"); - yield { - kind: "api_key", + if (value.type === "api_key" || value.type === "token") { + yield toSecretCredentialVisit({ + kind: value.type, profileId, provider, profile: value, - valueField: spec.valueField, - refField: spec.refField, - value: value[spec.valueField], - refValue: value[spec.refField], - }; - continue; - } - if (value.type === "token") { - const spec = getAuthProfileFieldSpec("token"); - yield { - kind: "token", - profileId, - provider, - profile: value, - valueField: spec.valueField, - refField: spec.refField, - value: value[spec.valueField], - refValue: value[spec.refField], - }; + }); continue; } if (value.type === "oauth") { diff --git a/src/secrets/auth-store-paths.ts b/src/secrets/auth-store-paths.ts new file mode 100644 index 00000000000..d2814850d23 --- /dev/null +++ b/src/secrets/auth-store-paths.ts @@ -0,0 +1,40 @@ +import fs from "node:fs"; +import path from "node:path"; +import { listAgentIds, resolveAgentDir } from "../agents/agent-scope.js"; +import { resolveAuthStorePath } from "../agents/auth-profiles/paths.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { resolveUserPath } from "../utils.js"; + +export function listAuthProfileStorePaths(config: OpenClawConfig, stateDir: string): string[] { + const paths = new Set(); + // Scope default auth store discovery to the provided stateDir instead of + // ambient process env, so scans do not include unrelated host-global stores. + paths.add(path.join(resolveUserPath(stateDir), "agents", "main", "agent", "auth-profiles.json")); + + const agentsRoot = path.join(resolveUserPath(stateDir), "agents"); + if (fs.existsSync(agentsRoot)) { + for (const entry of fs.readdirSync(agentsRoot, { withFileTypes: true })) { + if (!entry.isDirectory()) { + continue; + } + paths.add(path.join(agentsRoot, entry.name, "agent", "auth-profiles.json")); + } + } + + for (const agentId of listAgentIds(config)) { + if (agentId === "main") { + paths.add( + path.join(resolveUserPath(stateDir), "agents", "main", "agent", "auth-profiles.json"), + ); + continue; + } + const agentDir = resolveAgentDir(config, agentId); + paths.add(resolveUserPath(resolveAuthStorePath(agentDir))); + } + + return [...paths]; +} + +export function collectAuthStorePaths(config: OpenClawConfig, stateDir: string): string[] { + return listAuthProfileStorePaths(config, stateDir); +} diff --git a/src/secrets/path-utils.test.ts b/src/secrets/path-utils.test.ts index c8c69ceba83..4b13bcc299b 100644 --- a/src/secrets/path-utils.test.ts +++ b/src/secrets/path-utils.test.ts @@ -11,6 +11,14 @@ function asConfig(value: unknown): OpenClawConfig { return value as OpenClawConfig; } +function createAgentListConfig(): OpenClawConfig { + return asConfig({ + agents: { + list: [{ id: "a" }], + }, + }); +} + describe("secrets path utils", () => { it("deletePathStrict compacts arrays via splice", () => { const config = asConfig({}); @@ -30,11 +38,7 @@ describe("secrets path utils", () => { }); it("setPathExistingStrict throws when path does not already exist", () => { - const config = asConfig({ - agents: { - list: [{ id: "a" }], - }, - }); + const config = createAgentListConfig(); expect(() => setPathExistingStrict( config, @@ -72,19 +76,4 @@ describe("secrets path utils", () => { expect(changed).toBe(false); expect(getPath(config, ["talk", "apiKey"])).toBe("same"); }); - - it("setPathExistingStrict fails when intermediate segment is missing", () => { - const config = asConfig({ - agents: { - list: [{ id: "a" }], - }, - }); - expect(() => - setPathExistingStrict( - config, - ["agents", "list", "0", "memorySearch", "remote", "apiKey"], - "x", - ), - ).toThrow(/Path segment does not exist/); - }); }); diff --git a/src/secrets/path-utils.ts b/src/secrets/path-utils.ts index d88fc0487e5..b04066560c8 100644 --- a/src/secrets/path-utils.ts +++ b/src/secrets/path-utils.ts @@ -10,6 +10,63 @@ function expectedContainer(nextSegment: string): "array" | "object" { return isArrayIndexSegment(nextSegment) ? "array" : "object"; } +function parseArrayLeafTarget( + cursor: unknown, + leaf: string, + segments: string[], +): { array: unknown[]; index: number } | null { + if (!Array.isArray(cursor)) { + return null; + } + if (!isArrayIndexSegment(leaf)) { + throw new Error(`Invalid array index segment "${leaf}" at ${segments.join(".")}.`); + } + return { array: cursor, index: Number.parseInt(leaf, 10) }; +} + +function traverseToLeafParent(params: { + root: unknown; + segments: string[]; + requireExistingSegment: boolean; +}): unknown { + if (params.segments.length === 0) { + throw new Error("Target path is empty."); + } + + let cursor: unknown = params.root; + for (let index = 0; index < params.segments.length - 1; index += 1) { + const segment = params.segments[index] ?? ""; + if (Array.isArray(cursor)) { + if (!isArrayIndexSegment(segment)) { + throw new Error( + `Invalid array index segment "${segment}" at ${params.segments.join(".")}.`, + ); + } + const arrayIndex = Number.parseInt(segment, 10); + if (params.requireExistingSegment && (arrayIndex < 0 || arrayIndex >= cursor.length)) { + throw new Error( + `Path segment does not exist at ${params.segments.slice(0, index + 1).join(".")}.`, + ); + } + cursor = cursor[arrayIndex]; + continue; + } + + if (!isRecord(cursor)) { + throw new Error( + `Invalid path shape at ${params.segments.slice(0, index).join(".") || ""}.`, + ); + } + if (params.requireExistingSegment && !Object.prototype.hasOwnProperty.call(cursor, segment)) { + throw new Error( + `Path segment does not exist at ${params.segments.slice(0, index + 1).join(".")}.`, + ); + } + cursor = cursor[segment]; + } + return cursor; +} + export function getPath(root: unknown, segments: string[]): unknown { if (segments.length === 0) { return undefined; @@ -77,13 +134,10 @@ export function setPathCreateStrict( } const leaf = segments[segments.length - 1] ?? ""; - if (Array.isArray(cursor)) { - if (!isArrayIndexSegment(leaf)) { - throw new Error(`Invalid array index segment "${leaf}" at ${segments.join(".")}.`); - } - const arrayIndex = Number.parseInt(leaf, 10); - if (!isDeepStrictEqual(cursor[arrayIndex], value)) { - cursor[arrayIndex] = value; + const arrayTarget = parseArrayLeafTarget(cursor, leaf, segments); + if (arrayTarget) { + if (!isDeepStrictEqual(arrayTarget.array[arrayTarget.index], value)) { + arrayTarget.array[arrayTarget.index] = value; changed = true; } return changed; @@ -103,46 +157,16 @@ export function setPathExistingStrict( segments: string[], value: unknown, ): boolean { - if (segments.length === 0) { - throw new Error("Target path is empty."); - } - let cursor: unknown = root; - - for (let index = 0; index < segments.length - 1; index += 1) { - const segment = segments[index] ?? ""; - if (Array.isArray(cursor)) { - if (!isArrayIndexSegment(segment)) { - throw new Error(`Invalid array index segment "${segment}" at ${segments.join(".")}.`); - } - const arrayIndex = Number.parseInt(segment, 10); - if (arrayIndex < 0 || arrayIndex >= cursor.length) { - throw new Error( - `Path segment does not exist at ${segments.slice(0, index + 1).join(".")}.`, - ); - } - cursor = cursor[arrayIndex]; - continue; - } - if (!isRecord(cursor)) { - throw new Error(`Invalid path shape at ${segments.slice(0, index).join(".") || ""}.`); - } - if (!Object.prototype.hasOwnProperty.call(cursor, segment)) { - throw new Error(`Path segment does not exist at ${segments.slice(0, index + 1).join(".")}.`); - } - cursor = cursor[segment]; - } + const cursor = traverseToLeafParent({ root, segments, requireExistingSegment: true }); const leaf = segments[segments.length - 1] ?? ""; - if (Array.isArray(cursor)) { - if (!isArrayIndexSegment(leaf)) { - throw new Error(`Invalid array index segment "${leaf}" at ${segments.join(".")}.`); - } - const arrayIndex = Number.parseInt(leaf, 10); - if (arrayIndex < 0 || arrayIndex >= cursor.length) { + const arrayTarget = parseArrayLeafTarget(cursor, leaf, segments); + if (arrayTarget) { + if (arrayTarget.index < 0 || arrayTarget.index >= arrayTarget.array.length) { throw new Error(`Path segment does not exist at ${segments.join(".")}.`); } - if (!isDeepStrictEqual(cursor[arrayIndex], value)) { - cursor[arrayIndex] = value; + if (!isDeepStrictEqual(arrayTarget.array[arrayTarget.index], value)) { + arrayTarget.array[arrayTarget.index] = value; return true; } return false; @@ -161,36 +185,16 @@ export function setPathExistingStrict( } export function deletePathStrict(root: OpenClawConfig, segments: string[]): boolean { - if (segments.length === 0) { - throw new Error("Target path is empty."); - } - let cursor: unknown = root; - for (let index = 0; index < segments.length - 1; index += 1) { - const segment = segments[index] ?? ""; - if (Array.isArray(cursor)) { - if (!isArrayIndexSegment(segment)) { - throw new Error(`Invalid array index segment "${segment}" at ${segments.join(".")}.`); - } - cursor = cursor[Number.parseInt(segment, 10)]; - continue; - } - if (!isRecord(cursor)) { - throw new Error(`Invalid path shape at ${segments.slice(0, index).join(".") || ""}.`); - } - cursor = cursor[segment]; - } + const cursor = traverseToLeafParent({ root, segments, requireExistingSegment: false }); const leaf = segments[segments.length - 1] ?? ""; - if (Array.isArray(cursor)) { - if (!isArrayIndexSegment(leaf)) { - throw new Error(`Invalid array index segment "${leaf}" at ${segments.join(".")}.`); - } - const arrayIndex = Number.parseInt(leaf, 10); - if (arrayIndex < 0 || arrayIndex >= cursor.length) { + const arrayTarget = parseArrayLeafTarget(cursor, leaf, segments); + if (arrayTarget) { + if (arrayTarget.index < 0 || arrayTarget.index >= arrayTarget.array.length) { return false; } // Arrays are compacted to preserve predictable index semantics. - cursor.splice(arrayIndex, 1); + arrayTarget.array.splice(arrayTarget.index, 1); return true; } if (!isRecord(cursor)) { diff --git a/src/secrets/resolve-secret-input-string.ts b/src/secrets/resolve-secret-input-string.ts new file mode 100644 index 00000000000..0f23404acf2 --- /dev/null +++ b/src/secrets/resolve-secret-input-string.ts @@ -0,0 +1,41 @@ +import type { OpenClawConfig } from "../config/config.js"; +import { + normalizeSecretInputString, + resolveSecretInputRef, + type SecretRef, +} from "../config/types.secrets.js"; +import { resolveSecretRefString } from "./resolve.js"; + +type SecretDefaults = NonNullable["defaults"]; + +export async function resolveSecretInputString(params: { + config: OpenClawConfig; + value: unknown; + env: NodeJS.ProcessEnv; + defaults?: SecretDefaults; + normalize?: (value: unknown) => string | undefined; + onResolveRefError?: (error: unknown, ref: SecretRef) => never; +}): Promise { + const normalize = params.normalize ?? normalizeSecretInputString; + const { ref } = resolveSecretInputRef({ + value: params.value, + defaults: params.defaults ?? params.config.secrets?.defaults, + }); + if (!ref) { + return normalize(params.value); + } + + let resolved: string; + try { + resolved = await resolveSecretRefString(ref, { + config: params.config, + env: params.env, + }); + } catch (error) { + if (params.onResolveRefError) { + return params.onResolveRefError(error, ref); + } + throw error; + } + return normalize(resolved); +} diff --git a/src/secrets/resolve.ts b/src/secrets/resolve.ts index 8b2cb9c6a5d..039875c464c 100644 --- a/src/secrets/resolve.ts +++ b/src/secrets/resolve.ts @@ -127,6 +127,33 @@ function refResolutionError(params: { return new SecretRefResolutionError(params); } +function throwUnknownProviderResolutionError(params: { + source: SecretRefSource; + provider: string; + err: unknown; +}): never { + if (isSecretResolutionError(params.err)) { + throw params.err; + } + throw providerResolutionError({ + source: params.source, + provider: params.provider, + message: describeUnknownError(params.err), + cause: params.err, + }); +} + +async function readFileStatOrThrow(pathname: string, label: string) { + const stat = await safeStat(pathname); + if (!stat.ok) { + throw new Error(`${label} is not readable: ${pathname}`); + } + if (stat.isDir) { + throw new Error(`${label} must be a file: ${pathname}`); + } + return stat; +} + function isAbsolutePathname(value: string): boolean { return ( path.isAbsolute(value) || @@ -189,13 +216,7 @@ async function assertSecurePath(params: { } let effectivePath = params.targetPath; - let stat = await safeStat(effectivePath); - if (!stat.ok) { - throw new Error(`${params.label} is not readable: ${effectivePath}`); - } - if (stat.isDir) { - throw new Error(`${params.label} must be a file: ${effectivePath}`); - } + let stat = await readFileStatOrThrow(effectivePath, params.label); if (stat.isSymlink) { if (!params.allowSymlinkPath) { throw new Error(`${params.label} must not be a symlink: ${effectivePath}`); @@ -208,13 +229,7 @@ async function assertSecurePath(params: { if (!isAbsolutePathname(effectivePath)) { throw new Error(`${params.label} resolved symlink target must be an absolute path.`); } - stat = await safeStat(effectivePath); - if (!stat.ok) { - throw new Error(`${params.label} is not readable: ${effectivePath}`); - } - if (stat.isDir) { - throw new Error(`${params.label} must be a file: ${effectivePath}`); - } + stat = await readFileStatOrThrow(effectivePath, params.label); if (stat.isSymlink) { throw new Error(`${params.label} symlink target must not be a symlink: ${effectivePath}`); } @@ -372,14 +387,10 @@ async function resolveFileRefs(params: { cache: params.cache, }); } catch (err) { - if (isSecretResolutionError(err)) { - throw err; - } - throw providerResolutionError({ + throwUnknownProviderResolutionError({ source: "file", provider: params.providerName, - message: describeUnknownError(err), - cause: err, + err, }); } const mode = params.providerConfig.mode ?? "json"; @@ -664,14 +675,10 @@ async function resolveExecRefs(params: { allowSymlinkPath: params.providerConfig.allowSymlinkCommand, }); } catch (err) { - if (isSecretResolutionError(err)) { - throw err; - } - throw providerResolutionError({ + throwUnknownProviderResolutionError({ source: "exec", provider: params.providerName, - message: describeUnknownError(err), - cause: err, + err, }); } @@ -724,14 +731,10 @@ async function resolveExecRefs(params: { maxOutputBytes, }); } catch (err) { - if (isSecretResolutionError(err)) { - throw err; - } - throw providerResolutionError({ + throwUnknownProviderResolutionError({ source: "exec", provider: params.providerName, - message: describeUnknownError(err), - cause: err, + err, }); } if (result.termination === "timeout") { @@ -765,14 +768,10 @@ async function resolveExecRefs(params: { jsonOnly, }); } catch (err) { - if (isSecretResolutionError(err)) { - throw err; - } - throw providerResolutionError({ + throwUnknownProviderResolutionError({ source: "exec", provider: params.providerName, - message: describeUnknownError(err), - cause: err, + err, }); } const resolved = new Map(); @@ -822,14 +821,10 @@ async function resolveProviderRefs(params: { message: `Unsupported secret provider source "${String((params.providerConfig as { source?: unknown }).source)}".`, }); } catch (err) { - if (isSecretResolutionError(err)) { - throw err; - } - throw providerResolutionError({ + throwUnknownProviderResolutionError({ source: params.source, provider: params.providerName, - message: describeUnknownError(err), - cause: err, + err, }); } } diff --git a/src/secrets/storage-scan.ts b/src/secrets/storage-scan.ts index 15c02f1922c..ccbfc544f6d 100644 --- a/src/secrets/storage-scan.ts +++ b/src/secrets/storage-scan.ts @@ -1,49 +1,16 @@ import fs from "node:fs"; import path from "node:path"; -import { listAgentIds, resolveAgentDir } from "../agents/agent-scope.js"; -import { resolveAuthStorePath } from "../agents/auth-profiles/paths.js"; import type { OpenClawConfig } from "../config/config.js"; import { resolveUserPath } from "../utils.js"; +import { listAuthProfileStorePaths as listAuthProfileStorePathsFromAuthStorePaths } from "./auth-store-paths.js"; +import { parseEnvValue } from "./shared.js"; export function parseEnvAssignmentValue(raw: string): string { - const trimmed = raw.trim(); - if ( - (trimmed.startsWith('"') && trimmed.endsWith('"')) || - (trimmed.startsWith("'") && trimmed.endsWith("'")) - ) { - return trimmed.slice(1, -1); - } - return trimmed; + return parseEnvValue(raw); } export function listAuthProfileStorePaths(config: OpenClawConfig, stateDir: string): string[] { - const paths = new Set(); - // Scope default auth store discovery to the provided stateDir instead of - // ambient process env, so scans do not include unrelated host-global stores. - paths.add(path.join(resolveUserPath(stateDir), "agents", "main", "agent", "auth-profiles.json")); - - const agentsRoot = path.join(resolveUserPath(stateDir), "agents"); - if (fs.existsSync(agentsRoot)) { - for (const entry of fs.readdirSync(agentsRoot, { withFileTypes: true })) { - if (!entry.isDirectory()) { - continue; - } - paths.add(path.join(agentsRoot, entry.name, "agent", "auth-profiles.json")); - } - } - - for (const agentId of listAgentIds(config)) { - if (agentId === "main") { - paths.add( - path.join(resolveUserPath(stateDir), "agents", "main", "agent", "auth-profiles.json"), - ); - continue; - } - const agentDir = resolveAgentDir(config, agentId); - paths.add(resolveUserPath(resolveAuthStorePath(agentDir))); - } - - return [...paths]; + return listAuthProfileStorePathsFromAuthStorePaths(config, stateDir); } export function listLegacyAuthJsonPaths(stateDir: string): string[] { diff --git a/src/secrets/target-registry-query.ts b/src/secrets/target-registry-query.ts index 5d46020d3b8..fcfdc694f85 100644 --- a/src/secrets/target-registry-query.ts +++ b/src/secrets/target-registry-query.ts @@ -74,6 +74,73 @@ function buildAuthProfileTargetIdIndex(): Map): Set | null { + if (targetIds === undefined) { + return null; + } + return new Set( + Array.from(targetIds) + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0), + ); +} + +function resolveDiscoveryEntries(params: { + allowedTargetIds: Set | null; + defaultEntries: CompiledTargetRegistryEntry[]; + entriesById: Map; +}): CompiledTargetRegistryEntry[] { + if (params.allowedTargetIds === null) { + return params.defaultEntries; + } + return Array.from(params.allowedTargetIds).flatMap( + (targetId) => params.entriesById.get(targetId) ?? [], + ); +} + +function discoverSecretTargetsFromEntries( + source: unknown, + discoveryEntries: CompiledTargetRegistryEntry[], +): DiscoveredConfigSecretTarget[] { + const out: DiscoveredConfigSecretTarget[] = []; + const seen = new Set(); + + for (const entry of discoveryEntries) { + const expanded = expandPathTokens(source, entry.pathTokens); + for (const match of expanded) { + const resolved = toResolvedPlanTarget(entry, match.segments, match.captures); + if (!resolved) { + continue; + } + const key = `${entry.id}:${resolved.pathSegments.join(".")}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + const refValue = resolved.refPathSegments + ? getPath(source, resolved.refPathSegments) + : undefined; + out.push({ + entry, + path: resolved.pathSegments.join("."), + pathSegments: resolved.pathSegments, + ...(resolved.refPathSegments + ? { + refPathSegments: resolved.refPathSegments, + refPath: resolved.refPathSegments.join("."), + } + : {}), + value: match.value, + ...(resolved.providerId ? { providerId: resolved.providerId } : {}), + ...(resolved.accountId ? { accountId: resolved.accountId } : {}), + ...(resolved.refPathSegments ? { refValue } : {}), + }); + } + } + + return out; +} + function toResolvedPlanTarget( entry: CompiledTargetRegistryEntry, pathSegments: string[], @@ -182,58 +249,13 @@ export function discoverConfigSecretTargetsByIds( config: OpenClawConfig, targetIds?: Iterable, ): DiscoveredConfigSecretTarget[] { - const allowedTargetIds = - targetIds === undefined - ? null - : new Set( - Array.from(targetIds) - .map((entry) => entry.trim()) - .filter((entry) => entry.length > 0), - ); - const out: DiscoveredConfigSecretTarget[] = []; - const seen = new Set(); - - const discoveryEntries = - allowedTargetIds === null - ? OPENCLAW_COMPILED_SECRET_TARGETS - : Array.from(allowedTargetIds).flatMap( - (targetId) => OPENCLAW_TARGETS_BY_ID.get(targetId) ?? [], - ); - - for (const entry of discoveryEntries) { - const expanded = expandPathTokens(config, entry.pathTokens); - for (const match of expanded) { - const resolved = toResolvedPlanTarget(entry, match.segments, match.captures); - if (!resolved) { - continue; - } - const key = `${entry.id}:${resolved.pathSegments.join(".")}`; - if (seen.has(key)) { - continue; - } - seen.add(key); - const refValue = resolved.refPathSegments - ? getPath(config, resolved.refPathSegments) - : undefined; - out.push({ - entry, - path: resolved.pathSegments.join("."), - pathSegments: resolved.pathSegments, - ...(resolved.refPathSegments - ? { - refPathSegments: resolved.refPathSegments, - refPath: resolved.refPathSegments.join("."), - } - : {}), - value: match.value, - ...(resolved.providerId ? { providerId: resolved.providerId } : {}), - ...(resolved.accountId ? { accountId: resolved.accountId } : {}), - ...(resolved.refPathSegments ? { refValue } : {}), - }); - } - } - - return out; + const allowedTargetIds = normalizeAllowedTargetIds(targetIds); + const discoveryEntries = resolveDiscoveryEntries({ + allowedTargetIds, + defaultEntries: OPENCLAW_COMPILED_SECRET_TARGETS, + entriesById: OPENCLAW_TARGETS_BY_ID, + }); + return discoverSecretTargetsFromEntries(config, discoveryEntries); } export function discoverAuthProfileSecretTargets(store: unknown): DiscoveredConfigSecretTarget[] { @@ -244,58 +266,13 @@ export function discoverAuthProfileSecretTargetsByIds( store: unknown, targetIds?: Iterable, ): DiscoveredConfigSecretTarget[] { - const allowedTargetIds = - targetIds === undefined - ? null - : new Set( - Array.from(targetIds) - .map((entry) => entry.trim()) - .filter((entry) => entry.length > 0), - ); - const out: DiscoveredConfigSecretTarget[] = []; - const seen = new Set(); - - const discoveryEntries = - allowedTargetIds === null - ? AUTH_PROFILES_COMPILED_SECRET_TARGETS - : Array.from(allowedTargetIds).flatMap( - (targetId) => AUTH_PROFILES_TARGETS_BY_ID.get(targetId) ?? [], - ); - - for (const entry of discoveryEntries) { - const expanded = expandPathTokens(store, entry.pathTokens); - for (const match of expanded) { - const resolved = toResolvedPlanTarget(entry, match.segments, match.captures); - if (!resolved) { - continue; - } - const key = `${entry.id}:${resolved.pathSegments.join(".")}`; - if (seen.has(key)) { - continue; - } - seen.add(key); - const refValue = resolved.refPathSegments - ? getPath(store, resolved.refPathSegments) - : undefined; - out.push({ - entry, - path: resolved.pathSegments.join("."), - pathSegments: resolved.pathSegments, - ...(resolved.refPathSegments - ? { - refPathSegments: resolved.refPathSegments, - refPath: resolved.refPathSegments.join("."), - } - : {}), - value: match.value, - ...(resolved.providerId ? { providerId: resolved.providerId } : {}), - ...(resolved.accountId ? { accountId: resolved.accountId } : {}), - ...(resolved.refPathSegments ? { refValue } : {}), - }); - } - } - - return out; + const allowedTargetIds = normalizeAllowedTargetIds(targetIds); + const discoveryEntries = resolveDiscoveryEntries({ + allowedTargetIds, + defaultEntries: AUTH_PROFILES_COMPILED_SECRET_TARGETS, + entriesById: AUTH_PROFILES_TARGETS_BY_ID, + }); + return discoverSecretTargetsFromEntries(store, discoveryEntries); } export function listAuthProfileSecretTargetEntries(): SecretTargetRegistryEntry[] { diff --git a/src/shared/frontmatter.ts b/src/shared/frontmatter.ts index 91e49017be6..ce042b18762 100644 --- a/src/shared/frontmatter.ts +++ b/src/shared/frontmatter.ts @@ -137,3 +137,18 @@ export function parseOpenClawManifestInstallBase( } return spec; } + +export function applyOpenClawManifestInstallCommonFields< + T extends { id?: string; label?: string; bins?: string[] }, +>(spec: T, parsed: Pick): T { + if (parsed.id) { + spec.id = parsed.id; + } + if (parsed.label) { + spec.label = parsed.label; + } + if (parsed.bins) { + spec.bins = parsed.bins; + } + return spec; +} diff --git a/src/slack/monitor/events/interactions.ts b/src/slack/monitor/events/interactions.ts index 4f92df32be7..deca761dd52 100644 --- a/src/slack/monitor/events/interactions.ts +++ b/src/slack/monitor/events/interactions.ts @@ -37,26 +37,7 @@ type SelectOption = { text?: { text?: string }; }; -type InteractionSelectionFields = { - actionType?: string; - blockId?: string; - inputKind?: "text" | "number" | "email" | "url" | "rich_text"; - value?: string; - selectedValues?: string[]; - selectedUsers?: string[]; - selectedChannels?: string[]; - selectedConversations?: string[]; - selectedLabels?: string[]; - selectedDate?: string; - selectedTime?: string; - selectedDateTime?: number; - inputValue?: string; - inputNumber?: number; - inputEmail?: string; - inputUrl?: string; - richTextValue?: unknown; - richTextPreview?: string; -}; +type InteractionSelectionFields = Partial; type InteractionSummary = InteractionSelectionFields & { interactionType?: "block_action" | "view_submission" | "view_closed"; diff --git a/src/test-utils/imessage-test-plugin.ts b/src/test-utils/imessage-test-plugin.ts index 104d8ca847f..5a072141644 100644 --- a/src/test-utils/imessage-test-plugin.ts +++ b/src/test-utils/imessage-test-plugin.ts @@ -1,6 +1,7 @@ import { imessageOutbound } from "../channels/plugins/outbound/imessage.js"; import type { ChannelOutboundAdapter, ChannelPlugin } from "../channels/plugins/types.js"; import { normalizeIMessageHandle } from "../imessage/targets.js"; +import { collectStatusIssuesFromLastError } from "../plugin-sdk/status-helpers.js"; export const createIMessageTestPlugin = (params?: { outbound?: ChannelOutboundAdapter; @@ -20,21 +21,7 @@ export const createIMessageTestPlugin = (params?: { resolveAccount: () => ({}), }, status: { - collectStatusIssues: (accounts) => - accounts.flatMap((account) => { - const lastError = typeof account.lastError === "string" ? account.lastError.trim() : ""; - if (!lastError) { - return []; - } - return [ - { - channel: "imessage", - accountId: account.accountId, - kind: "runtime", - message: `Channel error: ${lastError}`, - }, - ]; - }), + collectStatusIssues: (accounts) => collectStatusIssuesFromLastError("imessage", accounts), }, outbound: params?.outbound ?? imessageOutbound, messaging: {