fix(synology-chat): deliver attachments without forwarding source URLs [AI] (#119941)

* fix(synology-chat): host outbound attachments

* fix(synology-chat): isolate hosted media routes

* fix(synology-chat): harden hosted media limits

* fix(synology-chat): inspect hosted media content

* fix(synology-chat): reject ambiguous media routes

* fix(synology-chat): retain indeterminate media capabilities

* fix(synology-chat): mask public callback URLs

* fix(synology-chat): align callback sensitivity metadata

* fix(synology-chat): reserve media capability query keys

* fix(synology-chat): release rejected staged media

* fix(synology-chat): preserve ambiguous media handoffs

* fix(synology-chat): scan complete active media preamble

* fix(synology-chat): report validated attachment readiness

* fix(synology-chat): bound public media capability probes

* fix(synology-chat): bound hosted media responses

* test(synology-chat): model response headers read-only

* fix(synology-chat): bound hosted media reads

* fix(synology-chat): bound hosted media delivery

* fix(synology-chat): retain media through active serves

* fix(plugin-sdk): lease hosted media readers atomically

* fix(synology-chat): close hosted media review gaps

* test(synology-chat): clean up hosted media state

* fix(system-agent): canonicalize sensitive config paths

* fix(system-agent): honor runtime config sensitivity hints

* fix(config): inherit sensitive metadata

* fix(synology-chat): close latest review findings

* test(system-agent): keep config recovery checks lint-clean

* fix(system-agent): redact structured config secrets

* fix(synology-chat): harden active-content sniffing

* fix(security): close hosted media review gaps

* fix(security): close remaining hosted media review findings

* fix(system-agent): narrow dynamic owner ids safely

* fix(system-agent): narrow dynamic channel ids safely

* test(channels): isolate hosted media proofs

* fix(security): close config and hosted media review gaps

* test(plugin-sdk): split outbound media retention coverage

* test(plugin-sdk): isolate capacity store fixtures

* test(system-agent): assert config secrecy invariant
This commit is contained in:
Pavan Kumar Gondhi
2026-08-15 03:35:20 +05:30
committed by GitHub
parent 1b98bc35a0
commit d5e89de906
57 changed files with 5481 additions and 476 deletions
+24 -4
View File
@@ -8,7 +8,7 @@ title: "Synology Chat"
Synology Chat connects to OpenClaw through a webhook pair: a Synology Chat outgoing webhook posts inbound direct messages to the Gateway, and replies go back through a Synology Chat incoming webhook.
Status: official plugin, installed separately. Direct messages only; text and URL-based file sends are supported.
Status: official plugin, installed separately. Direct messages only; text and hosted file sends are supported.
## Install
@@ -33,9 +33,10 @@ Details: [Plugins](/tools/plugin)
3. Point the outgoing webhook URL to your OpenClaw Gateway:
- `https://gateway-host/webhook/synology` by default.
- Or your custom `channels.synology-chat.webhookPath`.
- Record that exact externally reachable HTTPS URL as `channels.synology-chat.webhookUrl` so the NAS can retrieve hosted attachments.
4. Finish setup in OpenClaw. Synology Chat appears in the same channel setup list in both flows:
- Guided: `openclaw onboard` or `openclaw channels add`
- Direct: `openclaw channels add --channel synology-chat --token <token> --url <incoming-webhook-url>`
- Direct: `openclaw channels add --channel synology-chat --token <token> --url <incoming-webhook-url> --webhook-url <public-outgoing-webhook-url>`
5. Restart the Gateway and send a DM to the Synology Chat bot.
Webhook auth details:
@@ -65,6 +66,7 @@ Minimal config:
enabled: true,
token: "synology-outgoing-token",
incomingUrl: "https://nas.example.com/webapi/entry.cgi?api=SYNO.Chat.External&method=incoming&version=2&token=...",
webhookUrl: "https://gateway.example.com/webhook/synology",
webhookPath: "/webhook/synology",
dmPolicy: "allowlist",
allowedUserIds: ["123456"],
@@ -111,12 +113,22 @@ openclaw message send --channel synology-chat --target synology-chat:123456 --me
openclaw message send --channel synology-chat --target synology:123456 --message "Short prefix"
```
Outbound text is chunked at 2000 characters. Media sends are supported by URL-based file delivery: the NAS downloads and attaches the file (max 32 MB). Outbound file URLs must use `http` or `https`, and private or otherwise blocked network targets are rejected before OpenClaw forwards the URL to the NAS webhook.
Outbound text is chunked at 2000 characters, and ordinary links remain intact. Keep **Hide URL previews in conversations and channels** enabled in Synology Chat Admin Console on a supported Chat Server release.
For attachments, OpenClaw loads the source under its guarded outbound-media policy, freezes the resulting bytes in bounded plugin-scoped SQLite state, and gives Synology a short-lived opaque HTTPS capability on the configured webhook route. The NAS receives only this OpenClaw-hosted URL, never the original remote or local media reference. Capabilities are account- and route-scoped, reusable for delayed `GET` or `HEAD` requests during their ten-minute lifetime, and expire automatically. Files are limited to 32 MB. Each account can serve at most four attachment responses concurrently and 128 MB per minute; stalled responses are closed after two minutes. Byte-range responses are not advertised.
`webhookUrl` and `webhookPath` have different roles:
- `webhookUrl` is the exact externally reachable HTTPS callback configured in Synology Chat. OpenClaw uses its public origin, path, and existing query string when creating attachment capabilities.
- `webhookPath` is the internal Gateway route. A reverse proxy may map the public URL to this route, but should expose only this plugin path, not the general Gateway HTTP surface.
- `incomingUrl` points in the opposite direction: OpenClaw uses it to post replies to the NAS.
OpenClaw never derives the public URL from `Host` or `X-Forwarded-*` headers and never falls back to forwarding the original source URL. If `webhookUrl` is missing or invalid, inbound messages and outbound text continue to work, while attachment sends fail with an actionable setup error.
## Multi-account
Multiple Synology Chat accounts are supported under `channels.synology-chat.accounts`.
Each account can override token, incoming URL, webhook path, DM policy, and limits.
Each account can override token, incoming URL, public webhook URL, webhook path, DM policy, and limits.
Direct-message sessions are isolated per account and user, so the same numeric `user_id`
on two different Synology accounts does not share transcript state.
Give each enabled account a distinct `webhookPath`. OpenClaw rejects duplicate exact paths
@@ -134,10 +146,12 @@ but duplicate exact paths are still rejected fail-closed. Prefer explicit per-ac
default: {
token: "token-a",
incomingUrl: "https://nas-a.example.com/...token=...",
webhookUrl: "https://gateway.example.com/webhook/synology",
},
alerts: {
token: "token-b",
incomingUrl: "https://nas-b.example.com/...token=...",
webhookUrl: "https://gateway.example.com/webhook/synology-alerts",
webhookPath: "/webhook/synology-alerts",
dmPolicy: "allowlist",
allowedUserIds: ["987654"],
@@ -158,6 +172,8 @@ but duplicate exact paths are still rejected fail-closed. Prefer explicit per-ac
- Prefer `dmPolicy: "allowlist"` for production.
- Keep `dangerouslyAllowNameMatching` off unless you explicitly need legacy username-based reply delivery.
- Keep `dangerouslyAllowInheritedWebhookPath` off unless you explicitly accept shared-path routing risk in a multi-account setup.
- Reverse-proxy access logs can capture attachment capability tokens. Disable query-string logging or redact `__openclaw_synology_media_token_*` parameters, and keep application logs free of full capability URLs.
- Hosted attachments use `Content-Disposition: attachment`, `X-Content-Type-Options: nosniff`, and `Cache-Control: no-store`. Files declared or named as HTML, SVG, or XML are rejected. Frozen bytes that begin as a UTF-8, UTF-16, or UTF-32 markup document after an optional encoding marker, whitespace, and comments are also rejected; literal tags later in passive text or source files do not make those files active documents.
## Troubleshooting
@@ -175,6 +191,10 @@ but duplicate exact paths are still rejected fail-closed. Prefer explicit per-ac
- `dmPolicy="allowlist"` is enabled but no users are configured
- `User not authorized`:
- the sender's numeric `user_id` is not in `allowedUserIds`
- `Synology Chat attachments require webhookUrl`:
- set the account's exact externally reachable HTTPS outgoing-webhook callback URL
- confirm the reverse proxy maps only that public route to `webhookPath`
- text and inbound messaging remain available while attachment setup is incomplete
## Related
+3
View File
@@ -296,6 +296,9 @@ the shared outbound loader. Hosted media capacity defaults to
`overflowPolicy: "evict-oldest"` for compatibility. Use `"reject-new"` when
issued URLs must remain valid until expiry, and configure both backing keyed
stores with `"reject-new"` so independent writers cannot evict live rows.
Use `validateBeforePersist` to inspect the guarded loader's exact bytes and
metadata when a transport must reject a payload class. Treat its buffer as
read-only and throw to reject before capability creation or any store write.
Authenticate bearer requests with `readMetadata(...)` before calling `read(...)`
so invalid tokens and `HEAD` requests do not hydrate stored media chunks.
@@ -74,6 +74,17 @@ function mockCallArg(mock: ReturnType<typeof vi.fn>, callIndex = 0, argIndex = 0
return call[argIndex];
}
function stubIsolatedProcessEnv(patch: NodeJS.ProcessEnv): void {
const isolatedProcess = Object.create(process) as NodeJS.Process;
Object.defineProperty(isolatedProcess, "env", {
configurable: true,
value: { ...process.env, ...patch },
});
// Test files share the host process environment. Replace only this file's
// global view so proxy-policy coverage cannot redirect sibling transports.
vi.stubGlobal("process", isolatedProcess);
}
type GoogleAuthFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
async function createGoogleAuthTransportFetch(): Promise<GoogleAuthFetch> {
@@ -216,8 +227,10 @@ describe("googlechat google auth runtime", () => {
response: new Response("ok", { status: 200 }),
release,
});
vi.stubEnv("HTTPS_PROXY", "http://env-proxy.example:8080");
vi.stubEnv("https_proxy", "http://lower-proxy.example:8080");
stubIsolatedProcessEnv({
HTTPS_PROXY: "http://env-proxy.example:8080",
https_proxy: "http://lower-proxy.example:8080",
});
const guardedFetch = await createGoogleAuthTransportFetch();
const response = await guardedFetch("https://oauth2.googleapis.com/token", {
+10
View File
@@ -49,11 +49,21 @@
{
"key": "url",
"kind": "string",
"sensitive": true,
"cli": {
"flags": "--url <url>",
"description": "Synology Chat webhook URL"
}
},
{
"key": "webhookUrl",
"kind": "string",
"sensitive": true,
"cli": {
"flags": "--webhook-url <url>",
"description": "Public HTTPS Synology Chat callback URL used for attachments"
}
},
{
"key": "webhookPath",
"kind": "string",
+6
View File
@@ -133,6 +133,12 @@ export function resolveAccount(
enabled: merged.enabled ?? true,
token: merged.token ?? envToken,
incomingUrl: merged.incomingUrl ?? envIncomingUrl,
// The public callback is an exact per-route mapping. A named account with
// its own webhookPath must not silently publish capabilities on the base route.
webhookUrl:
normalizeOptionalString(
id === DEFAULT_ACCOUNT_ID ? merged.webhookUrl : rawAccount.webhookUrl,
) ?? "",
nasHost: merged.nasHost ?? envNasHost,
webhookPath: merged.webhookPath ?? "/webhook/synology",
webhookPathSource,
@@ -11,6 +11,7 @@ import {
setSynologyRuntimeConfigForTest,
synologyIngressStartMock,
synologyIngressStopMock,
tryHandleSynologyHostedMediaRequestMock,
} from "./channel.test-mocks.js";
import { makeFormBody, makeReq, makeRes } from "./test-http-utils.js";
@@ -54,6 +55,8 @@ describe("Synology channel wiring integration", () => {
resolveAgentRouteMock.mockClear();
synologyIngressStartMock.mockClear();
synologyIngressStopMock.mockClear();
tryHandleSynologyHostedMediaRequestMock.mockClear();
tryHandleSynologyHostedMediaRequestMock.mockResolvedValue(false);
setSynologyRuntimeConfigForTest({});
});
@@ -112,6 +115,46 @@ describe("Synology channel wiring integration", () => {
await started;
});
it("dispatches hosted GET and HEAD capabilities before the inbound webhook parser", async () => {
const abortController = new AbortController();
const cfg = {
channels: {
"synology-chat": {
enabled: true,
token: "valid-token",
incomingUrl: "https://nas.example.com/incoming",
webhookUrl: "https://gateway.example.com/webhook/synology",
webhookPath: "/webhook/synology",
dmPolicy: "allowlist",
allowedUserIds: ["123"],
},
},
};
const started = synologyChatPlugin.gateway.startAccount(
makeStartContext(cfg, "default", abortController.signal),
);
const [registered] = requireMockCall(
registerPluginHttpRouteMock,
0,
"Synology hosted media route",
);
tryHandleSynologyHostedMediaRequestMock.mockResolvedValue(true);
for (const method of ["GET", "HEAD"]) {
await registered.handler(
makeReq(method, "", {
url: "/webhook/synology?__openclaw_synology_media_token_id=token",
}),
makeRes(),
);
}
expect(tryHandleSynologyHostedMediaRequestMock).toHaveBeenCalledTimes(2);
expect(dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
abortController.abort();
await started;
});
it("stops ingress and rejects startup when the webhook route cannot bind", async () => {
const abortController = new AbortController();
const statusSink = vi.fn();
@@ -9,6 +9,7 @@ export const registerPluginHttpRouteMock: Mock<typeof registerPluginHttpRoute> =
);
export const synologyIngressStartMock = vi.fn();
export const synologyIngressStopMock = vi.fn(async () => undefined);
export const tryHandleSynologyHostedMediaRequestMock = vi.fn(async () => false);
export const dispatchReplyWithBufferedBlockDispatcher: Mock<
(_params: unknown) => Promise<{ counts: Record<string, number> }>
@@ -148,10 +149,22 @@ vi.mock("openclaw/plugin-sdk/webhook-ingress", async () => {
vi.mock("./client.js", () => ({
SYNOLOGY_CHAT_TEXT_CHUNK_LIMIT: 2_000,
sendMessage: vi.fn().mockResolvedValue(true),
sendFileUrl: vi.fn().mockResolvedValue(true),
sendHostedFileUrl: vi.fn().mockResolvedValue({ status: "accepted" }),
resolveLegacyWebhookNameToChatUserId: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("./outbound-media.js", async () => {
const actual = await vi.importActual<typeof import("./outbound-media.js")>("./outbound-media.js");
return {
...actual,
prepareSynologyHostedMedia: vi.fn(async () => ({
url: "https://gateway.example.com/webhook/synology?__openclaw_synology_media_token_test=value",
cleanup: vi.fn(async () => undefined),
})),
tryHandleSynologyHostedMediaRequest: tryHandleSynologyHostedMediaRequestMock,
};
});
vi.mock("./webhook-ingress.js", async () => {
const actual =
await vi.importActual<typeof import("./webhook-ingress.js")>("./webhook-ingress.js");
+198 -5
View File
@@ -9,6 +9,7 @@ const securityAccountDefaults: ResolvedSynologyChatAccount = {
enabled: true,
token: "t",
incomingUrl: "https://nas/incoming",
webhookUrl: "https://gateway.example.com/w",
nasHost: "h",
webhookPath: "/w",
webhookPathSource: "default" as const,
@@ -21,6 +22,23 @@ const securityAccountDefaults: ResolvedSynologyChatAccount = {
allowInsecureSsl: false,
};
const { preparedCapabilityUrl, prepareSynologyHostedMediaMock } = vi.hoisted(() => ({
preparedCapabilityUrl:
"https://gateway.example.com/w?__openclaw_synology_media_token_aaaaaaaaaaaaaaaaaaaaaaaa=secret",
prepareSynologyHostedMediaMock: vi.fn(),
}));
vi.mock("./outbound-media.js", () => ({
prepareSynologyHostedMedia: prepareSynologyHostedMediaMock,
resolveSynologyHostedMediaRoute: vi.fn(() => ({
localRoutePath: "/w/",
publicBaseUrl: "https://gateway.example.com",
publicRoutePath: "/w",
publicSearch: "",
})),
tryHandleSynologyHostedMediaRequest: vi.fn(async () => false),
}));
function makeSecurityAccount(
overrides: Partial<ResolvedSynologyChatAccount> = {},
): ResolvedSynologyChatAccount {
@@ -41,7 +59,9 @@ function mockStringMessages(mock: { mock: { calls: unknown[][] } }): string[] {
const clientModule = await import("./client.js");
const gatewayRuntimeModule = await import("./gateway-runtime.js");
const mockSendMessage = vi.spyOn(clientModule, "sendMessage").mockResolvedValue(true);
const mockSendFileUrl = vi.spyOn(clientModule, "sendFileUrl").mockResolvedValue(true);
const mockSendHostedFileUrl = vi
.spyOn(clientModule, "sendHostedFileUrl")
.mockResolvedValue({ status: "accepted" });
const registerSynologyWebhookRouteMock = vi
.spyOn(gatewayRuntimeModule, "registerSynologyWebhookRoute")
.mockImplementation(async () => vi.fn(async () => undefined));
@@ -65,10 +85,17 @@ describe("createSynologyChatPlugin", () => {
vi.stubEnv("SYNOLOGY_CHAT_TOKEN", "");
vi.stubEnv("SYNOLOGY_CHAT_INCOMING_URL", "");
mockSendMessage.mockClear();
mockSendFileUrl.mockClear();
mockSendHostedFileUrl.mockClear();
prepareSynologyHostedMediaMock.mockReset();
registerSynologyWebhookRouteMock.mockClear();
mockSendMessage.mockResolvedValue(true);
mockSendFileUrl.mockResolvedValue(true);
mockSendHostedFileUrl.mockResolvedValue({ status: "accepted" });
prepareSynologyHostedMediaMock.mockImplementation(async ({ account }) => {
if (!account.webhookUrl) {
throw new Error("Synology Chat attachments require webhookUrl");
}
return { url: preparedCapabilityUrl, cleanup: vi.fn(async () => undefined) };
});
registerSynologyWebhookRouteMock.mockImplementation(async () => vi.fn(async () => undefined));
});
@@ -159,6 +186,7 @@ describe("createSynologyChatPlugin", () => {
"synology-chat": {
token: "test-token",
incomingUrl: "https://nas/incoming",
webhookUrl: "https://gateway.example.com/webhook/synology?proxy-token=redacted",
},
},
};
@@ -174,7 +202,35 @@ describe("createSynologyChatPlugin", () => {
accountId: "default",
configured: true,
lifecycle: "ready",
webhookPath: "/webhook/synology",
attachmentsReady: true,
});
expect(snapshot).not.toHaveProperty("webhookUrl");
});
it.each([
"http://gateway.example.com/webhook/synology",
"https://gateway.example.com/webhook/synology#fragment",
"https://gateway.example.com/webhook/synology?__openclaw_synology_media_token_fixture=value",
])("reports attachments unready when webhookUrl is invalid: %s", async (webhookUrl) => {
const cfg = {
channels: {
"synology-chat": {
token: "test-token",
incomingUrl: "https://nas/incoming",
webhookUrl,
},
},
};
const account = synologyChatPlugin.config.resolveAccount(cfg, "default");
const snapshot = await synologyChatPlugin.status?.buildAccountSnapshot?.({
account,
cfg,
runtime: { accountId: "default", lifecycle: "ready" },
});
expect(snapshot).toMatchObject({ configured: true, attachmentsReady: false });
});
describe("config", () => {
@@ -277,6 +333,7 @@ describe("createSynologyChatPlugin", () => {
enabled: true,
token: "t",
incomingUrl: "u",
webhookUrl: "https://gateway.example.com/w",
nasHost: "h",
webhookPath: "/w",
webhookPathSource: "default" as const,
@@ -315,6 +372,7 @@ describe("createSynologyChatPlugin", () => {
"synology-chat": {
token: "t",
incomingUrl: "https://nas/incoming",
webhookUrl: "https://gateway.example.com/w",
allowInsecureSsl: true,
},
},
@@ -338,6 +396,7 @@ describe("createSynologyChatPlugin", () => {
"synology-chat": {
token: "base-token",
webhookPath: "/webhook/shared",
webhookUrl: "https://gateway.example.com/webhook/shared",
accounts: {
alerts: {
token: "alerts-token",
@@ -433,6 +492,18 @@ describe("createSynologyChatPlugin", () => {
expectIncludesSubstring(warnings, "conflicts on webhookPath");
});
it("warns when enabled accounts share the same public webhookUrl", () => {
const plugin = synologyChatPlugin;
const cfg = makeSharedWebhookConfig({
webhookPath: "/webhook/alerts",
webhookUrl: "https://gateway.example.com/synology?a=1&b=2",
});
cfg.channels["synology-chat"].webhookUrl = "https://gateway.example.com/synology?b=2&a=1";
const account = plugin.config.resolveAccount(cfg, "alerts");
const warnings = plugin.security.collectWarnings({ cfg, account });
expectIncludesSubstring(warnings, "conflicts on webhookUrl");
});
it("returns no warnings for fully configured account", () => {
const plugin = synologyChatPlugin;
const account = makeSecurityAccount({ allowedUserIds: ["user1"] });
@@ -498,6 +569,7 @@ describe("createSynologyChatPlugin", () => {
enabled: true,
token: "t",
incomingUrl: "https://nas/incoming",
webhookUrl: "https://gateway.example.com/w",
allowInsecureSsl: true,
},
},
@@ -598,6 +670,7 @@ describe("createSynologyChatPlugin", () => {
enabled: true,
token: "t",
incomingUrl: "https://nas/incoming",
webhookUrl: "https://gateway.example.com/w",
allowInsecureSsl: true,
},
},
@@ -613,12 +686,15 @@ describe("createSynologyChatPlugin", () => {
expect(result.receipt.platformMessageIds).toHaveLength(0);
expect(result.receipt.parts).toHaveLength(0);
expect(result.receipt.threadId).toBe("user1");
expect(mockSendFileUrl).toHaveBeenLastCalledWith(
expect(mockSendHostedFileUrl).toHaveBeenLastCalledWith(
"https://nas/incoming",
"https://example.com/img.png",
preparedCapabilityUrl,
"user1",
true,
);
expect(prepareSynologyHostedMediaMock).toHaveBeenCalledWith(
expect.objectContaining({ mediaUrl: "https://example.com/img.png" }),
);
});
it("sendMedia throws when missing incomingUrl", async () => {
@@ -636,6 +712,105 @@ describe("createSynologyChatPlugin", () => {
).rejects.toThrow("not configured");
});
it("sendMedia reports an actionable attachment-only setup failure without webhookUrl", async () => {
await expect(
synologyChatPlugin.outbound.sendMedia({
cfg: {
channels: {
"synology-chat": {
enabled: true,
token: "t",
incomingUrl: "https://nas/incoming",
},
},
},
mediaUrl: "https://example.com/img.png",
to: "user1",
}),
).rejects.toThrow("attachments require webhookUrl");
expect(mockSendHostedFileUrl).not.toHaveBeenCalled();
});
it("sendMedia retains staged bytes when webhook acceptance is indeterminate", async () => {
const cleanup = vi.fn(async () => undefined);
prepareSynologyHostedMediaMock.mockResolvedValueOnce({
url: preparedCapabilityUrl,
cleanup,
});
mockSendHostedFileUrl.mockResolvedValueOnce({ status: "indeterminate" });
await expect(
synologyChatPlugin.outbound.sendMedia({
cfg: {
channels: {
"synology-chat": {
enabled: true,
token: "t",
incomingUrl: "https://nas/incoming",
webhookUrl: "https://gateway.example.com/w",
},
},
},
mediaUrl: "https://example.com/img.png",
to: "user1",
}),
).rejects.toThrow("acceptance could not be confirmed");
expect(cleanup).not.toHaveBeenCalled();
});
it("sendMedia cleans up staged bytes when the webhook request never starts", async () => {
const cleanup = vi.fn(async () => undefined);
prepareSynologyHostedMediaMock.mockResolvedValueOnce({
url: preparedCapabilityUrl,
cleanup,
});
mockSendHostedFileUrl.mockResolvedValueOnce({ status: "not-dispatched" });
await expect(
synologyChatPlugin.outbound.sendMedia({
cfg: {
channels: {
"synology-chat": {
enabled: true,
token: "t",
incomingUrl: "https://nas/incoming",
webhookUrl: "https://gateway.example.com/w",
},
},
},
mediaUrl: "https://example.com/img.png",
to: "user1",
}),
).rejects.toThrow("request did not start");
expect(cleanup).toHaveBeenCalledOnce();
});
it("sendMedia cleans up staged bytes after a definitive webhook rejection", async () => {
const cleanup = vi.fn(async () => undefined);
prepareSynologyHostedMediaMock.mockResolvedValueOnce({
url: preparedCapabilityUrl,
cleanup,
});
mockSendHostedFileUrl.mockResolvedValueOnce({ status: "rejected" });
await expect(
synologyChatPlugin.outbound.sendMedia({
cfg: {
channels: {
"synology-chat": {
enabled: true,
token: "t",
incomingUrl: "https://nas/incoming",
webhookUrl: "https://gateway.example.com/w",
},
},
},
mediaUrl: "https://example.com/img.png",
to: "user1",
}),
).rejects.toThrow("rejected the attachment request");
expect(cleanup).toHaveBeenCalledOnce();
});
it("sanitizeText strips internal tool-trace banners from outbound text", () => {
const text = "Done.\n⚠️ 🛠️ `search repos (agent)` failed";
const sanitizeText = synologyChatPlugin.outbound.sanitizeText;
@@ -689,6 +864,7 @@ describe("createSynologyChatPlugin", () => {
token: "default-token",
incomingUrl: "https://nas/default",
webhookPath: "/webhook/synology-shared",
webhookUrl: "https://gateway.example.com/webhook/synology-default",
dmPolicy: "allowlist",
allowedUserIds: ["123"],
accounts: {
@@ -857,6 +1033,23 @@ describe("createSynologyChatPlugin", () => {
expect(registerMock).not.toHaveBeenCalled();
});
it("startAccount refuses duplicate public webhook URLs across accounts", async () => {
const registerMock = registerSynologyWebhookRouteMock;
const plugin = synologyChatPlugin;
const { ctx, abortController } = makeNamedStartAccountCtx({
webhookPath: "/webhook/synology-alerts",
webhookUrl: "https://gateway.example.com/synology",
dmPolicy: "open",
allowedUserIds: ["*"],
});
ctx.cfg.channels["synology-chat"].webhookUrl = "https://gateway.example.com/synology";
const result = plugin.gateway.startAccount(ctx);
await expectPendingStartAccountPromise(result, abortController);
expectIncludesSubstring(mockStringMessages(ctx.log.warn), "conflicts on webhookUrl");
expect(registerMock).not.toHaveBeenCalled();
});
it("re-registers same account/path through the route registrar", async () => {
const unregisterFirst = vi.fn(async () => undefined);
const unregisterSecond = vi.fn(async () => undefined);
+55 -7
View File
@@ -35,6 +35,7 @@ import {
channelStoppedPatch,
} from "openclaw/plugin-sdk/gateway-runtime";
import { parseStrictNonNegativeInteger } from "openclaw/plugin-sdk/number-runtime";
import type { OutboundMediaLoadOptions } from "openclaw/plugin-sdk/outbound-media";
import {
createComputedAccountStatusAdapter,
createDefaultChannelRuntimeState,
@@ -51,13 +52,16 @@ import {
} from "openclaw/plugin-sdk/text-chunking";
import { listAccountIds, resolveAccount } from "./accounts.js";
import { synologyChatApprovalAuth } from "./approval-auth.js";
import { SYNOLOGY_CHAT_TEXT_CHUNK_LIMIT, sendFileUrl, sendMessage } from "./client.js";
import { SYNOLOGY_CHAT_TEXT_CHUNK_LIMIT, sendHostedFileUrl, sendMessage } from "./client.js";
import { SynologyChatChannelConfigSchema } from "./config-schema.js";
import { synologyChatDoctor } from "./doctor.js";
import {
collectSynologyGatewayRoutingWarnings,
registerSynologyWebhookRoute,
validateSynologyGatewayAccountStartup,
} from "./gateway-runtime.js";
import { resolveSynologyHostedMediaRoute } from "./hosted-media-route.js";
import { prepareSynologyHostedMedia } from "./outbound-media.js";
import { collectSynologyChatSecurityAuditFindings } from "./security-audit.js";
import { buildSynologyChatOutboundSessionKey } from "./session-key.js";
import { synologyChatSetupContract, synologyChatSetupWizard } from "./setup-surface.js";
@@ -67,6 +71,15 @@ const CHANNEL_ID = "synology-chat";
const SYNOLOGY_MARKDOWN_LINK_RE =
/(?<!!)\[((?:\\[^\n]|[^\\\]\n])+)\]\((https?:\/\/(?:\\[^\n]|[^()\s<>\\])+(?:\((?:\\[^\n]|[^()\s<>\\])*\)(?:\\[^\n]|[^()\s<>\\])*)*)(?:\s+(?:"[^"\n]*"|'[^'\n]*'|\([^()\n]*\)))?\)/g;
function areSynologyAttachmentsReady(account: ResolvedSynologyChatAccount): boolean {
try {
resolveSynologyHostedMediaRoute(account);
return true;
} catch {
return false;
}
}
const resolveSynologyChatDmPolicy = createScopedDmSecurityResolver<ResolvedSynologyChatAccount>({
channelKey: CHANNEL_ID,
resolvePolicy: (account) => account.dmPolicy,
@@ -94,6 +107,9 @@ type SynologyChannelOutboundContext = {
text?: string;
mediaUrl?: string;
accountId?: string | null;
mediaAccess?: OutboundMediaLoadOptions["mediaAccess"];
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
};
type SynologyChannelSendTextContext = SynologyChannelOutboundContext & { text: string };
type SynologyChannelSendMediaContext = SynologyChannelOutboundContext & { mediaUrl: string };
@@ -110,6 +126,7 @@ const synologyChatConfigAdapter = createHybridChannelConfigAdapter<ResolvedSynol
clearBaseFields: [
"token",
"incomingUrl",
"webhookUrl",
"nasHost",
"webhookPath",
"dangerouslyAllowNameMatching",
@@ -132,6 +149,9 @@ const collectSynologyChatSecurityWarnings =
(account) =>
!account.incomingUrl &&
"- Synology Chat: incomingUrl is not configured. The bot cannot send replies.",
(account) =>
!account.webhookUrl &&
"- Synology Chat: webhookUrl is not configured. Text and inbound messages still work, but attachments require the exact externally reachable HTTPS callback URL.",
(account) =>
account.allowInsecureSsl &&
"- Synology Chat: SSL verification is disabled (allowInsecureSsl=true). Only use this for local NAS with self-signed certificates.",
@@ -300,9 +320,33 @@ async function sendSynologyChatMedia(
): Promise<SynologyChatOutboundResult> {
const account = resolveOutboundAccount(ctx.cfg ?? {}, ctx.accountId);
const incomingUrl = requireIncomingUrl(account);
const ok = await sendFileUrl(incomingUrl, ctx.mediaUrl, ctx.to, account.allowInsecureSsl);
if (!ok) {
throw new Error("Failed to send media to Synology Chat");
const prepared = await prepareSynologyHostedMedia({
account,
mediaUrl: ctx.mediaUrl,
mediaAccess: ctx.mediaAccess,
mediaLocalRoots: ctx.mediaLocalRoots,
mediaReadFile: ctx.mediaReadFile,
});
const sendResult = await sendHostedFileUrl(
incomingUrl,
prepared.url,
ctx.to,
account.allowInsecureSsl,
);
if (sendResult.status === "not-dispatched") {
await prepared.cleanup();
throw new Error(
"Synology Chat attachment request did not start. Retry, and check incomingUrl if it fails again.",
);
}
if (sendResult.status === "rejected") {
await prepared.cleanup();
throw new Error("Synology Chat rejected the attachment request");
}
if (sendResult.status === "indeterminate") {
// A timeout or lost response is indeterminate: the NAS may already have
// queued this capability. Retain it until bounded expiry for delayed fetches.
throw new Error("Synology Chat attachment request acceptance could not be confirmed");
}
return createSynologyChatSendResult({
chatId: ctx.to,
@@ -357,6 +401,7 @@ function createSynologyChatPlugin(): SynologyChatPlugin {
...synologyChatConfigAdapter,
},
approvalCapability: synologyChatApprovalAuth,
doctor: synologyChatDoctor,
messaging: {
targetPrefixes: ["synology-chat", "synology_chat", "synology"],
normalizeTarget: normalizeSynologyChatTarget,
@@ -397,7 +442,10 @@ function createSynologyChatPlugin(): SynologyChatPlugin {
accountId: account.accountId,
enabled: account.enabled,
configured: Boolean(account.token && account.incomingUrl),
extra: { webhookPath: account.webhookPath },
extra: {
webhookPath: account.webhookPath,
attachmentsReady: areSynologyAttachmentsReady(account),
},
}),
}),
gateway: {
@@ -451,8 +499,8 @@ function createSynologyChatPlugin(): SynologyChatPlugin {
"**Links**: Use `<URL|display text>` to create clickable links.",
" Example: `<https://example.com|Click here>` renders as a clickable link.",
"",
"**File sharing**: Include a publicly accessible URL to share files or images.",
" The NAS will download and attach the file (max 32 MB).",
"**File sharing**: Send files through the media attachment field.",
" OpenClaw freezes the bytes and gives the NAS a short-lived download capability (max 32 MB).",
"",
"**Limitations**:",
"- No markdown, bold, italic, or code blocks",
@@ -4,6 +4,18 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { synologyChatPlugin } from "./channel.js";
import { resolveLegacyWebhookNameToChatUserId, sendMessage } from "./client.js";
const { hostedCapabilityUrl } = vi.hoisted(() => ({
hostedCapabilityUrl:
"https://gateway.example.com/webhook/synology?__openclaw_synology_media_token_aaaaaaaaaaaaaaaaaaaaaaaa=secret",
}));
vi.mock("./outbound-media.js", () => ({
prepareSynologyHostedMedia: vi.fn(async () => ({
url: hostedCapabilityUrl,
cleanup: vi.fn(async () => undefined),
})),
resolveSynologyHostedMediaRoute: vi.fn(),
}));
const USER_LIST_RESPONSE_MAX_BYTES = 1 * 1024 * 1024;
describe("Synology Chat client loopback", () => {
@@ -201,9 +213,9 @@ describe("Synology Chat client loopback", () => {
expect(receivedPayloads).toEqual([
{ text: "native outbound text", user_ids: [42] },
{ file_url: mediaUrl, user_ids: [42] },
{ file_url: hostedCapabilityUrl, user_ids: [42] },
{ text: "durable adapter text", user_ids: [42] },
{ file_url: mediaUrl, user_ids: [42] },
{ file_url: hostedCapabilityUrl, user_ids: [42] },
]);
expect(durableText).toBeDefined();
expect(durableMedia).toBeDefined();
@@ -293,39 +305,10 @@ describe("Synology Chat client loopback", () => {
mediaUrl: "https://example.com/synology-receipt-proof.png",
to: "42",
}),
).rejects.toThrow("Failed to send media to Synology Chat");
).rejects.toThrow("rejected the attachment request");
expect(rejectedRequests).toBe(2);
});
it("rejects private file URLs before contacting the authenticated webhook", async () => {
let webhookRequests = 0;
const port = await listenLoopback((_req, res) => {
webhookRequests += 1;
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ success: true }));
});
const cfg = {
channels: {
"synology-chat": {
enabled: true,
token: "synology-loopback-proof",
incomingUrl:
`http://127.0.0.1:${port}/webapi/entry.cgi?` +
"api=SYNO.Chat.External&method=chatbot&version=2&token=synology-loopback-proof",
},
},
};
await expect(
synologyChatPlugin.outbound.sendMedia({
cfg,
mediaUrl: `http://127.0.0.1:${port}/private-proof.png`,
to: "42",
}),
).rejects.toThrow("Failed to send media to Synology Chat");
expect(webhookRequests).toBe(0);
});
it("aborts a streamed overflow and returns the stale cached identity", async () => {
let requestCount = 0;
const port = await listenLoopback((_req, res) => {
+118 -39
View File
@@ -4,10 +4,7 @@ import type { ClientRequest, IncomingMessage, RequestOptions } from "node:http";
import { PassThrough } from "node:stream";
import { coerceErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
const ssrfMocks = {
resolvePinnedHostnameWithPolicy: vi.fn(),
};
import type { SynologyHostedMediaUrl } from "./outbound-media.js";
// Mock http and https modules before importing the client
vi.mock("node:https", async () => {
@@ -28,13 +25,12 @@ vi.mock("node:http", async () => {
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
formatErrorMessage: coerceErrorMessage,
resolvePinnedHostnameWithPolicy: ssrfMocks.resolvePinnedHostnameWithPolicy,
}));
const https = await import("node:https");
let fakeNowMs = 1_700_000_000_000;
let sendMessage: typeof import("./client.js").sendMessage;
let sendFileUrl: typeof import("./client.js").sendFileUrl;
let sendHostedFileUrl: typeof import("./client.js").sendHostedFileUrl;
let resolveLegacyWebhookNameToChatUserId: typeof import("./client.js").resolveLegacyWebhookNameToChatUserId;
type RequestCallback = (res: IncomingMessage) => void;
@@ -117,7 +113,7 @@ function mockRequestErrorOnce(error: Error) {
function installFakeTimerHarness() {
beforeAll(async () => {
({ sendMessage, sendFileUrl, resolveLegacyWebhookNameToChatUserId } =
({ sendMessage, sendHostedFileUrl, resolveLegacyWebhookNameToChatUserId } =
await import("./client.js"));
});
@@ -126,10 +122,6 @@ function installFakeTimerHarness() {
vi.useFakeTimers();
fakeNowMs += 10_000;
vi.setSystemTime(fakeNowMs);
ssrfMocks.resolvePinnedHostnameWithPolicy.mockResolvedValue({
hostname: "example.com",
addresses: ["93.184.216.34"],
});
});
afterEach(() => {
@@ -137,14 +129,22 @@ function installFakeTimerHarness() {
});
}
const tlsVerificationDefaultCases = [
function hostedUrl(value: string): SynologyHostedMediaUrl {
return value as SynologyHostedMediaUrl;
}
const tlsVerificationDefaultCases: Array<{ name: string; invoke: () => Promise<unknown> }> = [
{
name: "sendMessage",
invoke: () => sendMessage("https://nas.example.com/incoming", "Hello"),
},
{
name: "sendFileUrl",
invoke: () => sendFileUrl("https://nas.example.com/incoming", "https://example.com/file.png"),
name: "sendHostedFileUrl",
invoke: () =>
sendHostedFileUrl(
"https://nas.example.com/incoming",
hostedUrl("https://gateway.example.com/webhook?__openclaw_synology_media_token_a=t"),
),
},
];
@@ -300,42 +300,122 @@ describe("sendMessage", () => {
});
});
describe("sendFileUrl", () => {
describe("sendHostedFileUrl", () => {
installFakeTimerHarness();
it("returns true on success", async () => {
it("returns accepted on success", async () => {
mockSuccessResponse();
const result = await settleTimers(
sendFileUrl("https://nas.example.com/incoming", "https://example.com/file.png"),
sendHostedFileUrl(
"https://nas.example.com/incoming",
hostedUrl("https://gateway.example.com/webhook?__openclaw_synology_media_token_a=t"),
),
);
expect(result).toBe(true);
expect(result).toEqual({ status: "accepted" });
});
it("returns false on failure", async () => {
it("returns indeterminate on an HTTP server failure", async () => {
mockFailureResponse(500);
const result = await settleTimers(
sendFileUrl("https://nas.example.com/incoming", "https://example.com/file.png"),
sendHostedFileUrl(
"https://nas.example.com/incoming",
hostedUrl("https://gateway.example.com/webhook?__openclaw_synology_media_token_a=t"),
),
);
expect(result).toBe(false);
expect(result).toEqual({ status: "indeterminate" });
});
it("returns false without retrying an HTTP-successful webhook rejection", async () => {
it("returns rejected on a definitive HTTP client failure", async () => {
mockFailureResponse(400);
const result = await settleTimers(
sendHostedFileUrl(
"https://nas.example.com/incoming",
hostedUrl("https://gateway.example.com/webhook?__openclaw_synology_media_token_a=t"),
),
);
expect(result).toEqual({ status: "rejected" });
});
it("returns rejected without retrying an HTTP-successful webhook rejection", async () => {
mockResponse(200, JSON.stringify({ success: false, error: { code: 105 } }));
const result = await settleTimers(
sendFileUrl("https://nas.example.com/incoming", "https://example.com/file.png"),
sendHostedFileUrl(
"https://nas.example.com/incoming",
hostedUrl("https://gateway.example.com/webhook?__openclaw_synology_media_token_a=t"),
),
);
expect(result).toBe(false);
expect(result).toEqual({ status: "rejected" });
expect(vi.mocked(https.request)).toHaveBeenCalledTimes(1);
});
it("returns indeterminate when the request outcome is lost", async () => {
vi.mocked(https.request).mockImplementation((() => {
const req = createMockRequestEmitter();
process.nextTick(() => req.emit("error", new Error("connection reset")));
return req;
}) as MockRequestHandler);
const result = await settleTimers(
sendHostedFileUrl(
"https://nas.example.com/incoming",
hostedUrl("https://gateway.example.com/webhook?__openclaw_synology_media_token_a=t"),
),
);
expect(result).toEqual({ status: "indeterminate" });
});
it("returns not-dispatched when the transport proves it never connected", async () => {
mockRequestErrorOnce(Object.assign(new Error("host not found"), { code: "ENOTFOUND" }));
const result = await settleTimers(
sendHostedFileUrl(
"https://nas.example.com/incoming",
hostedUrl("https://gateway.example.com/webhook?__openclaw_synology_media_token_a=t"),
),
);
expect(result).toEqual({ status: "not-dispatched" });
});
it("returns not-dispatched when request construction fails synchronously", async () => {
vi.mocked(https.request).mockImplementationOnce(() => {
throw new Error("request construction failed");
});
const result = await settleTimers(
sendHostedFileUrl(
"https://nas.example.com/incoming",
hostedUrl("https://gateway.example.com/webhook?__openclaw_synology_media_token_a=t"),
),
);
expect(result).toEqual({ status: "not-dispatched" });
});
it("returns not-dispatched when the incoming webhook URL is malformed", async () => {
const result = await settleTimers(
sendHostedFileUrl(
"not-a-url",
hostedUrl("https://gateway.example.com/webhook?__openclaw_synology_media_token_a=t"),
),
);
expect(result).toEqual({ status: "not-dispatched" });
expect(vi.mocked(https.request)).not.toHaveBeenCalled();
});
it("respects the shared send interval before posting a file URL", async () => {
mockSuccessResponse();
await settleTimers(sendMessage("https://nas.example.com/incoming", "hello"));
vi.mocked(https.request).mockClear();
const promise = sendFileUrl("https://nas.example.com/incoming", "https://example.com/file.png");
const promise = sendHostedFileUrl(
"https://nas.example.com/incoming",
hostedUrl("https://gateway.example.com/webhook?__openclaw_synology_media_token_a=t"),
);
await Promise.resolve();
expect(vi.mocked(https.request)).not.toHaveBeenCalled();
@@ -348,30 +428,29 @@ describe("sendFileUrl", () => {
});
it("rejects malformed file URLs before making a request", async () => {
const result = await settleTimers(sendFileUrl("https://nas.example.com/incoming", "not-a-url"));
expect(result).toBe(false);
expect(ssrfMocks.resolvePinnedHostnameWithPolicy).not.toHaveBeenCalled();
const result = await settleTimers(
sendHostedFileUrl("https://nas.example.com/incoming", hostedUrl("not-a-url")),
);
expect(result).toEqual({ status: "not-dispatched" });
expect(vi.mocked(https.request)).not.toHaveBeenCalled();
});
it("rejects non-http file URLs before making a request", async () => {
it("rejects non-HTTPS file URLs before making a request", async () => {
const result = await settleTimers(
sendFileUrl("https://nas.example.com/incoming", "file:///tmp/secret.txt"),
sendHostedFileUrl("https://nas.example.com/incoming", hostedUrl("http://example.com/file")),
);
expect(result).toBe(false);
expect(ssrfMocks.resolvePinnedHostnameWithPolicy).not.toHaveBeenCalled();
expect(result).toEqual({ status: "not-dispatched" });
expect(vi.mocked(https.request)).not.toHaveBeenCalled();
});
it("rejects SSRF-blocked hosts before making a request", async () => {
ssrfMocks.resolvePinnedHostnameWithPolicy.mockRejectedValueOnce(
new Error("Blocked private network target"),
);
it("rejects hosted URLs with embedded credentials or fragments", async () => {
const credentialedUrl = new URL("https://gateway.example.com/webhook#fragment");
credentialedUrl.username = "fixture-user";
credentialedUrl.password = "fixture-password";
const result = await settleTimers(
sendFileUrl("https://nas.example.com/incoming", "http://169.254.169.254/latest/meta-data"),
sendHostedFileUrl("https://nas.example.com/incoming", hostedUrl(credentialedUrl.toString())),
);
expect(result).toBe(false);
expect(ssrfMocks.resolvePinnedHostnameWithPolicy).toHaveBeenCalledWith("169.254.169.254");
expect(result).toEqual({ status: "not-dispatched" });
expect(vi.mocked(https.request)).not.toHaveBeenCalled();
});
});
+102 -62
View File
@@ -11,13 +11,11 @@ import { parseStrictNonNegativeInteger } from "openclaw/plugin-sdk/number-runtim
import { readByteStreamWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
import { classifyTransientNetworkErrorCode } from "openclaw/plugin-sdk/retry-runtime";
import { sleep } from "openclaw/plugin-sdk/runtime-env";
import {
formatErrorMessage,
resolvePinnedHostnameWithPolicy,
} from "openclaw/plugin-sdk/ssrf-runtime";
import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking";
import { z } from "zod";
import type { SynologyHostedMediaUrl } from "./outbound-media.js";
const MIN_SEND_INTERVAL_MS = 500;
export const SYNOLOGY_CHAT_TEXT_CHUNK_LIMIT = 2_000;
@@ -90,6 +88,12 @@ type ChatWebhookPayload = {
user_ids?: number[];
};
type SynologyHostedFileSendResult =
| { status: "accepted" }
| { status: "not-dispatched" }
| { status: "rejected" }
| { status: "indeterminate" };
const ChatUserSchema = z
.object({
user_id: z.number(),
@@ -166,7 +170,13 @@ async function sendMessageChunk(
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
await waitForSendSlot();
return await doPost(incomingUrl, body, allowInsecureSsl);
const result = await doPost(incomingUrl, body, allowInsecureSsl);
if (result === "accepted") {
return true;
}
// An explicit rejection is final, while a server-side/ambiguous outcome
// cannot be replayed safely after a non-idempotent webhook POST.
return false;
} catch (error) {
if (!isProvenPreConnectFailure(error)) {
return false;
@@ -182,23 +192,29 @@ async function sendMessageChunk(
}
/**
* Send a file URL to Synology Chat.
* Send an OpenClaw-hosted immutable file URL to Synology Chat.
*/
export async function sendFileUrl(
export async function sendHostedFileUrl(
incomingUrl: string,
fileUrl: string,
fileUrl: SynologyHostedMediaUrl,
userId?: string | number,
allowInsecureSsl = false,
): Promise<boolean> {
): Promise<SynologyHostedFileSendResult> {
let body: string;
try {
const safeFileUrl = await assertSafeWebhookFileUrl(fileUrl);
const body = buildWebhookBody({ file_url: safeFileUrl }, userId);
await waitForSendSlot();
const ok = await doPost(incomingUrl, body, allowInsecureSsl);
return ok;
body = buildWebhookBody({ file_url: assertHostedMediaUrl(fileUrl) }, userId);
} catch {
return false;
return { status: "not-dispatched" };
}
await waitForSendSlot();
try {
return { status: await doPost(incomingUrl, body, allowInsecureSsl) };
} catch (error) {
// Proven pre-connect failures cannot have queued the capability. All other
// transport errors stay indeterminate because Synology may have the POST.
return { status: isProvenPreConnectFailure(error) ? "not-dispatched" : "indeterminate" };
}
}
@@ -323,7 +339,7 @@ async function waitForSendSlot(): Promise<void> {
await next;
}
async function assertSafeWebhookFileUrl(fileUrl: string): Promise<string> {
function assertHostedMediaUrl(fileUrl: SynologyHostedMediaUrl): string {
let parsed: URL;
try {
parsed = new URL(fileUrl);
@@ -331,11 +347,17 @@ async function assertSafeWebhookFileUrl(fileUrl: string): Promise<string> {
throw new Error(`Invalid Synology Chat file URL: ${formatErrorMessage(err)}`, { cause: err });
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error("Synology Chat file URL must use HTTP or HTTPS");
if (
parsed.protocol !== "https:" ||
!parsed.hostname ||
parsed.username ||
parsed.password ||
parsed.hash
) {
throw new Error(
"Synology Chat hosted attachment URL must use HTTPS without credentials or a fragment",
);
}
await resolvePinnedHostnameWithPolicy(parsed.hostname);
return parsed.toString();
}
@@ -390,12 +412,16 @@ function parseNumericUserId(userId?: string | number): number | undefined {
return parseStrictNonNegativeInteger(userId);
}
function doPost(url: string, body: string, allowInsecureSsl = false): Promise<boolean> {
function doPost(
url: string,
body: string,
allowInsecureSsl = false,
): Promise<SynologyHostedFileSendResult["status"]> {
return new Promise((resolve, reject) => {
let settled = false;
let response: http.IncomingMessage | undefined;
let deadlineTimer: ReturnType<typeof setTimeout> | undefined;
const finish = (result: { ok?: boolean; error?: Error }) => {
const finish = (result: { status?: SynologyHostedFileSendResult["status"]; error?: Error }) => {
if (settled) {
return;
}
@@ -408,55 +434,69 @@ function doPost(url: string, body: string, allowInsecureSsl = false): Promise<bo
reject(result.error);
return;
}
resolve(result.ok === true);
resolve(result.status ?? "rejected");
};
let parsedUrl: URL;
try {
parsedUrl = new URL(url);
} catch {
reject(new Error(`Invalid URL: ${url}`));
resolve("not-dispatched");
return;
}
const transport = parsedUrl.protocol === "https:" ? https : http;
const req = transport.request(
url,
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"Content-Length": Buffer.byteLength(body),
let req: http.ClientRequest;
try {
req = transport.request(
url,
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"Content-Length": Buffer.byteLength(body),
},
// Synology NAS may use self-signed certs on local network.
// Set allowInsecureSsl: true in channel config to skip verification.
rejectUnauthorized: !allowInsecureSsl,
},
// Synology NAS may use self-signed certs on local network.
// Set allowInsecureSsl: true in channel config to skip verification.
rejectUnauthorized: !allowInsecureSsl,
},
(res) => {
response = res;
const responseChunks: Buffer[] = [];
let responseBytes = 0;
res.on("data", (chunk: Buffer) => {
responseBytes += chunk.length;
if (responseBytes <= USER_LIST_RESPONSE_MAX_BYTES) {
responseChunks.push(chunk);
} else {
responseChunks.length = 0;
}
});
res.on("end", () => {
const result =
responseBytes <= USER_LIST_RESPONSE_MAX_BYTES
? safeParseJsonWithSchema(
ChatUserListResponseSchema.pick({ success: true }),
Buffer.concat(responseChunks).toString("utf8"),
)
: null;
finish({ ok: res.statusCode === 200 && result?.success !== false });
});
res.on("error", (error) => finish({ error }));
res.resume();
},
);
(res) => {
response = res;
const responseChunks: Buffer[] = [];
let responseBytes = 0;
res.on("data", (chunk: Buffer) => {
responseBytes += chunk.length;
if (responseBytes <= USER_LIST_RESPONSE_MAX_BYTES) {
responseChunks.push(chunk);
} else {
responseChunks.length = 0;
}
});
res.on("end", () => {
const result =
responseBytes <= USER_LIST_RESPONSE_MAX_BYTES
? safeParseJsonWithSchema(
ChatUserListResponseSchema.pick({ success: true }),
Buffer.concat(responseChunks).toString("utf8"),
)
: null;
if (res.statusCode === 200) {
finish({ status: result?.success === false ? "rejected" : "accepted" });
return;
}
// A reverse proxy can emit a server error after forwarding the POST
// and losing Synology's response, so 5xx cannot prove non-acceptance.
finish({ status: (res.statusCode ?? 500) >= 500 ? "indeterminate" : "rejected" });
});
res.on("error", (error) => finish({ error }));
res.resume();
},
);
} catch {
// Synchronous request construction failed before Node returned a request
// that could write the capability to the network.
finish({ status: "not-dispatched" });
return;
}
req.on("error", (error) => finish({ error }));
// ClientRequest timeout is socket-idle based. Keep one absolute budget
@@ -5,8 +5,17 @@ import { z } from "zod";
export const SynologyChatChannelConfigSchema = buildChannelConfigSchema(
z
.object({
webhookUrl: z.string().optional(),
dangerouslyAllowNameMatching: z.boolean().optional(),
dangerouslyAllowInheritedWebhookPath: z.boolean().optional(),
})
.passthrough(),
{
uiHints: {
incomingUrl: { sensitive: true },
"accounts.*.incomingUrl": { sensitive: true },
webhookUrl: { sensitive: true },
"accounts.*.webhookUrl": { sensitive: true },
},
},
);
+58 -2
View File
@@ -17,7 +17,7 @@ import {
validateToken,
} from "./security.js";
import { buildSynologyChatInboundSessionKey } from "./session-key.js";
import { synologyChatSetupWizard } from "./setup-surface.js";
import { synologyChatSetupContract, synologyChatSetupWizard } from "./setup-surface.js";
const synologyChatSetupPlugin = {
id: "synology-chat",
@@ -43,6 +43,9 @@ function createSynologySetupPrompter(params: { allowedUserIds?: string } = {}) {
if (message === "Incoming webhook URL") {
return "https://nas.example.com/webapi/entry.cgi?token=incoming";
}
if (message === "Public attachment webhook URL (optional)") {
return "";
}
if (message === "Outgoing webhook path (optional)") {
return "";
}
@@ -91,19 +94,44 @@ describe("synology-chat core", () => {
delete process.env.OPENCLAW_BOT_NAME;
});
it("exports dangerouslyAllowNameMatching in the JSON schema", () => {
it("exports hosted media and dangerous compatibility fields in the JSON schema", () => {
const properties = (SynologyChatChannelConfigSchema.schema.properties ?? {}) as Record<
string,
{ type?: string }
>;
expect(properties.dangerouslyAllowNameMatching?.type).toBe("boolean");
expect(properties.webhookUrl?.type).toBe("string");
});
it("keeps the schema open for plugin-specific passthrough fields", () => {
expect(SynologyChatChannelConfigSchema.schema.additionalProperties).toEqual({});
});
it("masks incoming and public callback URLs that may contain credentials", () => {
expect(
synologyChatSetupContract.metadata.fields.find((field) => field.key === "url"),
).toMatchObject({ sensitive: true });
expect(
synologyChatSetupContract.metadata.fields.find((field) => field.key === "webhookUrl"),
).toMatchObject({ sensitive: true });
expect(
synologyChatSetupWizard.textInputs?.find((input) => input.inputKey === "webhookUrl"),
).toMatchObject({ sensitive: true });
expect(SynologyChatChannelConfigSchema.uiHints?.webhookUrl).toMatchObject({
sensitive: true,
});
expect(SynologyChatChannelConfigSchema.uiHints?.["accounts.*.webhookUrl"]).toMatchObject({
sensitive: true,
});
expect(SynologyChatChannelConfigSchema.uiHints?.incomingUrl).toMatchObject({
sensitive: true,
});
expect(SynologyChatChannelConfigSchema.uiHints?.["accounts.*.incomingUrl"]).toMatchObject({
sensitive: true,
});
});
it("isolates direct-message sessions by account and user", () => {
const alpha = buildSynologyChatInboundSessionKey({
agentId: "main",
@@ -155,6 +183,9 @@ describe("synology-chat core", () => {
if (message === "Incoming webhook URL") {
return replacementIncomingUrl;
}
if (message === "Public attachment webhook URL (optional)") {
return "";
}
if (message === "Outgoing webhook path (optional)") {
return "";
}
@@ -310,6 +341,7 @@ describe("synology-chat account resolution", () => {
expect(account.token).toBe("");
expect(account.incomingUrl).toBe("");
expect(account.webhookUrl).toBe("");
expect(account.nasHost).toBe("localhost");
expect(account.allowedUserIds).toEqual([]);
expect(account.botName).toBe("OpenClaw");
@@ -321,11 +353,13 @@ describe("synology-chat account resolution", () => {
channels: {
"synology-chat": {
token: "base-tok",
webhookUrl: "https://gateway.example.com/webhook/base",
botName: "BaseName",
dangerouslyAllowNameMatching: false,
accounts: {
work: {
token: "work-tok",
webhookUrl: " https://gateway.example.com/webhook/work ",
botName: "WorkBot",
dangerouslyAllowNameMatching: true,
},
@@ -340,6 +374,7 @@ describe("synology-chat account resolution", () => {
const account = resolveAccount(cfg, "work");
expect(account.token).toBe("work-tok");
expect(account.webhookUrl).toBe("https://gateway.example.com/webhook/work");
expect(account.botName).toBe("WorkBot");
expect(account.dangerouslyAllowNameMatching).toBe(true);
});
@@ -396,6 +431,27 @@ describe("synology-chat account resolution", () => {
expect(optedIn.dangerouslyAllowInheritedWebhookPath).toBe(true);
});
it("does not inherit the base public webhook URL into a named route", () => {
const account = resolveAccount(
{
channels: {
"synology-chat": {
webhookUrl: "https://gateway.example.com/webhook/synology",
accounts: {
work: {
token: "work-tok",
webhookPath: "/webhook/synology-work",
},
},
},
},
},
"work",
);
expect(account.webhookUrl).toBe("");
});
it("parses allowedUserIds strings, arrays, and rate limits", () => {
const parsedString = resolveAccount({
channels: {
@@ -0,0 +1,76 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { describe, expect, it } from "vitest";
import { synologyChatDoctor } from "./doctor.js";
async function collectWarnings(cfg: OpenClawConfig): Promise<string[]> {
return (
(await synologyChatDoctor.collectPreviewWarnings?.({
cfg,
doctorFixCommand: "openclaw doctor --fix",
})) ?? []
);
}
describe("synologyChatDoctor", () => {
it("reports an attachment-only setup gap without calling the account unconfigured", async () => {
const warnings = await collectWarnings({
channels: {
"synology-chat": {
enabled: true,
token: "token",
incomingUrl: "https://nas.example.com/incoming",
},
},
});
expect(warnings.join("\n")).toContain("attachments are unavailable");
expect(warnings.join("\n")).toContain("Text and inbound messages are unaffected");
});
it("accepts a valid exact HTTPS callback and reports invalid values", async () => {
const valid = await collectWarnings({
channels: {
"synology-chat": {
enabled: true,
token: "token",
incomingUrl: "https://nas.example.com/incoming",
webhookUrl: "https://gateway.example.com/webhook/synology?proxy=keep",
},
},
});
expect(valid).toEqual([]);
const invalid = await collectWarnings({
channels: {
"synology-chat": {
enabled: true,
token: "token",
incomingUrl: "https://nas.example.com/incoming",
webhookUrl: "http://gateway.example.com/webhook/synology",
},
},
});
expect(invalid.join("\n")).toContain("must be an absolute HTTPS URL");
});
it("requires a named route to configure its own exact public callback", async () => {
const warnings = await collectWarnings({
channels: {
"synology-chat": {
token: "base-token",
incomingUrl: "https://nas.example.com/incoming",
webhookUrl: "https://gateway.example.com/webhook/synology",
accounts: {
work: {
token: "work-token",
webhookPath: "/webhook/synology-work",
},
},
},
},
});
expect(warnings.join("\n")).toContain(
"channels.synology-chat.accounts.work.webhookUrl: attachments are unavailable",
);
});
});
+28
View File
@@ -0,0 +1,28 @@
// Synology Chat plugin module reports attachment-route setup gaps without blocking text/inbound use.
import type { ChannelDoctorAdapter } from "openclaw/plugin-sdk/channel-contract";
import { listAccountIds, resolveAccount } from "./accounts.js";
import { resolveSynologyHostedMediaRoute } from "./hosted-media-route.js";
export const synologyChatDoctor: ChannelDoctorAdapter = {
collectPreviewWarnings: ({ cfg }) => {
const warnings: string[] = [];
for (const accountId of listAccountIds(cfg)) {
const account = resolveAccount(cfg, accountId);
if (!account.enabled || !account.token || !account.incomingUrl) {
continue;
}
try {
resolveSynologyHostedMediaRoute(account);
} catch (error) {
warnings.push(
`- channels.synology-chat${
accountId === "default" ? "" : `.accounts.${accountId}`
}.webhookUrl: attachments are unavailable; ${
error instanceof Error ? error.message : String(error)
} Text and inbound messages are unaffected.`,
);
}
}
return warnings;
},
};
@@ -2,6 +2,7 @@
import { DEFAULT_ACCOUNT_ID, type OpenClawConfig } from "openclaw/plugin-sdk/account-resolution";
import { registerPluginHttpRoute } from "openclaw/plugin-sdk/webhook-ingress";
import { listAccountIds, resolveAccount } from "./accounts.js";
import { resolveSynologyPublicWebhookRouteKey } from "./hosted-media-route.js";
import { dispatchSynologyChatInboundEvent } from "./inbound-event.js";
import type { ResolvedSynologyChatAccount } from "./types.js";
import {
@@ -24,7 +25,8 @@ type SynologyGatewayStartupIssueCode =
| "empty-allowlist"
| "empty-open-allowlist"
| "inherited-shared-webhook-path"
| "duplicate-webhook-path";
| "duplicate-webhook-path"
| "duplicate-webhook-url";
type SynologyGatewayStartupIssue = {
code: SynologyGatewayStartupIssueCode;
logLevel: "info" | "warn";
@@ -146,6 +148,28 @@ function collectSynologyGatewayStartupIssues(params: {
);
}
const publicRouteKey = resolveSynologyPublicWebhookRouteKey(account.webhookUrl);
if (publicRouteKey) {
const conflictingPublicAccounts = accountIds.filter((candidateId) => {
if (candidateId === accountId) {
return false;
}
const candidate = resolveAccount(cfg, candidateId);
return (
candidate.enabled &&
resolveSynologyPublicWebhookRouteKey(candidate.webhookUrl) === publicRouteKey
);
});
if (conflictingPublicAccounts.length > 0) {
issues.push(
buildStartupIssue(
"duplicate-webhook-url",
`account ${accountId} conflicts on webhookUrl with ${conflictingPublicAccounts.join(", ")}; refusing to start ambiguous public route. Set a unique externally reachable callback URL for each account.`,
),
);
}
}
return issues;
}
@@ -160,7 +184,9 @@ export function collectSynologyGatewayRoutingWarnings(params: {
})
.filter(
(issue) =>
issue.code === "inherited-shared-webhook-path" || issue.code === "duplicate-webhook-path",
issue.code === "inherited-shared-webhook-path" ||
issue.code === "duplicate-webhook-path" ||
issue.code === "duplicate-webhook-url",
)
.map((issue) => `- Synology Chat: ${issue.message}`);
}
@@ -235,7 +261,13 @@ export async function registerSynologyWebhookRoute(params: {
accountId: account.accountId,
log: (msg: string) => log?.info?.(msg),
throwOnFailure: true,
handler,
handler: async (req, res) => {
const { tryHandleSynologyHostedMediaRequest } = await import("./outbound-media.js");
if (await tryHandleSynologyHostedMediaRequest(req, res, account)) {
return true;
}
return await handler(req, res);
},
});
} catch (error) {
await ingress.stop();
@@ -0,0 +1,73 @@
// Synology Chat plugin module maps one public callback URL to its internal Gateway route.
export const SYNOLOGY_HOSTED_MEDIA_TOKEN_PARAM_PREFIX = "__openclaw_synology_media_token";
function normalizeExactPath(path: string): string {
const trimmed = path.trim();
const withLeadingSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
return withLeadingSlash.length > 1 ? withLeadingSlash.replace(/\/+$/u, "") : "/";
}
export function resolveSynologyPublicWebhookRouteKey(webhookUrlValue: string): string | undefined {
try {
const webhookUrl = new URL(webhookUrlValue);
if (
webhookUrl.protocol !== "https:" ||
!webhookUrl.hostname ||
webhookUrl.username ||
webhookUrl.password ||
webhookUrl.hash
) {
return undefined;
}
webhookUrl.searchParams.sort();
return webhookUrl.toString();
} catch {
return undefined;
}
}
export function toSynologyHostedMediaStoreRoutePath(path: string): string {
const normalized = normalizeExactPath(path);
return normalized === "/" ? normalized : `${normalized}/`;
}
export function resolveSynologyHostedMediaRoute(params: {
webhookPath: string;
webhookUrl: string;
}): {
localRoutePath: string;
publicBaseUrl: string;
publicRoutePath: string;
publicSearch: string;
} {
if (!params.webhookUrl.trim()) {
throw new Error(
"Synology Chat attachments require webhookUrl. Set the account's exact externally reachable HTTPS callback URL.",
);
}
const routeKey = resolveSynologyPublicWebhookRouteKey(params.webhookUrl);
if (!routeKey) {
throw new Error(
"Synology Chat webhookUrl must be an absolute HTTPS URL with a hostname and no credentials or fragment.",
);
}
const webhookUrl = new URL(params.webhookUrl);
if (
[...webhookUrl.searchParams.keys()].some((key) =>
key.startsWith(`${SYNOLOGY_HOSTED_MEDIA_TOKEN_PARAM_PREFIX}_`),
)
) {
throw new Error(
`Synology Chat webhookUrl must not contain query parameters starting with ${SYNOLOGY_HOSTED_MEDIA_TOKEN_PARAM_PREFIX}_.`,
);
}
return {
localRoutePath: toSynologyHostedMediaStoreRoutePath(params.webhookPath),
publicBaseUrl: webhookUrl.origin,
// webhookUrl is the operator's exact proxy contract; trailing slashes and
// encoded path segments can be route-significant and must not be rewritten.
publicRoutePath: webhookUrl.pathname,
publicSearch: webhookUrl.search,
};
}
@@ -0,0 +1,992 @@
// Synology Chat tests cover guarded outbound attachment staging and same-route capability serving.
import fs from "node:fs";
import type { HostedOutboundMediaChunkRecord } from "openclaw/plugin-sdk/outbound-media";
import type { PluginRuntime } from "openclaw/plugin-sdk/plugin-runtime";
import type {
OpenKeyedStoreOptions,
PluginStateKeyedStore,
} from "openclaw/plugin-sdk/plugin-state-runtime";
import {
createPluginStateKeyedStoreForTests,
resetPluginStateStoreForTests,
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
import { useAutoCleanupTempDirTracker } from "openclaw/plugin-sdk/test-env";
import type { loadWebMedia as loadWebMediaType } from "openclaw/plugin-sdk/web-media";
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import { resolveSynologyHostedMediaRoute } from "./hosted-media-route.js";
import {
prepareSynologyHostedMedia,
tryHandleSynologyHostedMediaRequest,
} from "./outbound-media.js";
import { setSynologyRuntime } from "./runtime.js";
import { makeReq, makeRes as makeBaseRes } from "./test-http-utils.js";
import type { ResolvedSynologyChatAccount } from "./types.js";
const loadWebMediaMock = vi.hoisted(() => vi.fn<typeof loadWebMediaType>());
function makeRes(options: { finishOnEnd?: boolean } = {}) {
const res = makeBaseRes(options);
const chunks: Buffer[] = [];
const end = res.end.bind(res);
res.write = ((chunk: Uint8Array | string) => {
chunks.push(Buffer.from(chunk));
return true;
}) as typeof res.write;
res.end = ((chunk?: Uint8Array | string) => {
if (chunk !== undefined) {
chunks.push(Buffer.from(chunk));
}
end(chunks.length > 0 ? Buffer.concat(chunks) : undefined);
return res;
}) as typeof res.end;
return res;
}
vi.mock("openclaw/plugin-sdk/web-media", () => ({
loadWebMedia: loadWebMediaMock,
}));
const testStateDirs = useAutoCleanupTempDirTracker((cleanup) => {
afterAll(() => {
resetPluginStateStoreForTests();
cleanup();
});
});
// Each test gets clean SQLite state; reopen cases retain it within that test.
const testStateDir = testStateDirs.make(
"openclaw-synology-media-",
resolvePreferredOpenClawTmpDir(),
);
const testStateEnv: NodeJS.ProcessEnv = {
...process.env,
OPENCLAW_STATE_DIR: testStateDir,
};
function createAccount(overrides: Partial<ResolvedSynologyChatAccount> = {}) {
return {
accountId: "default",
enabled: true,
token: "token",
incomingUrl: "https://nas.example.com/incoming",
webhookUrl: "https://gateway.example.com/public/synology?proxy-token=keep",
nasHost: "nas.example.com",
webhookPath: "/internal/synology",
webhookPathSource: "explicit" as const,
dangerouslyAllowNameMatching: false,
dangerouslyAllowInheritedWebhookPath: false,
dmPolicy: "allowlist" as const,
allowedUserIds: ["42"],
rateLimitPerMinute: 30,
botName: "OpenClaw",
allowInsecureSsl: false,
...overrides,
} satisfies ResolvedSynologyChatAccount;
}
function installRuntime() {
const openedStores: Array<ReturnType<typeof createPluginStateKeyedStoreForTests>> = [];
let registerCallCount = 0;
const openKeyedStore = vi.fn((options: OpenKeyedStoreOptions) => {
const store = createPluginStateKeyedStoreForTests("synology-chat", {
...options,
env: testStateEnv,
});
const register = store.register.bind(store);
store.register = async (key, value, opts) => {
registerCallCount += 1;
await register(key, value, opts);
};
openedStores.push(store);
return store;
});
setSynologyRuntime({ state: { openKeyedStore } } as unknown as PluginRuntime);
return { openKeyedStore, openedStores, getRegisterCallCount: () => registerCallCount };
}
function internalCapabilityUrl(publicUrl: string, pathName = "/internal/synology"): string {
return `${pathName}${new URL(publicUrl).search}`;
}
function utf16Buffer(value: string, endian: "le" | "be", includeBom = true): Buffer {
const buffer = Buffer.from(`${includeBom ? "\ufeff" : ""}${value}`, "utf16le");
return endian === "le" ? buffer : buffer.swap16();
}
function utf32Buffer(value: string, endian: "le" | "be", includeBom = true): Buffer {
const codePoints = Array.from(value, (character) => character.codePointAt(0) ?? 0xfffd);
const bomBytes = includeBom ? 4 : 0;
const buffer = Buffer.alloc(bomBytes + codePoints.length * 4);
if (includeBom) {
if (endian === "le") {
buffer.writeUInt32LE(0xfeff, 0);
} else {
buffer.writeUInt32BE(0xfeff, 0);
}
}
codePoints.forEach((codePoint, index) => {
const offset = bomBytes + index * 4;
if (endian === "le") {
buffer.writeUInt32LE(codePoint, offset);
} else {
buffer.writeUInt32BE(codePoint, offset);
}
});
return buffer;
}
describe("Synology Chat hosted outbound media", () => {
beforeEach(() => {
resetPluginStateStoreForTests();
fs.rmSync(testStateDir, { recursive: true, force: true });
fs.mkdirSync(testStateDir, { recursive: true });
installRuntime();
loadWebMediaMock.mockReset();
loadWebMediaMock.mockResolvedValue({
buffer: Buffer.from("frozen-image-bytes"),
kind: "image",
contentType: "image/png",
fileName: "floor-plan.png",
});
vi.useRealTimers();
});
it("requires an exact public HTTPS callback without credentials or fragments", () => {
const credentialedUrl = new URL("https://gateway.example.com/webhook");
credentialedUrl.username = "fixture-user";
credentialedUrl.password = "fixture-password";
expect(() => resolveSynologyHostedMediaRoute(createAccount({ webhookUrl: "" }))).toThrow(
"attachments require webhookUrl",
);
expect(() =>
resolveSynologyHostedMediaRoute(
createAccount({ webhookUrl: "http://gateway.example.com/webhook" }),
),
).toThrow("must be an absolute HTTPS URL");
expect(() =>
resolveSynologyHostedMediaRoute(createAccount({ webhookUrl: credentialedUrl.toString() })),
).toThrow("must be an absolute HTTPS URL");
expect(() =>
resolveSynologyHostedMediaRoute(
createAccount({
webhookUrl:
"https://gateway.example.com/webhook?__openclaw_synology_media_token_existing=value",
}),
),
).toThrow("must not contain query parameters starting with");
});
it("preserves an exact public callback path with a trailing slash", async () => {
const prepared = await prepareSynologyHostedMedia({
account: createAccount({
webhookUrl: "https://gateway.example.com/public/synology/?proxy-token=keep",
}),
mediaUrl: "https://files.example.com/floor-plan.png",
});
expect(new URL(prepared.url).pathname).toBe("/public/synology/");
});
it("freezes source bytes and serves repeat GET/HEAD requests on the internal route", async () => {
const account = createAccount();
const prepared = await prepareSynologyHostedMedia({
account,
mediaUrl: "https://files.example.com/floor-plan.png",
});
expect(prepared.url).toMatch(
/^https:\/\/gateway\.example\.com\/public\/synology\?proxy-token=keep&__openclaw_synology_media_token_[a-f0-9]{24}=/u,
);
expect(prepared.url).not.toContain("files.example.com");
expect(loadWebMediaMock).toHaveBeenCalledTimes(1);
loadWebMediaMock.mockResolvedValue({
buffer: Buffer.from("changed-source-bytes"),
kind: "image",
contentType: "image/png",
fileName: "changed.png",
});
const requestUrl = internalCapabilityUrl(prepared.url);
const head = makeRes();
await expect(
tryHandleSynologyHostedMediaRequest(makeReq("HEAD", "", { url: requestUrl }), head, account),
).resolves.toBe(true);
expect(head.statusCode).toBe(200);
expect(head.body).toBe("");
expect(head.headers["content-disposition"]).toContain("attachment");
expect(head.headers["content-disposition"]).toContain("floor-plan.png");
expect(head.headers["x-content-type-options"]).toBe("nosniff");
expect(head.headers["cache-control"]).toBe("no-store");
for (let index = 0; index < 2; index += 1) {
const get = makeRes();
await tryHandleSynologyHostedMediaRequest(
makeReq("GET", "", { url: requestUrl }),
get,
account,
);
expect(get.statusCode).toBe(200);
expect(Buffer.from(get.body).toString("utf8")).toBe("frozen-image-bytes");
}
expect(loadWebMediaMock).toHaveBeenCalledTimes(1);
});
it("reconstructs persisted bytes before honoring response backpressure", async () => {
const { openedStores } = installRuntime();
const frozenBytes = Buffer.alloc(40 * 1024, 0x61);
loadWebMediaMock.mockResolvedValueOnce({
buffer: frozenBytes,
kind: undefined,
contentType: "application/pdf",
fileName: "report.pdf",
});
const account = createAccount();
const prepared = await prepareSynologyHostedMedia({
account,
mediaUrl: "https://files.example.com/report.pdf",
});
const chunkStore = openedStores[1] as
| PluginStateKeyedStore<HostedOutboundMediaChunkRecord>
| undefined;
if (!chunkStore) {
throw new Error("expected hosted media chunk store");
}
const chunkLookup = vi.spyOn(chunkStore, "lookup");
const response = makeRes();
const write = response.write.bind(response);
let firstWrite = true;
response.write = ((chunk: Uint8Array | string) => {
write(chunk);
if (firstWrite) {
firstWrite = false;
return false;
}
return true;
}) as typeof response.write;
let settled = false;
const serving = tryHandleSynologyHostedMediaRequest(
makeReq("GET", "", { url: internalCapabilityUrl(prepared.url) }),
response,
account,
).finally(() => {
settled = true;
});
await vi.waitFor(() => expect(chunkLookup).toHaveBeenCalledTimes(2));
expect(settled).toBe(false);
response.emit("drain");
await expect(serving).resolves.toBe(true);
expect(chunkLookup).toHaveBeenCalledTimes(2);
expect(Buffer.from(response.body)).toEqual(frozenBytes);
});
it("rejects a corrupt persisted payload before writing response bytes", async () => {
const { openedStores } = installRuntime();
loadWebMediaMock.mockResolvedValueOnce({
buffer: Buffer.alloc(40 * 1024, 0x61),
kind: undefined,
contentType: "application/pdf",
fileName: "report.pdf",
});
const account = createAccount();
const prepared = await prepareSynologyHostedMedia({
account,
mediaUrl: "https://files.example.com/report.pdf",
});
const chunkStore = openedStores[1] as
| PluginStateKeyedStore<HostedOutboundMediaChunkRecord>
| undefined;
if (!chunkStore) {
throw new Error("expected hosted media chunk store");
}
const originalLookup = chunkStore.lookup.bind(chunkStore);
vi.spyOn(chunkStore, "lookup").mockImplementation(async (key) => {
const chunk = await originalLookup(key);
return chunk?.index === 1
? { ...chunk, dataBase64: Buffer.from("oversized").toString("base64") }
: chunk;
});
const response = makeRes({ finishOnEnd: false });
const writeSpy = vi.spyOn(response, "write");
await expect(
tryHandleSynologyHostedMediaRequest(
makeReq("GET", "", { url: internalCapabilityUrl(prepared.url) }),
response,
account,
),
).resolves.toBe(true);
expect(response.statusCode).toBe(404);
expect(response.destroyed).toBe(false);
expect(writeSpy).not.toHaveBeenCalled();
});
it("never treats capability query values as an on-demand fetch target", async () => {
const account = createAccount();
const prepared = await prepareSynologyHostedMedia({
account,
mediaUrl: "https://files.example.com/floor-plan.png",
});
const requestUrl = new URL(internalCapabilityUrl(prepared.url), "http://localhost");
requestUrl.searchParams.set("url", "http://127.0.0.1/private");
requestUrl.searchParams.set("target", "https://files.example.com/changed.png");
const response = makeRes();
await tryHandleSynologyHostedMediaRequest(
makeReq("GET", "", { url: `${requestUrl.pathname}${requestUrl.search}` }),
response,
account,
);
expect(response.statusCode).toBe(200);
expect(Buffer.from(response.body).toString("utf8")).toBe("frozen-image-bytes");
expect(response.headers).not.toHaveProperty("location");
expect(loadWebMediaMock).toHaveBeenCalledTimes(1);
const targetOnly = makeRes();
await expect(
tryHandleSynologyHostedMediaRequest(
makeReq("GET", "", { url: "/internal/synology?target=http://127.0.0.1/private" }),
targetOnly,
account,
),
).resolves.toBe(false);
expect(loadWebMediaMock).toHaveBeenCalledTimes(1);
});
it("propagates guarded-load rejection without creating a capability", async () => {
loadWebMediaMock.mockRejectedValueOnce(
new Error("Blocked hostname or private/internal IP address"),
);
await expect(
prepareSynologyHostedMedia({
account: createAccount(),
mediaUrl: "https://rebind.example.test/private",
}),
).rejects.toThrow("Blocked hostname or private/internal IP address");
expect(loadWebMediaMock).toHaveBeenCalledTimes(1);
});
it("keeps preparation limits after a fresh runtime initializes its stores", async () => {
let releaseLoads: (() => void) | undefined;
const loadGate = new Promise<void>((resolve) => {
releaseLoads = resolve;
});
loadWebMediaMock.mockImplementation(async () => {
await loadGate;
return {
buffer: Buffer.from("frozen-image-bytes"),
kind: "image",
contentType: "image/png",
fileName: "floor-plan.png",
};
});
const account = createAccount();
const first = prepareSynologyHostedMedia({
account,
mediaUrl: "https://files.example.com/one.png",
});
const second = prepareSynologyHostedMedia({
account,
mediaUrl: "https://files.example.com/two.png",
});
await vi.waitFor(() => expect(loadWebMediaMock).toHaveBeenCalledTimes(2));
const third = prepareSynologyHostedMedia({
account,
mediaUrl: "https://files.example.com/three.png",
});
await Promise.resolve();
releaseLoads?.();
await expect(third).rejects.toThrow("attachment preparation is busy");
await expect(Promise.all([first, second])).resolves.toHaveLength(2);
expect(loadWebMediaMock).toHaveBeenCalledTimes(2);
});
it("fails closed for wrong tokens, accounts, routes, and unsupported methods", async () => {
const account = createAccount();
const prepared = await prepareSynologyHostedMedia({
account,
mediaUrl: "https://files.example.com/report.pdf",
});
const capability = new URL(prepared.url);
const tokenKey = [...capability.searchParams.keys()].find((key) =>
key.startsWith("__openclaw_synology_media_token_"),
);
if (!tokenKey) {
throw new Error("expected Synology hosted media token");
}
const wrongToken = new URLSearchParams(capability.search);
wrongToken.set(tokenKey, "wrong");
const unauthorized = makeRes();
await tryHandleSynologyHostedMediaRequest(
makeReq("GET", "", { url: `/internal/synology?${wrongToken.toString()}` }),
unauthorized,
account,
);
expect(unauthorized.statusCode).toBe(401);
const crossAccount = makeRes();
await tryHandleSynologyHostedMediaRequest(
makeReq("GET", "", { url: internalCapabilityUrl(prepared.url) }),
crossAccount,
createAccount({ accountId: "other" }),
);
expect(crossAccount.statusCode).toBe(404);
const crossRoute = makeRes();
await tryHandleSynologyHostedMediaRequest(
makeReq("GET", "", { url: internalCapabilityUrl(prepared.url, "/other") }),
crossRoute,
account,
);
expect(crossRoute.statusCode).toBe(404);
const method = makeRes();
await tryHandleSynologyHostedMediaRequest(
makeReq("POST", "", { url: internalCapabilityUrl(prepared.url) }),
method,
account,
);
expect(method.statusCode).toBe(405);
});
it("bounds unauthenticated capability lookups before reading persistent state", async () => {
const { openedStores } = installRuntime();
const account = createAccount();
const prepared = await prepareSynologyHostedMedia({
account,
mediaUrl: "https://files.example.com/report.pdf",
});
const metadataStore = openedStores[0];
if (!metadataStore) {
throw new Error("expected hosted media metadata store");
}
const originalLookup = metadataStore.lookup.bind(metadataStore);
let releaseReads: (() => void) | undefined;
const readGate = new Promise<void>((resolve) => {
releaseReads = resolve;
});
const lookupSpy = vi.spyOn(metadataStore, "lookup").mockImplementation(async (key) => {
await readGate;
return await originalLookup(key);
});
const capability = new URL(internalCapabilityUrl(prepared.url), "http://localhost");
const tokenKey = [...capability.searchParams.keys()].find((key) =>
key.startsWith("__openclaw_synology_media_token_"),
);
if (!tokenKey) {
throw new Error("expected Synology hosted media token");
}
capability.searchParams.set(tokenKey, "wrong");
const requestUrl = `${capability.pathname}${capability.search}`;
const responses = Array.from({ length: 5 }, () => makeRes());
const requests = responses.map((response) =>
tryHandleSynologyHostedMediaRequest(
makeReq("GET", "", { url: requestUrl }),
response,
account,
),
);
await vi.waitFor(() => expect(lookupSpy).toHaveBeenCalledTimes(4));
expect(responses.filter((response) => response.statusCode === 503)).toHaveLength(1);
releaseReads?.();
await expect(Promise.all(requests)).resolves.toEqual([true, true, true, true, true]);
expect(responses.map((response) => response.statusCode).toSorted((a, b) => a - b)).toEqual([
401, 401, 401, 401, 503,
]);
});
it("holds serving slots until responses finish or close", async () => {
const account = createAccount();
const prepared = await prepareSynologyHostedMedia({
account,
mediaUrl: "https://files.example.com/report.pdf",
});
const requestUrl = internalCapabilityUrl(prepared.url);
const stalled = Array.from({ length: 4 }, () => makeRes({ finishOnEnd: false }));
await Promise.all(
stalled.map((response) =>
tryHandleSynologyHostedMediaRequest(
makeReq("GET", "", { url: requestUrl }),
response,
account,
),
),
);
const blocked = makeRes();
await tryHandleSynologyHostedMediaRequest(
makeReq("GET", "", { url: requestUrl }),
blocked,
account,
);
expect(blocked.statusCode).toBe(503);
stalled[0]?.emit("finish");
const admitted = makeRes();
await tryHandleSynologyHostedMediaRequest(
makeReq("GET", "", { url: requestUrl }),
admitted,
account,
);
expect(admitted.statusCode).toBe(200);
for (const response of stalled.slice(1)) {
response.emit("close");
}
});
it("keeps serving limits when a fresh runtime reopens persisted capabilities", async () => {
const account = createAccount();
const prepared = await prepareSynologyHostedMedia({
account,
mediaUrl: "https://files.example.com/report.pdf",
});
installRuntime();
const requestUrl = internalCapabilityUrl(prepared.url);
const stalled = Array.from({ length: 5 }, () => makeRes({ finishOnEnd: false }));
for (const response of stalled) {
await tryHandleSynologyHostedMediaRequest(
makeReq("GET", "", { url: requestUrl }),
response,
account,
);
}
expect(stalled.slice(0, 4).map((response) => response.statusCode)).toEqual([
200, 200, 200, 200,
]);
expect(stalled[4]?.statusCode).toBe(503);
for (const response of stalled.slice(0, 4)) {
response.emit("close");
}
});
it("closes stalled attachment responses and releases their serving slot", async () => {
vi.useFakeTimers();
vi.setSystemTime(1_700_000_000_000);
installRuntime();
const account = createAccount();
const prepared = await prepareSynologyHostedMedia({
account,
mediaUrl: "https://files.example.com/report.pdf",
});
const requestUrl = internalCapabilityUrl(prepared.url);
const stalled = makeRes({ finishOnEnd: false });
await tryHandleSynologyHostedMediaRequest(
makeReq("GET", "", { url: requestUrl }),
stalled,
account,
);
await vi.advanceTimersByTimeAsync(2 * 60_000);
expect(stalled.destroyed).toBe(true);
const admitted = makeRes();
await tryHandleSynologyHostedMediaRequest(
makeReq("GET", "", { url: requestUrl }),
admitted,
account,
);
expect(admitted.statusCode).toBe(200);
});
it("starts the response deadline before persisted metadata can stall", async () => {
vi.useFakeTimers();
vi.setSystemTime(1_700_000_000_000);
const { openedStores } = installRuntime();
const account = createAccount();
const prepared = await prepareSynologyHostedMedia({
account,
mediaUrl: "https://files.example.com/report.pdf",
});
const metadataStore = openedStores[0];
if (!metadataStore) {
throw new Error("expected hosted media metadata store");
}
const lookup = metadataStore.lookup.bind(metadataStore);
let markLookupStarted: (() => void) | undefined;
let releaseLookup: (() => void) | undefined;
const lookupStarted = new Promise<void>((resolve) => {
markLookupStarted = resolve;
});
const lookupGate = new Promise<void>((resolve) => {
releaseLookup = resolve;
});
vi.spyOn(metadataStore, "lookup").mockImplementationOnce(async (key) => {
markLookupStarted?.();
await lookupGate;
return await lookup(key);
});
const response = makeRes({ finishOnEnd: false });
const pending = tryHandleSynologyHostedMediaRequest(
makeReq("GET", "", { url: internalCapabilityUrl(prepared.url) }),
response,
account,
);
await lookupStarted;
await vi.advanceTimersByTimeAsync(2 * 60_000);
expect(response.statusCode).toBe(504);
expect(Buffer.from(response.body).toString("utf8")).toBe("Attachment response timed out");
releaseLookup?.();
await expect(pending).resolves.toBe(true);
expect(response.statusCode).toBe(504);
});
it("bounds repeated authenticated downloads without charging HEAD requests", async () => {
const { openedStores } = installRuntime();
loadWebMediaMock.mockResolvedValueOnce({
buffer: Buffer.alloc(32 * 1024 * 1024, 0x61),
kind: undefined,
contentType: "application/pdf",
fileName: "report.pdf",
});
const account = createAccount();
const prepared = await prepareSynologyHostedMedia({
account,
mediaUrl: "https://files.example.com/report.pdf",
});
const requestUrl = internalCapabilityUrl(prepared.url);
const chunkStore = openedStores[1];
if (!chunkStore) {
throw new Error("expected hosted media chunk store");
}
const chunkReadSpy = vi.spyOn(chunkStore, "lookup");
for (let index = 0; index < 4; index += 1) {
const response = makeRes();
await tryHandleSynologyHostedMediaRequest(
makeReq("GET", "", { url: requestUrl }),
response,
account,
);
expect(response.statusCode).toBe(200);
await Promise.resolve();
}
const chunkReadsAtLimit = chunkReadSpy.mock.calls.length;
const head = makeRes();
await tryHandleSynologyHostedMediaRequest(
makeReq("HEAD", "", { url: requestUrl }),
head,
account,
);
expect(head.statusCode).toBe(200);
const limited = makeRes();
await tryHandleSynologyHostedMediaRequest(
makeReq("GET", "", { url: requestUrl }),
limited,
account,
);
expect(limited.statusCode).toBe(429);
expect(limited.headers["retry-after"]).toBe("60");
expect(chunkReadSpy).toHaveBeenCalledTimes(chunkReadsAtLimit);
});
it("persists frozen capabilities across plugin-state reopen and runtime replacement", async () => {
const account = createAccount();
const prepared = await prepareSynologyHostedMedia({
account,
mediaUrl: "https://files.example.com/floor-plan.png",
});
const requestUrl = internalCapabilityUrl(prepared.url);
resetPluginStateStoreForTests();
installRuntime();
loadWebMediaMock.mockResolvedValueOnce({
buffer: Buffer.from("changed-source-bytes"),
kind: "image",
contentType: "image/png",
fileName: "changed.png",
});
const response = makeRes();
await tryHandleSynologyHostedMediaRequest(
makeReq("GET", "", { url: requestUrl }),
response,
account,
);
expect(response.statusCode).toBe(200);
expect(Buffer.from(response.body).toString("utf8")).toBe("frozen-image-bytes");
expect(loadWebMediaMock).toHaveBeenCalledTimes(1);
});
it("rejects active content and leaves no live capability", async () => {
const { getRegisterCallCount, openedStores } = installRuntime();
loadWebMediaMock.mockResolvedValueOnce({
buffer: Buffer.from("<svg onload=alert(1)></svg>"),
kind: "image",
contentType: "image/svg+xml",
fileName: "active.svg",
});
await expect(
prepareSynologyHostedMedia({
account: createAccount(),
mediaUrl: "https://files.example.com/active.svg",
}),
).rejects.toThrow("do not support active content type");
await expect(
Promise.all(openedStores.map(async (store) => await store.entries())),
).resolves.toEqual([[], []]);
expect(getRegisterCallCount()).toBe(0);
});
it.each([
{
name: "HTML bytes with a passive MIME and filename",
buffer: Buffer.from("<script>alert('active')</script>"),
contentType: "image/png",
fileName: "photo.png",
},
{
name: "XML-prefixed SVG bytes with generic metadata",
buffer: Buffer.from('<?xml version="1.0"?><!--fixture--><svg onload="alert(1)"/>'),
contentType: "application/octet-stream",
fileName: "diagram.bin",
},
{
name: "SVG doctype bytes with generic metadata",
buffer: Buffer.from(
'<!DOCTYPE svg><svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"/>',
),
contentType: "application/octet-stream",
fileName: "diagram.bin",
},
{
name: "SVG bytes beyond long whitespace and repeated wrappers",
buffer: Buffer.from(
`${" ".repeat(5_000)}${"<!--fixture-->".repeat(6)}<svg onload="alert(1)"/>`,
),
contentType: "application/octet-stream",
fileName: "diagram.bin",
},
{
name: "UTF-16LE HTML bytes with passive metadata",
buffer: utf16Buffer("<script>alert('active')</script>", "le"),
contentType: "image/png",
fileName: "photo.png",
},
{
name: "UTF-16BE SVG bytes with passive metadata",
buffer: utf16Buffer(' <!--fixture--><svg onload="alert(1)"/>', "be"),
contentType: "image/png",
fileName: "photo.png",
},
{
name: "UTF-16LE XML bytes with passive metadata",
buffer: utf16Buffer('<?xml version="1.0"?><document/>', "le"),
contentType: "application/octet-stream",
fileName: "document.bin",
},
{
name: "UTF-16BE HTML doctype bytes with passive metadata",
buffer: utf16Buffer("<!DOCTYPE html><html><body>active</body></html>", "be"),
contentType: "application/octet-stream",
fileName: "document.bin",
},
{
name: "BOM-less UTF-16LE HTML bytes with generic metadata",
buffer: utf16Buffer('<img src="x" onerror="alert(1)">', "le", false),
contentType: "application/octet-stream",
fileName: "document.bin",
},
{
name: "BOM-less UTF-16BE HTML bytes with generic metadata",
buffer: utf16Buffer(" <div><script>alert('active')</script></div>", "be", false),
contentType: "application/octet-stream",
fileName: "document.bin",
},
{
name: "BOM-less UTF-32LE HTML bytes with generic metadata",
buffer: utf32Buffer('<embed src="data:text/html,active">', "le", false),
contentType: "application/octet-stream",
fileName: "document.bin",
},
{
name: "UTF-32BE SVG bytes with passive metadata",
buffer: utf32Buffer('<svg onload="alert(1)"/>', "be"),
contentType: "image/png",
fileName: "photo.png",
},
{
name: "an unlisted active HTML root with generic metadata",
buffer: Buffer.from('<object data="data:text/html,active"></object>'),
contentType: "application/octet-stream",
fileName: "document.bin",
},
{
name: "an active root whose tag name exceeds the old sniff prefix",
buffer: Buffer.from(`<${"custom-element-".repeat(8)}>active</custom-element>`),
contentType: "application/octet-stream",
fileName: "document.bin",
},
{
name: "a bogus declaration before an active element",
buffer: Buffer.from("<!fixture><script>alert('active')</script>"),
contentType: "application/octet-stream",
fileName: "document.bin",
},
{
name: "an unmatched closing tag before an active element",
buffer: Buffer.from("</fixture><script>alert('active')</script>"),
contentType: "application/octet-stream",
fileName: "document.bin",
},
{
name: "an abruptly closed comment before an active element",
buffer: Buffer.from("<!--><script>alert('active')</script>"),
contentType: "application/octet-stream",
fileName: "document.bin",
},
{
name: "an abruptly closed comment-start-dash before an active element",
buffer: Buffer.from("<!---><script>alert('active')</script>"),
contentType: "application/octet-stream",
fileName: "document.bin",
},
{
name: "an incorrectly closed comment before an active element",
buffer: Buffer.from("<!--fixture--!><script>alert('active')</script>"),
contentType: "application/octet-stream",
fileName: "document.bin",
},
{
name: "an active filename with generic content",
buffer: Buffer.from("not markup"),
contentType: "application/octet-stream",
fileName: "report.html",
},
])("rejects $name", async ({ buffer, contentType, fileName }) => {
loadWebMediaMock.mockResolvedValueOnce({
buffer,
kind: undefined,
contentType,
fileName,
});
await expect(
prepareSynologyHostedMedia({
account: createAccount(),
mediaUrl: "https://files.example.com/disguised-content",
}),
).rejects.toThrow("do not support active content type");
});
it.each([
{ endian: "le" as const, includeBom: true },
{ endian: "be" as const, includeBom: true },
{ endian: "le" as const, includeBom: false },
{ endian: "be" as const, includeBom: false },
])(
"keeps passive UTF-16$endian attachments available (BOM: $includeBom)",
async ({ endian, includeBom }) => {
const buffer = utf16Buffer("Passive attachment text", endian, includeBom);
loadWebMediaMock.mockResolvedValueOnce({
buffer,
kind: undefined,
contentType: "text/plain",
fileName: `notes-${endian}.txt`,
});
const account = createAccount();
const prepared = await prepareSynologyHostedMedia({
account,
mediaUrl: `https://files.example.com/notes-${endian}.txt`,
});
const response = makeRes();
await tryHandleSynologyHostedMediaRequest(
makeReq("GET", "", { url: internalCapabilityUrl(prepared.url) }),
response,
account,
);
expect(response.statusCode).toBe(200);
expect(Buffer.from(response.body)).toEqual(buffer);
},
);
it.each([
{
name: "UTF-8 source text",
buffer: Buffer.from("Example source: <div> is a literal tag."),
},
{
name: "BOM-less UTF-32 source text",
buffer: utf32Buffer("Example source: <div> is a literal tag.", "le", false),
},
])("keeps passive $name containing embedded markup available", async ({ buffer }) => {
loadWebMediaMock.mockResolvedValueOnce({
buffer,
kind: undefined,
contentType: "text/plain",
fileName: "example.txt",
});
const account = createAccount();
const prepared = await prepareSynologyHostedMedia({
account,
mediaUrl: "https://files.example.com/example.txt",
});
const response = makeRes();
await tryHandleSynologyHostedMediaRequest(
makeReq("GET", "", { url: internalCapabilityUrl(prepared.url) }),
response,
account,
);
expect(response.statusCode).toBe(200);
expect(Buffer.from(response.body)).toEqual(buffer);
});
it("sanitizes response filenames before constructing headers", async () => {
loadWebMediaMock.mockResolvedValueOnce({
buffer: Buffer.from("pdf"),
kind: undefined,
contentType: "application/pdf",
fileName: '../quarter\r\nX-Evil: yes/"plan".pdf',
});
const account = createAccount();
const prepared = await prepareSynologyHostedMedia({
account,
mediaUrl: "https://files.example.com/report.pdf",
});
const response = makeRes();
await tryHandleSynologyHostedMediaRequest(
makeReq("GET", "", { url: internalCapabilityUrl(prepared.url) }),
response,
account,
);
const disposition = response.headers["content-disposition"] ?? "";
expect(disposition).toContain("attachment");
expect(disposition).not.toMatch(/[\r\n]/u);
expect(disposition).not.toContain("../");
});
it("expires capabilities without falling back to the source URL", async () => {
vi.useFakeTimers();
vi.setSystemTime(1_700_000_000_000);
installRuntime();
const account = createAccount();
const prepared = await prepareSynologyHostedMedia({
account,
mediaUrl: "https://files.example.com/report.pdf",
});
vi.setSystemTime(1_700_000_000_000 + 10 * 60_000 + 1);
const response = makeRes();
await tryHandleSynologyHostedMediaRequest(
makeReq("GET", "", { url: internalCapabilityUrl(prepared.url) }),
response,
account,
);
expect(response.statusCode).toBe(404);
expect(loadWebMediaMock).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,678 @@
// Synology Chat plugin module stages immutable outbound bytes for NAS attachment pickup.
import { createHash } from "node:crypto";
import type { IncomingMessage, ServerResponse } from "node:http";
import { mimeTypeFromFilePath, normalizeMimeType } from "openclaw/plugin-sdk/media-mime";
import { resolveExpiresAtMsFromDurationMs } from "openclaw/plugin-sdk/number-runtime";
import {
buildHostedOutboundMediaResponseHeaders,
createHostedOutboundMediaStore,
type HostedOutboundMediaChunkRecord,
type HostedOutboundMediaEntry,
type HostedOutboundMediaMetaRecord,
type HostedOutboundMediaStore,
type OutboundMediaLoadOptions,
} from "openclaw/plugin-sdk/outbound-media";
import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime";
import { createWebhookInFlightLimiter } from "openclaw/plugin-sdk/webhook-ingress";
import {
resolveSynologyHostedMediaRoute,
SYNOLOGY_HOSTED_MEDIA_TOKEN_PARAM_PREFIX,
toSynologyHostedMediaStoreRoutePath,
} from "./hosted-media-route.js";
import { getSynologyRuntime } from "./runtime.js";
import type { ResolvedSynologyChatAccount } from "./types.js";
const SYNOLOGY_OUTBOUND_MEDIA_TTL_MS = 10 * 60_000;
const SYNOLOGY_OUTBOUND_MEDIA_MAX_BYTES = 32 * 1024 * 1024;
const SYNOLOGY_OUTBOUND_MEDIA_MAX_TOTAL_BYTES = 128 * 1024 * 1024;
const SYNOLOGY_OUTBOUND_MEDIA_MAX_ENTRIES = 16;
const SYNOLOGY_OUTBOUND_MEDIA_MAX_CHUNK_ROWS = 4_096;
const SYNOLOGY_OUTBOUND_MEDIA_ID_RE = /^[a-f0-9]{24}$/;
const SYNOLOGY_OUTBOUND_MEDIA_PREPARE_TIMEOUT_MS = 60_000;
const SYNOLOGY_OUTBOUND_MEDIA_MAX_PREPARATIONS = 2;
const SYNOLOGY_OUTBOUND_MEDIA_MAX_SERVES = 4;
const SYNOLOGY_OUTBOUND_MEDIA_SERVE_TIMEOUT_MS = 2 * 60_000;
const SYNOLOGY_OUTBOUND_MEDIA_POST_EXPIRY_RETENTION_MS =
SYNOLOGY_OUTBOUND_MEDIA_SERVE_TIMEOUT_MS + 60_000;
const SYNOLOGY_OUTBOUND_MEDIA_SERVED_BYTES_WINDOW_MS = 60_000;
const SYNOLOGY_OUTBOUND_MEDIA_MAX_SERVED_BYTES_PER_WINDOW = 128 * 1024 * 1024;
const SYNOLOGY_OUTBOUND_MEDIA_MAX_BUDGET_ACCOUNTS = 128;
const ACTIVE_CONTENT_TYPES = new Set([
"image/svg+xml",
"text/html",
"application/xhtml+xml",
"application/xml",
"text/xml",
]);
const OUTBOUND_MEDIA_NAMESPACE = "hosted-outbound-media";
const OUTBOUND_MEDIA_CHUNKS_NAMESPACE = "hosted-outbound-media-chunks";
declare const synologyHostedMediaUrlBrand: unique symbol;
export type SynologyHostedMediaUrl = string & {
readonly [synologyHostedMediaUrlBrand]: true;
};
type PreparedSynologyHostedMedia = {
url: SynologyHostedMediaUrl;
cleanup: () => Promise<void>;
};
const preparationLimiter = createWebhookInFlightLimiter({
maxInFlightPerKey: SYNOLOGY_OUTBOUND_MEDIA_MAX_PREPARATIONS,
maxTrackedKeys: 128,
});
const servingLimiter = createWebhookInFlightLimiter({
maxInFlightPerKey: SYNOLOGY_OUTBOUND_MEDIA_MAX_SERVES,
maxTrackedKeys: 128,
});
const hostedMediaStores = new Map<string, HostedOutboundMediaStore>();
const servedByteWindows = new Map<string, { startedAt: number; bytes: number }>();
let hostedMediaRuntime: ReturnType<typeof getSynologyRuntime> | undefined;
function reserveServedBytes(
accountId: string,
byteLength: number,
now = Date.now(),
): (() => void) | undefined {
const existing = servedByteWindows.get(accountId);
const active =
existing && now - existing.startedAt < SYNOLOGY_OUTBOUND_MEDIA_SERVED_BYTES_WINDOW_MS
? existing
: { startedAt: now, bytes: 0 };
if (active.bytes + byteLength > SYNOLOGY_OUTBOUND_MEDIA_MAX_SERVED_BYTES_PER_WINDOW) {
return undefined;
}
servedByteWindows.delete(accountId);
servedByteWindows.set(accountId, {
startedAt: active.startedAt,
bytes: active.bytes + byteLength,
});
while (servedByteWindows.size > SYNOLOGY_OUTBOUND_MEDIA_MAX_BUDGET_ACCOUNTS) {
const oldest = servedByteWindows.keys().next().value;
if (oldest === undefined) {
break;
}
servedByteWindows.delete(oldest);
}
return () => {
const current = servedByteWindows.get(accountId);
if (!current || current.startedAt !== active.startedAt) {
return;
}
current.bytes = Math.max(0, current.bytes - byteLength);
if (current.bytes === 0) {
servedByteWindows.delete(accountId);
}
};
}
function holdServingLeaseUntilResponseDone(
res: ServerResponse,
accountId: string,
): { isActive: () => boolean; release: () => void } {
let released = false;
const release = () => {
if (released) {
return;
}
released = true;
clearTimeout(timeout);
res.off("finish", release);
res.off("close", release);
servingLimiter.release(accountId);
};
// `res.end()` only queues the body. Keep the account slot until the socket
// finishes or closes so slow readers cannot bypass the response concurrency cap.
const timeout = setTimeout(() => {
if (!res.headersSent) {
res.statusCode = 504;
res.end("Attachment response timed out");
} else {
res.destroy();
}
release();
}, SYNOLOGY_OUTBOUND_MEDIA_SERVE_TIMEOUT_MS);
timeout.unref?.();
res.once("finish", release);
res.once("close", release);
return { isActive: () => !released, release };
}
async function writeHostedMediaChunk(res: ServerResponse, chunk: Buffer): Promise<void> {
if (res.destroyed) {
throw new Error("Synology Chat attachment response closed before completion.");
}
if (res.write(chunk)) {
return;
}
await new Promise<void>((resolve, reject) => {
const cleanup = () => {
res.off("drain", onDrain);
res.off("close", onClose);
};
const onDrain = () => {
cleanup();
resolve();
};
const onClose = () => {
cleanup();
reject(new Error("Synology Chat attachment response closed before completion."));
};
res.once("drain", onDrain);
res.once("close", onClose);
if (res.destroyed) {
onClose();
}
});
}
function createHostedMediaStore(accountId: string): HostedOutboundMediaStore {
const runtime = getSynologyRuntime();
const accountScope = createHash("sha256").update(accountId).digest("hex").slice(0, 16);
return createHostedOutboundMediaStore({
metadataStore: runtime.state.openKeyedStore<HostedOutboundMediaMetaRecord>({
namespace: `${OUTBOUND_MEDIA_NAMESPACE}-${accountScope}`,
maxEntries: SYNOLOGY_OUTBOUND_MEDIA_MAX_ENTRIES,
overflowPolicy: "reject-new",
}),
chunkStore: runtime.state.openKeyedStore<HostedOutboundMediaChunkRecord>({
namespace: `${OUTBOUND_MEDIA_CHUNKS_NAMESPACE}-${accountScope}`,
maxEntries: SYNOLOGY_OUTBOUND_MEDIA_MAX_CHUNK_ROWS,
overflowPolicy: "reject-new",
}),
ttlMs: SYNOLOGY_OUTBOUND_MEDIA_TTL_MS,
maxEntries: SYNOLOGY_OUTBOUND_MEDIA_MAX_ENTRIES,
maxChunkRows: SYNOLOGY_OUTBOUND_MEDIA_MAX_CHUNK_ROWS,
maxTotalBytes: SYNOLOGY_OUTBOUND_MEDIA_MAX_TOTAL_BYTES,
postExpiryRetentionMs: SYNOLOGY_OUTBOUND_MEDIA_POST_EXPIRY_RETENTION_MS,
overflowPolicy: "reject-new",
resolveExpiresAtMs: (ttlMs) => resolveExpiresAtMsFromDurationMs(ttlMs),
});
}
function getHostedMediaStore(accountId: string): HostedOutboundMediaStore {
const runtime = getSynologyRuntime();
if (hostedMediaRuntime !== runtime) {
hostedMediaRuntime = runtime;
hostedMediaStores.clear();
preparationLimiter.clear();
servingLimiter.clear();
servedByteWindows.clear();
}
const existing = hostedMediaStores.get(accountId);
if (existing) {
return existing;
}
const created = createHostedMediaStore(accountId);
hostedMediaStores.set(accountId, created);
return created;
}
function createCleanup(store: HostedOutboundMediaStore, id: string): () => Promise<void> {
let cleanup: Promise<void> | undefined;
return async () => {
const activeCleanup = cleanup ?? store.delete(id);
cleanup = activeCleanup;
try {
await activeCleanup;
} catch (error) {
if (cleanup === activeCleanup) {
cleanup = undefined;
}
throw error;
}
};
}
function normalizeMediaAccess(params: {
mediaAccess?: OutboundMediaLoadOptions["mediaAccess"];
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
}): OutboundMediaLoadOptions["mediaAccess"] {
const localRoots = params.mediaAccess?.localRoots ?? params.mediaLocalRoots;
const readFile = params.mediaAccess?.readFile ?? params.mediaReadFile;
const workspaceDir = params.mediaAccess?.workspaceDir;
if (!localRoots && !readFile && !workspaceDir) {
return undefined;
}
return {
...(localRoots ? { localRoots } : {}),
...(readFile ? { readFile } : {}),
...(workspaceDir ? { workspaceDir } : {}),
};
}
function skipAsciiWhitespace(buffer: Buffer, start: number): number {
let cursor = start;
while (cursor < buffer.length) {
const byte = buffer[cursor];
if (byte !== 0x09 && byte !== 0x0a && byte !== 0x0c && byte !== 0x0d && byte !== 0x20) {
break;
}
cursor += 1;
}
return cursor;
}
function isAsciiMarkupStart(byte: number | undefined): boolean {
return (
byte === 0x21 ||
byte === 0x3f ||
(byte !== undefined && ((byte >= 0x41 && byte <= 0x5a) || (byte >= 0x61 && byte <= 0x7a)))
);
}
type UnicodeMarkupEncoding = "utf-16le" | "utf-16be" | "utf-32le" | "utf-32be";
function readUnicodeCodePoint(
buffer: Buffer,
offset: number,
width: 2 | 4,
littleEndian: boolean,
): number {
if (width === 2) {
return littleEndian ? buffer.readUInt16LE(offset) : buffer.readUInt16BE(offset);
}
return littleEndian ? buffer.readUInt32LE(offset) : buffer.readUInt32BE(offset);
}
function containsEncodedMarkupStart(
buffer: Buffer,
width: 2 | 4,
littleEndian: boolean,
offset = 0,
): boolean {
for (let cursor = offset; cursor + width * 2 <= buffer.length; cursor += width) {
if (
readUnicodeCodePoint(buffer, cursor, width, littleEndian) === 0x3c &&
isAsciiMarkupStart(readUnicodeCodePoint(buffer, cursor + width, width, littleEndian))
) {
return true;
}
}
return false;
}
function detectBomlessUnicodeMarkupEncoding(buffer: Buffer): UnicodeMarkupEncoding | undefined {
// Only consider code-unit-aligned openers. Decoding then applies the same
// root-document policy as ordinary UTF-8, so embedded markup in source text
// remains a passive attachment.
if (containsEncodedMarkupStart(buffer, 4, true)) {
return "utf-32le";
}
if (containsEncodedMarkupStart(buffer, 4, false)) {
return "utf-32be";
}
if (containsEncodedMarkupStart(buffer, 2, true)) {
return "utf-16le";
}
if (containsEncodedMarkupStart(buffer, 2, false)) {
return "utf-16be";
}
return undefined;
}
function decodeUtf32(buffer: Buffer, littleEndian: boolean, offset: number): Buffer {
const chunks: string[] = [];
let codePoints: number[] = [];
for (let cursor = offset; cursor + 4 <= buffer.length; cursor += 4) {
const decoded = readUnicodeCodePoint(buffer, cursor, 4, littleEndian);
codePoints.push(
decoded <= 0x10ffff && (decoded < 0xd800 || decoded > 0xdfff) ? decoded : 0xfffd,
);
if (codePoints.length === 1_024) {
chunks.push(String.fromCodePoint(...codePoints));
codePoints = [];
}
}
if (codePoints.length > 0) {
chunks.push(String.fromCodePoint(...codePoints));
}
return Buffer.from(chunks.join(""));
}
function decodeTextForActiveContentSniffing(buffer: Buffer): Buffer {
if (buffer[0] === 0xff && buffer[1] === 0xfe && buffer[2] === 0x00 && buffer[3] === 0x00) {
return decodeUtf32(buffer, true, 4);
}
if (buffer[0] === 0x00 && buffer[1] === 0x00 && buffer[2] === 0xfe && buffer[3] === 0xff) {
return decodeUtf32(buffer, false, 4);
}
if (buffer[0] === 0xff && buffer[1] === 0xfe) {
return Buffer.from(buffer.subarray(2).toString("utf16le"));
}
if (buffer[0] === 0xfe && buffer[1] === 0xff) {
return Buffer.from(new TextDecoder("utf-16be").decode(buffer.subarray(2)));
}
const bomlessEncoding = detectBomlessUnicodeMarkupEncoding(buffer);
if (bomlessEncoding === "utf-32le") {
return decodeUtf32(buffer, true, 0);
}
if (bomlessEncoding === "utf-32be") {
return decodeUtf32(buffer, false, 0);
}
if (bomlessEncoding === "utf-16le") {
return Buffer.from(buffer.toString("utf16le"));
}
if (bomlessEncoding === "utf-16be") {
return Buffer.from(new TextDecoder("utf-16be").decode(buffer));
}
return buffer;
}
function startsWithAsciiIgnoreCase(buffer: Buffer, start: number, expected: string): boolean {
if (start + expected.length > buffer.length) {
return false;
}
for (let index = 0; index < expected.length; index += 1) {
const byte = buffer[start + index]!;
const lower = byte >= 0x41 && byte <= 0x5a ? byte + 0x20 : byte;
if (lower !== expected.charCodeAt(index)) {
return false;
}
}
return true;
}
function readAsciiRootTag(buffer: Buffer, start: number): string | undefined {
if (buffer[start] !== 0x3c) {
return undefined;
}
let cursor = start + 1;
const first = buffer[cursor];
if (
first === undefined ||
!(
(first >= 0x41 && first <= 0x5a) ||
(first >= 0x61 && first <= 0x7a) ||
first === 0x3a ||
first === 0x5f ||
first >= 0x80
)
) {
return undefined;
}
cursor += 1;
while (cursor < buffer.length) {
const byte = buffer[cursor]!;
if (
(byte >= 0x41 && byte <= 0x5a) ||
(byte >= 0x61 && byte <= 0x7a) ||
(byte >= 0x30 && byte <= 0x39) ||
byte === 0x2d ||
byte === 0x2e ||
byte === 0x3a ||
byte === 0x5f ||
byte >= 0x80
) {
cursor += 1;
continue;
}
if (byte === 0x2f || byte === 0x3e || skipAsciiWhitespace(buffer, cursor) > cursor) {
return buffer
.subarray(start + 1, cursor)
.toString("utf8")
.toLowerCase();
}
return undefined;
}
return undefined;
}
function skipRootHtmlComment(buffer: Buffer, start: number): number | undefined {
let cursor = start + 4;
// HTML closes an empty `<!-->` comment abruptly at the first `>`.
if (buffer[cursor] === 0x3e) {
return cursor + 1;
}
// The comment-start-dash state likewise closes `<!--->` at `>`.
if (buffer[cursor] === 0x2d && buffer[cursor + 1] === 0x3e) {
return cursor + 2;
}
while (cursor < buffer.length) {
if (buffer[cursor] !== 0x2d || buffer[cursor + 1] !== 0x2d) {
cursor += 1;
continue;
}
if (buffer[cursor + 2] === 0x3e) {
return cursor + 3;
}
// HTML also recovers `--!>` as an incorrectly closed comment.
if (buffer[cursor + 2] === 0x21 && buffer[cursor + 3] === 0x3e) {
return cursor + 4;
}
cursor += 2;
}
return undefined;
}
function sniffActiveTextContent(buffer: Buffer): string | undefined {
const decoded = decodeTextForActiveContentSniffing(buffer);
let cursor =
decoded.length >= 3 && decoded[0] === 0xef && decoded[1] === 0xbb && decoded[2] === 0xbf
? 3
: 0;
// A payload is treated as an active document only when markup is its root,
// after optional whitespace/comments. This avoids rejecting passive source
// and prose files merely because they contain a literal tag later on.
while (cursor < decoded.length) {
cursor = skipAsciiWhitespace(decoded, cursor);
if (decoded[cursor] === 0x3c && decoded[cursor + 1] === 0x3f) {
return "application/xml";
}
if (startsWithAsciiIgnoreCase(decoded, cursor, "<!--")) {
const end = skipRootHtmlComment(decoded, cursor);
if (end === undefined) {
return undefined;
}
cursor = end;
continue;
}
if (startsWithAsciiIgnoreCase(decoded, cursor, "<!doctype")) {
return "application/xml";
}
const rootTag = readAsciiRootTag(decoded, cursor);
if (rootTag) {
return rootTag === "svg" ? "image/svg+xml" : "text/html";
}
// A declaration or closing tag is still a markup-document root even when
// it is malformed or precedes a later executable element. Reject every
// remaining root-level opener instead of trying to parse HTML recovery.
if (decoded[cursor] === 0x3c) {
return "text/html";
}
return undefined;
}
return undefined;
}
function detectActiveContentType(params: {
buffer: Buffer;
contentType?: string;
fileName?: string;
}): string | undefined {
const declaredType = normalizeMimeType(params.contentType);
if (declaredType && ACTIVE_CONTENT_TYPES.has(declaredType)) {
return declaredType;
}
const fileNameType = normalizeMimeType(mimeTypeFromFilePath(params.fileName));
if (fileNameType && ACTIVE_CONTENT_TYPES.has(fileNameType)) {
return fileNameType;
}
return sniffActiveTextContent(params.buffer);
}
export async function prepareSynologyHostedMedia(params: {
account: ResolvedSynologyChatAccount;
mediaUrl: string;
mediaAccess?: OutboundMediaLoadOptions["mediaAccess"];
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
}): Promise<PreparedSynologyHostedMedia> {
const route = resolveSynologyHostedMediaRoute(params.account);
// Synchronize runtime-owned stores and counters before admitting work. A
// runtime change clears stale leases, so doing this after acquisition would
// erase the current request's slot.
const store = getHostedMediaStore(params.account.accountId);
if (!preparationLimiter.tryAcquire(params.account.accountId)) {
throw new Error(
"Synology Chat attachment preparation is busy. Retry after the current attachments finish preparing.",
);
}
try {
await store.cleanupExpired();
const stagedUrl = new URL(
await store.prepareUrl({
mediaUrl: params.mediaUrl,
routePath: route.localRoutePath,
publicBaseUrl: route.publicBaseUrl,
maxBytes: SYNOLOGY_OUTBOUND_MEDIA_MAX_BYTES,
mediaAccess: normalizeMediaAccess(params),
requestInit: { signal: AbortSignal.timeout(SYNOLOGY_OUTBOUND_MEDIA_PREPARE_TIMEOUT_MS) },
validateBeforePersist: (media) => {
const activeContentType = detectActiveContentType(media);
if (activeContentType) {
throw new Error(
`Synology Chat attachments do not support active content type ${activeContentType}.`,
);
}
},
}),
);
const id = stagedUrl.pathname.split("/").at(-1) ?? "";
const token = stagedUrl.searchParams.get("token");
if (!SYNOLOGY_OUTBOUND_MEDIA_ID_RE.test(id) || !token) {
throw new Error("Synology Chat attachment capability could not be prepared.");
}
const cleanup = createCleanup(store, id);
const tokenParam = `${SYNOLOGY_HOSTED_MEDIA_TOKEN_PARAM_PREFIX}_${id}`;
const querySeparator = route.publicSearch ? "&" : "?";
return {
url: `${route.publicBaseUrl}${route.publicRoutePath}${route.publicSearch}${querySeparator}${tokenParam}=${encodeURIComponent(token)}` as SynologyHostedMediaUrl,
cleanup,
};
} finally {
preparationLimiter.release(params.account.accountId);
}
}
export async function tryHandleSynologyHostedMediaRequest(
req: IncomingMessage,
res: ServerResponse,
account: ResolvedSynologyChatAccount,
): Promise<boolean> {
let url: URL;
try {
url = new URL(req.url ?? "/", "http://localhost");
} catch {
return false;
}
const tokenCandidates = [...url.searchParams.entries()]
.filter(([key]) => key.startsWith(`${SYNOLOGY_HOSTED_MEDIA_TOKEN_PARAM_PREFIX}_`))
.map(([key, token]) => ({
id: key.slice(SYNOLOGY_HOSTED_MEDIA_TOKEN_PARAM_PREFIX.length + 1),
token,
}))
.filter((candidate) => SYNOLOGY_OUTBOUND_MEDIA_ID_RE.test(candidate.id));
if (tokenCandidates.length === 0) {
return false;
}
if (tokenCandidates.length !== 1) {
res.statusCode = 400;
res.end("Bad Request");
return true;
}
const method = req.method ?? "GET";
if (method !== "GET" && method !== "HEAD") {
res.statusCode = 405;
res.setHeader("Allow", "GET, HEAD");
res.end("Method Not Allowed");
return true;
}
const candidate = tokenCandidates[0];
if (!candidate) {
return false;
}
// Runtime replacement resets the process-local limiter state. Resolve the
// matching store first so the lease acquired below belongs to that runtime.
const store = getHostedMediaStore(account.accountId);
if (!servingLimiter.tryAcquire(account.accountId)) {
res.statusCode = 503;
res.setHeader("Retry-After", "1");
res.end("Attachment temporarily unavailable");
return true;
}
let responseOwnsServingLease = false;
let rollbackServedBytes: (() => void) | undefined;
let entry: HostedOutboundMediaEntry | null | undefined;
const servingLease = holdServingLeaseUntilResponseDone(res, account.accountId);
try {
const routePath = toSynologyHostedMediaStoreRoutePath(url.pathname);
const metadata = await store.readMetadata(candidate.id);
if (!servingLease.isActive() || res.destroyed || res.writableEnded) {
return true;
}
if (!metadata || metadata.routePath !== routePath) {
res.statusCode = 404;
res.end("Not Found");
return true;
}
if (!safeEqualSecret(candidate.token, metadata.token)) {
res.statusCode = 401;
res.end("Unauthorized");
return true;
}
if (method === "GET") {
// Authenticate and reserve from metadata before reading stored chunks.
// Rejected over-budget requests must not force SQLite payload reads.
rollbackServedBytes = reserveServedBytes(account.accountId, metadata.byteLength);
if (!rollbackServedBytes) {
res.statusCode = 429;
res.setHeader("Retry-After", "60");
res.end("Attachment download limit exceeded");
return true;
}
entry = await store.read(candidate.id);
if (!servingLease.isActive() || res.destroyed || res.writableEnded) {
return true;
}
if (!entry) {
res.statusCode = 404;
res.end("Not Found");
return true;
}
}
for (const [name, value] of Object.entries(
buildHostedOutboundMediaResponseHeaders(metadata, {
fallbackFileName: `attachment-${candidate.id.slice(0, 10)}.bin`,
}),
)) {
res.setHeader(name, value);
}
res.statusCode = 200;
res.setHeader("Accept-Ranges", "none");
responseOwnsServingLease = true;
// An authenticated GET consumes its bandwidth budget even if the client
// disconnects mid-stream; otherwise retries can bypass the served-byte cap.
rollbackServedBytes = undefined;
if (entry) {
try {
await writeHostedMediaChunk(res, entry.buffer);
} catch {
if (!res.destroyed) {
res.destroy();
}
return true;
}
}
res.end();
return true;
} finally {
rollbackServedBytes?.();
if (!responseOwnsServingLease) {
servingLease.release();
}
}
}
@@ -12,6 +12,7 @@ function createAccount(params: {
enabled: true,
token: "t",
incomingUrl: "https://nas.example.com/incoming",
webhookUrl: "https://gateway.example.com/webapi/entry.cgi",
nasHost: "https://nas.example.com",
webhookPath: "/webapi/entry.cgi",
webhookPathSource: "explicit",
@@ -21,6 +21,7 @@ import {
normalizeStringEntries,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { listAccountIds, resolveAccount } from "./accounts.js";
import { resolveSynologyHostedMediaRoute } from "./hosted-media-route.js";
import type { SynologyChatAccountRaw, SynologyChatChannelConfig } from "./types.js";
const t = createSetupTranslator();
@@ -30,6 +31,7 @@ const DEFAULT_WEBHOOK_PATH = "/webhook/synology";
type SynologyChatSetupInput = ChannelSetupInput & {
url?: string;
webhookUrl?: string;
webhookPath?: string;
};
@@ -129,6 +131,19 @@ function validateWebhookUrl(value: string): string | undefined {
return undefined;
}
function validatePublicWebhookUrl(value: string): string | undefined {
const trimmed = value.trim();
if (!trimmed) {
return undefined;
}
try {
resolveSynologyHostedMediaRoute({ webhookUrl: trimmed, webhookPath: DEFAULT_WEBHOOK_PATH });
} catch (error) {
return error instanceof Error ? error.message : "Attachment webhook URL is invalid.";
}
return undefined;
}
function validateWebhookPath(value: string): string | undefined {
const trimmed = value.trim();
if (!trimmed) {
@@ -179,6 +194,12 @@ export const synologyChatSetupAdapter: ChannelSetupAdapter = {
if (urlError) {
return urlError;
}
if (setupInput.webhookUrl?.trim()) {
const webhookUrlError = validatePublicWebhookUrl(setupInput.webhookUrl);
if (webhookUrlError) {
return webhookUrlError;
}
}
if (setupInput.webhookPath?.trim()) {
return validateWebhookPath(setupInput.webhookPath.trim()) ?? null;
}
@@ -194,6 +215,7 @@ export const synologyChatSetupAdapter: ChannelSetupAdapter = {
patch: {
...(setupInput.useEnv ? {} : { token: setupInput.token?.trim() }),
incomingUrl: setupInput.url?.trim(),
...(setupInput.webhookUrl?.trim() ? { webhookUrl: setupInput.webhookUrl.trim() } : {}),
...(setupInput.webhookPath?.trim() ? { webhookPath: setupInput.webhookPath.trim() } : {}),
},
});
@@ -209,8 +231,17 @@ export const synologyChatSetupContract = defineChannelSetupContract({
},
url: {
kind: "string",
sensitive: true,
cli: { flags: "--url <url>", description: "Synology Chat webhook URL" },
},
webhookUrl: {
kind: "string",
sensitive: true,
cli: {
flags: "--webhook-url <url>",
description: "Public HTTPS Synology Chat callback URL used for attachments",
},
},
webhookPath: {
kind: "string",
cli: { flags: "--webhook-path <path>", description: "Synology Chat webhook path" },
@@ -307,6 +338,30 @@ export const synologyChatSetupWizard: ChannelSetupWizard = {
patch: { incomingUrl: value.trim() },
}),
},
{
inputKey: "webhookUrl",
message: t("wizard.synologyChat.publicWebhookUrlPrompt"),
placeholder: "https://gateway.example.com/webhook/synology",
required: false,
applyEmptyValue: true,
sensitive: true,
helpTitle: t("wizard.synologyChat.publicWebhookUrlTitle"),
helpLines: [
t("wizard.synologyChat.publicWebhookUrlHelp"),
t("wizard.synologyChat.publicWebhookUrlScope"),
],
currentValue: ({ cfg, accountId }) => getRawAccountConfig(cfg, accountId).webhookUrl?.trim(),
keepPrompt: t("wizard.synologyChat.publicWebhookUrlKeep"),
validate: ({ value }) => validatePublicWebhookUrl(value),
applySet: async ({ cfg, accountId, value }) =>
patchSynologyChatAccountConfig({
cfg,
accountId,
enabled: true,
clearFields: value.trim() ? undefined : ["webhookUrl"],
patch: value.trim() ? { webhookUrl: value.trim() } : {},
}),
},
{
inputKey: "webhookPath",
message: t("wizard.synologyChat.outgoingWebhookPathPrompt"),
@@ -45,28 +45,43 @@ export function makeStalledReq(
return makeBaseReq(method, opts);
}
export function makeRes(): ServerResponse & {
export function makeRes(options: { finishOnEnd?: boolean } = {}): ServerResponse & {
status: number;
body: string;
body: string | Buffer;
headers: Record<string, string>;
destroyed: boolean;
emit: (eventName: string) => boolean;
} {
const res = {
let headersSent = false;
const res = Object.assign(new EventEmitter(), {
status: 0,
body: "",
body: "" as string | Buffer,
headers: {} as Record<string, string>,
destroyed: false,
setHeader(name: string, value: string) {
res.headers[name.toLowerCase()] = value;
},
writeHead(statusCode: number, _headers?: Record<string, string>) {
res.status = statusCode;
},
end(body?: string) {
end(body?: string | Buffer) {
res.body = body ?? "";
headersSent = true;
if (options.finishOnEnd !== false) {
queueMicrotask(() => res.emit("finish"));
}
},
} as unknown as ServerResponse & {
destroy() {
res.destroyed = true;
res.emit("close");
return res;
},
}) as unknown as ServerResponse & {
status: number;
body: string;
body: string | Buffer;
headers: Record<string, string>;
destroyed: boolean;
emit: (eventName: string) => boolean;
};
Object.defineProperty(res, "statusCode", {
configurable: true,
@@ -78,6 +93,11 @@ export function makeRes(): ServerResponse & {
res.status = value;
},
});
Object.defineProperty(res, "headersSent", {
configurable: true,
enumerable: true,
get: () => headersSent,
});
return res;
}
+2
View File
@@ -6,6 +6,7 @@ type SynologyChatConfigFields = {
enabled?: boolean;
token?: string;
incomingUrl?: string;
webhookUrl?: string;
nasHost?: string;
webhookPath?: string;
dangerouslyAllowNameMatching?: boolean;
@@ -33,6 +34,7 @@ export interface ResolvedSynologyChatAccount {
enabled: boolean;
token: string;
incomingUrl: string;
webhookUrl: string;
nasHost: string;
webhookPath: string;
webhookPathSource: SynologyWebhookPathSource;
@@ -86,6 +86,7 @@ function makeAccount(
enabled: true,
token: "valid-token",
incomingUrl: "https://nas.example.com/incoming",
webhookUrl: "https://gateway.example.com/webhook/synology",
nasHost: "nas.example.com",
webhookPath: "/webhook/synology",
webhookPathSource: "default",
@@ -2333,11 +2333,21 @@
{
"key": "url",
"kind": "string",
"sensitive": true,
"cli": {
"flags": "--url <url>",
"description": "Synology Chat webhook URL"
}
},
{
"key": "webhookUrl",
"kind": "string",
"sensitive": true,
"cli": {
"flags": "--webhook-url <url>",
"description": "Public HTTPS Synology Chat callback URL used for attachments"
}
},
{
"key": "webhookPath",
"kind": "string",
File diff suppressed because one or more lines are too long
+10 -4
View File
@@ -112,15 +112,21 @@ const FIELD_PLACEHOLDERS: Record<string, string> = {
};
const CHANNEL_NAMESPACE_PREFIX = "channels.";
const CHANNEL_KERNEL_HINT_PREFIXES = ["channels.defaults", "channels.modelByChannel"] as const;
const CHANNEL_KERNEL_CONFIG_KEYS = new Set(["defaults", "modelByChannel"]);
/** Return whether a channel config key names a kernel-owned namespace. */
export function isKernelOwnedChannelConfigKey(key: string): boolean {
return CHANNEL_KERNEL_CONFIG_KEYS.has(key);
}
function isKernelOwnedChannelHintPath(path: string): boolean {
if (path === "channels") {
return true;
}
return CHANNEL_KERNEL_HINT_PREFIXES.some(
(prefix) => path === prefix || path.startsWith(`${prefix}.`),
);
const channelKey = path.startsWith(CHANNEL_NAMESPACE_PREFIX)
? path.slice(CHANNEL_NAMESPACE_PREFIX.length).split(".", 1)[0]
: undefined;
return channelKey !== undefined && isKernelOwnedChannelConfigKey(channelKey);
}
/** Return whether a channel hint path belongs to a plugin-owned channel namespace. */
+35
View File
@@ -19,6 +19,41 @@ describe("schema.shared", () => {
});
});
it("inherits the most specific ancestor hint when requested", () => {
const match = findWildcardHintMatch({
uiHints: {
"plugins.entries.*.config.headers": { sensitive: true },
"plugins.entries.*.config.headers.*": {},
"plugins.entries.codex.config.headers.Public": { sensitive: false },
},
path: "plugins.entries.codex.config.headers.Authorization",
splitPath: (value) => value.split("."),
includeAncestors: true,
acceptHint: (hint) => hint.sensitive !== undefined,
});
expect(match).toEqual({
path: "plugins.entries.*.config.headers",
hint: { sensitive: true },
});
expect(
findWildcardHintMatch({
uiHints: {
"plugins.entries.*.config.headers": { sensitive: true },
"plugins.entries.*.config.headers.*": {},
"plugins.entries.codex.config.headers.Public": { sensitive: false },
},
path: "plugins.entries.codex.config.headers.Public",
splitPath: (value) => value.split("."),
includeAncestors: true,
acceptHint: (hint) => hint.sensitive !== undefined,
}),
).toEqual({
path: "plugins.entries.codex.config.headers.Public",
hint: { sensitive: false },
});
});
it("treats branch schemas as having children", () => {
expect(
schemaHasChildren({
+21 -5
View File
@@ -8,6 +8,7 @@ export type ConfigJsonSchemaObject = Record<string, unknown> & {
properties?: Record<string, ConfigJsonSchemaObject>;
required?: string[];
additionalProperties?: ConfigJsonSchemaObject | boolean;
propertyNames?: ConfigJsonSchemaObject | boolean;
items?: ConfigJsonSchemaObject | ConfigJsonSchemaObject[];
anyOf?: ConfigJsonSchemaObject[];
allOf?: ConfigJsonSchemaObject[];
@@ -47,20 +48,30 @@ export function schemaHasChildren(schema: ConfigJsonSchemaObject): boolean {
export function findWildcardHintMatch<T>(params: {
uiHints: Record<string, T>;
path: string;
targetParts?: readonly string[];
splitPath: (path: string) => string[];
includeAncestors?: boolean;
acceptHint?: (hint: T) => boolean;
}): { path: string; hint: T } | null {
const targetParts = params.splitPath(params.path);
const targetParts = params.targetParts ?? params.splitPath(params.path);
let bestMatch:
| {
path: string;
hint: T;
partCount: number;
wildcardCount: number;
}
| undefined;
for (const [hintPath, hint] of Object.entries(params.uiHints)) {
if (params.acceptHint && !params.acceptHint(hint)) {
continue;
}
const hintParts = params.splitPath(hintPath);
if (hintParts.length !== targetParts.length) {
if (
hintParts.length > targetParts.length ||
(!params.includeAncestors && hintParts.length !== targetParts.length)
) {
continue;
}
@@ -83,9 +94,14 @@ export function findWildcardHintMatch<T>(params: {
if (!matches) {
continue;
}
// Fewer wildcards means the hint is closer to the concrete path and should win.
if (!bestMatch || wildcardCount < bestMatch.wildcardCount) {
bestMatch = { path: hintPath, hint, wildcardCount };
// The deepest hint lets an explicit child override an inherited sensitive parent;
// choosing the parent instead could either leak the child or over-redact it.
if (
!bestMatch ||
hintParts.length > bestMatch.partCount ||
(hintParts.length === bestMatch.partCount && wildcardCount < bestMatch.wildcardCount)
) {
bestMatch = { path: hintPath, hint, partCount: hintParts.length, wildcardCount };
}
}
+137
View File
@@ -669,6 +669,20 @@ function resolveLookupChildSchema(
return items;
}
for (const key of LOOKUP_SCHEMA_COMPOSITION_KEYS) {
const variants = schema[key];
if (!Array.isArray(variants)) {
continue;
}
for (const variant of variants) {
const variantSchema = asSchemaObject(variant);
const resolved = variantSchema ? resolveLookupChildSchema(variantSchema, segment) : null;
if (resolved) {
return resolved;
}
}
}
if (schema.additionalProperties && typeof schema.additionalProperties === "object") {
return schema.additionalProperties;
}
@@ -676,6 +690,129 @@ function resolveLookupChildSchema(
return null;
}
type ConfigSchemaPathSegmentKind = "property" | "record-key" | "array-index" | "invalid-record-key";
function classifyLookupChildSchema(
schema: JsonSchemaObject,
segment: string,
): ConfigSchemaPathSegmentKind | null {
if (schema.properties && Object.hasOwn(schema.properties, segment)) {
return "property";
}
if (parseConfigPathArrayIndex(segment) !== undefined && resolveItemsSchema(schema)) {
return "array-index";
}
for (const key of LOOKUP_SCHEMA_COMPOSITION_KEYS) {
const variants = schema[key];
if (!Array.isArray(variants)) {
continue;
}
for (const variant of variants) {
const variantSchema = asSchemaObject(variant);
const kind = variantSchema ? classifyLookupChildSchema(variantSchema, segment) : null;
if (kind) {
return kind;
}
}
}
if (schema.additionalProperties === true || typeof schema.additionalProperties === "object") {
return propertyNameSchemaAllows(schema.propertyNames, segment)
? "record-key"
: "invalid-record-key";
}
return null;
}
const PROPERTY_NAME_SCHEMA_KEYS = new Set([
"$id",
"$schema",
"title",
"description",
"type",
"const",
"enum",
"pattern",
"minLength",
"maxLength",
"anyOf",
"oneOf",
"allOf",
]);
function propertyNameSchemaAllows(schema: unknown, value: string): boolean {
if (schema === undefined || schema === true) {
return true;
}
if (schema === false) {
return false;
}
const object = asSchemaObject(schema);
if (!object || Object.keys(object).some((key) => !PROPERTY_NAME_SCHEMA_KEYS.has(key))) {
return false;
}
const types = Array.isArray(object.type) ? object.type : [object.type];
if (object.type !== undefined && !types.includes("string")) {
return false;
}
if (object.const !== undefined && object.const !== value) {
return false;
}
if (Array.isArray(object.enum) && !object.enum.includes(value)) {
return false;
}
if (typeof object.minLength === "number" && value.length < object.minLength) {
return false;
}
if (typeof object.maxLength === "number" && value.length > object.maxLength) {
return false;
}
if (typeof object.pattern === "string") {
try {
if (!new RegExp(object.pattern).test(value)) {
return false;
}
} catch {
return false;
}
}
if (object.allOf?.some((candidate) => !propertyNameSchemaAllows(candidate, value))) {
return false;
}
if (
object.anyOf &&
!object.anyOf.some((candidate) => propertyNameSchemaAllows(candidate, value))
) {
return false;
}
if (
object.oneOf &&
object.oneOf.filter((candidate) => propertyNameSchemaAllows(candidate, value)).length !== 1
) {
return false;
}
return true;
}
/** Classify one already-parsed path segment without losing dots inside record keys. */
export function classifyConfigSchemaPathSegment(
response: ConfigSchemaResponse,
parentParts: readonly string[],
segment: string,
): ConfigSchemaPathSegmentKind | null {
let current = asSchemaObject(response.schema);
if (!current) {
return null;
}
for (const parentPart of parentParts) {
const next = resolveLookupChildSchema(current, parentPart);
if (!next) {
return null;
}
current = next;
}
return classifyLookupChildSchema(current, segment);
}
function stripSchemaForLookup(schema: JsonSchemaObject, nestedFormDepth = 0): JsonSchemaNode {
const next: JsonSchemaNode = {};
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { buildHostedOutboundMediaResponseHeaders } from "./outbound-media.js";
describe("buildHostedOutboundMediaResponseHeaders", () => {
it("creates download-only no-sniff headers with a sanitized UTF-8 filename", () => {
const headers = buildHostedOutboundMediaResponseHeaders({
byteLength: 123,
contentType: "application/pdf; charset=binary",
fileName: '../測試\r\nX-Evil: yes/"plan".pdf',
});
expect(headers).toMatchObject({
"Content-Type": "application/pdf",
"Content-Length": "123",
"Cache-Control": "no-store",
"X-Content-Type-Options": "nosniff",
});
expect(headers["Content-Disposition"]).toContain("attachment");
expect(headers["Content-Disposition"]).toContain("filename*=UTF-8''");
expect(headers["Content-Disposition"]).not.toMatch(/[\r\n]/u);
});
});
@@ -0,0 +1,230 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type {
HostedOutboundMediaChunkRecord,
HostedOutboundMediaMetaRecord,
} from "./outbound-media.js";
import { createHostedOutboundMediaStore } from "./outbound-media.js";
import {
createPluginStateKeyedStoreForTests,
resetPluginStateStoreForTests,
} from "./plugin-state-test-runtime.js";
import * as webMedia from "./web-media.js";
const MEDIA_ID = "abc123abc123abc123abc123";
function prepare(store: ReturnType<typeof createHostedOutboundMediaStore>) {
return store.prepareUrl({
mediaUrl: "https://example.com/photo.png",
routePath: "/hook/media/",
publicBaseUrl: "https://gateway.example.com",
maxBytes: 1024,
});
}
describe("hosted outbound media post-expiry retention", () => {
beforeEach(() => {
resetPluginStateStoreForTests();
vi.restoreAllMocks();
vi.useFakeTimers();
vi.setSystemTime(1_000);
vi.spyOn(webMedia, "loadWebMedia").mockResolvedValue({
buffer: Buffer.from("image-bytes"),
kind: "image",
contentType: "image/png",
});
});
afterEach(() => {
vi.useRealTimers();
});
it("denies new reads at logical expiry and deletes rows after serving grace", async () => {
const metadataStore = createPluginStateKeyedStoreForTests<HostedOutboundMediaMetaRecord>(
"fixture-plugin",
{ namespace: "retained-ttl-media", maxEntries: 10 },
);
const chunkStore = createPluginStateKeyedStoreForTests<HostedOutboundMediaChunkRecord>(
"fixture-plugin",
{ namespace: "retained-ttl-media-chunks", maxEntries: 100 },
);
const store = createHostedOutboundMediaStore({
metadataStore,
chunkStore,
ttlMs: 100,
postExpiryRetentionMs: 100,
resolveExpiresAtMs: (ttlMs) => Date.now() + ttlMs,
createId: () => MEDIA_ID,
createToken: () => "token123",
rawChunkBytes: 4,
maxEntries: 10,
maxChunkRows: 100,
});
await prepare(store);
vi.setSystemTime(1_101);
await expect(store.readMetadata(MEDIA_ID)).resolves.toBeNull();
await store.cleanupExpired();
expect(await metadataStore.entries()).toHaveLength(1);
expect(await chunkStore.entries()).toHaveLength(3);
vi.setSystemTime(1_201);
await store.cleanupExpired();
expect(await metadataStore.entries()).toEqual([]);
expect(await chunkStore.entries()).toEqual([]);
});
it("counts retained rows under reject-new capacity", async () => {
const ids = [
"111111111111111111111111",
"222222222222222222222222",
"333333333333333333333333",
];
let idIndex = 0;
const store = createHostedOutboundMediaStore({
metadataStore: createPluginStateKeyedStoreForTests("fixture-plugin", {
namespace: "grace-capacity-media",
maxEntries: 1,
overflowPolicy: "reject-new",
}),
chunkStore: createPluginStateKeyedStoreForTests("fixture-plugin", {
namespace: "grace-capacity-media-chunks",
maxEntries: 10,
overflowPolicy: "reject-new",
}),
ttlMs: 100,
postExpiryRetentionMs: 100,
resolveExpiresAtMs: (ttlMs) => Date.now() + ttlMs,
createId: () => ids[idIndex++] ?? "444444444444444444444444",
createToken: () => "token123",
rawChunkBytes: 4,
maxEntries: 1,
maxChunkRows: 10,
overflowPolicy: "reject-new",
});
await expect(prepare(store)).resolves.toContain(ids[0]);
vi.setSystemTime(1_101);
await expect(prepare(store)).rejects.toThrow("capacity is full");
vi.setSystemTime(1_201);
await expect(prepare(store)).resolves.toContain(ids[2]);
});
});
describe("hosted outbound media aggregate byte capacity", () => {
beforeEach(() => {
resetPluginStateStoreForTests();
vi.restoreAllMocks();
vi.useFakeTimers();
vi.setSystemTime(1_000);
vi.spyOn(webMedia, "loadWebMedia").mockResolvedValue({
buffer: Buffer.from("image-bytes"),
kind: "image",
contentType: "image/png",
});
});
afterEach(() => {
vi.useRealTimers();
});
it("rejects a new entry without evicting a live capability", async () => {
let id = 0;
const ids = ["111111111111111111111111", "222222222222222222222222"];
const store = createHostedOutboundMediaStore({
metadataStore: createPluginStateKeyedStoreForTests("fixture-plugin", {
namespace: "aggregate-byte-media",
maxEntries: 2,
overflowPolicy: "reject-new",
}),
chunkStore: createPluginStateKeyedStoreForTests("fixture-plugin", {
namespace: "aggregate-byte-media-chunks",
maxEntries: 4,
overflowPolicy: "reject-new",
}),
ttlMs: 120_000,
resolveExpiresAtMs: () => Date.now() + 120_000,
createId: () => ids[id++] ?? "ffffffffffffffffffffffff",
createToken: () => "token123",
rawChunkBytes: 4,
maxEntries: 2,
maxChunkRows: 4,
maxTotalBytes: 5,
overflowPolicy: "reject-new",
});
vi.mocked(webMedia.loadWebMedia).mockResolvedValue({
buffer: Buffer.from("abc"),
kind: "image",
contentType: "image/png",
});
await store.prepareUrl({
mediaUrl: "https://example.com/first.png",
routePath: "/hook/media/",
publicBaseUrl: "https://gateway.example.com",
maxBytes: 10,
});
await expect(
store.prepareUrl({
mediaUrl: "https://example.com/second.png",
routePath: "/hook/media/",
publicBaseUrl: "https://gateway.example.com",
maxBytes: 10,
}),
).rejects.toThrow("hosted outbound media capacity is full");
expect(await store.read(ids[0] ?? "")).not.toBeNull();
expect(await store.read(ids[1] ?? "")).toBeNull();
});
it("rejects an individually oversized entry before evicting live capabilities", async () => {
let id = 0;
const ids = ["333333333333333333333333", "444444444444444444444444"];
const store = createHostedOutboundMediaStore({
metadataStore: createPluginStateKeyedStoreForTests("fixture-plugin", {
namespace: "evicting-byte-media",
maxEntries: 2,
}),
chunkStore: createPluginStateKeyedStoreForTests("fixture-plugin", {
namespace: "evicting-byte-media-chunks",
maxEntries: 4,
}),
ttlMs: 120_000,
resolveExpiresAtMs: () => Date.now() + 120_000,
createId: () => ids[id++] ?? "ffffffffffffffffffffffff",
createToken: () => "token123",
rawChunkBytes: 4,
maxEntries: 2,
maxChunkRows: 4,
maxTotalBytes: 5,
overflowPolicy: "evict-oldest",
});
vi.mocked(webMedia.loadWebMedia)
.mockResolvedValueOnce({
buffer: Buffer.from("abc"),
kind: "image",
contentType: "image/png",
})
.mockResolvedValueOnce({
buffer: Buffer.from("abcdef"),
kind: "image",
contentType: "image/png",
});
await store.prepareUrl({
mediaUrl: "https://example.com/first.png",
routePath: "/hook/media/",
publicBaseUrl: "https://gateway.example.com",
maxBytes: 10,
});
await expect(
store.prepareUrl({
mediaUrl: "https://example.com/oversized.png",
routePath: "/hook/media/",
publicBaseUrl: "https://gateway.example.com",
maxBytes: 10,
}),
).rejects.toThrow("payload exceeds aggregate byte capacity");
expect(await store.read(ids[0] ?? "")).not.toBeNull();
expect(await store.read(ids[1] ?? "")).toBeNull();
});
});
+149 -28
View File
@@ -8,21 +8,16 @@ 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();
});
@@ -39,6 +34,7 @@ describe("loadOutboundMediaFromUrl", () => {
buffer: Buffer.from("x"),
kind: "image",
contentType: "image/png",
fileName: "floor-plan.png",
});
await loadOutboundMediaFromUrl("file:///tmp/image.png", {
@@ -121,24 +117,40 @@ describe("loadOutboundMediaFromUrl", () => {
});
describe("createHostedOutboundMediaStore", () => {
function createStore() {
return createHostedOutboundMediaStore({
metadataStore: createPluginStateKeyedStoreForTests("fixture-plugin", {
namespace: "hosted-media",
function createStoreFixture(namespace = "hosted-media") {
const metadataStore = createPluginStateKeyedStoreForTests<HostedOutboundMediaMetaRecord>(
"fixture-plugin",
{
namespace,
maxEntries: 10,
}),
chunkStore: createPluginStateKeyedStoreForTests("fixture-plugin", {
namespace: "hosted-media-chunks",
},
);
const chunkStore = createPluginStateKeyedStoreForTests<HostedOutboundMediaChunkRecord>(
"fixture-plugin",
{
namespace: `${namespace}-chunks`,
maxEntries: 100,
},
);
return {
metadataStore,
chunkStore,
store: createHostedOutboundMediaStore({
metadataStore,
chunkStore,
ttlMs: 120_000,
resolveExpiresAtMs: () => Date.now() + 120_000,
createId: () => "abc123abc123abc123abc123",
createToken: () => "token123",
rawChunkBytes: 4,
maxEntries: 10,
maxChunkRows: 100,
}),
ttlMs: 120_000,
resolveExpiresAtMs: () => Date.now() + 120_000,
createId: () => "abc123abc123abc123abc123",
createToken: () => "token123",
rawChunkBytes: 4,
maxEntries: 10,
maxChunkRows: 100,
});
};
}
function createStore(namespace = "hosted-media") {
return createStoreFixture(namespace).store;
}
it("stores hosted media chunks and reads them back", async () => {
@@ -146,6 +158,7 @@ describe("createHostedOutboundMediaStore", () => {
buffer: Buffer.from("image-bytes"),
kind: "image",
contentType: "image/png",
fileName: "floor-plan.png",
});
const store = createStore();
@@ -164,11 +177,125 @@ describe("createHostedOutboundMediaStore", () => {
routePath: "/hook/media/",
token: "token123",
contentType: "image/png",
fileName: "floor-plan.png",
byteLength: Buffer.byteLength("image-bytes"),
});
expect(entry?.buffer.toString("utf8")).toBe("image-bytes");
});
it("validates the loaded bytes before persisting a capability", async () => {
const media = {
buffer: Buffer.from("active-bytes"),
kind: undefined,
contentType: "text/html",
fileName: "active.html",
};
loadWebMediaMock.mockResolvedValueOnce(media);
const store = createStore("hosted-media-validation");
const validateBeforePersist = vi.fn(() => {
throw new Error("active content rejected");
});
await expect(
store.prepareUrl({
mediaUrl: "https://example.com/active.html",
routePath: "/hook/media/",
publicBaseUrl: "https://gateway.example.com",
maxBytes: 1024,
validateBeforePersist,
}),
).rejects.toThrow("active content rejected");
expect(validateBeforePersist).toHaveBeenCalledWith(
expect.objectContaining({
buffer: media.buffer,
contentType: "text/html",
fileName: "active.html",
}),
);
await expect(store.readMetadata("abc123abc123abc123abc123")).resolves.toBeNull();
});
it("does not return metadata when deletion starts during lookup", async () => {
loadWebMediaMock.mockResolvedValueOnce({
buffer: Buffer.from("image-bytes"),
kind: "image",
contentType: "image/png",
});
const { metadataStore, store } = createStoreFixture("pending-metadata-media");
await store.prepareUrl({
mediaUrl: "https://example.com/photo.png",
routePath: "/hook/media/",
publicBaseUrl: "https://gateway.example.com",
maxBytes: 1024,
});
const originalLookup = metadataStore.lookup.bind(metadataStore);
let markLookupStarted: (() => void) | undefined;
let releaseLookup: (() => void) | undefined;
const lookupStarted = new Promise<void>((resolve) => {
markLookupStarted = resolve;
});
const lookupReleased = new Promise<void>((resolve) => {
releaseLookup = resolve;
});
vi.spyOn(metadataStore, "lookup").mockImplementationOnce(async (key) => {
const result = await originalLookup(key);
markLookupStarted?.();
await lookupReleased;
return result;
});
const pendingMetadata = store.readMetadata("abc123abc123abc123abc123");
await lookupStarted;
await store.delete("abc123abc123abc123abc123");
releaseLookup?.();
await expect(pendingMetadata).resolves.toBeNull();
});
it("lets an admitted complete read finish before deleting its chunks", async () => {
loadWebMediaMock.mockResolvedValueOnce({
buffer: Buffer.from("image-bytes"),
kind: "image",
contentType: "image/png",
});
const { chunkStore, metadataStore, store } = createStoreFixture("atomic-reader-media");
await store.prepareUrl({
mediaUrl: "https://example.com/photo.png",
routePath: "/hook/media/",
publicBaseUrl: "https://gateway.example.com",
maxBytes: 1024,
});
const originalLookup = metadataStore.lookup.bind(metadataStore);
let markLookupStarted: (() => void) | undefined;
let releaseLookup: (() => void) | undefined;
const lookupStarted = new Promise<void>((resolve) => {
markLookupStarted = resolve;
});
const lookupReleased = new Promise<void>((resolve) => {
releaseLookup = resolve;
});
vi.spyOn(metadataStore, "lookup").mockImplementationOnce(async (key) => {
const result = await originalLookup(key);
markLookupStarted?.();
await lookupReleased;
return result;
});
const pendingRead = store.read("abc123abc123abc123abc123");
await lookupStarted;
await store.delete("abc123abc123abc123abc123");
expect(await metadataStore.entries()).toHaveLength(1);
expect(await chunkStore.entries()).toHaveLength(3);
releaseLookup?.();
await expect(pendingRead).resolves.toMatchObject({
buffer: Buffer.from("image-bytes"),
});
expect(await metadataStore.entries()).toEqual([]);
expect(await chunkStore.entries()).toEqual([]);
});
it("reads hosted metadata without hydrating chunk rows", async () => {
loadWebMediaMock.mockResolvedValueOnce({
buffer: Buffer.from("image-bytes"),
@@ -257,17 +384,11 @@ describe("createHostedOutboundMediaStore", () => {
});
const metadataStore = createPluginStateKeyedStoreForTests<HostedOutboundMediaMetaRecord>(
"fixture-plugin",
{
namespace: "ttl-media",
maxEntries: 10,
},
{ namespace: "ttl-media", maxEntries: 10 },
);
const chunkStore = createPluginStateKeyedStoreForTests<HostedOutboundMediaChunkRecord>(
"fixture-plugin",
{
namespace: "ttl-media-chunks",
maxEntries: 100,
},
{ namespace: "ttl-media-chunks", maxEntries: 100 },
);
const store = createHostedOutboundMediaStore({
metadataStore,
+251 -39
View File
@@ -1,5 +1,7 @@
// Outbound media helpers normalize plugin media attachments before channel delivery.
import { randomBytes } from "node:crypto";
import { normalizeMimeType } from "@openclaw/media-core/mime";
import { sanitizeUntrustedFileName } from "../infra/fs-safe-advanced.js";
import { buildOutboundMediaLoadOptions, type OutboundMediaAccess } from "../media/load-options.js";
import type { PluginStateKeyedStore } from "./plugin-state-runtime.js";
import { loadWebMedia } from "./web-media.js";
@@ -54,6 +56,7 @@ export type HostedOutboundMediaMetadata = {
routePath: string;
token: string;
contentType?: string;
fileName?: string;
expiresAt: number;
byteLength: number;
};
@@ -89,6 +92,13 @@ export type HostedOutboundMediaStore = {
/** Host-authorized local media access forwarded to the shared outbound loader. */
mediaAccess?: OutboundMediaAccess;
proxyUrl?: string;
requestInit?: RequestInit;
/** Validate the exact loaded bytes before capability creation or persistence. */
validateBeforePersist?: (media: {
buffer: Buffer;
contentType?: string;
fileName?: string;
}) => void | Promise<void>;
}) => Promise<string>;
readMetadata: (id: string, nowMs?: number) => Promise<HostedOutboundMediaMetadata | null>;
read: (id: string, nowMs?: number) => Promise<HostedOutboundMediaEntry | null>;
@@ -107,7 +117,11 @@ export type CreateHostedOutboundMediaStoreOptions = {
rawChunkBytes?: number;
maxEntries?: number;
maxChunkRows?: number;
/** Aggregate live payload budget. Omit only when the backing owner enforces an equivalent cap. */
maxTotalBytes?: number;
chunkRowsPerEntryBudget?: number;
/** Physical retention after logical URL expiry, used to finish already-admitted readers. */
postExpiryRetentionMs?: number;
/**
* Capacity action before storing a new entry. Defaults to `"evict-oldest"`.
* With `"reject-new"`, configure both backing stores to reject overflow too.
@@ -146,19 +160,28 @@ function parseHostedOutboundMediaMetaKey(key: string): string | undefined {
return id || undefined;
}
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 isRetainedHostedOutboundMediaExpiry(
expiresAt: unknown,
nowMs: number,
postExpiryRetentionMs: number,
): expiresAt is number {
return (
typeof expiresAt === "number" &&
Number.isSafeInteger(expiresAt) &&
(expiresAt > nowMs || nowMs - expiresAt < postExpiryRetentionMs)
);
}
function createHostedOutboundMediaMetaRecord(params: {
id: string;
routePath: string;
token: string;
contentType?: string;
fileName?: string;
expiresAt: number;
chunkCount: number;
byteLength: number;
@@ -168,6 +191,7 @@ function createHostedOutboundMediaMetaRecord(params: {
routePath: params.routePath,
token: params.token,
...(params.contentType ? { contentType: params.contentType } : {}),
...(params.fileName ? { fileName: params.fileName } : {}),
expiresAt: params.expiresAt,
chunkCount: params.chunkCount,
byteLength: params.byteLength,
@@ -181,6 +205,7 @@ function createHostedOutboundMediaMetadata(
routePath: meta.routePath,
token: meta.token,
...(meta.contentType ? { contentType: meta.contentType } : {}),
...(meta.fileName ? { fileName: meta.fileName } : {}),
expiresAt: meta.expiresAt,
byteLength: meta.byteLength,
};
@@ -214,18 +239,46 @@ export function createHostedOutboundMediaStore(
options.chunkRowsPerEntryBudget ?? DEFAULT_HOSTED_OUTBOUND_MEDIA_CHUNK_ROWS_PER_ENTRY_BUDGET;
const maxChunkRows = options.maxChunkRows ?? maxEntries * chunkRowsPerEntryBudget;
const overflowPolicy = options.overflowPolicy ?? "evict-oldest";
const postExpiryRetentionMs = options.postExpiryRetentionMs ?? 0;
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");
}
if (!Number.isSafeInteger(postExpiryRetentionMs) || postExpiryRetentionMs < 0) {
throw new Error("hosted outbound media postExpiryRetentionMs must be a non-negative integer");
}
if (
options.maxTotalBytes !== undefined &&
(!Number.isSafeInteger(options.maxTotalBytes) || options.maxTotalBytes < 1)
) {
throw new Error("hosted outbound media maxTotalBytes must be a positive integer");
}
if (overflowPolicy !== "evict-oldest" && overflowPolicy !== "reject-new") {
throw new Error("hosted outbound media overflowPolicy must be evict-oldest or reject-new");
}
const createId = options.createId ?? createHostedOutboundMediaId;
const createToken = options.createToken ?? createHostedOutboundMediaToken;
const chunkPhysicalTtlMs = options.ttlMs + postExpiryRetentionMs;
const metadataPhysicalTtlMs =
options.ttlMs +
Math.max(
postExpiryRetentionMs,
Math.min(options.ttlMs, HOSTED_OUTBOUND_MEDIA_METADATA_TTL_GRACE_MS),
);
if (
!Number.isSafeInteger(chunkPhysicalTtlMs) ||
chunkPhysicalTtlMs < 1 ||
!Number.isSafeInteger(metadataPhysicalTtlMs) ||
metadataPhysicalTtlMs < 1
) {
throw new Error("hosted outbound media physical TTL must be a positive safe integer");
}
let capacityMutation = Promise.resolve();
const activeReaders = new Map<string, number>();
const deferredDeletes = new Set<string>();
const deletingEntries = new Set<string>();
async function withCapacityMutation<T>(operation: () => Promise<T>): Promise<T> {
const result = capacityMutation.then(operation, operation);
@@ -236,8 +289,21 @@ export function createHostedOutboundMediaStore(
return await result;
}
async function deleteEntry(id: string): Promise<void> {
await deleteHostedOutboundMediaRows(id, options.metadataStore, options.chunkStore);
async function deleteEntry(id: string): Promise<boolean> {
// Deletion revokes the bearer capability immediately, even when an admitted
// reader keeps the physical rows alive until its stream closes.
deferredDeletes.add(id);
if ((activeReaders.get(id) ?? 0) > 0) {
return false;
}
deletingEntries.add(id);
try {
await deleteHostedOutboundMediaRows(id, options.metadataStore, options.chunkStore);
deferredDeletes.delete(id);
return true;
} finally {
deletingEntries.delete(id);
}
}
async function deleteEntryRows(id: string, chunkCount: number): Promise<void> {
@@ -253,7 +319,9 @@ export function createHostedOutboundMediaStore(
return null;
}
if (!isFutureHostedOutboundMediaExpiry(meta.expiresAt, nowMs)) {
await withCapacityMutation(async () => await deleteEntry(id));
if (!isRetainedHostedOutboundMediaExpiry(meta.expiresAt, nowMs, postExpiryRetentionMs)) {
await withCapacityMutation(async () => await deleteEntry(id));
}
return null;
}
return meta;
@@ -272,23 +340,69 @@ export function createHostedOutboundMediaStore(
await options.metadataStore.delete(row.key);
return;
}
for (let index = 0; index < row.value.chunkCount; index += 1) {
await options.chunkStore.delete(buildHostedOutboundMediaChunkKey(id, index));
}
await options.metadataStore.delete(row.key);
await deleteEntry(id);
}
async function cleanupExpired(nowMs = Date.now()): Promise<void> {
await withCapacityMutation(async () => {
for (const row of await options.metadataStore.entries()) {
if (!isFutureHostedOutboundMediaExpiry(row.value.expiresAt, nowMs)) {
if (
!isRetainedHostedOutboundMediaExpiry(row.value.expiresAt, nowMs, postExpiryRetentionMs)
) {
await deleteStoredRow(row);
}
}
});
}
async function pruneForCapacity(incomingChunkCount: number, nowMs = Date.now()): Promise<void> {
async function acquireReader(
id: string,
nowMs: number,
): Promise<{
meta: HostedOutboundMediaMetaRecord;
close: () => Promise<void>;
} | null> {
// Register before the first await so deletion observes pending SQLite readers
// without serializing concurrent capability authentication.
activeReaders.set(id, (activeReaders.get(id) ?? 0) + 1);
let closed = false;
const close = async () => {
if (closed) {
return;
}
closed = true;
const remaining = (activeReaders.get(id) ?? 1) - 1;
if (remaining > 0) {
activeReaders.set(id, remaining);
return;
}
activeReaders.delete(id);
if (deferredDeletes.has(id)) {
await withCapacityMutation(async () => await deleteEntry(id));
}
};
if (deferredDeletes.has(id) || deletingEntries.has(id)) {
await close();
return null;
}
const meta = await readMetadataRecord(id, nowMs);
if (!meta) {
await close();
return null;
}
return { meta, close };
}
async function pruneForCapacity(
incomingChunkCount: number,
incomingByteLength: number,
nowMs = Date.now(),
): Promise<void> {
if (options.maxTotalBytes !== undefined && incomingByteLength > options.maxTotalBytes) {
throw new Error(
`hosted outbound media payload exceeds aggregate byte capacity (${incomingByteLength}/${options.maxTotalBytes} bytes)`,
);
}
const rows = await options.metadataStore.entries();
const validRows = rows.filter((row) => {
const id = parseHostedOutboundMediaMetaKey(row.key);
@@ -298,7 +412,9 @@ export function createHostedOutboundMediaStore(
Number.isSafeInteger(row.value.chunkCount) &&
row.value.chunkCount > 0 &&
row.value.chunkCount <= maxChunkRows &&
isFutureHostedOutboundMediaExpiry(row.value.expiresAt, nowMs)
Number.isSafeInteger(row.value.byteLength) &&
row.value.byteLength >= 0 &&
isRetainedHostedOutboundMediaExpiry(row.value.expiresAt, nowMs, postExpiryRetentionMs)
);
});
const validKeys = new Set(validRows.map((row) => row.key));
@@ -312,27 +428,53 @@ export function createHostedOutboundMediaStore(
let entryCount = orderedRows.length;
let chunkCount = orderedRows.reduce((total, row) => total + row.value.chunkCount, 0);
let totalBytes = orderedRows.reduce((total, row) => total + row.value.byteLength, 0);
if (
overflowPolicy === "reject-new" &&
(entryCount >= maxEntries || chunkCount + incomingChunkCount > maxChunkRows)
(entryCount >= maxEntries ||
chunkCount + incomingChunkCount > maxChunkRows ||
(options.maxTotalBytes !== undefined &&
totalBytes + incomingByteLength > options.maxTotalBytes))
) {
throw new Error(
`hosted outbound media capacity is full (${entryCount}/${maxEntries} entries, ${
chunkCount + incomingChunkCount
}/${maxChunkRows} chunk rows)`,
}/${maxChunkRows} chunk rows, ${totalBytes + incomingByteLength}/${
options.maxTotalBytes ?? "unbounded"
} bytes)`,
);
}
for (const row of orderedRows) {
if (entryCount < maxEntries && chunkCount + incomingChunkCount <= maxChunkRows) {
if (
entryCount < maxEntries &&
chunkCount + incomingChunkCount <= maxChunkRows &&
(options.maxTotalBytes === undefined ||
totalBytes + incomingByteLength <= options.maxTotalBytes)
) {
break;
}
const id = parseHostedOutboundMediaMetaKey(row.key);
if (!id) {
continue;
}
await deleteEntry(id);
entryCount -= 1;
chunkCount -= row.value.chunkCount;
// Capacity eviction is speculative until a candidate has no admitted
// readers. Skip active capabilities instead of revoking them on failure.
if ((activeReaders.get(id) ?? 0) > 0) {
continue;
}
if (await deleteEntry(id)) {
entryCount -= 1;
chunkCount -= row.value.chunkCount;
totalBytes -= row.value.byteLength;
}
}
if (
entryCount >= maxEntries ||
chunkCount + incomingChunkCount > maxChunkRows ||
(options.maxTotalBytes !== undefined &&
totalBytes + incomingByteLength > options.maxTotalBytes)
) {
throw new Error("hosted outbound media capacity is full while active readers retain entries");
}
}
@@ -346,10 +488,11 @@ export function createHostedOutboundMediaStore(
maxBytes: params.maxBytes,
mediaAccess: params.mediaAccess,
...(params.proxyUrl ? { proxyUrl: params.proxyUrl } : {}),
...(params.requestInit ? { requestInit: params.requestInit } : {}),
});
await params.validateBeforePersist?.(media);
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(
@@ -359,7 +502,7 @@ export function createHostedOutboundMediaStore(
// Capacity check and writes stay serialized per helper instance. Cross-process
// callers rely on reject-new backing stores so a race cannot evict live URLs.
return await withCapacityMutation(async () => {
await pruneForCapacity(chunkCount);
await pruneForCapacity(chunkCount, media.buffer.byteLength);
try {
for (let index = 0; index < chunkCount; index += 1) {
const chunk = media.buffer.subarray(index * rawChunkBytes, (index + 1) * rawChunkBytes);
@@ -370,7 +513,7 @@ export function createHostedOutboundMediaStore(
index,
dataBase64: chunk.toString("base64"),
},
{ ttlMs: options.ttlMs },
{ ttlMs: chunkPhysicalTtlMs },
);
}
await options.metadataStore.register(
@@ -380,11 +523,12 @@ export function createHostedOutboundMediaStore(
routePath: params.routePath,
token,
contentType: media.contentType,
fileName: media.fileName,
expiresAt,
chunkCount,
byteLength: media.buffer.byteLength,
}),
{ ttlMs: metadataTtlMs },
{ ttlMs: metadataPhysicalTtlMs },
);
} catch (error) {
await deleteEntryRows(id, chunkCount);
@@ -394,29 +538,69 @@ export function createHostedOutboundMediaStore(
});
},
async readMetadata(id, nowMs = Date.now()) {
const meta = await readMetadataRecord(id, nowMs);
return meta ? createHostedOutboundMediaMetadata(meta) : null;
},
async read(id, nowMs = Date.now()) {
const meta = await readMetadataRecord(id, nowMs);
if (!meta) {
const reader = await acquireReader(id, nowMs);
if (!reader) {
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) {
try {
return deferredDeletes.has(id) || deletingEntries.has(id)
? null
: createHostedOutboundMediaMetadata(reader.meta);
} finally {
await reader.close();
}
},
async read(id, nowMs = Date.now()) {
const reader = await acquireReader(id, nowMs);
if (!reader) {
return null;
}
const { close, meta } = reader;
try {
const expectedChunkCount = Math.max(1, Math.ceil(meta.byteLength / rawChunkBytes));
if (
!Number.isSafeInteger(meta.byteLength) ||
meta.byteLength < 0 ||
meta.chunkCount !== expectedChunkCount ||
meta.chunkCount > maxChunkRows
) {
await withCapacityMutation(async () => await deleteEntry(id));
return null;
}
chunks.push(Buffer.from(chunk.dataBase64, "base64"));
const buffer = Buffer.allocUnsafe(meta.byteLength);
let offset = 0;
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 withCapacityMutation(async () => await deleteEntry(id));
return null;
}
const decoded = Buffer.from(chunk.dataBase64, "base64");
const expectedBytes =
index === meta.chunkCount - 1
? meta.byteLength - rawChunkBytes * (meta.chunkCount - 1)
: rawChunkBytes;
if (decoded.byteLength !== expectedBytes) {
await withCapacityMutation(async () => await deleteEntry(id));
return null;
}
decoded.copy(buffer, offset);
offset += decoded.byteLength;
}
return {
metadata: createHostedOutboundMediaMetadata(meta),
buffer,
};
} finally {
await close();
}
return {
metadata: createHostedOutboundMediaMetadata(meta),
buffer: Buffer.concat(chunks, meta.byteLength),
};
},
async delete(id) {
// Mark the capability before entering the async mutation queue so readers
// cannot slip in after revocation starts but before SQLite deletion runs.
deferredDeletes.add(id);
await withCapacityMutation(async () => await deleteEntry(id));
},
cleanupExpired,
@@ -427,3 +611,31 @@ export function createHostedOutboundMediaStore(
},
};
}
function encodeHostedOutboundMediaFileName(fileName: string): string {
return encodeURIComponent(fileName).replace(
/[\x27()*]/g,
(char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`,
);
}
/** Build download-only response headers for immutable hosted outbound media. */
export function buildHostedOutboundMediaResponseHeaders(
metadata: Pick<HostedOutboundMediaMetadata, "byteLength" | "contentType" | "fileName">,
options: { fallbackFileName?: string } = {},
): Record<string, string> {
const contentType =
normalizeMimeType(metadata.contentType?.split(";", 1)[0]?.trim()) ?? "application/octet-stream";
const fileName = sanitizeUntrustedFileName(
metadata.fileName ?? options.fallbackFileName ?? "attachment.bin",
"attachment.bin",
);
const asciiFallback = fileName.replace(/[^\x20-\x7e]|[%"\\]/g, "_").trim() || "attachment.bin";
return {
"Content-Type": contentType,
"Content-Length": String(metadata.byteLength),
"Content-Disposition": `attachment; filename="${asciiFallback}"; filename*=UTF-8''${encodeHostedOutboundMediaFileName(fileName)}`,
"Cache-Control": "no-store",
"X-Content-Type-Options": "nosniff",
};
}
+1
View File
@@ -9,6 +9,7 @@ export { withFetchPreconnect, type FetchMock } from "../test-utils/fetch-mock.js
export { createMockServerResponse } from "../test-utils/mock-http-response.js";
export { createTempHomeEnv, type TempHomeEnv } from "../test-utils/temp-home.js";
export { withTempDir } from "../test-utils/temp-dir.js";
export { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
export { useFrozenTime, useRealTime } from "../test-utils/frozen-time.js";
export { withServer } from "./test-helpers/http-test-server.js";
export { createMockIncomingRequest } from "./test-helpers/mock-incoming-request.js";
@@ -1211,10 +1211,6 @@ export function sweepExpiredPluginStateEntries(): number {
}
}
export function isPluginStateDatabaseOpen(): boolean {
return cachedDatabase?.db.isOpen === true;
}
export function clearPluginStateDatabaseForTests(): void {
const store = openPluginStateDatabase("clear");
executeSqliteQuerySync(
+3 -3
View File
@@ -10,6 +10,7 @@ import {
} from "../state/openclaw-quarantine-store.js";
import {
clearOpenClawStateDatabaseOpenFailure,
isOpenClawStateDatabaseOpen,
OPENCLAW_STATE_SCHEMA_VERSION,
openOpenClawStateDatabase,
recordOpenClawStateDatabaseOpenFailure,
@@ -26,7 +27,6 @@ import {
createCorePluginStateSyncKeyedStore,
createPluginStateKeyedStore,
createPluginStateSyncKeyedStore,
isPluginStateDatabaseOpen,
pluginStateEntriesInKeyRange,
registerPluginStateSyncSequencedJournalEntry,
resetPluginStateStoreForTests,
@@ -889,7 +889,7 @@ describe("plugin state keyed store", () => {
await store.register("k", { ok: true });
resetPluginStateStoreForTests();
expect(isPluginStateDatabaseOpen()).toBe(false);
expect(isOpenClawStateDatabaseOpen()).toBe(false);
await expect(store.lookup("k")).resolves.toEqual({ ok: true });
await expect(store.entries()).resolves.toMatchObject([{ key: "k", value: { ok: true } }]);
expect(
@@ -902,7 +902,7 @@ describe("plugin state keyed store", () => {
}),
).toMatchObject([{ key: "k", value: { ok: true } }]);
expect(countPluginStateLiveEntries("discord")).toBe(1);
expect(isPluginStateDatabaseOpen()).toBe(false);
expect(isOpenClawStateDatabaseOpen()).toBe(false);
});
});
-1
View File
@@ -46,7 +46,6 @@ export {
closePluginStateDatabase,
countPluginStateLiveEntries,
getPluginStateCapacity,
isPluginStateDatabaseOpen,
MAX_PLUGIN_STATE_ENTRIES_PER_PLUGIN,
pluginStateEntriesInKeyRange,
resolveMaxPluginStateEntriesPerPlugin,
+6 -1
View File
@@ -138,7 +138,12 @@ export class SystemAgentChatEngine {
}
seedHistory(turns: readonly SystemAgentAssistantTurn[]): void {
this.history.push(...turns.map((turn) => ({ ...turn })));
this.history.push(
...turns.map((turn) => ({
...turn,
text: turn.role === "user" ? redactSensitiveCommandText(turn.text) : turn.text,
})),
);
}
historyLength(): number {
@@ -651,7 +651,19 @@ describe("SystemAgentChatEngine approval", () => {
expect(observedInput).toContain("<redacted>");
});
it("keeps an exact sensitive config set away from every model path", async () => {
it.each([
"channels.synology-chat.webhookUrl",
"channels.synology-chat[webhookUrl]",
"channels.synology-chat.accounts[work].webhookUrl",
'channels.synology-chat.accounts["prod.guild"].webhookUrl',
'channels.synology-chat.accounts["prod=us"].webhookUrl',
String.raw`channels.synology-chat.accounts.prod\ guild.webhookUrl`,
"channels.synology-chat.incomingUrl",
"channels.synology-chat.accounts[work].incomingUrl",
"plugins.entries.codex.config.appServer.headers",
"plugins.entries.codex.config.appServer.headers.Authorization",
"channels.synology-chat",
])("keeps hint-sensitive config set %s away from every model path", async (path) => {
useTempStateDir();
const runAgentTurn = vi.fn(async () => ({ text: "should never run" }));
const planner = vi.fn(async () => ({ reply: "should never run" }));
@@ -662,7 +674,11 @@ describe("SystemAgentChatEngine approval", () => {
deps: { runConfigSet, loadOverview: fakeOverviewLoader() },
});
const proposed = await engine.handle("config set channels.telegram.botToken 123:very-secret");
const value =
path === "channels.synology-chat"
? '{ webhookUrl: "https://gateway.example/webhook/synology?access_token=very-secret" }'
: "https://gateway.example/webhook/synology?access_token=very-secret";
const proposed = await engine.handle(`config set ${path} ${value}`);
expect(runAgentTurn).not.toHaveBeenCalled();
expect(planner).not.toHaveBeenCalled();
@@ -675,7 +691,200 @@ describe("SystemAgentChatEngine approval", () => {
expect(applied.text).toContain("[openclaw] done: config.set");
});
it("redacts sensitive config-set values from the AI-visible history", async () => {
it.each([
["channels.defaults.groupPolicy", '"open"', "open"],
["channels.modelByChannel.telegram.chat", '"openai/gpt-5.5"', "openai/gpt-5.5"],
['channels.modelByChannel["token=prod"].chat', '"openai/gpt-5.5"', "openai/gpt-5.5"],
])("keeps kernel-owned channel config %s visible in its approval", async (path, value, shown) => {
const runAgentTurn = vi.fn(async () => ({ text: "should never run" }));
const planner = vi.fn(async () => ({ reply: "should never run" }));
const engine = new SystemAgentChatEngine({
runAgentTurn: runAgentTurn as never,
planWithAssistant: planner as never,
deps: { runConfigSet: vi.fn(async () => {}), loadOverview: fakeOverviewLoader() },
});
const proposed = await engine.handle(`config set ${path} ${value}`);
expect(runAgentTurn).not.toHaveBeenCalled();
expect(planner).not.toHaveBeenCalled();
expect(proposed.text).toContain(path);
expect(proposed.text).toContain(shown);
expect(proposed.text).not.toContain("<redacted>");
expect(engine.hasPendingProposal()).toBe(true);
});
it("host-routes validated SecretRef writes without exposing the command to a model", async () => {
const runAgentTurn = vi.fn(async () => ({ text: "should never run" }));
const planner = vi.fn(async () => ({ reply: "should never run" }));
const runConfigSet = vi.fn(async () => {});
const engine = new SystemAgentChatEngine({
runAgentTurn: runAgentTurn as never,
planWithAssistant: planner as never,
deps: { runConfigSet, loadOverview: fakeOverviewLoader() },
});
const proposed = await engine.handle("config set-ref gateway.auth.token env GATEWAY_TOKEN");
expect(runAgentTurn).not.toHaveBeenCalled();
expect(planner).not.toHaveBeenCalled();
expect(proposed.text).toContain("env SecretRef <redacted>");
expect(proposed.text).not.toContain("GATEWAY_TOKEN");
expect(engine.hasPendingProposal()).toBe(true);
});
it("keeps valid SecretRef ids out of pending context and later history", async () => {
const rawRef = "123:actual-gateway-token";
const observedInputs: string[] = [];
const planner = vi.fn(async (params: unknown) => {
observedInputs.push(JSON.stringify(params));
return { reply: "still pending" };
});
const engine = new SystemAgentChatEngine({
classifyApproval: async () => "other",
runAgentTurn: async (params) => {
observedInputs.push(params.input);
throw new Error("force planner fallback");
},
planWithAssistant: planner as never,
deps: { runConfigSet: vi.fn(async () => {}), loadOverview: fakeOverviewLoader() },
});
const proposed = await engine.handle(`config set-ref gateway.auth.token exec ${rawRef}`);
expect(proposed.text).not.toContain(rawRef);
await engine.handle("what will this change?");
expect(observedInputs.join("\n")).not.toContain(rawRef);
expect(observedInputs.join("\n")).toContain("<redacted>");
});
it.each([
'channels.telegram.accounts["prod=us"].botToken',
String.raw`channels.telegram.accounts.prod\=us.botToken`,
])("host-routes a SecretRef write through dynamic config key %s", async (path) => {
const runAgentTurn = vi.fn(async () => ({ text: "should never run" }));
const planner = vi.fn(async () => ({ reply: "should never run" }));
const engine = new SystemAgentChatEngine({
runAgentTurn: runAgentTurn as never,
planWithAssistant: planner as never,
deps: { runConfigSet: vi.fn(async () => {}), loadOverview: fakeOverviewLoader() },
});
const proposed = await engine.handle(`config set-ref ${path} env TELEGRAM_TOKEN`);
expect(runAgentTurn).not.toHaveBeenCalled();
expect(planner).not.toHaveBeenCalled();
expect(proposed.text).toContain(path);
expect(proposed.text).not.toContain("TELEGRAM_TOKEN");
expect(engine.hasPendingProposal()).toBe(true);
});
it.each([
"config get gateway.auth.tokenabcDEF123",
'config get gateway.auth["token=abcDEF123"]',
String.raw`config get gateway.auth.token\=abcDEF123`,
"config get gateway.auth.token abcDEF123",
"config get channels.missing.opaque=abcDEF123",
"config schema gateway.port=abcDEF123",
"config schema gateway.auth.token=abcDEF123",
'config schema gateway.auth["token=abcDEF123"]',
"config schema channels.missing.opaque=abcDEF123",
])("keeps malformed config read path %s off model and history", async (command) => {
const runAgentTurn = vi.fn(async () => ({ text: "should never run" }));
const planner = vi.fn(async () => ({ reply: "should never run" }));
const engine = new SystemAgentChatEngine({
runAgentTurn: runAgentTurn as never,
planWithAssistant: planner as never,
deps: { loadOverview: fakeOverviewLoader() },
});
const reply = await engine.handle(command);
expect(reply.text).toContain("Invalid config path");
expect(runAgentTurn).not.toHaveBeenCalled();
expect(planner).not.toHaveBeenCalled();
expect(JSON.stringify(engine.historySince(0))).not.toContain("abcDEF123");
});
it("redacts vulnerable config command text when persisted history is seeded", () => {
const engine = new SystemAgentChatEngine({
deps: { loadOverview: fakeOverviewLoader() },
});
engine.seedHistory([
{ role: "user", text: "config set-ref gateway.auth.token exec 123:actual-gateway-token" },
{ role: "user", text: "config get gateway.auth.tokenabcDEF123" },
]);
expect(JSON.stringify(engine.historySince(0))).not.toContain("actual-gateway-token");
expect(JSON.stringify(engine.historySince(0))).not.toContain("abcDEF123");
});
it.each([
"config set gateway.auth..token very-secret",
"config set gateway.auth.token=very-secret",
"config set gateway.auth.token=very-secret please",
String.raw`config set gateway.auth.token\=very-secret please`,
String.raw`config set gateway.auth.token\ very-secret please`,
"config set gateway.auth.tokenabcDEF123 please",
"config set gateway.auth.token_abcDEF123 please",
"config set gateway.auth.token$abcDEF123 please",
"config set plugins.entries.codex.config.appServer.headersabcDEF123 please",
"config set plugins.entries.codex.config.appServer.headers.Authorization=Bearer-abc please",
'config set channels.synology-chat["webhookUrl=abcDEF123"] please',
'config set channels.buzz.groups["gateway.auth.token=abcDEF123"].enabled true',
'config set hooks.mappings["token=abcDEF123"].agentId main',
"config set-ref gateway.auth.tokenabcDEF123 env GATEWAY_TOKEN",
"config set-ref gateway.auth.token=abcDEF123 env GATEWAY_TOKEN",
"config set-ref gateway.auth.token env 123:actual-gateway-token",
'config set gateway.auth["token=very-secret"] please',
'config set gateway.auth["token very-secret"] please',
'config set gateway.auth["token:very-secret"] please',
'config set gateway.auth["token=very-secret"].nested please',
])("keeps malformed sensitive config write %s away from every model path", async (command) => {
useTempStateDir();
const runAgentTurn = vi.fn(async () => ({ text: "should never run" }));
const planner = vi.fn(async () => ({ reply: "should never run" }));
const engine = new SystemAgentChatEngine({
runAgentTurn: runAgentTurn as never,
planWithAssistant: planner as never,
deps: { runConfigSet: vi.fn(async () => {}), loadOverview: fakeOverviewLoader() },
});
const proposed = await engine.handle(command);
expect(runAgentTurn).not.toHaveBeenCalled();
expect(planner).not.toHaveBeenCalled();
expect(proposed.text).toContain("Invalid config path");
expect(proposed.text).not.toContain("very-secret");
expect(proposed.text).not.toContain("abcDEF123");
expect(engine.hasPendingProposal()).toBe(false);
});
it.each([
"config set gateway.auth.token=very-secret",
"config set gateway.auth.token=very-secret please",
String.raw`config set gateway.auth.token\=very-secret please`,
String.raw`config set gateway.auth.token\ very-secret please`,
"config set gateway.auth.token.verysecret please",
"config set gateway.auth.tokenabcDEF123 please",
"config set gateway.auth.token_abcDEF123 please",
"config set gateway.auth.token$abcDEF123 please",
"config set plugins.entries.codex.config.appServer.headersabcDEF123 please",
'config set hooks.mappings["token=abcDEF123"].agentId main',
"config set-ref gateway.auth.tokenabcDEF123 env GATEWAY_TOKEN",
"config set-ref gateway.auth.token=abcDEF123 env GATEWAY_TOKEN",
"config set-ref gateway.auth.token env 123:actual-gateway-token",
'config set gateway.auth["token=very-secret"] please',
'config set gateway.auth["token very-secret"] please',
'config set gateway.auth["token:very-secret"] please',
'config set gateway.auth["token=very-secret"].nested please',
"config set channels.missing.opaque=very-secret please",
'config set channels.missing["opaque=very-secret"].nested please',
"config set plugins.entries.missing.config.opaque=very-secret please",
'config set plugins.entries.missing.config["opaque=very-secret"].nested please',
'config set channels.synology-chat.accounts["prod.guild"].webhookUrl.abcDEF123 please',
])("redacts malformed config write %s from model-visible history", async (command) => {
const planner = vi.fn(async (_params: { history?: Array<{ role: string; text: string }> }) => ({
reply: "noted",
}));
@@ -686,7 +895,82 @@ describe("SystemAgentChatEngine approval", () => {
deps: { loadOverview: fakeOverviewLoader() },
});
await engine.handle("config set channels.telegram.botToken 123:very-secret");
await engine.handle(command);
await engine.handle("did that work?");
const history = planner.mock.calls.at(-1)?.[0]?.history ?? [];
const userTurns = history.filter((turn) => turn.role === "user").map((turn) => turn.text);
expect(userTurns.some((text) => text.includes("very-secret"))).toBe(false);
expect(userTurns.some((text) => text.includes("abcDEF123"))).toBe(false);
expect(userTurns.some((text) => text.includes("Bearer-abc"))).toBe(false);
expect(userTurns.some((text) => text.includes("<redacted secret>"))).toBe(true);
});
it.each([
"config set channels.missing.opaque.abcDEF123 please",
"config set plugins.entries.missing.config.opaque.abcDEF123 please",
"config set plugins.entries.codex.config.opaque=abcDEF123 please",
"config set channels.telegram.opaque=abcDEF123 please",
"config set gateway.auth.token.abcDEF123 please",
'config set channels.synology-chat.accounts["prod.guild"].webhookUrl.abcDEF123 please',
])(
"keeps sensitive dynamic or unknown-owner path %s out of model paths, responses, and history",
async (command) => {
const planner = vi.fn(
async (_params: { history?: Array<{ role: string; text: string }> }) => ({
reply: "noted",
}),
);
const runAgentTurn = vi.fn(async () => null);
const engine = new SystemAgentChatEngine({
runAgentTurn,
planWithAssistant: planner as never,
classifyApproval: async () => "other",
deps: { loadOverview: fakeOverviewLoader() },
});
const proposed = await engine.handle(command);
expect(proposed.text).not.toContain("abcDEF123");
expect(proposed.text).not.toContain("Bearer-abc");
expect(runAgentTurn).not.toHaveBeenCalled();
expect(planner).not.toHaveBeenCalled();
await engine.handle("no");
await engine.handle("did that work?");
const history = planner.mock.calls.at(-1)?.[0]?.history ?? [];
expect(history.some((turn) => turn.text.includes("abcDEF123"))).toBe(false);
expect(history.some((turn) => turn.text.includes("Bearer-abc"))).toBe(false);
},
);
it.each([
"channels.telegram.botToken",
"channels.synology-chat[webhookUrl]",
"channels.synology-chat.accounts[work].webhookUrl",
'channels.synology-chat.accounts["prod.guild"].webhookUrl',
String.raw`channels.synology-chat.accounts.prod\ guild.webhookUrl`,
"gateway.auth..token",
"channels.synology-chat.incomingUrl",
"channels.synology-chat.accounts[work].incomingUrl",
"plugins.entries.codex.config.appServer.headers",
"plugins.entries.codex.config.appServer.headers.Authorization",
"channels.synology-chat",
])("redacts config-set value at %s from the AI-visible history", async (path) => {
const planner = vi.fn(async (_params: { history?: Array<{ role: string; text: string }> }) => ({
reply: "noted",
}));
const engine = new SystemAgentChatEngine({
runAgentTurn: async () => null,
planWithAssistant: planner as never,
classifyApproval: async () => "other",
deps: { loadOverview: fakeOverviewLoader() },
});
const value =
path === "channels.synology-chat"
? '{ webhookUrl: "https://gateway.example/webhook/synology?access_token=very-secret" }'
: "123:very-secret";
await engine.handle(`config set ${path} ${value}`);
await engine.handle("did that work?");
const history = planner.mock.calls.at(-1)?.[0]?.history ?? [];
+29 -4
View File
@@ -1,4 +1,3 @@
import { isSensitiveConfigPath } from "../config/sensitive-paths.js";
import { formatErrorMessage } from "../infra/errors.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import type { RuntimeEnv } from "../runtime.js";
@@ -16,11 +15,16 @@ import type {
ChatWizardResult,
SystemAgentChatReply,
} from "./chat-wizard-host.js";
import {
isSystemAgentSensitiveConfigValue,
redactSystemAgentConfigPath,
} from "./config-redaction.js";
import { approvalQuestion } from "./dialogue.js";
import {
SystemAgentInferenceUnavailableError,
isSystemAgentInferenceUnavailableError,
} from "./inference-error.js";
import { isInvalidConfigSetOperation } from "./operations-internal.js";
import {
describeSystemAgentPersistentOperation,
executeSystemAgentOperation,
@@ -75,8 +79,21 @@ function formatOperationError(error: unknown): string {
export function redactSensitiveCommandText(text: string): string {
const operation = parseSystemAgentOperation(text);
if (operation.kind === "config-set" && isSensitiveConfigPath(operation.path)) {
return `config set ${operation.path} <redacted secret>`;
if (isInvalidConfigSetOperation(operation)) {
return "config set <invalid path> <redacted secret>";
}
if (operation.kind === "config-set") {
const displayPath = redactSystemAgentConfigPath(operation.path);
if (
displayPath !== operation.path ||
isSystemAgentSensitiveConfigValue(operation.path, operation.value)
) {
return `config set ${displayPath} <redacted secret>`;
}
}
if (operation.kind === "config-set-ref") {
const displayPath = redactSystemAgentConfigPath(operation.path);
return `config set-ref ${displayPath} <redacted reference>`;
}
return text;
}
@@ -218,7 +235,15 @@ export class ChatTurnRouter {
return { text: "Approval pending. Human must decide in OpenClaw UI.", action: "none" };
}
const typed = parseSystemAgentOperation(text);
if (typed.kind === "config-set" && isSensitiveConfigPath(typed.path)) {
if (isInvalidConfigSetOperation(typed)) {
return { text: typed.message, action: "none" };
}
if (
typed.kind === "config-set" ||
typed.kind === "config-set-ref" ||
typed.kind === "config-get" ||
typed.kind === "config-schema"
) {
return await this.runOperation(typed, undefined);
}
const typedRefusal = this.refuseDelegatedNavigationDirective(typed.kind);
+273
View File
@@ -0,0 +1,273 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
clearRuntimeConfigSnapshot,
setRuntimeConfigSnapshot,
} from "../config/runtime-snapshot.js";
import {
isSystemAgentSensitiveConfigPathEmbedding,
isSystemAgentSensitiveConfigValue,
redactSystemAgentConfigPath,
redactSystemAgentConfig,
} from "./config-redaction.js";
import {
installSystemAgentPluginMetadataTestSnapshot,
type SystemAgentPluginMetadataTestSnapshot,
} from "./system-agent.test-helpers.js";
let pluginMetadata: SystemAgentPluginMetadataTestSnapshot | undefined;
beforeEach(() => {
const config = {};
setRuntimeConfigSnapshot(config, config);
pluginMetadata = installSystemAgentPluginMetadataTestSnapshot(config);
});
afterEach(() => {
pluginMetadata?.restore();
pluginMetadata = undefined;
clearRuntimeConfigSnapshot();
});
describe("isSystemAgentSensitiveConfigValue", () => {
it("detects sensitive descendants in structured parent writes", () => {
expect(
isSystemAgentSensitiveConfigValue(
"channels.synology-chat",
'{ accounts: { work: { webhookUrl: "https://gateway.invalid/webhook?token=synthetic" } } }',
),
).toBe(true);
});
it("keeps structured parent writes visible when no descendant is sensitive", () => {
expect(
isSystemAgentSensitiveConfigValue(
"channels.synology-chat",
'{ enabled: true, webhookPath: "/synology" }',
),
).toBe(false);
});
it("preserves escaped path segments while matching wildcard descendant hints", () => {
expect(
isSystemAgentSensitiveConfigValue(
'channels.synology-chat.accounts["prod.guild"]',
'{ webhookUrl: "https://gateway.invalid/webhook?token=synthetic" }',
),
).toBe(true);
});
it("fails closed when a dynamic config owner has no current metadata", () => {
expect(
isSystemAgentSensitiveConfigValue("plugins.entries.missing.config.opaque", "plugin-secret"),
).toBe(true);
expect(isSystemAgentSensitiveConfigValue("channels.missing.opaque", "channel-secret")).toBe(
true,
);
expect(
isSystemAgentSensitiveConfigValue('channels["defaults.foo"].opaque', "channel-secret"),
).toBe(true);
expect(
isSystemAgentSensitiveConfigValue('channels["modelByChannel.evil"].opaque', "channel-secret"),
).toBe(true);
});
it.each([
["channels.defaults.groupPolicy", '"open"'],
["channels.modelByChannel.telegram.chat", '"openai/gpt-5.5"'],
['channels.modelByChannel["token=prod"].chat', '"openai/gpt-5.5"'],
])("keeps kernel-owned channel config %s visible", (path, value) => {
expect(isSystemAgentSensitiveConfigValue(path, value)).toBe(false);
});
});
describe("isSystemAgentSensitiveConfigPathEmbedding", () => {
it.each([
"gateway.auth.token=abcDEF123",
String.raw`gateway.auth.token\=abcDEF123`,
String.raw`gateway.auth.token\ abcDEF123`,
"gateway.auth.tokenabcDEF123",
"gateway.auth.token_abcDEF123",
"gateway.auth.token$abcDEF123",
"plugins.entries.codex.config.appServer.headersabcDEF123",
'gateway.auth["token=abcDEF123"]',
'gateway.auth["token abcDEF123"]',
'gateway.auth["token:abcDEF123"]',
'gateway.auth["token=abcDEF123"].nested',
])("detects sensitive data embedded in path %s", (path) => {
expect(isSystemAgentSensitiveConfigPathEmbedding(path)).toBe(true);
});
it("preserves a non-sensitive dynamic key containing an assignment delimiter", () => {
expect(
isSystemAgentSensitiveConfigPathEmbedding(
'channels.synology-chat.accounts["prod=us"].webhookUrl',
),
).toBe(false);
});
it.each([
"plugins.entries.codex.config.appServer.headers.Authorization",
'plugins.entries.codex.config.appServer.headers["X-Test"]',
String.raw`plugins.entries.codex.config.appServer.headers.X\-Test`,
'channels.synology-chat.accounts["token=prod"].webhookUrl',
String.raw`channels.synology-chat.accounts.token\=prod.webhookUrl`,
'channels.synology-chat.accounts["token=prod"].webhookPath',
String.raw`channels.synology-chat.accounts.token\=prod.webhookPath`,
'broadcast["token=prod"]',
'session.identityLinks["token=prod"]',
'channels.modelByChannel["token=prod"].chat',
'channels.telegram.groups["prod.guild"].topics["token=prod"].groupPolicy',
'channels.buzz.groups["00000000-0000-4000-8000-000000000000"].enabled',
'hooks.entries.work["token=prod"]',
String.raw`hooks.entries.work.token\=prod`,
'talk.providers.openai["token=prod"]',
"hooks.mappings[0].agentId",
])("preserves schema-valid dynamic path %s", (path) => {
expect(isSystemAgentSensitiveConfigPathEmbedding(path)).toBe(false);
});
it("rejects a nonnumeric array index", () => {
expect(
isSystemAgentSensitiveConfigPathEmbedding('hooks.mappings["token=abcDEF123"].agentId'),
).toBe(true);
});
it("redacts unknown-owner and sensitive descendant paths", () => {
expect(redactSystemAgentConfigPath("channels.missing.opaque.abcDEF123")).toBe(
"<redacted path>",
);
expect(redactSystemAgentConfigPath("plugins.entries.missing.config.opaque.abcDEF123")).toBe(
"<redacted path>",
);
expect(redactSystemAgentConfigPath("plugins.entries.codex.config.opaque=abcDEF123")).toBe(
"<redacted path>",
);
expect(redactSystemAgentConfigPath('channels.synology-chat["webhookUrl=abcDEF123"]')).toBe(
"<redacted path>",
);
expect(redactSystemAgentConfigPath('channels.synology-chat.accounts["prod=us"].enabled')).toBe(
'channels.synology-chat.accounts["prod=us"].enabled',
);
expect(
redactSystemAgentConfigPath(
'plugins.entries.codex.config.appServer.headers["Authorization=Bearer-abc"]',
),
).toBe("<redacted path>");
expect(
redactSystemAgentConfigPath(
"plugins.entries.codex.config.appServer.headers.AuthorizationabcDEF123",
),
).toBe("plugins.entries.codex.config.appServer.headers.AuthorizationabcDEF123");
expect(
redactSystemAgentConfigPath('plugins.entries.codex.config.appServer.headers["X-Test"]'),
).toBe('plugins.entries.codex.config.appServer.headers["X-Test"]');
expect(
redactSystemAgentConfigPath('channels.synology-chat.accounts["token=prod"].enabled'),
).toBe('channels.synology-chat.accounts["token=prod"].enabled');
expect(redactSystemAgentConfigPath('broadcast["token=prod"]')).toBe('broadcast["token=prod"]');
expect(redactSystemAgentConfigPath('session.identityLinks["token=prod"]')).toBe(
'session.identityLinks["token=prod"]',
);
expect(redactSystemAgentConfigPath('channels.modelByChannel["token=prod"].chat')).toBe(
'channels.modelByChannel["token=prod"].chat',
);
expect(
redactSystemAgentConfigPath(
'channels.telegram.groups["prod.guild"].topics["token=prod"].groupPolicy',
),
).toBe('channels.telegram.groups["prod.guild"].topics["token=prod"].groupPolicy');
expect(redactSystemAgentConfigPath('hooks.mappings["token=abcDEF123"].agentId')).toBe(
"<redacted path>",
);
expect(
redactSystemAgentConfigPath(
'channels.buzz.groups["gateway.auth.token=ACTUAL_GATEWAY_TOKEN"].enabled',
),
).toBe("<redacted path>");
});
});
describe("redactSystemAgentConfig", () => {
it("fails closed for dynamic owner secrets when the exact config is invalid", () => {
expect(
redactSystemAgentConfig(
{
plugins: { entries: { "custom.plugin": { config: { opaque: "plugin-secret" } } } },
channels: { "custom.channel": { opaque: "channel-secret" } },
},
{ valid: false },
),
).toEqual({
plugins: { entries: { "custom.plugin": { config: "<redacted>" } } },
channels: { "custom.channel": "<redacted>" },
});
});
it("does not trust known owner metadata for an invalid config snapshot", () => {
expect(
redactSystemAgentConfig(
{ channels: { "synology-chat": { opaque: "invalid-channel-secret" } } },
{ valid: false },
),
).toEqual({ channels: { "synology-chat": "<redacted>" } });
});
it("preserves kernel-owned channel namespaces while unknown owners fail closed", () => {
expect(
redactSystemAgentConfig(
{
channels: {
defaults: { groupPolicy: "open" },
modelByChannel: { telegram: { chat: "openai/gpt-5.5" } },
missing: { opaque: "channel-secret" },
"defaults.foo": { opaque: "dotted-channel-secret" },
"modelByChannel.evil": { opaque: "dotted-model-secret" },
},
},
{ valid: false },
),
).toEqual({
channels: {
defaults: { groupPolicy: "open" },
modelByChannel: { telegram: { chat: "openai/gpt-5.5" } },
missing: "<redacted>",
"defaults.foo": "<redacted>",
"modelByChannel.evil": "<redacted>",
},
});
});
it("redacts invalid descendants inside core channel namespaces", () => {
expect(
redactSystemAgentConfig(
{
channels: {
defaults: { groupPolicy: "open", opaque: "kernel-secret" },
modelByChannel: { telegram: { chat: 42 } },
},
},
{ valid: false },
),
).toEqual({
channels: {
defaults: { groupPolicy: "open", opaque: "<redacted>" },
modelByChannel: "<redacted>",
},
});
});
it("fails closed for malformed invalid-config owner containers", () => {
expect(
redactSystemAgentConfig(
{
channels: [{ opaque: "channel-secret" }],
plugins: { entries: { broken: "plugin-secret" } },
},
{ valid: false },
),
).toEqual({
channels: "<redacted>",
plugins: { entries: { broken: "<redacted>" } },
});
});
});
+542
View File
@@ -0,0 +1,542 @@
// System-agent config redaction keeps model-visible reads and plans aligned with config UI hints.
import {
hasSensitiveUrlHintTag,
isSensitiveUrlConfigPath,
redactSensitiveUrlLikeString,
} from "@openclaw/net-policy/redact-sensitive-url";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { CHANNEL_IDS } from "../channels/ids.js";
import { parseConfigSetPath, parseConfigSetValue } from "../cli/config-cli-path.js";
import {
collectChannelSchemaMetadataCore,
collectPluginSchemaMetadataCore,
} from "../config/channel-config-metadata.js";
import { REDACTED_SENTINEL, redactConfigObject } from "../config/redact-snapshot.js";
import { getRuntimeConfigSnapshot } from "../config/runtime-snapshot.js";
import { isKernelOwnedChannelConfigKey } from "../config/schema.hints.js";
import {
buildConfigSchemaCore,
classifyConfigSchemaPathSegment,
type ConfigSchemaResponse,
} from "../config/schema.js";
import { findWildcardHintMatch } from "../config/schema.shared.js";
import { isSensitiveConfigPath } from "../config/sensitive-paths.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { ChannelsSchema } from "../config/zod-schema.channels-config.js";
import { getCurrentPluginMetadataSnapshot } from "../plugins/current-plugin-metadata-snapshot.js";
import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.types.js";
import { normalizePluginPolicyId } from "../plugins/plugin-policy-id.js";
import type { ConfigUiHint, ConfigUiHints } from "../shared/config-ui-hints-types.js";
type SystemAgentConfigRedactionSource = {
config?: OpenClawConfig;
valid?: boolean;
};
type SystemAgentConfigRedactionMetadata = {
schema: ConfigSchemaResponse;
uiHints: ConfigUiHints;
sensitiveHintPaths: readonly (readonly string[])[];
wildcardHintPaths: readonly (readonly string[])[];
pluginIds: ReadonlySet<string>;
channelIds: ReadonlySet<string>;
};
const baseConfigSchema = buildConfigSchemaCore();
// These sensitive fields are maps whose child keys are operator labels, not secrets.
const SENSITIVE_CONFIG_CONTAINER_KEYS = new Set(["env", "headers"]);
function collectUiHintPaths(
uiHints: ConfigUiHints,
accept: (hint: ConfigUiHint, parts: readonly string[]) => boolean,
): readonly (readonly string[])[] {
return Object.entries(uiHints).flatMap(([path, hint]) => {
if (!path) {
return [];
}
const parts = splitConfigHintPath(path);
return accept(hint, parts) ? [parts] : [];
});
}
const baseConfigRedactionMetadata: SystemAgentConfigRedactionMetadata = {
schema: baseConfigSchema,
uiHints: baseConfigSchema.uiHints,
sensitiveHintPaths: collectUiHintPaths(
baseConfigSchema.uiHints,
(hint) => hint.sensitive === true || hasSensitiveUrlHintTag(hint),
),
wildcardHintPaths: collectUiHintPaths(baseConfigSchema.uiHints, (_hint, parts) =>
parts.includes("*"),
),
pluginIds: new Set(),
channelIds: new Set(CHANNEL_IDS),
};
const invalidConfigRedactionMetadata: SystemAgentConfigRedactionMetadata = {
...baseConfigRedactionMetadata,
channelIds: new Set(),
};
const metadataConfigRedaction = new WeakMap<
PluginMetadataSnapshot,
SystemAgentConfigRedactionMetadata
>();
function resolveMetadataConfigRedaction(
snapshot: PluginMetadataSnapshot,
): SystemAgentConfigRedactionMetadata {
const cached = metadataConfigRedaction.get(snapshot);
if (cached) {
return cached;
}
const plugins = collectPluginSchemaMetadataCore(snapshot.manifestRegistry);
const channels = collectChannelSchemaMetadataCore(snapshot.manifestRegistry);
const schema = buildConfigSchemaCore({ plugins, channels });
const uiHints = schema.uiHints;
const metadata = {
schema,
uiHints,
sensitiveHintPaths: collectUiHintPaths(
uiHints,
(hint) => hint.sensitive === true || hasSensitiveUrlHintTag(hint),
),
wildcardHintPaths: collectUiHintPaths(uiHints, (_hint, parts) => parts.includes("*")),
pluginIds: new Set(
plugins
.filter((plugin) => plugin.configSchema !== undefined)
.map((plugin) => normalizePluginPolicyId(plugin.id)),
),
channelIds: new Set([
...CHANNEL_IDS,
...channels
.filter((channel) => channel.configSchema !== undefined)
.map((channel) => channel.id),
]),
};
metadataConfigRedaction.set(snapshot, metadata);
return metadata;
}
function resolveSystemAgentConfigRedactionMetadata(
source?: SystemAgentConfigRedactionSource,
): SystemAgentConfigRedactionMetadata {
if (source?.valid === false) {
return invalidConfigRedactionMetadata;
}
const config = source?.config ?? getRuntimeConfigSnapshot();
if (!config) {
const snapshot = getCurrentPluginMetadataSnapshot({
env: process.env,
allowWorkspaceScopedSnapshot: true,
requireDefaultDiscoveryContext: true,
});
return snapshot ? resolveMetadataConfigRedaction(snapshot) : baseConfigRedactionMetadata;
}
// Gateway lifecycle owns this process-stable snapshot. A mismatch is unknown
// metadata, never a reason to rediscover plugins from a model-visible hot path.
const snapshot = getCurrentPluginMetadataSnapshot({
config,
env: process.env,
allowWorkspaceScopedSnapshot: true,
});
return snapshot ? resolveMetadataConfigRedaction(snapshot) : baseConfigRedactionMetadata;
}
function splitConfigHintPath(path: string): string[] {
// Schema hint paths use `[]` as an array wildcard; config writes spell the
// same segment as `[*]`.
return parseConfigSetPath(path.replace(/\[\]/g, "[*]"));
}
function resolveConfigUiHint(
path: readonly string[],
uiHints: ConfigUiHints,
includeAncestors = false,
acceptHint?: (hint: ConfigUiHint) => boolean,
): ConfigUiHint | undefined {
return (
findWildcardHintMatch({
uiHints,
path: path.join("."),
// Config path segments can themselves contain dots. Preserve the
// writer-parsed boundaries instead of reparsing a lossy joined path.
targetParts: path,
splitPath: splitConfigHintPath,
includeAncestors,
acceptHint,
})?.hint ?? undefined
);
}
function isUnknownDynamicOwnerPath(
path: readonly string[],
metadata: SystemAgentConfigRedactionMetadata,
): boolean {
const pluginId = path[2];
if (path[0] === "plugins" && path[1] === "entries" && pluginId && path[3] === "config") {
return !metadata.pluginIds.has(normalizePluginPolicyId(pluginId));
}
const channelId = path[1];
if (path[0] === "channels" && channelId) {
const knownOwner =
isKernelOwnedChannelConfigKey(channelId) || metadata.channelIds.has(channelId);
return !knownOwner;
}
return false;
}
function isDynamicOwnerIdSegment(path: readonly string[], index: number): boolean {
return (
(path[0] === "channels" && index === 1) ||
(path[0] === "plugins" && path[1] === "entries" && index === 2)
);
}
function hasSensitiveHintSegmentPrefix(
path: readonly string[],
index: number,
metadata: SystemAgentConfigRedactionMetadata,
): boolean {
const segment = path[index];
if (segment === undefined) {
return false;
}
for (let end = 1; end < segment.length; end += 1) {
const prefixPath = [...path.slice(0, index), segment.slice(0, end)];
if (metadata.sensitiveHintPaths.some((hintPath) => matchesHintPath(hintPath, prefixPath))) {
return true;
}
}
return false;
}
function isKernelPassthroughSegment(path: readonly string[], index: number): boolean {
return (
(path[0] === "hooks" && path[1] === "entries" && (index === 2 || index === 3)) ||
(path[0] === "talk" && path[1] === "providers" && (index === 2 || index === 3))
);
}
function isSchemaDynamicSegment(
path: readonly string[],
index: number,
metadata: SystemAgentConfigRedactionMetadata,
): boolean {
// The UI schema intentionally strips kernel channel fields. Preserve the
// two record-key levels owned by the canonical modelByChannel schema here.
if (path[0] === "channels" && path[1] === "modelByChannel" && (index === 2 || index === 3)) {
return true;
}
// Zod passthrough/catchall objects become untyped in the public form schema.
// These exact core containers still own arbitrary entry ids and child keys.
if (isKernelPassthroughSegment(path, index)) {
return !hasSensitiveHintSegmentPrefix(path, index, metadata);
}
const segment = path[index];
if (segment === undefined) {
return false;
}
const kind = classifyConfigSchemaPathSegment(metadata.schema, path.slice(0, index), segment);
if (kind === "record-key" || kind === "array-index") {
return !hasSensitiveHintSegmentPrefix(path, index, metadata);
}
if (kind === "invalid-record-key") {
return false;
}
if (
classifyConfigSchemaPathSegment(metadata.schema, path.slice(0, index), "0") === "array-index"
) {
return false;
}
// Some plugin UI schemas intentionally describe only their security-owned
// leaves. A later fixed hint still proves the wildcard container boundary.
return metadata.wildcardHintPaths.some(
(hintParts) =>
hintParts[index] === "*" &&
hintParts.slice(index + 1).some((part) => part !== "*") &&
hintParts
.slice(0, index)
.every((part, partIndex) => part === "*" || part === path[partIndex]),
);
}
function matchesHintPath(pattern: readonly string[], path: readonly string[]): boolean {
return (
pattern.length === path.length &&
pattern.every((part, index) => part === "*" || part === path[index])
);
}
function hasSensitiveConfigValue(
path: string[],
value: unknown,
metadata: SystemAgentConfigRedactionMetadata,
): boolean {
if (isUnknownDynamicOwnerPath(path, metadata)) {
return true;
}
const { uiHints } = metadata;
const canonicalPath = path.join(".");
const sensitiveHint = resolveConfigUiHint(
path,
uiHints,
true,
(candidate) => candidate.sensitive !== undefined,
);
if (sensitiveHint?.sensitive === true || isSensitiveConfigPath(canonicalPath)) {
return true;
}
const hint = resolveConfigUiHint(path, uiHints);
if (
typeof value === "string" &&
(hasSensitiveUrlHintTag(hint) || isSensitiveUrlConfigPath(canonicalPath)) &&
redactSensitiveUrlLikeString(value) !== value
) {
return true;
}
if (Array.isArray(value)) {
return value.some((entry, index) =>
hasSensitiveConfigValue([...path, String(index)], entry, metadata),
);
}
if (value && typeof value === "object") {
return Object.entries(value).some(([key, entry]) =>
hasSensitiveConfigValue([...path, key], entry, metadata),
);
}
return false;
}
/** Return whether a config value must stay out of model-visible command text. */
export function isSystemAgentSensitiveConfigValue(path: string, value: unknown): boolean {
let parsedPath: string[];
try {
parsedPath = parseConfigSetPath(path);
} catch {
// The command parser accepts a broader path surface than config writes do.
// Keep malformed paths out of model-visible text instead of guessing how a
// future writer or normalizer might interpret them.
return true;
}
const parsedValue = typeof value === "string" ? parseConfigSetValue(value, false) : value;
return hasSensitiveConfigValue(
parsedPath,
parsedValue,
resolveSystemAgentConfigRedactionMetadata(),
);
}
function isSensitiveConfigPathParts(
path: string[],
metadata: SystemAgentConfigRedactionMetadata,
includeAncestors = true,
): boolean {
const { uiHints } = metadata;
const canonicalPath = path.join(".");
const sensitiveHint = resolveConfigUiHint(
path,
uiHints,
includeAncestors,
(candidate) => candidate.sensitive !== undefined,
);
if (sensitiveHint?.sensitive === true || isSensitiveConfigPath(canonicalPath)) {
return true;
}
const hint = resolveConfigUiHint(path, uiHints);
return hasSensitiveUrlHintTag(hint) || isSensitiveUrlConfigPath(canonicalPath);
}
function hasSensitiveSegmentPrefix(
parsedPath: string[],
index: number,
metadata: SystemAgentConfigRedactionMetadata,
): boolean {
const segment = parsedPath[index];
if (segment === undefined) {
return false;
}
for (let end = 1; end < segment.length; end += 1) {
const prefixPath = [...parsedPath.slice(0, index), segment.slice(0, end)];
const canonicalPath = prefixPath.join(".");
if (
isSensitiveConfigPath(canonicalPath) ||
isSensitiveUrlConfigPath(canonicalPath) ||
metadata.sensitiveHintPaths.some((hintPath) => matchesHintPath(hintPath, prefixPath))
) {
return true;
}
}
return false;
}
function hasSensitiveContainerAssignment(path: readonly string[], index: number): boolean {
return (
SENSITIVE_CONFIG_CONTAINER_KEYS.has(path[index - 1] ?? "") &&
(path[index]?.includes("=") ?? false)
);
}
/** Redact unknown-owner paths and data appended after a sensitive key. */
export function redactSystemAgentConfigPath(path: string): string {
try {
const parsedPath = parseConfigSetPath(path);
const metadata = resolveSystemAgentConfigRedactionMetadata();
const hasSensitivePathData = parsedPath.some(
(segment, index) =>
hasSensitiveContainerAssignment(parsedPath, index) ||
(!isDynamicOwnerIdSegment(parsedPath, index) &&
!isSchemaDynamicSegment(parsedPath, index, metadata) &&
(segment.includes("=") ||
(!SENSITIVE_CONFIG_CONTAINER_KEYS.has(parsedPath[index - 1] ?? "") &&
hasSensitiveSegmentPrefix(parsedPath, index, metadata)))) ||
(index > 0 &&
!SENSITIVE_CONFIG_CONTAINER_KEYS.has(parsedPath[index - 1] ?? "") &&
isSensitiveConfigPathParts(parsedPath.slice(0, index), metadata)),
);
return isUnknownDynamicOwnerPath(parsedPath, metadata) || hasSensitivePathData
? "<redacted path>"
: path;
} catch {
return "<redacted path>";
}
}
/** Return whether a path segment embeds data after a sensitive config key. */
export function isSystemAgentSensitiveConfigPathEmbedding(path: string): boolean {
let parsedPath: string[];
try {
parsedPath = parseConfigSetPath(path);
} catch {
return true;
}
const metadata = resolveSystemAgentConfigRedactionMetadata();
const unknownOwner = isUnknownDynamicOwnerPath(parsedPath, metadata);
return parsedPath.some((segment, index) => {
if (unknownOwner && segment.includes("=")) {
return true;
}
if (hasSensitiveContainerAssignment(parsedPath, index)) {
return true;
}
if (
isDynamicOwnerIdSegment(parsedPath, index) ||
SENSITIVE_CONFIG_CONTAINER_KEYS.has(parsedPath[index - 1] ?? "") ||
isSchemaDynamicSegment(parsedPath, index, metadata)
) {
return false;
}
return segment.includes("=") || hasSensitiveSegmentPrefix(parsedPath, index, metadata);
});
}
function redactUnknownDynamicOwners(
value: unknown,
metadata: SystemAgentConfigRedactionMetadata,
invalidConfig: boolean,
): unknown {
if (!isRecord(value)) {
return value;
}
let result = value;
const plugins = isRecord(value.plugins) ? value.plugins : undefined;
if (invalidConfig && Object.hasOwn(value, "plugins") && !plugins) {
result = { ...result, plugins: REDACTED_SENTINEL };
}
const entries = plugins && isRecord(plugins.entries) ? plugins.entries : undefined;
if (invalidConfig && plugins && Object.hasOwn(plugins, "entries") && !entries) {
result = { ...result, plugins: { ...plugins, entries: REDACTED_SENTINEL } };
}
if (entries) {
let redactedEntries: Record<string, unknown> | undefined;
for (const [pluginId, entry] of Object.entries(entries)) {
if (!isRecord(entry) || !Object.hasOwn(entry, "config")) {
if (invalidConfig) {
redactedEntries ??= { ...entries };
redactedEntries[pluginId] = REDACTED_SENTINEL;
}
continue;
}
if (metadata.pluginIds.has(normalizePluginPolicyId(pluginId))) {
continue;
}
redactedEntries ??= { ...entries };
redactedEntries[pluginId] = { ...entry, config: REDACTED_SENTINEL };
}
if (redactedEntries) {
result = {
...result,
plugins: { ...plugins, entries: redactedEntries },
};
}
}
const channels = isRecord(value.channels) ? value.channels : undefined;
if (invalidConfig && Object.hasOwn(value, "channels") && !channels) {
result = { ...result, channels: REDACTED_SENTINEL };
}
if (channels) {
let redactedChannels: Record<string, unknown> | undefined;
for (const [channelId, channelConfig] of Object.entries(channels)) {
if (isKernelOwnedChannelConfigKey(channelId)) {
if (invalidConfig) {
redactedChannels ??= { ...channels };
redactedChannels[channelId] = redactInvalidKernelChannelConfig(channelId, channelConfig);
}
continue;
}
if (metadata.channelIds.has(channelId)) {
continue;
}
redactedChannels ??= { ...channels };
redactedChannels[channelId] = REDACTED_SENTINEL;
}
if (redactedChannels) {
result = { ...result, channels: redactedChannels };
}
}
return result;
}
function redactInvalidKernelChannelConfig(key: string, value: unknown): unknown {
if (key === "modelByChannel") {
return ChannelsSchema.safeParse({ modelByChannel: value }).success ? value : REDACTED_SENTINEL;
}
if (key !== "defaults" || !isRecord(value)) {
return REDACTED_SENTINEL;
}
return Object.fromEntries(
Object.entries(value).map(([field, entry]) => [
field,
ChannelsSchema.safeParse({ defaults: { [field]: entry } }).success
? entry
: REDACTED_SENTINEL,
]),
);
}
function replaceRedactionSentinels(value: unknown): unknown {
if (value === REDACTED_SENTINEL) {
return "<redacted>";
}
if (Array.isArray(value)) {
return value.map(replaceRedactionSentinels);
}
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value).map(([key, entry]) => [key, replaceRedactionSentinels(entry)]),
);
}
return value;
}
/** Redact a config object before any subtree is projected into a model-visible result. */
export function redactSystemAgentConfig(
value: unknown,
source?: SystemAgentConfigRedactionSource,
): unknown {
const metadata = resolveSystemAgentConfigRedactionMetadata(source);
return replaceRedactionSentinels(
redactConfigObject(
redactUnknownDynamicOwners(value, metadata, source?.valid === false),
metadata.uiHints,
),
);
}
+4
View File
@@ -2,6 +2,7 @@
import type { RuntimeEnv } from "../runtime.js";
import type { SystemAgentAssistantPlan, SystemAgentAssistantPlanner } from "./assistant.js";
import { SystemAgentInferenceUnavailableError } from "./inference-error.js";
import { isInvalidConfigSetOperation } from "./operations-internal.js";
import {
describeSystemAgentPersistentOperation,
parseSystemAgentOperation,
@@ -88,6 +89,9 @@ function shouldAskAssistant(input: string, operation: SystemAgentOperation): boo
if (operation.kind !== "none") {
return false;
}
if (isInvalidConfigSetOperation(operation)) {
return false;
}
const trimmed = input.trim().toLowerCase();
if (!trimmed || trimmed === "quit" || trimmed === "exit") {
return false;
+8 -7
View File
@@ -6,6 +6,7 @@ import { resolveUserPath, shortenHomePath } from "../utils.js";
import { t } from "../wizard/i18n/index.js";
import { isReservedSystemAgentId } from "./agent-id.js";
import { SYSTEM_AGENT_AUDIT_STORE_LABEL } from "./audit.js";
import { redactSystemAgentConfig } from "./config-redaction.js";
import {
CONFIG_GET_OUTPUT_MAX_CHARS,
CONFIG_SCHEMA_CHILDREN_MAX,
@@ -21,7 +22,6 @@ import {
loadOverviewForOperation,
readConfigFileSnapshotLazy,
readConfigValueAtPath,
redactConfigValue,
resolveChannelSetupState,
resolveTuiAgentId,
runConfigSetOperation,
@@ -122,18 +122,19 @@ export async function executeSystemAgentOperation(
runtime.log(`Config missing: ${shortenHomePath(snapshot.path)}`);
return { applied: false };
}
const cfg = snapshot.valid
? (snapshot.sourceConfig ?? snapshot.config)
: snapshot.sourceConfig;
const lookup = readConfigValueAtPath(cfg ?? {}, operation.path);
const cfg = snapshot.sourceConfig;
// Redact before selecting a subtree so wildcard channel/plugin hints retain full paths.
const lookup = readConfigValueAtPath(
redactSystemAgentConfig(cfg, { config: cfg, valid: snapshot.valid }),
operation.path,
);
if (!lookup.found) {
runtime.log(
`${operation.path}: not set. Use \`config schema ${operation.path}\` to see what is allowed.`,
);
return { applied: false };
}
const redacted = redactConfigValue(lookup.value, operation.path);
const rendered = JSON.stringify(redacted, null, 2) ?? "null";
const rendered = JSON.stringify(lookup.value, null, 2) ?? "null";
runtime.log(
rendered.length > CONFIG_GET_OUTPUT_MAX_CHARS
? `${operation.path} = ${truncateUtf16Safe(rendered, CONFIG_GET_OUTPUT_MAX_CHARS)}\n… (truncated)`
@@ -3,7 +3,6 @@ import { tryResolveLegacyCompatibilityAgentId } from "../agents/agent-scope-conf
import type { AgentExecutionAuthBinding } from "../agents/execution-auth-binding.js";
import type { ConfigSetOptions } from "../cli/config-set-input.js";
import type { OpenClawConfig } from "../config/config.js";
import { isSensitiveConfigPath } from "../config/sensitive-paths.js";
import { formatErrorMessage } from "../infra/errors.js";
import { normalizeAgentId } from "../routing/session-key.js";
import type { RuntimeEnv } from "../runtime.js";
@@ -37,24 +36,6 @@ const loadOverviewModule = async () => await import("./overview.js");
export const CONFIG_GET_OUTPUT_MAX_CHARS = 2_000;
export const CONFIG_SCHEMA_CHILDREN_MAX = 40;
export function redactConfigValue(value: unknown, configPath: string): unknown {
if (typeof value === "string" || typeof value === "number") {
return isSensitiveConfigPath(configPath) ? "<redacted>" : value;
}
if (Array.isArray(value)) {
return value.map((entry) => redactConfigValue(entry, `${configPath}[]`));
}
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([key, entry]) => [
key,
redactConfigValue(entry, configPath ? `${configPath}.${key}` : key),
]),
);
}
return value;
}
export function readConfigValueAtPath(
config: unknown,
path: string,
+10
View File
@@ -0,0 +1,10 @@
import type { SystemAgentOperation } from "./operation-types.js";
export const INVALID_CONFIG_SET_MESSAGE =
"Invalid config path. Check its quoting or escaping and try again.";
export function isInvalidConfigSetOperation(
operation: SystemAgentOperation,
): operation is Extract<SystemAgentOperation, { kind: "none" }> {
return operation.kind === "none" && operation.message === INVALID_CONFIG_SET_MESSAGE;
}
+392
View File
@@ -0,0 +1,392 @@
// OpenClaw operation parser tests protect direct command routing before model fallback.
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
clearRuntimeConfigSnapshot,
setRuntimeConfigSnapshot,
} from "../config/runtime-snapshot.js";
import { isPersistentSystemAgentOperation, parseSystemAgentOperation } from "./operations.js";
import {
installSystemAgentPluginMetadataTestSnapshot,
type SystemAgentPluginMetadataTestSnapshot,
} from "./system-agent.test-helpers.js";
let pluginMetadata: SystemAgentPluginMetadataTestSnapshot | undefined;
beforeAll(() => {
const config = {};
setRuntimeConfigSnapshot(config, config);
pluginMetadata = installSystemAgentPluginMetadataTestSnapshot(config);
});
afterAll(() => {
pluginMetadata?.restore();
clearRuntimeConfigSnapshot();
});
describe("parseSystemAgentOperation", () => {
it("parses typed model writes", () => {
expect(parseSystemAgentOperation("set default model openai/gpt-5.2")).toEqual({
kind: "set-default-model",
model: "openai/gpt-5.2",
});
expect(parseSystemAgentOperation("configure models openai/gpt-5.2")).toEqual({
kind: "set-default-model",
model: "openai/gpt-5.2",
});
});
it("parses interactive model provider setup", () => {
expect(parseSystemAgentOperation("configure model provider")).toEqual({
kind: "model-setup",
});
expect(parseSystemAgentOperation("setup model provider")).toEqual({
kind: "model-setup",
});
expect(parseSystemAgentOperation("model setup workspace /tmp/work")).toEqual({
kind: "model-setup",
workspace: "/tmp/work",
});
});
it("parses verbal agent switching", () => {
expect(parseSystemAgentOperation("talk to work agent")).toEqual({
kind: "open-tui",
agentId: "work",
});
});
it("routes ambiguous model requests to the AI instead of guessing", () => {
expect(parseSystemAgentOperation("models please").kind).toBe("none");
expect(parseSystemAgentOperation("why did my gateway stop").kind).toBe("none");
expect(parseSystemAgentOperation("should I talk to my agent about this?").kind).toBe("none");
expect(parseSystemAgentOperation("set me up with telegram").kind).toBe("none");
expect(parseSystemAgentOperation("can I set the default model gpt-5.5 later?").kind).toBe(
"none",
);
});
it("parses gateway lifecycle operations", () => {
expect(parseSystemAgentOperation("gateway status")).toEqual({ kind: "gateway-status" });
expect(parseSystemAgentOperation("restart gateway")).toEqual({ kind: "gateway-restart" });
expect(parseSystemAgentOperation("start gateway")).toEqual({ kind: "gateway-start" });
expect(parseSystemAgentOperation("stop gateway")).toEqual({ kind: "gateway-stop" });
});
it("parses config and doctor repair operations", () => {
expect(parseSystemAgentOperation("validate config")).toEqual({ kind: "config-validate" });
expect(parseSystemAgentOperation("config set gateway.port 19001")).toEqual({
kind: "config-set",
path: "gateway.port",
value: "19001",
});
expect(parseSystemAgentOperation('config set channels.defaults.groupPolicy "open"')).toEqual({
kind: "config-set",
path: "channels.defaults.groupPolicy",
value: '"open"',
});
expect(
parseSystemAgentOperation(
'config set channels.modelByChannel.telegram.chat "openai/gpt-5.5"',
),
).toEqual({
kind: "config-set",
path: "channels.modelByChannel.telegram.chat",
value: '"openai/gpt-5.5"',
});
expect(
parseSystemAgentOperation(
'config set channels.modelByChannel["token=prod"].chat "openai/gpt-5.5"',
),
).toEqual({
kind: "config-set",
path: 'channels.modelByChannel["token=prod"].chat',
value: '"openai/gpt-5.5"',
});
expect(
parseSystemAgentOperation(
'config set channels.synology-chat.accounts["prod.guild"].webhookUrl "secret"',
),
).toEqual({
kind: "config-set",
path: 'channels.synology-chat.accounts["prod.guild"].webhookUrl',
value: '"secret"',
});
expect(
parseSystemAgentOperation(
String.raw`config set channels.synology-chat.accounts.prod\ guild.webhookUrl "secret"`,
),
).toEqual({
kind: "config-set",
path: String.raw`channels.synology-chat.accounts.prod\ guild.webhookUrl`,
value: '"secret"',
});
expect(
parseSystemAgentOperation(
'config set channels.synology-chat.accounts["prod=us"].webhookUrl "secret"',
),
).toEqual({
kind: "config-set",
path: 'channels.synology-chat.accounts["prod=us"].webhookUrl',
value: '"secret"',
});
expect(
parseSystemAgentOperation(
'config set plugins.entries.codex.config.appServer.headers["X-Test"] "secret"',
),
).toEqual({
kind: "config-set",
path: 'plugins.entries.codex.config.appServer.headers["X-Test"]',
value: '"secret"',
});
expect(
parseSystemAgentOperation(
'config set channels.synology-chat.accounts["token=prod"].webhookUrl "secret"',
),
).toEqual({
kind: "config-set",
path: 'channels.synology-chat.accounts["token=prod"].webhookUrl',
value: '"secret"',
});
expect(
parseSystemAgentOperation(
String.raw`config set plugins.entries.codex.config.appServer.headers.X\-Test "secret"`,
),
).toEqual({
kind: "config-set",
path: String.raw`plugins.entries.codex.config.appServer.headers.X\-Test`,
value: '"secret"',
});
expect(
parseSystemAgentOperation(
String.raw`config set channels.synology-chat.accounts.token\=prod.webhookUrl "secret"`,
),
).toEqual({
kind: "config-set",
path: String.raw`channels.synology-chat.accounts.token\=prod.webhookUrl`,
value: '"secret"',
});
expect(
parseSystemAgentOperation(
'config set channels.synology-chat.accounts["token=prod"].webhookPath "/hook"',
),
).toEqual({
kind: "config-set",
path: 'channels.synology-chat.accounts["token=prod"].webhookPath',
value: '"/hook"',
});
expect(
parseSystemAgentOperation(
String.raw`config set channels.synology-chat.accounts.token\=prod.webhookPath "/hook"`,
),
).toEqual({
kind: "config-set",
path: String.raw`channels.synology-chat.accounts.token\=prod.webhookPath`,
value: '"/hook"',
});
expect(
parseSystemAgentOperation(
'config set-ref channels.telegram.accounts["prod=us"].botToken env TELEGRAM_TOKEN',
),
).toEqual({
kind: "config-set-ref",
path: 'channels.telegram.accounts["prod=us"].botToken',
source: "env",
id: "TELEGRAM_TOKEN",
});
expect(
parseSystemAgentOperation(
String.raw`config set-ref channels.telegram.accounts.prod\=us.botToken env TELEGRAM_TOKEN`,
),
).toEqual({
kind: "config-set-ref",
path: String.raw`channels.telegram.accounts.prod\=us.botToken`,
source: "env",
id: "TELEGRAM_TOKEN",
});
expect(parseSystemAgentOperation('config set broadcast["token=prod"] ["telegram"]')).toEqual({
kind: "config-set",
path: 'broadcast["token=prod"]',
value: '["telegram"]',
});
expect(
parseSystemAgentOperation('config set hooks.mappings["token=abcDEF123"].agentId main'),
).toEqual({
kind: "none",
message: "Invalid config path. Check its quoting or escaping and try again.",
});
expect(
parseSystemAgentOperation(
'config set channels.buzz.groups["gateway.auth.token=ACTUAL_GATEWAY_TOKEN"].enabled true',
),
).toEqual({
kind: "none",
message: "Invalid config path. Check its quoting or escaping and try again.",
});
expect(parseSystemAgentOperation("config set gateway.auth..token very-secret")).toEqual({
kind: "none",
message: "Invalid config path. Check its quoting or escaping and try again.",
});
expect(parseSystemAgentOperation("config set gateway.auth.token=very-secret")).toEqual({
kind: "none",
message: "Invalid config path. Check its quoting or escaping and try again.",
});
expect(parseSystemAgentOperation("config set gateway.auth.token=very-secret please")).toEqual({
kind: "none",
message: "Invalid config path. Check its quoting or escaping and try again.",
});
expect(
parseSystemAgentOperation('config set channels.synology-chat["webhookUrl=abcDEF123"] please'),
).toEqual({
kind: "none",
message: "Invalid config path. Check its quoting or escaping and try again.",
});
for (const command of [
String.raw`config set gateway.auth.token\=very-secret please`,
String.raw`config set gateway.auth.token\ very-secret please`,
"config set gateway.auth.tokenabcDEF123 please",
"config set gateway.auth.token_abcDEF123 please",
"config set gateway.auth.token$abcDEF123 please",
'config set gateway.auth["token=very-secret"] please',
'config set gateway.auth["token very-secret"] please',
'config set gateway.auth["token:very-secret"] please',
'config set gateway.auth["token=very-secret"].nested please',
]) {
expect(parseSystemAgentOperation(command)).toEqual({
kind: "none",
message: "Invalid config path. Check its quoting or escaping and try again.",
});
}
expect(
parseSystemAgentOperation("config set-ref gateway.auth.token env GATEWAY_TOKEN"),
).toEqual({
kind: "config-set-ref",
path: "gateway.auth.token",
source: "env",
id: "GATEWAY_TOKEN",
});
expect(
parseSystemAgentOperation("config set-ref gateway.auth.token store GATEWAY_TOKEN"),
).toEqual({
kind: "config-set-ref",
path: "gateway.auth.token",
source: "store",
id: "GATEWAY_TOKEN",
});
expect(
parseSystemAgentOperation("config set-ref gateway.auth.tokenabcDEF123 env GATEWAY_TOKEN"),
).toEqual({
kind: "none",
message: "Invalid config path. Check its quoting or escaping and try again.",
});
expect(
parseSystemAgentOperation("config set-ref gateway.auth.token=abcDEF123 env GATEWAY_TOKEN"),
).toEqual({
kind: "none",
message: "Invalid config path. Check its quoting or escaping and try again.",
});
expect(
parseSystemAgentOperation("config set-ref gateway.auth.token env 123:actual-gateway-token"),
).toEqual({
kind: "none",
message: "Invalid config path. Check its quoting or escaping and try again.",
});
expect(parseSystemAgentOperation("doctor fix")).toEqual({ kind: "doctor-fix" });
});
it("parses plugin management operations", () => {
expect(parseSystemAgentOperation("plugins list")).toEqual({ kind: "plugin-list" });
expect(parseSystemAgentOperation("list plugin")).toEqual({ kind: "plugin-list" });
expect(parseSystemAgentOperation("plugins search calendar sync")).toEqual({
kind: "plugin-search",
query: "calendar sync",
});
expect(parseSystemAgentOperation("install npm plugin @openclaw/discord")).toEqual({
kind: "plugin-install",
spec: "npm:@openclaw/discord",
});
expect(parseSystemAgentOperation("plugin install clawhub:openclaw-demo")).toEqual({
kind: "plugin-install",
spec: "clawhub:openclaw-demo",
});
expect(parseSystemAgentOperation("plugin uninstall openclaw-demo")).toEqual({
kind: "plugin-uninstall",
pluginId: "openclaw-demo",
});
expect(parseSystemAgentOperation("plugin install npm:@example/plugin")).toEqual({
kind: "none",
message:
"OpenClaw installs only ClawHub, bundled, or official-catalog plugins. Use `openclaw plugins install <spec>` in a trusted shell to review an arbitrary executable source.",
});
});
it("parses config read and schema lookups", () => {
expect(parseSystemAgentOperation("config get gateway.port")).toEqual({
kind: "config-get",
path: "gateway.port",
});
expect(parseSystemAgentOperation("config schema channels.telegram")).toEqual({
kind: "config-schema",
path: "channels.telegram",
});
expect(parseSystemAgentOperation("config schema")).toEqual({ kind: "config-schema" });
expect(parseSystemAgentOperation("config schema .")).toEqual({
kind: "config-schema",
path: ".",
});
expect(
parseSystemAgentOperation('config get channels.telegram.accounts["prod=us"].botToken'),
).toEqual({
kind: "config-get",
path: 'channels.telegram.accounts["prod=us"].botToken',
});
expect(parseSystemAgentOperation("config get channels.missing.opaque")).toEqual({
kind: "config-get",
path: "channels.missing.opaque",
});
for (const command of [
"config get gateway.auth.tokenabcDEF123",
'config get gateway.auth["token=abcDEF123"]',
String.raw`config get gateway.auth.token\=abcDEF123`,
"config get gateway.auth.token abcDEF123",
"config get channels.missing.opaque=abcDEF123",
"config schema gateway.port=abcDEF123",
"config schema gateway.auth.token=abcDEF123",
'config schema gateway.auth["token=abcDEF123"]',
"config schema channels.missing.opaque=abcDEF123",
]) {
expect(parseSystemAgentOperation(command)).toEqual({
kind: "none",
message: "Invalid config path. Check its quoting or escaping and try again.",
});
}
// Read-only: no approval gate.
expect(isPersistentSystemAgentOperation({ kind: "config-get", path: "gateway.port" })).toBe(
false,
);
expect(isPersistentSystemAgentOperation({ kind: "config-schema" })).toBe(false);
});
it("parses agent creation requests", () => {
expect(
parseSystemAgentOperation("create agent Work workspace /tmp/work model openai/gpt-5.2"),
).toEqual({
kind: "create-agent",
agentId: "work",
workspace: "/tmp/work",
model: "openai/gpt-5.2",
});
expect(parseSystemAgentOperation("add agent ops")).toEqual({
kind: "create-agent",
agentId: "ops",
});
expect(parseSystemAgentOperation("setup workspace /tmp/work model openai/gpt-5.5")).toEqual({
kind: "setup",
workspace: "/tmp/work",
model: "openai/gpt-5.5",
});
expect(parseSystemAgentOperation("setup agent ops")).toEqual({
kind: "create-agent",
agentId: "ops",
});
});
});
+157 -35
View File
@@ -1,13 +1,21 @@
// OpenClaw operation grammar, approval descriptions, and public types.
import { parseConfigSetPath } from "../cli/config-cli-path.js";
import type { ConfigSetOptions } from "../cli/config-set-input.js";
import type { DoctorOptions } from "../commands/doctor.types.js";
import { isSensitiveConfigPath } from "../config/sensitive-paths.js";
import { DEFAULT_SECRET_PROVIDER_ALIAS } from "../config/types.secrets.js";
import { normalizeAgentId } from "../routing/session-key.js";
import type { RuntimeEnv } from "../runtime.js";
import { isValidSecretRef } from "../secrets/ref-contract.js";
import type { TuiResult } from "../tui/tui-types.js";
import { resolveUserPath, shortenHomePath } from "../utils.js";
import { isReservedSystemAgentId } from "./agent-id.js";
import {
isSystemAgentSensitiveConfigPathEmbedding,
isSystemAgentSensitiveConfigValue,
redactSystemAgentConfigPath,
} from "./config-redaction.js";
import type { SystemAgentOperation } from "./operation-types.js";
import { INVALID_CONFIG_SET_MESSAGE } from "./operations-internal.js";
import type { SystemAgentOverview } from "./overview.js";
import { validateSystemAgentPluginInstallSpec } from "./plugin-install-spec.js";
@@ -76,21 +84,15 @@ export type SystemAgentCommandDeps = {
// Grammar tokens. Workspace/path tokens accept quoted strings so paths with
// spaces survive; model refs and ids stay single tokens.
const ARG_WORD = String.raw`(?:"[^"]+"|'[^']+'|\S+)`;
const CONFIG_PATH = String.raw`[A-Za-z0-9_.[\]-]+`;
// Every command pattern is anchored to the whole input. Optional clauses use a
// fixed order (workspace before model) so filler words never become values.
const CONFIG_SET_RE = new RegExp(
String.raw`^(?:config\s+set|set\s+config)\s+(?<path>${CONFIG_PATH})\s+(?<value>.+)$`,
"i",
);
const CONFIG_GET_RE = new RegExp(String.raw`^config\s+get\s+(?<path>${CONFIG_PATH})$`, "i");
const CONFIG_SCHEMA_RE = new RegExp(
String.raw`^config\s+schema(?:\s+(?<path>${CONFIG_PATH}))?$`,
"i",
);
const CONFIG_SET_REF_RE = new RegExp(
String.raw`^(?:config\s+set-ref|set\s+secretref|set\s+secret\s+ref)\s+(?<path>${CONFIG_PATH})\s+(?:(?<source>env|file|exec|store)\s+)?(?<id>\S+)(?:\s+provider\s+(?<provider>[A-Za-z0-9_-]+))?$`,
const CONFIG_SET_PREFIX_RE = /^(?:config\s+set|set\s+config)\s+/i;
const CONFIG_SET_REF_PREFIX_RE = /^(?:config\s+set-ref|set\s+secretref|set\s+secret\s+ref)\s+/i;
const CONFIG_GET_PREFIX_RE = /^config\s+get(?=\s|$)/i;
const CONFIG_SCHEMA_PREFIX_RE = /^config\s+schema(?=\s|$)/i;
const CONFIG_SET_REF_ARGS_RE = new RegExp(
String.raw`^(?:(?<source>env|file|exec|store)\s+)?(?<id>\S+)(?:\s+provider\s+(?<provider>[A-Za-z0-9_-]+))?$`,
"i",
);
const SETUP_RE = new RegExp(
@@ -141,6 +143,114 @@ const OPEN_GATEWAY_SETUP_RE = /^open\s+gateway\s+wizard$/i;
const NO_MATCH_MESSAGE =
"I can run doctor/status/health, check or restart Gateway, configure gateway settings, list agents/models, configure skills or web search, import memory, set default model, connect channels (`connect telegram`), show `channel info <channel>`, open the setup wizard, show audit, or switch to your agent TUI.";
function parseConfigSetCommand(
input: string,
): { path: string; value: string; valid: true } | { valid: false } | undefined {
const prefix = input.match(CONFIG_SET_PREFIX_RE)?.[0];
if (!prefix) {
return undefined;
}
const body = input.slice(prefix.length);
for (const separator of body.matchAll(/\s+/gu)) {
const path = body.slice(0, separator.index);
const value = body.slice(separator.index).trim();
if (!value) {
continue;
}
try {
// Reuse the writer's grammar so quoted/escaped dynamic keys cannot fall
// through to model-visible text while remaining valid config commands.
parseConfigSetPath(path);
if (isSystemAgentSensitiveConfigPathEmbedding(path)) {
return { valid: false };
}
return { path, value, valid: true };
} catch {
continue;
}
}
// Keep malformed writes on the host side so their values never reach the
// model. This outcome is deliberately non-executable.
return body.trim() ? { valid: false } : undefined;
}
function parseConfigReadPath(
input: string,
prefixPattern: RegExp,
options: { allowEmpty: boolean; allowRoot?: boolean },
): { path?: string; valid: true } | { valid: false } | undefined {
const prefix = input.match(prefixPattern)?.[0];
if (!prefix) {
return undefined;
}
const path = input.slice(prefix.length).trim();
if (!path) {
return options.allowEmpty ? { valid: true } : { valid: false };
}
if (options.allowRoot && path === ".") {
return { path, valid: true };
}
try {
parseConfigSetPath(path);
return isSystemAgentSensitiveConfigPathEmbedding(path)
? { valid: false }
: { path, valid: true };
} catch {
return { valid: false };
}
}
function parseConfigSetRefCommand(input: string):
| {
path: string;
source: "env" | "file" | "exec" | "store";
id: string;
provider?: string;
valid: true;
}
| { valid: false }
| undefined {
const prefix = input.match(CONFIG_SET_REF_PREFIX_RE)?.[0];
if (!prefix) {
return undefined;
}
const body = input.slice(prefix.length);
for (const separator of body.matchAll(/\s+/gu)) {
const path = body.slice(0, separator.index);
const args = body.slice(separator.index).trim().match(CONFIG_SET_REF_ARGS_RE);
if (!args?.groups?.id) {
continue;
}
try {
parseConfigSetPath(path);
if (isSystemAgentSensitiveConfigPathEmbedding(path)) {
return { valid: false };
}
} catch {
continue;
}
const source = (args.groups.source?.toLowerCase() ?? "env") as
| "env"
| "file"
| "exec"
| "store";
const id = args.groups.id.trim();
const provider = args.groups.provider ?? DEFAULT_SECRET_PROVIDER_ALIAS;
if (!isValidSecretRef({ source, provider, id })) {
return { valid: false };
}
return {
path,
source,
id,
...(args.groups.provider ? { provider: args.groups.provider } : {}),
valid: true,
};
}
return body.trim() ? { valid: false } : undefined;
}
/**
* Parse one user command into OpenClaw's closed operation union. Anything
* that does not match the anchored grammar exactly returns kind "none" so the
@@ -191,34 +301,46 @@ export function parseSystemAgentOperation(input: string): SystemAgentOperation {
default:
break;
}
const configSetRefMatch = trimmed.match(CONFIG_SET_REF_RE);
if (configSetRefMatch?.groups?.path && configSetRefMatch.groups.id?.trim()) {
// SecretRef commands store references only; raw secret values are never embedded here.
const source = configSetRefMatch.groups.source?.toLowerCase() ?? "env";
const configSetRef = parseConfigSetRefCommand(trimmed);
if (configSetRef?.valid) {
return {
kind: "config-set-ref",
path: configSetRefMatch.groups.path,
source: source as "env" | "file" | "exec" | "store",
id: configSetRefMatch.groups.id.trim(),
...(configSetRefMatch.groups.provider ? { provider: configSetRefMatch.groups.provider } : {}),
path: configSetRef.path,
source: configSetRef.source,
id: configSetRef.id,
...(configSetRef.provider ? { provider: configSetRef.provider } : {}),
};
}
const configSetMatch = trimmed.match(CONFIG_SET_RE);
if (configSetMatch?.groups?.path && configSetMatch.groups.value?.trim()) {
if (configSetRef && !configSetRef.valid) {
return { kind: "none", message: INVALID_CONFIG_SET_MESSAGE };
}
const configSet = parseConfigSetCommand(trimmed);
if (configSet) {
if (!configSet.valid) {
return { kind: "none", message: INVALID_CONFIG_SET_MESSAGE };
}
return {
kind: "config-set",
path: configSetMatch.groups.path,
value: configSetMatch.groups.value.trim(),
path: configSet.path,
value: configSet.value,
};
}
const configGetMatch = trimmed.match(CONFIG_GET_RE);
if (configGetMatch?.groups?.path) {
return { kind: "config-get", path: configGetMatch.groups.path };
const configGet = parseConfigReadPath(trimmed, CONFIG_GET_PREFIX_RE, { allowEmpty: false });
if (configGet?.valid && configGet.path) {
return { kind: "config-get", path: configGet.path };
}
const configSchemaMatch = trimmed.match(CONFIG_SCHEMA_RE);
if (configSchemaMatch) {
const path = configSchemaMatch.groups?.path?.trim();
return { kind: "config-schema", ...(path ? { path } : {}) };
if (configGet && !configGet.valid) {
return { kind: "none", message: INVALID_CONFIG_SET_MESSAGE };
}
const configSchema = parseConfigReadPath(trimmed, CONFIG_SCHEMA_PREFIX_RE, {
allowEmpty: true,
allowRoot: true,
});
if (configSchema?.valid) {
return { kind: "config-schema", ...(configSchema.path ? { path: configSchema.path } : {}) };
}
if (configSchema && !configSchema.valid) {
return { kind: "none", message: INVALID_CONFIG_SET_MESSAGE };
}
if (PLUGIN_LIST_RE.test(trimmed)) {
return { kind: "plugin-list" };
@@ -408,9 +530,9 @@ export function describeSystemAgentPersistentOperation(operation: SystemAgentOpe
? `set agent ${operation.agentId}'s model to ${operation.model}`
: `set agents.defaults.model.primary to ${operation.model}`;
case "config-set":
return `set config ${operation.path} to ${formatConfigSetValueForPlan(operation.path, operation.value)}`;
return `set config ${redactSystemAgentConfigPath(operation.path)} to ${formatConfigSetValueForPlan(operation.path, operation.value)}`;
case "config-set-ref":
return `set config ${operation.path} to ${operation.source} SecretRef ${operation.source === "env" ? operation.id : "<redacted>"}`;
return `set config ${redactSystemAgentConfigPath(operation.path)} to ${operation.source} SecretRef <redacted>`;
case "setup":
return formatSetupPlanDescription(operation);
case "model-setup":
@@ -444,7 +566,7 @@ function formatCreateAgentWorkspace(workspace: string | undefined): string {
}
function formatConfigSetValueForPlan(configPath: string, value: string): string {
if (isSensitiveConfigPath(configPath)) {
if (isSystemAgentSensitiveConfigValue(configPath, value)) {
return "<redacted>";
}
return value;
+173 -143
View File
@@ -4,6 +4,10 @@ import path from "node:path";
import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import {
clearRuntimeConfigSnapshot,
setRuntimeConfigSnapshot,
} from "../config/runtime-snapshot.js";
import { resetPluginStateStoreForTests } from "../plugin-state/plugin-state-store.js";
import type { RuntimeEnv } from "../runtime.js";
import { captureEnv, setTestEnvValue } from "../test-utils/env.js";
@@ -13,9 +17,9 @@ import {
describeSystemAgentPersistentOperation,
executeSystemAgentOperation,
isPersistentSystemAgentOperation,
parseSystemAgentOperation,
} from "./operations.js";
import { createSystemAgentTestRuntime } from "./system-agent.runtime.test-support.js";
import { installSystemAgentPluginMetadataTestSnapshot } from "./system-agent.test-helpers.js";
type TestConfig = Record<string, unknown>;
@@ -59,7 +63,9 @@ const mockConfig = vi.hoisted(() => {
const state = {
path: "/tmp/openclaw.json",
exists: true,
valid: true,
config: initial as TestConfig,
pinnedConfig: undefined as TestConfig | undefined,
sourceConfigBeforeMigrations: undefined as TestConfig | undefined,
hash: "mock-hash-0" as string | undefined,
};
@@ -74,7 +80,7 @@ const mockConfig = vi.hoisted(() => {
sourceConfigBeforeMigrations: structuredClone(state.sourceConfigBeforeMigrations ?? config),
sourceConfig: config,
resolved: config,
valid: state.exists,
valid: state.valid,
runtimeConfig: config,
config,
hash: state.hash,
@@ -87,22 +93,32 @@ const mockConfig = vi.hoisted(() => {
reset() {
state.path = "/tmp/openclaw.json";
state.exists = true;
state.valid = true;
state.config = {};
state.pinnedConfig = undefined;
state.sourceConfigBeforeMigrations = undefined;
state.hash = "mock-hash-0";
},
missing(pathLocal: string) {
state.path = pathLocal;
state.exists = false;
state.valid = false;
state.config = {};
state.pinnedConfig = undefined;
state.sourceConfigBeforeMigrations = undefined;
state.hash = undefined;
},
currentConfig() {
return cloneConfig();
},
setConfig(config: TestConfig) {
state.config = structuredClone(config);
state.valid = true;
state.pinnedConfig = undefined;
state.sourceConfigBeforeMigrations = undefined;
},
setInvalidConfig(config: TestConfig, pinnedConfig?: TestConfig) {
state.exists = true;
state.valid = false;
state.config = structuredClone(config);
state.pinnedConfig = pinnedConfig ? structuredClone(pinnedConfig) : undefined;
state.sourceConfigBeforeMigrations = undefined;
},
setResolvedConfig(config: TestConfig, sourceConfigBeforeMigrations: TestConfig) {
@@ -110,6 +126,15 @@ const mockConfig = vi.hoisted(() => {
state.sourceConfigBeforeMigrations = structuredClone(sourceConfigBeforeMigrations);
},
readConfigFileSnapshot: vi.fn(async () => snapshot()),
getRuntimeConfig() {
if (state.pinnedConfig) {
return structuredClone(state.pinnedConfig);
}
if (!state.valid) {
throw new Error("invalid runtime config");
}
return cloneConfig();
},
mutateConfigFile: vi.fn(
async (params: {
writeOptions?: {
@@ -200,12 +225,13 @@ vi.mock("./overview.js", () => ({
}));
vi.mock("../config/config.js", () => ({
getRuntimeConfig: () => mockConfig.getRuntimeConfig(),
mutateConfigFile: mockConfig.mutateConfigFile,
readConfigFileSnapshot: mockConfig.readConfigFileSnapshot,
}));
const opTempDirs = useAutoCleanupTempDirTracker(afterEach);
describe("parseSystemAgentOperation", () => {
describe("system agent operations", () => {
let stateDirSnapshot: ReturnType<typeof captureEnv> | undefined;
beforeEach(() => {
@@ -222,123 +248,6 @@ describe("parseSystemAgentOperation", () => {
vi.unstubAllEnvs();
});
it("parses typed model writes", () => {
expect(parseSystemAgentOperation("set default model openai/gpt-5.2")).toEqual({
kind: "set-default-model",
model: "openai/gpt-5.2",
});
expect(parseSystemAgentOperation("configure models openai/gpt-5.2")).toEqual({
kind: "set-default-model",
model: "openai/gpt-5.2",
});
});
it("parses interactive model provider setup", () => {
expect(parseSystemAgentOperation("configure model provider")).toEqual({
kind: "model-setup",
});
expect(parseSystemAgentOperation("setup model provider")).toEqual({
kind: "model-setup",
});
expect(parseSystemAgentOperation("model setup workspace /tmp/work")).toEqual({
kind: "model-setup",
workspace: "/tmp/work",
});
});
it("parses verbal agent switching", () => {
expect(parseSystemAgentOperation("talk to work agent")).toEqual({
kind: "open-tui",
agentId: "work",
});
});
it("routes ambiguous model requests to the AI instead of guessing", () => {
expect(parseSystemAgentOperation("models please").kind).toBe("none");
expect(parseSystemAgentOperation("why did my gateway stop").kind).toBe("none");
expect(parseSystemAgentOperation("should I talk to my agent about this?").kind).toBe("none");
expect(parseSystemAgentOperation("set me up with telegram").kind).toBe("none");
expect(parseSystemAgentOperation("can I set the default model gpt-5.5 later?").kind).toBe(
"none",
);
});
it("parses gateway lifecycle operations", () => {
expect(parseSystemAgentOperation("gateway status")).toEqual({ kind: "gateway-status" });
expect(parseSystemAgentOperation("restart gateway")).toEqual({ kind: "gateway-restart" });
expect(parseSystemAgentOperation("start gateway")).toEqual({ kind: "gateway-start" });
expect(parseSystemAgentOperation("stop gateway")).toEqual({ kind: "gateway-stop" });
});
it("parses config and doctor repair operations", () => {
expect(parseSystemAgentOperation("validate config")).toEqual({ kind: "config-validate" });
expect(parseSystemAgentOperation("config set gateway.port 19001")).toEqual({
kind: "config-set",
path: "gateway.port",
value: "19001",
});
expect(
parseSystemAgentOperation("config set-ref gateway.auth.token env GATEWAY_TOKEN"),
).toEqual({
kind: "config-set-ref",
path: "gateway.auth.token",
source: "env",
id: "GATEWAY_TOKEN",
});
expect(
parseSystemAgentOperation("config set-ref gateway.auth.token store GATEWAY_TOKEN"),
).toEqual({
kind: "config-set-ref",
path: "gateway.auth.token",
source: "store",
id: "GATEWAY_TOKEN",
});
expect(parseSystemAgentOperation("doctor fix")).toEqual({ kind: "doctor-fix" });
});
it("parses plugin management operations", () => {
expect(parseSystemAgentOperation("plugins list")).toEqual({ kind: "plugin-list" });
expect(parseSystemAgentOperation("list plugin")).toEqual({ kind: "plugin-list" });
expect(parseSystemAgentOperation("plugins search calendar sync")).toEqual({
kind: "plugin-search",
query: "calendar sync",
});
expect(parseSystemAgentOperation("install npm plugin @openclaw/discord")).toEqual({
kind: "plugin-install",
spec: "npm:@openclaw/discord",
});
expect(parseSystemAgentOperation("plugin install clawhub:openclaw-demo")).toEqual({
kind: "plugin-install",
spec: "clawhub:openclaw-demo",
});
expect(parseSystemAgentOperation("plugin uninstall openclaw-demo")).toEqual({
kind: "plugin-uninstall",
pluginId: "openclaw-demo",
});
expect(parseSystemAgentOperation("plugin install npm:@example/plugin")).toEqual({
kind: "none",
message:
"OpenClaw installs only ClawHub, bundled, or official-catalog plugins. Use `openclaw plugins install <spec>` in a trusted shell to review an arbitrary executable source.",
});
});
it("parses config read and schema lookups", () => {
expect(parseSystemAgentOperation("config get gateway.port")).toEqual({
kind: "config-get",
path: "gateway.port",
});
expect(parseSystemAgentOperation("config schema channels.telegram")).toEqual({
kind: "config-schema",
path: "channels.telegram",
});
expect(parseSystemAgentOperation("config schema")).toEqual({ kind: "config-schema" });
// Read-only: no approval gate.
expect(isPersistentSystemAgentOperation({ kind: "config-get", path: "gateway.port" })).toBe(
false,
);
expect(isPersistentSystemAgentOperation({ kind: "config-schema" })).toBe(false);
});
it("redacts sensitive config values using their complete paths", async () => {
mockConfig.setConfig({
models: {
@@ -369,28 +278,149 @@ describe("parseSystemAgentOperation", () => {
).toBe("set config models.providers.local.localService.env.HF_HOME to <redacted>");
});
it("parses agent creation requests", () => {
expect(
parseSystemAgentOperation("create agent Work workspace /tmp/work model openai/gpt-5.2"),
).toEqual({
kind: "create-agent",
agentId: "work",
workspace: "/tmp/work",
model: "openai/gpt-5.2",
it("keeps invalid config reads available without exposing recovery secrets", async () => {
mockConfig.setInvalidConfig(
{
gateway: { port: 19_001, auth: { token: "recovery-secret" } },
plugins: {
entries: { missing: { config: { opaque: "invalid-plugin-secret" } } },
},
},
{},
);
const { runtime, lines } = createSystemAgentTestRuntime();
await executeSystemAgentOperation({ kind: "config-get", path: "gateway" }, runtime);
await executeSystemAgentOperation(
{ kind: "config-get", path: "plugins.entries.missing" },
runtime,
);
const output = lines.join("\n");
expect(output).toContain('"port": 19001');
expect(output).toContain('"token": "<redacted>"');
expect(output).toContain('"config": "<redacted>"');
expect(output).not.toContain("recovery-secret");
expect(output).not.toContain("invalid-plugin-secret");
});
it("fails closed for model-visible config owned by missing plugins and channels", async () => {
mockConfig.setConfig({
plugins: {
entries: {
missing: { enabled: true, config: { opaque: "missing-plugin-secret" } },
},
},
channels: { missing: { enabled: true, opaque: "missing-channel-secret" } },
});
expect(parseSystemAgentOperation("add agent ops")).toEqual({
kind: "create-agent",
agentId: "ops",
});
expect(parseSystemAgentOperation("setup workspace /tmp/work model openai/gpt-5.5")).toEqual({
kind: "setup",
workspace: "/tmp/work",
model: "openai/gpt-5.5",
});
expect(parseSystemAgentOperation("setup agent ops")).toEqual({
kind: "create-agent",
agentId: "ops",
const { runtime, lines } = createSystemAgentTestRuntime();
await executeSystemAgentOperation(
{ kind: "config-get", path: "plugins.entries.missing" },
runtime,
);
await executeSystemAgentOperation({ kind: "config-get", path: "channels.missing" }, runtime);
const output = lines.join("\n");
expect(output).toContain('"enabled": true');
expect(output).toContain('"config": "<redacted>"');
expect(output).toContain('channels.missing = "<redacted>"');
expect(output).not.toContain("missing-plugin-secret");
expect(output).not.toContain("missing-channel-secret");
});
it("preserves kernel-owned channel namespaces in model-visible config reads", async () => {
mockConfig.setConfig({
channels: {
defaults: { groupPolicy: "open" },
modelByChannel: { telegram: { chat: "openai/gpt-5.5" } },
},
});
const { runtime, lines } = createSystemAgentTestRuntime();
await executeSystemAgentOperation({ kind: "config-get", path: "channels.defaults" }, runtime);
await executeSystemAgentOperation(
{ kind: "config-get", path: "channels.modelByChannel" },
runtime,
);
const output = lines.join("\n");
expect(output).toContain('"groupPolicy": "open"');
expect(output).toContain('"chat": "openai/gpt-5.5"');
expect(output).not.toContain("<redacted>");
});
it("redacts config values marked sensitive only by active plugin metadata", async () => {
const authorization = "Bearer plugin-only-secret";
const config = {
plugins: {
entries: {
codex: { config: { appServer: { headers: { Authorization: authorization } } } },
},
},
};
mockConfig.setConfig(config);
const pluginMetadata = installSystemAgentPluginMetadataTestSnapshot(config);
const { runtime, lines } = createSystemAgentTestRuntime();
try {
await executeSystemAgentOperation(
{ kind: "config-get", path: "plugins.entries.codex.config.appServer" },
runtime,
);
} finally {
pluginMetadata.restore();
}
expect(lines.join("\n")).toContain('"headers": "<redacted>"');
expect(lines.join("\n")).not.toContain(authorization);
});
it("keeps sensitive channel callback URLs out of model-visible config reads", async () => {
const callbackUrl = "https://gateway.example/webhook/synology?access_token=callback-secret";
const incomingUrl = "https://nas.example/webapi/entry.cgi?token=incoming-secret";
const config = {
channels: {
"synology-chat": {
incomingUrl,
webhookUrl: callbackUrl,
accounts: {
work: { incomingUrl, webhookUrl: callbackUrl },
},
},
},
};
mockConfig.setConfig(config);
setRuntimeConfigSnapshot(config, config);
const pluginMetadata = installSystemAgentPluginMetadataTestSnapshot(config);
const { runtime, lines } = createSystemAgentTestRuntime();
try {
await executeSystemAgentOperation(
{ kind: "config-get", path: "channels.synology-chat" },
runtime,
);
expect(lines.join("\n")).toContain('"webhookUrl": "<redacted>"');
expect(lines.join("\n")).toContain('"incomingUrl": "<redacted>"');
expect(lines.join("\n")).not.toContain("callback-secret");
expect(lines.join("\n")).not.toContain("incoming-secret");
expect(
describeSystemAgentPersistentOperation({
kind: "config-set",
path: "channels.synology-chat.accounts.work.webhookUrl",
value: callbackUrl,
}),
).toBe("set config channels.synology-chat.accounts.work.webhookUrl to <redacted>");
expect(
describeSystemAgentPersistentOperation({
kind: "config-set",
path: "channels.synology-chat",
value: `{ webhookUrl: "${callbackUrl}" }`,
}),
).toBe("set config channels.synology-chat to <redacted>");
} finally {
pluginMetadata.restore();
clearRuntimeConfigSnapshot();
}
});
it("rejects an explicit new-agent model before any config write or audit", async () => {
+32
View File
@@ -188,6 +188,38 @@ describe("runSystemAgent", () => {
);
});
it.each([
"config set gateway.auth.token=very-secret",
"config set gateway.auth.token=very-secret please",
String.raw`config set gateway.auth.token\ very-secret please`,
"config set gateway.auth.tokenabcDEF123 please",
"config set gateway.auth.token_abcDEF123 please",
"config set gateway.auth.token$abcDEF123 please",
"config set-ref gateway.auth.tokenabcDEF123 env GATEWAY_TOKEN",
'config set gateway.auth["token:very-secret"] please',
])(
"keeps malformed config write %s away from the one-shot assistant planner",
async (message) => {
const { runtime, lines } = createSystemAgentTestRuntime();
const planWithAssistant = vi.fn(async () => ({ command: "restart gateway" }));
await runSystemAgent(
{
...createVerifiedRunOptions(),
message,
planWithAssistant,
...systemAgentOverviewDeps,
},
runtime,
);
expect(planWithAssistant).not.toHaveBeenCalled();
expect(lines.join("\n")).toContain("Invalid config path");
expect(lines.join("\n")).not.toContain("very-secret");
expect(lines.join("\n")).not.toContain("abcDEF123");
},
);
it("does not apply a one-shot plan after the verified route changes", async () => {
const { runtime } = createSystemAgentTestRuntime();
const changedConfig = {
+8 -10
View File
@@ -24,10 +24,7 @@ import { resolveCronTaskRecordTimestamp } from "../cron/task-run-detail.js";
import { getAgentRunContext } from "../infra/agent-run-registry.js";
import { getSessionBindingService } from "../infra/outbound/session-binding-service.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import {
isPluginStateDatabaseOpen,
sweepExpiredPluginStateEntries,
} from "../plugin-state/plugin-state-store.js";
import { sweepExpiredPluginStateEntries } from "../plugin-state/plugin-state-store.js";
import { runWithGatewayIndependentRootWorkAdmission } from "../process/gateway-work-admission.js";
import { parseAgentSessionKey } from "../routing/session-key.js";
import {
@@ -1111,12 +1108,13 @@ export async function runTaskRegistryMaintenance(): Promise<TaskRegistryMaintena
}
}
await cleanupOrphanedParentOwnedAcpSessions();
if (isPluginStateDatabaseOpen()) {
try {
sweepExpiredPluginStateEntries();
} catch (error) {
log.warn("Failed to sweep expired plugin state entries", { error });
}
try {
// Task-registry readiness has already opened the shared state database.
// Sweep plugin TTL rows even when no plugin namespace was opened this process,
// so expired state from removed accounts is reclaimed after restart.
sweepExpiredPluginStateEntries();
} catch (error) {
log.warn("Failed to sweep expired plugin state entries", { error });
}
return { reconciled, recovered, cleanupStamped, pruned };
}
+28
View File
@@ -13,6 +13,11 @@ import {
} from "../infra/heartbeat-wake.js";
import type { SessionBindingRecord } from "../infra/outbound/session-binding-service.js";
import { peekSystemEvents, resetSystemEventsForTest } from "../infra/system-events.js";
import {
createPluginStateKeyedStore,
resetPluginStateStoreForTests,
sweepExpiredPluginStateEntries,
} from "../plugin-state/plugin-state-store.js";
import {
beginGatewayRestartSignalAdmission,
getActiveGatewayRootWorkCount,
@@ -555,6 +560,29 @@ describe("task-registry", () => {
hoisted.killSubagentRunAdminMock.mockReset();
});
it("sweeps expired plugin state after restart before a plugin namespace reopens", async () => {
await withTaskRegistryTempDir(async () => {
try {
vi.useFakeTimers();
vi.setSystemTime(1_000);
const store = createPluginStateKeyedStore<{ value: string }>("fixture-plugin", {
namespace: "maintenance-restart",
maxEntries: 10,
});
await store.register("expired", { value: "stale" }, { ttlMs: 100 });
// Close plugin-state's process-local handle while preserving the shared SQLite file.
resetPluginStateStoreForTests();
vi.setSystemTime(1_200);
await runTaskRegistryMaintenance();
expect(sweepExpiredPluginStateEntries()).toBe(0);
} finally {
resetPluginStateStoreForTests();
}
});
});
it("updates task status from lifecycle events", async () => {
await withTaskRegistryTempDir(async () => {
resetTaskRegistryMemoryForTest();
+7
View File
@@ -930,6 +930,13 @@ export const en = {
outgoingWebhookPathKeep: "Outgoing webhook path set ({value}). Keep it?",
outgoingWebhookPathPrompt: "Outgoing webhook path (optional)",
outgoingWebhookPathTitle: "Synology Chat outgoing webhook path",
publicWebhookUrlHelp:
"Use the exact externally reachable HTTPS outgoing-webhook callback URL configured in Synology Chat.",
publicWebhookUrlKeep: "Public attachment webhook URL already configured. Keep it?",
publicWebhookUrlPrompt: "Public attachment webhook URL (optional)",
publicWebhookUrlScope:
"Expose only this webhook route; OpenClaw uses it for short-lived attachment downloads.",
publicWebhookUrlTitle: "Synology Chat public attachment route",
setupTitle: "Synology Chat webhook setup",
tokenEnvPrompt: "SYNOLOGY_CHAT_TOKEN detected. Use env var?",
tokenInput: "Enter Synology Chat outgoing webhook token",
+6
View File
@@ -900,6 +900,12 @@ export const zh_CN = {
outgoingWebhookPathKeep: "Outgoing webhook path 已设置({value})。保留?",
outgoingWebhookPathPrompt: "Outgoing webhook path(可选)",
outgoingWebhookPathTitle: "Synology Chat outgoing webhook path",
publicWebhookUrlHelp:
"使用在 Synology Chat 中配置的、可从外部访问的确切 HTTPS outgoing webhook 回调 URL。",
publicWebhookUrlKeep: "已配置公开附件 webhook URL。保留当前值?",
publicWebhookUrlPrompt: "公开附件 webhook URL(可选)",
publicWebhookUrlScope: "仅公开此 webhook 路由;OpenClaw 使用它提供短期附件下载。",
publicWebhookUrlTitle: "Synology Chat 公开附件路由",
setupTitle: "Synology Chat webhook 设置",
tokenEnvPrompt: "检测到 SYNOLOGY_CHAT_TOKEN。使用环境变量?",
tokenInput: "输入 Synology Chat outgoing webhook token",
+6
View File
@@ -901,6 +901,12 @@ export const zh_TW = {
outgoingWebhookPathKeep: "Outgoing webhook path 已設定({value})。保留?",
outgoingWebhookPathPrompt: "Outgoing webhook path(可選)",
outgoingWebhookPathTitle: "Synology Chat outgoing webhook path",
publicWebhookUrlHelp:
"使用在 Synology Chat 中設定、可從外部存取的確切 HTTPS outgoing webhook 回呼 URL。",
publicWebhookUrlKeep: "已設定公開附件 webhook URL。保留目前值?",
publicWebhookUrlPrompt: "公開附件 webhook URL(可選)",
publicWebhookUrlScope: "僅公開此 webhook 路由;OpenClaw 使用它提供短期附件下載。",
publicWebhookUrlTitle: "Synology Chat 公開附件路由",
setupTitle: "Synology Chat webhook 設定",
tokenEnvPrompt: "偵測到 SYNOLOGY_CHAT_TOKEN。使用環境變數?",
tokenInput: "輸入 Synology Chat outgoing webhook token",