Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,60 @@ RUN if [ -n "$OPENCLAW_INSTALL_GH_CLI" ]; then \
rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*; \
fi

# Optional: install document-generation toolchain for the commonly extension's
# commonly_attach_file flow. Adds ~170MB total. Build with:
# --build-arg OPENCLAW_INSTALL_DOC_TOOLCHAIN=1
# Includes:
# - OfficeCLI (iOfficeAI, Apache-2.0): single ~30MB static binary for
# DOCX/XLSX/PPTX create + edit + validate, LLM-optimized addressing.
# Pinned to OPENCLAW_OFFICECLI_VERSION; SHA256 verified against the
# SHA256SUMS artifact published on the release.
# - pandoc + texlive-xetex + texlive-fonts-recommended (~80MB): md → PDF
# via LaTeX engine, md → simple DOCX fallback.
# - poppler-utils: pdftoppm / pdftotext for PDF-skill workflows.
# - python3 + pip + markitdown + pypdf: parse direction (binary doc → md
# for agent input).
ARG OPENCLAW_INSTALL_DOC_TOOLCHAIN=""
ARG OPENCLAW_OFFICECLI_VERSION="1.0.70"
RUN if [ -n "$OPENCLAW_INSTALL_DOC_TOOLCHAIN" ]; then \
apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
ca-certificates curl \
pandoc texlive-xetex texlive-fonts-recommended \
poppler-utils python3 python3-pip && \
apt-get clean && \
rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/* && \
\
# OfficeCLI: download pinned binary, verify SHA256 against the
# release's SHA256SUMS artifact, install to /usr/local/bin.
ARCH="$(uname -m)" && \
case "$ARCH" in \
x86_64) ASSET="officecli-linux-x64" ;; \
aarch64) ASSET="officecli-linux-arm64" ;; \
*) echo "Unsupported architecture for OfficeCLI: $ARCH" >&2; exit 1 ;; \
esac && \
RELEASE_URL="https://github.com/iOfficeAI/OfficeCLI/releases/download/v${OPENCLAW_OFFICECLI_VERSION}" && \
curl -fsSL "${RELEASE_URL}/${ASSET}" -o /usr/local/bin/officecli && \
curl -fsSL "${RELEASE_URL}/SHA256SUMS" -o /tmp/officecli-SHA256SUMS && \
( cd /usr/local/bin && \
EXPECTED="$(grep " ${ASSET}\$" /tmp/officecli-SHA256SUMS | awk '{print $1}')" && \
if [ -z "$EXPECTED" ]; then echo "OfficeCLI SHA256 not found for ${ASSET}" >&2; exit 1; fi && \
echo "${EXPECTED} officecli" | sha256sum -c - ) && \
rm -f /tmp/officecli-SHA256SUMS && \
chmod +x /usr/local/bin/officecli && \
\
# Python parse-direction utilities. --break-system-packages is required
# on Debian Bookworm's PEP-668-protected system Python.
pip3 install --break-system-packages --no-cache-dir \
markitdown pypdf && \
\
# Self-test the toolchain so a regression (lost binary, broken pip)
# surfaces at build time, not at agent runtime via "command not found".
officecli --version && \
pandoc --version | head -1 && \
python3 -c "import markitdown, pypdf; print('parse-direction OK')"; \
fi

# Normalize extension paths so plugin safety checks do not reject
# world-writable directories inherited from source file modes.
RUN for dir in /app/extensions /app/.agent /app/.agents; do \
Expand Down
43 changes: 43 additions & 0 deletions extensions/commonly/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,49 @@ export class CommonlyClient {
return res.json();
}

/**
* Upload a file to a pod via the agent runtime endpoint.
*
* Multipart/form-data POST to /api/agents/runtime/pods/:podId/uploads.
* Returns metadata the caller can embed in a [[upload:...]] directive
* via postMessage.
*/
async uploadFile(
podId: string,
fileBytes: Uint8Array,
originalName: string,
mimeType?: string,
): Promise<{
_id: string;
fileName: string;
originalName: string;
size: number;
kind: string;
}> {
const token = this.config.runtimeToken?.trim();
if (!token) {
throw new Error('Commonly runtime token is required');
}

const form = new FormData();
const blob = new Blob([fileBytes], { type: mimeType || 'application/octet-stream' });
form.append('file', blob, originalName);

const res = await fetch(
`${this.config.baseUrl}/api/agents/runtime/pods/${podId}/uploads`,
{
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: form,
},
);
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Failed to upload file: ${res.status} ${text}`);
}
return res.json();
}

/**
* Post a comment to a thread
*/
Expand Down
125 changes: 125 additions & 0 deletions extensions/commonly/src/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,45 @@ import {
jsonResult,
readNumberParam,
readStringParam,
toRelativeWorkspacePath,
} from "openclaw/plugin-sdk";

import { parseInlineDirectives } from "./directive-tags.js";
import type { MemorySectionName, MemoryVisibility } from "./client.js";

// MIME detection for commonly_attach_file. Backend validates against the
// ADR-002 allowlist; this is a best-effort hint based on extension.
const MIME_BY_EXT: Record<string, string> = {
pdf: "application/pdf",
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
doc: "application/msword",
xls: "application/vnd.ms-excel",
ppt: "application/vnd.ms-powerpoint",
csv: "text/csv",
tsv: "text/tab-separated-values",
txt: "text/plain",
md: "text/markdown",
json: "application/json",
yaml: "application/x-yaml",
yml: "application/x-yaml",
html: "text/html",
xml: "application/xml",
svg: "image/svg+xml",
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
webp: "image/webp",
};

function detectMimeFromPath(filePath: string): string | undefined {
const dot = filePath.lastIndexOf(".");
if (dot === -1 || dot === filePath.length - 1) return undefined;
return MIME_BY_EXT[filePath.slice(dot + 1).toLowerCase()];
}

// ADR-003 Phase 2 section taxonomy — mirrors the backend validator in
// backend/routes/agentsRuntime.ts validateSectionsPayload. Keep in sync.
const ALL_SECTIONS: ReadonlyArray<MemorySectionName> = [
Expand Down Expand Up @@ -336,6 +370,97 @@ export class CommonlyTools {
return jsonResult({ ok: true, message: result });
},
},
{
name: "commonly_attach_file",
label: "Commonly Attach File",
description:
"Attach a file from your workspace to pod chat. Use after producing a deliverable (PDF, DOCX, XLSX, PPTX, CSV, MD, image). " +
"Reads the file from /workspace/<accountId>/<filePath>, uploads it via the runtime upload endpoint, and posts a chat message " +
"with an inline [[upload:...]] directive that the recipient renders as a clickable preview pill. " +
"Examples: after `pandoc input.md -o report.pdf`, call commonly_attach_file({ podId, filePath: 'report.pdf', message: 'Q1 brief attached.' }). " +
"After `officecli create deck.pptx && officecli add ...`, call commonly_attach_file({ podId, filePath: 'deck.pptx', message: 'Stakeholder deck.' }). " +
"Path must stay inside the agent workspace (no '..', no symlinks pointing outside) — escape attempts are rejected. " +
"Max file size 25 MB. If `message` is omitted, returns file metadata so you can compose your own message.",
parameters: Type.Object({
podId: Type.String({ description: "Pod ID to post the attachment into." }),
filePath: Type.String({
description:
"File path relative to the agent's workspace root (e.g. 'report.pdf' or 'output/deck.pptx'). Must not escape the workspace.",
}),
message: Type.Optional(
Type.String({
description:
"Optional caption text. If provided, a chat message is posted with the caption followed by the upload directive. If omitted, returns file metadata for the caller to compose its own message.",
}),
),
replyToId: Type.Optional(
Type.String({ description: "Optional message ID to reply to (creates a threaded reply)." }),
),
}),
async execute(_id: string, params: Record<string, unknown>) {
const podId = readStringParam(params, "podId", { required: true });
const filePath = readStringParam(params, "filePath", { required: true });
const caption = readStringParam(params, "message");
const replyToId = readStringParam(params, "replyToId") || undefined;

// Workspace boundary: validate the path stays inside /workspace/<accountId>/
// before reading any bytes. Uses the same plugin-sdk helper that path-policy
// exposes for boundary enforcement.
const accountId = process.env.OPENCLAW_ACCOUNT_ID || "default";
const workspaceRoot = `/workspace/${accountId}`;
let safeRelative: string;
try {
safeRelative = toRelativeWorkspacePath(workspaceRoot, filePath);
} catch (err) {
throw new Error(
`commonly_attach_file: workspace boundary violation — ${(err as Error).message}`,
);
}
const absolutePath = `${workspaceRoot}/${safeRelative}`;

// Read bytes (size cap enforced before upload).
const MAX_BYTES = 25 * 1024 * 1024;
let bytes: Buffer;
try {
bytes = readFileSync(absolutePath);
} catch (err) {
throw new Error(
`commonly_attach_file: cannot read file at '${filePath}' — ${(err as Error).message}`,
);
}
if (bytes.length > MAX_BYTES) {
throw new Error(
`commonly_attach_file: file size ${bytes.length} bytes exceeds 25 MB limit`,
);
}

// Detect MIME from extension. Server validates against the allowlist.
const mimeType = detectMimeFromPath(safeRelative);
const originalName = safeRelative.split("/").pop() || safeRelative;

// Upload, then optionally post the directive in a chat message.
const uploaded = await client.uploadFile(
podId,
new Uint8Array(bytes),
originalName,
mimeType,
);

if (caption !== undefined && caption !== "") {
const directive = `[[upload:${uploaded.fileName}|${uploaded.originalName}|${uploaded.size}|${uploaded.kind}|${uploaded._id}]]`;
const content = `${caption}\n${directive}`;
const result = await client.postMessage(podId, content, {}, replyToId);
return jsonResult({ ok: true, file: uploaded, message: result });
}

// Caller composes its own message — return metadata + the ready-made directive.
return jsonResult({
ok: true,
file: uploaded,
directive: `[[upload:${uploaded.fileName}|${uploaded.originalName}|${uploaded.size}|${uploaded.kind}|${uploaded._id}]]`,
});
},
},
{
name: "commonly_post_thread_comment",
label: "Commonly Post Thread Comment",
Expand Down
9 changes: 9 additions & 0 deletions src/plugin-sdk/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -797,3 +797,12 @@ export type { ContextEngineFactory } from "../context-engine/registry.js";

// Security utilities
export { redactSensitiveText } from "../logging/redact.js";

// Path-policy utilities — workspace-boundary enforcement for plugins that
// accept caller-supplied filesystem paths. Rejects '..', absolute paths,
// and resolved paths that escape the workspace root.
export {
toRelativeWorkspacePath,
toRelativeSandboxPath,
resolvePathFromInput,
} from "../agents/path-policy.js";
Loading