diff --git a/docs/automation/standing-orders.md b/docs/automation/standing-orders.md index 495d6adee05..b0d52494fdb 100644 --- a/docs/automation/standing-orders.md +++ b/docs/automation/standing-orders.md @@ -16,12 +16,14 @@ This is the difference between telling your assistant "send the weekly report" e ## Why Standing Orders? **Without standing orders:** + - You must prompt the agent for every task - The agent sits idle between requests - Routine work gets forgotten or delayed - You become the bottleneck **With standing orders:** + - The agent executes autonomously within defined boundaries - Routine work happens on schedule without prompting - You only get involved for exceptions and approvals @@ -55,6 +57,7 @@ Put standing orders in `AGENTS.md` to guarantee they're loaded every session. Th **Escalation:** If data source is unavailable or metrics look unusual (>2σ from norm) ### Execution Steps + 1. Pull metrics from configured sources 2. Compare to prior week and targets 3. Generate report in Reports/weekly/YYYY-MM-DD.md @@ -62,6 +65,7 @@ Put standing orders in `AGENTS.md` to guarantee they're loaded every session. Th 5. Log completion to Agent/Logs/ ### What NOT to Do + - Do not send reports to external parties - Do not modify source data - Do not skip delivery if metrics look bad — report accurately @@ -105,11 +109,13 @@ openclaw cron create \ **Trigger:** Weekly cycle (Monday review → mid-week drafts → Friday brief) ### Weekly Cycle + - **Monday:** Review platform metrics and audience engagement - **Tuesday–Thursday:** Draft social posts, create blog content - **Friday:** Compile weekly marketing brief → deliver to owner ### Content Rules + - Voice must match the brand (see SOUL.md or brand voice guide) - Never identify as AI in public-facing content - Include metrics when available @@ -126,6 +132,7 @@ openclaw cron create \ **Trigger:** New data file detected OR scheduled monthly cycle ### When New Data Arrives + 1. Detect new file in designated input directory 2. Parse and categorize all transactions 3. Compare against budget targets @@ -134,6 +141,7 @@ openclaw cron create \ 6. Deliver summary to owner via configured channel ### Escalation Rules + - Single item > $500: immediate alert - Category > budget by 20%: flag in report - Unrecognizable transaction: ask owner for categorization @@ -150,18 +158,20 @@ openclaw cron create \ **Trigger:** Every heartbeat cycle ### Checks + - Service health endpoints responding - Disk space above threshold - Pending tasks not stale (>24 hours) - Delivery channels operational ### Response Matrix -| Condition | Action | Escalate? | -|-----------|--------|-----------| -| Service down | Restart automatically | Only if restart fails 2x | -| Disk space < 10% | Alert owner | Yes | -| Stale task > 24h | Remind owner | No | -| Channel offline | Log and retry next cycle | If offline > 2 hours | + +| Condition | Action | Escalate? | +| ---------------- | ------------------------ | ------------------------ | +| Service down | Restart automatically | Only if restart fails 2x | +| Disk space < 10% | Alert owner | Yes | +| Stale task > 24h | Remind owner | No | +| Channel offline | Log and retry next cycle | If offline > 2 hours | ``` ## The Execute-Verify-Report Pattern @@ -174,6 +184,7 @@ Standing orders work best when combined with strict execution discipline. Every ```markdown ### Execution Rules + - Every task follows Execute-Verify-Report. No exceptions. - "I'll do that" is not execution. Do it, then report. - "Done" without verification is not acceptable. Prove it. @@ -192,20 +203,25 @@ For agents managing multiple concerns, organize standing orders as separate prog # Standing Orders ## Program 1: [Domain A] (Weekly) + ... ## Program 2: [Domain B] (Monthly + On-Demand) + ... ## Program 3: [Domain C] (As-Needed) + ... ## Escalation Rules (All Programs) + - [Common escalation criteria] - [Approval gates that apply across programs] ``` Each program should have: + - Its own **trigger cadence** (weekly, monthly, event-driven, continuous) - Its own **approval gates** (some programs need more oversight than others) - Clear **boundaries** (the agent should know where one program ends and another begins) @@ -213,6 +229,7 @@ Each program should have: ## Best Practices ### Do + - Start with narrow authority and expand as trust builds - Define explicit approval gates for high-risk actions - Include "What NOT to do" sections — boundaries matter as much as permissions @@ -221,6 +238,7 @@ Each program should have: - Update standing orders as your needs evolve — they're living documents ### Don't + - Grant broad authority on day one ("do whatever you think is best") - Skip escalation rules — every program needs a "when to stop and ask" clause - Assume the agent will remember verbal instructions — put everything in the file diff --git a/docs/plugins/building-extensions.md b/docs/plugins/building-extensions.md index 259accaa3f0..bdbd384f192 100644 --- a/docs/plugins/building-extensions.md +++ b/docs/plugins/building-extensions.md @@ -100,6 +100,7 @@ import { createChannelPairingController } from "openclaw/plugin-sdk/channel-pair import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; import { createOptionalChannelSetupSurface } from "openclaw/plugin-sdk/channel-setup"; import { resolveChannelGroupRequireMention } from "openclaw/plugin-sdk/channel-policy"; +import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-oauth"; // Wrong: monolithic root (lint will reject this) import { ... } from "openclaw/plugin-sdk"; @@ -120,6 +121,7 @@ Common subpaths: | `plugin-sdk/runtime-store` | Persistent plugin storage | | `plugin-sdk/allow-from` | Allowlist resolution | | `plugin-sdk/reply-payload` | Message reply types | +| `plugin-sdk/provider-oauth` | OAuth login + PKCE helpers | | `plugin-sdk/provider-onboard` | Provider onboarding config patches | | `plugin-sdk/testing` | Test utilities | diff --git a/extensions/acpx/runtime-api.ts b/extensions/acpx/runtime-api.ts index 8d1d125f226..9a019cdd0e6 100644 --- a/extensions/acpx/runtime-api.ts +++ b/extensions/acpx/runtime-api.ts @@ -1 +1 @@ -export * from "openclaw/plugin-sdk/acpx"; +export * from "../../src/plugin-sdk/acpx.js"; diff --git a/extensions/chutes/index.ts b/extensions/chutes/index.ts index b715ad46c5a..89a2fc4a6fe 100644 --- a/extensions/chutes/index.ts +++ b/extensions/chutes/index.ts @@ -1,12 +1,12 @@ -import { definePluginEntry } from "openclaw/plugin-sdk/core"; +import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; import { - buildOauthProviderAuthResult, createProviderApiKeyAuthMethod, resolveOAuthApiKeyMarker, type ProviderAuthContext, type ProviderAuthResult, } from "openclaw/plugin-sdk/provider-auth"; import { loginChutes } from "openclaw/plugin-sdk/provider-auth-login"; +import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-oauth"; import { CHUTES_DEFAULT_MODEL_REF, applyChutesApiKeyConfig, diff --git a/extensions/device-pair/api.ts b/extensions/device-pair/api.ts index 299ad90f05d..eb4001b8a91 100644 --- a/extensions/device-pair/api.ts +++ b/extensions/device-pair/api.ts @@ -1 +1,8 @@ -export * from "openclaw/plugin-sdk/device-pair"; +export { + approveDevicePairing, + issueDeviceBootstrapToken, + listDevicePairing, +} from "openclaw/plugin-sdk/device-bootstrap"; +export { definePluginEntry, type OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; +export { resolveGatewayBindUrl, resolveTailnetHostWithRunner } from "openclaw/plugin-sdk/core"; +export { runPluginCommandWithTimeout } from "openclaw/plugin-sdk/sandbox"; diff --git a/extensions/google/gemini-cli-provider.ts b/extensions/google/gemini-cli-provider.ts index 412d02dd85f..77fa7077b5d 100644 --- a/extensions/google/gemini-cli-provider.ts +++ b/extensions/google/gemini-cli-provider.ts @@ -3,7 +3,7 @@ import type { ProviderAuthContext, ProviderFetchUsageSnapshotContext, } from "openclaw/plugin-sdk/plugin-entry"; -import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-auth"; +import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-oauth"; import { fetchGeminiUsage } from "openclaw/plugin-sdk/provider-usage"; import { isModernGoogleModel, resolveGoogle31ForwardCompatModel } from "./provider-models.js"; diff --git a/extensions/google/runtime-api.ts b/extensions/google/runtime-api.ts index 7deb5b38f92..60e25c7303e 100644 --- a/extensions/google/runtime-api.ts +++ b/extensions/google/runtime-api.ts @@ -1 +1 @@ -export * from "openclaw/plugin-sdk/google"; +export * from "../../src/plugin-sdk/google.js"; diff --git a/extensions/lobster/runtime-api.ts b/extensions/lobster/runtime-api.ts index 7ab2351b77d..24898e04cf5 100644 --- a/extensions/lobster/runtime-api.ts +++ b/extensions/lobster/runtime-api.ts @@ -1 +1 @@ -export * from "openclaw/plugin-sdk/lobster"; +export * from "../../src/plugin-sdk/lobster.js"; diff --git a/extensions/minimax/index.ts b/extensions/minimax/index.ts index e219ceec6a0..7dfd9816264 100644 --- a/extensions/minimax/index.ts +++ b/extensions/minimax/index.ts @@ -1,16 +1,16 @@ import { - buildOauthProviderAuthResult, definePluginEntry, type ProviderAuthContext, type ProviderAuthResult, type ProviderCatalogContext, -} from "openclaw/plugin-sdk/minimax-portal-auth"; +} from "openclaw/plugin-sdk/plugin-entry"; import { MINIMAX_OAUTH_MARKER, createProviderApiKeyAuthMethod, ensureAuthProfileStore, listProfilesForProvider, } from "openclaw/plugin-sdk/provider-auth"; +import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-oauth"; import { fetchMinimaxUsage } from "openclaw/plugin-sdk/provider-usage"; import { minimaxMediaUnderstandingProvider, diff --git a/extensions/minimax/oauth.ts b/extensions/minimax/oauth.ts index fb405cd5559..20296b2a710 100644 --- a/extensions/minimax/oauth.ts +++ b/extensions/minimax/oauth.ts @@ -2,7 +2,7 @@ import { randomBytes, randomUUID } from "node:crypto"; import { generatePkceVerifierChallenge, toFormUrlEncoded, -} from "openclaw/plugin-sdk/minimax-portal-auth"; +} from "openclaw/plugin-sdk/provider-oauth"; export type MiniMaxRegion = "cn" | "global"; diff --git a/extensions/openai/openai-codex-provider.ts b/extensions/openai/openai-codex-provider.ts index 66d182a341f..5027f486bb0 100644 --- a/extensions/openai/openai-codex-provider.ts +++ b/extensions/openai/openai-codex-provider.ts @@ -3,7 +3,6 @@ import type { ProviderResolveDynamicModelContext, ProviderRuntimeModel, } from "openclaw/plugin-sdk/plugin-entry"; -import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-auth"; import { CODEX_CLI_PROFILE_ID, ensureAuthProfileStore, @@ -17,6 +16,7 @@ import { normalizeProviderId, type ProviderPlugin, } from "openclaw/plugin-sdk/provider-models"; +import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-oauth"; import { createOpenAIAttributionHeadersWrapper } from "openclaw/plugin-sdk/provider-stream"; import { fetchCodexUsage } from "openclaw/plugin-sdk/provider-usage"; import { buildOpenAICodexProvider } from "./openai-codex-catalog.js"; diff --git a/extensions/phone-control/runtime-api.ts b/extensions/phone-control/runtime-api.ts index 7db40d08280..940bc8fe2ba 100644 --- a/extensions/phone-control/runtime-api.ts +++ b/extensions/phone-control/runtime-api.ts @@ -2,6 +2,6 @@ export { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; export type { OpenClawPluginApi, OpenClawPluginCommandDefinition, - OpenClawPluginService, PluginCommandContext, -} from "openclaw/plugin-sdk/core"; + OpenClawPluginService, +} from "openclaw/plugin-sdk/plugin-entry"; diff --git a/extensions/qwen-portal-auth/index.ts b/extensions/qwen-portal-auth/index.ts index e32eb8ef791..bcbc564dc33 100644 --- a/extensions/qwen-portal-auth/index.ts +++ b/extensions/qwen-portal-auth/index.ts @@ -1,9 +1,10 @@ -import { ensureAuthProfileStore, listProfilesForProvider } from "openclaw/plugin-sdk/agent-runtime"; -import { QWEN_OAUTH_MARKER } from "openclaw/plugin-sdk/agent-runtime"; import { buildQwenPortalProvider, QWEN_PORTAL_BASE_URL } from "./provider-catalog.js"; import { buildOauthProviderAuthResult, definePluginEntry, + ensureAuthProfileStore, + listProfilesForProvider, + QWEN_OAUTH_MARKER, refreshQwenPortalCredentials, type ProviderAuthContext, type ProviderCatalogContext, diff --git a/extensions/qwen-portal-auth/refresh.test.ts b/extensions/qwen-portal-auth/refresh.test.ts new file mode 100644 index 00000000000..2cbaeb65d27 --- /dev/null +++ b/extensions/qwen-portal-auth/refresh.test.ts @@ -0,0 +1,135 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { refreshQwenPortalCredentials } from "./refresh.js"; + +function expiredCredentials() { + return { + type: "oauth" as const, + provider: "qwen-portal", + access: "expired-access", + refresh: "refresh-token", + expires: Date.now() - 60_000, + }; +} + +describe("refreshQwenPortalCredentials", () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + const runRefresh = async () => await refreshQwenPortalCredentials(expiredCredentials()); + + it("refreshes oauth credentials and preserves existing refresh token when absent", async () => { + globalThis.fetch = vi.fn(async () => { + return new Response( + JSON.stringify({ + access_token: "new-access", + expires_in: 3600, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ); + }) as typeof fetch; + + const result = await runRefresh(); + + expect(result.access).toBe("new-access"); + expect(result.refresh).toBe("refresh-token"); + expect(result.expires).toBeGreaterThan(Date.now()); + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + expect(globalThis.fetch).toHaveBeenCalledWith( + "https://chat.qwen.ai/api/v1/oauth2/token", + expect.objectContaining({ + method: "POST", + body: expect.any(URLSearchParams), + }), + ); + }); + + it("replaces the refresh token when the server rotates it", async () => { + globalThis.fetch = vi.fn(async () => { + return new Response( + JSON.stringify({ + access_token: "new-access", + refresh_token: "rotated-refresh", + expires_in: 1200, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ); + }) as typeof fetch; + + const result = await runRefresh(); + + expect(result.refresh).toBe("rotated-refresh"); + }); + + it("rejects invalid expires_in payloads", async () => { + globalThis.fetch = vi.fn(async () => { + return new Response( + JSON.stringify({ + access_token: "new-access", + expires_in: 0, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ); + }) as typeof fetch; + + await expect(runRefresh()).rejects.toThrow( + "Qwen OAuth refresh response missing or invalid expires_in", + ); + }); + + it("turns 400 responses into a re-authenticate hint", async () => { + globalThis.fetch = vi.fn( + async () => new Response("bad refresh", { status: 400 }), + ) as typeof fetch; + + await expect(runRefresh()).rejects.toThrow("Qwen OAuth refresh token expired or invalid"); + }); + + it("requires a refresh token", async () => { + await expect( + refreshQwenPortalCredentials({ + type: "oauth", + provider: "qwen-portal", + access: "expired-access", + refresh: "", + expires: Date.now() - 60_000, + }), + ).rejects.toThrow("Qwen OAuth refresh token missing"); + }); + + it("rejects missing access tokens", async () => { + globalThis.fetch = vi.fn(async () => { + return new Response( + JSON.stringify({ + expires_in: 3600, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ); + }) as typeof fetch; + + await expect(runRefresh()).rejects.toThrow("Qwen OAuth refresh response missing access token"); + }); + + it("surfaces non-400 refresh failures", async () => { + globalThis.fetch = vi.fn( + async () => new Response("gateway down", { status: 502 }), + ) as typeof fetch; + + await expect(runRefresh()).rejects.toThrow("Qwen OAuth refresh failed: gateway down"); + }); +}); diff --git a/src/providers/qwen-portal-oauth.ts b/extensions/qwen-portal-auth/refresh.ts similarity index 96% rename from src/providers/qwen-portal-oauth.ts rename to extensions/qwen-portal-auth/refresh.ts index 159942ef2a9..eee8421e011 100644 --- a/src/providers/qwen-portal-oauth.ts +++ b/extensions/qwen-portal-auth/refresh.ts @@ -1,5 +1,5 @@ import type { OAuthCredentials } from "@mariozechner/pi-ai"; -import { formatCliCommand } from "../cli/command-format.js"; +import { formatCliCommand } from "openclaw/plugin-sdk/setup-tools"; const QWEN_OAUTH_BASE_URL = "https://chat.qwen.ai"; const QWEN_OAUTH_TOKEN_ENDPOINT = `${QWEN_OAUTH_BASE_URL}/api/v1/oauth2/token`; @@ -54,9 +54,9 @@ export async function refreshQwenPortalCredentials( return { ...credentials, - access: accessToken, // RFC 6749 section 6: new refresh token is optional; if present, replace old. refresh: newRefreshToken || refreshToken, + access: accessToken, expires: Date.now() + expiresIn * 1000, }; } diff --git a/extensions/qwen-portal-auth/runtime-api.ts b/extensions/qwen-portal-auth/runtime-api.ts index 232a2886110..52ad77bf6f0 100644 --- a/extensions/qwen-portal-auth/runtime-api.ts +++ b/extensions/qwen-portal-auth/runtime-api.ts @@ -1 +1,10 @@ -export * from "openclaw/plugin-sdk/qwen-portal-auth"; +export { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-oauth"; +export { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; +export type { ProviderAuthContext, ProviderCatalogContext } from "openclaw/plugin-sdk/plugin-entry"; +export { ensureAuthProfileStore, listProfilesForProvider } from "openclaw/plugin-sdk/provider-auth"; +export { QWEN_OAUTH_MARKER } from "openclaw/plugin-sdk/agent-runtime"; +export { + generatePkceVerifierChallenge, + toFormUrlEncoded, +} from "openclaw/plugin-sdk/provider-oauth"; +export { refreshQwenPortalCredentials } from "./refresh.js"; diff --git a/extensions/synology-chat/api.ts b/extensions/synology-chat/api.ts deleted file mode 100644 index 4ff5241bd49..00000000000 --- a/extensions/synology-chat/api.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "openclaw/plugin-sdk/synology-chat"; -export * from "./setup-api.js"; diff --git a/extensions/synology-chat/src/channel.test-mocks.ts b/extensions/synology-chat/src/channel.test-mocks.ts index 21859ba90e9..77c4a6d223f 100644 --- a/extensions/synology-chat/src/channel.test-mocks.ts +++ b/extensions/synology-chat/src/channel.test-mocks.ts @@ -27,20 +27,37 @@ async function readRequestBodyWithLimitForTest(req: IncomingMessage): Promise ({ - DEFAULT_ACCOUNT_ID: "default", - setAccountEnabledInConfigSection: vi.fn((_opts: unknown) => ({})), - registerPluginHttpRoute: registerPluginHttpRouteMock, - buildChannelConfigSchema: vi.fn((schema: unknown) => ({ schema })), - readRequestBodyWithLimit: vi.fn(readRequestBodyWithLimitForTest), - isRequestBodyLimitError: vi.fn(() => false), - requestBodyErrorToText: vi.fn(() => "Request body too large"), - createFixedWindowRateLimiter: vi.fn(() => ({ - isRateLimited: vi.fn(() => false), - size: vi.fn(() => 0), - clear: vi.fn(), - })), -})); +vi.mock("openclaw/plugin-sdk/setup", async () => { + const actual = await vi.importActual("openclaw/plugin-sdk/setup"); + return { + ...actual, + DEFAULT_ACCOUNT_ID: "default", + }; +}); + +vi.mock("openclaw/plugin-sdk/channel-config-schema", async () => { + const actual = await vi.importActual("openclaw/plugin-sdk/channel-config-schema"); + return { + ...actual, + buildChannelConfigSchema: vi.fn((schema: unknown) => ({ schema })), + }; +}); + +vi.mock("openclaw/plugin-sdk/webhook-ingress", async () => { + const actual = await vi.importActual("openclaw/plugin-sdk/webhook-ingress"); + return { + ...actual, + registerPluginHttpRoute: registerPluginHttpRouteMock, + readRequestBodyWithLimit: vi.fn(readRequestBodyWithLimitForTest), + isRequestBodyLimitError: vi.fn(() => false), + requestBodyErrorToText: vi.fn(() => "Request body too large"), + createFixedWindowRateLimiter: vi.fn(() => ({ + isRateLimited: vi.fn(() => false), + size: vi.fn(() => 0), + clear: vi.fn(), + })), + }; +}); vi.mock("./client.js", () => ({ sendMessage: vi.fn().mockResolvedValue(true), diff --git a/extensions/synology-chat/src/channel.ts b/extensions/synology-chat/src/channel.ts index 9617dc129ae..ef01c240e10 100644 --- a/extensions/synology-chat/src/channel.ts +++ b/extensions/synology-chat/src/channel.ts @@ -8,6 +8,7 @@ import { createHybridChannelConfigAdapter, createScopedDmSecurityResolver, } from "openclaw/plugin-sdk/channel-config-helpers"; +import { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema"; import { createConditionalWarningCollector, projectWarningCollector, @@ -17,8 +18,9 @@ import { createEmptyChannelDirectoryAdapter, createTextPairingAdapter, } from "openclaw/plugin-sdk/channel-runtime"; +import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup"; +import { registerPluginHttpRoute } from "openclaw/plugin-sdk/webhook-ingress"; import { z } from "zod"; -import { DEFAULT_ACCOUNT_ID, registerPluginHttpRoute, buildChannelConfigSchema } from "../api.js"; import { listAccountIds, resolveAccount } from "./accounts.js"; import { sendMessage, sendFileUrl } from "./client.js"; import { getSynologyRuntime } from "./runtime.js"; diff --git a/extensions/synology-chat/src/config-schema.ts b/extensions/synology-chat/src/config-schema.ts index cfdc3fb7a81..4a9f868a87f 100644 --- a/extensions/synology-chat/src/config-schema.ts +++ b/extensions/synology-chat/src/config-schema.ts @@ -1,4 +1,4 @@ +import { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema"; import { z } from "zod"; -import { buildChannelConfigSchema } from "../api.js"; export const SynologyChatChannelConfigSchema = buildChannelConfigSchema(z.object({}).passthrough()); diff --git a/extensions/synology-chat/src/runtime.ts b/extensions/synology-chat/src/runtime.ts index e1288f74468..3e0234029ac 100644 --- a/extensions/synology-chat/src/runtime.ts +++ b/extensions/synology-chat/src/runtime.ts @@ -1,5 +1,5 @@ +import type { PluginRuntime } from "openclaw/plugin-sdk/core"; import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; -import type { PluginRuntime } from "../api.js"; const { setRuntime: setSynologyRuntime, getRuntime: getSynologyRuntime } = createPluginRuntimeStore( diff --git a/extensions/synology-chat/src/security.ts b/extensions/synology-chat/src/security.ts index 8ac50016a12..c6a10560efb 100644 --- a/extensions/synology-chat/src/security.ts +++ b/extensions/synology-chat/src/security.ts @@ -3,7 +3,10 @@ */ import * as crypto from "node:crypto"; -import { createFixedWindowRateLimiter, type FixedWindowRateLimiter } from "../api.js"; +import { + createFixedWindowRateLimiter, + type FixedWindowRateLimiter, +} from "openclaw/plugin-sdk/webhook-ingress"; export type DmAuthorizationResult = | { allowed: true } diff --git a/extensions/synology-chat/src/webhook-handler.ts b/extensions/synology-chat/src/webhook-handler.ts index 4f38136e9a5..9382b78e54f 100644 --- a/extensions/synology-chat/src/webhook-handler.ts +++ b/extensions/synology-chat/src/webhook-handler.ts @@ -9,7 +9,7 @@ import { isRequestBodyLimitError, readRequestBodyWithLimit, requestBodyErrorToText, -} from "../api.js"; +} from "openclaw/plugin-sdk/webhook-ingress"; import { sendMessage, resolveChatUserId } from "./client.js"; import { validateToken, authorizeUserForDm, sanitizeInput, RateLimiter } from "./security.js"; import type { SynologyWebhookPayload, ResolvedSynologyChatAccount } from "./types.js"; diff --git a/extensions/zai/runtime-api.ts b/extensions/zai/runtime-api.ts index 27c34abce5a..16d46dd4362 100644 --- a/extensions/zai/runtime-api.ts +++ b/extensions/zai/runtime-api.ts @@ -1 +1 @@ -export * from "openclaw/plugin-sdk/zai"; +export * from "../../src/plugin-sdk/zai.js"; diff --git a/package.json b/package.json index ed8cc402625..cca5df23276 100644 --- a/package.json +++ b/package.json @@ -173,10 +173,6 @@ "types": "./dist/plugin-sdk/acp-runtime.d.ts", "default": "./dist/plugin-sdk/acp-runtime.js" }, - "./plugin-sdk/acpx": { - "types": "./dist/plugin-sdk/acpx.d.ts", - "default": "./dist/plugin-sdk/acpx.js" - }, "./plugin-sdk/telegram": { "types": "./dist/plugin-sdk/telegram.d.ts", "default": "./dist/plugin-sdk/telegram.js" @@ -197,10 +193,6 @@ "types": "./dist/plugin-sdk/feishu.d.ts", "default": "./dist/plugin-sdk/feishu.js" }, - "./plugin-sdk/google": { - "types": "./dist/plugin-sdk/google.d.ts", - "default": "./dist/plugin-sdk/google.js" - }, "./plugin-sdk/googlechat": { "types": "./dist/plugin-sdk/googlechat.d.ts", "default": "./dist/plugin-sdk/googlechat.js" @@ -213,10 +205,6 @@ "types": "./dist/plugin-sdk/line-core.d.ts", "default": "./dist/plugin-sdk/line-core.js" }, - "./plugin-sdk/lobster": { - "types": "./dist/plugin-sdk/lobster.d.ts", - "default": "./dist/plugin-sdk/lobster.js" - }, "./plugin-sdk/matrix": { "types": "./dist/plugin-sdk/matrix.d.ts", "default": "./dist/plugin-sdk/matrix.js" @@ -313,9 +301,9 @@ "types": "./dist/plugin-sdk/boolean-param.d.ts", "default": "./dist/plugin-sdk/boolean-param.js" }, - "./plugin-sdk/device-pair": { - "types": "./dist/plugin-sdk/device-pair.d.ts", - "default": "./dist/plugin-sdk/device-pair.js" + "./plugin-sdk/device-bootstrap": { + "types": "./dist/plugin-sdk/device-bootstrap.d.ts", + "default": "./dist/plugin-sdk/device-bootstrap.js" }, "./plugin-sdk/diagnostics-otel": { "types": "./dist/plugin-sdk/diagnostics-otel.d.ts", @@ -381,14 +369,14 @@ "types": "./dist/plugin-sdk/memory-lancedb.d.ts", "default": "./dist/plugin-sdk/memory-lancedb.js" }, - "./plugin-sdk/minimax-portal-auth": { - "types": "./dist/plugin-sdk/minimax-portal-auth.d.ts", - "default": "./dist/plugin-sdk/minimax-portal-auth.js" - }, "./plugin-sdk/provider-auth": { "types": "./dist/plugin-sdk/provider-auth.d.ts", "default": "./dist/plugin-sdk/provider-auth.js" }, + "./plugin-sdk/provider-oauth": { + "types": "./dist/plugin-sdk/provider-oauth.d.ts", + "default": "./dist/plugin-sdk/provider-oauth.js" + }, "./plugin-sdk/provider-auth-api-key": { "types": "./dist/plugin-sdk/provider-auth-api-key.d.ts", "default": "./dist/plugin-sdk/provider-auth-api-key.js" @@ -453,10 +441,6 @@ "types": "./dist/plugin-sdk/request-url.d.ts", "default": "./dist/plugin-sdk/request-url.js" }, - "./plugin-sdk/qwen-portal-auth": { - "types": "./dist/plugin-sdk/qwen-portal-auth.d.ts", - "default": "./dist/plugin-sdk/qwen-portal-auth.js" - }, "./plugin-sdk/webhook-ingress": { "types": "./dist/plugin-sdk/webhook-ingress.d.ts", "default": "./dist/plugin-sdk/webhook-ingress.js" @@ -477,10 +461,6 @@ "types": "./dist/plugin-sdk/signal-core.d.ts", "default": "./dist/plugin-sdk/signal-core.js" }, - "./plugin-sdk/synology-chat": { - "types": "./dist/plugin-sdk/synology-chat.d.ts", - "default": "./dist/plugin-sdk/synology-chat.js" - }, "./plugin-sdk/thread-ownership": { "types": "./dist/plugin-sdk/thread-ownership.d.ts", "default": "./dist/plugin-sdk/thread-ownership.js" @@ -501,10 +481,6 @@ "types": "./dist/plugin-sdk/web-media.d.ts", "default": "./dist/plugin-sdk/web-media.js" }, - "./plugin-sdk/zai": { - "types": "./dist/plugin-sdk/zai.d.ts", - "default": "./dist/plugin-sdk/zai.js" - }, "./plugin-sdk/zalo": { "types": "./dist/plugin-sdk/zalo.d.ts", "default": "./dist/plugin-sdk/zalo.js" diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index f9c20590e4b..461be926f78 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -33,17 +33,14 @@ "hook-runtime", "process-runtime", "acp-runtime", - "acpx", "telegram", "telegram-core", "discord", "discord-core", "feishu", - "google", "googlechat", "irc", "line-core", - "lobster", "matrix", "mattermost", "msteams", @@ -68,7 +65,7 @@ "allowlist-resolution", "allowlist-config-edit", "boolean-param", - "device-pair", + "device-bootstrap", "diagnostics-otel", "diffs", "extension-shared", @@ -85,8 +82,8 @@ "line", "llm-task", "memory-lancedb", - "minimax-portal-auth", "provider-auth", + "provider-oauth", "provider-auth-api-key", "provider-auth-login", "plugin-entry", @@ -103,19 +100,16 @@ "secret-input-runtime", "secret-input-schema", "request-url", - "qwen-portal-auth", "webhook-ingress", "webhook-path", "runtime-store", "secret-input", "signal-core", - "synology-chat", "thread-ownership", "tlon", "twitch", "voice-call", "web-media", - "zai", "zalo", "zalouser", "speech", diff --git a/src/plugin-sdk/core.ts b/src/plugin-sdk/core.ts index 3c588f5a06e..38509cef4ab 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -91,7 +91,6 @@ export { parseOptionalDelimitedEntries, } from "../channels/plugins/helpers.js"; export { getChatChannelMeta } from "../channels/registry.js"; -export { buildOauthProviderAuthResult } from "./provider-auth-result.js"; export { channelTargetSchema, channelTargetsSchema, diff --git a/src/plugin-sdk/device-bootstrap.ts b/src/plugin-sdk/device-bootstrap.ts new file mode 100644 index 00000000000..c3ecf15ab51 --- /dev/null +++ b/src/plugin-sdk/device-bootstrap.ts @@ -0,0 +1,4 @@ +// Shared bootstrap/pairing helpers for plugins that provision remote devices. + +export { approveDevicePairing, listDevicePairing } from "../infra/device-pairing.js"; +export { issueDeviceBootstrapToken } from "../infra/device-bootstrap.js"; diff --git a/src/plugin-sdk/minimax-portal-auth.ts b/src/plugin-sdk/minimax-portal-auth.ts deleted file mode 100644 index a8dad415488..00000000000 --- a/src/plugin-sdk/minimax-portal-auth.ts +++ /dev/null @@ -1,12 +0,0 @@ -// Narrow plugin-sdk surface for MiniMax OAuth helpers used by the bundled minimax plugin. -// Keep this list additive and scoped to MiniMax OAuth support code. - -export { definePluginEntry } from "./core.js"; -export { buildOauthProviderAuthResult } from "./provider-auth-result.js"; -export type { - OpenClawPluginApi, - ProviderAuthContext, - ProviderCatalogContext, - ProviderAuthResult, -} from "../plugins/types.js"; -export { generatePkceVerifierChallenge, toFormUrlEncoded } from "./oauth-utils.js"; diff --git a/src/plugin-sdk/plugin-entry.ts b/src/plugin-sdk/plugin-entry.ts index 9d0cb1eceba..e411cb51e89 100644 --- a/src/plugin-sdk/plugin-entry.ts +++ b/src/plugin-sdk/plugin-entry.ts @@ -11,6 +11,7 @@ export type { AnyAgentTool, MediaUnderstandingProviderPlugin, OpenClawPluginApi, + PluginCommandContext, OpenClawPluginConfigSchema, ProviderDiscoveryContext, ProviderCatalogContext, diff --git a/src/plugin-sdk/provider-auth.ts b/src/plugin-sdk/provider-auth.ts index 645073a4d02..13125b7704c 100644 --- a/src/plugin-sdk/provider-auth.ts +++ b/src/plugin-sdk/provider-auth.ts @@ -5,7 +5,6 @@ export type { SecretInput } from "../config/types.secrets.js"; export type { ProviderAuthResult } from "../plugins/types.js"; export type { ProviderAuthContext } from "../plugins/types.js"; export type { AuthProfileStore, OAuthCredential } from "../agents/auth-profiles/types.js"; -export { buildOauthProviderAuthResult } from "./provider-auth-result.js"; export { CLAUDE_CLI_PROFILE_ID, diff --git a/src/plugin-sdk/provider-oauth.ts b/src/plugin-sdk/provider-oauth.ts new file mode 100644 index 00000000000..8e183c55954 --- /dev/null +++ b/src/plugin-sdk/provider-oauth.ts @@ -0,0 +1,4 @@ +// Focused OAuth helpers for provider plugins. + +export { buildOauthProviderAuthResult } from "./provider-auth-result.js"; +export { generatePkceVerifierChallenge, toFormUrlEncoded } from "./oauth-utils.js"; diff --git a/src/plugin-sdk/qwen-portal-auth.ts b/src/plugin-sdk/qwen-portal-auth.ts deleted file mode 100644 index adc61259a09..00000000000 --- a/src/plugin-sdk/qwen-portal-auth.ts +++ /dev/null @@ -1,14 +0,0 @@ -// Narrow plugin-sdk surface for the bundled qwen-portal-auth plugin. -// Keep this list additive and scoped to symbols used under extensions/qwen-portal-auth. - -export { definePluginEntry } from "./core.js"; -export { buildOauthProviderAuthResult } from "./provider-auth-result.js"; -export type { - OpenClawPluginApi, - ProviderAuthContext, - ProviderCatalogContext, -} from "../plugins/types.js"; -export { ensureAuthProfileStore, listProfilesForProvider } from "../agents/auth-profiles.js"; -export { QWEN_OAUTH_MARKER } from "../agents/model-auth-markers.js"; -export { refreshQwenPortalCredentials } from "../providers/qwen-portal-oauth.js"; -export { generatePkceVerifierChallenge, toFormUrlEncoded } from "./oauth-utils.js"; diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index 069a0be8067..d570ef58cab 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -16,7 +16,9 @@ import * as imessageSdk from "openclaw/plugin-sdk/imessage"; import * as imessageCoreSdk from "openclaw/plugin-sdk/imessage-core"; import * as lazyRuntimeSdk from "openclaw/plugin-sdk/lazy-runtime"; import * as ollamaSetupSdk from "openclaw/plugin-sdk/ollama-setup"; +import * as providerAuthSdk from "openclaw/plugin-sdk/provider-auth"; import * as providerModelsSdk from "openclaw/plugin-sdk/provider-models"; +import * as providerOauthSdk from "openclaw/plugin-sdk/provider-oauth"; import * as providerSetupSdk from "openclaw/plugin-sdk/provider-setup"; import * as replyPayloadSdk from "openclaw/plugin-sdk/reply-payload"; import * as routingSdk from "openclaw/plugin-sdk/routing"; @@ -56,10 +58,17 @@ const allowlistEditSdk = await import("openclaw/plugin-sdk/allowlist-config-edit describe("plugin-sdk subpath exports", () => { it("keeps the curated public list free of internal implementation subpaths", () => { + expect(pluginSdkSubpaths).not.toContain("acpx"); expect(pluginSdkSubpaths).not.toContain("compat"); + expect(pluginSdkSubpaths).not.toContain("device-pair"); + expect(pluginSdkSubpaths).not.toContain("google"); + expect(pluginSdkSubpaths).not.toContain("lobster"); expect(pluginSdkSubpaths).not.toContain("pairing-access"); + expect(pluginSdkSubpaths).not.toContain("qwen-portal-auth"); expect(pluginSdkSubpaths).not.toContain("reply-prefix"); + expect(pluginSdkSubpaths).not.toContain("synology-chat"); expect(pluginSdkSubpaths).not.toContain("typing"); + expect(pluginSdkSubpaths).not.toContain("zai"); expect(pluginSdkSubpaths).not.toContain("provider-model-definitions"); }); @@ -91,6 +100,13 @@ describe("plugin-sdk subpath exports", () => { expect(typeof accountHelpersSdk.createAccountListHelpers).toBe("function"); }); + it("exports device bootstrap helpers from the dedicated subpath", async () => { + const deviceBootstrapSdk = await import("openclaw/plugin-sdk/device-bootstrap"); + expect(typeof deviceBootstrapSdk.approveDevicePairing).toBe("function"); + expect(typeof deviceBootstrapSdk.issueDeviceBootstrapToken).toBe("function"); + expect(typeof deviceBootstrapSdk.listDevicePairing).toBe("function"); + }); + it("exports allowlist edit helpers from the dedicated subpath", () => { expect(typeof allowlistEditSdk.buildDmGroupAccountAllowlistAdapter).toBe("function"); expect(typeof allowlistEditSdk.createNestedAllowlistOverrideResolver).toBe("function"); @@ -139,6 +155,14 @@ describe("plugin-sdk subpath exports", () => { expect(typeof providerSetupSdk.discoverOpenAICompatibleSelfHostedProvider).toBe("function"); }); + it("exports oauth helpers from the dedicated provider oauth subpath", () => { + expect(typeof providerOauthSdk.buildOauthProviderAuthResult).toBe("function"); + expect(typeof providerOauthSdk.generatePkceVerifierChallenge).toBe("function"); + expect(typeof providerOauthSdk.toFormUrlEncoded).toBe("function"); + expect("buildOauthProviderAuthResult" in asExports(coreSdk)).toBe(false); + expect("buildOauthProviderAuthResult" in asExports(providerAuthSdk)).toBe(false); + }); + it("keeps provider models focused on shared provider primitives", () => { expect(typeof providerModelsSdk.applyOpenAIConfig).toBe("function"); expect(typeof providerModelsSdk.buildKilocodeModelDefinition).toBe("function"); @@ -187,8 +211,11 @@ describe("plugin-sdk subpath exports", () => { }); it("exports webhook ingress helpers from the dedicated subpath", () => { + expect(typeof webhookIngressSdk.registerPluginHttpRoute).toBe("function"); expect(typeof webhookIngressSdk.resolveWebhookPath).toBe("function"); + expect(typeof webhookIngressSdk.readRequestBodyWithLimit).toBe("function"); expect(typeof webhookIngressSdk.readJsonWebhookBodyOrReject).toBe("function"); + expect(typeof webhookIngressSdk.requestBodyErrorToText).toBe("function"); expect(typeof webhookIngressSdk.withResolvedWebhookRequestPipeline).toBe("function"); }); diff --git a/src/plugin-sdk/webhook-ingress.ts b/src/plugin-sdk/webhook-ingress.ts index c76e986c050..88d71b18248 100644 --- a/src/plugin-sdk/webhook-ingress.ts +++ b/src/plugin-sdk/webhook-ingress.ts @@ -14,14 +14,18 @@ export { beginWebhookRequestPipelineOrReject, createWebhookInFlightLimiter, isJsonContentType, + isRequestBodyLimitError, + readRequestBodyWithLimit, readJsonWebhookBodyOrReject, readWebhookBodyOrReject, + requestBodyErrorToText, WEBHOOK_BODY_READ_DEFAULTS, WEBHOOK_IN_FLIGHT_DEFAULTS, type WebhookBodyReadProfile, type WebhookInFlightLimiter, } from "./webhook-request-guards.js"; export { + registerPluginHttpRoute, registerWebhookTarget, registerWebhookTargetWithPluginRoute, resolveSingleWebhookTarget, diff --git a/src/plugin-sdk/webhook-request-guards.ts b/src/plugin-sdk/webhook-request-guards.ts index f181859bc84..670e5b34565 100644 --- a/src/plugin-sdk/webhook-request-guards.ts +++ b/src/plugin-sdk/webhook-request-guards.ts @@ -10,6 +10,12 @@ import type { FixedWindowRateLimiter } from "./webhook-memory-guards.js"; export type WebhookBodyReadProfile = "pre-auth" | "post-auth"; +export { + isRequestBodyLimitError, + readRequestBodyWithLimit, + requestBodyErrorToText, +} from "../infra/http-body.js"; + export const WEBHOOK_BODY_READ_DEFAULTS = Object.freeze({ preAuth: { maxBytes: 64 * 1024, diff --git a/src/plugin-sdk/webhook-targets.ts b/src/plugin-sdk/webhook-targets.ts index e3dd9eda01d..43d67a93e27 100644 --- a/src/plugin-sdk/webhook-targets.ts +++ b/src/plugin-sdk/webhook-targets.ts @@ -19,6 +19,8 @@ export type RegisterWebhookTargetOptions = { type RegisterPluginHttpRouteParams = Parameters[0]; +export { registerPluginHttpRoute }; + export type RegisterWebhookPluginRouteOptions = Omit< RegisterPluginHttpRouteParams, "path" | "fallbackPath" diff --git a/src/plugins/contracts/runtime.contract.test.ts b/src/plugins/contracts/runtime.contract.test.ts index 1e614150cb3..551361d1bdd 100644 --- a/src/plugins/contracts/runtime.contract.test.ts +++ b/src/plugins/contracts/runtime.contract.test.ts @@ -23,8 +23,8 @@ vi.mock("@mariozechner/pi-ai/oauth", async () => { }; }); -vi.mock("../../plugin-sdk/qwen-portal-auth.js", async () => { - const actual = await vi.importActual("../../plugin-sdk/qwen-portal-auth.js"); +vi.mock("../../../extensions/qwen-portal-auth/refresh.js", async () => { + const actual = await vi.importActual("../../../extensions/qwen-portal-auth/refresh.js"); return { ...actual, refreshQwenPortalCredentials: refreshQwenPortalCredentialsMock, diff --git a/src/providers/qwen-portal-oauth.test.ts b/src/providers/qwen-portal-oauth.test.ts deleted file mode 100644 index 4e73062d8fe..00000000000 --- a/src/providers/qwen-portal-oauth.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { describe, expect, it, vi, afterEach } from "vitest"; -import { refreshQwenPortalCredentials } from "./qwen-portal-oauth.js"; - -const originalFetch = globalThis.fetch; - -afterEach(() => { - vi.unstubAllGlobals(); - globalThis.fetch = originalFetch; -}); - -describe("refreshQwenPortalCredentials", () => { - const expiredCredentials = () => ({ - access: "old-access", - refresh: "old-refresh", - expires: Date.now() - 1000, - }); - - const runRefresh = async () => await refreshQwenPortalCredentials(expiredCredentials()); - - const stubFetchResponse = (response: unknown) => { - const fetchSpy = vi.fn().mockResolvedValue(response); - vi.stubGlobal("fetch", fetchSpy); - return fetchSpy; - }; - - it("refreshes tokens with a new access token", async () => { - const fetchSpy = stubFetchResponse({ - ok: true, - status: 200, - json: async () => ({ - access_token: "new-access", - refresh_token: "new-refresh", - expires_in: 3600, - }), - }); - - const result = await runRefresh(); - - expect(fetchSpy).toHaveBeenCalledWith( - "https://chat.qwen.ai/api/v1/oauth2/token", - expect.objectContaining({ - method: "POST", - }), - ); - expect(result.access).toBe("new-access"); - expect(result.refresh).toBe("new-refresh"); - expect(result.expires).toBeGreaterThan(Date.now()); - }); - - it("keeps refresh token when refresh response omits it", async () => { - stubFetchResponse({ - ok: true, - status: 200, - json: async () => ({ - access_token: "new-access", - expires_in: 1800, - }), - }); - - const result = await runRefresh(); - - expect(result.refresh).toBe("old-refresh"); - }); - - it("keeps refresh token when response sends an empty refresh token", async () => { - stubFetchResponse({ - ok: true, - status: 200, - json: async () => ({ - access_token: "new-access", - refresh_token: "", - expires_in: 1800, - }), - }); - - const result = await runRefresh(); - - expect(result.refresh).toBe("old-refresh"); - }); - - it("errors when refresh response has invalid expires_in", async () => { - stubFetchResponse({ - ok: true, - status: 200, - json: async () => ({ - access_token: "new-access", - refresh_token: "new-refresh", - expires_in: 0, - }), - }); - - await expect(runRefresh()).rejects.toThrow( - "Qwen OAuth refresh response missing or invalid expires_in", - ); - }); - - it("errors when refresh token is invalid", async () => { - stubFetchResponse({ - ok: false, - status: 400, - text: async () => "invalid_grant", - }); - - await expect(runRefresh()).rejects.toThrow("Qwen OAuth refresh token expired or invalid"); - }); - - it("errors when refresh token is missing before any request", async () => { - await expect( - refreshQwenPortalCredentials({ - access: "old-access", - refresh: " ", - expires: Date.now() - 1000, - }), - ).rejects.toThrow("Qwen OAuth refresh token missing"); - }); - - it("errors when refresh response omits access token", async () => { - stubFetchResponse({ - ok: true, - status: 200, - json: async () => ({ - refresh_token: "new-refresh", - expires_in: 1800, - }), - }); - - await expect(runRefresh()).rejects.toThrow("Qwen OAuth refresh response missing access token"); - }); - - it("errors with server payload text for non-400 status", async () => { - stubFetchResponse({ - ok: false, - status: 500, - statusText: "Server Error", - text: async () => "gateway down", - }); - - await expect(runRefresh()).rejects.toThrow("Qwen OAuth refresh failed: gateway down"); - }); -});