diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml new file mode 100644 index 0000000..6e3f079 --- /dev/null +++ b/.github/codeql/codeql-config.yml @@ -0,0 +1,7 @@ +name: liquid-potassium CodeQL config + +paths-ignore: + - dist/** + - src/generated/** + - tests/generated/** + - coverage/** diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..788d8cf --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,13 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f12d0db..c4c20b5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,16 +2,30 @@ name: CI on: pull_request: + branches: + - main push: branches: - main + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true jobs: verify: + name: Verify runs-on: ubuntu-latest + timeout-minutes: 20 steps: - name: Check out repository uses: actions/checkout@v4 + with: + persist-credentials: false - name: Set up Node.js uses: actions/setup-node@v4 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..245025f --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,53 @@ +name: CodeQL + +on: + pull_request: + branches: + - main + push: + branches: + - main + schedule: + - cron: "27 4 * * 1" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + actions: read + contents: read + strategy: + fail-fast: false + matrix: + include: + - language: javascript-typescript + build-mode: none + steps: + - name: Check out repository + uses: actions/checkout@v5 + with: + persist-credentials: false + + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + config-file: ./.github/codeql/codeql-config.yml + queries: security-extended + + - name: Perform CodeQL analysis + uses: github/codeql-action/analyze@v4 + with: + category: "/language:${{ matrix.language }}" + upload: never diff --git a/.gitignore b/.gitignore index b3ae12f..2fc1c60 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,15 @@ coverage/ .vitest/ .tsbuildinfo *.tsbuildinfo +*.log +npm-debug.log* + +# Local credentials and machine-specific package registry state. +.env +.env.* +!.env.example +.npmrc +*.pem +*.key +*.p12 +*.pfx diff --git a/PROGRESS.md b/PROGRESS.md index b77e6df..7f8dd73 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -4,9 +4,9 @@ This file is the canonical live tracker for the Infomaniak SDK and OpenClaw inte ## Current State -- Phase: OpenClaw migration readiness. -- Active task: SDK workflow actions and migration documentation for `potassium-openclaw` are complete and validated. -- Repository status: The Phase 1-11 implementation is complete on branch `codex/native-domain-workflows`. +- Phase: Published SDK release and OpenClaw migration readiness. +- Active task: Migrating downstream `potassium-openclaw` usage to the published `liquid-potassium` package. +- Repository status: `liquid-potassium@0.1.0` is published on npm; OpenClaw workflow tooling and kDrive upload file-path support are implemented on branch `codex/liquid-library-openclaw-workflows`. - Coordination mode: one main agent owns sequencing, verification, integration, and commits. ## Coordination Rules @@ -93,10 +93,22 @@ This file is the canonical live tracker for the Infomaniak SDK and OpenClaw inte - `docs: document OpenClaw migration workflow bridge` - Documents `client.workflows` as the SDK-first bridge for migrating `potassium-openclaw`. - Records that OpenClaw-specific orchestration belongs in the OpenClaw adapter/repository, while this library owns reusable network/domain workflows. +- `feat: expose openclaw workflow tools` + - Added OpenClaw workflow list/describe/run tools backed by `client.workflows`, with domain/operation allow/deny policy, mutating blocks, explicit confirmation, and injected fetch. + - Added `tokenEnvName` config defaulting to `INFOMANIAK_TOKEN` so OpenClaw can inject credentials without copying bearer tokens into plugin config. + - Added public exports and the `./openclaw/tools` package subpath for downstream `potassium-openclaw` migration. + - Added kDrive workflow uploads from absolute local `file_path`, including size validation, default remote file names, and binary body preservation in the shared transport. + - Added a package `prepare` script so GitHub-pinned installs can build `dist` without publishing `liquid-potassium` to npm. +- `security: harden public repository readiness` + - Redacted sensitive response headers and secret-shaped JSON/text fields from `InfomaniakOperationError.responseSummary` while preserving status, request id, and non-sensitive diagnostics. + - Added tests for JSON, malformed JSON, and text error redaction while maintaining 100% coverage. + - Tightened local ignore rules for credential files and generated build leftovers. + - Restricted CI workflow permissions to read-only contents access and disabled checkout credential persistence. + - Added `SECURITY.md` and Dependabot updates for npm and GitHub Actions. ## Next Task Queue -- In `/Users/opencow/Software/potassium-openclaw`, migrate existing skills/tools to call `liquid-potassium` through `client.workflows` where a curated action exists. +- In `/Users/opencow/Software/potassium-openclaw`, depend on a specific `OpenCow42/liquidPotassium` commit and migrate existing skills/tools to call `liquid-potassium` through the exported OpenClaw tools and `client.workflows` where a curated action exists. - Fall back to catalog search/describe plus generated/raw SDK calls for operations that are not yet worth promoting to workflow actions. - Add new workflow actions in this repository only when migration exposes repeated adapter code or a common user workflow. @@ -125,6 +137,11 @@ This file is the canonical live tracker for the Infomaniak SDK and OpenClaw inte - Open-source release preparation: passing `npm run ci`, `npm audit`, `npm audit --omit=dev`, `npm pack --dry-run`, and `git diff --check`. - Package preview: `npm pack --dry-run` reports `liquid-potassium@0.1.0`, 160 files, approximately 881.6 kB packed and 16.1 MB unpacked, including `LICENSE`, `README.md`, built SDK output, OpenClaw plugin output, skill docs, Mail application docs, and normalization report. - Domain workflow actions: passing `npm run typecheck` and `npm run test:coverage` with 100% statements, branches, functions, and lines after the workflow expansion and module split. +- OpenClaw workflow tool migration slice: passing `npm run typecheck`, `npm test`, `npm run test:coverage` with 100% statements/branches/functions/lines, `npm run build`, and `npm pack --dry-run --json`. +- Package preview after workflow tool migration: `npm pack --dry-run --json` reports `liquid-potassium@0.1.0`, 169 files, approximately 898.8 kB packed and 16.2 MB unpacked, including `dist/src/openclaw/infomaniak-tools.*`, `dist/openclaw/plugin.*`, skill docs, package exports, and the `prepare` build flow. +- npm publication: `liquid-potassium@0.1.0` is published to `https://registry.npmjs.org/` with `latest` pointing to `0.1.0`. +- Post-publish install verification: a fresh temporary project installed `liquid-potassium@0.1.0` from npm with 0 vulnerabilities and successfully imported the main SDK, OpenClaw plugin, and OpenClaw tools subpaths. +- Public GitHub security preflight: passing `npm run ci`, `npm audit`, `npm audit --omit=dev`, `npm pack --dry-run --json`, `git diff --check`, and high-confidence secret scans across current tracked files and git history. ## Blockers And Risks diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..a9138d2 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,20 @@ +# Security Policy + +## Supported Versions + +Security fixes are prepared for the latest published npm release line. + +## Reporting A Vulnerability + +Please report suspected vulnerabilities through GitHub's private vulnerability reporting or a private security advisory for this repository. + +Do not open a public issue with exploit details, access tokens, cookies, mailbox content, customer data, or production payloads. If a public issue is the only available channel, keep it minimal and ask for a private follow-up path. + +Useful reports include: + +- affected package version or commit; +- impacted SDK, OpenClaw, generation, or documentation-snapshot surface; +- reproduction steps using mocked data whenever possible; +- whether credentials, authorization headers, cookies, request bodies, response summaries, generated metadata, or package contents are involved. + +The project does not require real Infomaniak network calls for tests. Security reproductions should use injected `fetch`, local fixtures, or redacted recordings. diff --git a/openclaw/skills/infomaniak/SKILL.md b/openclaw/skills/infomaniak/SKILL.md index 5906964..e8444a1 100644 --- a/openclaw/skills/infomaniak/SKILL.md +++ b/openclaw/skills/infomaniak/SKILL.md @@ -15,7 +15,8 @@ Use the Infomaniak plugin as a compact API navigator and caller. Prefer domain n 3. Use `infomaniak_describe` on the best operation before calling it. Check path parameters, query parameters, request content types, auth, operation requirements, discovery suggestions, and the `mutating` flag. 4. Use `infomaniak_discover` when an operation needs opaque IDs such as account ids, drive ids, mail access ids, kChat team ids, or product ids. If discovery reports `no_public_discovery`, explain that clearly instead of guessing. 5. Use `infomaniak_mail_application` for mailbox content workflows such as listing the current user's mailboxes, folders, threads, reading message resources, checking quotas, drafts, schedules, cancellation, and moving messages. This is separate from Mail Hosting/admin operations in the generated `mail` domain. -6. Use `infomaniak_call` only after the operation and inputs are clear. +6. Use `infomaniak_workflow_list` and `infomaniak_workflow_describe` for reviewed SDK workflow actions before falling back to raw operations. Prefer `infomaniak_workflow_run` for common tasks such as kDrive browsing/upload, kChat posts, URL shortener links, discovery workflows, and other domain actions. +7. Use `infomaniak_call` only after no reviewed workflow fits and the operation and inputs are clear. ## Safety @@ -23,9 +24,10 @@ Use the Infomaniak plugin as a compact API navigator and caller. Prefer domain n - Use `infomaniak_discover` before asking the user for opaque resource IDs when a reviewed public discovery recipe exists. - If discovery says a resource has no public discovery path, ask the user for the ID or explain where it must come from. - Use Mail application actions for mailbox consumption, not generated Mail Hosting operations. +- Prefer reviewed workflow actions over raw operation calls when they cover the user request. - Treat `mutating: true` as create/update/delete/send/archive or other state-changing work. - Call a mutating operation only when the user has explicitly asked for that state change and the request parameters are clear. -- Set `confirm_mutating=true` only for explicit mutating user intent, including mutating Mail application actions. If the plugin blocks mutation, explain that the plugin policy prevents the call. +- Set `confirm_mutating=true` only for explicit mutating user intent, including mutating Mail application and workflow actions. If the plugin blocks mutation, explain that the plugin policy prevents the call. - Prefer read-only discovery for ambiguous requests. ## Tool Model @@ -35,4 +37,7 @@ Use the Infomaniak plugin as a compact API navigator and caller. Prefer domain n - `infomaniak_describe`: inspect one normalized operation ID, including resource requirements and discovery suggestions. - `infomaniak_discover`: discover resource IDs or return reviewed no-public-discovery explanations. - `infomaniak_mail_application`: call reviewed mailbox-consumption actions outside the public OpenAPI catalog. +- `infomaniak_workflow_list`: list reviewed SDK workflow actions available under plugin policy. +- `infomaniak_workflow_describe`: inspect one reviewed workflow action, including input metadata, mutating status, and backing operation IDs. +- `infomaniak_workflow_run`: run one reviewed workflow action with optional `input` and `confirm_mutating`. - `infomaniak_call`: call one normalized operation ID with optional `path`, `query`, `headers`, `body`, and `confirm_mutating`. diff --git a/package.json b/package.json index 7f25846..5e7b560 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,10 @@ "./openclaw/plugin": { "types": "./dist/openclaw/plugin.d.ts", "default": "./dist/openclaw/plugin.js" + }, + "./openclaw/tools": { + "types": "./dist/src/openclaw/infomaniak-tools.d.ts", + "default": "./dist/src/openclaw/infomaniak-tools.js" } }, "files": [ @@ -43,6 +47,7 @@ "openclaw/skills", "README.md", "RELEASE.md", + "SECURITY.md", "spec/normalization-report.json" ], "engines": { @@ -54,6 +59,7 @@ "check:generated": "npm run normalize && npm run generate && git diff --exit-code -- spec/infomaniak.normalized.json spec/normalization-report.json src/generated tests/generated", "check:docs": "npm run docs:check && git diff --exit-code -- spec/docs-enrichment-report.json", "check:no-network": "tsx scripts/check-no-network-policy.ts", + "prepare": "npm run build", "typecheck": "tsc -p tsconfig.json --noEmit", "test": "vitest run", "test:coverage": "vitest run --coverage", diff --git a/src/client/create-client.ts b/src/client/create-client.ts index 7f9865c..d0c6485 100644 --- a/src/client/create-client.ts +++ b/src/client/create-client.ts @@ -46,6 +46,10 @@ export type InfomaniakClient = GeneratedOperationClient & const defaultBaseUrl = "https://api.infomaniak.com"; const defaultMailApplicationBaseUrl = "https://mail.infomaniak.com"; +const responseSummaryMaxLength = 500; +const redactedValue = "[redacted]"; +const sensitiveHeaderNames = new Set(["authorization", "proxy-authorization", "cookie", "set-cookie", "x-api-key", "x-auth-token"]); +const sensitiveFieldNameFragments = ["authorization", "password", "passwd", "secret", "token", "apikey", "privatekey", "session", "cookie", "csrf"]; const operationById = new Map(operations.map((operation) => [operation.operationId, operation])); export function createInfomaniakClient(config: InfomaniakClientConfig = {}): InfomaniakClient { @@ -237,7 +241,15 @@ function serializeBody(body: unknown, headers: Headers): BodyInit | undefined { } function isJsonSerializableBody(body: unknown): boolean { - return typeof body === "object" && body !== null && !(body instanceof ArrayBuffer) && !(body instanceof Blob) && !(body instanceof FormData) && !(body instanceof URLSearchParams); + return ( + typeof body === "object" && + body !== null && + !(body instanceof ArrayBuffer) && + !ArrayBuffer.isView(body) && + !(body instanceof Blob) && + !(body instanceof FormData) && + !(body instanceof URLSearchParams) + ); } async function parseSuccessResponse(response: Response): Promise { @@ -256,16 +268,54 @@ async function parseSuccessResponse(response: Response): Promise { async function safeResponseSummary(response: Response): Promise { const contentType = response.headers.get("content-type") ?? ""; + const body = await response.text(); + if (!body) { + return ""; + } if (contentType.includes("application/json")) { - return JSON.stringify(await response.json()); + try { + return truncateResponseSummary(JSON.stringify(redactJsonValue(JSON.parse(body)))); + } catch { + return truncateResponseSummary(redactSensitiveText(body)); + } + } + return truncateResponseSummary(redactSensitiveText(body)); +} + +function redactJsonValue(value: unknown, key?: string): unknown { + if (key && isSensitiveFieldName(key)) { + return redactedValue; + } + if (Array.isArray(value)) { + return value.map((item) => redactJsonValue(item)); } - return (await response.text()).slice(0, 500); + if (isQueryObject(value)) { + return Object.fromEntries(Object.entries(value).map(([entryKey, entryValue]) => [entryKey, redactJsonValue(entryValue, entryKey)])); + } + return value; +} + +function redactSensitiveText(value: string): string { + return value + .replace(/\b(authorization|proxy-authorization)\s*:\s*bearer\s+[^\r\n]+/gi, "$1: Bearer [redacted]") + .replace(/\b((?:set-)?cookie)\s*:\s*[^\r\n]+/gi, "$1: [redacted]") + .replace(/\b(access[_-]?token|refresh[_-]?token|api[_-]?key|password|secret|token)\s*=\s*[^&\s]+/gi, "$1=[redacted]") + .replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [redacted]"); +} + +function truncateResponseSummary(value: string): string { + return value.slice(0, responseSummaryMaxLength); +} + +function isSensitiveFieldName(key: string): boolean { + const normalized = key.toLowerCase().replace(/[^a-z0-9]/g, ""); + return sensitiveFieldNameFragments.some((fragment) => normalized.includes(fragment)); } function headersToRecord(headers: Headers): Record { const record: Record = {}; headers.forEach((value, key) => { - record[key] = value; + record[key] = sensitiveHeaderNames.has(key.toLowerCase()) ? redactedValue : value; }); return record; } diff --git a/src/index.ts b/src/index.ts index 09a1a07..8f5c18f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -71,6 +71,24 @@ export { type DomainWorkflowClient, type DomainWorkflowRuntimeClient, } from "./workflows/domain-actions.js"; +export { + createInfomaniakOpenClawTools, + InfomaniakPluginConfigJsonSchema, + infomaniakCallToolSchema, + infomaniakDiscoverToolSchema, + infomaniakDescribeToolSchema, + infomaniakDomainsToolSchema, + infomaniakMailApplicationToolSchema, + infomaniakSearchToolSchema, + infomaniakWorkflowDescribeToolSchema, + infomaniakWorkflowListToolSchema, + infomaniakWorkflowRunToolSchema, + resolveInfomaniakPluginConfig, + type InfomaniakOpenClawPluginConfig, + type InfomaniakOpenClawToolsOptions, + type OperationDescriptionWithRequirements, + type OperationSummary, +} from "./openclaw/infomaniak-tools.js"; export { buildDomainMetadata, buildOperationCatalog, diff --git a/src/openclaw/infomaniak-tools.ts b/src/openclaw/infomaniak-tools.ts index 452849e..688d0ad 100644 --- a/src/openclaw/infomaniak-tools.ts +++ b/src/openclaw/infomaniak-tools.ts @@ -9,10 +9,12 @@ import { describeOperationRequirements, describeResourceKind, getOperation, + listDomainActions, listDomains, listResourceDiscoveryRecipes, listOperations, searchOperations, + type DomainActionMetadata, type InfomaniakClientConfig, type MailDraftPayload, type MailMoveMessagesPayload, @@ -25,6 +27,7 @@ import { export interface InfomaniakOpenClawPluginConfig { token?: string; + tokenEnvName: string; baseUrl?: string; mailApplicationBaseUrl?: string; allowedDomains: readonly string[]; @@ -65,6 +68,11 @@ export const InfomaniakPluginConfigJsonSchema = { additionalProperties: false, properties: { token: { type: "string", description: "Infomaniak API bearer token." }, + tokenEnvName: { + type: "string", + default: "INFOMANIAK_TOKEN", + description: "Environment variable name used for the Infomaniak API bearer token when token is not configured directly.", + }, baseUrl: { type: "string", description: "Infomaniak API base URL. Defaults to https://api.infomaniak.com." }, mailApplicationBaseUrl: { type: "string", @@ -179,6 +187,32 @@ export const infomaniakMailApplicationToolSchema = Type.Object( { additionalProperties: false }, ); +export const infomaniakWorkflowListToolSchema = Type.Object( + { + domain: Type.Optional(Type.String({ description: "Restrict workflow actions to one Infomaniak domain." })), + mutating: Type.Optional(Type.Boolean({ description: "Restrict workflow actions by mutating status." })), + }, + { additionalProperties: false }, +); + +export const infomaniakWorkflowDescribeToolSchema = Type.Object( + { + domain: Type.String({ description: "Infomaniak workflow domain." }), + action: Type.String({ description: "Infomaniak workflow action name." }), + }, + { additionalProperties: false }, +); + +export const infomaniakWorkflowRunToolSchema = Type.Object( + { + domain: Type.String({ description: "Infomaniak workflow domain." }), + action: Type.String({ description: "Infomaniak workflow action name." }), + input: Type.Optional(Type.Record(Type.String(), Type.Unknown(), { description: "Workflow action input." })), + confirm_mutating: Type.Optional(Type.Boolean({ description: "Required for mutating workflow actions when plugin config allows mutation." })), + }, + { additionalProperties: false }, +); + type MailApplicationActionName = | "listUserMailboxes" | "listFolders" @@ -381,6 +415,29 @@ export function createInfomaniakOpenClawTools(options: InfomaniakOpenClawToolsOp executionMode: "sequential", execute: async (_toolCallId, params, signal) => jsonResult(await callMailApplicationAction(config, options.fetch, asRecord(params), signal)), }, + { + name: "infomaniak_workflow_list", + label: "Infomaniak Workflow List", + description: "List reviewed Infomaniak SDK workflow actions available through the current OpenClaw plugin policy.", + parameters: infomaniakWorkflowListToolSchema, + execute: async (_toolCallId, params) => jsonResult({ actions: listAllowedWorkflowActions(config, asRecord(params)) }), + }, + { + name: "infomaniak_workflow_describe", + label: "Infomaniak Workflow Describe", + description: "Describe one reviewed Infomaniak SDK workflow action before running it.", + parameters: infomaniakWorkflowDescribeToolSchema, + execute: async (_toolCallId, params) => jsonResult({ action: getAllowedWorkflowAction(config, readDomain(params), readAction(params)) }), + }, + { + name: "infomaniak_workflow_run", + label: "Infomaniak Workflow Run", + description: + "Run one reviewed Infomaniak SDK workflow action. Prefer this for common domain workflows; mutating actions require explicit confirmation and plugin permission.", + parameters: infomaniakWorkflowRunToolSchema, + executionMode: "sequential", + execute: async (_toolCallId, params, signal) => jsonResult(await runWorkflowAction(config, options.fetch, asRecord(params), signal)), + }, { name: "infomaniak_call", label: "Infomaniak Call", @@ -404,6 +461,7 @@ export function resolveInfomaniakPluginConfig(config: Record | const record = asRecord(config ?? {}); return { ...optionalStringProperty(record, "token"), + tokenEnvName: stringParam(record.tokenEnvName) ?? "INFOMANIAK_TOKEN", ...optionalStringProperty(record, "baseUrl"), ...optionalStringProperty(record, "mailApplicationBaseUrl"), allowedDomains: stringArrayProperty(record, "allowedDomains"), @@ -523,6 +581,23 @@ async function callMailApplicationAction( return { operation: summarizeMailApplicationAction(action), result }; } +async function runWorkflowAction( + config: InfomaniakOpenClawPluginConfig, + fetchImpl: typeof fetch | undefined, + params: Record, + signal: AbortSignal | undefined, +) { + const action = getAllowedWorkflowAction(config, readDomain(params), readAction(params)); + assertMutationAllowed(config, { operationId: action.id, mutating: action.mutating }, params.confirm_mutating === true); + const client = createInfomaniakClient(buildClientConfig(config, fetchImpl)); + return client.workflows.run({ + domain: action.domain, + action: action.action, + ...optionalProperty("input", asOptionalRecord(params.input)), + ...optionalProperty("signal", signal), + }); +} + async function executeMailApplicationAction( client: ReturnType, action: MailApplicationActionMetadata, @@ -618,6 +693,39 @@ function mailApplicationActionAllowed(config: InfomaniakOpenClawPluginConfig, ac return !deniedOperationSet.has(action.operationId); } +function listAllowedWorkflowActions(config: InfomaniakOpenClawPluginConfig, params: Record): readonly DomainActionMetadata[] { + return listDomainActions({ + ...optionalProperty("domain", stringParam(params.domain)), + ...optionalProperty("mutating", typeof params.mutating === "boolean" ? params.mutating : undefined), + }).filter((action) => workflowActionAllowed(config, action)); +} + +function getAllowedWorkflowAction(config: InfomaniakOpenClawPluginConfig, domain: string, actionName: string): DomainActionMetadata { + const action = listDomainActions({ domain }).find((candidate) => candidate.action === actionName); + if (!action) { + throw new Error(`Unknown Infomaniak workflow action: ${domain}/${actionName}`); + } + if (!workflowActionAllowed(config, action)) { + throw new Error(`Infomaniak workflow action is not allowed by plugin policy: ${action.id}`); + } + return action; +} + +function workflowActionAllowed(config: InfomaniakOpenClawPluginConfig, action: DomainActionMetadata): boolean { + const allowedOperationSet = new Set(config.allowedOperations); + const deniedOperationSet = new Set(config.deniedOperations); + if (!domainAllowed(config, action.domain)) { + return false; + } + if (action.operationIds.some((operationId) => deniedOperationSet.has(operationId))) { + return false; + } + if (allowedOperationSet.size > 0) { + return action.operationIds.length > 0 && action.operationIds.every((operationId) => allowedOperationSet.has(operationId)); + } + return true; +} + function domainAllowed(config: InfomaniakOpenClawPluginConfig, domain: string): boolean { return config.allowedDomains.length === 0 || config.allowedDomains.includes(domain); } @@ -693,6 +801,8 @@ function buildClientConfig(config: InfomaniakOpenClawPluginConfig, fetchImpl: ty const clientConfig: InfomaniakClientConfig = {}; if (config.token) { clientConfig.token = config.token; + } else { + clientConfig.token = () => process.env[config.tokenEnvName] ?? ""; } if (config.baseUrl) { clientConfig.baseUrl = config.baseUrl; @@ -753,6 +863,22 @@ function readMailApplicationAction(params: Record): string { return action; } +function readDomain(params: unknown): string { + const domain = stringParam(asRecord(params).domain); + if (!domain) { + throw new Error("domain is required."); + } + return domain; +} + +function readAction(params: unknown): string { + const action = stringParam(asRecord(params).action); + if (!action) { + throw new Error("action is required."); + } + return action; +} + function requiredString(params: Record, key: string): string { const value = stringParam(params[key]); if (!value) { diff --git a/src/workflows/domain-action-helpers.ts b/src/workflows/domain-action-helpers.ts index a281d29..b64022b 100644 --- a/src/workflows/domain-action-helpers.ts +++ b/src/workflows/domain-action-helpers.ts @@ -1,3 +1,6 @@ +import { readFile, stat } from "node:fs/promises"; +import { basename, isAbsolute } from "node:path"; + import { getOperation } from "../catalog/operation-catalog.js"; import { InfomaniakError } from "../client/errors.js"; import type { OperationRequest } from "../client/generated-operation-client.js"; @@ -166,11 +169,52 @@ export function optionalRecord(input: Record, key: string): Rec return isRecord(value) ? value : undefined; } -export function requiredValue(input: Record, key: string): unknown { - if (!(key in input) || input[key] === undefined || input[key] === null) { - throw new InfomaniakError(`${key} is required.`); +export async function buildUploadFileRequest(input: Record): Promise> { + const upload = await readUploadBody(input); + return { + path: { drive_id: requiredInteger(input, "drive_id") }, + query: optionalQuery({ + total_size: upload.totalSize, + file_name: upload.fileName, + directory_id: integerParam(input.directory_id), + directory_path: optionalString(input, "directory_path"), + conflict: optionalString(input, "conflict"), + }), + body: upload.body, + }; +} + +async function readUploadBody(input: Record): Promise<{ body: unknown; totalSize: number; fileName: string | undefined }> { + if ("body" in input && input.body !== undefined && input.body !== null) { + return { + body: input.body, + totalSize: requiredInteger(input, "total_size"), + fileName: optionalString(input, "file_name"), + }; + } + + const filePath = optionalString(input, "file_path"); + if (!filePath) { + throw new InfomaniakError("body or file_path is required."); } - return input[key]; + if (!isAbsolute(filePath)) { + throw new InfomaniakError("file_path must be absolute."); + } + + const fileStats = await stat(filePath); + if (!fileStats.isFile()) { + throw new InfomaniakError("file_path must point to a regular file."); + } + const expectedSize = integerParam(input.total_size); + if (expectedSize !== undefined && expectedSize !== fileStats.size) { + throw new InfomaniakError("total_size must match file_path byte size."); + } + + return { + body: await readFile(filePath), + totalSize: fileStats.size, + fileName: optionalString(input, "file_name") ?? basename(filePath), + }; } function stringNumberRecord(value: unknown): Record | undefined { diff --git a/src/workflows/domain-actions.ts b/src/workflows/domain-actions.ts index f328ccc..ce739a0 100644 --- a/src/workflows/domain-actions.ts +++ b/src/workflows/domain-actions.ts @@ -3,6 +3,7 @@ import { InfomaniakError } from "../client/errors.js"; import type { MailDraftPayload, MailMoveMessagesPayload } from "../client/mail-application.js"; import { booleanParam, + buildUploadFileRequest, customAction, discoveryActions, field, @@ -19,7 +20,6 @@ import { requiredInteger, requiredRecord, requiredString, - requiredValue, responseItems, withSignal, } from "./domain-action-helpers.js"; @@ -485,36 +485,24 @@ const staticActions: readonly DomainActionDefinition[] = [ signal, ), }), - operationAction({ + customAction({ domain: "kdrive", action: "uploadFile", - operationId: "kDriveUploadV3", + operationIds: ["kDriveUploadV3"], summary: "Upload file bytes to kDrive.", - description: "Upload direct file content to a kDrive destination using the documented v3 upload route.", + description: "Upload file content from a local path or direct body to a kDrive destination using the documented v3 upload route.", input: [ field("drive_id", "number", true, "Drive identifier."), - field("body", "binary", true, "File bytes as a BodyInit-compatible value."), - field("total_size", "number", true, "Total uploaded byte size."), + field("body", "binary", false, "File bytes as a BodyInit-compatible value."), + field("file_path", "string", false, "Absolute local file path to read and upload."), + field("total_size", "number", false, "Total uploaded byte size. Required for direct body uploads and validated for file_path uploads."), field("file_name", "string", false, "Remote file name."), field("directory_id", "number", false, "Destination directory identifier."), field("directory_path", "string", false, "Destination directory path."), field("conflict", "string", false, "Conflict handling mode accepted by the API."), ], - buildRequest: (input, signal) => - withSignal( - { - path: { drive_id: requiredInteger(input, "drive_id") }, - query: optionalQuery({ - total_size: requiredInteger(input, "total_size"), - file_name: optionalString(input, "file_name"), - directory_id: integerParam(input.directory_id), - directory_path: optionalString(input, "directory_path"), - conflict: optionalString(input, "conflict"), - }), - body: requiredValue(input, "body"), - }, - signal, - ), + mutating: true, + run: async (client, input, signal) => client.requestOperation("kDriveUploadV3", withSignal(await buildUploadFileRequest(input), signal)), }), operationAction({ domain: "publicCloud", diff --git a/tests/unit/client/create-client.test.ts b/tests/unit/client/create-client.test.ts index c6b1982..0e3a360 100644 --- a/tests/unit/client/create-client.test.ts +++ b/tests/unit/client/create-client.test.ts @@ -100,6 +100,22 @@ describe("createInfomaniakClient", () => { expect((init?.headers as Headers).has("content-type")).toBe(false); }); + it("preserves typed array request bodies as binary content", async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 })); + const binary = new Uint8Array([1, 2, 3]); + const client = createInfomaniakClient({ fetch: fetchMock }); + + const result = await client.raw.POST("/upload", { + body: binary, + requestContentTypes: ["application/octet-stream"], + }); + + expect(result).toBeUndefined(); + const init = fetchMock.mock.calls[0]?.[1]; + expect(init?.body).toBe(binary); + expect((init?.headers as Headers).has("content-type")).toBe(false); + }); + it("supports the generic request helper, custom base URLs, header providers, and all raw mutating methods", async () => { const fetchMock = vi.fn().mockImplementation(async () => jsonResponse({ ok: true })); const client = createInfomaniakClient({ @@ -142,10 +158,10 @@ describe("createInfomaniakClient", () => { it("throws typed operation errors with safe response summaries", async () => { const fetchMock = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ error: "nope" }), { + new Response(JSON.stringify({ error: "nope", token: "returned-token", nested: { password: "mail-secret" }, items: [{ api_key: "key-1" }] }), { status: 403, statusText: "Forbidden", - headers: { "content-type": "application/json", "x-request-id": "req-1" }, + headers: { authorization: "Bearer returned-token", "content-type": "application/json", "set-cookie": "sid=returned-cookie", "x-request-id": "req-1" }, }), ); const client = createInfomaniakClient({ fetch: fetchMock }); @@ -156,8 +172,8 @@ describe("createInfomaniakClient", () => { path: "/danger", status: 403, statusText: "Forbidden", - responseSummary: "{\"error\":\"nope\"}", - headers: { "content-type": "application/json", "x-request-id": "req-1" }, + responseSummary: "{\"error\":\"nope\",\"token\":\"[redacted]\",\"nested\":{\"password\":\"[redacted]\"},\"items\":[{\"api_key\":\"[redacted]\"}]}", + headers: { authorization: "[redacted]", "content-type": "application/json", "set-cookie": "[redacted]", "x-request-id": "req-1" }, } satisfies Partial); }); @@ -185,6 +201,32 @@ describe("createInfomaniakClient", () => { }); }); + it("redacts secret-shaped values from text operation errors", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response("Authorization: Bearer returned-token\npassword=returned-password\nmessage=visible", { status: 500, statusText: "Broken" }), + ); + const client = createInfomaniakClient({ fetch: fetchMock }); + + await expect(client.raw.GET("/broken")).rejects.toMatchObject({ + responseSummary: "Authorization: Bearer [redacted]\npassword=[redacted]\nmessage=visible", + }); + }); + + it("redacts malformed JSON operation errors as text", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response("token=returned-token&message=visible", { + status: 500, + statusText: "Broken", + headers: { "content-type": "application/json" }, + }), + ); + const client = createInfomaniakClient({ fetch: fetchMock }); + + await expect(client.raw.GET("/broken")).rejects.toMatchObject({ + responseSummary: "token=[redacted]&message=visible", + }); + }); + it("uses global fetch when no injected fetch is provided", () => { expect(createInfomaniakClient().raw).toBeDefined(); }); diff --git a/tests/unit/index.test.ts b/tests/unit/index.test.ts index c633cfd..d825f7d 100644 --- a/tests/unit/index.test.ts +++ b/tests/unit/index.test.ts @@ -1,10 +1,14 @@ import { describe, expect, it } from "vitest"; -import { packageName } from "../../src/index.js"; +import { createInfomaniakOpenClawTools, packageName, resolveInfomaniakPluginConfig } from "../../src/index.js"; describe("package entrypoint", () => { it("exports the package identity", () => { expect(packageName).toBe("liquid-potassium"); }); -}); + it("exports the OpenClaw adapter helpers from the public entrypoint", () => { + expect(resolveInfomaniakPluginConfig(undefined).tokenEnvName).toBe("INFOMANIAK_TOKEN"); + expect(createInfomaniakOpenClawTools().map((tool) => tool.name)).toContain("infomaniak_workflow_run"); + }); +}); diff --git a/tests/unit/openclaw/infomaniak-skill.test.ts b/tests/unit/openclaw/infomaniak-skill.test.ts index 27d8e68..cab5b95 100644 --- a/tests/unit/openclaw/infomaniak-skill.test.ts +++ b/tests/unit/openclaw/infomaniak-skill.test.ts @@ -20,6 +20,9 @@ describe("Infomaniak OpenClaw skill", () => { "infomaniak_describe", "infomaniak_discover", "infomaniak_mail_application", + "infomaniak_workflow_list", + "infomaniak_workflow_describe", + "infomaniak_workflow_run", "infomaniak_call", ]) { expect(registeredToolNames).toContain(toolName); @@ -32,15 +35,18 @@ describe("Infomaniak OpenClaw skill", () => { const searchIndex = skill.indexOf("Use `infomaniak_search`"); const describeIndex = skill.indexOf("Use `infomaniak_describe`"); const discoverIndex = skill.indexOf("Use `infomaniak_discover`"); + const workflowIndex = skill.indexOf("Use `infomaniak_workflow_list`"); const callIndex = skill.indexOf("Use `infomaniak_call`"); expect(searchIndex).toBeGreaterThan(0); expect(describeIndex).toBeGreaterThan(searchIndex); expect(discoverIndex).toBeGreaterThan(describeIndex); - expect(callIndex).toBeGreaterThan(discoverIndex); + expect(workflowIndex).toBeGreaterThan(discoverIndex); + expect(callIndex).toBeGreaterThan(workflowIndex); expect(skill).toContain("Do not guess operation IDs"); expect(skill).toContain("no_public_discovery"); expect(skill).toContain("Mail application actions for mailbox consumption"); + expect(skill).toContain("Prefer reviewed workflow actions over raw operation calls"); expect(skill).toContain("Call a mutating operation only when the user has explicitly asked"); expect(skill).toContain("confirm_mutating=true"); }); diff --git a/tests/unit/openclaw/infomaniak-tools.test.ts b/tests/unit/openclaw/infomaniak-tools.test.ts index 8ae72bc..64099ee 100644 --- a/tests/unit/openclaw/infomaniak-tools.test.ts +++ b/tests/unit/openclaw/infomaniak-tools.test.ts @@ -11,6 +11,9 @@ import { infomaniakDomainsToolSchema, infomaniakMailApplicationToolSchema, infomaniakSearchToolSchema, + infomaniakWorkflowDescribeToolSchema, + infomaniakWorkflowListToolSchema, + infomaniakWorkflowRunToolSchema, resolveInfomaniakPluginConfig, } from "../../../src/openclaw/infomaniak-tools.js"; @@ -28,12 +31,16 @@ describe("Infomaniak OpenClaw tools", () => { expect(infomaniakOpenClawPlugin.id).toBe("infomaniak"); expect(InfomaniakPluginConfigJsonSchema.properties.blockMutating.default).toBe(true); + expect(InfomaniakPluginConfigJsonSchema.properties.tokenEnvName.default).toBe("INFOMANIAK_TOKEN"); expect(registeredTools.map((tool) => tool.name)).toEqual([ "infomaniak_domains", "infomaniak_search", "infomaniak_describe", "infomaniak_discover", "infomaniak_mail_application", + "infomaniak_workflow_list", + "infomaniak_workflow_describe", + "infomaniak_workflow_run", "infomaniak_call", ]); expect(registeredTools.map((tool) => tool.parameters)).toEqual([ @@ -42,6 +49,9 @@ describe("Infomaniak OpenClaw tools", () => { infomaniakDescribeToolSchema, infomaniakDiscoverToolSchema, infomaniakMailApplicationToolSchema, + infomaniakWorkflowListToolSchema, + infomaniakWorkflowDescribeToolSchema, + infomaniakWorkflowRunToolSchema, infomaniakCallToolSchema, ]); expect(registeredTools.every((tool) => tool.label && tool.description)).toBe(true); @@ -49,6 +59,7 @@ describe("Infomaniak OpenClaw tools", () => { it("resolves strict plugin config defaults and validates supported config types", () => { expect(resolveInfomaniakPluginConfig(undefined)).toEqual({ + tokenEnvName: "INFOMANIAK_TOKEN", allowedDomains: [], allowedOperations: [], deniedOperations: [], @@ -57,6 +68,7 @@ describe("Infomaniak OpenClaw tools", () => { expect( resolveInfomaniakPluginConfig({ token: " token ", + tokenEnvName: " LIQUID_TEST_TOKEN ", baseUrl: " https://example.test ", mailApplicationBaseUrl: " https://mail.example.test ", allowedDomains: ["kmeet", "", 1], @@ -66,6 +78,7 @@ describe("Infomaniak OpenClaw tools", () => { }), ).toEqual({ token: "token", + tokenEnvName: "LIQUID_TEST_TOKEN", baseUrl: "https://example.test", mailApplicationBaseUrl: "https://mail.example.test", allowedDomains: ["kmeet"], @@ -458,6 +471,162 @@ describe("Infomaniak OpenClaw tools", () => { ); }); + it("lists, describes, and runs allowed SDK workflow actions through injected fetch", async () => { + const abort = new AbortController(); + const fetchMock = vi.fn().mockImplementation(async () => + new Response(JSON.stringify({ result: "success", data: [] }), { + headers: { "content-type": "application/json" }, + }), + ); + const tools = createInfomaniakOpenClawTools({ + fetch: fetchMock, + config: { + allowedDomains: ["kdrive"], + tokenEnvName: "LIQUID_POTASSIUM_TEST_TOKEN", + }, + }); + const previousToken = process.env.LIQUID_POTASSIUM_TEST_TOKEN; + process.env.LIQUID_POTASSIUM_TEST_TOKEN = "env-secret"; + + try { + const readOnlyActions = await executeTool(findTool(tools, "infomaniak_workflow_list"), { + domain: "kdrive", + mutating: false, + }); + const allKdriveActions = await executeTool(findTool(tools, "infomaniak_workflow_list"), { + domain: "kdrive", + }); + const mutatingActions = await executeTool(findTool(tools, "infomaniak_workflow_list"), { + domain: "kdrive", + mutating: true, + }); + const description = await executeTool(findTool(tools, "infomaniak_workflow_describe"), { + domain: "kdrive", + action: "uploadFile", + }); + const run = await findTool(tools, "infomaniak_workflow_run").execute( + "workflow-run", + { + domain: "kdrive", + action: "listDirectory", + input: { drive_id: 3024749, file_id: 5, limit: 2 }, + }, + abort.signal, + ); + + expect(readOnlyActions.actions).toEqual( + expect.arrayContaining([expect.objectContaining({ id: "kdrive/listDirectory", operationIds: ["kDriveGetFilesInDirectoryV3"] })]), + ); + expect((allKdriveActions.actions as unknown[]).length).toBeGreaterThan((readOnlyActions.actions as unknown[]).length); + expect(mutatingActions.actions).toEqual(expect.arrayContaining([expect.objectContaining({ id: "kdrive/uploadFile", mutating: true })])); + expect(description.action).toEqual( + expect.objectContaining({ + id: "kdrive/uploadFile", + input: expect.arrayContaining([expect.objectContaining({ name: "file_path", required: false })]), + }), + ); + expect(run.details).toEqual({ + action: expect.objectContaining({ id: "kdrive/listDirectory" }), + result: { result: "success", data: [] }, + }); + expect(fetchMock.mock.calls[0]?.[0]).toBe("https://api.infomaniak.com/3/drive/3024749/files/5/files?limit=2"); + expect(fetchMock.mock.calls[0]?.[1]?.signal).toBe(abort.signal); + expect((fetchMock.mock.calls[0]?.[1]?.headers as Headers).get("authorization")).toBe("Bearer env-secret"); + } finally { + if (previousToken === undefined) { + delete process.env.LIQUID_POTASSIUM_TEST_TOKEN; + } else { + process.env.LIQUID_POTASSIUM_TEST_TOKEN = previousToken; + } + } + }); + + it("applies operation policy, confirmation, and parameter validation to workflow actions", async () => { + const fetchMock = vi.fn().mockImplementation(async () => + new Response(JSON.stringify({ created: true }), { + headers: { "content-type": "application/json" }, + }), + ); + + const allowedByOperation = await executeTool( + findTool(createInfomaniakOpenClawTools({ config: { allowedOperations: ["kDriveGetFilesInDirectoryV3"] } }), "infomaniak_workflow_describe"), + { domain: "kdrive", action: "listDirectory" }, + ); + const emptyOperationIds = await executeTool( + findTool(createInfomaniakOpenClawTools({ config: { allowedDomains: ["newsletter"] } }), "infomaniak_workflow_list"), + { domain: "newsletter", mutating: false }, + ); + const mutationResult = await executeTool( + findTool( + createInfomaniakOpenClawTools({ + fetch: fetchMock, + config: { blockMutating: false, allowedOperations: ["kDriveCreateDirectoryV3"] }, + }), + "infomaniak_workflow_run", + ), + { + domain: "kdrive", + action: "createDirectory", + input: { drive_id: 3024749, file_id: 5, body: { name: "Docs" } }, + confirm_mutating: true, + }, + ); + + expect(allowedByOperation.action).toEqual(expect.objectContaining({ id: "kdrive/listDirectory" })); + expect(emptyOperationIds.actions).toEqual(expect.arrayContaining([expect.objectContaining({ id: "newsletter/discoverNewsletterDomain", operationIds: [] })])); + expect(mutationResult).toEqual({ + action: expect.objectContaining({ id: "kdrive/createDirectory" }), + result: { created: true }, + }); + expect(fetchMock.mock.calls[0]?.[0]).toBe("https://api.infomaniak.com/3/drive/3024749/files/5/directory"); + + await expect( + executeTool( + findTool(createInfomaniakOpenClawTools({ config: { allowedOperations: ["NewsletterShowDomain"] } }), "infomaniak_workflow_describe"), + { domain: "newsletter", action: "discoverNewsletterDomain" }, + ), + ).rejects.toThrow(/not allowed/); + await expect( + executeTool( + findTool(createInfomaniakOpenClawTools({ config: { deniedOperations: ["kDriveGetFilesInDirectoryV3"] } }), "infomaniak_workflow_describe"), + { domain: "kdrive", action: "listDirectory" }, + ), + ).rejects.toThrow(/not allowed/); + await expect( + executeTool( + findTool(createInfomaniakOpenClawTools({ config: { allowedDomains: ["mail"] } }), "infomaniak_workflow_describe"), + { domain: "kdrive", action: "listDirectory" }, + ), + ).rejects.toThrow(/not allowed/); + await expect( + executeTool(findTool(createInfomaniakOpenClawTools(), "infomaniak_workflow_describe"), { + domain: "kdrive", + action: "missing", + }), + ).rejects.toThrow(/Unknown Infomaniak workflow action/); + await expect( + executeTool(findTool(createInfomaniakOpenClawTools(), "infomaniak_workflow_run"), { + domain: "kdrive", + action: "uploadFile", + confirm_mutating: true, + }), + ).rejects.toThrow(/blocked by plugin config/); + await expect( + executeTool( + findTool(createInfomaniakOpenClawTools({ config: { blockMutating: false } }), "infomaniak_workflow_run"), + { domain: "kdrive", action: "uploadFile" }, + ), + ).rejects.toThrow(/confirm_mutating=true/); + await expect(findTool(createInfomaniakOpenClawTools(), "infomaniak_workflow_describe").execute("bad-call", undefined)).rejects.toThrow( + /domain is required/, + ); + await expect( + executeTool(findTool(createInfomaniakOpenClawTools(), "infomaniak_workflow_describe"), { + domain: "kdrive", + }), + ).rejects.toThrow(/action is required/); + }); + it("calls allowed operations through the SDK with injected fetch", async () => { const fetchMock = vi.fn().mockResolvedValue( new Response(JSON.stringify({ planned: true }), { @@ -497,6 +666,29 @@ describe("Infomaniak OpenClaw tools", () => { expect((init?.headers as Headers).get("x-extra")).toBe("1"); }); + it("omits authorization when the configured token environment variable is absent", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ settings: true }), { + headers: { "content-type": "application/json" }, + }), + ); + delete process.env.LIQUID_POTASSIUM_MISSING_TOKEN; + const tools = createInfomaniakOpenClawTools({ + fetch: fetchMock, + config: { + tokenEnvName: "LIQUID_POTASSIUM_MISSING_TOKEN", + allowedOperations: ["get_1_kmeet_rooms_room_id_settings"], + }, + }); + + await executeTool(findTool(tools, "infomaniak_call"), { + operation_id: "get_1_kmeet_rooms_room_id_settings", + path: { room_id: "room-1" }, + }); + + expect((fetchMock.mock.calls[0]?.[1]?.headers as Headers).has("authorization")).toBe(false); + }); + it("blocks disallowed calls and mutating operations without explicit permission", async () => { await expect( executeTool(findTool(createInfomaniakOpenClawTools(), "infomaniak_call"), { diff --git a/tests/unit/workflows/domain-actions.test.ts b/tests/unit/workflows/domain-actions.test.ts index 7374ea7..a6c23e4 100644 --- a/tests/unit/workflows/domain-actions.test.ts +++ b/tests/unit/workflows/domain-actions.test.ts @@ -1,3 +1,7 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + import { describe, expect, it, vi } from "vitest"; import { @@ -132,6 +136,43 @@ describe("domain workflow actions", () => { }); }); + it("uploads kDrive files from absolute file paths", async () => { + const client = createMockWorkflowRuntime(); + const directory = await mkdtemp(join(tmpdir(), "liquid-potassium-")); + const filePath = join(directory, "note.txt"); + + try { + await writeFile(filePath, "hello"); + + await runDomainAction(client, { + domain: "kdrive", + action: "uploadFile", + input: { drive_id: 3024749, file_path: filePath, directory_path: "/Docs" }, + }); + await runDomainAction(client, { + domain: "kdrive", + action: "uploadFile", + input: { drive_id: 3024749, file_path: filePath, total_size: 5, file_name: "remote.txt" }, + }); + + const firstUpload = client.requestOperation.mock.calls.find((call) => call[0] === "kDriveUploadV3")?.[1]; + const secondUpload = client.requestOperation.mock.calls.filter((call) => call[0] === "kDriveUploadV3")[1]?.[1]; + expect(firstUpload).toEqual({ + path: { drive_id: 3024749 }, + query: { total_size: 5, file_name: "note.txt", directory_path: "/Docs" }, + body: expect.any(Uint8Array), + }); + expect([...(firstUpload?.body as Uint8Array)]).toEqual([104, 101, 108, 108, 111]); + expect(secondUpload).toEqual({ + path: { drive_id: 3024749 }, + query: { total_size: 5, file_name: "remote.txt" }, + body: expect.any(Uint8Array), + }); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + it("delegates mailbox actions to the Mail application client", async () => { const client = createMockWorkflowRuntime(); @@ -249,7 +290,12 @@ describe("domain workflow actions", () => { await expect(runDomainAction(client, { domain: "kdrive", action: "createDirectory", input: { drive_id: 1, file_id: 1, body: "bad" } })).rejects.toThrow( /body must be an object/, ); - await expect(runDomainAction(client, { domain: "kdrive", action: "uploadFile", input: { drive_id: 1, total_size: 10 } })).rejects.toThrow(/body is required/); + await expect(runDomainAction(client, { domain: "kdrive", action: "uploadFile", input: { drive_id: 1, total_size: 10 } })).rejects.toThrow( + /body or file_path is required/, + ); + await expect(runDomainAction(client, { domain: "kdrive", action: "uploadFile", input: { drive_id: 1, file_path: "relative.txt" } })).rejects.toThrow( + /file_path must be absolute/, + ); await expect(runDomainAction(client, { domain: "kdrive", action: "discoverKdriveDrive", input: { parents: "bad" } })).rejects.toThrow( /parents must be an object/, ); @@ -262,6 +308,25 @@ describe("domain workflow actions", () => { await expect(runDomainAction(client, { domain: "kdrive", action: "findPrivateFolder", input: { drive_id: 1 } })).rejects.toThrow(/Private/); }); + it("fails closed for invalid kDrive upload file paths", async () => { + const client = createMockWorkflowRuntime(); + const directory = await mkdtemp(join(tmpdir(), "liquid-potassium-")); + const filePath = join(directory, "note.txt"); + + try { + await writeFile(filePath, "hello"); + + await expect( + runDomainAction(client, { domain: "kdrive", action: "uploadFile", input: { drive_id: 1, file_path: directory } }), + ).rejects.toThrow(/regular file/); + await expect( + runDomainAction(client, { domain: "kdrive", action: "uploadFile", input: { drive_id: 1, file_path: filePath, total_size: 4 } }), + ).rejects.toThrow(/total_size must match/); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + it("exposes workflows on createInfomaniakClient", async () => { const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ data: [] })); const client = createInfomaniakClient({ token: "secret", fetch: fetchMock });