mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
443ac732a1
commit
08ae0e6d29
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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(() =>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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(", ")}`,
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }> =
|
||||
[];
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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])) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user