Compare commits

..

1 Commits

Author SHA1 Message Date
Vincent Koc
486d285c24 fix(gateway): advertise exec approval node commands 2026-05-30 12:31:06 +02:00
587 changed files with 5396 additions and 19973 deletions

View File

@@ -6,10 +6,18 @@ class: standard
capacity:
market: spot
strategy: most-available
# Fail closed instead of silently falling back to on-demand while the
# Azure-backed billing account is the default runner path.
fallback: spot-only
fallback: on-demand-after-120s
hints: true
availabilityZones:
- eu-west-1a
- eu-west-1b
- eu-west-1c
regions:
- eu-west-1
- eu-west-2
- eu-central-1
- us-east-1
- us-west-2
actions:
workflow: .github/workflows/crabbox-hydrate.yml
# Default AWS hydration uses local Actions replay. Use
@@ -29,8 +37,6 @@ blacksmith:
job: check
ref: main
aws:
# AWS-specific overrides still pin direct `--provider aws` runs without
# leaking AWS region names into the Azure default capacity fallback list.
region: eu-west-1
rootGB: 400
sync:

View File

@@ -601,7 +601,7 @@ jobs:
uses: actions/cache@v5
with:
path: .artifacts/build-all-cache
key: ${{ runner.os }}-build-all-v3-${{ hashFiles('package.json', 'pnpm-lock.yaml', 'npm-shrinkwrap.json', 'packages/plugin-sdk/package.json', 'packages/llm-core/package.json', 'packages/model-catalog-core/package.json', 'packages/memory-host-sdk/package.json', 'scripts/build-all.mjs', 'scripts/write-plugin-sdk-entry-dts.ts', 'scripts/lib/plugin-sdk-entries.mjs', 'tsconfig.json', 'tsconfig.plugin-sdk.dts.json', 'src/plugin-sdk/**', 'packages/llm-core/src/**', 'packages/model-catalog-core/src/**', 'packages/memory-host-sdk/src/**', 'src/types/**', 'src/video-generation/dashscope-compatible.ts', 'src/video-generation/types.ts', 'scripts/copy-export-html-templates.ts', 'scripts/lib/copy-assets.ts', 'src/auto-reply/reply/export-html/**') }}
key: ${{ runner.os }}-build-all-v3-${{ hashFiles('package.json', 'pnpm-lock.yaml', 'npm-shrinkwrap.json', 'packages/plugin-sdk/package.json', 'packages/llm-core/package.json', 'packages/memory-host-sdk/package.json', 'scripts/build-all.mjs', 'scripts/write-plugin-sdk-entry-dts.ts', 'scripts/lib/plugin-sdk-entries.mjs', 'tsconfig.json', 'tsconfig.plugin-sdk.dts.json', 'src/plugin-sdk/**', 'packages/llm-core/src/**', 'packages/memory-host-sdk/src/**', 'src/types/**', 'src/video-generation/dashscope-compatible.ts', 'src/video-generation/types.ts', 'scripts/copy-export-html-templates.ts', 'scripts/lib/copy-assets.ts', 'src/auto-reply/reply/export-html/**') }}
restore-keys: |
${{ runner.os }}-build-all-v3-
@@ -1403,7 +1403,7 @@ jobs:
packages/plugin-sdk/dist
extensions/*/dist/.boundary-tsc.tsbuildinfo
extensions/*/dist/.boundary-tsc.stamp
key: ${{ runner.os }}-extension-package-boundary-v1-${{ hashFiles('tsconfig.json', 'tsconfig.plugin-sdk.dts.json', 'packages/plugin-sdk/tsconfig.json', 'packages/llm-core/package.json', 'packages/model-catalog-core/package.json', 'scripts/check-extension-package-tsc-boundary.mjs', 'scripts/prepare-extension-package-boundary-artifacts.mjs', 'scripts/write-plugin-sdk-entry-dts.ts', 'scripts/lib/plugin-sdk-entrypoints.json', 'scripts/lib/plugin-sdk-entries.mjs', 'src/plugin-sdk/**', 'src/auto-reply/**', 'packages/llm-core/src/**', 'packages/model-catalog-core/src/**', 'src/video-generation/dashscope-compatible.ts', 'src/video-generation/types.ts', 'src/types/**', 'extensions/**', 'extensions/tsconfig.package-boundary*.json', 'package.json', 'pnpm-lock.yaml') }}
key: ${{ runner.os }}-extension-package-boundary-v1-${{ hashFiles('tsconfig.json', 'tsconfig.plugin-sdk.dts.json', 'packages/plugin-sdk/tsconfig.json', 'packages/llm-core/package.json', 'scripts/check-extension-package-tsc-boundary.mjs', 'scripts/prepare-extension-package-boundary-artifacts.mjs', 'scripts/write-plugin-sdk-entry-dts.ts', 'scripts/lib/plugin-sdk-entrypoints.json', 'scripts/lib/plugin-sdk-entries.mjs', 'src/plugin-sdk/**', 'src/auto-reply/**', 'packages/llm-core/src/**', 'src/video-generation/dashscope-compatible.ts', 'src/video-generation/types.ts', 'src/types/**', 'extensions/**', 'extensions/tsconfig.package-boundary*.json', 'package.json', 'pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-extension-package-boundary-v1-
@@ -1420,22 +1420,14 @@ jobs:
find src \
-type f \( -name '*.ts' -o -name '*.tsx' -o -name '*.mts' -o -name '*.cts' -o -name '*.js' -o -name '*.mjs' -o -name '*.json' \) \
-exec touch -t 200001010000 {} +
if [ -d packages/llm-core/src ]; then
find packages/llm-core/src \
-type f \( -name '*.ts' -o -name '*.tsx' -o -name '*.mts' -o -name '*.cts' -o -name '*.js' -o -name '*.mjs' -o -name '*.json' \) \
-exec touch -t 200001010000 {} +
fi
if [ -d packages/model-catalog-core/src ]; then
find packages/model-catalog-core/src \
-type f \( -name '*.ts' -o -name '*.tsx' -o -name '*.mts' -o -name '*.cts' -o -name '*.js' -o -name '*.mjs' -o -name '*.json' \) \
-exec touch -t 200001010000 {} +
fi
cache_inputs=(
find packages/llm-core/src \
-type f \( -name '*.ts' -o -name '*.tsx' -o -name '*.mts' -o -name '*.cts' -o -name '*.js' -o -name '*.mjs' -o -name '*.json' \) \
-exec touch -t 200001010000 {} +
touch -t 200001010000 \
tsconfig.json \
tsconfig.plugin-sdk.dts.json \
packages/plugin-sdk/tsconfig.json \
packages/llm-core/package.json \
packages/model-catalog-core/package.json \
scripts/check-extension-package-tsc-boundary.mjs \
scripts/prepare-extension-package-boundary-artifacts.mjs \
scripts/write-plugin-sdk-entry-dts.ts \
@@ -1443,12 +1435,6 @@ jobs:
scripts/lib/plugin-sdk-entries.mjs \
package.json \
pnpm-lock.yaml
)
for cache_input in "${cache_inputs[@]}"; do
if [ -e "$cache_input" ]; then
touch -t 200001010000 "$cache_input"
fi
done
- name: Run additional check shard
env:

View File

@@ -24,7 +24,6 @@ Docs: https://docs.openclaw.ai
### Fixes
- CLI: keep `plugins list --json` on the snapshot-only path so plugin sweeps avoid loading the full runtime status graph.
- Plugins: make PixVerse external-plugin ClawHub metadata explicit and keep it out of bundled dist builds.
- Providers: bound generated media downloads from OpenAI, Runway, xAI, MiniMax, BytePlus, DashScope-compatible, FAL, OpenRouter, Google, Vydra, and Comfy providers.
- Providers: cap GitHub Copilot OAuth request timeouts before creating abort signals.
@@ -34,20 +33,6 @@ Docs: https://docs.openclaw.ai
- Security/config parsing: reject unsafe OAuth/token lifetimes, retry-after delays, inbound timestamps, response body sizes, command timeout config, sandbox observer token TTLs, and gateway WebSocket calls after close.
- Providers/media: cap local service, model, usage, queue, generated media, TTS, music, workflow polling, and provider OAuth request timers across hosted and local providers.
- Release/CI/E2E: bound release candidate reads, beta smoke REST calls, changelog restore, kitchen-sink and bundled plugin readiness probes, secret-provider probes, Vitest routing, and mainline test flakes. (#88127, #88137, #88155, #88160)
- Release/CI/E2E: run the secret-provider integration proof through the repo pnpm runner so native macOS and Windows validation use the hydrated package-manager shim.
- Release/CI/E2E: run the Telegram desktop proof gateway through the repo pnpm runner so native macOS proof uses the hydrated package-manager shim.
- Docs/CI: run Mintlify anchor checks through the repo pnpm runner so docs link validation works when pnpm is only available through the hydrated package-manager shim.
- Agents: keep configured fallback model metadata typed so provider params, context-token caps, and media input limits do not break changed-gate typechecks.
- CI/Crabbox: keep default runner capacity spot-only and provider-neutral so OpenClaw remote validation does not silently fall back to on-demand leases or stale AWS region hints.
- CI/Crabbox: route Crabbox wrapper and Testbox workflow edits to their regression tests so changed-test gates do not silently run zero specs.
- CI/workflows: route workflow sanity helper edits to their guard tests and cover composite-action input interpolation checks.
- CI/tooling: route CI scope, dependency, changelog, and docs helper edits to their owner tests instead of silently skipping changed-test coverage.
- CI/tooling: route package, release, and install helper edits to their owner tests so changed-test gates cover publish and installer script changes.
- CI/tooling: route shared script library edits through their owner tests so lock, process, safety, and scan helpers do not skip changed-test coverage.
- CI/tooling: skip expensive import-graph scans once a changed diff already requires broad fallback, keeping local changed-test planning fast while still collecting explicit owner tests.
- CI/tooling: route script edits through conventional owner tests when matching `test/scripts` or `src/scripts` coverage already exists.
- CI/tooling: honor option terminators in the memory FD repro script so follow-on arguments are not reparsed.
- Release/CI/E2E: honor option terminators across release, Parallels smoke, plugin gauntlet, and extension-memory scripts.
- Performance: reuse prepared provider handles, strict tool schemas, gateway runtime metadata, session maintenance config, plugin metadata, bundled skill allowlists, package-local plugin artifacts, and single-entry store writes.
## 2026.5.28

View File

@@ -5,8 +5,6 @@
Maintenance update for the current OpenClaw release.
- Added hosted push relay defaults, realtime Talk playback, and safer WebSocket ping handling for mobile sessions.
- Updated App Store screenshots to cover Gateway pairing, Command, Chat, Talk, Agent, and Settings flows.
- Highlighted realtime Talk relay, Gateway connection status, node capabilities, push wake, and privacy controls.
## 2026.5.28 - 2026-05-28

View File

@@ -29,14 +29,6 @@ def clear_empty_env_var(key)
ENV.delete(key) unless env_present?(ENV[key])
end
def screenshot_upload_requested?
ENV["DELIVER_SCREENSHOTS"] == "1"
end
def screenshot_paths
Dir[File.join(__dir__, "screenshots", "**", "*.png")]
end
def maybe_decode_hex_keychain_secret(value)
return value unless env_present?(value)
@@ -322,7 +314,6 @@ platform :ios do
desc "Upload App Store metadata (and optionally screenshots)"
lane :metadata do
sync_ios_versioning!
version_metadata = read_ios_version_metadata
api_key = asc_api_key
clear_empty_env_var("APP_STORE_CONNECT_API_KEY_PATH")
app_identifier = ENV["ASC_APP_IDENTIFIER"]
@@ -330,21 +321,11 @@ platform :ios do
app_identifier = nil unless env_present?(app_identifier)
app_id = nil unless env_present?(app_id)
if screenshot_upload_requested? && screenshot_paths.empty?
UI.user_error!("DELIVER_SCREENSHOTS=1 but no PNG screenshots were found under apps/ios/fastlane/screenshots.")
end
deliver_options = {
api_key: api_key,
force: true,
app_version: version_metadata[:short_version],
copyright: "2026 OpenClaw",
primary_category: "PRODUCTIVITY",
secondary_category: "UTILITIES",
skip_screenshots: !screenshot_upload_requested?,
skip_screenshots: ENV["DELIVER_SCREENSHOTS"] != "1",
skip_metadata: ENV["DELIVER_METADATA"] != "1",
skip_binary_upload: true,
overwrite_screenshots: screenshot_upload_requested?,
run_precheck_before_submit: false
}
deliver_options[:app_identifier] = app_identifier if app_identifier

View File

@@ -1,19 +1,18 @@
OpenClaw is a personal AI assistant you run on your own devices.
Pair this iPhone app with your OpenClaw Gateway to use your phone as a secure node for chat, voice, approvals, sharing, and device-aware automation.
Pair this iPhone app with your OpenClaw Gateway to connect your phone as a secure node for voice, camera, and device automation.
What you can do:
- Pair with your private OpenClaw Gateway by QR code or setup code
- Chat with your assistant from iPhone
- Use realtime Talk mode and push-to-talk
- Review Gateway action approvals from your phone
- Use voice wake and push-to-talk
- Capture photos and short clips on request
- Record screen snippets for troubleshooting and workflows
- Share text, links, and media directly from iOS into OpenClaw
- Enable device capabilities such as camera, screen, location, photos, contacts, calendar, and reminders when you choose
- Receive push wakes and node status updates for connected workflows
- Run location-aware and device-aware automations
OpenClaw is local-first: you control your gateway, keys, configuration, and permissions. Device access is managed by iOS permissions and can be enabled only for the capabilities you want to use.
OpenClaw is local-first: you control your gateway, keys, and configuration.
Getting started:
1) Set up your OpenClaw Gateway
2) Open the iOS app and pair with your gateway
3) Start using chat, Talk mode, approvals, and automations from your phone
3) Start using commands and automations from your phone

View File

@@ -1 +1 @@
openclaw,ai assistant,local ai,iphone ai,voice assistant,automation,gateway,chat,agent
openclaw,ai assistant,local ai,voice assistant,automation,gateway,chat,agent,node

View File

@@ -1 +1 @@
Pair your iPhone with your OpenClaw Gateway for chat, realtime voice, approvals, device capabilities, and private automation.
Run OpenClaw from your iPhone: pair with your own gateway, trigger automations, and use voice, camera, and share actions.

View File

@@ -1,5 +1,3 @@
Maintenance update for the current OpenClaw release.
- Added hosted push relay defaults, realtime Talk playback, and safer WebSocket ping handling for mobile sessions.
- Updated App Store screenshots to cover Gateway pairing, Command, Chat, Talk, Agent, and Settings flows.
- Highlighted realtime Talk relay, Gateway connection status, node capabilities, push wake, and privacy controls.

View File

@@ -326,8 +326,6 @@ Use `--link` to avoid copying a local directory (adds to `plugins.load.paths`):
openclaw plugins install -l ./my-plugin
```
Standalone plugin files must be listed in `plugins.load.paths` rather than placed directly in `~/.openclaw/extensions` or `<workspace>/.openclaw/extensions`. Those auto-discovered roots load plugin package or bundle directories, while top-level script files are treated as local helpers and skipped.
<Note>
`--force` is not supported with `--link` because linked installs reuse the source path instead of copying over a managed install target.

View File

@@ -214,8 +214,7 @@ See [MCP](/cli/mcp#openclaw-as-an-mcp-client-registry) and
}
```
- Loaded from package or bundle directories under `~/.openclaw/extensions` and `<workspace>/.openclaw/extensions`, plus files or directories listed in `plugins.load.paths`.
- Put standalone plugin files in `plugins.load.paths`; auto-discovered extension roots ignore top-level `.js`, `.mjs`, and `.ts` files so helper scripts in those roots do not block startup.
- Loaded from `~/.openclaw/extensions`, `<workspace>/.openclaw/extensions`, plus `plugins.load.paths`.
- Discovery accepts native OpenClaw plugins plus compatible Codex bundles and Claude bundles, including manifestless Claude default-layout bundles.
- **Config changes require a gateway restart.**
- `allow`: optional allowlist (only listed plugins load). `deny` wins.

View File

@@ -7,7 +7,7 @@ read_when:
title: "iOS app"
---
Availability: iPhone app builds are distributed through Apple channels when enabled for a release. Local development builds can also run from source.
Availability: internal preview. The iOS app is not publicly distributed yet.
## What it does

View File

@@ -108,18 +108,6 @@ Workboard also exposes optional agent tools for board-aware workflows:
final summaries, proof, artifacts, created-card manifests, and blocker
reasons. Created-card manifests must reference cards linked back to the
completed card, which keeps phantom children out of summaries.
- `workboard_board_create`, `workboard_board_archive`, and
`workboard_board_delete` manage persisted board metadata such as display name,
description, archive state, and default workspace.
- `workboard_runs` returns the persisted run-attempt history stored on a card.
- `workboard_specify` turns a rough triage or backlog card into a clarified
`todo` card and records the specification summary on the card.
- `workboard_decompose` fans a parent orchestration card into linked children,
inherits board and tenant metadata, and can complete the parent with a
created-card manifest.
- `workboard_notify_subscribe`, `workboard_notify_list`, and
`workboard_notify_unsubscribe` manage notification subscriptions in plugin
state so operators and agents can discover durable notification intent.
- `workboard_boards`, `workboard_stats`, `workboard_promote`,
`workboard_reassign`, `workboard_reclaim`, `workboard_comment`,
`workboard_proof`, `workboard_unblock`, and `workboard_dispatch` let an agent
@@ -131,12 +119,6 @@ Claimed cards reject agent-tool mutations from other agents unless the caller
has the claim token returned by `workboard_claim`. Dashboard operators still use
the normal Gateway RPC surface and can recover or reassign cards.
Workboard stores all durable board data through the plugin SQLite key-value
store. Cards live in `workboard.cards`, board metadata in `workboard.boards`,
and notification subscriptions in `workboard.notify`. Run history, comments,
proof, artifacts, diagnostics, dependencies, lifecycle events, and automation
metadata stay on the card record so a card export remains self-contained.
Workboard diagnostics are computed from local card metadata. The built-in checks
flag assigned cards that wait too long, running cards without recent heartbeat,
blocked cards that need attention, repeated failures, done cards without proof,
@@ -144,9 +126,9 @@ and running cards that only have a loose session link.
Dispatch is intentionally Gateway-local. It does not spawn arbitrary operating
system processes; normal OpenClaw sessions still own execution. A dispatch nudge
promotes dependency-ready cards, records dispatch metadata on ready cards,
blocks expired claims or timed-out runs, and leaves durable notification
subscriptions for the caller that delivers notifications.
promotes dependency-ready cards, records dispatch metadata on ready cards, and
blocks expired claims or timed-out runs so operators can recover them from the
board.
## Session lifecycle sync

View File

@@ -4130,50 +4130,6 @@ describe("active-memory plugin", () => {
expect(cached?.summary).toBe("memory 1");
});
it("drops cached active-memory results when the current clock is not a valid date timestamp", () => {
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000);
const cacheKey = testing.buildCacheKey({
agentId: "main",
sessionKey: "agent:main:invalid-clock-cache",
query: "cache invalid clock prompt",
});
testing.setCachedResult(
cacheKey,
{
status: "ok",
elapsedMs: 1,
rawReply: "memory",
summary: "memory",
},
15_000,
);
nowSpy.mockReturnValue(Number.NaN);
expect(testing.getCachedResult(cacheKey)).toBeUndefined();
});
it("does not cache active-memory results when the expiry timestamp would exceed the valid date range", () => {
vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_000);
const cacheKey = testing.buildCacheKey({
agentId: "main",
sessionKey: "agent:main:overflow-cache",
query: "cache overflow prompt",
});
testing.setCachedResult(
cacheKey,
{
status: "ok",
elapsedMs: 1,
rawReply: "memory",
summary: "memory",
},
15_000,
);
expect(testing.getCachedResult(cacheKey)).toBeUndefined();
});
it("skips recall after consecutive timeouts when circuit breaker trips (#74054)", async () => {
const CONFIGURED_TIMEOUT_MS = 25;
testing.setMinimumTimeoutMsForTests(1);

View File

@@ -13,11 +13,7 @@ import {
} from "openclaw/plugin-sdk/agent-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { closeActiveMemorySearchManager } from "openclaw/plugin-sdk/memory-host-search";
import {
asDateTimestampMs,
parseStrictPositiveInteger,
resolveExpiresAtMsFromDurationMs,
} from "openclaw/plugin-sdk/number-runtime";
import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
import {
resolveLivePluginConfigObject,
resolvePluginConfigObject,
@@ -1364,12 +1360,7 @@ function getCachedResult(cacheKey: string): ActiveRecallResult | undefined {
if (!cached) {
return undefined;
}
const now = asDateTimestampMs(Date.now());
if (
now === undefined ||
asDateTimestampMs(cached.expiresAt) === undefined ||
cached.expiresAt <= now
) {
if (cached.expiresAt <= Date.now()) {
activeRecallCache.delete(cacheKey);
return undefined;
}
@@ -1377,27 +1368,19 @@ function getCachedResult(cacheKey: string): ActiveRecallResult | undefined {
}
function setCachedResult(cacheKey: string, result: ActiveRecallResult, ttlMs: number): void {
const rawNow = Date.now();
const now = asDateTimestampMs(rawNow);
const now = Date.now();
if (
activeRecallCache.size >= DEFAULT_MAX_CACHE_ENTRIES ||
(now !== undefined && now - lastActiveRecallCacheSweepAt >= CACHE_SWEEP_INTERVAL_MS)
now - lastActiveRecallCacheSweepAt >= CACHE_SWEEP_INTERVAL_MS
) {
sweepExpiredCacheEntries(now);
if (now !== undefined) {
lastActiveRecallCacheSweepAt = now;
}
}
const expiresAt = resolveExpiresAtMsFromDurationMs(ttlMs, { nowMs: rawNow });
if (expiresAt === undefined) {
activeRecallCache.delete(cacheKey);
return;
lastActiveRecallCacheSweepAt = now;
}
if (activeRecallCache.has(cacheKey)) {
activeRecallCache.delete(cacheKey);
}
activeRecallCache.set(cacheKey, {
expiresAt,
expiresAt: now + ttlMs,
result,
});
while (activeRecallCache.size > DEFAULT_MAX_CACHE_ENTRIES) {
@@ -1409,13 +1392,9 @@ function setCachedResult(cacheKey: string, result: ActiveRecallResult, ttlMs: nu
}
}
function sweepExpiredCacheEntries(now = asDateTimestampMs(Date.now())): void {
if (now === undefined) {
activeRecallCache.clear();
return;
}
function sweepExpiredCacheEntries(now = Date.now()): void {
for (const [cacheKey, cached] of activeRecallCache.entries()) {
if (asDateTimestampMs(cached.expiresAt) === undefined || cached.expiresAt <= now) {
if (cached.expiresAt <= now) {
activeRecallCache.delete(cacheKey);
}
}

View File

@@ -230,32 +230,6 @@ describe("bedrock mantle discovery", () => {
expect(getCachedIamToken("us-east-1")).toBeUndefined();
});
it("does not cache generated IAM tokens when ttl expiry overflows", async () => {
const tokenProvider = vi
.fn<() => Promise<string>>()
.mockResolvedValueOnce("bedrock-overflow-token-1") // pragma: allowlist secret
.mockResolvedValueOnce("bedrock-overflow-token-2"); // pragma: allowlist secret
const tokenProviderFactory = createTokenProviderFactory(tokenProvider);
await expect(
generateBearerTokenFromIam({
region: "us-east-1",
now: () => 8_640_000_000_000_000,
tokenProviderFactory,
}),
).resolves.toBe("bedrock-overflow-token-1");
expect(getCachedIamToken("us-east-1")).toBeUndefined();
await expect(
generateBearerTokenFromIam({
region: "us-east-1",
now: () => 8_640_000_000_000_000,
tokenProviderFactory,
}),
).resolves.toBe("bedrock-overflow-token-2");
expect(tokenProvider).toHaveBeenCalledTimes(2);
});
// ---------------------------------------------------------------------------
// Model discovery
// ---------------------------------------------------------------------------

View File

@@ -1,9 +1,5 @@
import { createSubsystemLogger } from "openclaw/plugin-sdk/core";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import {
isFutureDateTimestampMs,
resolveExpiresAtMsFromDurationMs,
} from "openclaw/plugin-sdk/number-runtime";
import type {
ModelDefinitionConfig,
ModelProviderConfig,
@@ -96,10 +92,9 @@ function getCachedIamTokenEntry(
now: number = Date.now(),
): { token: string; expiresAt: number } | undefined {
const cached = iamTokenCache.get(region);
if (cached && isFutureDateTimestampMs(cached.expiresAt, { nowMs: now })) {
if (cached && cached.expiresAt > now) {
return cached;
}
iamTokenCache.delete(region);
return undefined;
}
@@ -128,10 +123,7 @@ export async function generateBearerTokenFromIam(params: {
region: params.region,
expiresInSeconds: 7200, // 2 hours
})();
const expiresAt = resolveExpiresAtMsFromDurationMs(IAM_TOKEN_TTL_MS, { nowMs: now });
if (expiresAt !== undefined) {
iamTokenCache.set(params.region, { token, expiresAt });
}
iamTokenCache.set(params.region, { token, expiresAt: now + IAM_TOKEN_TTL_MS });
return token;
} catch (error) {
log.debug?.("Mantle IAM token generation unavailable", {

View File

@@ -256,28 +256,6 @@ describe("bedrock discovery", () => {
expect(sendMock).toHaveBeenCalledTimes(2);
});
it("skips cache when refreshInterval expiry overflows", async () => {
sendMock
.mockResolvedValueOnce({ modelSummaries: [baseActiveAnthropicSummary] })
.mockResolvedValueOnce({ inferenceProfileSummaries: [] })
.mockResolvedValueOnce({ modelSummaries: [baseActiveAnthropicSummary] })
.mockResolvedValueOnce({ inferenceProfileSummaries: [] });
await discoverBedrockModels({
region: "us-east-1",
config: { refreshInterval: 1 },
now: () => 8_640_000_000_000_000,
clientFactory,
});
await discoverBedrockModels({
region: "us-east-1",
config: { refreshInterval: 1 },
now: () => 8_640_000_000_000_000,
clientFactory,
});
expect(sendMock).toHaveBeenCalledTimes(4);
});
it("skips cache when refreshInterval is 0", async () => {
sendMock
.mockResolvedValueOnce({ modelSummaries: [baseActiveAnthropicSummary] })

View File

@@ -5,10 +5,6 @@ import {
} from "@aws-sdk/client-bedrock";
import { createSubsystemLogger } from "openclaw/plugin-sdk/core";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import {
isFutureDateTimestampMs,
resolveExpiresAtMsFromDurationSeconds,
} from "openclaw/plugin-sdk/number-runtime";
import type {
BedrockDiscoveryConfig,
ModelDefinitionConfig,
@@ -507,16 +503,11 @@ export async function discoverBedrockModels(params: {
if (refreshIntervalSeconds > 0) {
const cached = discoveryCache.get(cacheKey);
if (cached && isFutureDateTimestampMs(cached.expiresAt, { nowMs: now })) {
if (cached.value) {
return cached.value;
}
if (cached.inFlight) {
return cached.inFlight;
}
if (cached?.value && cached.expiresAt > now) {
return cached.value;
}
if (cached) {
discoveryCache.delete(cacheKey);
if (cached?.inFlight) {
return cached.inFlight;
}
}
@@ -590,27 +581,19 @@ export async function discoverBedrockModels(params: {
})();
if (refreshIntervalSeconds > 0) {
const expiresAt = resolveExpiresAtMsFromDurationSeconds(refreshIntervalSeconds, { nowMs: now });
if (expiresAt !== undefined) {
discoveryCache.set(cacheKey, {
expiresAt,
inFlight: discoveryPromise,
});
}
discoveryCache.set(cacheKey, {
expiresAt: now + refreshIntervalSeconds * 1000,
inFlight: discoveryPromise,
});
}
try {
const value = await discoveryPromise;
if (refreshIntervalSeconds > 0) {
const expiresAt = resolveExpiresAtMsFromDurationSeconds(refreshIntervalSeconds, {
nowMs: now,
discoveryCache.set(cacheKey, {
expiresAt: now + refreshIntervalSeconds * 1000,
value,
});
if (expiresAt !== undefined) {
discoveryCache.set(cacheKey, {
expiresAt,
value,
});
}
}
return value;
} catch (error) {

View File

@@ -17,13 +17,6 @@ export {
import { buildAnthropicVertexProvider } from "./provider-catalog.js";
import { hasAnthropicVertexAvailableAuth } from "./region.js";
let streamRuntimeModulePromise: Promise<typeof import("./stream-runtime.js")> | null = null;
const loadStreamRuntimeModule = async () => {
streamRuntimeModulePromise ??= import("./stream-runtime.js");
return await streamRuntimeModulePromise;
};
export function mergeImplicitAnthropicVertexProvider(params: {
existing?: ReturnType<typeof buildAnthropicVertexProvider>;
implicit: ReturnType<typeof buildAnthropicVertexProvider>;
@@ -57,7 +50,7 @@ export function createAnthropicVertexStreamFn(
baseURL?: string,
deps?: AnthropicVertexStreamDeps,
): StreamFn {
const streamFnPromise = loadStreamRuntimeModule().then((runtime) =>
const streamFnPromise = import("./stream-runtime.js").then((runtime) =>
runtime.createAnthropicVertexStreamFn(projectId, region, baseURL, deps),
);
return async (model, context, options) => {
@@ -71,7 +64,7 @@ export function createAnthropicVertexStreamFnForModel(
env: NodeJS.ProcessEnv = process.env,
deps?: AnthropicVertexStreamDeps,
): StreamFn {
const streamFnPromise = loadStreamRuntimeModule().then((runtime) =>
const streamFnPromise = import("./stream-runtime.js").then((runtime) =>
runtime.createAnthropicVertexStreamFnForModel(model, env, deps),
);
return async (...args) => {

View File

@@ -15,15 +15,6 @@ import { BrowserToolSchema } from "./src/browser-tool.schema.js";
const EAGER_BROWSER_CONTROL_SERVICE_ENV = "OPENCLAW_EAGER_BROWSER_CONTROL_SERVER";
let browserRegistrationRuntimeModulePromise: Promise<
typeof import("./register.runtime.js")
> | null = null;
const loadBrowserRegistrationRuntimeModule = async () => {
browserRegistrationRuntimeModulePromise ??= import("./register.runtime.js");
return await browserRegistrationRuntimeModulePromise;
};
function isTruthyEnvValue(value: string | undefined): boolean {
return /^(?:1|true|yes|on)$/iu.test(value?.trim() ?? "");
}
@@ -60,7 +51,7 @@ function createLazyBrowserTool(opts?: {
].join(" "),
parameters: BrowserToolSchema,
execute: async (toolCallId, args, signal, onUpdate) => {
const { createBrowserTool } = await loadBrowserRegistrationRuntimeModule();
const { createBrowserTool } = await import("./register.runtime.js");
const tool = createBrowserTool(opts);
return await tool.execute(toolCallId, args, signal, onUpdate);
},
@@ -74,7 +65,7 @@ export const browserPluginNodeHostCommands: OpenClawPluginNodeHostCommand[] = [
command: "browser.proxy",
cap: "browser",
handle: async (paramsJSON) => {
const { runBrowserProxyCommand } = await loadBrowserRegistrationRuntimeModule();
const { runBrowserProxyCommand } = await import("./register.runtime.js");
return await runBrowserProxyCommand(paramsJSON);
},
},
@@ -82,7 +73,7 @@ export const browserPluginNodeHostCommands: OpenClawPluginNodeHostCommand[] = [
export const browserSecurityAuditCollectors: OpenClawPluginSecurityAuditCollector[] = [
async (ctx) => {
const { collectBrowserSecurityAuditFindings } = await loadBrowserRegistrationRuntimeModule();
const { collectBrowserSecurityAuditFindings } = await import("./register.runtime.js");
return collectBrowserSecurityAuditFindings(ctx);
},
];
@@ -91,7 +82,7 @@ function createLazyBrowserPluginService(): OpenClawPluginService {
let service: OpenClawPluginService | null = null;
const loadService = async () => {
if (!service) {
const { createBrowserPluginService } = await loadBrowserRegistrationRuntimeModule();
const { createBrowserPluginService } = await import("./register.runtime.js");
service = createBrowserPluginService();
}
return service;
@@ -133,7 +124,7 @@ export function registerBrowserPlugin(api: OpenClawPluginApi) {
api.registerGatewayMethod(
BROWSER_REQUEST_GATEWAY_METHOD,
async (opts) => {
const { handleBrowserGatewayRequest } = await loadBrowserRegistrationRuntimeModule();
const { handleBrowserGatewayRequest } = await import("./register.runtime.js");
return await handleBrowserGatewayRequest(opts);
},
{

View File

@@ -1,6 +1,6 @@
import { redactCdpUrl } from "../cdp.helpers.js";
import { snapshotAria } from "../cdp.js";
import { getChromeMcpPid, takeChromeMcpSnapshot } from "../chrome-mcp.js";
import { getChromeMcpPid } from "../chrome-mcp.js";
import { resolveBrowserExecutableForPlatform } from "../chrome.executables.js";
import { resolveManagedBrowserHeadlessMode } from "../config.js";
import { buildBrowserDoctorReport } from "../doctor.js";
@@ -227,6 +227,7 @@ async function runBrowserLiveProbe(req: BrowserRequest, ctx: BrowserRouteContext
try {
const tab = await profileCtx.ensureTabAvailable();
if (capabilities.usesChromeMcp) {
const { takeChromeMcpSnapshot } = await import("../chrome-mcp.js");
await takeChromeMcpSnapshot({
profileName: profileCtx.profile.name,
profile: profileCtx.profile,

View File

@@ -13,20 +13,13 @@ export type CodexAppServerClientFactory = (
config?: AuthProfileOrderConfig,
) => Promise<CodexAppServerClient>;
let sharedClientModulePromise: Promise<typeof import("./shared-client.js")> | null = null;
const loadSharedClientModule = async () => {
sharedClientModulePromise ??= import("./shared-client.js");
return await sharedClientModulePromise;
};
export const defaultCodexAppServerClientFactory: CodexAppServerClientFactory = (
startOptions,
authProfileId,
agentDir,
config,
) =>
loadSharedClientModule().then(({ getSharedCodexAppServerClient }) =>
import("./shared-client.js").then(({ getSharedCodexAppServerClient }) =>
getSharedCodexAppServerClient({ startOptions, authProfileId, agentDir, config }),
);
@@ -36,6 +29,6 @@ export const defaultLeasedCodexAppServerClientFactory: CodexAppServerClientFacto
agentDir,
config,
) =>
loadSharedClientModule().then(({ getLeasedSharedCodexAppServerClient }) =>
import("./shared-client.js").then(({ getLeasedSharedCodexAppServerClient }) =>
getLeasedSharedCodexAppServerClient({ startOptions, authProfileId, agentDir, config }),
);

View File

@@ -189,7 +189,7 @@ function resolveEffectiveExecHost(params: {
function readRuntimeSessionEntryBestEffort(sessionKey: string): SessionEntry | undefined {
try {
return getSessionEntry({ sessionKey, hydrateSkillPromptRefs: false });
return getSessionEntry({ sessionKey });
} catch {
return undefined;
}

View File

@@ -77,31 +77,6 @@ describe("Codex app-server startup binding", () => {
expect(savedBinding?.threadId).toBe("thread-existing");
});
it("reuses the session record cache while sessions.json is unchanged", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const agentDir = path.join(tempDir, "agent");
await writeExistingBinding(sessionFile, workspaceDir, { dynamicToolsFingerprint: "[]" });
await writeSessionRecord(sessionFile, { totalTokens: 12_000 });
const sessionsJson = path.join(path.dirname(sessionFile), "sessions.json");
const readFileSpy = vi.spyOn(fs, "readFile");
for (let i = 0; i < 2; i += 1) {
const binding = await rotateOversizedCodexAppServerStartupBinding({
binding: await readCodexAppServerBinding(sessionFile),
sessionFile,
agentDir,
config: undefined,
});
expect(binding?.threadId).toBe("thread-existing");
}
const sessionStoreReads = readFileSpy.mock.calls.filter(
([file]) => typeof file === "string" && file === sessionsJson,
);
expect(sessionStoreReads).toHaveLength(1);
});
it("checks native rollout token pressure under default compaction config", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");

View File

@@ -30,14 +30,6 @@ const CODEX_APP_SERVER_BYTE_UNITS: Record<string, number> = {
tb: 1024 * 1024 * 1024 * 1024,
tib: 1024 * 1024 * 1024 * 1024,
};
type CodexSessionRecordCacheEntry = {
sessionsFile: string;
mtimeMs: number;
size: number;
record: (Record<string, unknown> & { sessionKey: string }) | undefined;
};
const codexSessionRecordCache = new Map<string, CodexSessionRecordCacheEntry>();
function parseCodexAppServerByteLimit(value: unknown): number | undefined {
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
@@ -120,34 +112,16 @@ async function readCodexSessionRecordForSessionFile(
sessionFile: string,
): Promise<(Record<string, unknown> & { sessionKey: string }) | undefined> {
const sessionsFile = path.join(path.dirname(sessionFile), "sessions.json");
const resolvedSessionFile = path.resolve(sessionFile);
let stat: Awaited<ReturnType<typeof fs.stat>>;
try {
stat = await fs.stat(sessionsFile);
} catch {
codexSessionRecordCache.delete(resolvedSessionFile);
return undefined;
}
const cached = codexSessionRecordCache.get(resolvedSessionFile);
if (
cached?.sessionsFile === sessionsFile &&
cached.mtimeMs === stat.mtimeMs &&
cached.size === stat.size
) {
return cached.record;
}
let store: JsonValue | undefined;
try {
store = JSON.parse(await fs.readFile(sessionsFile, "utf8")) as JsonValue;
} catch {
codexSessionRecordCache.delete(resolvedSessionFile);
return undefined;
}
if (!isJsonObject(store)) {
codexSessionRecordCache.delete(resolvedSessionFile);
return undefined;
}
let found: (Record<string, unknown> & { sessionKey: string }) | undefined;
const resolvedSessionFile = path.resolve(sessionFile);
for (const [sessionKey, record] of Object.entries(store)) {
if (!isJsonObject(record) || typeof record.sessionFile !== "string") {
continue;
@@ -155,16 +129,9 @@ async function readCodexSessionRecordForSessionFile(
if (path.resolve(record.sessionFile) !== resolvedSessionFile) {
continue;
}
found = { sessionKey, ...record };
break;
return { sessionKey, ...record };
}
codexSessionRecordCache.set(resolvedSessionFile, {
sessionsFile,
mtimeMs: stat.mtimeMs,
size: stat.size,
record: found,
});
return found;
return undefined;
}
type CodexAppServerRolloutTokenSnapshot = {

View File

@@ -54,21 +54,6 @@ describe("DiffArtifactStore", () => {
expect(await store.readHtml(artifact.id)).toBe("<html>demo</html>");
});
it("caps artifact expiry instead of throwing near the Date boundary", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(8_640_000_000_000_000 - 1_000));
const artifact = await store.createArtifact({
html: "<html>demo</html>",
title: "Demo",
inputKind: "patch",
fileCount: 1,
ttlMs: 60_000,
});
expect(artifact.expiresAt).toBe("+275760-09-13T00:00:00.000Z");
});
it("expires artifacts after the ttl", async () => {
vi.useFakeTimers();
const now = new Date("2026-02-27T16:00:00Z");
@@ -146,15 +131,6 @@ describe("DiffArtifactStore", () => {
});
});
it("caps standalone file expiry instead of throwing near the Date boundary", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(8_640_000_000_000_000 - 1_000));
const standalone = await store.createStandaloneFileArtifact({ ttlMs: 60_000 });
expect(standalone.expiresAt).toBe("+275760-09-13T00:00:00.000Z");
});
it("expires standalone file artifacts using ttl metadata", async () => {
vi.useFakeTimers();
const now = new Date("2026-02-27T16:00:00Z");

View File

@@ -1,7 +1,6 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { MAX_DATE_TIMESTAMP_MS, timestampMsToIsoString } from "openclaw/plugin-sdk/number-runtime";
import { root as fsRoot } from "openclaw/plugin-sdk/security-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { PluginLogger } from "../api.js";
@@ -65,16 +64,15 @@ export class DiffArtifactStore {
const htmlPath = path.join(artifactDir, "viewer.html");
const ttlMs = normalizeTtlMs(params.ttlMs);
const createdAt = new Date();
const createdAtIso = createdAt.toISOString();
const expiresAt = resolveExpiresAtIso(createdAt.getTime(), ttlMs);
const expiresAt = new Date(createdAt.getTime() + ttlMs);
const meta: DiffArtifactMeta = {
id,
token,
title: params.title,
inputKind: params.inputKind,
fileCount: params.fileCount,
createdAt: createdAtIso,
expiresAt,
createdAt: createdAt.toISOString(),
expiresAt: expiresAt.toISOString(),
viewerPath: `${VIEWER_PREFIX}/${id}/${token}`,
htmlPath,
...(params.context ? { context: params.context } : {}),
@@ -146,12 +144,11 @@ export class DiffArtifactStore {
const filePath = path.join(artifactDir, `preview.${format}`);
const ttlMs = normalizeTtlMs(params.ttlMs);
const createdAt = new Date();
const createdAtIso = createdAt.toISOString();
const expiresAt = resolveExpiresAtIso(createdAt.getTime(), ttlMs);
const expiresAt = new Date(createdAt.getTime() + ttlMs).toISOString();
const meta: StandaloneFileMeta = {
kind: "standalone_file",
id,
createdAt: createdAtIso,
createdAt: createdAt.toISOString(),
expiresAt,
filePath: this.normalizeStoredPath(filePath, "filePath"),
...(params.context ? { context: params.context } : {}),
@@ -360,14 +357,6 @@ function normalizeTtlMs(value?: number): number {
return Math.min(rounded, MAX_TTL_MS);
}
function resolveExpiresAtIso(createdAtMs: number, ttlMs: number): string {
return (
timestampMsToIsoString(createdAtMs + ttlMs) ??
timestampMsToIsoString(MAX_DATE_TIMESTAMP_MS) ??
"1970-01-01T00:00:00.000Z"
);
}
function isExpired(meta: { expiresAt: string }): boolean {
const expiresAt = Date.parse(meta.expiresAt);
if (!Number.isFinite(expiresAt)) {

View File

@@ -342,47 +342,6 @@ describe("Client.deployCommands", () => {
await client.fetchChannel("c1");
expect(get).toHaveBeenCalledTimes(2);
});
it("does not reuse cached REST objects while the process clock is invalid", async () => {
const client = createInternalTestClient();
const get = vi
.fn()
.mockResolvedValueOnce({ id: "c1", type: 0, name: "old" })
.mockResolvedValueOnce({ id: "c1", type: 0, name: "fresh" })
.mockResolvedValueOnce({ id: "c1", type: 0, name: "recovered" });
attachRestMock(client, { get });
const first = await client.fetchChannel("c1");
expect(first.name).toBe("old");
vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_001);
const second = await client.fetchChannel("c1");
expect(second.name).toBe("fresh");
vi.mocked(Date.now).mockReturnValue(1_000);
const third = await client.fetchChannel("c1");
expect(third.name).toBe("recovered");
expect(get).toHaveBeenCalledTimes(3);
});
it("does not cache REST objects when the cache expiry would exceed the Date range", async () => {
const client = createInternalTestClient();
const get = vi
.fn()
.mockResolvedValueOnce({ id: "c1", type: 0, name: "first" })
.mockResolvedValueOnce({ id: "c1", type: 0, name: "second" });
attachRestMock(client, { get });
vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_000);
const first = await client.fetchChannel("c1");
const second = await client.fetchChannel("c1");
expect(first.name).toBe("first");
expect(second.name).toBe("second");
expect(get).toHaveBeenCalledTimes(2);
});
});
describe("Client gateway event queue", () => {

View File

@@ -1,8 +1,4 @@
import { GatewayDispatchEvents } from "discord-api-types/v10";
import {
asDateTimestampMs,
resolveExpiresAtMsFromDurationMs,
} from "openclaw/plugin-sdk/number-runtime";
import { getChannel, getGuild, getGuildMember, getUser } from "./api.js";
import type { RequestClient } from "./rest.js";
import { Guild, GuildMember, User, channelFactory, type StructureClient } from "./structures.js";
@@ -83,23 +79,15 @@ export class DiscordEntityCache {
private async fetchCached<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
const ttl = this.params.ttlMs ?? DEFAULT_REST_CACHE_TTL_MS;
const rawNow = Date.now();
const now = asDateTimestampMs(rawNow);
if (ttl > 0) {
const cached = this.entries.get(key) as CacheEntry<T> | undefined;
if (cached && now !== undefined && cached.expiresAt > now) {
if (cached && cached.expiresAt > Date.now()) {
return cached.value;
}
if (cached) {
this.entries.delete(key);
}
}
const value = await fetcher();
if (ttl > 0) {
const expiresAt = resolveExpiresAtMsFromDurationMs(ttl, { nowMs: rawNow });
if (expiresAt !== undefined) {
this.entries.set(key, { expiresAt, value });
}
this.entries.set(key, { expiresAt: Date.now() + ttl, value });
}
return value;
}

View File

@@ -1,7 +1,3 @@
import {
asDateTimestampMs,
resolveExpiresAtMsFromDurationMs,
} from "openclaw/plugin-sdk/number-runtime";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { normalizeOptionalStringifiedId } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { ChannelType, Message } from "../internal/discord.js";
@@ -34,22 +30,6 @@ export function resetDiscordChannelInfoCacheForTest() {
DISCORD_CHANNEL_INFO_CACHE.clear();
}
function resolveDiscordChannelInfoCacheExpiresAt(ttlMs: number, nowMs: number): number | undefined {
return resolveExpiresAtMsFromDurationMs(ttlMs, { nowMs });
}
function cacheDiscordChannelInfo(
channelId: string,
value: DiscordChannelInfo | null,
ttlMs: number,
nowMs: number,
): void {
const expiresAt = resolveDiscordChannelInfoCacheExpiresAt(ttlMs, nowMs);
if (expiresAt !== undefined) {
DISCORD_CHANNEL_INFO_CACHE.set(channelId, { value, expiresAt });
}
}
function normalizeDiscordChannelId(value: unknown): string {
return normalizeOptionalStringifiedId(value) ?? "";
}
@@ -71,11 +51,9 @@ export async function resolveDiscordChannelInfo(
client: DiscordChannelInfoClient,
channelId: string,
): Promise<DiscordChannelInfo | null> {
const rawNow = Date.now();
const now = asDateTimestampMs(rawNow);
const cached = DISCORD_CHANNEL_INFO_CACHE.get(channelId);
if (cached) {
if (now !== undefined && cached.expiresAt > now) {
if (cached.expiresAt > Date.now()) {
return cached.value;
}
DISCORD_CHANNEL_INFO_CACHE.delete(channelId);
@@ -83,7 +61,10 @@ export async function resolveDiscordChannelInfo(
try {
const channel = await client.fetchChannel(channelId);
if (!channel) {
cacheDiscordChannelInfo(channelId, null, DISCORD_CHANNEL_INFO_NEGATIVE_CACHE_TTL_MS, rawNow);
DISCORD_CHANNEL_INFO_CACHE.set(channelId, {
value: null,
expiresAt: Date.now() + DISCORD_CHANNEL_INFO_NEGATIVE_CACHE_TTL_MS,
});
return null;
}
const channelInfo = resolveDiscordChannelInfoSafe(channel);
@@ -99,11 +80,17 @@ export async function resolveDiscordChannelInfo(
parentId: channelInfo.parentId,
ownerId: channelInfo.ownerId,
};
cacheDiscordChannelInfo(channelId, payload, DISCORD_CHANNEL_INFO_CACHE_TTL_MS, rawNow);
DISCORD_CHANNEL_INFO_CACHE.set(channelId, {
value: payload,
expiresAt: Date.now() + DISCORD_CHANNEL_INFO_CACHE_TTL_MS,
});
return payload;
} catch (err) {
logVerbose(`discord: failed to fetch channel ${channelId}: ${String(err)}`);
cacheDiscordChannelInfo(channelId, null, DISCORD_CHANNEL_INFO_NEGATIVE_CACHE_TTL_MS, rawNow);
DISCORD_CHANNEL_INFO_CACHE.set(channelId, {
value: null,
expiresAt: Date.now() + DISCORD_CHANNEL_INFO_NEGATIVE_CACHE_TTL_MS,
});
return null;
}
}

View File

@@ -4,7 +4,7 @@ import {
MessageReferenceType,
StickerFormatType,
} from "discord-api-types/v10";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { ChannelType, type Client, type Message } from "../internal/discord.js";
const readRemoteMediaBuffer = vi.fn();
@@ -65,10 +65,6 @@ beforeAll(async () => {
} = await import("./message-utils.js"));
});
afterEach(() => {
vi.restoreAllMocks();
});
function asMessage(payload: Record<string, unknown>): Message {
return payload as unknown as Message;
}
@@ -1235,37 +1231,4 @@ describe("resolveDiscordChannelInfo", () => {
expect(second).toBeNull();
expect(fetchChannel).toHaveBeenCalledTimes(1);
});
it("does not reuse cached channel info while the process clock is invalid", async () => {
const fetchChannel = vi
.fn()
.mockResolvedValueOnce({ type: ChannelType.GuildText, name: "old" })
.mockResolvedValueOnce({ type: ChannelType.GuildText, name: "fresh" });
const client = { fetchChannel } as unknown as Client;
const first = await resolveDiscordChannelInfo(client, "invalid-clock-channel");
expect(first?.name).toBe("old");
vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_001);
const second = await resolveDiscordChannelInfo(client, "invalid-clock-channel");
expect(second?.name).toBe("fresh");
expect(fetchChannel).toHaveBeenCalledTimes(2);
});
it("does not cache channel info when the cache expiry would exceed the Date range", async () => {
vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_000);
const fetchChannel = vi
.fn()
.mockResolvedValueOnce({ type: ChannelType.GuildText, name: "first" })
.mockResolvedValueOnce({ type: ChannelType.GuildText, name: "second" });
const client = { fetchChannel } as unknown as Client;
const first = await resolveDiscordChannelInfo(client, "overflow-cache-channel");
const second = await resolveDiscordChannelInfo(client, "overflow-cache-channel");
expect(first?.name).toBe("first");
expect(second?.name).toBe("second");
expect(fetchChannel).toHaveBeenCalledTimes(2);
});
});

View File

@@ -63,88 +63,4 @@ describe("Discord model picker preference migration", () => {
updatedAt: "2026-05-29T00:00:00.001Z",
});
});
it("plans legacy JSON import with max Date timestamps", async () => {
const stateDir = await makeStateDir();
const sourcePath = path.join(stateDir, "discord", "model-picker-preferences.json");
await fs.mkdir(path.dirname(sourcePath), { recursive: true });
await fs.writeFile(
sourcePath,
JSON.stringify({
version: 1,
entries: {
"discord:default:dm:user:max-date": {
recent: ["openai/gpt-5", "openai/gpt-4.1"],
updatedAt: "+275760-09-13T00:00:00.000Z",
},
},
}),
);
const plans = await Promise.resolve(
detectDiscordLegacyStateMigrations({
cfg: {},
env: {},
oauthDir: path.join(stateDir, "credentials"),
stateDir,
}),
);
const plan = plans?.[0];
if (plan?.kind !== "plugin-state-import") {
throw new Error("expected plugin-state import plan");
}
const entries = await plan.readEntries();
expect(
entries.map((entry) => {
const value = entry.value as { updatedAt?: unknown };
return value.updatedAt;
}),
).toEqual(["+275760-09-13T00:00:00.000Z", "+275760-09-12T23:59:59.999Z"]);
});
it("keeps legacy JSON import order near max Date", async () => {
const stateDir = await makeStateDir();
const sourcePath = path.join(stateDir, "discord", "model-picker-preferences.json");
await fs.mkdir(path.dirname(sourcePath), { recursive: true });
await fs.writeFile(
sourcePath,
JSON.stringify({
version: 1,
entries: {
"discord:default:dm:user:near-max-date": {
recent: ["openai/gpt-5", "openai/gpt-4.1"],
updatedAt: "+275760-09-12T23:59:59.999Z",
},
},
}),
);
const plans = await Promise.resolve(
detectDiscordLegacyStateMigrations({
cfg: {},
env: {},
oauthDir: path.join(stateDir, "credentials"),
stateDir,
}),
);
const plan = plans?.[0];
if (plan?.kind !== "plugin-state-import") {
throw new Error("expected plugin-state import plan");
}
const entries = await plan.readEntries();
expect(
entries.map((entry) => {
const value = entry.value as { modelRef?: unknown };
return value.modelRef;
}),
).toEqual(["openai/gpt-5", "openai/gpt-4.1"]);
expect(
entries.map((entry) => {
const value = entry.value as { updatedAt?: unknown };
return value.updatedAt;
}),
).toEqual(["+275760-09-13T00:00:00.000Z", "+275760-09-12T23:59:59.999Z"]);
});
});

View File

@@ -2,7 +2,6 @@ import { createHash } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import type { BundledChannelLegacyStateMigrationDetector } from "openclaw/plugin-sdk/channel-entry-contract";
import { MAX_DATE_TIMESTAMP_MS, timestampMsToIsoString } from "openclaw/plugin-sdk/number-runtime";
import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared";
const PREFERENCE_MAX_ENTRIES = 2_000;
@@ -93,15 +92,7 @@ function timestampMs(value: unknown): number {
}
function legacyUpdatedAtForIndex(updatedAt: unknown, index: number, total: number): string {
const baseMs = timestampMs(updatedAt);
const anchorMs = Math.min(baseMs + Math.max(0, total), MAX_DATE_TIMESTAMP_MS);
const shiftedMs = anchorMs - Math.max(0, index);
return (
timestampMsToIsoString(shiftedMs) ??
timestampMsToIsoString(baseMs) ??
timestampMsToIsoString(Math.max(0, total - index)) ??
"1970-01-01T00:00:00.000Z"
);
return new Date(timestampMs(updatedAt) + Math.max(0, total - index)).toISOString();
}
export const detectDiscordLegacyStateMigrations: BundledChannelLegacyStateMigrationDetector = ({

View File

@@ -6,7 +6,7 @@ import {
createPluginStateKeyedStoreForTests,
resetPluginStateStoreForTests,
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it } from "vitest";
import { setDiscordRuntime, type DiscordRuntime } from "../runtime.js";
import {
buildDiscordModelPickerPreferenceKey,
@@ -163,68 +163,6 @@ describe("discord model picker preferences", () => {
]);
});
it("imports legacy JSON preferences with max Date timestamps", async () => {
const env = await createStateEnv();
const scope = { accountId: "main", guildId: "guild-max-date", userId: "user-max-date" };
const key = buildDiscordModelPickerPreferenceKey(scope);
expect(key).toBeTruthy();
const legacyPath = path.join(
env.OPENCLAW_STATE_DIR as string,
"discord",
"model-picker-preferences.json",
);
await fs.mkdir(path.dirname(legacyPath), { recursive: true });
await fs.writeFile(
legacyPath,
JSON.stringify({
version: 1,
entries: {
[key as string]: {
recent: ["openai/gpt-4.1", "openai/gpt-4o"],
updatedAt: "+275760-09-13T00:00:00.000Z",
},
},
}),
"utf8",
);
await expect(readDiscordModelPickerRecentModels({ env, scope })).resolves.toEqual([
"openai/gpt-4.1",
"openai/gpt-4o",
]);
});
it("preserves legacy JSON preference order near max Date", async () => {
const env = await createStateEnv();
const scope = { accountId: "main", guildId: "guild-near-max-date", userId: "user-near-max" };
const key = buildDiscordModelPickerPreferenceKey(scope);
expect(key).toBeTruthy();
const legacyPath = path.join(
env.OPENCLAW_STATE_DIR as string,
"discord",
"model-picker-preferences.json",
);
await fs.mkdir(path.dirname(legacyPath), { recursive: true });
await fs.writeFile(
legacyPath,
JSON.stringify({
version: 1,
entries: {
[key as string]: {
recent: ["openai/gpt-4.1", "openai/gpt-4o"],
updatedAt: "+275760-09-12T23:59:59.999Z",
},
},
}),
"utf8",
);
await expect(readDiscordModelPickerRecentModels({ env, scope })).resolves.toEqual([
"openai/gpt-4.1",
"openai/gpt-4o",
]);
});
it("skips malformed legacy JSON entries during import", async () => {
const env = await createStateEnv();
const scope = { userId: "valid-legacy-user" };
@@ -269,34 +207,4 @@ describe("discord model picker preferences", () => {
const recent = await readDiscordModelPickerRecentModels({ env, scope });
expect(new Set(recent)).toEqual(new Set(["openai/gpt-4o", "openai/gpt-4.1"]));
});
it("keeps selections recent when the process clock is outside the Date range", async () => {
const env = await createStateEnv();
const scope = { userId: "invalid-clock-user" };
await recordDiscordModelPickerRecentModel({ env, scope, modelRef: "openai/gpt-4.1" });
await recordDiscordModelPickerRecentModel({ env, scope, modelRef: "openai/gpt-4o" });
const dateNowSpy = vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_001);
try {
await recordDiscordModelPickerRecentModel({
env,
scope,
modelRef: "openai/gpt-5.5",
limit: 2,
});
await recordDiscordModelPickerRecentModel({
env,
scope,
modelRef: "openai/gpt-5.6",
limit: 2,
});
} finally {
dateNowSpy.mockRestore();
}
await expect(readDiscordModelPickerRecentModels({ env, scope, limit: 3 })).resolves.toEqual([
"openai/gpt-5.6",
"openai/gpt-5.5",
]);
});
});

View File

@@ -3,12 +3,6 @@ import os from "node:os";
import path from "node:path";
import { normalizeAccountId as normalizeSharedAccountId } from "openclaw/plugin-sdk/account-id";
import { readJsonFileWithFallback } from "openclaw/plugin-sdk/json-store";
import {
MAX_DATE_TIMESTAMP_MS,
resolveDateTimestampMs,
resolveTimestampMsToIsoString,
timestampMsToIsoString,
} from "openclaw/plugin-sdk/number-runtime";
import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared";
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
@@ -19,13 +13,11 @@ const PREFERENCE_MAX_ENTRIES = 2_000;
const MAX_PLUGIN_STATE_KEY_BYTES = 512;
const textEncoder = new TextEncoder();
let lastPreferenceTimestampMs = 0;
let lastPreferenceOrder = 0;
type ModelPickerPreferencesEntry = {
scopeKey: string;
modelRef: string;
updatedAt: string;
updatedOrder?: number;
};
type LegacyModelPickerPreferencesEntry = {
@@ -132,7 +124,6 @@ function sanitizeStoredPreferenceEntry(value: unknown): ModelPickerPreferencesEn
scopeKey?: unknown;
modelRef?: unknown;
updatedAt?: unknown;
updatedOrder?: unknown;
};
if (typeof typedValue.scopeKey !== "string" || typeof typedValue.modelRef !== "string") {
return undefined;
@@ -145,10 +136,6 @@ function sanitizeStoredPreferenceEntry(value: unknown): ModelPickerPreferencesEn
scopeKey: typedValue.scopeKey,
modelRef,
updatedAt: typeof typedValue.updatedAt === "string" ? typedValue.updatedAt : "",
updatedOrder:
typeof typedValue.updatedOrder === "number" && Number.isSafeInteger(typedValue.updatedOrder)
? typedValue.updatedOrder
: undefined,
};
}
@@ -165,58 +152,13 @@ function timestampMs(value: string): number {
return Number.isFinite(parsed) ? parsed : 0;
}
function timestampOrder(value?: number): number {
return value !== undefined && value >= 0 ? value : 0;
}
function comparePreferenceEntries(
left: { key: string; value: ModelPickerPreferencesEntry },
right: { key: string; value: ModelPickerPreferencesEntry },
): number {
return (
timestampMs(right.value.updatedAt) - timestampMs(left.value.updatedAt) ||
timestampOrder(right.value.updatedOrder) - timestampOrder(left.value.updatedOrder) ||
left.key.localeCompare(right.key)
);
}
function legacyUpdatedAtForIndex(updatedAt: string, index: number, total: number): string {
const baseMs = timestampMs(updatedAt);
const anchorMs = Math.min(baseMs + Math.max(0, total), MAX_DATE_TIMESTAMP_MS);
const shiftedMs = anchorMs - Math.max(0, index);
return (
timestampMsToIsoString(shiftedMs) ??
timestampMsToIsoString(baseMs) ??
timestampMsToIsoString(Math.max(0, total - index)) ??
"1970-01-01T00:00:00.000Z"
);
return new Date(timestampMs(updatedAt) + Math.max(0, total - index)).toISOString();
}
function nextPreferenceTimestamp(existingEntries: ModelPickerPreferencesEntry[]): {
updatedAt: string;
updatedOrder: number;
} {
const existingMaxTimestampMs = existingEntries.reduce(
(max, entry) => Math.max(max, timestampMs(entry.updatedAt)),
0,
);
lastPreferenceTimestampMs = Math.min(
Math.max(
resolveDateTimestampMs(Date.now(), 0),
lastPreferenceTimestampMs + 1,
existingMaxTimestampMs + 1,
),
MAX_DATE_TIMESTAMP_MS,
);
const existingMaxOrder = existingEntries.reduce(
(max, entry) => Math.max(max, timestampOrder(entry.updatedOrder)),
0,
);
lastPreferenceOrder = Math.max(lastPreferenceOrder + 1, existingMaxOrder + 1);
return {
updatedAt: resolveTimestampMsToIsoString(lastPreferenceTimestampMs),
updatedOrder: lastPreferenceOrder,
};
function nextPreferenceTimestampIso(): string {
lastPreferenceTimestampMs = Math.max(Date.now(), lastPreferenceTimestampMs + 1);
return new Date(lastPreferenceTimestampMs).toISOString();
}
function normalizeLegacyPreferenceKey(key: string): string | undefined {
@@ -295,13 +237,10 @@ export async function readDiscordModelPickerRecentModels(params: {
await importLegacyPreferences(params.env);
const store = openPreferenceStore(params.env);
const recent = (await store.entries())
.map((entry) => ({ key: entry.key, value: sanitizeStoredPreferenceEntry(entry.value) }))
.filter(
(entry): entry is { key: string; value: ModelPickerPreferencesEntry } =>
entry.value?.scopeKey === key,
)
.toSorted(comparePreferenceEntries)
.map((entry) => entry.value.modelRef);
.map((entry) => sanitizeStoredPreferenceEntry(entry.value))
.filter((entry): entry is ModelPickerPreferencesEntry => entry?.scopeKey === key)
.toSorted((left, right) => timestampMs(right.updatedAt) - timestampMs(left.updatedAt))
.map((entry) => entry.modelRef);
if (!params.allowedModelRefs || params.allowedModelRefs.size === 0) {
return sanitizeRecentModels(recent, limit);
}
@@ -329,14 +268,10 @@ export async function recordDiscordModelPickerRecentModel(params: {
try {
await importLegacyPreferences(params.env);
const store = openPreferenceStore(params.env);
const existingEntries = (await store.entries())
.map((entry) => sanitizeStoredPreferenceEntry(entry.value))
.filter((entry): entry is ModelPickerPreferencesEntry => entry?.scopeKey === key);
const timestamp = nextPreferenceTimestamp(existingEntries);
await store.register(buildPreferenceModelKey(key, normalizedModelRef), {
scopeKey: key,
modelRef: normalizedModelRef,
...timestamp,
updatedAt: nextPreferenceTimestampIso(),
});
const limit = Math.max(1, Math.min(params.limit ?? DEFAULT_RECENT_LIMIT, 10));
const scopedEntries = (await store.entries())
@@ -345,7 +280,11 @@ export async function recordDiscordModelPickerRecentModel(params: {
(entry): entry is { key: string; value: ModelPickerPreferencesEntry } =>
entry.value?.scopeKey === key,
)
.toSorted(comparePreferenceEntries);
.toSorted(
(left, right) =>
timestampMs(right.value.updatedAt) - timestampMs(left.value.updatedAt) ||
left.key.localeCompare(right.key),
);
await Promise.all(scopedEntries.slice(limit).map((entry) => store.delete(entry.key)));
} catch {
return;

View File

@@ -121,7 +121,6 @@ beforeEach(() => {
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
afterAll(() => {
@@ -323,18 +322,6 @@ describe("sendMessageDiscord", () => {
).toBeTypeOf("string");
});
it("rejects timeout durations outside Date range", async () => {
const { rest, patchMock } = makeDiscordRest();
await expect(
timeoutMemberDiscord(
{ guildId: "g1", userId: "u1", durationMinutes: 8_640_000_000_000_001 },
discordClientOpts(rest),
),
).rejects.toThrow("Discord timeout duration is outside the supported Date range");
expect(patchMock).not.toHaveBeenCalled();
});
it("adds and removes roles", async () => {
const { rest, putMock, deleteMock } = makeDiscordRest();
putMock.mockResolvedValue({});

View File

@@ -6,7 +6,6 @@ import type {
APIVoiceState,
RESTPostAPIGuildScheduledEventJSONBody,
} from "discord-api-types/v10";
import { timestampMsToIsoString } from "openclaw/plugin-sdk/number-runtime";
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { loadWebMediaRaw } from "openclaw/plugin-sdk/web-media";
import {
@@ -140,10 +139,7 @@ export async function timeoutMemberDiscord(
let until = payload.until;
if (!until && payload.durationMinutes) {
const ms = payload.durationMinutes * 60 * 1000;
until = timestampMsToIsoString(Date.now() + ms);
if (!until) {
throw new Error("Discord timeout duration is outside the supported Date range");
}
until = new Date(Date.now() + ms).toISOString();
}
return await timeoutGuildMember(rest, payload.guildId, payload.userId, {
body: { communication_disabled_until: until ?? null },

View File

@@ -1,9 +1,5 @@
import { PassThrough } from "node:stream";
import type { DiscordAccountConfig, OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
asDateTimestampMs,
resolveExpiresAtMsFromDurationMs,
} from "openclaw/plugin-sdk/number-runtime";
import {
buildRealtimeVoiceAgentConsultChatMessage,
buildRealtimeVoiceAgentConsultPolicyInstructions,
@@ -1501,14 +1497,10 @@ export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession {
);
return;
}
const expiresAt = resolveExpiresAtMsFromDurationMs(DISCORD_REALTIME_WAKE_NAME_FOLLOWUP_TTL_MS);
if (expiresAt === undefined) {
return;
}
this.pendingWakeNameFollowup = {
context,
startedAt: turn?.startedAt ?? Date.now(),
expiresAt,
expiresAt: Date.now() + DISCORD_REALTIME_WAKE_NAME_FOLLOWUP_TTL_MS,
};
logger.info(
`discord voice: realtime wake-name follow-up armed speaker=${context.speakerLabel} voiceSession=${this.params.entry.voiceSessionKey} agent=${this.params.entry.route.agentId}`,
@@ -1518,9 +1510,7 @@ export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession {
private consumePendingWakeNameFollowup(): TranscriptUtteranceAttribution | undefined {
const pending = this.pendingWakeNameFollowup;
this.pendingWakeNameFollowup = undefined;
const now = asDateTimestampMs(Date.now());
const expiresAt = pending ? asDateTimestampMs(pending.expiresAt) : undefined;
if (!pending || now === undefined || expiresAt === undefined || now > expiresAt) {
if (!pending || Date.now() > pending.expiresAt) {
return undefined;
}
const currentTurn = this.peekPendingSpeakerTurn();

View File

@@ -1,68 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { DiscordRealtimeVoiceSession } from "./realtime.js";
type WakeNameFollowupTestSession = {
armWakeNameFollowup: () => void;
consumePendingWakeNameFollowup: () => unknown;
pendingWakeNameFollowup?: unknown;
speakerTurns: {
consumeAudioContext: () => unknown;
peekAudioTurn: () => unknown;
};
};
function createSession(): WakeNameFollowupTestSession {
return new DiscordRealtimeVoiceSession({
cfg: {},
discordConfig: { voice: { realtime: {} } },
entry: {
voiceSessionKey: "voice-1",
route: { agentId: "agent-1" },
},
mode: "agent-proxy",
runAgentTurn: vi.fn(),
} as never) as unknown as WakeNameFollowupTestSession;
}
describe("DiscordRealtimeVoiceSession wake-name follow-up cache", () => {
afterEach(() => {
vi.useRealTimers();
});
it("arms and consumes a valid wake-name follow-up", () => {
const session = createSession();
session.speakerTurns = {
consumeAudioContext: vi.fn(() => ({
userId: "u1",
speakerLabel: "Ada",
senderIsOwner: true,
})),
peekAudioTurn: vi.fn(() => undefined),
};
session.armWakeNameFollowup();
expect(session.consumePendingWakeNameFollowup()).toMatchObject({
context: { userId: "u1", speakerLabel: "Ada" },
});
});
it("does not arm follow-ups when the expiry would exceed Date range", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(8_640_000_000_000_000));
const session = createSession();
session.speakerTurns = {
consumeAudioContext: vi.fn(() => ({
userId: "u1",
speakerLabel: "Ada",
senderIsOwner: true,
})),
peekAudioTurn: vi.fn(() => undefined),
};
session.armWakeNameFollowup();
expect(session.pendingWakeNameFollowup).toBeUndefined();
expect(session.consumePendingWakeNameFollowup()).toBeUndefined();
});
});

View File

@@ -1,57 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Client } from "../internal/discord.js";
import { DiscordVoiceSpeakerContextResolver } from "./speaker-context.js";
function createClient(fetchMember: ReturnType<typeof vi.fn>): Client {
return {
fetchMember,
fetchUser: vi.fn(),
} as unknown as Client;
}
describe("DiscordVoiceSpeakerContextResolver", () => {
afterEach(() => {
vi.useRealTimers();
});
it("reuses cached speaker context for repeated speaker lookups", async () => {
const fetchMember = vi.fn().mockResolvedValue({
nickname: "Ada",
roles: [],
user: { id: "u1", username: "ada", globalName: "Ada" },
});
const resolver = new DiscordVoiceSpeakerContextResolver({
client: createClient(fetchMember),
});
await expect(resolver.resolveContext("g1", "u1")).resolves.toMatchObject({ label: "Ada" });
await expect(resolver.resolveContext("g1", "u1")).resolves.toMatchObject({ label: "Ada" });
expect(fetchMember).toHaveBeenCalledTimes(1);
});
it("does not cache speaker context when the cache expiry would exceed Date range", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(8_640_000_000_000_000));
const fetchMember = vi
.fn()
.mockResolvedValueOnce({
nickname: "Ada",
roles: [],
user: { id: "u1", username: "ada", globalName: "Ada" },
})
.mockResolvedValueOnce({
nickname: "Grace",
roles: [],
user: { id: "u1", username: "grace", globalName: "Grace" },
});
const resolver = new DiscordVoiceSpeakerContextResolver({
client: createClient(fetchMember),
});
await expect(resolver.resolveContext("g1", "u1")).resolves.toMatchObject({ label: "Ada" });
await expect(resolver.resolveContext("g1", "u1")).resolves.toMatchObject({ label: "Grace" });
expect(fetchMember).toHaveBeenCalledTimes(2);
});
});

View File

@@ -1,7 +1,3 @@
import {
asDateTimestampMs,
resolveExpiresAtMsFromDurationMs,
} from "openclaw/plugin-sdk/number-runtime";
import type { Client } from "../internal/discord.js";
import { resolveDiscordOwnerAccess } from "../monitor/allow-list.js";
import { formatDiscordUserTag } from "../monitor/format.js";
@@ -108,9 +104,7 @@ export class DiscordVoiceSpeakerContextResolver {
if (!cached) {
return undefined;
}
const now = asDateTimestampMs(Date.now());
const expiresAt = asDateTimestampMs(cached.expiresAt);
if (now === undefined || expiresAt === undefined || expiresAt <= now) {
if (cached.expiresAt <= Date.now()) {
this.cache.delete(key);
return undefined;
}
@@ -125,12 +119,9 @@ export class DiscordVoiceSpeakerContextResolver {
private setCachedContext(guildId: string, userId: string, context: VoiceSpeakerContext): void {
const key = this.resolveCacheKey(guildId, userId);
const expiresAt = resolveExpiresAtMsFromDurationMs(SPEAKER_CONTEXT_CACHE_TTL_MS);
if (expiresAt !== undefined) {
this.cache.set(key, {
...context,
expiresAt,
});
}
this.cache.set(key, {
...context,
expiresAt: Date.now() + SPEAKER_CONTEXT_CACHE_TTL_MS,
});
}
}

View File

@@ -84,37 +84,6 @@ describe("resolveGroupName", () => {
expect(mockGetChatInfo).toHaveBeenCalledOnce(); // only 1 API call
});
it("does not cache group names when the expiry would exceed a valid Date", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(8_640_000_000_000_000));
try {
mockGetChatInfo.mockResolvedValue({ name: "Boundary Group" });
const first = await resolveGroupName({ account, chatId: "oc_boundary", log });
const second = await resolveGroupName({ account, chatId: "oc_boundary", log });
expect(first).toBe("Boundary Group");
expect(second).toBe("Boundary Group");
expect(mockGetChatInfo).toHaveBeenCalledTimes(2);
} finally {
vi.useRealTimers();
}
});
it("evicts cached group names when the current clock is invalid", async () => {
mockGetChatInfo.mockResolvedValue({ name: "Cached Group" });
await resolveGroupName({ account, chatId: "oc_invalid_clock", log });
const dateNow = vi.spyOn(Date, "now").mockReturnValue(Number.NaN);
try {
const result = await resolveGroupName({ account, chatId: "oc_invalid_clock", log });
expect(result).toBe("Cached Group");
} finally {
dateNow.mockRestore();
}
expect(mockGetChatInfo).toHaveBeenCalledTimes(2);
});
it("caches negative result (API failure) and skips retry", async () => {
mockGetChatInfo.mockRejectedValue(new Error("fail"));
await resolveGroupName({ account, chatId: "oc_test5", log });

View File

@@ -1,67 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { resolveFeishuSenderName } from "./bot-sender-name.js";
import { FeishuConfigSchema } from "./config-schema.js";
import type { ResolvedFeishuAccount } from "./types.js";
const createFeishuClientMock = vi.hoisted(() => vi.fn());
vi.mock("./client.js", () => ({
createFeishuClient: createFeishuClientMock,
}));
const account = {
accountId: "main",
selectionSource: "explicit",
enabled: true,
configured: true,
appId: "app-id",
appSecret: "secret",
domain: "feishu",
config: FeishuConfigSchema.parse({}),
} satisfies ResolvedFeishuAccount;
function mockUserNames(...names: string[]): ReturnType<typeof vi.fn> {
const get = vi.fn();
for (const name of names) {
get.mockResolvedValueOnce({ data: { user: { name } } });
}
createFeishuClientMock.mockReturnValue({
contact: { user: { get } },
});
return get;
}
describe("resolveFeishuSenderName", () => {
afterEach(() => {
vi.useRealTimers();
createFeishuClientMock.mockReset();
});
it("reuses a cached sender name within the TTL", async () => {
const get = mockUserNames("Ada");
await expect(
resolveFeishuSenderName({ account, senderId: "ou_sender_cache", log: vi.fn() }),
).resolves.toEqual({ name: "Ada" });
await expect(
resolveFeishuSenderName({ account, senderId: "ou_sender_cache", log: vi.fn() }),
).resolves.toEqual({ name: "Ada" });
expect(get).toHaveBeenCalledTimes(1);
});
it("does not cache sender names when the expiry would exceed Date range", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(8_640_000_000_000_000));
const get = mockUserNames("Ada", "Grace");
await expect(
resolveFeishuSenderName({ account, senderId: "ou_sender_overflow", log: vi.fn() }),
).resolves.toEqual({ name: "Ada" });
await expect(
resolveFeishuSenderName({ account, senderId: "ou_sender_overflow", log: vi.fn() }),
).resolves.toEqual({ name: "Grace" });
expect(get).toHaveBeenCalledTimes(2);
});
});

View File

@@ -1,7 +1,3 @@
import {
asDateTimestampMs,
resolveExpiresAtMsFromDurationMs,
} from "openclaw/plugin-sdk/number-runtime";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import { createFeishuClient } from "./client.js";
import type { ResolvedFeishuAccount } from "./types.js";
@@ -93,14 +89,10 @@ export async function resolveFeishuSenderName(params: {
}
const cached = senderNameCache.get(normalizedSenderId);
const now = asDateTimestampMs(Date.now());
const cachedExpireAt = cached ? asDateTimestampMs(cached.expireAt) : undefined;
if (cached && now !== undefined && cachedExpireAt !== undefined && cachedExpireAt > now) {
const now = Date.now();
if (cached && cached.expireAt > now) {
return { name: cached.name };
}
if (cached) {
senderNameCache.delete(normalizedSenderId);
}
try {
const client = createFeishuClient(account);
@@ -113,10 +105,7 @@ export async function resolveFeishuSenderName(params: {
const name = user?.name ?? user?.nickname ?? user?.en_name;
if (name) {
const expireAt = resolveExpiresAtMsFromDurationMs(SENDER_NAME_TTL_MS);
if (expireAt !== undefined) {
senderNameCache.set(normalizedSenderId, { name, expireAt });
}
senderNameCache.set(normalizedSenderId, { name, expireAt: now + SENDER_NAME_TTL_MS });
return { name };
}
return {};

View File

@@ -270,45 +270,6 @@ describe("Feishu Card Action Handler", () => {
expect(handleFeishuMessage).not.toHaveBeenCalled();
});
it("does not open approval cards when the expiry would exceed a valid Date", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(8_640_000_000_000_000));
try {
const event: FeishuCardActionEvent = {
operator: { open_id: "u123", user_id: "uid1", union_id: "un1" },
token: "tok4-boundary",
action: {
value: createFeishuCardInteractionEnvelope({
k: "meta",
a: FEISHU_APPROVAL_REQUEST_ACTION,
m: {
command: "/new",
prompt: "Start a fresh session?",
},
c: {
u: "u123",
h: "chat1",
t: "group",
s: "agent:codex:feishu:chat:chat1",
e: 8_640_000_000_000_000,
},
}),
tag: "button",
},
context: { open_id: "u123", user_id: "uid1", chat_id: "chat1" },
};
await handleFeishuCardAction({ cfg, event, runtime, accountId: "main" });
expect(sendCardFeishuMock).not.toHaveBeenCalled();
const sendMessage = sendMessageCall();
expect(sendMessage.to).toBe("chat:chat1");
expect(String(sendMessage.text)).toContain("payload is invalid");
} finally {
vi.useRealTimers();
}
});
it("runs approval confirmation through the normal message path", async () => {
const event = createStructuredQuickActionEvent({
token: "tok5",
@@ -415,39 +376,6 @@ describe("Feishu Card Action Handler", () => {
expect(createFeishuClientMock).toHaveBeenCalledTimes(1);
});
it("does not cache resolved chat type when expiry would exceed a valid Date", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(8_640_000_000_000_000));
try {
const getChat = vi.fn().mockResolvedValue({ code: 0, data: { chat_type: "p2p" } });
createFeishuClientMock.mockReturnValue({
im: {
chat: {
get: getChat,
},
},
});
const firstEvent = createCardActionEvent({
token: "tok9b-boundary-1",
chatId: "oc_dm_chat_boundary",
actionValue: { text: "/help" },
});
const secondEvent = createCardActionEvent({
token: "tok9b-boundary-2",
chatId: "oc_dm_chat_boundary",
actionValue: { text: "/help" },
});
await handleFeishuCardAction({ cfg, event: firstEvent, runtime });
await handleFeishuCardAction({ cfg, event: secondEvent, runtime });
expect(getChat).toHaveBeenCalledTimes(2);
expect(handleFeishuMessage).toHaveBeenCalledTimes(2);
} finally {
vi.useRealTimers();
}
});
it("uses resolved DM chat type when building approval cards without stored context", async () => {
createFeishuClientMock.mockReturnValueOnce({
im: {
@@ -531,20 +459,6 @@ describe("Feishu Card Action Handler", () => {
expect(handleFeishuMessage).toHaveBeenCalledTimes(1);
});
it("does not cache callback tokens when token ttl expiry overflows", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(8_640_000_000_000_000));
const event = createCardActionEvent({
token: "tok10-boundary",
actionValue: { text: "/help" },
});
await handleFeishuCardAction({ cfg, event, runtime });
await handleFeishuCardAction({ cfg, event, runtime });
expect(handleFeishuMessage).toHaveBeenCalledTimes(2);
});
it("rejects empty callback tokens before dispatch", async () => {
const log = vi.fn();
const event = createStructuredQuickActionEvent({

View File

@@ -10,11 +10,7 @@ import {
resolveConfiguredBindingRoute,
resolveRuntimeConversationBindingRoute,
} from "openclaw/plugin-sdk/conversation-runtime";
import {
asDateTimestampMs,
parseStrictNonNegativeInteger,
resolveExpiresAtMsFromDurationMs,
} from "openclaw/plugin-sdk/number-runtime";
import { parseStrictNonNegativeInteger } from "openclaw/plugin-sdk/number-runtime";
import {
DEFAULT_GROUP_HISTORY_LIMIT,
createChannelHistoryWindow,
@@ -112,14 +108,9 @@ function isFeishuTopicSessionScope(scope: FeishuGroupSessionScope): boolean {
}
function evictGroupNameCache(): void {
const now = asDateTimestampMs(Date.now());
if (now === undefined) {
groupNameCache.clear();
return;
}
const now = Date.now();
for (const [key, val] of groupNameCache) {
const expiresAt = asDateTimestampMs(val.expiresAt);
if (expiresAt === undefined || expiresAt <= now) {
if (val.expiresAt <= now) {
groupNameCache.delete(key);
}
}
@@ -137,12 +128,9 @@ function evictGroupNameCache(): void {
}
}
function setCacheEntry(key: string, name: string): void {
const expiresAt = resolveExpiresAtMsFromDurationMs(GROUP_NAME_CACHE_TTL_MS);
function setCacheEntry(key: string, value: { name: string; expiresAt: number }): void {
groupNameCache.delete(key);
if (expiresAt !== undefined) {
groupNameCache.set(key, { name, expiresAt });
}
groupNameCache.set(key, value);
}
export function clearGroupNameCache(): void {
@@ -162,34 +150,37 @@ export async function resolveGroupName(params: {
const cacheKey = `${account.accountId}:${chatId}`;
const cached = groupNameCache.get(cacheKey);
if (cached) {
const now = asDateTimestampMs(Date.now());
const expiresAt = asDateTimestampMs(cached.expiresAt);
if (now !== undefined && expiresAt !== undefined && expiresAt > now) {
return cached.name || undefined;
}
groupNameCache.delete(cacheKey);
if (cached && cached.expiresAt > Date.now()) {
return cached.name || undefined;
}
let resolvedName: string | undefined;
try {
const client = createFeishuClient(account);
const chatInfo = await getChatInfo(client, chatId);
const name = chatInfo?.name?.trim();
if (name) {
setCacheEntry(cacheKey, name);
resolvedName = name;
setCacheEntry(cacheKey, {
name,
expiresAt: Date.now() + GROUP_NAME_CACHE_TTL_MS,
});
} else {
setCacheEntry(cacheKey, "");
setCacheEntry(cacheKey, {
name: "",
expiresAt: Date.now() + GROUP_NAME_CACHE_TTL_MS,
});
}
} catch (err) {
log(`feishu[${account.accountId}]: getChatInfo failed for ${chatId}: ${String(err)}`);
setCacheEntry(cacheKey, "");
setCacheEntry(cacheKey, {
name: "",
expiresAt: Date.now() + GROUP_NAME_CACHE_TTL_MS,
});
}
const result = groupNameCache.get(cacheKey)?.name || undefined;
evictGroupNameCache();
return resolvedName;
return result;
}
async function resolveFeishuAudioPreflightTranscript(params: {

View File

@@ -1,8 +1,3 @@
import {
asDateTimestampMs,
isFutureDateTimestampMs,
resolveExpiresAtMsFromDurationMs,
} from "openclaw/plugin-sdk/number-runtime";
import type { ClawdbotConfig, RuntimeEnv } from "../runtime-api.js";
import { resolveFeishuRuntimeAccount } from "./accounts.js";
import { handleFeishuMessage, type FeishuMessageEvent } from "./bot.js";
@@ -52,26 +47,16 @@ export class FeishuRetryableCardActionError extends Error {
export function resetProcessedFeishuCardActionTokensForTests(): void {
processedCardActionTokens.clear();
resolvedChatTypeCache.clear();
}
function pruneProcessedCardActionTokens(now: number): void {
const validNow = asDateTimestampMs(now);
if (validNow === undefined) {
processedCardActionTokens.clear();
return;
}
for (const [key, entry] of processedCardActionTokens.entries()) {
if (!isFutureDateTimestampMs(entry.expiresAt, { nowMs: validNow })) {
if (entry.expiresAt <= now) {
processedCardActionTokens.delete(key);
}
}
}
function resolveProcessedCardActionTokenExpiresAt(now: number): number | undefined {
return resolveExpiresAtMsFromDurationMs(FEISHU_CARD_ACTION_TOKEN_TTL_MS, { nowMs: now });
}
function beginFeishuCardActionToken(params: {
token: string;
accountId: string;
@@ -85,17 +70,13 @@ function beginFeishuCardActionToken(params: {
}
const key = `${params.accountId}:${normalizedToken}`;
const existing = processedCardActionTokens.get(key);
if (existing && isFutureDateTimestampMs(existing.expiresAt, { nowMs: now })) {
if (existing && existing.expiresAt > now) {
return false;
}
processedCardActionTokens.delete(key);
const expiresAt = resolveProcessedCardActionTokenExpiresAt(now);
if (expiresAt !== undefined) {
processedCardActionTokens.set(key, {
status: "inflight",
expiresAt,
});
}
processedCardActionTokens.set(key, {
status: "inflight",
expiresAt: now + FEISHU_CARD_ACTION_TOKEN_TTL_MS,
});
return true;
}
@@ -109,15 +90,9 @@ function completeFeishuCardActionToken(params: {
if (!normalizedToken) {
return;
}
const key = `${params.accountId}:${normalizedToken}`;
const expiresAt = resolveProcessedCardActionTokenExpiresAt(now);
if (expiresAt === undefined) {
processedCardActionTokens.delete(key);
return;
}
processedCardActionTokens.set(key, {
processedCardActionTokens.set(`${params.accountId}:${normalizedToken}`, {
status: "completed",
expiresAt,
expiresAt: now + FEISHU_CARD_ACTION_TOKEN_TTL_MS,
});
}
@@ -210,14 +185,8 @@ const CHAT_TYPE_CACHE_TTL_MS = 30 * 60_000;
const CHAT_TYPE_CACHE_MAX_SIZE = 5_000;
function pruneChatTypeCache(now: number): void {
const validNow = asDateTimestampMs(now);
if (validNow === undefined) {
resolvedChatTypeCache.clear();
return;
}
for (const [key, entry] of resolvedChatTypeCache.entries()) {
const expiresAt = asDateTimestampMs(entry.expiresAt);
if (expiresAt === undefined || expiresAt <= validNow) {
if (entry.expiresAt <= now) {
resolvedChatTypeCache.delete(key);
}
}
@@ -237,25 +206,6 @@ function sanitizeLogValue(v: string): string {
return v.replace(/[\r\n]/g, " ").slice(0, 500);
}
function resolveFeishuApprovalCardExpiresAt(nowRaw = Date.now()): number | undefined {
const now = asDateTimestampMs(nowRaw);
return now === undefined
? undefined
: resolveExpiresAtMsFromDurationMs(FEISHU_APPROVAL_CARD_TTL_MS, { nowMs: now });
}
function cacheResolvedCardActionChatType(
cacheKey: string,
value: "p2p" | "group",
now: number,
): void {
const expiresAt = resolveExpiresAtMsFromDurationMs(CHAT_TYPE_CACHE_TTL_MS, { nowMs: now });
resolvedChatTypeCache.delete(cacheKey);
if (expiresAt !== undefined) {
resolvedChatTypeCache.set(cacheKey, { value, expiresAt });
}
}
async function resolveCardActionChatType(params: {
event: FeishuCardActionEvent;
account: ReturnType<typeof resolveFeishuRuntimeAccount>;
@@ -276,12 +226,8 @@ async function resolveCardActionChatType(params: {
const now = Date.now();
pruneChatTypeCache(now);
const cached = resolvedChatTypeCache.get(cacheKey);
const cachedExpiresAt = cached ? asDateTimestampMs(cached.expiresAt) : undefined;
if (cached && cachedExpiresAt !== undefined) {
return cached.value;
}
if (cached) {
resolvedChatTypeCache.delete(cacheKey);
return cached.value;
}
try {
@@ -293,7 +239,10 @@ async function resolveCardActionChatType(params: {
normalizeResolvedCardActionChatType(response.data?.chat_mode) ??
normalizeResolvedCardActionChatType(response.data?.chat_type);
if (resolvedChatType) {
cacheResolvedCardActionChatType(cacheKey, resolvedChatType, now);
resolvedChatTypeCache.set(cacheKey, {
value: resolvedChatType,
expiresAt: now + CHAT_TYPE_CACHE_TTL_MS,
});
return resolvedChatType;
}
params.log(
@@ -400,17 +349,6 @@ export async function handleFeishuCardAction(params: {
typeof envelope.m?.prompt === "string" && envelope.m.prompt.trim()
? envelope.m.prompt
: `Run \`${command}\` in this Feishu conversation?`;
const expiresAt = resolveFeishuApprovalCardExpiresAt();
if (expiresAt === undefined) {
await sendInvalidInteractionNotice({
cfg,
event,
reason: "malformed",
accountId,
});
completeFeishuCardActionToken({ token: event.token, accountId: account.accountId });
return;
}
await sendCardFeishu({
cfg,
to: resolveCallbackTarget(event),
@@ -420,7 +358,7 @@ export async function handleFeishuCardAction(params: {
command,
prompt,
sessionKey: envelope.c?.s,
expiresAt,
expiresAt: Date.now() + FEISHU_APPROVAL_CARD_TTL_MS,
chatType: await resolveCardActionChatType({
event,
account,

View File

@@ -88,25 +88,6 @@ describe("feishu quick-action launcher", () => {
expectFirstSentCardUsesFillWidthOnly(sendCardFeishuMock);
});
it("does not send launcher cards when expiry would exceed a valid Date", async () => {
const runtime: RuntimeEnv = createRuntimeEnv();
const handled = await maybeHandleFeishuQuickActionMenu({
cfg,
eventKey: "quick-actions",
operatorOpenId: "u123",
accountId: "main",
runtime,
now: 8_640_000_000_000_000,
});
expect(handled).toBe(false);
expect(sendCardFeishuMock).not.toHaveBeenCalled();
expect(runtime.log).toHaveBeenCalledWith(
"feishu[main]: failed to open quick-action launcher for u123: invalid expiry clock",
);
});
it("falls back to legacy menu handling when launcher send fails", async () => {
sendCardFeishuMock.mockRejectedValueOnce(new Error("network"));
const runtime: RuntimeEnv = createRuntimeEnv();

View File

@@ -1,7 +1,3 @@
import {
asDateTimestampMs,
resolveExpiresAtMsFromDurationMs,
} from "openclaw/plugin-sdk/number-runtime";
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { ClawdbotConfig, RuntimeEnv } from "../runtime-api.js";
import { createFeishuCardInteractionEnvelope } from "./card-interaction.js";
@@ -100,17 +96,7 @@ export async function maybeHandleFeishuQuickActionMenu(params: {
return false;
}
const now = asDateTimestampMs(params.now ?? Date.now());
const expiresAt =
now === undefined
? undefined
: resolveExpiresAtMsFromDurationMs(FEISHU_QUICK_ACTION_CARD_TTL_MS, { nowMs: now });
if (expiresAt === undefined) {
params.runtime?.log?.(
`feishu[${params.accountId ?? "default"}]: failed to open quick-action launcher for ${params.operatorOpenId}: invalid expiry clock`,
);
return false;
}
const expiresAt = (params.now ?? Date.now()) + FEISHU_QUICK_ACTION_CARD_TTL_MS;
try {
await sendCardFeishu({
cfg: params.cfg,

View File

@@ -187,32 +187,6 @@ describe("probeFeishu", () => {
expect(requestFn).toHaveBeenCalledTimes(1);
});
it("does not cache probe results when the expiry would exceed a valid Date", async () => {
await withFakeTimers(async () => {
vi.setSystemTime(new Date(8_640_000_000_000_000));
const requestFn = setupSuccessClient();
const { first, second } = await readSequentialDefaultProbePair();
expect(first).toEqual(second);
expect(requestFn).toHaveBeenCalledTimes(2);
});
});
it("evicts cached probe results when the current clock is invalid", async () => {
const requestFn = setupSuccessClient();
await probeFeishu(DEFAULT_CREDS);
const dateNow = vi.spyOn(Date, "now").mockReturnValue(Number.NaN);
try {
await probeFeishu(DEFAULT_CREDS);
} finally {
dateNow.mockRestore();
}
expect(requestFn).toHaveBeenCalledTimes(2);
});
it("makes a fresh API call after cache expires", async () => {
await withFakeTimers(async () => {
const requestFn = setupSuccessClient();

View File

@@ -1,8 +1,4 @@
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import {
asDateTimestampMs,
resolveExpiresAtMsFromDurationMs,
} from "openclaw/plugin-sdk/number-runtime";
import { raceWithTimeoutAndAbort } from "./async.js";
import { createFeishuClient, type FeishuClientCredentials } from "./client.js";
import type { FeishuProbeResult } from "./types.js";
@@ -42,12 +38,7 @@ function setCachedProbeResult(
result: FeishuProbeResult,
ttlMs: number,
): FeishuProbeResult {
const expiresAt = resolveExpiresAtMsFromDurationMs(ttlMs);
if (expiresAt === undefined) {
probeCache.delete(cacheKey);
return result;
}
probeCache.set(cacheKey, { result, expiresAt });
probeCache.set(cacheKey, { result, expiresAt: Date.now() + ttlMs });
if (probeCache.size > MAX_PROBE_CACHE_SIZE) {
const oldest = probeCache.keys().next().value;
if (oldest !== undefined) {
@@ -83,13 +74,8 @@ export async function probeFeishu(
// pollute each other's cache entry.
const cacheKey = creds.accountId ?? `${creds.appId}:${creds.appSecret.slice(0, 8)}`;
const cached = probeCache.get(cacheKey);
if (cached) {
const now = asDateTimestampMs(Date.now());
const expiresAt = asDateTimestampMs(cached.expiresAt);
if (now !== undefined && expiresAt !== undefined && expiresAt > now) {
return cached.result;
}
probeCache.delete(cacheKey);
if (cached && cached.expiresAt > Date.now()) {
return cached.result;
}
try {

View File

@@ -251,13 +251,6 @@ function applyNewAppSecurityPolicy(
// Scan-to-create flow
// ---------------------------------------------------------------------------
let appRegistrationModulePromise: Promise<typeof import("./app-registration.js")> | null = null;
const loadAppRegistrationModule = async () => {
appRegistrationModulePromise ??= import("./app-registration.js");
return await appRegistrationModulePromise;
};
async function promptFeishuDomain(params: {
prompter: WizardPrompter;
initialValue?: FeishuDomain;
@@ -288,7 +281,7 @@ async function runScanToCreate(
domain: FeishuDomain,
): Promise<AppRegistrationResult | null> {
const { beginAppRegistration, initAppRegistration, pollAppRegistration, printQrCode } =
await loadAppRegistrationModule();
await import("./app-registration.js");
try {
await initAppRegistration(domain);
} catch {
@@ -399,7 +392,7 @@ async function runNewAppFlow(params: {
// Fetch openId via API for manual flow.
if (appId && appSecretProbeValue) {
const { getAppOwnerOpenId } = await loadAppRegistrationModule();
const { getAppOwnerOpenId } = await import("./app-registration.js");
scanOpenId = await getAppOwnerOpenId({
appId,
appSecret: appSecretProbeValue,

View File

@@ -50,7 +50,6 @@ describe("FeishuStreamingSession", () => {
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
@@ -112,45 +111,6 @@ describe("FeishuStreamingSession", () => {
);
}
function mockStreamingTokenStart(resolveAuthJson: (token: string) => Record<string, unknown>): {
authTokens: string[];
client: ConstructorParameters<typeof FeishuStreamingSession>[0];
} {
const release = vi.fn(async () => {});
const authTokens: string[] = [];
fetchWithSsrFGuardMock.mockImplementation(
async ({ url }: { url: string; init?: { body?: string } }) => {
if (url.includes("/auth/")) {
const token = `token-${authTokens.length + 1}`;
authTokens.push(token);
return {
response: { ok: true, json: async () => resolveAuthJson(token) },
release,
};
}
return {
response: {
ok: true,
json: async () => ({
code: 0,
msg: "ok",
data: { card_id: `card-${authTokens.length}` },
}),
},
release,
};
},
);
const client = {
im: {
message: {
create: vi.fn(async () => ({ code: 0, msg: "ok", data: { message_id: "om_1" } })),
},
},
} as unknown as ConstructorParameters<typeof FeishuStreamingSession>[0];
return { authTokens, client };
}
it("flushes throttled pending text after the throttle window", async () => {
vi.useFakeTimers();
vi.setSystemTime(1_000);
@@ -386,12 +346,46 @@ describe("FeishuStreamingSession", () => {
it("bounds streaming token cache lifetime when token expiry overflows", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-29T12:00:00.000Z"));
const { authTokens, client } = mockStreamingTokenStart((token) => ({
code: 0,
msg: "ok",
tenant_access_token: token,
expire: Number.MAX_SAFE_INTEGER,
}));
const release = vi.fn(async () => {});
const authTokens: string[] = [];
fetchWithSsrFGuardMock.mockImplementation(
async ({ url }: { url: string; init?: { body?: string } }) => {
if (url.includes("/auth/")) {
const token = `token-${authTokens.length + 1}`;
authTokens.push(token);
return {
response: {
ok: true,
json: async () => ({
code: 0,
msg: "ok",
tenant_access_token: token,
expire: Number.MAX_SAFE_INTEGER,
}),
},
release,
};
}
return {
response: {
ok: true,
json: async () => ({
code: 0,
msg: "ok",
data: { card_id: `card-${authTokens.length}` },
}),
},
release,
};
},
);
const client = {
im: {
message: {
create: vi.fn(async () => ({ code: 0, msg: "ok", data: { message_id: "om_1" } })),
},
},
} as never;
await new FeishuStreamingSession(client, {
appId: "app_unsafe_token_expiry",
@@ -407,55 +401,6 @@ describe("FeishuStreamingSession", () => {
expect(authTokens).toEqual(["token-1", "token-2"]);
});
it("bounds streaming token fallback lifetime when the process clock is invalid", async () => {
const dateNow = vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_001);
const { authTokens, client } = mockStreamingTokenStart((token) => ({
code: 0,
msg: "ok",
tenant_access_token: token,
}));
await new FeishuStreamingSession(client, {
appId: "app_invalid_clock_token_expiry",
appSecret: "secret",
}).start("chat_id", "open_id");
expect(authTokens).toEqual(["token-1"]);
dateNow.mockReturnValue(7200 * 1000 - 60_000 + 1);
await new FeishuStreamingSession(client, {
appId: "app_invalid_clock_token_expiry",
appSecret: "secret",
}).start("chat_id", "open_id");
expect(authTokens).toEqual(["token-1", "token-2"]);
dateNow.mockRestore();
});
it("treats an invalid process clock as a streaming token cache miss", async () => {
const dateNow = vi.spyOn(Date, "now").mockReturnValue(Date.parse("2026-05-29T12:00:00.000Z"));
const { authTokens, client } = mockStreamingTokenStart((token) => ({
code: 0,
msg: "ok",
tenant_access_token: token,
expire: 7200,
}));
await new FeishuStreamingSession(client, {
appId: "app_invalid_clock_cache_miss",
appSecret: "secret",
}).start("chat_id", "open_id");
expect(authTokens).toEqual(["token-1"]);
dateNow.mockReturnValue(8_640_000_000_000_001);
await new FeishuStreamingSession(client, {
appId: "app_invalid_clock_cache_miss",
appSecret: "secret",
}).start("chat_id", "open_id");
expect(authTokens).toEqual(["token-1", "token-2"]);
dateNow.mockRestore();
});
});
describe("mergeStreamingText", () => {

View File

@@ -3,11 +3,7 @@
*/
import type { Client } from "@larksuiteoapi/node-sdk";
import {
asDateTimestampMs,
resolveDateTimestampMs,
resolveExpiresAtMsFromDurationSeconds,
} from "openclaw/plugin-sdk/number-runtime";
import { resolveExpiresAtMsFromDurationSeconds } from "openclaw/plugin-sdk/number-runtime";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { getFeishuUserAgent } from "./client.js";
import { resolveFeishuCardTemplate, type CardHeaderConfig } from "./send.js";
@@ -52,17 +48,13 @@ const FEISHU_STREAMING_TOKEN_DEFAULT_LIFETIME_SECONDS = 7200;
// Token cache (keyed by domain + appId)
const tokenCache = new Map<string, { token: string; expiresAt: number }>();
function resolveStreamingTokenExpiresAt(value: unknown, nowMs = Date.now()): number {
const now = resolveDateTimestampMs(nowMs);
function resolveStreamingTokenExpiresAt(value: unknown): number {
if (typeof value === "number" && Number.isFinite(value) && value <= 0) {
return now;
return Date.now();
}
return (
resolveExpiresAtMsFromDurationSeconds(value, { nowMs: now }) ??
resolveExpiresAtMsFromDurationSeconds(FEISHU_STREAMING_TOKEN_DEFAULT_LIFETIME_SECONDS, {
nowMs: now,
}) ??
now
resolveExpiresAtMsFromDurationSeconds(value) ??
Date.now() + FEISHU_STREAMING_TOKEN_DEFAULT_LIFETIME_SECONDS * 1000
);
}
@@ -93,11 +85,7 @@ function resolveAllowedHostnames(domain?: FeishuDomain): string[] {
async function getToken(creds: Credentials): Promise<string> {
const key = `${creds.domain ?? "feishu"}|${creds.appId}`;
const cached = tokenCache.get(key);
const rawNow = Date.now();
const hasValidClock = asDateTimestampMs(rawNow) !== undefined;
const now = resolveDateTimestampMs(rawNow);
const minUsableExpiresAt = resolveExpiresAtMsFromDurationSeconds(60, { nowMs: now }) ?? now;
if (cached && hasValidClock && cached.expiresAt > minUsableExpiresAt) {
if (cached && cached.expiresAt > Date.now() + 60000) {
return cached.token;
}
@@ -127,7 +115,7 @@ async function getToken(creds: Credentials): Promise<string> {
}
tokenCache.set(key, {
token: data.tenant_access_token,
expiresAt: resolveStreamingTokenExpiresAt(data.expire, now),
expiresAt: resolveStreamingTokenExpiresAt(data.expire),
});
return data.tenant_access_token;
}

View File

@@ -23,3 +23,9 @@ export function registerFeishuSubagentHooks(api: OpenClawPluginApi): void {
handleFeishuSubagentEnded(event);
});
}
export {
handleFeishuSubagentDeliveryTarget,
handleFeishuSubagentEnded,
handleFeishuSubagentSpawning,
} from "./src/subagent-hooks.js";

View File

@@ -37,19 +37,6 @@ import { handleGoogleMeetNodeHostCommand } from "./src/node-host.js";
import { GoogleMeetRuntime } from "./src/runtime.js";
import { isGoogleMeetBrowserManualActionError } from "./src/transports/chrome-create.js";
let googleMeetCreateModulePromise: Promise<typeof import("./src/create.js")> | null = null;
let googleMeetCliModulePromise: Promise<typeof import("./src/cli.js")> | null = null;
const loadGoogleMeetCreateModule = async () => {
googleMeetCreateModulePromise ??= import("./src/create.js");
return await googleMeetCreateModulePromise;
};
const loadGoogleMeetCliModule = async () => {
googleMeetCliModulePromise ??= import("./src/cli.js");
return await googleMeetCliModulePromise;
};
const googleMeetConfigSchema = {
parse(value: unknown) {
return resolveGoogleMeetConfig(value);
@@ -506,7 +493,7 @@ async function createMeetFromParams(params: {
runtime: OpenClawPluginApi["runtime"];
raw: Record<string, unknown>;
}) {
const create = await loadGoogleMeetCreateModule();
const create = await import("./src/create.js");
return create.createMeetFromParams(params);
}
@@ -516,7 +503,7 @@ async function createAndJoinMeetFromParams(params: {
raw: Record<string, unknown>;
ensureRuntime: () => Promise<GoogleMeetRuntime>;
}) {
const create = await loadGoogleMeetCreateModule();
const create = await import("./src/create.js");
return create.createAndJoinMeetFromParams(params);
}
@@ -628,7 +615,7 @@ async function exportGoogleMeetBundleFromParams(
}),
]);
const { buildGoogleMeetExportManifest, googleMeetExportFileNames, writeMeetExportBundle } =
await loadGoogleMeetCliModule();
await import("./src/cli.js");
const calendarId = normalizeOptionalString(raw.calendarId);
const request = {
...(resolved.meeting ? { meeting: resolved.meeting } : {}),
@@ -1202,7 +1189,7 @@ export default definePluginEntry({
api.registerCli(
async ({ program }) => {
const { registerGoogleMeetCli } = await loadGoogleMeetCliModule();
const { registerGoogleMeetCli } = await import("./src/cli.js");
registerGoogleMeetCli({
program,
config,

View File

@@ -7,7 +7,6 @@ import {
describe("Google Meet OAuth", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
vi.useRealTimers();
});
@@ -118,27 +117,6 @@ describe("Google Meet OAuth", () => {
expect(tokens.expiresAt).toBe(Date.now() + 3600 * 1000);
});
it("bounds fallback token lifetimes when the process clock is invalid", async () => {
vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_001);
const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => {
return new Response(
JSON.stringify({
access_token: "new-access-token",
expires_in: Number.MAX_SAFE_INTEGER,
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
});
vi.stubGlobal("fetch", fetchMock);
const tokens = await refreshGoogleMeetAccessToken({
clientId: "client-id",
refreshToken: "refresh-token",
});
expect(tokens.expiresAt).toBe(3600 * 1000);
});
it("keeps explicit zero-second token lifetimes immediately stale", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-29T12:00:00.000Z"));

View File

@@ -1,6 +1,5 @@
import {
MAX_DATE_TIMESTAMP_MS,
resolveDateTimestampMs,
resolveExpiresAtMsFromDurationSeconds,
} from "openclaw/plugin-sdk/number-runtime";
import { generateHexPkceVerifierChallenge } from "openclaw/plugin-sdk/provider-auth";
@@ -25,17 +24,13 @@ const GOOGLE_MEET_SCOPES = [
"https://www.googleapis.com/auth/drive.meet.readonly",
] as const;
function resolveGoogleMeetTokenExpiresAt(value: unknown, nowMs = Date.now()): number {
const now = resolveDateTimestampMs(nowMs);
function resolveGoogleMeetTokenExpiresAt(value: unknown): number {
if (typeof value === "number" && Number.isFinite(value) && value <= 0) {
return now;
return Date.now();
}
return (
resolveExpiresAtMsFromDurationSeconds(value, { nowMs: now }) ??
resolveExpiresAtMsFromDurationSeconds(GOOGLE_MEET_DEFAULT_TOKEN_LIFETIME_SECONDS, {
nowMs: now,
}) ??
now
resolveExpiresAtMsFromDurationSeconds(value) ??
Date.now() + GOOGLE_MEET_DEFAULT_TOKEN_LIFETIME_SECONDS * 1000
);
}

View File

@@ -20,13 +20,6 @@ const ENV_VARS = [
"GEMINI_CLI_OAUTH_CLIENT_SECRET",
] as const;
let oauthRuntimeModulePromise: Promise<typeof import("./oauth.runtime.js")> | null = null;
const loadOauthRuntimeModule = async () => {
oauthRuntimeModulePromise ??= import("./oauth.runtime.js");
return await oauthRuntimeModulePromise;
};
async function fetchGeminiCliUsage(ctx: ProviderFetchUsageSnapshotContext) {
return await fetchGeminiUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn, PROVIDER_ID);
}
@@ -65,7 +58,7 @@ export function buildGoogleGeminiCliProvider(): ProviderPlugin {
const spin = ctx.prompter.progress("Starting Gemini CLI OAuth…");
try {
const { loginGeminiCliOAuth } = await loadOauthRuntimeModule();
const { loginGeminiCliOAuth } = await import("./oauth.runtime.js");
const result = await loginGeminiCliOAuth({
isRemote: ctx.isRemote,
openUrl: ctx.openUrl,
@@ -129,7 +122,7 @@ export function buildGoogleGeminiCliProvider(): ProviderPlugin {
isModernModelRef: ({ modelId }) => isModernGoogleModel(modelId),
formatApiKey: (cred) => formatGoogleOauthApiKey(cred),
refreshOAuth: async (cred) => {
const { refreshGeminiCliOAuthToken } = await loadOauthRuntimeModule();
const { refreshGeminiCliOAuthToken } = await import("./oauth.runtime.js");
return await refreshGeminiCliOAuthToken(cred);
},
resolveUsageAuth: async (ctx) => {

View File

@@ -951,39 +951,6 @@ describe("loginGeminiCliOAuth", () => {
expect(result.expires).toBeLessThanOrEqual(beforeRefresh);
});
it("keeps invalid clocks out of refreshed Gemini CLI credential expiry", async () => {
mockSettingsExistsSync.mockReturnValue(true);
mockSettingsReadFileSync.mockReturnValue(
JSON.stringify({
security: {
auth: {
selectedType: "oauth-personal",
},
},
}),
);
installGeminiOAuthFetchMock(() => undefined, {
tokenResponse: () =>
responseJson({
access_token: "access-token",
expires_in: 3600,
}),
});
const dateNow = vi.spyOn(Date, "now").mockReturnValue(Number.NaN);
try {
const { refreshTokensForGeminiCli } = await import("./oauth.token.js");
const result = await refreshTokensForGeminiCli({
refresh: "refresh-token",
email: "lobster@openclaw.ai",
});
expect(result.expires).toBe(0);
} finally {
dateNow.mockRestore();
}
});
it("keeps unsafe token expiry values out of refreshed Gemini CLI credentials", async () => {
mockSettingsExistsSync.mockReturnValue(true);
mockSettingsReadFileSync.mockReturnValue(

View File

@@ -1,7 +1,4 @@
import {
asDateTimestampMs,
resolveExpiresAtMsFromDurationSeconds,
} from "openclaw/plugin-sdk/number-runtime";
import { resolveExpiresAtMsFromDurationSeconds } from "openclaw/plugin-sdk/number-runtime";
import { resolveOAuthClientConfig } from "./oauth.credentials.js";
import { fetchWithTimeout } from "./oauth.http.js";
import { resolveGoogleOAuthIdentity, resolveGooglePersonalOAuthIdentity } from "./oauth.project.js";
@@ -37,18 +34,10 @@ async function requestTokenGrant(body: URLSearchParams): Promise<{
};
}
function resolveExpiredTokenTimestampMs(nowMs: number): number {
return asDateTimestampMs(nowMs - TOKEN_EXPIRY_BUFFER_MS) ?? nowMs;
}
function resolveTokenExpiresAt(value: unknown): number {
const nowMs = asDateTimestampMs(Date.now());
if (nowMs === undefined) {
return 0;
}
return (
resolveExpiresAtMsFromDurationSeconds(value, { nowMs, bufferMs: TOKEN_EXPIRY_BUFFER_MS }) ??
resolveExpiredTokenTimestampMs(nowMs)
resolveExpiresAtMsFromDurationSeconds(value, { bufferMs: TOKEN_EXPIRY_BUFFER_MS }) ??
Date.now() - TOKEN_EXPIRY_BUFFER_MS
);
}

View File

@@ -100,7 +100,6 @@ describe("buildGoogleRealtimeVoiceProvider", () => {
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
for (const key of ENV_KEYS) {
const value = envSnapshot[key];
if (value === undefined) {
@@ -453,20 +452,6 @@ describe("buildGoogleRealtimeVoiceProvider", () => {
]);
});
it("rejects browser session expiry outside Date range", async () => {
vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_001);
const provider = buildGoogleRealtimeVoiceProvider();
await expect(
provider.createBrowserSession?.({
providerConfig: {
apiKey: "gemini-key",
},
}),
).rejects.toThrow("Google realtime browser session expiry is outside the supported Date range");
expect(createTokenMock).not.toHaveBeenCalled();
});
it("can opt out of Google Live session resumption and context compression", async () => {
const provider = buildGoogleRealtimeVoiceProvider();
const bridge = provider.createBridge({

View File

@@ -16,7 +16,6 @@ import type {
ThinkingConfig,
TurnCoverage,
} from "@google/genai";
import { timestampMsToIsoString } from "openclaw/plugin-sdk/number-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-onboard";
import type {
RealtimeVoiceAudioFormat,
@@ -858,11 +857,6 @@ async function createGoogleRealtimeBrowserSession(
const voice = req.voice ?? config.voice ?? GOOGLE_REALTIME_DEFAULT_VOICE;
const expiresAtMs = Date.now() + GOOGLE_REALTIME_BROWSER_SESSION_TTL_MS;
const newSessionExpiresAtMs = Date.now() + GOOGLE_REALTIME_BROWSER_NEW_SESSION_TTL_MS;
const expireTime = timestampMsToIsoString(expiresAtMs);
const newSessionExpireTime = timestampMsToIsoString(newSessionExpiresAtMs);
if (!expireTime || !newSessionExpireTime) {
throw new Error("Google realtime browser session expiry is outside the supported Date range");
}
const ai = createGoogleGenAI({
apiKey,
httpOptions: {
@@ -872,8 +866,8 @@ async function createGoogleRealtimeBrowserSession(
const token = await ai.authTokens.create({
config: {
uses: 1,
expireTime,
newSessionExpireTime,
expireTime: new Date(expiresAtMs).toISOString(),
newSessionExpireTime: new Date(newSessionExpiresAtMs).toISOString(),
liveConnectConstraints: {
model,
config: buildGoogleLiveConnectConfig({

View File

@@ -292,7 +292,6 @@ describe("google transport stream", () => {
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllEnvs();
});
@@ -768,29 +767,6 @@ describe("google transport stream", () => {
expect(tokenFetchMock).not.toHaveBeenCalled();
});
it("does not cache google-auth ADC tokens when fallback expiry would exceed Date range", async () => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-google-vertex-authlib-expiry-"));
vi.useFakeTimers();
vi.setSystemTime(new Date(8_640_000_000_000_000));
vi.stubEnv("GOOGLE_APPLICATION_CREDENTIALS", "");
vi.stubEnv("HOME", path.join(tempDir, "home"));
vi.stubEnv("APPDATA", "");
googleAuthGetAccessTokenMock
.mockResolvedValueOnce("ya29.first-token")
.mockResolvedValueOnce("ya29.second-token");
const tokenFetchMock = vi.fn();
await expect(resolveGoogleVertexAuthorizedUserHeaders(tokenFetchMock)).resolves.toEqual({
Authorization: "Bearer ya29.first-token",
});
await expect(resolveGoogleVertexAuthorizedUserHeaders(tokenFetchMock)).resolves.toEqual({
Authorization: "Bearer ya29.second-token",
});
expect(googleAuthGetAccessTokenMock).toHaveBeenCalledTimes(2);
expect(tokenFetchMock).not.toHaveBeenCalled();
});
it("uses google-auth-library bearer auth for Google Vertex credential marker requests", async () => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-google-vertex-authlib-stream-"));
vi.stubEnv("GOOGLE_APPLICATION_CREDENTIALS", "");

View File

@@ -2,11 +2,7 @@ import { existsSync, readFileSync } from "node:fs";
import { readFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import {
asDateTimestampMs,
resolveExpiresAtMsFromDurationMs,
resolveExpiresAtMsFromDurationSeconds,
} from "openclaw/plugin-sdk/number-runtime";
import { resolveExpiresAtMsFromDurationSeconds } from "openclaw/plugin-sdk/number-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
type GoogleAuthorizedUserCredentials = {
@@ -36,7 +32,6 @@ const GOOGLE_VERTEX_OAUTH_SCOPE = "https://www.googleapis.com/auth/cloud-platfor
// leaves the gateway.
const GOOGLE_VERTEX_TOKEN_EXPIRY_BUFFER_MS = 60_000;
const GOOGLE_VERTEX_DEFAULT_TOKEN_LIFETIME_SECONDS = 3600;
const GOOGLE_VERTEX_AUTHLIB_TOKEN_CACHE_MS = 5 * 60_000;
let cachedGoogleVertexAuthorizedUserToken: GoogleVertexAuthorizedUserToken | undefined;
let cachedGoogleAuthClient:
@@ -48,36 +43,18 @@ let cachedGoogleAuthClient:
| undefined;
let cachedGoogleVertexAdcToken: GoogleVertexAdcToken | undefined;
function isGoogleVertexTokenFresh(expiresAtMsRaw: number, nowRaw = Date.now()): boolean {
const expiresAtMs = asDateTimestampMs(expiresAtMsRaw);
const nowMs = asDateTimestampMs(nowRaw);
if (expiresAtMs === undefined || nowMs === undefined) {
return false;
function resolveAuthorizedUserTokenExpiresAtMs(value: unknown, nowMs: number): number {
if (typeof value === "number" && Number.isFinite(value)) {
return (
resolveExpiresAtMsFromDurationSeconds(Math.max(1, value), { nowMs }) ??
nowMs - GOOGLE_VERTEX_TOKEN_EXPIRY_BUFFER_MS
);
}
const minFreshExpiresAtMs = resolveExpiresAtMsFromDurationMs(
GOOGLE_VERTEX_TOKEN_EXPIRY_BUFFER_MS,
{ nowMs },
return (
resolveExpiresAtMsFromDurationSeconds(GOOGLE_VERTEX_DEFAULT_TOKEN_LIFETIME_SECONDS, {
nowMs,
}) ?? nowMs - GOOGLE_VERTEX_TOKEN_EXPIRY_BUFFER_MS
);
return minFreshExpiresAtMs !== undefined && expiresAtMs > minFreshExpiresAtMs;
}
function resolveAuthorizedUserTokenExpiresAtMs(value: unknown, nowRaw: number): number | undefined {
const nowMs = asDateTimestampMs(nowRaw);
if (nowMs === undefined) {
return undefined;
}
const lifetimeSeconds =
typeof value === "number" && Number.isFinite(value)
? Math.max(1, value)
: GOOGLE_VERTEX_DEFAULT_TOKEN_LIFETIME_SECONDS;
return resolveExpiresAtMsFromDurationSeconds(lifetimeSeconds, { nowMs }) ?? nowMs;
}
function resolveGoogleAuthLibraryTokenExpiresAtMs(nowRaw = Date.now()): number | undefined {
const nowMs = asDateTimestampMs(nowRaw);
return nowMs === undefined
? undefined
: resolveExpiresAtMsFromDurationMs(GOOGLE_VERTEX_AUTHLIB_TOKEN_CACHE_MS, { nowMs });
}
export function resetGoogleVertexAuthorizedUserTokenCacheForTest(): void {
@@ -200,7 +177,7 @@ async function refreshGoogleVertexAuthorizedUserAccessToken(params: {
if (
cached?.credentialsPath === params.credentialsPath &&
cached.refreshToken === refreshToken &&
isGoogleVertexTokenFresh(cached.expiresAtMs)
cached.expiresAtMs - Date.now() > GOOGLE_VERTEX_TOKEN_EXPIRY_BUFFER_MS
) {
return cached.token;
}
@@ -231,15 +208,12 @@ async function refreshGoogleVertexAuthorizedUserAccessToken(params: {
throw new Error("Google Vertex ADC token refresh response did not include an access_token.");
}
const nowMs = Date.now();
const expiresAtMs = resolveAuthorizedUserTokenExpiresAtMs(payload?.expires_in, nowMs);
if (expiresAtMs !== undefined) {
cachedGoogleVertexAuthorizedUserToken = {
token,
expiresAtMs,
credentialsPath: params.credentialsPath,
refreshToken,
};
}
cachedGoogleVertexAuthorizedUserToken = {
token,
expiresAtMs: resolveAuthorizedUserTokenExpiresAtMs(payload?.expires_in, nowMs),
credentialsPath: params.credentialsPath,
refreshToken,
};
return token;
}
@@ -264,7 +238,7 @@ async function resolveGoogleVertexAccessTokenViaGoogleAuth(): Promise<string> {
const auth = await cachedGoogleAuthClient.promise;
const cached = cachedGoogleVertexAdcToken;
if (cached && isGoogleVertexTokenFresh(cached.expiresAtMs)) {
if (cached && cached.expiresAtMs - Date.now() > GOOGLE_VERTEX_TOKEN_EXPIRY_BUFFER_MS) {
return cached.token;
}
@@ -281,13 +255,10 @@ async function resolveGoogleVertexAccessTokenViaGoogleAuth(): Promise<string> {
// `getAccessToken()` return type, so we cache for a conservative 5 minutes.
// The library itself already refreshes well before its own internal expiry,
// so this cache is mainly to avoid hot-loop calls into the auth client.
const expiresAtMs = resolveGoogleAuthLibraryTokenExpiresAtMs();
if (expiresAtMs !== undefined) {
cachedGoogleVertexAdcToken = {
token: normalized,
expiresAtMs,
};
}
cachedGoogleVertexAdcToken = {
token: normalized,
expiresAtMs: Date.now() + 5 * 60_000,
};
return normalized;
}

View File

@@ -1,27 +1,16 @@
import { EventEmitter } from "node:events";
import { afterEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
const spawnMock = vi.hoisted(() => vi.fn());
const createIMessageRpcClientMock = vi.hoisted(() => vi.fn());
vi.mock("node:child_process", async (importOriginal) => ({
...(await importOriginal<typeof import("node:child_process")>()),
spawn: spawnMock,
}));
vi.mock("./client.js", () => ({
createIMessageRpcClient: createIMessageRpcClientMock,
}));
const { imessageActionsRuntime, findChatGuidForTest, normalizeDirectChatIdentifierForTest } =
await import("./actions.runtime.js");
afterEach(() => {
vi.restoreAllMocks();
createIMessageRpcClientMock.mockReset();
spawnMock.mockReset();
});
function mockSpawnJsonResponse(payload: Record<string, unknown> = { success: true }) {
spawnMock.mockImplementationOnce(() => {
const child = new EventEmitter() as EventEmitter & {
@@ -40,13 +29,6 @@ function mockSpawnJsonResponse(payload: Record<string, unknown> = { success: tru
});
}
function mockRpcChatList(chats: Array<Record<string, unknown>>) {
const request = vi.fn().mockResolvedValue({ chats });
const stop = vi.fn().mockResolvedValue(undefined);
createIMessageRpcClientMock.mockResolvedValueOnce({ request, stop });
return { request, stop };
}
describe("imessage actions runtime", () => {
it("passes the configured Messages db path to private API bridge commands", async () => {
mockSpawnJsonResponse();
@@ -81,58 +63,6 @@ describe("imessage actions runtime", () => {
{ stdio: ["ignore", "pipe", "pipe"] },
);
});
it("drops cached chats.list entries when the current clock is not a valid date timestamp", async () => {
vi.spyOn(Date, "now").mockReturnValueOnce(1_700_000_000_000).mockReturnValueOnce(Number.NaN);
const firstClient = mockRpcChatList([{ id: 1, guid: "iMessage;+;first" }]);
const secondClient = mockRpcChatList([{ id: 2, guid: "iMessage;+;second" }]);
await expect(
imessageActionsRuntime.resolveChatGuidForTarget({
target: { kind: "chat_id", chatId: 1 },
options: { cliPath: "imsg-invalid-clock" },
}),
).resolves.toBe("iMessage;+;first");
await expect(
imessageActionsRuntime.resolveChatGuidForTarget({
target: { kind: "chat_id", chatId: 2 },
options: { cliPath: "imsg-invalid-clock" },
}),
).resolves.toBe("iMessage;+;second");
expect(createIMessageRpcClientMock).toHaveBeenCalledTimes(2);
expect(firstClient.request).toHaveBeenCalledWith(
"chats.list",
{ limit: 1000 },
{ timeoutMs: undefined },
);
expect(secondClient.request).toHaveBeenCalledWith(
"chats.list",
{ limit: 1000 },
{ timeoutMs: undefined },
);
});
it("does not cache chats.list when the expiry timestamp would exceed the valid date range", async () => {
vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_000);
mockRpcChatList([{ id: 1, guid: "iMessage;+;first" }]);
mockRpcChatList([{ id: 2, guid: "iMessage;+;second" }]);
await expect(
imessageActionsRuntime.resolveChatGuidForTarget({
target: { kind: "chat_id", chatId: 1 },
options: { cliPath: "imsg-overflow-clock" },
}),
).resolves.toBe("iMessage;+;first");
await expect(
imessageActionsRuntime.resolveChatGuidForTarget({
target: { kind: "chat_id", chatId: 2 },
options: { cliPath: "imsg-overflow-clock" },
}),
).resolves.toBe("iMessage;+;second");
expect(createIMessageRpcClientMock).toHaveBeenCalledTimes(2);
});
});
describe("findChatGuid cross-format identifier resolution", () => {

View File

@@ -1,11 +1,7 @@
import { spawn } from "node:child_process";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { extname, join } from "node:path";
import {
asDateTimestampMs,
parseStrictInteger,
resolveExpiresAtMsFromDurationMs,
} from "openclaw/plugin-sdk/number-runtime";
import { parseStrictInteger } from "openclaw/plugin-sdk/number-runtime";
import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
import { appendIMessageCliStderrTail, appendIMessageCliStdout } from "./cli-output.js";
@@ -80,14 +76,12 @@ function chatListCacheGet(
cliPath: string,
dbPath?: string,
): ReadonlyArray<Record<string, unknown>> | null {
const key = chatListCacheKey(cliPath, dbPath);
const entry = chatListCache.get(key);
const entry = chatListCache.get(chatListCacheKey(cliPath, dbPath));
if (!entry) {
return null;
}
const now = asDateTimestampMs(Date.now());
if (now === undefined || entry.expiresAt <= now) {
chatListCache.delete(key);
if (entry.expiresAt < Date.now()) {
chatListCache.delete(chatListCacheKey(cliPath, dbPath));
return null;
}
return entry.list;
@@ -98,13 +92,9 @@ function chatListCacheSet(
dbPath: string | undefined,
list: ReadonlyArray<Record<string, unknown>>,
): void {
const expiresAt = resolveExpiresAtMsFromDurationMs(CHAT_LIST_CACHE_TTL_MS);
if (expiresAt === undefined) {
return;
}
chatListCache.set(chatListCacheKey(cliPath, dbPath), {
list,
expiresAt,
expiresAt: Date.now() + CHAT_LIST_CACHE_TTL_MS,
});
}

View File

@@ -24,7 +24,7 @@ import {
rememberIMessageReplyCache,
type IMessageChatContext,
} from "./monitor-reply-cache.js";
import { getCachedIMessagePrivateApiStatus, probeIMessagePrivateApi } from "./probe.js";
import { getCachedIMessagePrivateApiStatus } from "./probe.js";
import { parseIMessageTarget, type IMessageTarget } from "./targets.js";
const loadIMessageActionsRuntime = createLazyRuntimeNamedExport(
@@ -417,6 +417,7 @@ export const imessageMessageActions: ChannelMessageActionAdapter = {
// status adapter, which doesn't fire eagerly on first dispatch. Run
// an inline probe so the first react/send-rich attempt after `imsg
// launch` succeeds without requiring a manual `channels status`.
const { probeIMessagePrivateApi } = await import("./probe.js");
privateApiStatus = await probeIMessagePrivateApi(
cliPathForProbe,
account.config.probeTimeoutMs ?? DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS,

View File

@@ -168,7 +168,7 @@ describe("iMessage monitor last-route updates", () => {
expect(recordParams?.updateLastRoute?.sessionKey).toBe(recordParams?.sessionKey);
expect(recordParams?.updateLastRoute?.sessionKey).not.toBe("agent:main:main");
expect(recordParams?.updateLastRoute?.channel).toBe("imessage");
expect(recordParams?.updateLastRoute?.to).toBe("imessage:+15550001111");
expect(recordParams?.updateLastRoute?.to).toBe("+15550001111");
expect(recordParams?.updateLastRoute?.mainDmOwnerPin).toBeUndefined();
});

View File

@@ -3,7 +3,7 @@ import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { runIMessageCatchup } from "./catchup-bridge.js";
import { resolveCatchupConfig, saveIMessageCatchupCursor } from "./catchup.js";
import { resolveCatchupConfig } from "./catchup.js";
import type { IMessagePayload } from "./types.js";
type RpcCall = {
@@ -157,32 +157,6 @@ describe("runIMessageCatchup", () => {
expect(summary.replayed).toBe(1);
});
it("does not crash on Date-invalid persisted cursor timestamps", async () => {
const log = vi.fn();
await saveIMessageCatchupCursor("default", {
lastSeenMs: 8_700_000_000_000_000,
lastSeenRowid: 10,
});
const { client, calls } = makeFakeClient(() => {
throw new Error("unexpected rpc");
});
const summary = await runIMessageCatchup({
client: client as never,
accountId: "default",
config: resolveCatchupConfig({ enabled: true, perRunLimit: 50, maxAgeMinutes: 60 }),
includeAttachments: false,
dispatchPayload: async () => {},
runtime: { log },
});
expect(summary.querySucceeded).toBe(false);
expect(calls).toEqual([]);
expect(log).toHaveBeenCalledWith(
expect.stringContaining("imessage catchup: invalid since timestamp"),
);
});
it("returns querySucceeded=false when chats.list throws", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-08T12:00:00Z"));

View File

@@ -1,4 +1,3 @@
import { timestampMsToIsoString } from "openclaw/plugin-sdk/number-runtime";
import { warn } from "openclaw/plugin-sdk/runtime-env";
import type { IMessageRpcClient } from "../client.js";
import {
@@ -89,11 +88,6 @@ export async function runIMessageCatchup(
const payloadByGuid = new Map<string, IMessagePayload>();
const fetchFn: CatchupFetchFn = async ({ sinceMs, sinceRowid, limit }) => {
const sinceISO = timestampMsToIsoString(sinceMs);
if (!sinceISO) {
warnLog(`imessage catchup: invalid since timestamp ${sinceMs}`);
return { resolved: false, rows: [] };
}
let chatsResult: { chats?: ChatsListEntry[] } | undefined;
try {
chatsResult = await client.request<{ chats?: ChatsListEntry[] }>(
@@ -106,6 +100,7 @@ export async function runIMessageCatchup(
return { resolved: false, rows: [] };
}
const chats = chatsResult?.chats ?? [];
const sinceISO = new Date(sinceMs).toISOString();
const collected: IMessageCatchupRow[] = [];
const perChatLimit = Math.min(limit, PER_CHAT_HISTORY_LIMIT_CAP);
let historyFetchFailed = false;

View File

@@ -1,5 +1,3 @@
import { asDateTimestampMs } from "openclaw/plugin-sdk/number-runtime";
export type IMessagePrivateApiStatus = {
available: boolean;
v2Ready: boolean;
@@ -58,11 +56,7 @@ export function getCachedIMessagePrivateApiStatus(
if (!entry) {
return undefined;
}
if (entry.expiresAt === 0) {
return entry.status;
}
const now = asDateTimestampMs(Date.now());
if (now === undefined || entry.expiresAt <= now) {
if (entry.expiresAt > 0 && entry.expiresAt < Date.now()) {
bridgeStatusCache.delete(key);
return undefined;
}
@@ -74,9 +68,6 @@ export function setCachedIMessagePrivateApiStatus(
status: IMessagePrivateApiStatus,
expiresAt = 0,
): void {
if (expiresAt !== 0 && asDateTimestampMs(expiresAt) === undefined) {
return;
}
bridgeStatusCache.set(normalizeCliPath(cliPath), { status, expiresAt });
}

View File

@@ -1,16 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
clearCachedIMessagePrivateApiStatus,
getCachedIMessagePrivateApiStatus,
setCachedIMessagePrivateApiStatus,
} from "./private-api-status.js";
import { describe, expect, it } from "vitest";
import { imessageRpcSupportsMethod } from "./probe.js";
afterEach(() => {
vi.restoreAllMocks();
clearCachedIMessagePrivateApiStatus();
});
describe("imessageRpcSupportsMethod", () => {
it("returns false when the bridge is not available", () => {
expect(
@@ -102,35 +92,3 @@ describe("imessageRpcSupportsMethod", () => {
}
});
});
describe("iMessage private API status cache", () => {
const availableStatus = {
available: true,
v2Ready: true,
selectors: {},
rpcMethods: ["chats.list"],
};
it("drops expiring private API status when the current clock is not a valid date timestamp", () => {
clearCachedIMessagePrivateApiStatus();
setCachedIMessagePrivateApiStatus(
"imsg-invalid-private-clock",
availableStatus,
1_700_000_030_000,
);
vi.spyOn(Date, "now").mockReturnValue(Number.NaN);
expect(getCachedIMessagePrivateApiStatus("imsg-invalid-private-clock")).toBeUndefined();
});
it("does not cache private API status with an invalid expiry timestamp", () => {
clearCachedIMessagePrivateApiStatus();
setCachedIMessagePrivateApiStatus(
"imsg-overflow-private-clock",
availableStatus,
Number.POSITIVE_INFINITY,
);
expect(getCachedIMessagePrivateApiStatus("imsg-overflow-private-clock")).toBeUndefined();
});
});

View File

@@ -1,9 +1,5 @@
import path from "node:path";
import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-contract";
import {
asDateTimestampMs,
resolveExpiresAtMsFromDurationMs,
} from "openclaw/plugin-sdk/number-runtime";
import { runCommandWithTimeout } from "openclaw/plugin-sdk/process-runtime";
import { getRuntimeConfig } from "openclaw/plugin-sdk/runtime-config-snapshot";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
@@ -57,27 +53,6 @@ type RpcSupportCacheEntry = { result: RpcSupportResult; expiresAt: number };
const rpcSupportCache = new Map<string, RpcSupportCacheEntry>();
function getCachedRpcSupport(cliPath: string): RpcSupportResult | undefined {
const cached = rpcSupportCache.get(cliPath);
if (!cached) {
return undefined;
}
const now = asDateTimestampMs(Date.now());
if (now === undefined || cached.expiresAt <= now) {
rpcSupportCache.delete(cliPath);
return undefined;
}
return cached.result;
}
function setCachedRpcSupport(cliPath: string, result: RpcSupportResult): void {
const expiresAt = resolveExpiresAtMsFromDurationMs(RPC_SUPPORT_CACHE_TTL_MS);
if (expiresAt === undefined) {
return;
}
rpcSupportCache.set(cliPath, { result, expiresAt });
}
function isDefaultLocalIMessageCliPath(cliPath: string): boolean {
const trimmed = cliPath.trim();
return trimmed === "imsg" || (!trimmed.includes("/") && path.basename(trimmed) === "imsg");
@@ -94,9 +69,9 @@ export function resolveIMessageNonMacHostError(
}
async function probeRpcSupport(cliPath: string, timeoutMs: number): Promise<RpcSupportResult> {
const cached = getCachedRpcSupport(cliPath);
if (cached) {
return cached;
const cached = rpcSupportCache.get(cliPath);
if (cached && cached.expiresAt > Date.now()) {
return cached.result;
}
try {
const result = await runCommandWithTimeout([cliPath, "rpc", "--help"], { timeoutMs });
@@ -108,12 +83,18 @@ async function probeRpcSupport(cliPath: string, timeoutMs: number): Promise<RpcS
fatal: true,
error: 'imsg CLI does not support the "rpc" subcommand (update imsg)',
};
setCachedRpcSupport(cliPath, fatal);
rpcSupportCache.set(cliPath, {
result: fatal,
expiresAt: Date.now() + RPC_SUPPORT_CACHE_TTL_MS,
});
return fatal;
}
if (result.code === 0) {
const supported = { supported: true };
setCachedRpcSupport(cliPath, supported);
rpcSupportCache.set(cliPath, {
result: supported,
expiresAt: Date.now() + RPC_SUPPORT_CACHE_TTL_MS,
});
return supported;
}
return {

View File

@@ -5,7 +5,7 @@ import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vites
import { resolveIMessageAccount } from "./accounts.js";
import * as channelRuntimeModule from "./channel.runtime.js";
import * as clientModule from "./client.js";
import { clearIMessagePrivateApiCache, probeIMessage } from "./probe.js";
import { probeIMessage } from "./probe.js";
import { imessageSetupWizard } from "./setup-surface.js";
import { probeIMessageStatusAccount } from "./status-core.js";
@@ -159,7 +159,6 @@ describe("imessage setup status", () => {
describe("probeIMessage", () => {
beforeEach(() => {
vi.restoreAllMocks();
clearIMessagePrivateApiCache();
spawnMock.mockClear();
vi.spyOn(setupRuntime, "detectBinary").mockResolvedValue(true);
vi.spyOn(processRuntime, "runCommandWithTimeout").mockResolvedValue({
@@ -186,102 +185,6 @@ describe("probeIMessage", () => {
expect(createIMessageRpcClientMock).not.toHaveBeenCalled();
});
it("drops cached rpc support when the current clock is not a valid date timestamp", async () => {
vi.spyOn(Date, "now")
.mockReturnValueOnce(1_700_000_000_000)
.mockReturnValueOnce(Number.NaN)
.mockReturnValue(1_700_000_000_000);
const runCommand = vi
.spyOn(processRuntime, "runCommandWithTimeout")
.mockResolvedValueOnce({
stdout: "",
stderr: 'unknown command "rpc" for "imsg"',
code: 1,
signal: null,
killed: false,
termination: "exit",
})
.mockResolvedValueOnce({
stdout: "rpc help",
stderr: "",
code: 0,
signal: null,
killed: false,
termination: "exit",
})
.mockResolvedValueOnce({
stdout: JSON.stringify({
advanced_features: true,
v2_ready: true,
selectors: {},
rpc_methods: ["chats.list"],
}),
stderr: "",
code: 0,
signal: null,
killed: false,
termination: "exit",
})
.mockResolvedValueOnce({
stdout: "send-rich --file",
stderr: "",
code: 0,
signal: null,
killed: false,
termination: "exit",
});
vi.spyOn(clientModule, "createIMessageRpcClient").mockResolvedValue({
request: vi.fn().mockResolvedValue({ chats: [] }),
stop: vi.fn().mockResolvedValue(undefined),
} as unknown as Awaited<ReturnType<typeof clientModule.createIMessageRpcClient>>);
await expect(probeIMessage(1000, { cliPath: "imsg-invalid-rpc-clock" })).resolves.toMatchObject(
{
ok: false,
fatal: true,
},
);
await expect(probeIMessage(1000, { cliPath: "imsg-invalid-rpc-clock" })).resolves.toMatchObject(
{
ok: true,
},
);
expect(runCommand).toHaveBeenNthCalledWith(1, ["imsg-invalid-rpc-clock", "rpc", "--help"], {
timeoutMs: 1000,
});
expect(runCommand).toHaveBeenNthCalledWith(2, ["imsg-invalid-rpc-clock", "rpc", "--help"], {
timeoutMs: 1000,
});
});
it("does not cache rpc support when the expiry timestamp would exceed the valid date range", async () => {
vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_000);
const runCommand = vi.spyOn(processRuntime, "runCommandWithTimeout").mockResolvedValue({
stdout: "",
stderr: 'unknown command "rpc" for "imsg"',
code: 1,
signal: null,
killed: false,
termination: "exit",
});
await expect(
probeIMessage(1000, { cliPath: "imsg-overflow-rpc-clock" }),
).resolves.toMatchObject({
ok: false,
fatal: true,
});
await expect(
probeIMessage(1000, { cliPath: "imsg-overflow-rpc-clock" }),
).resolves.toMatchObject({
ok: false,
fatal: true,
});
expect(runCommand).toHaveBeenCalledTimes(2);
});
it("fails fast for default local imsg probes on non-mac hosts", async () => {
const createIMessageRpcClientMock = vi
.spyOn(clientModule, "createIMessageRpcClient")

View File

@@ -28,7 +28,7 @@ describe("imessage targets", () => {
it("parses sms handles with service", () => {
const target = parseIMessageTarget("sms:+1555");
expect(target).toEqual({ kind: "handle", to: "+1555", service: "sms", serviceExplicit: true });
expect(target).toEqual({ kind: "handle", to: "+1555", service: "sms" });
});
it("normalizes handles", () => {

View File

@@ -85,12 +85,6 @@ const loadMatrixChannelRuntime = createLazyRuntimeNamedExport(
() => import("./channel.runtime.js"),
"matrixChannelRuntime",
);
let matrixDoctorModulePromise: Promise<typeof import("./doctor.js")> | null = null;
const loadMatrixDoctorModule = async () => {
matrixDoctorModulePromise ??= import("./doctor.js");
return await matrixDoctorModulePromise;
};
const meta = {
id: "matrix",
@@ -124,9 +118,9 @@ const matrixDoctor: ChannelDoctorAdapter = {
legacyConfigRules: MATRIX_LEGACY_CONFIG_RULES,
normalizeCompatibilityConfig: normalizeMatrixCompatibilityConfig,
runConfigSequence: async ({ cfg, env, shouldRepair }) =>
await (await loadMatrixDoctorModule()).runMatrixDoctorSequence({ cfg, env, shouldRepair }),
await (await import("./doctor.js")).runMatrixDoctorSequence({ cfg, env, shouldRepair }),
cleanStaleConfig: async ({ cfg }) =>
await (await loadMatrixDoctorModule()).cleanStaleMatrixPluginConfig(cfg),
await (await import("./doctor.js")).cleanStaleMatrixPluginConfig(cfg),
};
const listMatrixDirectoryPeersFromConfig =

View File

@@ -1,7 +1,7 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { ensureMatrixStartupVerification } from "./startup-verification.js";
function createTempStateDir(): string {
@@ -80,10 +80,6 @@ function createHarness(params?: {
}
describe("ensureMatrixStartupVerification", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("skips automatic requests when the device is already verified", async () => {
const tempHome = createTempStateDir();
const harness = createHarness({ verified: true });
@@ -207,27 +203,6 @@ describe("ensureMatrixStartupVerification", () => {
expect(fs.existsSync(createStateFilePath(tempHome))).toBe(true);
});
it("falls back when startup verification nowMs is outside Date range", async () => {
vi.spyOn(Date, "now").mockReturnValue(Date.parse("2026-05-30T12:00:00.000Z"));
const tempHome = createTempStateDir();
const stateFilePath = createStateFilePath(tempHome);
const harness = createHarness();
const result = await ensureMatrixStartupVerification({
client: harness.client as never,
auth: createAuth(),
accountConfig: {},
stateFilePath,
nowMs: 8_640_000_000_000_001,
});
expect(result.kind).toBe("requested");
const state = JSON.parse(fs.readFileSync(stateFilePath, "utf-8")) as {
attemptedAt?: string;
};
expect(state.attemptedAt).toBe("2026-05-30T12:00:00.000Z");
});
it("keeps startup verification failures non-fatal", async () => {
const tempHome = createTempStateDir();
const harness = createHarness({

View File

@@ -1,7 +1,6 @@
import fs from "node:fs/promises";
import path from "node:path";
import { readJsonFileWithFallback, writeJsonFileAtomically } from "openclaw/plugin-sdk/json-store";
import { timestampMsToIsoString } from "openclaw/plugin-sdk/number-runtime";
import type { MatrixConfig } from "../../types.js";
import { resolveMatrixStoragePaths } from "../client/storage.js";
import type { MatrixAuth } from "../client/types.js";
@@ -96,14 +95,6 @@ function resolveRetryAfterMs(params: {
return remaining > 0 ? remaining : undefined;
}
function resolveStartupVerificationTimestamp(nowMs: unknown): string {
return (
timestampMsToIsoString(nowMs) ??
timestampMsToIsoString(Date.now()) ??
"1970-01-01T00:00:00.000Z"
);
}
function shouldHonorCooldown(params: {
state: MatrixStartupVerificationState | null;
verification: MatrixOwnDeviceVerificationStatus;
@@ -198,7 +189,6 @@ export async function ensureMatrixStartupVerification(params: {
);
const cooldownMs = cooldownHours * 60 * 60 * 1000;
const nowMs = params.nowMs ?? Date.now();
const attemptedAt = resolveStartupVerificationTimestamp(nowMs);
const state = await readStartupVerificationState(statePath);
const stateCooldownMs = resolveStateCooldownMs(state, cooldownMs);
if (shouldHonorCooldown({ state, verification, stateCooldownMs, nowMs })) {
@@ -218,7 +208,7 @@ export async function ensureMatrixStartupVerification(params: {
await writeJsonFileAtomically(statePath, {
userId: verification.userId,
deviceId: verification.deviceId,
attemptedAt,
attemptedAt: new Date(nowMs).toISOString(),
outcome: "requested",
requestId: request.id,
transactionId: request.transactionId,
@@ -234,7 +224,7 @@ export async function ensureMatrixStartupVerification(params: {
await writeJsonFileAtomically(statePath, {
userId: verification.userId,
deviceId: verification.deviceId,
attemptedAt,
attemptedAt: new Date(nowMs).toISOString(),
outcome: "failed",
error,
} satisfies MatrixStartupVerificationState).catch(() => {});

View File

@@ -166,26 +166,6 @@ describe("MatrixVerificationManager", () => {
expect(summary.phaseName).toBe("requested");
});
it("tracks verification requests when the process clock is outside the Date range", () => {
const manager = new MatrixVerificationManager();
const dateNowSpy = vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_001);
try {
const summary = manager.trackVerificationRequest(
new MockVerificationRequest({
transactionId: "txn-invalid-clock",
phase: VerificationPhase.Requested,
}),
);
expect(summary.createdAt).toBe("1970-01-01T00:00:00.000Z");
expect(summary.updatedAt).toBe("1970-01-01T00:00:00.000Z");
expect(manager.listVerifications()).toHaveLength(1);
} finally {
dateNowSpy.mockRestore();
}
});
it("reuses the same tracked id for repeated transaction IDs", () => {
const manager = new MatrixVerificationManager();
const first = new MockVerificationRequest({

View File

@@ -4,10 +4,6 @@ import {
VerifierEvent,
} from "matrix-js-sdk/lib/crypto-api/verification.js";
import { VerificationMethod } from "matrix-js-sdk/lib/types.js";
import {
resolveDateTimestampMs,
resolveTimestampMsToIsoString,
} from "openclaw/plugin-sdk/number-runtime";
import { formatMatrixErrorMessage } from "../errors.js";
export type MatrixVerificationMethod = "sas" | "show-qr" | "scan-qr";
@@ -270,7 +266,7 @@ export class MatrixVerificationManager {
}
private touchVerificationSession(session: MatrixVerificationSession): void {
session.updatedAtMs = resolveDateTimestampMs(Date.now());
session.updatedAtMs = Date.now();
this.emitVerificationSummary(session);
}
@@ -321,8 +317,8 @@ export class MatrixVerificationManager {
hasReciprocateQr: Boolean(session.reciprocateQrCallbacks),
completed: phase === VerificationPhase.Done,
error: session.error,
createdAt: resolveTimestampMsToIsoString(session.createdAtMs),
updatedAt: resolveTimestampMsToIsoString(session.updatedAtMs),
createdAt: new Date(session.createdAtMs).toISOString(),
updatedAt: new Date(session.updatedAtMs).toISOString(),
};
}
@@ -598,7 +594,7 @@ export class MatrixVerificationManager {
}
}
const now = resolveDateTimestampMs(Date.now());
const now = Date.now();
const id = `verification-${++this.verificationSessionCounter}`;
const session: MatrixVerificationSession = {
id,

View File

@@ -23,3 +23,9 @@ export function registerMatrixSubagentHooks(api: OpenClawPluginApi): void {
return handleMatrixSubagentDeliveryTarget(event);
});
}
export {
handleMatrixSubagentDeliveryTarget,
handleMatrixSubagentEnded,
handleMatrixSubagentSpawning,
} from "./src/matrix/subagent-hooks.js";

View File

@@ -342,7 +342,7 @@ export function renderMattermostModelsPickerView(params: {
const page = paginateItems(models, params.page);
const rows: MattermostInteractiveButtonInput[][] = page.items.map((model) => {
const isCurrent = current?.provider === provider && current?.model === model;
const isCurrent = current?.provider === provider && current.model === model;
return [
buildButton({
action: "select",

View File

@@ -1,4 +1,4 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const fetchMattermostChannel = vi.hoisted(() => vi.fn());
const fetchMattermostUser = vi.hoisted(() => vi.fn());
@@ -32,10 +32,6 @@ describe("mattermost monitor resources", () => {
buildButtonProps.mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("downloads media, preserves auth headers, and infers media kind", async () => {
const saveRemoteMedia = vi.fn(async () => ({
path: "/tmp/file.png",
@@ -124,70 +120,6 @@ describe("mattermost monitor resources", () => {
});
});
it("does not reuse cached lookups while the process clock is invalid", async () => {
fetchMattermostChannel
.mockResolvedValueOnce({ id: "chan-1", name: "old" })
.mockResolvedValueOnce({ id: "chan-1", name: "fresh" })
.mockResolvedValueOnce({ id: "chan-1", name: "recovered" });
const resources = createMattermostMonitorResources({
accountId: "default",
callbackUrl: "https://openclaw.test/callback",
client: {} as never,
logger: {},
mediaMaxBytes: 1024,
saveRemoteMedia: vi.fn(),
mediaKindFromMime: () => "document",
});
await expect(resources.resolveChannelInfo("chan-1")).resolves.toEqual({
id: "chan-1",
name: "old",
});
vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_001);
await expect(resources.resolveChannelInfo("chan-1")).resolves.toEqual({
id: "chan-1",
name: "fresh",
});
vi.mocked(Date.now).mockReturnValue(1_000);
await expect(resources.resolveChannelInfo("chan-1")).resolves.toEqual({
id: "chan-1",
name: "recovered",
});
expect(fetchMattermostChannel).toHaveBeenCalledTimes(3);
});
it("does not cache lookups when cache expiry would exceed the Date range", async () => {
vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_000);
fetchMattermostUser
.mockResolvedValueOnce({ id: "user-1", username: "first" })
.mockResolvedValueOnce({ id: "user-1", username: "second" });
const resources = createMattermostMonitorResources({
accountId: "default",
callbackUrl: "https://openclaw.test/callback",
client: {} as never,
logger: {},
mediaMaxBytes: 1024,
saveRemoteMedia: vi.fn(),
mediaKindFromMime: () => "document",
});
await expect(resources.resolveUserInfo("user-1")).resolves.toEqual({
id: "user-1",
username: "first",
});
await expect(resources.resolveUserInfo("user-1")).resolves.toEqual({
id: "user-1",
username: "second",
});
expect(fetchMattermostUser).toHaveBeenCalledTimes(2);
});
it("proxies typing indicators to the mattermost client helper", async () => {
const client = {} as never;

View File

@@ -1,7 +1,3 @@
import {
asDateTimestampMs,
resolveExpiresAtMsFromDurationMs,
} from "openclaw/plugin-sdk/number-runtime";
import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
fetchMattermostChannel,
@@ -54,35 +50,6 @@ export function createMattermostMonitorResources(params: {
const channelCache = new Map<string, { value: MattermostChannel | null; expiresAt: number }>();
const userCache = new Map<string, { value: MattermostUser | null; expiresAt: number }>();
const getCachedValue = <T>(
cache: Map<string, { value: T | null; expiresAt: number }>,
key: string,
nowMs: number | undefined,
): T | null | undefined => {
const cached = cache.get(key);
if (!cached) {
return undefined;
}
if (nowMs !== undefined && cached.expiresAt > nowMs) {
return cached.value;
}
cache.delete(key);
return undefined;
};
const setCachedValue = <T>(
cache: Map<string, { value: T | null; expiresAt: number }>,
key: string,
value: T | null,
ttlMs: number,
rawNowMs: number,
): void => {
const expiresAt = resolveExpiresAtMsFromDurationMs(ttlMs, { nowMs: rawNowMs });
if (expiresAt !== undefined) {
cache.set(key, { value, expiresAt });
}
};
const resolveMattermostMedia = async (
fileIds?: string[] | null,
): Promise<MattermostMediaInfo[]> => {
@@ -122,35 +89,45 @@ export function createMattermostMonitorResources(params: {
};
const resolveChannelInfo = async (channelId: string): Promise<MattermostChannel | null> => {
const rawNow = Date.now();
const cached = getCachedValue(channelCache, channelId, asDateTimestampMs(rawNow));
if (cached !== undefined) {
return cached;
const cached = channelCache.get(channelId);
if (cached && cached.expiresAt > Date.now()) {
return cached.value;
}
try {
const info = await fetchMattermostChannel(client, channelId);
setCachedValue(channelCache, channelId, info, CHANNEL_CACHE_TTL_MS, rawNow);
channelCache.set(channelId, {
value: info,
expiresAt: Date.now() + CHANNEL_CACHE_TTL_MS,
});
return info;
} catch (err) {
logger.debug?.(`mattermost: channel lookup failed: ${String(err)}`);
setCachedValue(channelCache, channelId, null, CHANNEL_CACHE_TTL_MS, rawNow);
channelCache.set(channelId, {
value: null,
expiresAt: Date.now() + CHANNEL_CACHE_TTL_MS,
});
return null;
}
};
const resolveUserInfo = async (userId: string): Promise<MattermostUser | null> => {
const rawNow = Date.now();
const cached = getCachedValue(userCache, userId, asDateTimestampMs(rawNow));
if (cached !== undefined) {
return cached;
const cached = userCache.get(userId);
if (cached && cached.expiresAt > Date.now()) {
return cached.value;
}
try {
const info = await fetchMattermostUser(client, userId);
setCachedValue(userCache, userId, info, USER_CACHE_TTL_MS, rawNow);
userCache.set(userId, {
value: info,
expiresAt: Date.now() + USER_CACHE_TTL_MS,
});
return info;
} catch (err) {
logger.debug?.(`mattermost: user lookup failed: ${String(err)}`);
setCachedValue(userCache, userId, null, USER_CACHE_TTL_MS, rawNow);
userCache.set(userId, {
value: null,
expiresAt: Date.now() + USER_CACHE_TTL_MS,
});
return null;
}
};

View File

@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it } from "vitest";
import {
addMattermostReaction,
removeMattermostReaction,
@@ -15,10 +15,6 @@ describe("mattermost reactions", () => {
resetMattermostReactionBotUserCacheForTests();
});
afterEach(() => {
vi.restoreAllMocks();
});
async function addReactionWithFetch(fetchMock: typeof fetch) {
return addMattermostReaction({
cfg: createMattermostTestConfig(),
@@ -108,94 +104,4 @@ describe("mattermost reactions", () => {
expect(removeResult).toEqual({ ok: true });
expect(usersMeCalls).toHaveLength(1);
});
it("does not reuse cached bot user ids while the process clock is invalid", async () => {
const cfg = createMattermostTestConfig();
const firstFetch = createMattermostReactionFetchMock({
mode: "add",
postId: "POST1",
emojiName: "thumbsup",
userId: "BOT_OLD",
});
const secondFetch = createMattermostReactionFetchMock({
mode: "add",
postId: "POST2",
emojiName: "thumbsup",
userId: "BOT_FRESH",
});
const thirdFetch = createMattermostReactionFetchMock({
mode: "add",
postId: "POST3",
emojiName: "thumbsup",
userId: "BOT_RECOVERED",
});
await expect(
addMattermostReaction({
cfg,
postId: "POST1",
emojiName: "thumbsup",
fetchImpl: firstFetch,
}),
).resolves.toEqual({ ok: true });
vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_001);
await expect(
addMattermostReaction({
cfg,
postId: "POST2",
emojiName: "thumbsup",
fetchImpl: secondFetch,
}),
).resolves.toEqual({ ok: true });
vi.mocked(Date.now).mockReturnValue(1_000);
await expect(
addMattermostReaction({
cfg,
postId: "POST3",
emojiName: "thumbsup",
fetchImpl: thirdFetch,
}),
).resolves.toEqual({ ok: true });
const usersMeCalls = [
...firstFetch.mock.calls,
...secondFetch.mock.calls,
...thirdFetch.mock.calls,
].filter((call) => requestUrl(call[0]).endsWith("/api/v4/users/me"));
expect(usersMeCalls).toHaveLength(3);
});
it("does not cache bot user ids when cache expiry would exceed the Date range", async () => {
vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_000);
const cfg = createMattermostTestConfig();
const fetchMock = createMattermostReactionFetchMock({
mode: "both",
postId: "POST1",
emojiName: "thumbsup",
});
await expect(
addMattermostReaction({
cfg,
postId: "POST1",
emojiName: "thumbsup",
fetchImpl: fetchMock,
}),
).resolves.toEqual({ ok: true });
await expect(
removeMattermostReaction({
cfg,
postId: "POST1",
emojiName: "thumbsup",
fetchImpl: fetchMock,
}),
).resolves.toEqual({ ok: true });
const usersMeCalls = fetchMock.mock.calls.filter((call) =>
requestUrl(call[0]).endsWith("/api/v4/users/me"),
);
expect(usersMeCalls).toHaveLength(2);
});
});

View File

@@ -1,7 +1,3 @@
import {
asDateTimestampMs,
resolveExpiresAtMsFromDurationMs,
} from "openclaw/plugin-sdk/number-runtime";
import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime";
import { resolveMattermostAccount } from "./accounts.js";
import {
@@ -30,24 +26,16 @@ async function resolveBotUserId(
client: MattermostClient,
cacheKey: string,
): Promise<string | null> {
const rawNow = Date.now();
const now = asDateTimestampMs(rawNow);
const cached = botUserIdCache.get(cacheKey);
if (cached) {
if (now !== undefined && cached.expiresAt > now) {
return cached.userId;
}
botUserIdCache.delete(cacheKey);
if (cached && cached.expiresAt > Date.now()) {
return cached.userId;
}
const me = await fetchMattermostMe(client);
const userId = me?.id?.trim();
if (!userId) {
return null;
}
const expiresAt = resolveExpiresAtMsFromDurationMs(BOT_USER_CACHE_TTL_MS, { nowMs: rawNow });
if (expiresAt !== undefined) {
botUserIdCache.set(cacheKey, { userId, expiresAt });
}
botUserIdCache.set(cacheKey, { userId, expiresAt: Date.now() + BOT_USER_CACHE_TTL_MS });
return userId;
}

View File

@@ -458,119 +458,6 @@ describe("slash-http", () => {
expect(client.requests).toEqual(["/commands/cmd-1"]);
});
it("does not cache failed command validation when the expiry would exceed a valid Date", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(8_640_000_000_000_000));
try {
const registeredCommand = createRegisteredCommand({ token: "old-token" });
const client = createCommandLookupClient({
command: {
id: "cmd-1",
token: "new-token",
team_id: "t1",
trigger: "oc_status",
method: MATTERMOST_SLASH_POST_METHOD,
url: "https://gateway.example.com/slash",
auto_complete: true,
delete_at: 0,
},
});
const payload = {
token: "old-token",
team_id: "t1",
channel_id: "c1",
user_id: "u1",
command: "/oc_status",
text: "",
};
await expect(
validateMattermostSlashCommandToken({
accountId: "default",
client,
registeredCommand,
payload,
}),
).resolves.toBe(false);
await expect(
validateMattermostSlashCommandToken({
accountId: "default",
client,
registeredCommand,
payload,
}),
).resolves.toBe(false);
expect(client.requests).toEqual(["/commands/cmd-1", "/commands/cmd-1"]);
} finally {
vi.useRealTimers();
}
});
it("drops exhausted validation lookup buckets when the current clock is invalid", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-27T00:00:00Z"));
try {
const registeredCommand = createRegisteredCommand({ token: "valid-token" });
const command = {
id: "cmd-1",
token: "valid-token",
team_id: "t1",
trigger: "oc_status",
method: MATTERMOST_SLASH_POST_METHOD,
url: "https://gateway.example.com/slash",
auto_complete: true,
delete_at: 0,
};
const client = createCommandLookupClient({ command });
const payload = {
token: "valid-token",
team_id: "t1",
channel_id: "c1",
user_id: "u1",
command: "/oc_status",
text: "",
};
for (let i = 0; i < 20; i += 1) {
await expect(
validateMattermostSlashCommandToken({
accountId: "default",
client,
registeredCommand,
payload,
}),
).resolves.toBe(true);
}
await expect(
validateMattermostSlashCommandToken({
accountId: "default",
client,
registeredCommand,
payload,
}),
).resolves.toBe(false);
const dateNow = vi.spyOn(Date, "now").mockReturnValue(Number.NaN);
try {
await expect(
validateMattermostSlashCommandToken({
accountId: "default",
client,
registeredCommand,
payload,
}),
).resolves.toBe(true);
} finally {
dateNow.mockRestore();
}
expect(client.requests).toHaveLength(21);
} finally {
vi.useRealTimers();
}
});
it("scopes validation cache entries by account", async () => {
const registeredCommand = createRegisteredCommand();
const clientA = createCommandLookupClient({

View File

@@ -6,10 +6,6 @@
*/
import type { IncomingMessage, ServerResponse } from "node:http";
import {
asDateTimestampMs,
resolveExpiresAtMsFromDurationMs,
} from "openclaw/plugin-sdk/number-runtime";
import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime";
import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime";
import type { ResolvedMattermostAccount } from "../mattermost/accounts.js";
@@ -213,14 +209,8 @@ export function clearMattermostSlashCommandValidationCacheForAccount(accountId:
}
function sweepCommandValidationFailureCache(now = Date.now()): void {
const validNow = asDateTimestampMs(now);
if (validNow === undefined) {
commandValidationFailureCache.clear();
return;
}
for (const [key, entry] of commandValidationFailureCache) {
const expiresAt = asDateTimestampMs(entry.expiresAt);
if (expiresAt === undefined || expiresAt <= validNow) {
if (entry.expiresAt <= now) {
commandValidationFailureCache.delete(key);
}
}
@@ -235,16 +225,11 @@ function sweepCommandValidationFailureCache(now = Date.now()): void {
function hasCachedCommandValidationFailure(key: string, now = Date.now()): boolean {
sweepCommandValidationFailureCache(now);
const validNow = asDateTimestampMs(now);
if (validNow === undefined) {
return false;
}
const cached = commandValidationFailureCache.get(key);
if (!cached) {
return false;
}
const expiresAt = asDateTimestampMs(cached.expiresAt);
if (expiresAt !== undefined && expiresAt > validNow) {
if (cached.expiresAt > now) {
return true;
}
commandValidationFailureCache.delete(key);
@@ -252,31 +237,17 @@ function hasCachedCommandValidationFailure(key: string, now = Date.now()): boole
}
function cacheCommandValidationFailure(key: string, accountId: string): void {
const now = Date.now();
sweepCommandValidationFailureCache(now);
const expiresAt = resolveExpiresAtMsFromDurationMs(COMMAND_VALIDATION_FAILURE_CACHE_MS, {
nowMs: now,
});
if (expiresAt === undefined) {
commandValidationFailureCache.delete(key);
return;
}
sweepCommandValidationFailureCache();
commandValidationFailureCache.set(key, {
accountId,
expiresAt,
expiresAt: Date.now() + COMMAND_VALIDATION_FAILURE_CACHE_MS,
});
}
function sweepCommandValidationLookupRateLimit(now = Date.now()): void {
const validNow = asDateTimestampMs(now);
if (validNow === undefined) {
commandValidationLookupRateLimit.clear();
return;
}
const staleAfterMs = COMMAND_VALIDATION_LOOKUP_REFILL_MS * COMMAND_VALIDATION_LOOKUP_BURST * 2;
for (const [key, entry] of commandValidationLookupRateLimit) {
const updatedAt = asDateTimestampMs(entry.updatedAt);
if (updatedAt === undefined || validNow - updatedAt > staleAfterMs) {
if (now - entry.updatedAt > staleAfterMs) {
commandValidationLookupRateLimit.delete(key);
}
}
@@ -294,12 +265,7 @@ function reserveCommandValidationLookup(params: {
accountId: string;
now?: number;
}): { allowed: true } | { allowed: false; shouldLog: boolean } {
const rawNow = params.now ?? Date.now();
const now = asDateTimestampMs(rawNow);
if (now === undefined) {
commandValidationLookupRateLimit.clear();
return { allowed: true };
}
const now = params.now ?? Date.now();
sweepCommandValidationLookupRateLimit(now);
const existing = commandValidationLookupRateLimit.get(params.key);
if (!existing) {

View File

@@ -1,15 +1,11 @@
import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import { writeDailyDreamingPhaseBlock, writeDeepDreamingReport } from "./dreaming-markdown.js";
import { createMemoryCoreTestHarness } from "./test-helpers.js";
const { createTempWorkspace } = createMemoryCoreTestHarness();
afterEach(() => {
vi.restoreAllMocks();
});
async function expectPathMissing(targetPath: string): Promise<void> {
const error = await fs.access(targetPath).then(
() => undefined,
@@ -59,25 +55,6 @@ describe("dreaming markdown storage", () => {
expect(content).toContain("- Candidate: remember the API key is fake");
});
it("falls back when the injected timestamp is outside Date range", async () => {
vi.spyOn(Date, "now").mockReturnValue(Date.UTC(2026, 4, 30, 12, 0, 0));
const workspaceDir = await createTempWorkspace("openclaw-dreaming-markdown-");
const result = await writeDailyDreamingPhaseBlock({
workspaceDir,
phase: "light",
bodyLines: ["- Candidate: bounded fallback"],
nowMs: 8_640_000_000_000_001,
timezone,
storage: {
mode: "inline",
separateReports: false,
},
});
expect(requireInlinePath(result)).toBe(path.join(workspaceDir, "memory", "2026-05-30.md"));
});
it("keeps multiple inline phases in the shared daily memory file", async () => {
const workspaceDir = await createTempWorkspace("openclaw-dreaming-markdown-");

View File

@@ -10,7 +10,6 @@ import {
replaceManagedMarkdownBlock,
withTrailingNewline,
} from "openclaw/plugin-sdk/memory-host-markdown";
import { resolveMemoryCoreNowMs, resolveMemoryCoreTimestamp } from "./time.js";
const DAILY_PHASE_HEADINGS: Record<Exclude<MemoryDreamingPhaseName, "deep">, string> = {
light: "## Light Sleep",
@@ -64,7 +63,7 @@ export async function writeDailyDreamingPhaseBlock(params: {
timezone?: string;
storage: MemoryDreamingStorageConfig;
}): Promise<{ inlinePath?: string; reportPath?: string }> {
const nowMs = resolveMemoryCoreNowMs(params.nowMs);
const nowMs = Number.isFinite(params.nowMs) ? (params.nowMs as number) : Date.now();
const body = params.bodyLines.length > 0 ? params.bodyLines.join("\n") : "- No notable updates.";
let inlinePath: string | undefined;
let reportPath: string | undefined;
@@ -108,7 +107,7 @@ export async function writeDailyDreamingPhaseBlock(params: {
await appendMemoryHostEvent(params.workspaceDir, {
type: "memory.dream.completed",
timestamp: resolveMemoryCoreTimestamp(nowMs),
timestamp: new Date(nowMs).toISOString(),
phase: params.phase,
...(inlinePath ? { inlinePath } : {}),
...(reportPath ? { reportPath } : {}),
@@ -132,14 +131,14 @@ export async function writeDeepDreamingReport(params: {
if (!shouldWriteSeparate(params.storage)) {
return undefined;
}
const nowMs = resolveMemoryCoreNowMs(params.nowMs);
const nowMs = Number.isFinite(params.nowMs) ? (params.nowMs as number) : Date.now();
const reportPath = resolveSeparateReportPath(params.workspaceDir, "deep", nowMs, params.timezone);
await fs.mkdir(path.dirname(reportPath), { recursive: true });
const body = params.bodyLines.length > 0 ? params.bodyLines.join("\n") : "- No durable changes.";
await fs.writeFile(reportPath, `# Deep Sleep\n\n${body}\n`, "utf-8");
await appendMemoryHostEvent(params.workspaceDir, {
type: "memory.dream.completed",
timestamp: resolveMemoryCoreTimestamp(nowMs),
timestamp: new Date(nowMs).toISOString(),
phase: "deep",
reportPath,
lineCount: params.bodyLines.length,

View File

@@ -1,18 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { buildMemoryFlushPlan } from "./flush-plan.js";
describe("buildMemoryFlushPlan", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("falls back when the injected timestamp is outside Date range", () => {
vi.spyOn(Date, "now").mockReturnValue(Date.UTC(2026, 4, 30, 12, 0, 0));
const plan = buildMemoryFlushPlan({
nowMs: 8_640_000_000_000_001,
});
expect(plan?.relativePath).toBe("memory/2026-05-30.md");
});
});

View File

@@ -6,7 +6,6 @@ import {
type MemoryFlushPlan,
type OpenClawConfig,
} from "openclaw/plugin-sdk/memory-core-host-runtime-core";
import { resolveMemoryCoreNowMs } from "./time.js";
export const DEFAULT_MEMORY_FLUSH_SOFT_TOKENS = 4000;
export const DEFAULT_MEMORY_FLUSH_FORCE_TRANSCRIPT_BYTES = 2 * 1024 * 1024;
@@ -54,7 +53,7 @@ function formatDateStampInTimezone(nowMs: number, timezone: string): string {
if (year && month && day) {
return `${year}-${month}-${day}`;
}
return new Date(resolveMemoryCoreNowMs(nowMs)).toISOString().slice(0, 10);
return new Date(nowMs).toISOString().slice(0, 10);
}
function normalizeNonNegativeInt(value: unknown): number | null {
@@ -100,7 +99,7 @@ export function buildMemoryFlushPlan(
} = {},
): MemoryFlushPlan | null {
const resolved = params;
const nowMs = resolveMemoryCoreNowMs(resolved.nowMs);
const nowMs = Number.isFinite(resolved.nowMs) ? (resolved.nowMs as number) : Date.now();
const cfg = resolved.cfg;
const defaults = cfg?.agents?.defaults?.compaction?.memoryFlush;
if (defaults?.enabled === false) {

View File

@@ -1,7 +1,7 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
vi.mock("openclaw/plugin-sdk/memory-host-events", () => ({
appendMemoryHostEvent: vi.fn(async () => {}),
@@ -40,10 +40,6 @@ describe("short-term promotion", () => {
await fs.rm(fixtureRoot, { recursive: true, force: true });
});
afterEach(() => {
vi.restoreAllMocks();
});
async function withTempWorkspace(run: (workspaceDir: string) => Promise<void>) {
const workspaceDir = path.join(fixtureRoot, `case-${caseId++}`);
await fs.mkdir(path.join(workspaceDir, "memory", ".dreams"), { recursive: true });
@@ -93,31 +89,19 @@ describe("short-term promotion", () => {
return candidate.promotedAt;
}
async function readRecallStoreEntries(workspaceDir: string): Promise<
async function readRecallStoreEntries(
workspaceDir: string,
): Promise<
Record<
string,
{
claimHash?: unknown;
firstRecalledAt?: unknown;
lastRecalledAt?: unknown;
recallCount?: unknown;
snippet?: unknown;
totalScore?: unknown;
}
{ claimHash?: unknown; recallCount?: unknown; snippet?: unknown; totalScore?: unknown }
>
> {
const raw = await fs.readFile(resolveShortTermRecallStorePath(workspaceDir), "utf-8");
const store = JSON.parse(raw) as {
entries?: Record<
string,
{
claimHash?: unknown;
firstRecalledAt?: unknown;
lastRecalledAt?: unknown;
recallCount?: unknown;
snippet?: unknown;
totalScore?: unknown;
}
{ claimHash?: unknown; recallCount?: unknown; snippet?: unknown; totalScore?: unknown }
>;
};
return store.entries ?? {};
@@ -176,35 +160,6 @@ describe("short-term promotion", () => {
});
});
it("falls back when the injected recall timestamp is outside Date range", async () => {
vi.spyOn(Date, "now").mockReturnValue(Date.UTC(2026, 4, 30, 12, 0, 0));
await withTempWorkspace(async (workspaceDir) => {
const notePath = await writeDailyMemoryNote(workspaceDir, "2026-05-30", [
"Bounded recall timestamp note.",
]);
await recordShortTermRecalls({
workspaceDir,
query: "bounded recall",
nowMs: 8_640_000_000_000_001,
results: [
{
path: path.relative(workspaceDir, notePath).replaceAll("\\", "/"),
source: "memory",
startLine: 1,
endLine: 1,
score: 0.9,
snippet: "Bounded recall timestamp note.",
},
],
});
const [entry] = Object.values(await readRecallStoreEntries(workspaceDir));
expect(entry?.firstRecalledAt).toBe("2026-05-30T12:00:00.000Z");
expect(entry?.lastRecalledAt).toBe("2026-05-30T12:00:00.000Z");
});
});
it("records short-term recall for notes stored in spaced and Unicode memory subdirectories", async () => {
await withTempWorkspace(async (workspaceDir) => {
const spacedPath = await writeDailyMemoryNoteInSubdir(

View File

@@ -21,7 +21,6 @@ import {
} from "./concept-vocabulary.js";
import { asRecord } from "./dreaming-shared.js";
import { compactMemoryForBudget, DEFAULT_MEMORY_FILE_MAX_CHARS } from "./memory-budget.js";
import { resolveMemoryCoreNowMs, resolveMemoryCoreTimestamp } from "./time.js";
const SHORT_TERM_PATH_RE = /(?:^|\/)memory\/(?:[^/]+\/)*(\d{4})-(\d{2})-(\d{2})(?:-[^/]+)?\.md$/;
const DREAMING_MEMORY_PATH_RE = /(?:^|\/)memory\/dreaming\//;
@@ -1053,8 +1052,8 @@ export async function recordShortTermRecalls(params: {
return;
}
const nowMs = resolveMemoryCoreNowMs(params.nowMs);
const nowIso = resolveMemoryCoreTimestamp(nowMs);
const nowMs = Number.isFinite(params.nowMs) ? (params.nowMs as number) : Date.now();
const nowIso = new Date(nowMs).toISOString();
const signalType = params.signalType ?? "recall";
const queryHash = hashQuery(query);
const todayBucket =
@@ -1200,8 +1199,8 @@ export async function recordGroundedShortTermCandidates(params: {
return;
}
const nowMs = resolveMemoryCoreNowMs(params.nowMs);
const nowIso = resolveMemoryCoreTimestamp(nowMs);
const nowMs = Number.isFinite(params.nowMs) ? (params.nowMs as number) : Date.now();
const nowIso = new Date(nowMs).toISOString();
const fallbackDayBucket = formatMemoryDreamingDay(nowMs, params.timezone);
await withShortTermLock(workspaceDir, async () => {
const store = await readStore(workspaceDir, nowIso);
@@ -1282,8 +1281,8 @@ export async function recordDreamingPhaseSignals(params: {
if (keys.length === 0) {
return;
}
const nowMs = resolveMemoryCoreNowMs(params.nowMs);
const nowIso = resolveMemoryCoreTimestamp(nowMs);
const nowMs = Number.isFinite(params.nowMs) ? (params.nowMs as number) : Date.now();
const nowIso = new Date(nowMs).toISOString();
await withShortTermLock(workspaceDir, async () => {
const [store, phaseSignals] = await Promise.all([
@@ -1335,8 +1334,8 @@ export async function recordRemConsideredPhaseSignals(params: {
if (keys.length === 0) {
return;
}
const nowMs = resolveMemoryCoreNowMs(params.nowMs);
const nowIso = resolveMemoryCoreTimestamp(nowMs);
const nowMs = Number.isFinite(params.nowMs) ? (params.nowMs as number) : Date.now();
const nowIso = new Date(nowMs).toISOString();
await withShortTermLock(workspaceDir, async () => {
const [store, phaseSignals] = await Promise.all([
@@ -1377,8 +1376,8 @@ export async function readLightStagedKeys(params: {
if (!workspaceDir) {
return new Set();
}
const nowMs = resolveMemoryCoreNowMs(params.nowMs);
const nowIso = resolveMemoryCoreTimestamp(nowMs);
const nowMs = Number.isFinite(params.nowMs) ? (params.nowMs as number) : Date.now();
const nowIso = new Date(nowMs).toISOString();
const store = await readPhaseSignalStore(workspaceDir, nowIso);
const keys = new Set<string>();
for (const [key, entry] of Object.entries(store.entries)) {
@@ -1410,8 +1409,8 @@ export async function rankShortTermPromotionCandidates(
return [];
}
const nowMs = resolveMemoryCoreNowMs(options.nowMs);
const nowIso = resolveMemoryCoreTimestamp(nowMs);
const nowMs = Number.isFinite(options.nowMs) ? (options.nowMs as number) : Date.now();
const nowIso = new Date(nowMs).toISOString();
const minScore = toFiniteScore(options.minScore, DEFAULT_PROMOTION_MIN_SCORE);
const minRecallCount = toFiniteNonNegativeInt(
options.minRecallCount,
@@ -1551,8 +1550,8 @@ export async function readShortTermRecallEntries(params: {
if (!workspaceDir) {
return [];
}
const nowMs = resolveMemoryCoreNowMs(params.nowMs);
const nowIso = resolveMemoryCoreTimestamp(nowMs);
const nowMs = Number.isFinite(params.nowMs) ? (params.nowMs as number) : Date.now();
const nowIso = new Date(nowMs).toISOString();
const store = await readStore(workspaceDir, nowIso);
return Object.values(store.entries).filter(
(entry): entry is ShortTermRecallEntry =>
@@ -1839,8 +1838,8 @@ export async function applyShortTermPromotions(
options: ApplyShortTermPromotionsOptions,
): Promise<ApplyShortTermPromotionsResult> {
const workspaceDir = options.workspaceDir.trim();
const nowMs = resolveMemoryCoreNowMs(options.nowMs);
const nowIso = resolveMemoryCoreTimestamp(nowMs);
const nowMs = Number.isFinite(options.nowMs) ? (options.nowMs as number) : Date.now();
const nowIso = new Date(nowMs).toISOString();
const limit = Number.isFinite(options.limit)
? Math.max(0, Math.floor(options.limit as number))
: options.candidates.length;

View File

@@ -1,10 +0,0 @@
import { timestampMsToIsoString } from "openclaw/plugin-sdk/number-runtime";
export function resolveMemoryCoreNowMs(nowMs: unknown): number {
return timestampMsToIsoString(nowMs) === undefined ? Date.now() : (nowMs as number);
}
export function resolveMemoryCoreTimestamp(nowMs: unknown): string {
const timestampMs = resolveMemoryCoreNowMs(nowMs);
return timestampMsToIsoString(timestampMs) ?? new Date().toISOString();
}

View File

@@ -17,7 +17,6 @@ import {
WIKI_RELATED_END_MARKER,
WIKI_RELATED_START_MARKER,
} from "./markdown.js";
import { resolveMemoryWikiTimestamp } from "./time.js";
import { initializeMemoryWikiVault } from "./vault.js";
const CHATGPT_PREFERENCE_SIGNAL_RE =
@@ -747,7 +746,7 @@ export async function importChatGptConversations(params: {
let updatedCount = 0;
let skippedCount = 0;
let runId: string | undefined;
const nowIso = resolveMemoryWikiTimestamp(params.nowMs);
const nowIso = new Date(params.nowMs ?? Date.now()).toISOString();
let importRunRecord: ChatGptImportRunRecord | undefined;
let importRunDir = "";

View File

@@ -5,7 +5,6 @@ import { compileMemoryWikiVault } from "./compile.js";
import type { ResolvedMemoryWikiConfig } from "./config.js";
import { appendMemoryWikiLog } from "./log.js";
import { renderMarkdownFence, renderWikiMarkdown, slugifyWikiSegment } from "./markdown.js";
import { resolveMemoryWikiTimestamp } from "./time.js";
import { initializeMemoryWikiVault } from "./vault.js";
type IngestMemoryWikiSourceResult = {
@@ -49,7 +48,7 @@ export async function ingestMemoryWikiSource(params: {
const pageRelativePath = path.join("sources", `${slug}.md`);
const pagePath = path.join(params.config.vault.path, pageRelativePath);
const created = !(await pathExists(pagePath));
const timestamp = resolveMemoryWikiTimestamp(params.nowMs);
const timestamp = new Date(params.nowMs ?? Date.now()).toISOString();
const markdown = renderWikiMarkdown({
frontmatter: {

View File

@@ -1,48 +0,0 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { writeImportedSourcePage } from "./source-page-shared.js";
describe("writeImportedSourcePage", () => {
let suiteRoot: string;
beforeEach(async () => {
suiteRoot = await fs.mkdtemp(path.join(os.tmpdir(), "memory-wiki-source-page-"));
});
afterEach(async () => {
vi.useRealTimers();
await fs.rm(suiteRoot, { recursive: true, force: true });
});
it("falls back when the source mtime is outside the Date range", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-01T12:00:00.000Z"));
const sourcePath = path.join(suiteRoot, "source.txt");
await fs.writeFile(sourcePath, "source body", "utf8");
const state: Parameters<typeof writeImportedSourcePage>[0]["state"] = {
entries: {},
version: 1,
};
const result = await writeImportedSourcePage({
vaultRoot: suiteRoot,
syncKey: "unsafe:source",
sourcePath,
sourceUpdatedAtMs: 8_700_000_000_000_000,
sourceSize: 11,
renderFingerprint: "fingerprint",
pagePath: "pages/source.md",
group: "unsafe-local",
state,
buildRendered: (raw, updatedAt) => `updatedAt: ${updatedAt}\n${raw}`,
});
await expect(fs.readFile(path.join(suiteRoot, "pages/source.md"), "utf8")).resolves.toBe(
"updatedAt: 2026-05-01T12:00:00.000Z\nsource body",
);
expect(result).toEqual({ pagePath: "pages/source.md", changed: true, created: true });
expect(state.entries["unsafe:source"]?.sourceUpdatedAtMs).toBe(8_700_000_000_000_000);
});
});

Some files were not shown because too many files have changed in this diff Show More