diff --git a/extensions/nextcloud-talk/src/core.test.ts b/extensions/nextcloud-talk/src/core.test.ts index 3f4cfb26a62..3b146e75898 100644 --- a/extensions/nextcloud-talk/src/core.test.ts +++ b/extensions/nextcloud-talk/src/core.test.ts @@ -2,15 +2,6 @@ import { mkdtemp, rm } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; -import { - escapeNextcloudTalkMarkdown, - formatNextcloudTalkCodeBlock, - formatNextcloudTalkInlineCode, - formatNextcloudTalkMention, - markdownToNextcloudTalk, - stripNextcloudTalkFormatting, - truncateNextcloudTalkText, -} from "./format.js"; import { looksLikeNextcloudTalkTargetId, normalizeNextcloudTalkMessagingTarget, @@ -73,30 +64,6 @@ async function makeTempDir(): Promise { } describe("nextcloud talk core", () => { - it("accepts SecretRef botSecret and apiPassword at top-level", () => { - expect(markdownToNextcloudTalk(" **hello** ")).toBe("**hello**"); - }); - - it("escapes markdown-sensitive characters", () => { - expect(escapeNextcloudTalkMarkdown("*hello* [x](y)")).toBe("\\*hello\\* \\[x\\]\\(y\\)"); - }); - - it("formats mentions and code consistently", () => { - expect(formatNextcloudTalkMention("@alice")).toBe("@alice"); - expect(formatNextcloudTalkMention("bob")).toBe("@bob"); - expect(formatNextcloudTalkCodeBlock("const x = 1;", "ts")).toBe("```ts\nconst x = 1;\n```"); - expect(formatNextcloudTalkInlineCode("x")).toBe("`x`"); - expect(formatNextcloudTalkInlineCode("x ` y")).toBe("`` x ` y ``"); - }); - - it("strips markdown formatting and truncates on word boundaries", () => { - expect(stripNextcloudTalkFormatting("**bold** [link](https://example.com) `code`")).toBe( - "bold link", - ); - expect(truncateNextcloudTalkText("alpha beta gamma delta", 14)).toBe("alpha beta..."); - expect(truncateNextcloudTalkText("short", 14)).toBe("short"); - }); - it("builds an outbound session route for normalized room targets", () => { const route = resolveNextcloudTalkOutboundSessionRoute({ cfg: {}, diff --git a/extensions/nextcloud-talk/src/format.ts b/extensions/nextcloud-talk/src/format.ts deleted file mode 100644 index 4ea7fc1de6a..00000000000 --- a/extensions/nextcloud-talk/src/format.ts +++ /dev/null @@ -1,79 +0,0 @@ -/** - * Format utilities for Nextcloud Talk messages. - * - * Nextcloud Talk supports markdown natively, so most formatting passes through. - * This module handles any edge cases or transformations needed. - */ - -/** - * Convert markdown to Nextcloud Talk compatible format. - * Nextcloud Talk supports standard markdown, so minimal transformation needed. - */ -export function markdownToNextcloudTalk(text: string): string { - return text.trim(); -} - -/** - * Escape special characters in text to prevent markdown interpretation. - */ -export function escapeNextcloudTalkMarkdown(text: string): string { - return text.replace(/([*_`~[\]()#>+\-=|{}!\\])/g, "\\$1"); -} - -/** - * Format a mention for a Nextcloud user. - * Nextcloud Talk uses @user format for mentions. - */ -export function formatNextcloudTalkMention(userId: string): string { - return `@${userId.replace(/^@/, "")}`; -} - -/** - * Format a code block for Nextcloud Talk. - */ -export function formatNextcloudTalkCodeBlock(code: string, language?: string): string { - const lang = language ?? ""; - return `\`\`\`${lang}\n${code}\n\`\`\``; -} - -/** - * Format inline code for Nextcloud Talk. - */ -export function formatNextcloudTalkInlineCode(code: string): string { - if (code.includes("`")) { - return `\`\` ${code} \`\``; - } - return `\`${code}\``; -} - -/** - * Strip Nextcloud Talk specific formatting from text. - * Useful for extracting plain text content. - */ -export function stripNextcloudTalkFormatting(text: string): string { - return text - .replace(/```[\s\S]*?```/g, "") - .replace(/`[^`]+`/g, "") - .replace(/\*\*([^*]+)\*\*/g, "$1") - .replace(/\*([^*]+)\*/g, "$1") - .replace(/_([^_]+)_/g, "$1") - .replace(/~~([^~]+)~~/g, "$1") - .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") - .replace(/\s+/g, " ") - .trim(); -} - -/** - * Truncate text to a maximum length, preserving word boundaries. - */ -export function truncateNextcloudTalkText(text: string, maxLength: number, suffix = "..."): string { - if (text.length <= maxLength) { - return text; - } - const truncated = text.slice(0, maxLength - suffix.length); - const lastSpace = truncated.lastIndexOf(" "); - if (lastSpace > maxLength * 0.7) { - return truncated.slice(0, lastSpace) + suffix; - } - return truncated + suffix; -} diff --git a/extensions/openshell/src/openshell-core.test.ts b/extensions/openshell/src/openshell-core.test.ts index 354e82fa8a2..fba55b96fdb 100644 --- a/extensions/openshell/src/openshell-core.test.ts +++ b/extensions/openshell/src/openshell-core.test.ts @@ -1,4 +1,3 @@ -import fsSync from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -221,220 +220,6 @@ function createMirrorBackendMock(): OpenShellSandboxBackend { } as unknown as OpenShellSandboxBackend; } -function translateRemotePath(value: string, roots: { workspace: string; agent: string }) { - if (value === "/sandbox" || value.startsWith("/sandbox/")) { - return path.join(roots.workspace, value.slice("/sandbox".length)); - } - if (value === "/agent" || value.startsWith("/agent/")) { - return path.join(roots.agent, value.slice("/agent".length)); - } - return value; -} - -async function runLocalShell(params: { - script: string; - args?: string[]; - stdin?: Buffer | string; - allowFailure?: boolean; - roots: { workspace: string; agent: string }; -}) { - const translatedArgs = (params.args ?? []).map((arg) => translateRemotePath(arg, params.roots)); - const stdinBuffer = - params.stdin === undefined - ? undefined - : Buffer.isBuffer(params.stdin) - ? params.stdin - : Buffer.from(params.stdin); - const result = await emulateRemoteShell({ - script: params.script, - args: translatedArgs, - stdin: stdinBuffer, - allowFailure: params.allowFailure, - }); - return { - ...result, - stdout: Buffer.from(rewriteLocalPaths(result.stdout.toString("utf8"), params.roots), "utf8"), - }; -} - -function createRemoteBackendMock(roots: { - workspace: string; - agent: string; -}): OpenShellSandboxBackend { - return { - id: "openshell", - runtimeId: "openshell-test", - runtimeLabel: "openshell-test", - workdir: "/sandbox", - env: {}, - mode: "remote", - remoteWorkspaceDir: "/sandbox", - remoteAgentWorkspaceDir: "/agent", - buildExecSpec: vi.fn(), - runShellCommand: vi.fn(), - runRemoteShellScript: vi.fn( - async (params) => - await runLocalShell({ - ...params, - roots, - }), - ), - syncLocalPathToRemote: vi.fn().mockResolvedValue(undefined), - } as unknown as OpenShellSandboxBackend; -} - -function rewriteLocalPaths(value: string, roots: { workspace: string; agent: string }) { - return value.replaceAll(roots.workspace, "/sandbox").replaceAll(roots.agent, "/agent"); -} - -async function emulateRemoteShell(params: { - script: string; - args: string[]; - stdin?: Buffer; - allowFailure?: boolean; -}): Promise<{ stdout: Buffer; stderr: Buffer; code: number }> { - try { - if (params.script === 'set -eu\ncat -- "$1"') { - return { stdout: await fs.readFile(params.args[0] ?? ""), stderr: Buffer.alloc(0), code: 0 }; - } - - if ( - params.script === 'if [ -e "$1" ] || [ -L "$1" ]; then printf "1\\n"; else printf "0\\n"; fi' - ) { - const target = params.args[0] ?? ""; - const exists = await pathExistsOrSymlink(target); - return { stdout: Buffer.from(exists ? "1\n" : "0\n"), stderr: Buffer.alloc(0), code: 0 }; - } - - if (params.script.includes('canonical=$(readlink -f -- "$cursor")')) { - const canonical = await resolveCanonicalPath(params.args[0] ?? "", params.args[1] === "1"); - return { stdout: Buffer.from(`${canonical}\n`), stderr: Buffer.alloc(0), code: 0 }; - } - - if (params.script.includes('stats=$(stat -c "%F|%h" -- "$1")')) { - const target = params.args[0] ?? ""; - if (!(await pathExistsOrSymlink(target))) { - return { stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), code: 0 }; - } - const stats = await fs.lstat(target); - return { - stdout: Buffer.from(`${describeKind(stats)}|${String(stats.nlink)}\n`), - stderr: Buffer.alloc(0), - code: 0, - }; - } - - if (params.script.includes('stat -c "%F|%s|%Y" -- "$1"')) { - const target = params.args[0] ?? ""; - const stats = await fs.lstat(target); - return { - stdout: Buffer.from( - `${describeKind(stats)}|${String(stats.size)}|${String(Math.trunc(stats.mtimeMs / 1000))}\n`, - ), - stderr: Buffer.alloc(0), - code: 0, - }; - } - - if (params.script.includes("python3 /dev/fd/3 \"$@\" 3<<'PY'")) { - const stdout = (await applyMutation(params.args, params.stdin)) ?? Buffer.alloc(0); - return { stdout, stderr: Buffer.alloc(0), code: 0 }; - } - - throw new Error(`unsupported remote shell script: ${params.script}`); - } catch (error) { - if (!params.allowFailure) { - throw error; - } - const message = error instanceof Error ? error.message : String(error); - return { stdout: Buffer.alloc(0), stderr: Buffer.from(message), code: 1 }; - } -} - -async function pathExistsOrSymlink(target: string) { - try { - await fs.lstat(target); - return true; - } catch { - return false; - } -} - -function describeKind(stats: fsSync.Stats) { - if (stats.isDirectory()) { - return "directory"; - } - if (stats.isFile()) { - return "regular file"; - } - return "other"; -} - -async function resolveCanonicalPath(target: string, allowFinalSymlink: boolean) { - let suffix = ""; - let cursor = target; - if (allowFinalSymlink && (await isSymlink(target))) { - cursor = path.dirname(target); - } - while (!(await pathExistsOrSymlink(cursor))) { - const parent = path.dirname(cursor); - if (parent === cursor) { - break; - } - suffix = `${path.posix.sep}${path.basename(cursor)}${suffix}`; - cursor = parent; - } - const canonical = await fs.realpath(cursor); - return `${canonical}${suffix}`; -} - -async function isSymlink(target: string) { - try { - return (await fs.lstat(target)).isSymbolicLink(); - } catch { - return false; - } -} - -async function applyMutation(args: string[], stdin?: Buffer): Promise { - const operation = args[0]; - if (operation === "read") { - const [root, relativeParent, basename] = args.slice(1); - return await fs.readFile(path.join(root ?? "", relativeParent ?? "", basename ?? "")); - } - if (operation === "write") { - const [root, relativeParent, basename, mkdir] = args.slice(1); - const parent = path.join(root ?? "", relativeParent ?? ""); - if (mkdir === "1") { - await fs.mkdir(parent, { recursive: true }); - } - await fs.writeFile(path.join(parent, basename ?? ""), stdin ?? Buffer.alloc(0)); - return; - } - if (operation === "mkdirp") { - const [root, relativePath] = args.slice(1); - await fs.mkdir(path.join(root ?? "", relativePath ?? ""), { recursive: true }); - return; - } - if (operation === "remove") { - const [root, relativeParent, basename, recursive, force] = args.slice(1); - const target = path.join(root ?? "", relativeParent ?? "", basename ?? ""); - await fs.rm(target, { recursive: recursive === "1", force: force !== "0" }); - return; - } - if (operation === "rename") { - const [srcRoot, srcParent, srcBase, dstRoot, dstParent, dstBase, mkdir] = args.slice(1); - const source = path.join(srcRoot ?? "", srcParent ?? "", srcBase ?? ""); - const destinationParent = path.join(dstRoot ?? "", dstParent ?? ""); - if (mkdir === "1") { - await fs.mkdir(destinationParent, { recursive: true }); - } - await fs.rename(source, path.join(destinationParent, dstBase ?? "")); - return; - } - throw new Error(`unknown mutation operation: ${operation}`); -} - describe("openshell fs bridges", () => { it("writes locally and syncs the file to the remote workspace", async () => { const workspaceDir = await makeTempDir("openclaw-openshell-fs-"); @@ -484,66 +269,4 @@ describe("openshell fs bridges", () => { expect(resolved.hostPath).toBe(path.join(agentWorkspaceDir, "note.txt")); expect(await bridge.readFile({ filePath: "/agent/note.txt" })).toEqual(Buffer.from("agent")); }); - - it("writes, reads, renames, and removes files without local host paths", async () => { - const workspaceDir = await makeTempDir("openclaw-openshell-remote-local-"); - const remoteWorkspaceDir = await makeTempDir("openclaw-openshell-remote-workspace-"); - const remoteAgentDir = await makeTempDir("openclaw-openshell-remote-agent-"); - const remoteWorkspaceRealDir = await fs.realpath(remoteWorkspaceDir); - const remoteAgentRealDir = await fs.realpath(remoteAgentDir); - const backend = createRemoteBackendMock({ - workspace: remoteWorkspaceRealDir, - agent: remoteAgentRealDir, - }); - const sandbox = createSandboxTestContext({ - overrides: { - backendId: "openshell", - workspaceDir, - agentWorkspaceDir: workspaceDir, - containerWorkdir: "/sandbox", - }, - }); - - const { createOpenShellRemoteFsBridge } = await import("./remote-fs-bridge.js"); - const bridge = createOpenShellRemoteFsBridge({ sandbox, backend }); - await bridge.writeFile({ - filePath: "nested/file.txt", - data: "hello", - mkdir: true, - }); - - expect(await fs.readFile(path.join(remoteWorkspaceRealDir, "nested", "file.txt"), "utf8")).toBe( - "hello", - ); - expect(await fs.readdir(workspaceDir)).toEqual([]); - - const resolved = bridge.resolvePath({ filePath: "nested/file.txt" }); - expect(resolved.hostPath).toBeUndefined(); - expect(resolved.containerPath).toBe("/sandbox/nested/file.txt"); - expect(await bridge.readFile({ filePath: "nested/file.txt" })).toEqual(Buffer.from("hello")); - expect(await bridge.stat({ filePath: "nested/file.txt" })).toEqual( - expect.objectContaining({ - type: "file", - size: 5, - }), - ); - - await bridge.rename({ - from: "nested/file.txt", - to: "nested/renamed.txt", - }); - await expect( - fs.readFile(path.join(remoteWorkspaceRealDir, "nested", "file.txt"), "utf8"), - ).rejects.toBeDefined(); - expect( - await fs.readFile(path.join(remoteWorkspaceRealDir, "nested", "renamed.txt"), "utf8"), - ).toBe("hello"); - - await bridge.remove({ - filePath: "nested/renamed.txt", - }); - await expect( - fs.readFile(path.join(remoteWorkspaceRealDir, "nested", "renamed.txt"), "utf8"), - ).rejects.toBeDefined(); - }); }); diff --git a/extensions/openshell/src/remote-fs-bridge.ts b/extensions/openshell/src/remote-fs-bridge.ts deleted file mode 100644 index eeee51b7ee6..00000000000 --- a/extensions/openshell/src/remote-fs-bridge.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { - createRemoteShellSandboxFsBridge, - type RemoteShellSandboxHandle, - type SandboxContext, - type SandboxFsBridge, -} from "openclaw/plugin-sdk/sandbox"; - -export function createOpenShellRemoteFsBridge(params: { - sandbox: SandboxContext; - backend: RemoteShellSandboxHandle; -}): SandboxFsBridge { - return createRemoteShellSandboxFsBridge({ - sandbox: params.sandbox, - runtime: params.backend, - }); -} diff --git a/extensions/qqbot/src/update-checker.ts b/extensions/qqbot/src/update-checker.ts deleted file mode 100644 index a75e36cf7cc..00000000000 --- a/extensions/qqbot/src/update-checker.ts +++ /dev/null @@ -1,244 +0,0 @@ -/** - * Update-check helpers for the standalone npm package. - * - * `triggerUpdateCheck()` warms the cache in the background and `getUpdateInfo()` - * queries the registry on demand. The lookup talks directly to the npm registry - * API and falls back from npmjs.org to npmmirror.com. - */ - -import https from "node:https"; -import { createRequire } from "node:module"; - -const require = createRequire(import.meta.url); - -const PKG_NAME = "@openclaw/qqbot"; -const ENCODED_PKG = encodeURIComponent(PKG_NAME); - -const REGISTRIES = [ - `https://registry.npmjs.org/${ENCODED_PKG}`, - `https://registry.npmmirror.com/${ENCODED_PKG}`, -]; - -let CURRENT_VERSION = "unknown"; -try { - const pkg = require("../package.json"); - CURRENT_VERSION = pkg.version ?? "unknown"; -} catch { - // fallback -} - -export interface UpdateInfo { - current: string; - /** Preferred upgrade target: alpha for prerelease users, latest for stable users. */ - latest: string | null; - /** Stable dist-tag. */ - stable: string | null; - /** Alpha dist-tag. */ - alpha: string | null; - hasUpdate: boolean; - checkedAt: number; - error?: string; -} - -let _log: - | { info: (msg: string) => void; error: (msg: string) => void; debug?: (msg: string) => void } - | undefined; - -function asRecord(value: unknown): Record | undefined { - return typeof value === "object" && value !== null - ? (value as Record) - : undefined; -} - -function readString(record: Record | undefined, key: string): string | undefined { - const value = record?.[key]; - return typeof value === "string" ? value : undefined; -} - -function getErrorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - -function fetchJson(url: string, timeoutMs: number): Promise { - return new Promise((resolve, reject) => { - const req = https.get( - url, - { timeout: timeoutMs, headers: { Accept: "application/json" } }, - (res) => { - if (res.statusCode !== 200) { - res.resume(); - reject(new Error(`HTTP ${res.statusCode} from ${url}`)); - return; - } - let data = ""; - res.on("data", (chunk: string) => { - data += chunk; - }); - res.on("end", () => { - try { - resolve(JSON.parse(data)); - } catch (e) { - reject(e); - } - }); - }, - ); - req.on("error", reject); - req.on("timeout", () => { - req.destroy(); - reject(new Error(`timeout fetching ${url}`)); - }); - }); -} - -async function fetchDistTags(): Promise> { - for (const url of REGISTRIES) { - try { - const json = await fetchJson(url, 10_000); - const tags = asRecord(asRecord(json)?.["dist-tags"]); - if (tags) { - return Object.fromEntries( - Object.entries(tags).flatMap(([key, value]) => - typeof value === "string" ? [[key, value]] : [], - ), - ); - } - } catch (e: unknown) { - _log?.debug?.(`[qqbot:update-checker] ${url} failed: ${getErrorMessage(e)}`); - } - } - throw new Error("all registries failed"); -} - -function buildUpdateInfo(tags: Record): UpdateInfo { - const currentIsPrerelease = CURRENT_VERSION.includes("-"); - const stableTag = tags.latest || null; - const alphaTag = tags.alpha || null; - - // Keep prerelease and stable tracks isolated from each other. - const compareTarget = currentIsPrerelease ? alphaTag : stableTag; - - const hasUpdate = - typeof compareTarget === "string" && - compareTarget !== CURRENT_VERSION && - compareVersions(compareTarget, CURRENT_VERSION) > 0; - - return { - current: CURRENT_VERSION, - latest: compareTarget, - stable: stableTag, - alpha: alphaTag, - hasUpdate, - checkedAt: Date.now(), - }; -} - -/** Capture a logger and warm the update check in the background. */ -export function triggerUpdateCheck(log?: { - info: (msg: string) => void; - error: (msg: string) => void; - debug?: (msg: string) => void; -}): void { - if (log) { - _log = log; - } - // Warm the cache without blocking startup. - getUpdateInfo() - .then((info) => { - if (info.hasUpdate) { - _log?.info?.( - `[qqbot:update-checker] new version available: ${info.latest} (current: ${CURRENT_VERSION})`, - ); - } - }) - .catch(() => {}); -} - -/** Query the npm registry on demand. */ -export async function getUpdateInfo(): Promise { - try { - const tags = await fetchDistTags(); - return buildUpdateInfo(tags); - } catch (err: unknown) { - const errorMessage = getErrorMessage(err); - _log?.debug?.(`[qqbot:update-checker] check failed: ${errorMessage}`); - return { - current: CURRENT_VERSION, - latest: null, - stable: null, - alpha: null, - hasUpdate: false, - checkedAt: Date.now(), - error: errorMessage, - }; - } -} - -/** - * Check whether a specific version exists in the npm registry. - */ -export async function checkVersionExists(version: string): Promise { - for (const baseUrl of REGISTRIES) { - try { - const url = `${baseUrl}/${version}`; - const json = await fetchJson(url, 10_000); - if (readString(asRecord(json), "version") === version) { - return true; - } - } catch { - // try next registry - } - } - return false; -} - -function compareVersions(a: string, b: string): number { - const parse = (v: string) => { - const clean = v.replace(/^v/, ""); - const [main, pre] = clean.split("-", 2); - return { parts: main.split(".").map(Number), pre: pre || null }; - }; - const pa = parse(a); - const pb = parse(b); - // Compare the numeric core version first. - for (let i = 0; i < 3; i++) { - const diff = (pa.parts[i] || 0) - (pb.parts[i] || 0); - if (diff !== 0) { - return diff; - } - } - // For equal core versions, stable beats prerelease. - if (!pa.pre && pb.pre) { - return 1; - } - if (pa.pre && !pb.pre) { - return -1; - } - if (!pa.pre && !pb.pre) { - return 0; - } - // When both are prereleases, compare each prerelease segment in order. - const aParts = pa.pre!.split("."); - const bParts = pb.pre!.split("."); - for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) { - const aP = aParts[i] ?? ""; - const bP = bParts[i] ?? ""; - const aNum = Number(aP); - const bNum = Number(bP); - // Compare numerically when both segments are numbers. - if (!isNaN(aNum) && !isNaN(bNum)) { - if (aNum !== bNum) { - return aNum - bNum; - } - } else { - // Fall back to lexical comparison for string segments. - if (aP < bP) { - return -1; - } - if (aP > bP) { - return 1; - } - } - } - return 0; -} diff --git a/extensions/qqbot/src/user-messages.ts b/extensions/qqbot/src/user-messages.ts deleted file mode 100644 index 3fd2ad099ec..00000000000 --- a/extensions/qqbot/src/user-messages.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * User-facing prompt strings are intentionally empty. - * - * The QQ Bot plugin follows the same rule as Feishu: runtime errors are logged - * but no extra plugin-layer user messages are injected. - */ -export const QQBOT_USER_MESSAGES = Object.freeze({});