mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 11:55:47 -06:00
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:
committed by
GitHub
parent
1b98bc35a0
commit
d5e89de906
@@ -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
|
||||
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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. */
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -46,7 +46,6 @@ export {
|
||||
closePluginStateDatabase,
|
||||
countPluginStateLiveEntries,
|
||||
getPluginStateCapacity,
|
||||
isPluginStateDatabaseOpen,
|
||||
MAX_PLUGIN_STATE_ENTRIES_PER_PLUGIN,
|
||||
pluginStateEntriesInKeyRange,
|
||||
resolveMaxPluginStateEntriesPerPlugin,
|
||||
|
||||
@@ -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 ?? [];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>" } },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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 () => {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user