From 08ae0e6d29928587f3d02fe9b7835f2bb3708fb2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 6 Jun 2026 22:56:48 -0700 Subject: [PATCH] refactor: store Zalo hosted media in plugin state Move Zalo hosted outbound media metadata and expiry into plugin state, add SDK chunked hosted media storage, and keep CI/type/lint gates green after rebase. --- .github/workflows/ci.yml | 2 +- .../.generated/plugin-sdk-api-baseline.sha256 | 4 +- docs/plugins/sdk-channel-plugins.md | 5 + docs/plugins/sdk-subpaths.md | 2 +- extensions/acpx/doctor-contract-api.ts | 4 +- extensions/device-pair/notify.ts | 4 +- .../memory-core/doctor-contract-api.test.ts | 3 +- extensions/memory-core/doctor-contract-api.ts | 4 +- extensions/memory-core/src/dreaming-state.ts | 56 ++- .../memory-core/src/short-term-promotion.ts | 15 +- .../qa-lab/src/agentic-parity-report.test.ts | 81 ++-- extensions/qa-lab/src/runtime-tool-fixture.ts | 51 ++- .../src/monitor.polling.media-reply.test.ts | 68 +-- extensions/zalo/src/outbound-media.test.ts | 96 ++-- extensions/zalo/src/outbound-media.ts | 199 +++----- packages/plugin-sdk/package.json | 4 + packages/plugin-sdk/src/outbound-media.ts | 1 + scripts/check-gateway-watch-regression.mjs | 4 +- scripts/e2e/kitchen-sink-rpc-walk.mjs | 2 +- .../lib/kitchen-sink-plugin/assertions.mjs | 12 +- scripts/lib/kova-report-gate.mjs | 2 +- scripts/lib/run-extension-oxlint.mjs | 20 + scripts/openclaw-cross-os-release-checks.ts | 33 +- scripts/run-oxlint.mjs | 20 + scripts/test-group-report.mjs | 8 +- scripts/test-live-media.ts | 4 +- src/channels/plugins/bundled.ts | 3 +- src/cli/exec-approvals-cli.ts | 4 +- src/media/read-capability.test.ts | 46 ++ src/media/read-capability.ts | 11 +- src/pairing/pairing-store.test.ts | 59 ++- src/pairing/pairing-store.ts | 54 ++- src/plugin-sdk/browser-bridge.ts | 2 +- src/plugin-sdk/browser-profiles.ts | 59 +-- src/plugin-sdk/browser-types.ts | 73 +++ src/plugin-sdk/channel-entry-contract.ts | 32 +- src/plugin-sdk/outbound-media.test.ts | 308 ++++++++++++- src/plugin-sdk/outbound-media.ts | 274 +++++++++++ src/plugin-sdk/plugin-entry.ts | 427 ++++++++---------- src/plugin-sdk/provider-stream-shared.ts | 3 +- src/plugins/runtime/types.ts | 3 +- .../check-gateway-watch-regression.test.ts | 3 + 42 files changed, 1381 insertions(+), 684 deletions(-) create mode 100644 packages/plugin-sdk/src/outbound-media.ts create mode 100644 src/plugin-sdk/browser-types.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1675b8822cd4..d64ddf678f87 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -722,7 +722,7 @@ jobs: if [ "$RUN_GATEWAY_WATCH" = "true" ]; then start_check "gateway-watch" \ - node scripts/check-gateway-watch-regression.mjs --skip-build --ready-timeout-ms 5000 + node scripts/check-gateway-watch-regression.mjs --skip-build fi for index in "${!pids[@]}"; do diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index a12921683a3a..28676a5dc1cb 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -a3ab01b572937539e563aa320ad80135bc701e20fffc43c0351d799590b7a0e0 plugin-sdk-api-baseline.json -9d49587923f8fc4abb16d981bcab54acbf90a3e74ab05933761049e2da0cffe1 plugin-sdk-api-baseline.jsonl +fd2aaa281db68de9db32463e87fddecfb84b6db75080d0fe47719b2b9fff3d5c plugin-sdk-api-baseline.json +329a8fdad622d2ec801f99939ac6ac08685c3dd89e54aa3c2b4da4ac5580d504 plugin-sdk-api-baseline.jsonl diff --git a/docs/plugins/sdk-channel-plugins.md b/docs/plugins/sdk-channel-plugins.md index 0b26b874f0a5..801fa27bde86 100644 --- a/docs/plugins/sdk-channel-plugins.md +++ b/docs/plugins/sdk-channel-plugins.md @@ -99,6 +99,11 @@ Prefer returning an action-keyed map such as `{ "set-profile": ["avatarUrl", "avatarPath"] }` so unrelated actions do not inherit another action's media args. A flat array still works for params that are intentionally shared across every exposed action. +Channels that must expose a temporary public URL for a platform-side media fetch +can use `createHostedOutboundMediaStore(...)` from +`openclaw/plugin-sdk/outbound-media` with plugin state stores. Keep platform +route parsing and token enforcement in the channel plugin; the shared helper +only owns media loading, expiry metadata, chunk rows, and cleanup. If your channel needs provider-specific shaping for `message(action="send")`, prefer `actions.prepareSendPayload(...)`. Put native cards, blocks, embeds, or diff --git a/docs/plugins/sdk-subpaths.md b/docs/plugins/sdk-subpaths.md index 8ac213462a51..66175cce9539 100644 --- a/docs/plugins/sdk-subpaths.md +++ b/docs/plugins/sdk-subpaths.md @@ -90,7 +90,7 @@ by package contract guardrails. | `plugin-sdk/inbound-envelope` | Shared inbound route + envelope builder helpers | | `plugin-sdk/inbound-reply-dispatch` | Deprecated compatibility facade. Use `plugin-sdk/channel-inbound` for inbound runners and dispatch predicates, and `plugin-sdk/channel-outbound` for message delivery helpers. | | `plugin-sdk/messaging-targets` | Deprecated target parsing alias; use `plugin-sdk/channel-targets` | - | `plugin-sdk/outbound-media` | Shared outbound media loading helpers | + | `plugin-sdk/outbound-media` | Shared outbound media loading and hosted-media state helpers | | `plugin-sdk/outbound-send-deps` | Deprecated compatibility facade. Use `plugin-sdk/channel-outbound`. | | `plugin-sdk/outbound-runtime` | Deprecated compatibility facade. Use `plugin-sdk/channel-outbound`. | | `plugin-sdk/poll-runtime` | Narrow poll normalization helpers | diff --git a/extensions/acpx/doctor-contract-api.ts b/extensions/acpx/doctor-contract-api.ts index 9c78e6464303..f64952d7eab0 100644 --- a/extensions/acpx/doctor-contract-api.ts +++ b/extensions/acpx/doctor-contract-api.ts @@ -47,7 +47,7 @@ async function readLegacyGatewayInstanceId(filePath: string): Promise { try { const leaseFile = normalizeAcpxProcessLeaseFile( - JSON.parse(await fs.readFile(filePath, "utf8")) as unknown, + JSON.parse(await fs.readFile(filePath, "utf8")), ); return leaseFile.leases.filter((lease) => lease.state === "open" || lease.state === "closing"); } catch { @@ -124,7 +124,7 @@ export const stateMigrations: PluginDoctorStateMigration[] = [ .map((entry) => normalizeAcpxProcessLease(entry.value)) .filter( (lease): lease is AcpxProcessLease => - lease !== undefined && (lease.state === "open" || lease.state === "closing"), + lease != null && (lease.state === "open" || lease.state === "closing"), ); const leaseGatewayIds = new Set(openLeases.map((lease) => lease.gatewayInstanceId)); const onlyLeaseGatewayId = leaseGatewayIds.size === 1 ? [...leaseGatewayIds][0] : null; diff --git a/extensions/device-pair/notify.ts b/extensions/device-pair/notify.ts index 8ceeb8b56ea7..0730a5be2455 100644 --- a/extensions/device-pair/notify.ts +++ b/extensions/device-pair/notify.ts @@ -108,7 +108,7 @@ async function readNotifyState(api: OpenClawPluginApi): Promise const subscribers = subscriberEntries .map((entry) => entry.value) - .sort((a, b) => a.addedAtMs - b.addedAtMs); + .toSorted((a, b) => a.addedAtMs - b.addedAtMs); const notifiedRequestIds: Record = {}; for (const entry of seenRequestEntries) { const requestId = normalizeOptionalString(entry.value.requestId); @@ -287,7 +287,7 @@ async function notifySubscriber(params: { async function notifyPendingPairingRequests(params: { api: OpenClawPluginApi }): Promise { const state = await readNotifyState(params.api); const pairing = await listDevicePairing(); - const pending = pairing.pending as PendingPairingRequest[]; + const pending: PendingPairingRequest[] = pairing.pending; const now = Date.now(); const pendingIds = new Set(pending.map((entry) => entry.requestId)); let changed = false; diff --git a/extensions/memory-core/doctor-contract-api.test.ts b/extensions/memory-core/doctor-contract-api.test.ts index b10b7d854abe..a12833ebd23f 100644 --- a/extensions/memory-core/doctor-contract-api.test.ts +++ b/extensions/memory-core/doctor-contract-api.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { createPluginStateKeyedStoreForTests, resetPluginStateStoreForTests, @@ -53,7 +54,7 @@ describe("memory-core doctor dreaming migration", () => { } function migrationParams( - config: unknown = { + config: OpenClawConfig = { agents: { list: [{ id: "main", workspace: workspaceDir }], }, diff --git a/extensions/memory-core/doctor-contract-api.ts b/extensions/memory-core/doctor-contract-api.ts index 2af70df66e9f..07abce3b3038 100644 --- a/extensions/memory-core/doctor-contract-api.ts +++ b/extensions/memory-core/doctor-contract-api.ts @@ -51,8 +51,8 @@ async function fileExists(filePath: string): Promise { } } -async function readJsonFile(filePath: string): Promise { - return JSON.parse(await fs.readFile(filePath, "utf8")) as unknown; +async function readJsonFile(filePath: string): Promise { + return JSON.parse(await fs.readFile(filePath, "utf8")); } async function archiveLegacySource(params: { diff --git a/extensions/memory-core/src/dreaming-state.ts b/extensions/memory-core/src/dreaming-state.ts index 130ea5f40e88..4fdc2b041b15 100644 --- a/extensions/memory-core/src/dreaming-state.ts +++ b/extensions/memory-core/src/dreaming-state.ts @@ -31,6 +31,20 @@ type WorkspaceValue = { value: T; }; +export type MemoryCoreWorkspaceEntry = { key: string; value: T }; + +type MemoryCoreWorkspaceParams = { + namespace: string; + workspaceDir: string; +}; + +type WriteMemoryCoreWorkspaceEntriesParams = MemoryCoreWorkspaceParams & { + entries: Array>; +}; + +type WriteMemoryCoreWorkspaceEntryParams = MemoryCoreWorkspaceParams & + MemoryCoreWorkspaceEntry; + let configuredOpenKeyedStore: MemoryCoreOpenKeyedStore | undefined; export function configureMemoryCoreDreamingState(openKeyedStore: MemoryCoreOpenKeyedStore): void { @@ -87,24 +101,29 @@ function openWorkspaceStore(namespace: string): PluginStateKeyedStore(params: { - namespace: string; - workspaceDir: string; -}): Promise> { +// Caller owns typed decoding for values read from plugin state. +export function readMemoryCoreWorkspaceEntries( + params: MemoryCoreWorkspaceParams, +): Promise>>; +export async function readMemoryCoreWorkspaceEntries( + params: MemoryCoreWorkspaceParams, +): Promise>> { const workspaceKey = memoryCoreWorkspaceStateKey(params.workspaceDir); const prefix = `${workspaceKey}:`; - const entries = await openWorkspaceStore(params.namespace).entries(); + const entries = await openWorkspaceStore(params.namespace).entries(); return entries .filter((entry) => entry.key.startsWith(prefix) && entry.value.workspaceKey === workspaceKey) .map((entry) => ({ key: entry.value.key, value: entry.value.value })); } -export async function writeMemoryCoreWorkspaceEntries(params: { - namespace: string; - workspaceDir: string; - entries: Array<{ key: string; value: T }>; -}): Promise { - const store = openWorkspaceStore(params.namespace); +// Caller owns typed encoding for values written to plugin state. +export function writeMemoryCoreWorkspaceEntries( + params: WriteMemoryCoreWorkspaceEntriesParams, +): Promise; +export async function writeMemoryCoreWorkspaceEntries( + params: WriteMemoryCoreWorkspaceEntriesParams, +): Promise { + const store = openWorkspaceStore(params.namespace); const workspaceKey = memoryCoreWorkspaceStateKey(params.workspaceDir); const prefix = `${workspaceKey}:`; const replacementKeys = new Set(); @@ -126,14 +145,15 @@ export async function writeMemoryCoreWorkspaceEntries(params: { } } -export async function writeMemoryCoreWorkspaceEntry(params: { - namespace: string; - workspaceDir: string; - key: string; - value: T; -}): Promise { +// Caller owns typed encoding for values written to plugin state. +export function writeMemoryCoreWorkspaceEntry( + params: WriteMemoryCoreWorkspaceEntryParams, +): Promise; +export async function writeMemoryCoreWorkspaceEntry( + params: WriteMemoryCoreWorkspaceEntryParams, +): Promise { const workspaceKey = memoryCoreWorkspaceStateKey(params.workspaceDir); - await openWorkspaceStore(params.namespace).register( + await openWorkspaceStore(params.namespace).register( memoryCoreWorkspaceEntryKey(params.workspaceDir, params.key), { version: 1, diff --git a/extensions/memory-core/src/short-term-promotion.ts b/extensions/memory-core/src/short-term-promotion.ts index 25403f12d2fb..734c04955154 100644 --- a/extensions/memory-core/src/short-term-promotion.ts +++ b/extensions/memory-core/src/short-term-promotion.ts @@ -542,7 +542,7 @@ function calculateConsolidationComponent(recallDays: string[]): number { return 0.2; } const parsed = recallDays - .map((value) => Date.parse(`${value}T00:00:00.000Z`)) + .map((recallDay) => Date.parse(recallDay + "T00:00:00.000Z")) .filter((value) => Number.isFinite(value)) .toSorted((left, right) => left - right); if (parsed.length <= 1) { @@ -612,7 +612,7 @@ export function normalizeShortTermRecallStore(raw: unknown, nowIso: string): Sho : []; const recallDays = Array.isArray(entry.recallDays) ? entry.recallDays - .map((valueValue) => normalizeIsoDay(String(valueValue))) + .map((recallDay) => (typeof recallDay === "string" ? normalizeIsoDay(recallDay) : null)) .filter((valueLocal): valueLocal is string => valueLocal !== null) : []; const conceptTags = Array.isArray(entry.conceptTags) @@ -2499,7 +2499,6 @@ export async function auditShortTermPromotionArtifacts(params: { const storePath = resolveStorePath(workspaceDir); const lockPath = resolveLockPath(workspaceDir); const issues: ShortTermAuditIssue[] = []; - let exists = false; let entryCount = 0; let promotedCount = 0; let spacedEntryCount = 0; @@ -2513,7 +2512,7 @@ export async function auditShortTermPromotionArtifacts(params: { namespace: SHORT_TERM_RECALL_NAMESPACE, workspaceDir, }); - exists = rawEntries.length > 0; + const exists = rawEntries.length > 0; if (exists) { const parsed = { version: 1, @@ -2788,9 +2787,7 @@ export const testing = { writeMemoryCoreWorkspaceEntries({ namespace: SHORT_TERM_RECALL_NAMESPACE, workspaceDir, - entries: entries - ? Object.entries(entries).map(([key, value]) => ({ key, value: value as unknown })) - : [], + entries: entries ? Object.entries(entries).map(([key, value]) => ({ key, value })) : [], }), writeMemoryCoreWorkspaceEntry({ namespace: SHORT_TERM_META_NAMESPACE, @@ -2812,9 +2809,7 @@ export const testing = { writeMemoryCoreWorkspaceEntries({ namespace: SHORT_TERM_PHASE_SIGNAL_NAMESPACE, workspaceDir, - entries: entries - ? Object.entries(entries).map(([key, value]) => ({ key, value: value as unknown })) - : [], + entries: entries ? Object.entries(entries).map(([key, value]) => ({ key, value })) : [], }), writeMemoryCoreWorkspaceEntry({ namespace: SHORT_TERM_META_NAMESPACE, diff --git a/extensions/qa-lab/src/agentic-parity-report.test.ts b/extensions/qa-lab/src/agentic-parity-report.test.ts index 59bf6dae7162..ee222a7a9381 100644 --- a/extensions/qa-lab/src/agentic-parity-report.test.ts +++ b/extensions/qa-lab/src/agentic-parity-report.test.ts @@ -13,18 +13,18 @@ import { } from "./agentic-parity-report.js"; const FULL_PARITY_PASS_SCENARIOS: QaParityReportScenario[] = [ - { name: "Approval turn tool followthrough", status: "pass" as const }, - { name: "Compaction retry after mutating tool", status: "pass" as const }, - { name: "Model switch with tool continuity", status: "pass" as const }, - { name: "Source and docs discovery report", status: "pass" as const }, - { name: "Image understanding from attachment", status: "pass" as const }, - { name: "Subagent handoff", status: "pass" as const }, - { name: "Subagent fanout synthesis", status: "pass" as const }, - { name: "Subagent stale child links", status: "pass" as const }, - { name: "Memory recall after context switch", status: "pass" as const }, - { name: "Thread memory isolation", status: "pass" as const }, - { name: "Config restart capability flip", status: "pass" as const }, - { name: "Instruction followthrough repo contract", status: "pass" as const }, + { name: "Approval turn tool followthrough", status: "pass" }, + { name: "Compaction retry after mutating tool", status: "pass" }, + { name: "Model switch with tool continuity", status: "pass" }, + { name: "Source and docs discovery report", status: "pass" }, + { name: "Image understanding from attachment", status: "pass" }, + { name: "Subagent handoff", status: "pass" }, + { name: "Subagent fanout synthesis", status: "pass" }, + { name: "Subagent stale child links", status: "pass" }, + { name: "Memory recall after context switch", status: "pass" }, + { name: "Thread memory isolation", status: "pass" }, + { name: "Config restart capability flip", status: "pass" }, + { name: "Instruction followthrough repo contract", status: "pass" }, ]; function withScenarioOverride(name: string, override: Partial) { @@ -109,6 +109,14 @@ function makeRuntimeParitySummary(): QaRuntimeParitySuiteSummary { }; } +function firstRuntimeParityScenario() { + const scenario = makeRuntimeParitySummary().scenarios[0]; + if (!scenario) { + throw new Error("missing runtime parity scenario fixture"); + } + return scenario; +} + describe("qa agentic parity report", () => { it("computes first-wave parity metrics from suite summaries", () => { const summary: QaParitySuiteSummary = { @@ -216,7 +224,7 @@ describe("qa agentic parity report", () => { it("counts passing runtime parity scenarios with tool calls in both runtimes", () => { const metrics = computeQaAgenticParityMetrics({ - scenarios: [makeRuntimeParitySummary().scenarios[0]!], + scenarios: [firstRuntimeParityScenario()], }); expect(metrics.validToolCallCount).toBe(1); @@ -262,13 +270,14 @@ describe("qa agentic parity report", () => { }); it("fails the parity gate when candidate and baseline cover different non-parity scenarios", () => { + const passScenario = (name: string): QaParityReportScenario => ({ name, status: "pass" }); const baselineScenarios = [ - { name: "Approval turn tool followthrough", status: "pass" as const }, - { name: "Compaction retry after mutating tool", status: "pass" as const }, - { name: "Model switch with tool continuity", status: "pass" as const }, - { name: "Source and docs discovery report", status: "pass" as const }, - { name: "Image understanding from attachment", status: "pass" as const }, - { name: "Extra non-parity lane", status: "pass" as const }, + passScenario("Approval turn tool followthrough"), + passScenario("Compaction retry after mutating tool"), + passScenario("Model switch with tool continuity"), + passScenario("Source and docs discovery report"), + passScenario("Image understanding from attachment"), + passScenario("Extra non-parity lane"), ]; const comparison = buildQaAgenticParityComparison({ candidateLabel: "openai/gpt-5.5", @@ -314,20 +323,20 @@ describe("qa agentic parity report", () => { }); it("scopes parity metrics to declared parity scenarios even when extra lanes are present", () => { - const scopedSummary = { + const scopedSummary: QaParitySuiteSummary = { scenarios: [ - { name: "Approval turn tool followthrough", status: "pass" as const }, - { name: "Compaction retry after mutating tool", status: "pass" as const }, - { name: "Model switch with tool continuity", status: "pass" as const }, - { name: "Source and docs discovery report", status: "pass" as const }, - { name: "Image understanding from attachment", status: "pass" as const }, + { name: "Approval turn tool followthrough", status: "pass" }, + { name: "Compaction retry after mutating tool", status: "pass" }, + { name: "Model switch with tool continuity", status: "pass" }, + { name: "Source and docs discovery report", status: "pass" }, + { name: "Image understanding from attachment", status: "pass" }, ], }; - const summaryWithExtras = { + const summaryWithExtras: QaParitySuiteSummary = { scenarios: [ ...scopedSummary.scenarios, - { name: "Extra lane A", status: "fail" as const, details: "timed out" }, - { name: "Extra lane B", status: "fail" as const, details: "timed out" }, + { name: "Extra lane A", status: "fail", details: "timed out" }, + { name: "Extra lane B", status: "fail", details: "timed out" }, ], }; @@ -633,12 +642,12 @@ status=done`, // silently produce a reversed verdict. PR L #64789 ships the `run` // block on every summary so the parity report can verify it against // the caller-supplied label; this test pins the precondition check. - const parityPassScenarios = [ - { name: "Approval turn tool followthrough", status: "pass" as const }, - { name: "Compaction retry after mutating tool", status: "pass" as const }, - { name: "Model switch with tool continuity", status: "pass" as const }, - { name: "Source and docs discovery report", status: "pass" as const }, - { name: "Image understanding from attachment", status: "pass" as const }, + const parityPassScenarios: QaParityReportScenario[] = [ + { name: "Approval turn tool followthrough", status: "pass" }, + { name: "Compaction retry after mutating tool", status: "pass" }, + { name: "Model switch with tool continuity", status: "pass" }, + { name: "Source and docs discovery report", status: "pass" }, + { name: "Image understanding from attachment", status: "pass" }, ]; expect(() => @@ -659,8 +668,8 @@ status=done`, }); it("throws QaParityLabelMismatchError when the baseline run.primaryProvider does not match the label", () => { - const parityPassScenarios = [ - { name: "Approval turn tool followthrough", status: "pass" as const }, + const parityPassScenarios: QaParityReportScenario[] = [ + { name: "Approval turn tool followthrough", status: "pass" }, ]; expect(() => diff --git a/extensions/qa-lab/src/runtime-tool-fixture.ts b/extensions/qa-lab/src/runtime-tool-fixture.ts index a5f3f8e80ae7..1b6cafe44eb2 100644 --- a/extensions/qa-lab/src/runtime-tool-fixture.ts +++ b/extensions/qa-lab/src/runtime-tool-fixture.ts @@ -7,7 +7,7 @@ import { liveTurnTimeoutMs } from "./suite-runtime-agent-common.js"; import { readRawQaSessionStore } from "./suite-runtime-agent-session.js"; import type { QaSuiteRuntimeEnv } from "./suite-runtime-types.js"; -type QaRuntimeToolFixtureConfig = { +type QaRuntimeToolFixtureConfig = Record & { toolName?: unknown; happyPrompt?: unknown; failurePrompt?: unknown; @@ -64,28 +64,33 @@ type QaRuntimeToolFixtureDeps = { ensureImageGenerationConfigured: (env: QaSuiteRuntimeEnv) => Promise; }; -function readString(value: unknown, fallback = "") { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : fallback; +function readString(raw: unknown, fallback = "") { + return typeof raw === "string" && raw.trim().length > 0 ? raw.trim() : fallback; } -function readBoolean(value: unknown, fallback: boolean) { - return typeof value === "boolean" ? value : fallback; +function readBoolean(raw: unknown, fallback: boolean) { + return typeof raw === "boolean" ? raw : fallback; } -function isKnownBroken(value: unknown) { - return Boolean(value && typeof value === "object"); +function isKnownBroken(raw: unknown): raw is Record { + return isRecord(raw); } -function isKnownHarnessGap(value: unknown) { - return Boolean(value && typeof value === "object"); +function isKnownHarnessGap(raw: unknown): raw is Record { + return isRecord(raw); } -function isQaRuntimeToolFixtureRequest(value: unknown): value is QaRuntimeToolFixtureRequest { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); +function isQaRuntimeToolFixtureRequest(raw: unknown): raw is QaRuntimeToolFixtureRequest { + return isRecord(raw); } -function readQaRuntimeToolFixtureRequests(value: unknown): QaRuntimeToolFixtureRequest[] { - return Array.isArray(value) ? value.filter(isQaRuntimeToolFixtureRequest) : []; +function readQaRuntimeToolFixtureRequests(raw: unknown): QaRuntimeToolFixtureRequest[] { + return Array.isArray(raw) ? raw.filter(isQaRuntimeToolFixtureRequest) : []; +} + +function formatPlannedToolArgs(rawArgs: unknown) { + const encodedArgs = JSON.stringify(rawArgs ?? {}); + return encodedArgs ?? "undefined"; } function requestMatchesPrompt(request: QaRuntimeToolFixtureRequest, promptSnippet: string) { @@ -117,7 +122,7 @@ function stringifyTranscriptToolResult(value: unknown): string { try { return JSON.stringify(value); } catch { - return String(value); + return ""; } } @@ -454,9 +459,7 @@ function formatKnownBrokenDetails( tools: Set, config: QaRuntimeToolFixtureConfig, ) { - const knownBroken = isKnownBroken(config.knownBroken) - ? (config.knownBroken as Record) - : {}; + const knownBroken = isKnownBroken(config.knownBroken) ? config.knownBroken : {}; const issue = readString(knownBroken.issue); const reason = readString(knownBroken.reason, "known broken runtime tool fixture"); return [ @@ -487,10 +490,10 @@ function formatCodexNativeWorkspaceDetails(params: { params.reason ? `reason: ${params.reason}` : undefined, `available OpenClaw dynamic tools: ${[...params.tools].toSorted().join(", ")}`, params.happyRequest - ? `${params.toolName} mock provider happy planned args (diagnostic only): ${JSON.stringify(params.happyRequest.plannedToolArgs ?? {})}` + ? `${params.toolName} mock provider happy planned args (diagnostic only): ${formatPlannedToolArgs(params.happyRequest.plannedToolArgs)}` : undefined, params.failureRequest - ? `${params.toolName} mock provider failure planned args (diagnostic only): ${JSON.stringify(params.failureRequest.plannedToolArgs ?? {})}` + ? `${params.toolName} mock provider failure planned args (diagnostic only): ${formatPlannedToolArgs(params.failureRequest.plannedToolArgs)}` : undefined, ] .filter(Boolean) @@ -498,9 +501,7 @@ function formatCodexNativeWorkspaceDetails(params: { } function formatKnownHarnessGapDetails(toolName: string, config: QaRuntimeToolFixtureConfig) { - const knownHarnessGap = isKnownHarnessGap(config.knownHarnessGap) - ? (config.knownHarnessGap as Record) - : {}; + const knownHarnessGap = isKnownHarnessGap(config.knownHarnessGap) ? config.knownHarnessGap : {}; const issue = readString(knownHarnessGap.issue); const reason = readString(knownHarnessGap.reason, "known QA harness gap"); return [`known-harness-gap ${toolName}: ${reason}`, issue ? `tracking: ${issue}` : undefined] @@ -538,7 +539,7 @@ export async function runRuntimeToolFixture( ); const tools = await deps.readEffectiveTools(env, happySessionKey); const metadata = readRuntimeToolCoverageMetadata({ - config: config as Record, + config, }); const dynamicExposureIntentionallyExcluded = env.gateway.runtimeEnv.OPENCLAW_QA_FORCE_RUNTIME === "codex" && @@ -709,7 +710,7 @@ export async function runRuntimeToolFixture( } return [ - `${toolName} mock provider happy planned args (diagnostic only): ${JSON.stringify(happyRequest.plannedRequest.plannedToolArgs ?? {})}`, - `${toolName} mock provider failure planned args (diagnostic only): ${JSON.stringify(failureRequest.plannedRequest.plannedToolArgs ?? {})}`, + `${toolName} mock provider happy planned args (diagnostic only): ${formatPlannedToolArgs(happyRequest.plannedRequest.plannedToolArgs)}`, + `${toolName} mock provider failure planned args (diagnostic only): ${formatPlannedToolArgs(failureRequest.plannedRequest.plannedToolArgs)}`, ].join("\n"); } diff --git a/extensions/zalo/src/monitor.polling.media-reply.test.ts b/extensions/zalo/src/monitor.polling.media-reply.test.ts index 18cabc3eaf61..63dc5b4665a5 100644 --- a/extensions/zalo/src/monitor.polling.media-reply.test.ts +++ b/extensions/zalo/src/monitor.polling.media-reply.test.ts @@ -1,15 +1,18 @@ // Zalo tests cover monitor.polling.media reply plugin behavior. -import { chmod, mkdir, writeFile } from "node:fs/promises"; import type { ServerResponse } from "node:http"; -import { join } from "node:path"; +import type { OpenKeyedStoreOptions } from "openclaw/plugin-sdk/plugin-state-runtime"; +import { + createPluginStateKeyedStoreForTests, + resetPluginStateStoreForTests, +} from "openclaw/plugin-sdk/plugin-state-test-runtime"; import { createEmptyPluginRegistry, createRuntimeEnv, setActivePluginRegistry, } from "openclaw/plugin-sdk/plugin-test-runtime"; -import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path"; import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { PluginRuntime } from "../runtime-api.js"; +import { setZaloRuntime } from "./runtime.js"; import { createLifecycleMonitorSetup, createTextUpdate, @@ -24,7 +27,6 @@ import { } from "./test-support/monitor-mocks-test-support.js"; const prepareHostedZaloMediaUrlMock = vi.fn(); -const ZALO_OUTBOUND_MEDIA_DIR_NAME = "openclaw-zalo-outbound-media"; vi.mock("./outbound-media.js", async () => { const actual = await vi.importActual("./outbound-media.js"); @@ -36,16 +38,15 @@ vi.mock("./outbound-media.js", async () => { import { clearHostedZaloMediaForTest } from "./outbound-media.js"; -function resolveHostedZaloMediaDirName(): string { - const workerId = process.env.VITEST_WORKER_ID ?? process.env.VITEST_POOL_ID; - return workerId ? `${ZALO_OUTBOUND_MEDIA_DIR_NAME}-${workerId}` : ZALO_OUTBOUND_MEDIA_DIR_NAME; +function installZaloRuntimeForTest(): void { + setZaloRuntime({ + state: { + openKeyedStore: (options: OpenKeyedStoreOptions) => + createPluginStateKeyedStoreForTests("zalo", options), + }, + } as unknown as PluginRuntime); } -const ZALO_OUTBOUND_MEDIA_DIR = join( - resolvePreferredOpenClawTmpDir(), - resolveHostedZaloMediaDirName(), -); - async function writeHostedZaloMediaFixture(params: { id: string; routePath: string; @@ -53,21 +54,28 @@ async function writeHostedZaloMediaFixture(params: { buffer: Buffer; contentType?: string; }): Promise { - await mkdir(ZALO_OUTBOUND_MEDIA_DIR, { recursive: true, mode: 0o700 }); - await chmod(ZALO_OUTBOUND_MEDIA_DIR, 0o700).catch(() => undefined); - await Promise.all([ - writeFile( - join(ZALO_OUTBOUND_MEDIA_DIR, `${params.id}.json`), - JSON.stringify({ - routePath: params.routePath, - token: params.token, - contentType: params.contentType, - expiresAt: Date.now() + 60_000, - }), - { encoding: "utf8", mode: 0o600 }, - ), - writeFile(join(ZALO_OUTBOUND_MEDIA_DIR, `${params.id}.bin`), params.buffer, { mode: 0o600 }), - ]); + const metaStore = createPluginStateKeyedStoreForTests("zalo", { + namespace: "hosted-outbound-media", + maxEntries: 80, + }); + const chunkStore = createPluginStateKeyedStoreForTests("zalo", { + namespace: "hosted-outbound-media-chunks", + maxEntries: 16_384, + }); + await chunkStore.register(`media:${params.id}:chunk:0000`, { + id: params.id, + index: 0, + dataBase64: params.buffer.toString("base64"), + }); + await metaStore.register(`media:${params.id}:meta`, { + id: params.id, + routePath: params.routePath, + token: params.token, + ...(params.contentType ? { contentType: params.contentType } : {}), + expiresAt: Date.now() + 60_000, + chunkCount: 1, + byteLength: params.buffer.byteLength, + }); } function createHostedMediaResponse() { @@ -111,7 +119,9 @@ describe("Zalo polling media replies", () => { beforeEach(async () => { await resetLifecycleTestState(); - clearHostedZaloMediaForTest(); + await clearHostedZaloMediaForTest(); + resetPluginStateStoreForTests(); + installZaloRuntimeForTest(); prepareHostedZaloMediaUrlMock.mockReset(); prepareHostedZaloMediaUrlMock.mockResolvedValue( "https://example.com/hooks/zalo/media/abc123abc123abc123abc123?token=secret", @@ -148,7 +158,7 @@ describe("Zalo polling media replies", () => { }); afterAll(async () => { - clearHostedZaloMediaForTest(); + await clearHostedZaloMediaForTest(); await resetLifecycleTestState(); }); diff --git a/extensions/zalo/src/outbound-media.test.ts b/extensions/zalo/src/outbound-media.test.ts index 02dedb7b1edd..27b9db05fdd4 100644 --- a/extensions/zalo/src/outbound-media.test.ts +++ b/extensions/zalo/src/outbound-media.test.ts @@ -1,15 +1,19 @@ +import type { OpenKeyedStoreOptions } from "openclaw/plugin-sdk/plugin-state-runtime"; // Zalo tests cover outbound media plugin behavior. -import { stat } from "node:fs/promises"; -import { join } from "node:path"; -import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path"; +import { + createPluginStateKeyedStoreForTests, + resetPluginStateStoreForTests, +} from "openclaw/plugin-sdk/plugin-state-test-runtime"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { PluginRuntime } from "../runtime-api.js"; -const loadOutboundMediaFromUrlMock = vi.fn(); -const ZALO_OUTBOUND_MEDIA_DIR_NAME = "openclaw-zalo-outbound-media"; +const loadWebMediaMock = vi.hoisted(() => vi.fn()); -vi.mock("openclaw/plugin-sdk/outbound-media", () => ({ - loadOutboundMediaFromUrl: (...args: unknown[]) => loadOutboundMediaFromUrlMock(...args), -})); +vi.mock("openclaw/plugin-sdk/web-media", () => { + return { + loadWebMedia: (...args: unknown[]) => loadWebMediaMock(...args), + }; +}); import { clearHostedZaloMediaForTest, @@ -17,11 +21,7 @@ import { resolveHostedZaloMediaRoutePrefix, tryHandleHostedZaloMediaRequest, } from "./outbound-media.js"; - -function resolveHostedZaloMediaDirName(): string { - const workerId = process.env.VITEST_WORKER_ID ?? process.env.VITEST_POOL_ID; - return workerId ? `${ZALO_OUTBOUND_MEDIA_DIR_NAME}-${workerId}` : ZALO_OUTBOUND_MEDIA_DIR_NAME; -} +import { setZaloRuntime } from "./runtime.js"; function createMockResponse() { const headers = new Map(); @@ -37,12 +37,24 @@ function createMockResponse() { }; } +function installZaloRuntimeForTest(): void { + setZaloRuntime({ + state: { + openKeyedStore: (options: OpenKeyedStoreOptions) => + createPluginStateKeyedStoreForTests("zalo", options), + }, + } as unknown as PluginRuntime); +} + describe("zalo outbound hosted media", () => { - beforeEach(() => { - clearHostedZaloMediaForTest(); - loadOutboundMediaFromUrlMock.mockReset(); - loadOutboundMediaFromUrlMock.mockResolvedValue({ + beforeEach(async () => { + resetPluginStateStoreForTests(); + installZaloRuntimeForTest(); + await clearHostedZaloMediaForTest(); + loadWebMediaMock.mockReset(); + loadWebMediaMock.mockResolvedValue({ buffer: Buffer.from("image-bytes"), + kind: "image", contentType: "image/png", fileName: "photo.png", }); @@ -55,9 +67,10 @@ describe("zalo outbound hosted media", () => { maxBytes: 1024, }); - expect(loadOutboundMediaFromUrlMock).toHaveBeenCalledWith("https://example.com/photo.png", { - maxBytes: 1024, - }); + expect(loadWebMediaMock).toHaveBeenCalledWith( + "https://example.com/photo.png", + expect.objectContaining({ maxBytes: 1024 }), + ); expect(hostedUrl).toMatch( /^https:\/\/gateway\.example\.com\/zalo-webhook\/media\/[a-f0-9]+\?token=[a-f0-9]+$/, ); @@ -71,24 +84,19 @@ describe("zalo outbound hosted media", () => { proxyUrl: "http://proxy.example:8080", }); - expect(loadOutboundMediaFromUrlMock).toHaveBeenCalledWith("https://example.com/photo.png", { - maxBytes: 1024, - proxyUrl: "http://proxy.example:8080", - }); + expect(loadWebMediaMock).toHaveBeenCalledWith( + "https://example.com/photo.png", + expect.objectContaining({ maxBytes: 1024, proxyUrl: "http://proxy.example:8080" }), + ); }); - it("creates hosted media storage with private filesystem permissions", async () => { + it("persists hosted media in SQLite plugin state instead of temp sidecars", async () => { const hostedUrl = await prepareHostedZaloMediaUrl({ mediaUrl: "https://example.com/photo.png", webhookUrl: "https://gateway.example.com/zalo-webhook", maxBytes: 1024, }); - if (process.platform === "win32") { - expect(hostedUrl).toContain("/zalo-webhook/media/"); - return; - } - const { pathname } = new URL(hostedUrl); const id = pathname.split("/").pop(); if (!id) { @@ -97,16 +105,24 @@ describe("zalo outbound hosted media", () => { expect(id).toHaveLength(24); expect(/^[0-9a-f]+$/.test(id)).toBe(true); - const storageDir = join(resolvePreferredOpenClawTmpDir(), resolveHostedZaloMediaDirName()); - const [dirStats, metadataStats, bufferStats] = await Promise.all([ - stat(storageDir), - stat(join(storageDir, `${id}.json`)), - stat(join(storageDir, `${id}.bin`)), - ]); + const metaStore = createPluginStateKeyedStoreForTests("zalo", { + namespace: "hosted-outbound-media", + maxEntries: 80, + }); + const chunkStore = createPluginStateKeyedStoreForTests("zalo", { + namespace: "hosted-outbound-media-chunks", + maxEntries: 16_384, + }); - expect(dirStats.mode & 0o777).toBe(0o700); - expect(metadataStats.mode & 0o777).toBe(0o600); - expect(bufferStats.mode & 0o777).toBe(0o600); + const metaEntries = await metaStore.entries(); + expect(metaEntries).toHaveLength(1); + expect(metaEntries[0]?.value).toMatchObject({ + id, + routePath: "/zalo-webhook/media/", + contentType: "image/png", + byteLength: Buffer.byteLength("image-bytes"), + }); + expect(await chunkStore.entries()).toHaveLength(1); }); it("preserves the root webhook path when deriving the hosted media route", () => { @@ -164,7 +180,7 @@ describe("zalo outbound hosted media", () => { }), ).rejects.toThrow(/expiry/); - expect(loadOutboundMediaFromUrlMock).not.toHaveBeenCalled(); + expect(loadWebMediaMock).not.toHaveBeenCalled(); } finally { vi.useRealTimers(); } diff --git a/extensions/zalo/src/outbound-media.ts b/extensions/zalo/src/outbound-media.ts index cc5d993ac148..cee1a1ef10b3 100644 --- a/extensions/zalo/src/outbound-media.ts +++ b/extensions/zalo/src/outbound-media.ts @@ -1,120 +1,52 @@ // Zalo plugin module implements outbound media behavior. -import { randomBytes } from "node:crypto"; -import { rmSync } from "node:fs"; -import { readdir, readFile, stat, unlink } from "node:fs/promises"; import type { IncomingMessage, ServerResponse } from "node:http"; -import { join } from "node:path"; import { asDateTimestampMs, resolveExpiresAtMsFromDurationMs, } from "openclaw/plugin-sdk/number-runtime"; -import { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/outbound-media"; -import { privateFileStore } from "openclaw/plugin-sdk/security-runtime"; -import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path"; +import { + createHostedOutboundMediaStore, + type HostedOutboundMediaChunkRecord, + type HostedOutboundMediaMetaRecord, + type HostedOutboundMediaStore, +} from "openclaw/plugin-sdk/outbound-media"; import { resolveWebhookPath } from "openclaw/plugin-sdk/webhook-ingress"; +import { getZaloRuntime } from "./runtime.js"; const ZALO_OUTBOUND_MEDIA_TTL_MS = 2 * 60_000; const ZALO_OUTBOUND_MEDIA_SEGMENT = "media"; const ZALO_OUTBOUND_MEDIA_PREFIX = `/${ZALO_OUTBOUND_MEDIA_SEGMENT}/`; -const ZALO_OUTBOUND_MEDIA_DIR_NAME = "openclaw-zalo-outbound-media"; - -function resolveHostedZaloMediaDirName(): string { - const workerId = process.env.VITEST_WORKER_ID ?? process.env.VITEST_POOL_ID; - if (!workerId) { - return ZALO_OUTBOUND_MEDIA_DIR_NAME; - } - const safeWorkerId = workerId.replaceAll(/[^a-zA-Z0-9_.-]/gu, "_"); - return `${ZALO_OUTBOUND_MEDIA_DIR_NAME}-${safeWorkerId}`; -} - -const ZALO_OUTBOUND_MEDIA_DIR = join( - resolvePreferredOpenClawTmpDir(), - resolveHostedZaloMediaDirName(), -); const ZALO_OUTBOUND_MEDIA_ID_RE = /^[a-f0-9]{24}$/; +const ZALO_OUTBOUND_MEDIA_NAMESPACE = "hosted-outbound-media"; +const ZALO_OUTBOUND_MEDIA_CHUNKS_NAMESPACE = "hosted-outbound-media-chunks"; +const ZALO_OUTBOUND_MEDIA_MAX_ENTRIES = 64; +const ZALO_OUTBOUND_MEDIA_CHUNK_ROWS_PER_ENTRY_BUDGET = 256; +const ZALO_OUTBOUND_MEDIA_MAX_CHUNK_ROWS = + ZALO_OUTBOUND_MEDIA_MAX_ENTRIES * ZALO_OUTBOUND_MEDIA_CHUNK_ROWS_PER_ENTRY_BUDGET; -type HostedZaloMediaMetadata = { - routePath: string; - token: string; - contentType?: string; - expiresAt: number; -}; +let hostedZaloMediaStore: HostedOutboundMediaStore | undefined; -function resolveHostedZaloMediaMetadataPath(id: string): string { - return join(ZALO_OUTBOUND_MEDIA_DIR, `${id}.json`); +function createHostedZaloMediaStore(): HostedOutboundMediaStore { + const runtime = getZaloRuntime(); + return createHostedOutboundMediaStore({ + metadataStore: runtime.state.openKeyedStore({ + namespace: ZALO_OUTBOUND_MEDIA_NAMESPACE, + maxEntries: ZALO_OUTBOUND_MEDIA_MAX_ENTRIES + 16, + }), + chunkStore: runtime.state.openKeyedStore({ + namespace: ZALO_OUTBOUND_MEDIA_CHUNKS_NAMESPACE, + maxEntries: ZALO_OUTBOUND_MEDIA_MAX_CHUNK_ROWS, + }), + ttlMs: ZALO_OUTBOUND_MEDIA_TTL_MS, + maxEntries: ZALO_OUTBOUND_MEDIA_MAX_ENTRIES, + maxChunkRows: ZALO_OUTBOUND_MEDIA_MAX_CHUNK_ROWS, + resolveExpiresAtMs: (ttlMs) => resolveExpiresAtMsFromDurationMs(ttlMs), + }); } -function resolveHostedZaloMediaBufferPath(id: string): string { - return join(ZALO_OUTBOUND_MEDIA_DIR, `${id}.bin`); -} - -function createHostedZaloMediaId(): string { - return randomBytes(12).toString("hex"); -} - -function createHostedZaloMediaToken(): string { - return randomBytes(24).toString("hex"); -} - -async function ensureHostedZaloMediaDir(): Promise { - await privateFileStore(ZALO_OUTBOUND_MEDIA_DIR).writeText(".ready", ""); - await unlink(join(ZALO_OUTBOUND_MEDIA_DIR, ".ready")).catch(() => undefined); -} - -async function deleteHostedZaloMediaEntry(id: string): Promise { - await Promise.all([ - unlink(resolveHostedZaloMediaMetadataPath(id)).catch(() => undefined), - unlink(resolveHostedZaloMediaBufferPath(id)).catch(() => undefined), - ]); -} - -async function cleanupExpiredHostedZaloMedia(nowMs = Date.now()): Promise { - const now = asDateTimestampMs(nowMs); - if (now === undefined) { - return; - } - let fileNames: string[]; - try { - fileNames = await readdir(ZALO_OUTBOUND_MEDIA_DIR); - } catch { - return; - } - - await Promise.all( - fileNames - .filter((fileName) => fileName.endsWith(".json")) - .map(async (fileName) => { - const id = fileName.slice(0, -5); - try { - const metadataRaw = await readFile(resolveHostedZaloMediaMetadataPath(id), "utf8"); - const metadata = JSON.parse(metadataRaw) as HostedZaloMediaMetadata; - const expiresAt = asDateTimestampMs(metadata.expiresAt); - if (expiresAt === undefined || expiresAt <= now) { - await deleteHostedZaloMediaEntry(id); - } - } catch { - await deleteHostedZaloMediaEntry(id); - } - }), - ); -} - -async function readHostedZaloMediaEntry(id: string): Promise<{ - metadata: HostedZaloMediaMetadata; - buffer: Buffer; -} | null> { - try { - const [metadataRaw, buffer] = await Promise.all([ - readFile(resolveHostedZaloMediaMetadataPath(id), "utf8"), - readFile(resolveHostedZaloMediaBufferPath(id)), - ]); - return { - metadata: JSON.parse(metadataRaw) as HostedZaloMediaMetadata, - buffer, - }; - } catch { - return null; - } +function getHostedZaloMediaStore(): HostedOutboundMediaStore { + hostedZaloMediaStore ??= createHostedZaloMediaStore(); + return hostedZaloMediaStore; } export function resolveHostedZaloMediaRoutePrefix(params: { @@ -148,9 +80,6 @@ export async function prepareHostedZaloMediaUrl(params: { maxBytes: number; proxyUrl?: string; }): Promise { - await ensureHostedZaloMediaDir(); - await cleanupExpiredHostedZaloMedia(); - const now = asDateTimestampMs(Date.now()); const expiresAt = now === undefined @@ -160,41 +89,27 @@ export async function prepareHostedZaloMediaUrl(params: { throw new Error("Zalo outbound media expiry could not be resolved"); } - const media = await loadOutboundMediaFromUrl(params.mediaUrl, { - maxBytes: params.maxBytes, - ...(params.proxyUrl ? { proxyUrl: params.proxyUrl } : {}), - }); - const routePath = resolveHostedZaloMediaRoutePath({ webhookUrl: params.webhookUrl, webhookPath: params.webhookPath, }); - const id = createHostedZaloMediaId(); - const token = createHostedZaloMediaToken(); const publicBaseUrl = new URL(params.webhookUrl).origin; - const store = privateFileStore(ZALO_OUTBOUND_MEDIA_DIR); - await store.writeText(`${id}.bin`, media.buffer); - try { - await store.writeJson(`${id}.json`, { - routePath, - token, - contentType: media.contentType, - expiresAt, - } satisfies HostedZaloMediaMetadata); - } catch (error) { - await deleteHostedZaloMediaEntry(id); - throw error; - } - - return `${publicBaseUrl}${routePath}${id}?token=${token}`; + return await getHostedZaloMediaStore().prepareUrl({ + mediaUrl: params.mediaUrl, + routePath, + publicBaseUrl, + maxBytes: params.maxBytes, + ...(params.proxyUrl ? { proxyUrl: params.proxyUrl } : {}), + }); } export async function tryHandleHostedZaloMediaRequest( req: IncomingMessage, res: ServerResponse, ): Promise { - await cleanupExpiredHostedZaloMedia(); + const store = getHostedZaloMediaStore(); + await store.cleanupExpired(); const method = req.method ?? "GET"; if (method !== "GET" && method !== "HEAD") { @@ -222,17 +137,24 @@ export async function tryHandleHostedZaloMediaRequest( return true; } - const entry = await readHostedZaloMediaEntry(id); + const now = asDateTimestampMs(Date.now()); + if (now === undefined) { + await store.delete(id); + res.statusCode = 410; + res.end("Expired"); + return true; + } + + const entry = await store.read(id, now); if (!entry || entry.metadata.routePath !== routePath) { res.statusCode = 404; res.end("Not Found"); return true; } - const now = asDateTimestampMs(Date.now()); const expiresAt = asDateTimestampMs(entry.metadata.expiresAt); - if (now === undefined || expiresAt === undefined || expiresAt <= now) { - await deleteHostedZaloMediaEntry(id); + if (expiresAt === undefined || expiresAt <= now) { + await store.delete(id); res.statusCode = 410; res.end("Expired"); return true; @@ -249,10 +171,7 @@ export async function tryHandleHostedZaloMediaRequest( } res.setHeader("Cache-Control", "no-store"); res.setHeader("X-Content-Type-Options", "nosniff"); - const bufferStats = await stat(resolveHostedZaloMediaBufferPath(id)).catch(() => null); - if (bufferStats) { - res.setHeader("Content-Length", String(bufferStats.size)); - } + res.setHeader("Content-Length", String(entry.metadata.byteLength)); if (method === "HEAD") { res.statusCode = 200; @@ -262,10 +181,14 @@ export async function tryHandleHostedZaloMediaRequest( res.statusCode = 200; res.end(entry.buffer); - await deleteHostedZaloMediaEntry(id); + await store.delete(id); return true; } -export function clearHostedZaloMediaForTest(): void { - rmSync(ZALO_OUTBOUND_MEDIA_DIR, { recursive: true, force: true }); +export async function clearHostedZaloMediaForTest(): Promise { + if (!hostedZaloMediaStore) { + return; + } + await hostedZaloMediaStore.clear(); + hostedZaloMediaStore = undefined; } diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index 0f2f0015bd77..8194a1d07fbe 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -52,6 +52,10 @@ "types": "./dist/src/plugin-sdk/number-runtime.d.ts", "default": "./src/number-runtime.ts" }, + "./outbound-media": { + "types": "./dist/src/plugin-sdk/outbound-media.d.ts", + "default": "./src/outbound-media.ts" + }, "./secure-random-runtime": { "types": "./dist/src/plugin-sdk/secure-random-runtime.d.ts", "default": "./src/secure-random-runtime.ts" diff --git a/packages/plugin-sdk/src/outbound-media.ts b/packages/plugin-sdk/src/outbound-media.ts new file mode 100644 index 000000000000..5077815c66f6 --- /dev/null +++ b/packages/plugin-sdk/src/outbound-media.ts @@ -0,0 +1 @@ +export * from "../../../src/plugin-sdk/outbound-media.js"; diff --git a/scripts/check-gateway-watch-regression.mjs b/scripts/check-gateway-watch-regression.mjs index cd0b4e14fb24..cfee95eb1220 100644 --- a/scripts/check-gateway-watch-regression.mjs +++ b/scripts/check-gateway-watch-regression.mjs @@ -51,6 +51,7 @@ const WATCH_GATEWAY_SKIP_ENV = { export const WATCH_LOG_CAPTURE_MAX_CHARS = 2 * 1024 * 1024; const WATCH_BUILD_DETECTION_MAX_CHARS = 4096; const NON_NEGATIVE_INTEGER_PATTERN = /^(0|[1-9]\d*)$/u; +const ANSI_ESCAPE_PATTERN = new RegExp(`${String.fromCharCode(27)}\\[[0-?]*[ -/]*[@-~]`, "g"); /** * Appends watch output while preserving only the diagnostic tail. @@ -416,7 +417,8 @@ function readProcessTreeCpuMs(rootPid) { * Reports whether gateway watch output contains a ready marker. */ export function hasGatewayReadyLog(text) { - return /\[gateway\] (?:http server listening|ready \()/.test(text); + const normalized = text.replaceAll(ANSI_ESCAPE_PATTERN, ""); + return /\[gateway\] (?:http server listening|ready(?:\b|\s*\())/.test(normalized); } async function waitForGatewayReady(readText, timeoutMs) { diff --git a/scripts/e2e/kitchen-sink-rpc-walk.mjs b/scripts/e2e/kitchen-sink-rpc-walk.mjs index 94842d554a6c..8c9548cee8f6 100644 --- a/scripts/e2e/kitchen-sink-rpc-walk.mjs +++ b/scripts/e2e/kitchen-sink-rpc-walk.mjs @@ -289,7 +289,7 @@ export function runCommand(command, args, options = {}) { }); } }) - .catch((error) => { + .catch((/** @type {unknown} */ error) => { lastResourceSampleError = error; }) .finally(() => { diff --git a/scripts/e2e/lib/kitchen-sink-plugin/assertions.mjs b/scripts/e2e/lib/kitchen-sink-plugin/assertions.mjs index c00b56d4a619..c0315c2e95d1 100644 --- a/scripts/e2e/lib/kitchen-sink-plugin/assertions.mjs +++ b/scripts/e2e/lib/kitchen-sink-plugin/assertions.mjs @@ -75,12 +75,12 @@ function scanTextFileLines(file, onLine) { while (currentLine.length > LOG_SCAN_MAX_LINE_CHARS) { const segment = currentLine.slice(0, LOG_SCAN_MAX_LINE_CHARS); currentLine = currentLine.slice(LOG_SCAN_MAX_LINE_CHARS - LOG_SCAN_SEGMENT_OVERLAP_CHARS); - if (emitLine(segment, { truncated: true }) === false) { + if (!emitLine(segment, { truncated: true })) { return false; } } if (complete) { - if (emitLine(currentLine) === false) { + if (!emitLine(currentLine)) { return false; } currentLine = ""; @@ -97,11 +97,11 @@ function scanTextFileLines(file, onLine) { const text = buffer.subarray(0, bytesRead).toString("utf8"); const lines = text.split(/\r?\n/u); for (let index = 0; index < lines.length - 1; index += 1) { - if (appendLineText(lines[index], true) === false) { + if (!appendLineText(lines[index], true)) { return; } } - if (appendLineText(lines.at(-1) ?? "", false) === false) { + if (!appendLineText(lines.at(-1) ?? "", false)) { return; } } @@ -164,7 +164,7 @@ function scanLogFiles(roots, onFile) { if (scannedFiles > LOG_SCAN_MAX_FILES) { throw new Error(`kitchen-sink log scan exceeded ${LOG_SCAN_MAX_FILES} candidate files`); } - if (onFile(entry, scannedFiles) === false) { + if (!onFile(entry, scannedFiles)) { return scannedFiles; } } @@ -449,7 +449,7 @@ function assertInstalled() { if (inspect.plugin?.id !== pluginId) { throw new Error(`unexpected inspected kitchen-sink plugin id: ${inspect.plugin?.id}`); } - if (inspect.plugin?.enabled !== true || inspect.plugin?.status !== "loaded") { + if (!inspect.plugin?.enabled || inspect.plugin?.status !== "loaded") { throw new Error( `expected enabled loaded kitchen-sink plugin, got enabled=${inspect.plugin?.enabled} status=${inspect.plugin?.status}`, ); diff --git a/scripts/lib/kova-report-gate.mjs b/scripts/lib/kova-report-gate.mjs index f2fa00572b8d..5efd00c34a2c 100644 --- a/scripts/lib/kova-report-gate.mjs +++ b/scripts/lib/kova-report-gate.mjs @@ -6,7 +6,7 @@ function numericCount(value) { if (typeof value !== "number") { return undefined; } - const count = Number(value); + const count = value; return Number.isFinite(count) ? count : undefined; } diff --git a/scripts/lib/run-extension-oxlint.mjs b/scripts/lib/run-extension-oxlint.mjs index 635f5778973f..b87f71fb669f 100644 --- a/scripts/lib/run-extension-oxlint.mjs +++ b/scripts/lib/run-extension-oxlint.mjs @@ -9,6 +9,23 @@ import { } from "./local-heavy-check-runtime.mjs"; import { createManagedCommandInvocation } from "./managed-child-process.mjs"; +function hasOxlintFormatArg(args) { + return args.some( + (arg) => + arg === "--format" || + arg.startsWith("--format=") || + arg === "-f" || + arg.startsWith("-f=") || + (arg.startsWith("-f") && arg.length > 2), + ); +} + +function addOxlintFormatArg(args, value) { + const separatorIndex = args.indexOf("--"); + const insertIndex = separatorIndex === -1 ? args.length : separatorIndex; + args.splice(insertIndex, 0, "--format", value); +} + /** * Runs focused extension oxlint with a temp config and local heavy-check lock. */ @@ -44,6 +61,9 @@ export function runExtensionOxlint(params) { const baseArgs = ["-c", tempConfigPath, ...process.argv.slice(2), ...extensionFiles]; const { args: finalArgs, env } = applyLocalOxlintPolicy(baseArgs, process.env); + if (env.GITHUB_ACTIONS === "true" && !hasOxlintFormatArg(finalArgs)) { + addOxlintFormatArg(finalArgs, "stylish"); + } const oxlint = createManagedCommandInvocation({ args: finalArgs, bin: oxlintPath, diff --git a/scripts/openclaw-cross-os-release-checks.ts b/scripts/openclaw-cross-os-release-checks.ts index d53fcc7b5cd3..89ab18be4121 100644 --- a/scripts/openclaw-cross-os-release-checks.ts +++ b/scripts/openclaw-cross-os-release-checks.ts @@ -3965,19 +3965,26 @@ export async function startStaticFileServer(params) { close: () => { closePromise ??= new Promise((resolvePromise, rejectPromise) => { closeStaticFileServerConnections(server, sockets); - server.close(async (error) => { - const closeLogError = await finishStaticFileServerLog(logStream, logStreamError).catch( - (logError) => logError, - ); - if (error) { - rejectPromise(error); - return; - } - if (closeLogError) { - rejectPromise(closeLogError); - return; - } - resolvePromise(); + server.close((error) => { + void (async () => { + const closeLogError = await finishStaticFileServerLog(logStream, logStreamError).catch( + (logError: unknown): Error => + logError instanceof Error ? logError : new Error(String(logError)), + ); + if (error) { + rejectPromise(error); + return; + } + if (closeLogError) { + rejectPromise( + closeLogError instanceof Error + ? closeLogError + : new Error(formatError(closeLogError)), + ); + return; + } + resolvePromise(); + })(); }); closeStaticFileServerConnections(server, sockets); }); diff --git a/scripts/run-oxlint.mjs b/scripts/run-oxlint.mjs index 3c285449ee47..45109ec46ebc 100644 --- a/scripts/run-oxlint.mjs +++ b/scripts/run-oxlint.mjs @@ -40,6 +40,23 @@ const OXLINT_VALUE_FLAGS = new Set([ "--warn", ]); +function hasOxlintFormatArg(args) { + return args.some( + (arg) => + arg === "--format" || + arg.startsWith("--format=") || + arg === "-f" || + arg.startsWith("-f=") || + (arg.startsWith("-f") && arg.length > 2), + ); +} + +function addOxlintFormatArg(args, value) { + const separatorIndex = args.indexOf("--"); + const insertIndex = separatorIndex === -1 ? args.length : separatorIndex; + args.splice(insertIndex, 0, "--format", value); +} + /** * Returns whether oxlint args need package-boundary declaration artifacts first. */ @@ -212,6 +229,9 @@ export async function main(argv = process.argv.slice(2), runtimeEnv = process.en ); const sparseTargets = filterSparseMissingOxlintTargets(policyArgs); const finalArgs = sparseTargets.args; + if (env.GITHUB_ACTIONS === "true" && !hasOxlintFormatArg(finalArgs)) { + addOxlintFormatArg(finalArgs, "stylish"); + } if (sparseTargets.skippedTargets.length > 0) { console.error( `[oxlint] sparse checkout is missing tracked target(s); skipping ${sparseTargets.skippedTargets.join(", ")}`, diff --git a/scripts/test-group-report.mjs b/scripts/test-group-report.mjs index bd3bca89955b..611c3618b2d6 100644 --- a/scripts/test-group-report.mjs +++ b/scripts/test-group-report.mjs @@ -465,16 +465,18 @@ function isFiniteNumber(value) { } function validateCounter(counter, reportPath, fieldName, index = null) { - const label = index === null ? fieldName : `${fieldName}[${index}]`; + const fieldLabel = String(fieldName); + const label = index === null ? fieldLabel : `${fieldLabel}[${String(index)}]`; + const displayPath = String(reportPath); if (!counter || typeof counter !== "object" || Array.isArray(counter)) { throw new Error( - `[test-group-report] invalid grouped report ${reportPath}: ${label} must be an object`, + `[test-group-report] invalid grouped report ${displayPath}: ${label} must be an object`, ); } for (const key of ["durationMs", "fileCount", "testCount"]) { if (!isFiniteNumber(counter[key])) { throw new Error( - `[test-group-report] invalid grouped report ${reportPath}: ${label}.${key} must be a finite number`, + `[test-group-report] invalid grouped report ${displayPath}: ${label}.${key} must be a finite number`, ); } } diff --git a/scripts/test-live-media.ts b/scripts/test-live-media.ts index 8b60eda6b596..89b59f9913c4 100644 --- a/scripts/test-live-media.ts +++ b/scripts/test-live-media.ts @@ -353,7 +353,9 @@ export async function buildRunPlan( ( await Promise.all( options.suites.flatMap((suiteId) => - MEDIA_SUITES[suiteId].providers.map((provider) => getProviderEnvVarsImpl(provider)), + MEDIA_SUITES[suiteId].providers.map( + async (provider) => await getProviderEnvVarsImpl(provider), + ), ), ) ).flat(), diff --git a/src/channels/plugins/bundled.ts b/src/channels/plugins/bundled.ts index 8d3198a4c727..12c7d470c41d 100644 --- a/src/channels/plugins/bundled.ts +++ b/src/channels/plugins/bundled.ts @@ -26,13 +26,14 @@ import { getCachedPluginModuleLoader, type PluginModuleLoaderCache, } from "../../plugins/plugin-module-loader-cache.js"; -import type { PluginRuntime } from "../../plugins/runtime/types.js"; import { resolveBundledChannelRootScope, type BundledChannelRootScope } from "./bundled-root.js"; import { normalizeChannelMeta } from "./meta-normalization.js"; import { loadChannelPluginModule } from "./module-loader.js"; import type { ChannelPlugin } from "./types.plugin.js"; import type { ChannelId } from "./types.public.js"; +type PluginRuntime = import("../../plugins/runtime/types.js").PluginRuntime; + type BundledChannelEntryRuntimeContract = { kind: "bundled-channel-entry"; id: string; diff --git a/src/cli/exec-approvals-cli.ts b/src/cli/exec-approvals-cli.ts index 1cf8583579cb..e1e4da9d92bb 100644 --- a/src/cli/exec-approvals-cli.ts +++ b/src/cli/exec-approvals-cli.ts @@ -63,7 +63,7 @@ async function readStdin( const chunks: Buffer[] = []; let total = 0; for await (const chunk of stream) { - const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)); + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); total += buffer.byteLength; if (total > maxBytes) { throw new Error(`Exec approvals stdin exceeds ${maxBytes} bytes.`); @@ -304,7 +304,7 @@ function renderApprovalsSnapshot(snapshot: ExecApprovalsSnapshot, targetLabel: s typeof defaults.autoAllowSkills === "boolean" ? `autoAllowSkills=${defaults.autoAllowSkills ? "on" : "off"}` : null, - ].filter(Boolean) as string[]; + ].filter((part): part is string => part != null); const agents = file.agents ?? {}; const allowlistRows: Array<{ Target: string; Agent: string; Pattern: string; LastUsed: string }> = []; diff --git a/src/media/read-capability.test.ts b/src/media/read-capability.test.ts index a5e1c78aa8f3..8618634914db 100644 --- a/src/media/read-capability.test.ts +++ b/src/media/read-capability.test.ts @@ -4,13 +4,27 @@ import type { OpenClawConfig } from "../config/types.js"; import { getDefaultMediaLocalRoots } from "./local-roots.js"; import { resolveAgentScopedOutboundMediaAccess } from "./read-capability.js"; +const channelPluginMocks = vi.hoisted(() => ({ + getLoadedChannelPlugin: vi.fn< + () => + | { + groups?: { + resolveToolPolicy?: (params: unknown) => { deny?: string[]; allow?: string[] }; + }; + } + | undefined + >(() => undefined), +})); + vi.mock("../channels/plugins/index.js", () => ({ getChannelPlugin: () => undefined, + getLoadedChannelPlugin: channelPluginMocks.getLoadedChannelPlugin, })); describe("resolveAgentScopedOutboundMediaAccess", () => { afterEach(() => { vi.unstubAllEnvs(); + channelPluginMocks.getLoadedChannelPlugin.mockReset(); }); it("preserves caller-provided workspaceDir from mediaAccess", () => { @@ -93,6 +107,38 @@ describe("resolveAgentScopedOutboundMediaAccess", () => { expect(result.localRoots).not.toContain("/Users/peter/Pictures"); }); + it("honors plugin-owned group tool policy with channel metadata", () => { + const resolveToolPolicy = vi.fn(() => ({ deny: ["read"] })); + channelPluginMocks.getLoadedChannelPlugin.mockReturnValue({ + groups: { resolveToolPolicy }, + }); + + const result = resolveAgentScopedOutboundMediaAccess({ + cfg: { + tools: { + allow: ["read"], + }, + } as OpenClawConfig, + sessionKey: "agent:main:slack:group:C123", + groupChannel: "#incidents", + groupSpace: "team-a", + accountId: "workspace-1", + requesterSenderId: "U123", + mediaSources: ["/Users/peter/Pictures/photo.png"], + }); + + expect(result.readFile).toBeUndefined(); + expect(resolveToolPolicy).toHaveBeenCalledWith( + expect.objectContaining({ + groupId: "C123", + groupChannel: "#incidents", + groupSpace: "team-a", + accountId: "workspace-1", + senderId: "U123", + }), + ); + }); + it("keeps host reads enabled when sender group policy allows read", () => { const cfg: OpenClawConfig = { tools: { diff --git a/src/media/read-capability.ts b/src/media/read-capability.ts index 3bc59e7af32d..d9c28896d1a8 100644 --- a/src/media/read-capability.ts +++ b/src/media/read-capability.ts @@ -1,13 +1,12 @@ // Media read capability helpers gate file reads by configured media access rules. import path from "node:path"; -import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js"; import { resolveGroupToolPolicy } from "../agents/agent-tools.policy.js"; import { resolvePathFromInput } from "../agents/path-policy.js"; import { resolveEffectiveToolFsRootExpansionAllowed } from "../agents/tool-fs-policy.js"; import { isToolAllowedByPolicies } from "../agents/tool-policy-match.js"; import { resolveWorkspaceRoot } from "../agents/workspace-dir.js"; -import type { OpenClawConfig } from "../config/types.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; import { readLocalFileSafely } from "../infra/fs-safe.js"; import type { OutboundMediaAccess, OutboundMediaReadFile } from "./load-options.js"; import { @@ -50,10 +49,10 @@ function isAgentScopedHostMediaReadAllowed( groupChannel: params.groupChannel, groupSpace: params.groupSpace, accountId: params.accountId, - senderId: normalizeOptionalString(params.requesterSenderId), - senderName: normalizeOptionalString(params.requesterSenderName), - senderUsername: normalizeOptionalString(params.requesterSenderUsername), - senderE164: normalizeOptionalString(params.requesterSenderE164), + senderId: params.requesterSenderId, + senderName: params.requesterSenderName, + senderUsername: params.requesterSenderUsername, + senderE164: params.requesterSenderE164, }); // Sender/group policy only applies when a concrete group override exists. if (groupPolicy && !isToolAllowedByPolicies("read", [groupPolicy])) { diff --git a/src/pairing/pairing-store.test.ts b/src/pairing/pairing-store.test.ts index a60bca918265..5f53d0844014 100644 --- a/src/pairing/pairing-store.test.ts +++ b/src/pairing/pairing-store.test.ts @@ -16,8 +16,17 @@ import { import { resolveOAuthDir } from "../config/paths.js"; import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; +const pairingMocks = vi.hoisted(() => ({ + getPairingAdapter: vi.fn< + () => { + idLabel: string; + normalizeAllowEntry?: (entry: string) => string; + } | null + >(() => null), +})); + vi.mock("../channels/plugins/pairing.js", () => ({ - getPairingAdapter: () => null, + getPairingAdapter: pairingMocks.getPairingAdapter, })); import { @@ -56,6 +65,7 @@ afterAll(() => { beforeEach(() => { clearPairingAllowFromReadCacheForTest(); + pairingMocks.getPairingAdapter.mockReset(); nextRandomInt = 0; randomIntSpy ??= vi.spyOn(crypto, "randomInt") as unknown as MockInstance; setDefaultRandomIntMock(); @@ -309,6 +319,53 @@ async function expectPendingPairingRequestsIsolatedByAccount(params: { } describe("pairing store", () => { + it("normalizes allowlist entries through channel pairing adapters", async () => { + pairingMocks.getPairingAdapter.mockReturnValue({ + idLabel: "Telegram user", + normalizeAllowEntry: (entry: string) => entry.replace(/^telegram:/i, ""), + }); + + await withTempStateDir(async (stateDir, env) => { + await expect( + addChannelAllowFromStoreEntry({ + channel: "telegram", + entry: "telegram:1001", + accountId: "main", + env, + }), + ).resolves.toMatchObject({ changed: true, allowFrom: ["1001"] }); + expect(await readChannelAllowFromStore("telegram", env, "main")).toEqual(["1001"]); + expect(readChannelAllowFromStoreSync("telegram", env, "main")).toEqual(["1001"]); + + await writeAllowFromFixture({ + stateDir, + channel: "telegram", + accountId: "main", + allowFrom: ["telegram:1002", "telegram:*"], + }); + clearPairingAllowFromReadCacheForTest(); + expect(await readChannelAllowFromStore("telegram", env, "main")).toEqual(["1002"]); + + await expect( + addChannelAllowFromStoreEntry({ + channel: "telegram", + entry: "telegram:*", + accountId: "main", + env, + }), + ).resolves.toMatchObject({ changed: false, allowFrom: ["1002"] }); + + await expect( + removeChannelAllowFromStoreEntry({ + channel: "telegram", + entry: "telegram:1002", + accountId: "main", + env, + }), + ).resolves.toMatchObject({ changed: true, allowFrom: [] }); + }); + }); + it("skips malformed persisted pairing requests while approving valid codes", async () => { await withTempStateDir(async (stateDir, env) => { const now = new Date().toISOString(); diff --git a/src/pairing/pairing-store.ts b/src/pairing/pairing-store.ts index bf3d1a0c8415..72af4d2fd55c 100644 --- a/src/pairing/pairing-store.ts +++ b/src/pairing/pairing-store.ts @@ -278,7 +278,14 @@ function normalizeId(value: string | number): string { return normalizeStringifiedOptionalString(value) ?? ""; } -function normalizeAllowEntry(channel: PairingChannel, entry: string): string { +function resolvePairingAdapter( + channel: PairingChannel, + pairingAdapter?: ChannelPairingAdapter, +): ChannelPairingAdapter | undefined { + return pairingAdapter ?? getPairingAdapter(channel) ?? undefined; +} + +function normalizeAllowEntry(entry: string, pairingAdapter?: ChannelPairingAdapter): string { const trimmed = entry.trim(); if (!trimmed) { return ""; @@ -286,18 +293,28 @@ function normalizeAllowEntry(channel: PairingChannel, entry: string): string { if (trimmed === "*") { return ""; } - const adapter = getPairingAdapter(channel); - const normalized = adapter?.normalizeAllowEntry ? adapter.normalizeAllowEntry(trimmed) : trimmed; - return normalizeOptionalString(normalized) ?? ""; + const normalized = pairingAdapter?.normalizeAllowEntry + ? pairingAdapter.normalizeAllowEntry(trimmed) + : trimmed; + const normalizedEntry = normalizeOptionalString(normalized) ?? ""; + return normalizedEntry === "*" ? "" : normalizedEntry; } -function normalizeAllowFromList(channel: PairingChannel, store: AllowFromStore): string[] { +function normalizeAllowFromList( + store: AllowFromStore, + pairingAdapter?: ChannelPairingAdapter, +): string[] { const list = Array.isArray(store.allowFrom) ? store.allowFrom : []; - return dedupePreserveOrder(list.map((v) => normalizeAllowEntry(channel, v)).filter(Boolean)); + return dedupePreserveOrder( + list.map((v) => normalizeAllowEntry(v, pairingAdapter)).filter(Boolean), + ); } -function normalizeAllowFromInput(channel: PairingChannel, entry: string | number): string { - return normalizeAllowEntry(channel, normalizeId(entry)); +function normalizeAllowFromInput( + entry: string | number, + pairingAdapter?: ChannelPairingAdapter, +): string { + return normalizeAllowEntry(normalizeId(entry), pairingAdapter); } async function readAllowFromStateForPath( @@ -314,7 +331,7 @@ async function readAllowFromStateForPathWithExists( return await readAllowFromFileWithExists({ cacheNamespace: PAIRING_ALLOW_FROM_CACHE_NAMESPACE, filePath, - normalizeStore: (store) => normalizeAllowFromList(channel, store), + normalizeStore: (store) => normalizeAllowFromList(store, resolvePairingAdapter(channel)), }); } @@ -325,11 +342,14 @@ function readAllowFromStateForPathSync(channel: PairingChannel, filePath: string function readAllowFromStateForPathSyncWithExists( channel: PairingChannel, filePath: string, -): { entries: string[]; exists: boolean } { +): { + entries: string[]; + exists: boolean; +} { return readAllowFromFileSyncWithExists({ cacheNamespace: PAIRING_ALLOW_FROM_CACHE_NAMESPACE, filePath, - normalizeStore: (store) => normalizeAllowFromList(channel, store), + normalizeStore: (store) => normalizeAllowFromList(store, resolvePairingAdapter(channel)), }); } @@ -337,13 +357,15 @@ async function readAllowFromState(params: { channel: PairingChannel; entry: string | number; filePath: string; + pairingAdapter?: ChannelPairingAdapter; }): Promise<{ current: string[]; normalized: string | null }> { const { value } = await readJsonFile(params.filePath, { version: 1, allowFrom: [], }); - const current = normalizeAllowFromList(params.channel, value); - const normalized = normalizeAllowFromInput(params.channel, params.entry); + const pairingAdapter = resolvePairingAdapter(params.channel, params.pairingAdapter); + const current = normalizeAllowFromList(value, pairingAdapter); + const normalized = normalizeAllowFromInput(params.entry, pairingAdapter); return { current, normalized: normalized || null }; } @@ -396,6 +418,7 @@ async function updateAllowFromStoreEntry(params: { entry: string | number; accountId?: string; env?: NodeJS.ProcessEnv; + pairingAdapter?: ChannelPairingAdapter; apply: (current: string[], normalized: string) => string[] | null; }): Promise<{ changed: boolean; allowFrom: string[] }> { const env = params.env ?? process.env; @@ -408,6 +431,7 @@ async function updateAllowFromStoreEntry(params: { channel: params.channel, entry: params.entry, filePath, + pairingAdapter: params.pairingAdapter, }); if (!normalized) { return { changed: false, allowFrom: current }; @@ -491,6 +515,7 @@ type AllowFromStoreEntryUpdateParams = { entry: string | number; accountId?: string; env?: NodeJS.ProcessEnv; + pairingAdapter?: ChannelPairingAdapter; }; type ChannelAllowFromStoreEntryMutation = ( @@ -508,6 +533,7 @@ async function updateChannelAllowFromStore( entry: params.entry, accountId: params.accountId, env: params.env, + pairingAdapter: params.pairingAdapter, apply: params.apply, }); } @@ -690,6 +716,7 @@ export async function approveChannelPairingCode(params: { code: string; accountId?: string; env?: NodeJS.ProcessEnv; + pairingAdapter?: ChannelPairingAdapter; }): Promise<{ id: string; entry?: PairingRequest } | null> { const env = params.env ?? process.env; const code = (normalizeNullableString(params.code) ?? "").toUpperCase(); @@ -734,6 +761,7 @@ export async function approveChannelPairingCode(params: { entry: entry.id, accountId: normalizeOptionalString(params.accountId) ?? entryAccountId, env, + pairingAdapter: params.pairingAdapter, }); return { id: entry.id, entry }; }, diff --git a/src/plugin-sdk/browser-bridge.ts b/src/plugin-sdk/browser-bridge.ts index 931831b562b8..d4995bcdbffa 100644 --- a/src/plugin-sdk/browser-bridge.ts +++ b/src/plugin-sdk/browser-bridge.ts @@ -2,7 +2,7 @@ * Public SDK facade for starting and stopping the bundled browser bridge server. */ import type { Server } from "node:http"; -import type { ResolvedBrowserConfig } from "./browser-profiles.js"; +import type { ResolvedBrowserConfig } from "./browser-types.js"; import { loadActivatedBundledPluginPublicSurfaceModuleSync } from "./facade-runtime.js"; /** Running browser bridge server state returned to plugin callers. */ diff --git a/src/plugin-sdk/browser-profiles.ts b/src/plugin-sdk/browser-profiles.ts index ab50c5dc8bbe..f21ad37970af 100644 --- a/src/plugin-sdk/browser-profiles.ts +++ b/src/plugin-sdk/browser-profiles.ts @@ -2,10 +2,16 @@ * Public SDK facade for browser profile defaults and activated profile resolution. */ import path from "node:path"; -import type { BrowserConfig, BrowserProfileConfig, OpenClawConfig } from "../config/config.js"; -import type { SsrFPolicy } from "../infra/net/ssrf.js"; +import type { BrowserConfig } from "../config/types.browser.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; +import type { ResolvedBrowserConfig, ResolvedBrowserProfile } from "./browser-types.js"; import { loadBundledPluginPublicSurfaceModuleSync } from "./facade-loader.js"; +export type { + ResolvedBrowserConfig, + ResolvedBrowserProfile, + ResolvedBrowserTabCleanupConfig, +} from "./browser-types.js"; /** Default global browser plugin enabled state. */ export const DEFAULT_OPENCLAW_BROWSER_ENABLED = true; @@ -24,55 +30,6 @@ export const DEFAULT_AI_SNAPSHOT_MAX_CHARS = 80_000; /** Default upload staging directory used by browser-backed file uploads. */ export const DEFAULT_UPLOAD_DIR = path.join(resolvePreferredOpenClawTmpDir(), "uploads"); -/** Resolved browser tab cleanup settings after defaults and config are applied. */ -export type ResolvedBrowserTabCleanupConfig = { - enabled: boolean; - idleMinutes: number; - maxTabsPerSession: number; - sweepMinutes: number; -}; - -/** Fully resolved browser plugin config used by browser runtime callers. */ -export type ResolvedBrowserConfig = { - enabled: boolean; - evaluateEnabled: boolean; - controlPort: number; - cdpPortRangeStart: number; - cdpPortRangeEnd: number; - cdpProtocol: "http" | "https"; - cdpHost: string; - cdpIsLoopback: boolean; - remoteCdpTimeoutMs: number; - remoteCdpHandshakeTimeoutMs: number; - localLaunchTimeoutMs: number; - localCdpReadyTimeoutMs: number; - actionTimeoutMs: number; - color: string; - executablePath?: string; - headless: boolean; - noSandbox: boolean; - attachOnly: boolean; - defaultProfile: string; - profiles: Record; - tabCleanup: ResolvedBrowserTabCleanupConfig; - ssrfPolicy?: SsrFPolicy; - extraArgs: string[]; -}; - -/** One resolved browser profile target including CDP endpoint and launch mode. */ -export type ResolvedBrowserProfile = { - name: string; - cdpPort: number; - cdpUrl: string; - cdpHost: string; - cdpIsLoopback: boolean; - userDataDir?: string; - color: string; - driver: "openclaw" | "existing-session"; - headless?: boolean; - attachOnly: boolean; -}; - type BrowserProfilesSurface = { resolveBrowserConfig: ( cfg: BrowserConfig | undefined, diff --git a/src/plugin-sdk/browser-types.ts b/src/plugin-sdk/browser-types.ts new file mode 100644 index 000000000000..571fd272e4b0 --- /dev/null +++ b/src/plugin-sdk/browser-types.ts @@ -0,0 +1,73 @@ +/** Browser profile config embedded in resolved browser config. */ +export type ResolvedBrowserProfileConfig = { + cdpPort?: number; + cdpUrl?: string; + userDataDir?: string; + mcpCommand?: string; + mcpArgs?: string[]; + driver?: "openclaw" | "clawd" | "existing-session"; + headless?: boolean; + executablePath?: string; + attachOnly?: boolean; + color: string; +}; + +/** SSRF policy embedded in resolved browser config. */ +export type ResolvedBrowserSsrFPolicy = { + allowPrivateNetwork?: boolean; + dangerouslyAllowPrivateNetwork?: boolean; + allowRfc2544BenchmarkRange?: boolean; + allowIpv6UniqueLocalRange?: boolean; + allowedHostnames?: string[]; + allowedOrigins?: string[]; + hostnameAllowlist?: string[]; +}; + +/** Resolved browser tab cleanup settings after defaults and config are applied. */ +export type ResolvedBrowserTabCleanupConfig = { + enabled: boolean; + idleMinutes: number; + maxTabsPerSession: number; + sweepMinutes: number; +}; + +/** Fully resolved browser plugin config used by browser runtime callers. */ +export type ResolvedBrowserConfig = { + enabled: boolean; + evaluateEnabled: boolean; + controlPort: number; + cdpPortRangeStart: number; + cdpPortRangeEnd: number; + cdpProtocol: "http" | "https"; + cdpHost: string; + cdpIsLoopback: boolean; + remoteCdpTimeoutMs: number; + remoteCdpHandshakeTimeoutMs: number; + localLaunchTimeoutMs: number; + localCdpReadyTimeoutMs: number; + actionTimeoutMs: number; + color: string; + executablePath?: string; + headless: boolean; + noSandbox: boolean; + attachOnly: boolean; + defaultProfile: string; + profiles: Record; + tabCleanup: ResolvedBrowserTabCleanupConfig; + ssrfPolicy?: ResolvedBrowserSsrFPolicy; + extraArgs: string[]; +}; + +/** One resolved browser profile target including CDP endpoint and launch mode. */ +export type ResolvedBrowserProfile = { + name: string; + cdpPort: number; + cdpUrl: string; + cdpHost: string; + cdpIsLoopback: boolean; + userDataDir?: string; + color: string; + driver: "openclaw" | "existing-session"; + headless?: boolean; + attachOnly: boolean; +}; diff --git a/src/plugin-sdk/channel-entry-contract.ts b/src/plugin-sdk/channel-entry-contract.ts index 28bd2cd35580..00952a611678 100644 --- a/src/plugin-sdk/channel-entry-contract.ts +++ b/src/plugin-sdk/channel-entry-contract.ts @@ -21,22 +21,16 @@ import { type PluginModuleLoaderFactory, type PluginModuleLoaderCache, } from "../plugins/plugin-module-loader-cache.js"; -import type { PluginRuntime } from "../plugins/runtime/types.js"; import { buildPluginLoaderAliasMap, resolveLoaderPackageRoot } from "../plugins/sdk-alias.js"; -import type { - AnyAgentTool, - OpenClawPluginApi, - OpenClawPluginCommandDefinition, - PluginCommandContext, -} from "../plugins/types.js"; import { toSafeImportPath } from "../shared/import-specifier.js"; -export type { - AnyAgentTool, - OpenClawPluginApi, - OpenClawPluginCommandDefinition, - PluginCommandContext, -}; +export type AnyAgentTool = import("../plugins/types.js").AnyAgentTool; +export type OpenClawPluginApi = import("../plugins/types.js").OpenClawPluginApi; +export type OpenClawPluginCommandDefinition = + import("../plugins/types.js").OpenClawPluginCommandDefinition; +export type PluginCommandContext = import("../plugins/types.js").PluginCommandContext; + +type BundledChannelRuntime = unknown; type ChannelEntryConfigSchema = TPlugin extends ChannelPlugin @@ -126,7 +120,7 @@ export type BundledChannelEntryContract = { loadChannelAccountInspector?: ( options?: BundledEntryModuleLoadOptions, ) => NonNullable; - setChannelRuntime?: (runtime: PluginRuntime) => void; + setChannelRuntime?: (runtime: BundledChannelRuntime) => void; }; /** Runtime contract returned by a bundled channel's setup-only entrypoint definition. */ @@ -142,7 +136,7 @@ export type BundledChannelSetupEntryContract = { loadLegacySessionSurface?: ( options?: BundledEntryModuleLoadOptions, ) => BundledChannelLegacySessionSurface; - setChannelRuntime?: (runtime: PluginRuntime) => void; + setChannelRuntime?: (runtime: BundledChannelRuntime) => void; registerSetupRuntime?: (api: OpenClawPluginApi) => void; features?: BundledChannelSetupEntryFeatures; }; @@ -537,8 +531,8 @@ export function defineBundledChannelEntry({ ) : undefined; const setChannelRuntime = runtime - ? (pluginRuntime: PluginRuntime) => { - const setter = loadBundledEntryExportSync<(runtime: PluginRuntime) => void>( + ? (pluginRuntime: BundledChannelRuntime) => { + const setter = loadBundledEntryExportSync<(runtime: BundledChannelRuntime) => void>( importMetaUrl, runtime, ); @@ -604,8 +598,8 @@ export function defineBundledChannelSetupEntry({ // When runtime wiring is needed, expose only the setter so the loader can hand // the setup surface the active runtime without importing the full channel entry. const setChannelRuntime = runtime - ? (pluginRuntime: PluginRuntime) => { - const setter = loadBundledEntryExportSync<(runtime: PluginRuntime) => void>( + ? (pluginRuntime: BundledChannelRuntime) => { + const setter = loadBundledEntryExportSync<(runtime: BundledChannelRuntime) => void>( importMetaUrl, runtime, ); diff --git a/src/plugin-sdk/outbound-media.test.ts b/src/plugin-sdk/outbound-media.test.ts index b6aa6cbdca97..d94e0522df88 100644 --- a/src/plugin-sdk/outbound-media.test.ts +++ b/src/plugin-sdk/outbound-media.test.ts @@ -1,27 +1,39 @@ -// Outbound media tests cover plugin media attachment normalization and access policy. import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { + HostedOutboundMediaChunkRecord, + HostedOutboundMediaMetaRecord, +} from "./outbound-media.js"; +// Outbound media tests cover plugin media attachment normalization and access policy. +import { + createPluginStateKeyedStoreForTests, + resetPluginStateStoreForTests, +} from "./plugin-state-test-runtime.js"; const loadWebMediaMock = vi.hoisted(() => vi.fn()); type OutboundMediaModule = typeof import("./outbound-media.js"); +let createHostedOutboundMediaStore: OutboundMediaModule["createHostedOutboundMediaStore"]; let loadOutboundMediaFromUrl: OutboundMediaModule["loadOutboundMediaFromUrl"]; +beforeAll(async () => { + const webMedia = await import("./web-media.js"); + vi.spyOn(webMedia, "loadWebMedia").mockImplementation(loadWebMediaMock); + ({ createHostedOutboundMediaStore, loadOutboundMediaFromUrl } = + await import("./outbound-media.js")); +}); + +afterAll(() => { + vi.restoreAllMocks(); +}); + +beforeEach(() => { + resetPluginStateStoreForTests(); + loadWebMediaMock.mockReset(); + vi.useRealTimers(); +}); + describe("loadOutboundMediaFromUrl", () => { - beforeAll(async () => { - const webMedia = await import("./web-media.js"); - vi.spyOn(webMedia, "loadWebMedia").mockImplementation(loadWebMediaMock); - ({ loadOutboundMediaFromUrl } = await import("./outbound-media.js")); - }); - - afterAll(() => { - vi.restoreAllMocks(); - }); - - beforeEach(() => { - loadWebMediaMock.mockReset(); - }); - it("forwards maxBytes and mediaLocalRoots to loadWebMedia", async () => { loadWebMediaMock.mockResolvedValueOnce({ buffer: Buffer.from("x"), @@ -105,3 +117,269 @@ describe("loadOutboundMediaFromUrl", () => { }); }); }); + +describe("createHostedOutboundMediaStore", () => { + function createStore() { + return createHostedOutboundMediaStore({ + metadataStore: createPluginStateKeyedStoreForTests("fixture-plugin", { + namespace: "hosted-media", + maxEntries: 10, + }), + chunkStore: createPluginStateKeyedStoreForTests("fixture-plugin", { + namespace: "hosted-media-chunks", + maxEntries: 100, + }), + ttlMs: 120_000, + resolveExpiresAtMs: () => Date.now() + 120_000, + createId: () => "abc123abc123abc123abc123", + createToken: () => "token123", + rawChunkBytes: 4, + maxEntries: 10, + maxChunkRows: 100, + }); + } + + it("stores hosted media chunks and reads them back", async () => { + loadWebMediaMock.mockResolvedValueOnce({ + buffer: Buffer.from("image-bytes"), + kind: "image", + contentType: "image/png", + }); + const store = createStore(); + + const url = await store.prepareUrl({ + mediaUrl: "https://example.com/photo.png", + routePath: "/hook/media/", + publicBaseUrl: "https://gateway.example.com", + maxBytes: 1024, + }); + const entry = await store.read("abc123abc123abc123abc123"); + + expect(url).toBe( + "https://gateway.example.com/hook/media/abc123abc123abc123abc123?token=token123", + ); + expect(entry?.metadata).toMatchObject({ + routePath: "/hook/media/", + token: "token123", + contentType: "image/png", + byteLength: Buffer.byteLength("image-bytes"), + }); + expect(entry?.buffer.toString("utf8")).toBe("image-bytes"); + }); + + it("keeps metadata long enough to clean up expired chunk rows", async () => { + vi.useFakeTimers(); + vi.setSystemTime(1000); + loadWebMediaMock.mockResolvedValueOnce({ + buffer: Buffer.from("image-bytes"), + kind: "image", + contentType: "image/png", + }); + const metadataStore = createPluginStateKeyedStoreForTests( + "fixture-plugin", + { + namespace: "ttl-media", + maxEntries: 10, + }, + ); + const chunkStore = createPluginStateKeyedStoreForTests( + "fixture-plugin", + { + namespace: "ttl-media-chunks", + maxEntries: 100, + }, + ); + const store = createHostedOutboundMediaStore({ + metadataStore, + chunkStore, + ttlMs: 100, + resolveExpiresAtMs: (ttlMs) => Date.now() + ttlMs, + createId: () => "abc123abc123abc123abc123", + createToken: () => "token123", + rawChunkBytes: 4, + maxEntries: 10, + maxChunkRows: 100, + }); + + await store.prepareUrl({ + mediaUrl: "https://example.com/photo.png", + routePath: "/hook/media/", + publicBaseUrl: "https://gateway.example.com", + maxBytes: 1024, + }); + expect(await metadataStore.entries()).toHaveLength(1); + expect(await chunkStore.entries()).toHaveLength(3); + + vi.setSystemTime(1101); + expect(await metadataStore.entries()).toHaveLength(1); + expect(await chunkStore.entries()).toEqual([]); + await store.cleanupExpired(1101); + expect(await metadataStore.entries()).toEqual([]); + expect(await chunkStore.entries()).toEqual([]); + }); + + it("deletes all chunks for one hosted entry", async () => { + loadWebMediaMock.mockResolvedValueOnce({ + buffer: Buffer.from("image-bytes"), + kind: "image", + contentType: "image/png", + }); + const store = createStore(); + + await store.prepareUrl({ + mediaUrl: "https://example.com/photo.png", + routePath: "/hook/media/", + publicBaseUrl: "https://gateway.example.com", + maxBytes: 1024, + }); + await store.delete("abc123abc123abc123abc123"); + + expect(await store.read("abc123abc123abc123abc123")).toBeNull(); + }); + + it("prunes oldest complete entries before chunk rows evict independently", async () => { + let idCounter = 0; + const store = createHostedOutboundMediaStore({ + metadataStore: createPluginStateKeyedStoreForTests("fixture-plugin", { + namespace: "capacity-media", + maxEntries: 4, + }), + chunkStore: createPluginStateKeyedStoreForTests("fixture-plugin", { + namespace: "capacity-media-chunks", + maxEntries: 4, + }), + ttlMs: 120_000, + resolveExpiresAtMs: () => Date.now() + 120_000, + createId: () => { + idCounter += 1; + return idCounter === 1 ? "111111111111111111111111" : "222222222222222222222222"; + }, + createToken: () => "token123", + rawChunkBytes: 4, + maxEntries: 2, + maxChunkRows: 4, + }); + loadWebMediaMock.mockResolvedValue({ + buffer: Buffer.from("image-bytes"), + kind: "image", + contentType: "image/png", + }); + + await store.prepareUrl({ + mediaUrl: "https://example.com/first.png", + routePath: "/hook/media/", + publicBaseUrl: "https://gateway.example.com", + maxBytes: 1024, + }); + await store.prepareUrl({ + mediaUrl: "https://example.com/second.png", + routePath: "/hook/media/", + publicBaseUrl: "https://gateway.example.com", + maxBytes: 1024, + }); + + expect(await store.read("111111111111111111111111")).toBeNull(); + expect(await store.read("222222222222222222222222")).not.toBeNull(); + }); + + it("removes written chunks when metadata registration fails", async () => { + const metadataStore = createPluginStateKeyedStoreForTests( + "fixture-plugin", + { + namespace: "rollback-media", + maxEntries: 10, + }, + ); + const chunkStore = createPluginStateKeyedStoreForTests( + "fixture-plugin", + { + namespace: "rollback-media-chunks", + maxEntries: 100, + }, + ); + const store = createHostedOutboundMediaStore({ + metadataStore: { + ...metadataStore, + register: async () => { + throw new Error("metadata write failed"); + }, + }, + chunkStore, + ttlMs: 120_000, + resolveExpiresAtMs: () => Date.now() + 120_000, + createId: () => "333333333333333333333333", + createToken: () => "token123", + rawChunkBytes: 4, + maxEntries: 10, + maxChunkRows: 100, + }); + loadWebMediaMock.mockResolvedValue({ + buffer: Buffer.from("image-bytes"), + kind: "image", + contentType: "image/png", + }); + + await expect( + store.prepareUrl({ + mediaUrl: "https://example.com/photo.png", + routePath: "/hook/media/", + publicBaseUrl: "https://gateway.example.com", + maxBytes: 1024, + }), + ).rejects.toThrow("metadata write failed"); + + expect(await chunkStore.entries()).toHaveLength(0); + }); + + it("cleans chunks after helper-owned metadata expiry", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(1_000)); + try { + const chunkStore = createPluginStateKeyedStoreForTests( + "fixture-plugin", + { + namespace: "expiry-media-chunks", + maxEntries: 100, + }, + ); + const store = createHostedOutboundMediaStore({ + metadataStore: createPluginStateKeyedStoreForTests( + "fixture-plugin", + { + namespace: "expiry-media", + maxEntries: 10, + }, + ), + chunkStore, + ttlMs: 1_000, + resolveExpiresAtMs: (ttlMs) => Date.now() + ttlMs, + createId: () => "444444444444444444444444", + createToken: () => "token123", + rawChunkBytes: 4, + maxEntries: 10, + maxChunkRows: 100, + }); + loadWebMediaMock.mockResolvedValue({ + buffer: Buffer.from("image-bytes"), + kind: "image", + contentType: "image/png", + }); + + await store.prepareUrl({ + mediaUrl: "https://example.com/photo.png", + routePath: "/hook/media/", + publicBaseUrl: "https://gateway.example.com", + maxBytes: 1024, + }); + expect(await chunkStore.entries()).toHaveLength(3); + + vi.setSystemTime(new Date(3_000)); + await store.cleanupExpired(); + + expect(await chunkStore.entries()).toHaveLength(0); + expect(await store.read("444444444444444444444444")).toBeNull(); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/src/plugin-sdk/outbound-media.ts b/src/plugin-sdk/outbound-media.ts index c23b5bb0fd65..c7cb3591af71 100644 --- a/src/plugin-sdk/outbound-media.ts +++ b/src/plugin-sdk/outbound-media.ts @@ -1,5 +1,7 @@ // Outbound media helpers normalize plugin media attachments before channel delivery. +import { randomBytes } from "node:crypto"; import { buildOutboundMediaLoadOptions, type OutboundMediaAccess } from "../media/load-options.js"; +import type { PluginStateKeyedStore } from "./plugin-state-runtime.js"; import { loadWebMedia } from "./web-media.js"; /** Media loading policy used before plugin media is handed to channel delivery. */ @@ -44,3 +46,275 @@ export async function loadOutboundMediaFromUrl( }), ); } + +export type HostedOutboundMediaMetadata = { + routePath: string; + token: string; + contentType?: string; + expiresAt: number; + byteLength: number; +}; + +export type HostedOutboundMediaEntry = { + metadata: HostedOutboundMediaMetadata; + buffer: Buffer; +}; + +export type HostedOutboundMediaMetaRecord = HostedOutboundMediaMetadata & { + id: string; + chunkCount: number; +}; + +export type HostedOutboundMediaChunkRecord = { + id: string; + index: number; + dataBase64: string; +}; + +export type HostedOutboundMediaStore = { + prepareUrl: (params: { + mediaUrl: string; + routePath: string; + publicBaseUrl: string; + maxBytes: number; + proxyUrl?: string; + }) => Promise; + read: (id: string, nowMs?: number) => Promise; + delete: (id: string) => Promise; + cleanupExpired: (nowMs?: number) => Promise; + clear: () => Promise; +}; + +export type CreateHostedOutboundMediaStoreOptions = { + metadataStore: PluginStateKeyedStore; + chunkStore: PluginStateKeyedStore; + ttlMs: number; + resolveExpiresAtMs: (ttlMs: number) => number | undefined; + createId?: () => string; + createToken?: () => string; + rawChunkBytes?: number; + maxEntries?: number; + maxChunkRows?: number; + chunkRowsPerEntryBudget?: number; +}; + +const DEFAULT_HOSTED_OUTBOUND_MEDIA_RAW_CHUNK_BYTES = 36 * 1024; +const DEFAULT_HOSTED_OUTBOUND_MEDIA_MAX_ENTRIES = 64; +const DEFAULT_HOSTED_OUTBOUND_MEDIA_CHUNK_ROWS_PER_ENTRY_BUDGET = 512; +const HOSTED_OUTBOUND_MEDIA_METADATA_TTL_GRACE_MS = 60_000; + +function createHostedOutboundMediaId(): string { + return randomBytes(12).toString("hex"); +} + +function createHostedOutboundMediaToken(): string { + return randomBytes(24).toString("hex"); +} + +function buildHostedOutboundMediaMetaKey(id: string): string { + return `media:${id}:meta`; +} + +function buildHostedOutboundMediaChunkKey(id: string, index: number): string { + return `media:${id}:chunk:${String(index).padStart(4, "0")}`; +} + +function resolveHostedOutboundMediaMetadataTtlMs(ttlMs: number): number { + return ttlMs + Math.min(ttlMs, HOSTED_OUTBOUND_MEDIA_METADATA_TTL_GRACE_MS); +} + +function isFutureHostedOutboundMediaExpiry(expiresAt: unknown, nowMs: number): expiresAt is number { + return typeof expiresAt === "number" && Number.isSafeInteger(expiresAt) && expiresAt > nowMs; +} + +function createHostedOutboundMediaMetaRecord(params: { + id: string; + routePath: string; + token: string; + contentType?: string; + expiresAt: number; + chunkCount: number; + byteLength: number; +}): HostedOutboundMediaMetaRecord { + return { + id: params.id, + routePath: params.routePath, + token: params.token, + ...(params.contentType ? { contentType: params.contentType } : {}), + expiresAt: params.expiresAt, + chunkCount: params.chunkCount, + byteLength: params.byteLength, + }; +} + +function createHostedOutboundMediaMetadata( + meta: HostedOutboundMediaMetaRecord, +): HostedOutboundMediaMetadata { + return { + routePath: meta.routePath, + token: meta.token, + ...(meta.contentType ? { contentType: meta.contentType } : {}), + expiresAt: meta.expiresAt, + byteLength: meta.byteLength, + }; +} + +async function deleteHostedOutboundMediaRows( + id: string, + metadataStore: PluginStateKeyedStore, + chunkStore: PluginStateKeyedStore, + knownChunkCount?: number, +): Promise { + const meta = await metadataStore.lookup(buildHostedOutboundMediaMetaKey(id)); + await metadataStore.delete(buildHostedOutboundMediaMetaKey(id)); + const chunkCount = meta?.chunkCount ?? knownChunkCount; + if (chunkCount == null) { + return; + } + for (let index = 0; index < chunkCount; index += 1) { + await chunkStore.delete(buildHostedOutboundMediaChunkKey(id, index)); + } +} + +export function createHostedOutboundMediaStore( + options: CreateHostedOutboundMediaStoreOptions, +): HostedOutboundMediaStore { + const rawChunkBytes = options.rawChunkBytes ?? DEFAULT_HOSTED_OUTBOUND_MEDIA_RAW_CHUNK_BYTES; + const maxEntries = options.maxEntries ?? DEFAULT_HOSTED_OUTBOUND_MEDIA_MAX_ENTRIES; + const chunkRowsPerEntryBudget = + options.chunkRowsPerEntryBudget ?? DEFAULT_HOSTED_OUTBOUND_MEDIA_CHUNK_ROWS_PER_ENTRY_BUDGET; + const maxChunkRows = options.maxChunkRows ?? maxEntries * chunkRowsPerEntryBudget; + if (!Number.isSafeInteger(maxEntries) || maxEntries < 1) { + throw new Error("hosted outbound media maxEntries must be a positive integer"); + } + if (!Number.isSafeInteger(maxChunkRows) || maxChunkRows < 1) { + throw new Error("hosted outbound media maxChunkRows must be a positive integer"); + } + const createId = options.createId ?? createHostedOutboundMediaId; + const createToken = options.createToken ?? createHostedOutboundMediaToken; + + async function deleteEntry(id: string): Promise { + await deleteHostedOutboundMediaRows(id, options.metadataStore, options.chunkStore); + } + + async function deleteEntryRows(id: string, chunkCount: number): Promise { + await deleteHostedOutboundMediaRows(id, options.metadataStore, options.chunkStore, chunkCount); + } + + async function cleanupExpired(nowMs = Date.now()): Promise { + for (const row of await options.metadataStore.entries()) { + if (!isFutureHostedOutboundMediaExpiry(row.value.expiresAt, nowMs)) { + await deleteEntry(row.value.id); + } + } + } + + async function pruneForCapacity(incomingChunkCount: number): Promise { + const rows = await options.metadataStore.entries(); + const validRows = rows.filter( + (row) => Number.isSafeInteger(row.value.chunkCount) && row.value.chunkCount > 0, + ); + const validKeys = new Set(validRows.map((row) => row.key)); + const orderedRows = validRows.toSorted( + (a, b) => a.createdAt - b.createdAt || a.key.localeCompare(b.key), + ); + const invalidRows = rows.filter((row) => !validKeys.has(row.key)); + for (const row of invalidRows) { + await deleteEntry(row.value.id); + } + + let entryCount = orderedRows.length; + let chunkCount = orderedRows.reduce((total, row) => total + row.value.chunkCount, 0); + for (const row of orderedRows) { + if (entryCount < maxEntries && chunkCount + incomingChunkCount <= maxChunkRows) { + break; + } + await deleteEntry(row.value.id); + entryCount -= 1; + chunkCount -= row.value.chunkCount; + } + } + + return { + async prepareUrl(params) { + await cleanupExpired(); + const expiresAt = options.resolveExpiresAtMs(options.ttlMs); + if (expiresAt === undefined) { + throw new Error("hosted outbound media expiry could not be resolved"); + } + const media = await loadOutboundMediaFromUrl(params.mediaUrl, { + maxBytes: params.maxBytes, + ...(params.proxyUrl ? { proxyUrl: params.proxyUrl } : {}), + }); + const id = createId(); + const token = createToken(); + const metadataTtlMs = resolveHostedOutboundMediaMetadataTtlMs(options.ttlMs); + const chunkCount = Math.max(1, Math.ceil(media.buffer.byteLength / rawChunkBytes)); + if (chunkCount > maxChunkRows) { + throw new Error( + `hosted outbound media exceeds SQLite chunk row limit (${chunkCount}/${maxChunkRows})`, + ); + } + await pruneForCapacity(chunkCount); + try { + for (let index = 0; index < chunkCount; index += 1) { + const chunk = media.buffer.subarray(index * rawChunkBytes, (index + 1) * rawChunkBytes); + await options.chunkStore.register( + buildHostedOutboundMediaChunkKey(id, index), + { + id, + index, + dataBase64: chunk.toString("base64"), + }, + { ttlMs: options.ttlMs }, + ); + } + await options.metadataStore.register( + buildHostedOutboundMediaMetaKey(id), + createHostedOutboundMediaMetaRecord({ + id, + routePath: params.routePath, + token, + contentType: media.contentType, + expiresAt, + chunkCount, + byteLength: media.buffer.byteLength, + }), + { ttlMs: metadataTtlMs }, + ); + } catch (error) { + await deleteEntryRows(id, chunkCount); + throw error; + } + return `${params.publicBaseUrl}${params.routePath}${id}?token=${token}`; + }, + async read(id, nowMs = Date.now()) { + const meta = await options.metadataStore.lookup(buildHostedOutboundMediaMetaKey(id)); + if (!meta) { + return null; + } + if (!isFutureHostedOutboundMediaExpiry(meta.expiresAt, nowMs)) { + await deleteEntry(id); + return null; + } + const chunks: Buffer[] = []; + for (let index = 0; index < meta.chunkCount; index += 1) { + const chunk = await options.chunkStore.lookup(buildHostedOutboundMediaChunkKey(id, index)); + if (!chunk || chunk.id !== id || chunk.index !== index) { + await deleteEntry(id); + return null; + } + chunks.push(Buffer.from(chunk.dataBase64, "base64")); + } + return { + metadata: createHostedOutboundMediaMetadata(meta), + buffer: Buffer.concat(chunks, meta.byteLength), + }; + }, + delete: deleteEntry, + cleanupExpired, + async clear() { + await Promise.all([options.metadataStore.clear(), options.chunkStore.clear()]); + }, + }; +} diff --git a/src/plugin-sdk/plugin-entry.ts b/src/plugin-sdk/plugin-entry.ts index 5b19dae1eca4..9c3d17bff03a 100644 --- a/src/plugin-sdk/plugin-entry.ts +++ b/src/plugin-sdk/plugin-entry.ts @@ -1,248 +1,195 @@ // Plugin entry contracts define the manifest-facing hooks implemented by plugin packages. import type { OpenClawConfig } from "../config/types.openclaw.js"; import { emptyPluginConfigSchema } from "../plugins/config-schema.js"; -import type { - AnyAgentTool, - AgentHarness, - AgentPromptGuidance, - AgentPromptGuidanceEntry, - AgentPromptSurfaceKind, - MediaUnderstandingProviderPlugin, - TranscriptSourceProvider, - MigrationApplyResult, - MigrationDetection, - MigrationItem, - MigrationPlan, - MigrationProviderContext, - MigrationProviderPlugin, - MigrationSummary, - OpenClawPluginApi, - OpenClawPluginCommandDefinition, - OpenClawPluginConfigSchema, - OpenClawPluginDefinition, - OpenClawPluginHttpRouteHandler, - OpenClawPluginNodeHostCommand, - OpenClawPluginNodeInvokePolicy, - OpenClawPluginNodeInvokePolicyContext, - OpenClawPluginNodeInvokePolicyResult, - OpenClawPluginReloadRegistration, - OpenClawPluginSecurityAuditCollector, - OpenClawPluginSecurityAuditContext, - OpenClawPluginService, - OpenClawPluginServiceContext, - OpenClawPluginToolContext, - OpenClawPluginToolFactory, - PluginLogger, - ProviderAugmentModelCatalogContext, - ProviderAuthContext, - ProviderAuthDoctorHintContext, - ProviderAuthMethod, - ProviderAuthMethodNonInteractiveContext, - ProviderAuthResult, - ProviderApplyConfigDefaultsContext, - ProviderBuildMissingAuthMessageContext, - ProviderBuildUnknownModelHintContext, - ProviderBuiltInModelSuppressionContext, - ProviderBuiltInModelSuppressionResult, - ProviderCacheTtlEligibilityContext, - ProviderCatalogContext, - ProviderCatalogResult, - ProviderDeferSyntheticProfileAuthContext, - ProviderDefaultThinkingPolicyContext, - ProviderDiscoveryContext, - ProviderFailoverErrorContext, - ProviderFetchUsageSnapshotContext, - ProviderModernModelPolicyContext, - ProviderNormalizeConfigContext, - ProviderNormalizeToolSchemasContext, - ProviderNormalizeTransportContext, - ProviderResolveConfigApiKeyContext, - ProviderNormalizeModelIdContext, - ProviderNormalizeResolvedModelContext, - ProviderPrepareDynamicModelContext, - ProviderPrepareExtraParamsContext, - ProviderPrepareRuntimeAuthContext, - ProviderPreparedRuntimeAuth, - ProviderReasoningOutputMode, - ProviderReasoningOutputModeContext, - ProviderReplayPolicy, - ProviderReplayPolicyContext, - ProviderReplaySessionEntry, - ProviderReplaySessionState, - RealtimeTranscriptionProviderPlugin, - ProviderResolvedUsageAuth, - ProviderUsageAuthToken, - ProviderResolveDynamicModelContext, - ProviderResolveTransportTurnStateContext, - ProviderResolveWebSocketSessionPolicyContext, - ProviderSanitizeReplayHistoryContext, - ProviderTransportTurnState, - ProviderToolSchemaDiagnostic, - ProviderResolveUsageAuthContext, - ProviderThinkingProfile, - ProviderThinkingPolicyContext, - ProviderValidateReplayTurnsContext, - ProviderWebSocketSessionPolicy, - ProviderWrapStreamFnContext, - UnifiedModelCatalogProviderContext, - UnifiedModelCatalogProviderPlugin, - OpenClawGatewayDiscoveryAdvertiseContext, - OpenClawGatewayDiscoveryService, - SpeechProviderPlugin, - PluginCommandContext, - PluginCommandResult, - PluginAgentEventEmitParams, - PluginAgentEventEmitResult, - PluginAgentEventSubscriptionRegistration, - PluginAgentTurnPrepareEvent, - PluginAgentTurnPrepareResult, - PluginControlUiDescriptor, - PluginHeartbeatPromptContributionEvent, - PluginHeartbeatPromptContributionResult, - PluginJsonValue, - PluginNextTurnInjection, - PluginNextTurnInjectionEnqueueResult, - PluginNextTurnInjectionRecord, - PluginRunContextGetParams, - PluginRunContextPatch, - PluginRuntimeLifecycleRegistration, - PluginSessionActionContext, - PluginSessionActionRegistration, - PluginSessionActionResult, - PluginSessionAttachmentParams, - PluginSessionAttachmentResult, - PluginSessionSchedulerJobHandle, - PluginSessionSchedulerJobRegistration, - PluginSessionTurnScheduleParams, - PluginSessionTurnUnscheduleByTagParams, - PluginSessionTurnUnscheduleByTagResult, - PluginSessionExtensionRegistration, - PluginSessionExtensionProjection, - PluginToolMetadataRegistration, - PluginTrustedToolPolicyRegistration, -} from "../plugins/types.js"; import { createCachedLazyValueGetter } from "./lazy-value.js"; -export type { - AnyAgentTool, - AgentHarness, - AgentPromptGuidance, - AgentPromptGuidanceEntry, - AgentPromptSurfaceKind, - MediaUnderstandingProviderPlugin, - TranscriptSourceProvider, - MigrationApplyResult, - MigrationDetection, - MigrationItem, - MigrationPlan, - MigrationProviderContext, - MigrationProviderPlugin, - MigrationSummary, - OpenClawPluginApi, - OpenClawPluginNodeHostCommand, - OpenClawPluginNodeInvokePolicy, - OpenClawPluginNodeInvokePolicyContext, - OpenClawPluginNodeInvokePolicyResult, - OpenClawPluginReloadRegistration, - OpenClawPluginSecurityAuditCollector, - OpenClawPluginSecurityAuditContext, - OpenClawPluginToolContext, - OpenClawPluginToolFactory, - PluginCommandContext, - PluginCommandResult, - PluginAgentEventEmitParams, - PluginAgentEventEmitResult, - PluginAgentEventSubscriptionRegistration, - PluginAgentTurnPrepareEvent, - PluginAgentTurnPrepareResult, - PluginControlUiDescriptor, - PluginHeartbeatPromptContributionEvent, - PluginHeartbeatPromptContributionResult, - PluginJsonValue, - PluginNextTurnInjection, - PluginNextTurnInjectionEnqueueResult, - PluginNextTurnInjectionRecord, - PluginRunContextGetParams, - PluginRunContextPatch, - PluginRuntimeLifecycleRegistration, - PluginSessionActionContext, - PluginSessionActionRegistration, - PluginSessionActionResult, - PluginSessionSchedulerJobHandle, - PluginSessionSchedulerJobRegistration, - PluginSessionAttachmentParams, - PluginSessionAttachmentResult, - PluginSessionTurnScheduleParams, - PluginSessionTurnUnscheduleByTagParams, - PluginSessionTurnUnscheduleByTagResult, - PluginSessionExtensionRegistration, - PluginSessionExtensionProjection, - PluginToolMetadataRegistration, - PluginTrustedToolPolicyRegistration, - OpenClawPluginConfigSchema, - OpenClawPluginHttpRouteHandler, - ProviderDiscoveryContext, - ProviderCatalogContext, - ProviderCatalogResult, - ProviderDeferSyntheticProfileAuthContext, - ProviderAugmentModelCatalogContext, - ProviderApplyConfigDefaultsContext, - ProviderBuiltInModelSuppressionContext, - ProviderBuiltInModelSuppressionResult, - ProviderBuildMissingAuthMessageContext, - ProviderBuildUnknownModelHintContext, - ProviderCacheTtlEligibilityContext, - ProviderDefaultThinkingPolicyContext, - ProviderFetchUsageSnapshotContext, - ProviderFailoverErrorContext, - ProviderModernModelPolicyContext, - ProviderNormalizeConfigContext, - ProviderNormalizeToolSchemasContext, - ProviderNormalizeTransportContext, - ProviderResolveConfigApiKeyContext, - ProviderNormalizeModelIdContext, - ProviderReplayPolicy, - ProviderReplayPolicyContext, - ProviderReplaySessionEntry, - ProviderReplaySessionState, - ProviderPreparedRuntimeAuth, - ProviderReasoningOutputMode, - ProviderReasoningOutputModeContext, - ProviderResolvedUsageAuth, - ProviderUsageAuthToken, - ProviderToolSchemaDiagnostic, - ProviderPrepareExtraParamsContext, - ProviderPrepareDynamicModelContext, - ProviderPrepareRuntimeAuthContext, - ProviderSanitizeReplayHistoryContext, - ProviderResolveUsageAuthContext, - ProviderThinkingProfile, - ProviderResolveDynamicModelContext, - ProviderResolveTransportTurnStateContext, - ProviderResolveWebSocketSessionPolicyContext, - ProviderNormalizeResolvedModelContext, - RealtimeTranscriptionProviderPlugin, - ProviderTransportTurnState, - SpeechProviderPlugin, - ProviderThinkingPolicyContext, - ProviderValidateReplayTurnsContext, - ProviderWebSocketSessionPolicy, - ProviderWrapStreamFnContext, - UnifiedModelCatalogProviderContext, - UnifiedModelCatalogProviderPlugin, - OpenClawGatewayDiscoveryAdvertiseContext, - OpenClawGatewayDiscoveryService, - OpenClawPluginService, - OpenClawPluginServiceContext, - ProviderAuthContext, - ProviderAuthDoctorHintContext, - ProviderAuthMethodNonInteractiveContext, - ProviderAuthMethod, - ProviderAuthResult, - OpenClawPluginCommandDefinition, - OpenClawPluginDefinition, - PluginLogger, -}; +export type AnyAgentTool = import("../plugins/types.js").AnyAgentTool; +export type AgentHarness = import("../plugins/types.js").AgentHarness; +export type AgentPromptGuidance = import("../plugins/types.js").AgentPromptGuidance; +export type AgentPromptGuidanceEntry = import("../plugins/types.js").AgentPromptGuidanceEntry; +export type AgentPromptSurfaceKind = import("../plugins/types.js").AgentPromptSurfaceKind; +export type MediaUnderstandingProviderPlugin = + import("../plugins/types.js").MediaUnderstandingProviderPlugin; +export type TranscriptSourceProvider = import("../plugins/types.js").TranscriptSourceProvider; +export type MigrationApplyResult = import("../plugins/types.js").MigrationApplyResult; +export type MigrationDetection = import("../plugins/types.js").MigrationDetection; +export type MigrationItem = import("../plugins/types.js").MigrationItem; +export type MigrationPlan = import("../plugins/types.js").MigrationPlan; +export type MigrationProviderContext = import("../plugins/types.js").MigrationProviderContext; +export type MigrationProviderPlugin = import("../plugins/types.js").MigrationProviderPlugin; +export type MigrationSummary = import("../plugins/types.js").MigrationSummary; +export type OpenClawPluginApi = import("../plugins/types.js").OpenClawPluginApi; +export type OpenClawPluginCommandDefinition = + import("../plugins/types.js").OpenClawPluginCommandDefinition; +export type OpenClawPluginConfigSchema = import("../plugins/types.js").OpenClawPluginConfigSchema; +export type OpenClawPluginDefinition = import("../plugins/types.js").OpenClawPluginDefinition; +export type OpenClawPluginHttpRouteHandler = + import("../plugins/types.js").OpenClawPluginHttpRouteHandler; +export type OpenClawPluginNodeHostCommand = + import("../plugins/types.js").OpenClawPluginNodeHostCommand; +export type OpenClawPluginNodeInvokePolicy = + import("../plugins/types.js").OpenClawPluginNodeInvokePolicy; +export type OpenClawPluginNodeInvokePolicyContext = + import("../plugins/types.js").OpenClawPluginNodeInvokePolicyContext; +export type OpenClawPluginNodeInvokePolicyResult = + import("../plugins/types.js").OpenClawPluginNodeInvokePolicyResult; +export type OpenClawPluginReloadRegistration = + import("../plugins/types.js").OpenClawPluginReloadRegistration; +export type OpenClawPluginSecurityAuditCollector = + import("../plugins/types.js").OpenClawPluginSecurityAuditCollector; +export type OpenClawPluginSecurityAuditContext = + import("../plugins/types.js").OpenClawPluginSecurityAuditContext; +export type OpenClawPluginService = import("../plugins/types.js").OpenClawPluginService; +export type OpenClawPluginServiceContext = + import("../plugins/types.js").OpenClawPluginServiceContext; +export type OpenClawPluginToolContext = import("../plugins/types.js").OpenClawPluginToolContext; +export type OpenClawPluginToolFactory = import("../plugins/types.js").OpenClawPluginToolFactory; +export type PluginLogger = import("../plugins/types.js").PluginLogger; +export type ProviderAugmentModelCatalogContext = + import("../plugins/types.js").ProviderAugmentModelCatalogContext; +export type ProviderAuthContext = import("../plugins/types.js").ProviderAuthContext; +export type ProviderAuthDoctorHintContext = + import("../plugins/types.js").ProviderAuthDoctorHintContext; +export type ProviderAuthMethod = import("../plugins/types.js").ProviderAuthMethod; +export type ProviderAuthMethodNonInteractiveContext = + import("../plugins/types.js").ProviderAuthMethodNonInteractiveContext; +export type ProviderAuthResult = import("../plugins/types.js").ProviderAuthResult; +export type ProviderApplyConfigDefaultsContext = + import("../plugins/types.js").ProviderApplyConfigDefaultsContext; +export type ProviderBuildMissingAuthMessageContext = + import("../plugins/types.js").ProviderBuildMissingAuthMessageContext; +export type ProviderBuildUnknownModelHintContext = + import("../plugins/types.js").ProviderBuildUnknownModelHintContext; +export type ProviderBuiltInModelSuppressionContext = + import("../plugins/types.js").ProviderBuiltInModelSuppressionContext; +export type ProviderBuiltInModelSuppressionResult = + import("../plugins/types.js").ProviderBuiltInModelSuppressionResult; +export type ProviderCacheTtlEligibilityContext = + import("../plugins/types.js").ProviderCacheTtlEligibilityContext; +export type ProviderCatalogContext = import("../plugins/types.js").ProviderCatalogContext; +export type ProviderCatalogResult = import("../plugins/types.js").ProviderCatalogResult; +export type ProviderDeferSyntheticProfileAuthContext = + import("../plugins/types.js").ProviderDeferSyntheticProfileAuthContext; +export type ProviderDefaultThinkingPolicyContext = + import("../plugins/types.js").ProviderDefaultThinkingPolicyContext; +export type ProviderDiscoveryContext = import("../plugins/types.js").ProviderDiscoveryContext; +export type ProviderFailoverErrorContext = + import("../plugins/types.js").ProviderFailoverErrorContext; +export type ProviderFetchUsageSnapshotContext = + import("../plugins/types.js").ProviderFetchUsageSnapshotContext; +export type ProviderModernModelPolicyContext = + import("../plugins/types.js").ProviderModernModelPolicyContext; +export type ProviderNormalizeConfigContext = + import("../plugins/types.js").ProviderNormalizeConfigContext; +export type ProviderNormalizeToolSchemasContext = + import("../plugins/types.js").ProviderNormalizeToolSchemasContext; +export type ProviderNormalizeTransportContext = + import("../plugins/types.js").ProviderNormalizeTransportContext; +export type ProviderResolveConfigApiKeyContext = + import("../plugins/types.js").ProviderResolveConfigApiKeyContext; +export type ProviderNormalizeModelIdContext = + import("../plugins/types.js").ProviderNormalizeModelIdContext; +export type ProviderNormalizeResolvedModelContext = + import("../plugins/types.js").ProviderNormalizeResolvedModelContext; +export type ProviderPrepareDynamicModelContext = + import("../plugins/types.js").ProviderPrepareDynamicModelContext; +export type ProviderPrepareExtraParamsContext = + import("../plugins/types.js").ProviderPrepareExtraParamsContext; +export type ProviderPrepareRuntimeAuthContext = + import("../plugins/types.js").ProviderPrepareRuntimeAuthContext; +export type ProviderPreparedRuntimeAuth = import("../plugins/types.js").ProviderPreparedRuntimeAuth; +export type ProviderReasoningOutputMode = import("../plugins/types.js").ProviderReasoningOutputMode; +export type ProviderReasoningOutputModeContext = + import("../plugins/types.js").ProviderReasoningOutputModeContext; +export type ProviderReplayPolicy = import("../plugins/types.js").ProviderReplayPolicy; +export type ProviderReplayPolicyContext = import("../plugins/types.js").ProviderReplayPolicyContext; +export type ProviderReplaySessionEntry = import("../plugins/types.js").ProviderReplaySessionEntry; +export type ProviderReplaySessionState = import("../plugins/types.js").ProviderReplaySessionState; +export type RealtimeTranscriptionProviderPlugin = + import("../plugins/types.js").RealtimeTranscriptionProviderPlugin; +export type ProviderResolvedUsageAuth = import("../plugins/types.js").ProviderResolvedUsageAuth; +export type ProviderUsageAuthToken = import("../plugins/types.js").ProviderUsageAuthToken; +export type ProviderResolveDynamicModelContext = + import("../plugins/types.js").ProviderResolveDynamicModelContext; +export type ProviderResolveTransportTurnStateContext = + import("../plugins/types.js").ProviderResolveTransportTurnStateContext; +export type ProviderResolveWebSocketSessionPolicyContext = + import("../plugins/types.js").ProviderResolveWebSocketSessionPolicyContext; +export type ProviderSanitizeReplayHistoryContext = + import("../plugins/types.js").ProviderSanitizeReplayHistoryContext; +export type ProviderTransportTurnState = import("../plugins/types.js").ProviderTransportTurnState; +export type ProviderToolSchemaDiagnostic = + import("../plugins/types.js").ProviderToolSchemaDiagnostic; +export type ProviderResolveUsageAuthContext = + import("../plugins/types.js").ProviderResolveUsageAuthContext; +export type ProviderThinkingProfile = import("../plugins/types.js").ProviderThinkingProfile; +export type ProviderThinkingPolicyContext = + import("../plugins/types.js").ProviderThinkingPolicyContext; +export type ProviderValidateReplayTurnsContext = + import("../plugins/types.js").ProviderValidateReplayTurnsContext; +export type ProviderWebSocketSessionPolicy = + import("../plugins/types.js").ProviderWebSocketSessionPolicy; +export type ProviderWrapStreamFnContext = import("../plugins/types.js").ProviderWrapStreamFnContext; +export type UnifiedModelCatalogProviderContext = + import("../plugins/types.js").UnifiedModelCatalogProviderContext; +export type UnifiedModelCatalogProviderPlugin = + import("../plugins/types.js").UnifiedModelCatalogProviderPlugin; +export type OpenClawGatewayDiscoveryAdvertiseContext = + import("../plugins/types.js").OpenClawGatewayDiscoveryAdvertiseContext; +export type OpenClawGatewayDiscoveryService = + import("../plugins/types.js").OpenClawGatewayDiscoveryService; +export type SpeechProviderPlugin = import("../plugins/types.js").SpeechProviderPlugin; +export type PluginCommandContext = import("../plugins/types.js").PluginCommandContext; +export type PluginCommandResult = import("../plugins/types.js").PluginCommandResult; +export type PluginAgentEventEmitParams = import("../plugins/types.js").PluginAgentEventEmitParams; +export type PluginAgentEventEmitResult = import("../plugins/types.js").PluginAgentEventEmitResult; +export type PluginAgentEventSubscriptionRegistration = + import("../plugins/types.js").PluginAgentEventSubscriptionRegistration; +export type PluginAgentTurnPrepareEvent = import("../plugins/types.js").PluginAgentTurnPrepareEvent; +export type PluginAgentTurnPrepareResult = + import("../plugins/types.js").PluginAgentTurnPrepareResult; +export type PluginControlUiDescriptor = import("../plugins/types.js").PluginControlUiDescriptor; +export type PluginHeartbeatPromptContributionEvent = + import("../plugins/types.js").PluginHeartbeatPromptContributionEvent; +export type PluginHeartbeatPromptContributionResult = + import("../plugins/types.js").PluginHeartbeatPromptContributionResult; +export type PluginJsonValue = import("../plugins/types.js").PluginJsonValue; +export type PluginNextTurnInjection = import("../plugins/types.js").PluginNextTurnInjection; +export type PluginNextTurnInjectionEnqueueResult = + import("../plugins/types.js").PluginNextTurnInjectionEnqueueResult; +export type PluginNextTurnInjectionRecord = + import("../plugins/types.js").PluginNextTurnInjectionRecord; +export type PluginRunContextGetParams = import("../plugins/types.js").PluginRunContextGetParams; +export type PluginRunContextPatch = import("../plugins/types.js").PluginRunContextPatch; +export type PluginRuntimeLifecycleRegistration = + import("../plugins/types.js").PluginRuntimeLifecycleRegistration; +export type PluginSessionActionContext = import("../plugins/types.js").PluginSessionActionContext; +export type PluginSessionActionRegistration = + import("../plugins/types.js").PluginSessionActionRegistration; +export type PluginSessionActionResult = import("../plugins/types.js").PluginSessionActionResult; +export type PluginSessionAttachmentParams = + import("../plugins/types.js").PluginSessionAttachmentParams; +export type PluginSessionAttachmentResult = + import("../plugins/types.js").PluginSessionAttachmentResult; +export type PluginSessionSchedulerJobHandle = + import("../plugins/types.js").PluginSessionSchedulerJobHandle; +export type PluginSessionSchedulerJobRegistration = + import("../plugins/types.js").PluginSessionSchedulerJobRegistration; +export type PluginSessionTurnScheduleParams = + import("../plugins/types.js").PluginSessionTurnScheduleParams; +export type PluginSessionTurnUnscheduleByTagParams = + import("../plugins/types.js").PluginSessionTurnUnscheduleByTagParams; +export type PluginSessionTurnUnscheduleByTagResult = + import("../plugins/types.js").PluginSessionTurnUnscheduleByTagResult; +export type PluginSessionExtensionRegistration = + import("../plugins/types.js").PluginSessionExtensionRegistration; +export type PluginSessionExtensionProjection = + import("../plugins/types.js").PluginSessionExtensionProjection; +export type PluginToolMetadataRegistration = + import("../plugins/types.js").PluginToolMetadataRegistration; +export type PluginTrustedToolPolicyRegistration = + import("../plugins/types.js").PluginTrustedToolPolicyRegistration; + export type { PluginConversationBinding, PluginConversationBindingResolvedEvent, diff --git a/src/plugin-sdk/provider-stream-shared.ts b/src/plugin-sdk/provider-stream-shared.ts index 0b4f3f2008de..9a63642aca55 100644 --- a/src/plugin-sdk/provider-stream-shared.ts +++ b/src/plugin-sdk/provider-stream-shared.ts @@ -13,7 +13,8 @@ import type { StreamFn } from "../agents/runtime/index.js"; import { streamWithPayloadPatch } from "../llm/providers/stream-wrappers/stream-payload-utils.js"; import { streamSimple } from "../llm/stream.js"; import { createAssistantMessageEventStream } from "../llm/utils/event-stream.js"; -import type { ProviderWrapStreamFnContext } from "./plugin-entry.js"; + +type ProviderWrapStreamFnContext = import("../plugins/types.js").ProviderWrapStreamFnContext; /** Optional provider stream decorator factory used by shared provider wrappers. */ export type ProviderStreamWrapperFactory = diff --git a/src/plugins/runtime/types.ts b/src/plugins/runtime/types.ts index f4d6633dca94..084af1450c1c 100644 --- a/src/plugins/runtime/types.ts +++ b/src/plugins/runtime/types.ts @@ -1,9 +1,10 @@ // Plugin runtime types describe activated plugin capabilities exposed to core execution. -import type { PluginRuntimeChannel } from "./types-channel.js"; import type { PluginRuntimeCore, RuntimeLogger } from "./types-core.js"; export type { RuntimeLogger }; +type PluginRuntimeChannel = import("./types-channel.js").PluginRuntimeChannel; + // ── Subagent runtime types ────────────────────────────────────────── export type SubagentRunParams = { diff --git a/test/scripts/check-gateway-watch-regression.test.ts b/test/scripts/check-gateway-watch-regression.test.ts index 801fc06cc82e..f35143a7f216 100644 --- a/test/scripts/check-gateway-watch-regression.test.ts +++ b/test/scripts/check-gateway-watch-regression.test.ts @@ -86,6 +86,9 @@ describe("check-gateway-watch-regression", () => { it("recognizes current and legacy gateway ready logs", () => { expect(hasGatewayReadyLog("[gateway] http server listening (0 plugins, 0.8s)")).toBe(true); expect(hasGatewayReadyLog("[gateway] ready (0 plugins, 0.8s)")).toBe(true); + expect(hasGatewayReadyLog("\u001B[36m[gateway]\u001B[39m \u001B[36mready\u001B[39m")).toBe( + true, + ); expect(hasGatewayReadyLog("[gateway] starting HTTP server...")).toBe(false); });