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.
This commit is contained in:
Peter Steinberger
2026-06-06 22:56:48 -07:00
committed by GitHub
parent 443ac732a1
commit 08ae0e6d29
42 changed files with 1381 additions and 684 deletions
+1 -1
View File
@@ -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
@@ -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
+5
View File
@@ -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
+1 -1
View File
@@ -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 |
+2 -2
View File
@@ -47,7 +47,7 @@ async function readLegacyGatewayInstanceId(filePath: string): Promise<string | n
async function readLegacyOpenProcessLeases(filePath: string): Promise<AcpxProcessLease[]> {
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;
+2 -2
View File
@@ -108,7 +108,7 @@ async function readNotifyState(api: OpenClawPluginApi): Promise<NotifyStateFile>
const subscribers = subscriberEntries
.map((entry) => entry.value)
.sort((a, b) => a.addedAtMs - b.addedAtMs);
.toSorted((a, b) => a.addedAtMs - b.addedAtMs);
const notifiedRequestIds: Record<string, number> = {};
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<void> {
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;
@@ -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 }],
},
@@ -51,8 +51,8 @@ async function fileExists(filePath: string): Promise<boolean> {
}
}
async function readJsonFile(filePath: string): Promise<unknown | null> {
return JSON.parse(await fs.readFile(filePath, "utf8")) as unknown;
async function readJsonFile(filePath: string): Promise<unknown> {
return JSON.parse(await fs.readFile(filePath, "utf8"));
}
async function archiveLegacySource(params: {
+38 -18
View File
@@ -31,6 +31,20 @@ type WorkspaceValue<T> = {
value: T;
};
export type MemoryCoreWorkspaceEntry<T> = { key: string; value: T };
type MemoryCoreWorkspaceParams = {
namespace: string;
workspaceDir: string;
};
type WriteMemoryCoreWorkspaceEntriesParams<T> = MemoryCoreWorkspaceParams & {
entries: Array<MemoryCoreWorkspaceEntry<T>>;
};
type WriteMemoryCoreWorkspaceEntryParams<T> = MemoryCoreWorkspaceParams &
MemoryCoreWorkspaceEntry<T>;
let configuredOpenKeyedStore: MemoryCoreOpenKeyedStore | undefined;
export function configureMemoryCoreDreamingState(openKeyedStore: MemoryCoreOpenKeyedStore): void {
@@ -87,24 +101,29 @@ function openWorkspaceStore<T>(namespace: string): PluginStateKeyedStore<Workspa
});
}
export async function readMemoryCoreWorkspaceEntries<T>(params: {
namespace: string;
workspaceDir: string;
}): Promise<Array<{ key: string; value: T }>> {
// Caller owns typed decoding for values read from plugin state.
export function readMemoryCoreWorkspaceEntries<T>(
params: MemoryCoreWorkspaceParams,
): Promise<Array<MemoryCoreWorkspaceEntry<T>>>;
export async function readMemoryCoreWorkspaceEntries(
params: MemoryCoreWorkspaceParams,
): Promise<Array<MemoryCoreWorkspaceEntry<unknown>>> {
const workspaceKey = memoryCoreWorkspaceStateKey(params.workspaceDir);
const prefix = `${workspaceKey}:`;
const entries = await openWorkspaceStore<T>(params.namespace).entries();
const entries = await openWorkspaceStore<unknown>(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<T>(params: {
namespace: string;
workspaceDir: string;
entries: Array<{ key: string; value: T }>;
}): Promise<void> {
const store = openWorkspaceStore<T>(params.namespace);
// Caller owns typed encoding for values written to plugin state.
export function writeMemoryCoreWorkspaceEntries<T>(
params: WriteMemoryCoreWorkspaceEntriesParams<T>,
): Promise<void>;
export async function writeMemoryCoreWorkspaceEntries(
params: WriteMemoryCoreWorkspaceEntriesParams<unknown>,
): Promise<void> {
const store = openWorkspaceStore<unknown>(params.namespace);
const workspaceKey = memoryCoreWorkspaceStateKey(params.workspaceDir);
const prefix = `${workspaceKey}:`;
const replacementKeys = new Set<string>();
@@ -126,14 +145,15 @@ export async function writeMemoryCoreWorkspaceEntries<T>(params: {
}
}
export async function writeMemoryCoreWorkspaceEntry<T>(params: {
namespace: string;
workspaceDir: string;
key: string;
value: T;
}): Promise<void> {
// Caller owns typed encoding for values written to plugin state.
export function writeMemoryCoreWorkspaceEntry<T>(
params: WriteMemoryCoreWorkspaceEntryParams<T>,
): Promise<void>;
export async function writeMemoryCoreWorkspaceEntry(
params: WriteMemoryCoreWorkspaceEntryParams<unknown>,
): Promise<void> {
const workspaceKey = memoryCoreWorkspaceStateKey(params.workspaceDir);
await openWorkspaceStore<T>(params.namespace).register(
await openWorkspaceStore<unknown>(params.namespace).register(
memoryCoreWorkspaceEntryKey(params.workspaceDir, params.key),
{
version: 1,
@@ -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,
@@ -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<QaParityReportScenario>) {
@@ -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(() =>
+26 -25
View File
@@ -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<string, unknown> & {
toolName?: unknown;
happyPrompt?: unknown;
failurePrompt?: unknown;
@@ -64,28 +64,33 @@ type QaRuntimeToolFixtureDeps = {
ensureImageGenerationConfigured: (env: QaSuiteRuntimeEnv) => Promise<unknown>;
};
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<string, unknown> {
return isRecord(raw);
}
function isKnownHarnessGap(value: unknown) {
return Boolean(value && typeof value === "object");
function isKnownHarnessGap(raw: unknown): raw is Record<string, unknown> {
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<string>,
config: QaRuntimeToolFixtureConfig,
) {
const knownBroken = isKnownBroken(config.knownBroken)
? (config.knownBroken as Record<string, unknown>)
: {};
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<string, unknown>)
: {};
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<string, unknown>,
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");
}
@@ -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<typeof import("./outbound-media.js")>("./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: <T>(options: OpenKeyedStoreOptions) =>
createPluginStateKeyedStoreForTests<T>("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<void> {
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();
});
+56 -40
View File
@@ -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<string, string>();
@@ -37,12 +37,24 @@ function createMockResponse() {
};
}
function installZaloRuntimeForTest(): void {
setZaloRuntime({
state: {
openKeyedStore: <T>(options: OpenKeyedStoreOptions) =>
createPluginStateKeyedStoreForTests<T>("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();
}
+61 -138
View File
@@ -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<HostedOutboundMediaMetaRecord>({
namespace: ZALO_OUTBOUND_MEDIA_NAMESPACE,
maxEntries: ZALO_OUTBOUND_MEDIA_MAX_ENTRIES + 16,
}),
chunkStore: runtime.state.openKeyedStore<HostedOutboundMediaChunkRecord>({
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<void> {
await privateFileStore(ZALO_OUTBOUND_MEDIA_DIR).writeText(".ready", "");
await unlink(join(ZALO_OUTBOUND_MEDIA_DIR, ".ready")).catch(() => undefined);
}
async function deleteHostedZaloMediaEntry(id: string): Promise<void> {
await Promise.all([
unlink(resolveHostedZaloMediaMetadataPath(id)).catch(() => undefined),
unlink(resolveHostedZaloMediaBufferPath(id)).catch(() => undefined),
]);
}
async function cleanupExpiredHostedZaloMedia(nowMs = Date.now()): Promise<void> {
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<string> {
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<boolean> {
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<void> {
if (!hostedZaloMediaStore) {
return;
}
await hostedZaloMediaStore.clear();
hostedZaloMediaStore = undefined;
}
+4
View File
@@ -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"
@@ -0,0 +1 @@
export * from "../../../src/plugin-sdk/outbound-media.js";
+3 -1
View File
@@ -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) {
+1 -1
View File
@@ -289,7 +289,7 @@ export function runCommand(command, args, options = {}) {
});
}
})
.catch((error) => {
.catch((/** @type {unknown} */ error) => {
lastResourceSampleError = error;
})
.finally(() => {
@@ -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}`,
);
+1 -1
View File
@@ -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;
}
+20
View File
@@ -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,
+20 -13
View File
@@ -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);
});
+20
View File
@@ -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(", ")}`,
+5 -3
View File
@@ -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`,
);
}
}
+3 -1
View File
@@ -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(),
+2 -1
View File
@@ -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;
+2 -2
View File
@@ -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 }> =
[];
+46
View File
@@ -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: {
+5 -6
View File
@@ -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])) {
+58 -1
View File
@@ -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<RandomIntSync>;
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();
+41 -13
View File
@@ -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<AllowFromStore>(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 };
},
+1 -1
View File
@@ -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. */
+8 -51
View File
@@ -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<string, BrowserProfileConfig>;
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,
+73
View File
@@ -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<string, ResolvedBrowserProfileConfig>;
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;
};
+13 -19
View File
@@ -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> =
TPlugin extends ChannelPlugin<unknown>
@@ -126,7 +120,7 @@ export type BundledChannelEntryContract<TPlugin = ChannelPlugin> = {
loadChannelAccountInspector?: (
options?: BundledEntryModuleLoadOptions,
) => NonNullable<ChannelPlugin["config"]["inspectAccount"]>;
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<TPlugin = ChannelPlugin> = {
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<TPlugin = ChannelPlugin>({
)
: 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<TPlugin = ChannelPlugin>({
// 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,
);
+293 -15
View File
@@ -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<HostedOutboundMediaMetaRecord>(
"fixture-plugin",
{
namespace: "ttl-media",
maxEntries: 10,
},
);
const chunkStore = createPluginStateKeyedStoreForTests<HostedOutboundMediaChunkRecord>(
"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<HostedOutboundMediaMetaRecord>(
"fixture-plugin",
{
namespace: "rollback-media",
maxEntries: 10,
},
);
const chunkStore = createPluginStateKeyedStoreForTests<HostedOutboundMediaChunkRecord>(
"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<HostedOutboundMediaChunkRecord>(
"fixture-plugin",
{
namespace: "expiry-media-chunks",
maxEntries: 100,
},
);
const store = createHostedOutboundMediaStore({
metadataStore: createPluginStateKeyedStoreForTests<HostedOutboundMediaMetaRecord>(
"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();
}
});
});
+274
View File
@@ -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<string>;
read: (id: string, nowMs?: number) => Promise<HostedOutboundMediaEntry | null>;
delete: (id: string) => Promise<void>;
cleanupExpired: (nowMs?: number) => Promise<void>;
clear: () => Promise<void>;
};
export type CreateHostedOutboundMediaStoreOptions = {
metadataStore: PluginStateKeyedStore<HostedOutboundMediaMetaRecord>;
chunkStore: PluginStateKeyedStore<HostedOutboundMediaChunkRecord>;
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<HostedOutboundMediaMetaRecord>,
chunkStore: PluginStateKeyedStore<HostedOutboundMediaChunkRecord>,
knownChunkCount?: number,
): Promise<void> {
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<void> {
await deleteHostedOutboundMediaRows(id, options.metadataStore, options.chunkStore);
}
async function deleteEntryRows(id: string, chunkCount: number): Promise<void> {
await deleteHostedOutboundMediaRows(id, options.metadataStore, options.chunkStore, chunkCount);
}
async function cleanupExpired(nowMs = Date.now()): Promise<void> {
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<void> {
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()]);
},
};
}
+187 -240
View File
@@ -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,
+2 -1
View File
@@ -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 =
+2 -1
View File
@@ -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 = {
@@ -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);
});