Compare commits

...

8 Commits

Author SHA1 Message Date
Lambda
977188dac1 docs(claude): note pnpm dev:desktop self-isolates per worktree
Clarify that desktop dev worktree isolation (renderer port + app name) is
automatic and independent of .env.worktree, which only covers backend/
frontend DB names/ports. Follow-up to MUL-3724 (#4598).

Co-authored-by: multica-agent <github@multica.ai>
2026-06-26 16:00:51 +08:00
Jiayuan Zhang
9c1d8d2659 fix(selfhost): fail early when Docker Compose v2 is missing (#4354)
* fix(selfhost): fail early with actionable message when Docker Compose v2 is missing

The selfhost make targets hardcoded 'docker compose' (Compose v2). On hosts with only the legacy v1 'docker-compose' (e.g. apt install docker-compose) the plugin is absent, so 'docker compose -f ... pull' fell through to the top-level docker CLI and failed with the cryptic 'unknown shorthand flag: f in -f', which users mistook for a Docker version problem (MUL-3458).

Add a REQUIRE_COMPOSE preflight that checks 'docker compose version' and prints an actionable English install hint, and route every selfhost invocation through $(COMPOSE). v1 fallback is intentionally not supported: docker-compose.selfhost.yml uses compose-spec syntax (top-level name:, no version:) that v1 cannot parse.

Co-authored-by: multica-agent <github@multica.ai>

* fix(selfhost): shorten Compose v2 hint and reject legacy v1

Address review on PR #4354:
- Drop Linux-specific install commands from the runtime error; point to
  the OS-agnostic https://docs.docker.com/compose/install/ and keep the
  message short and stable. Per-OS steps belong in docs, not the Makefile.
- Reject Compose v1 explicitly: the preflight now also requires the
  reported version to be v2, so COMPOSE=docker-compose no longer passes
  and then fails later on the compose-spec file.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Lambda <agent@multica.ai>
2026-06-26 09:40:05 +02:00
Jiayuan Zhang
6dcf82a58a feat(desktop): isolate pnpm dev:desktop per worktree (MUL-3724) (#4598)
* feat(desktop): isolate pnpm dev:desktop per worktree (MUL-3724)

Two worktrees could not run pnpm dev:desktop at once: both grabbed the
renderer port 5173 and the single-instance lock keyed by the app name
"Multica Canary". The env hooks to override each already existed
(DESKTOP_RENDERER_PORT in electron.vite.config.ts, DESKTOP_APP_SUFFIX in
src/main/index.ts) but nothing derived per-worktree values.

A new dev launcher (scripts/dev.mjs) derives both from the worktree path
for linked worktrees only — reusing the same cksum%1000 offset as
scripts/init-worktree-env.sh, so renderer port is 5173+offset and the app
becomes "Multica Canary <folder>" with its own userData/lock. The primary
checkout is untouched; explicit env vars still win. Backend targeting is
unchanged (apps/desktop/.env*). Also: brand-dev-electron honors the suffix,
turbo globalEnv passes it through, and CONTRIBUTING documents the flow.

Co-authored-by: multica-agent <github@multica.ai>

* fix(desktop): make worktree dev port/suffix collision-safe (MUL-3724)

Addresses code review on #4598:

- Renderer port base 5173 → 5174 so a worktree whose offset is 0 (e.g.
  cksum("/tmp/multica-3494") % 1000 === 0) no longer collides with the
  primary checkout's default 5173.
- DESKTOP_APP_SUFFIX is now "<folder>-<offset>" instead of just the folder
  name, so worktrees that share a basename at different paths (or names that
  slug to the same fallback) get distinct single-instance locks. Without it
  the second Electron was still blocked by the shared lock.
- Tests: offset-0 port guard, and same-basename-different-path disambiguation.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Lambda <agent@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-26 15:11:28 +08:00
Multica Eve
8d0ea04fb0 feat(composio): add standalone Go SDK client (MVP) (#4603)
* feat(composio): add standalone Go SDK client (MVP)

Adds server/pkg/composio — a self-contained Go SDK for the Composio v3.1
REST API. Built on go-resty/resty v2; zero coupling to other Multica
packages so it can be vendored or extracted later without surgery.

MVP surface (just the endpoints Stage 2 needs):

- POST /connected_accounts/link        Client.CreateLink
- POST /tool_router/session            Client.CreateSession
- GET  /connected_accounts             Client.ListConnectedAccounts
- POST /connected_accounts/{id}/revoke Client.RevokeConnection
- DELETE /connected_accounts/{id}      Client.DeleteConnectedAccount
                                       (404 -> nil, idempotent)
- GET  /toolkits                       Client.ListToolkits
- GET  /toolkits/{slug}                Client.GetToolkit
- POST /tools/execute/{slug}           Client.ExecuteTool
- Webhook HMAC-SHA256 verification     composio.VerifyWebhook /
                                       VerifyHTTPRequest + ParseEvent

Other notes:

- Auth via x-api-key header (Composio v3.1 contract).
- Typed *APIError envelope with IsNotFound / IsUnauthorized /
  IsRateLimited helpers; falls back to raw body when upstream returns
  non-JSON.
- Webhook signature accepts the official "v1,<sig>" format and any
  comma-separated multi-version list; 300s replay tolerance by default,
  honors an injectable clock for tests; RFC3339 timestamps tolerated.
- README.md documents all public APIs and design choices.

Tests:

- All exercise httptest.NewServer - no real Composio calls.
- 36 tests covering happy paths, validation, 404 idempotence, error
  decoding, signature verify (good / tampered / stale / multi-version /
  bare / RFC3339 / missing headers / empty secret).
- go test ./pkg/composio/... -cover -> 82.2%, exceeds the >=80% bar.

Follow-ups (separate PRs):

- server/internal/integrations/composio - DB schema, REST handlers,
  registration_service (CSRF), dispatch hook (MUL-3720 remainder).
- Pagination iterators, retry middleware, proxy execute, triggers.

Refs: MUL-3720, MUL-3715
Co-authored-by: multica-agent <github@multica.ai>

* fix(composio): align SDK with v3.1 wire contract (PR #4603 review)

Addresses GPT-Boy's review on PR #4603:

Must-fix
- ListConnectedAccountsRequest: switch UserID/AuthConfigID singular fields
  to plural slices (UserIDs, AuthConfigIDs, ToolkitSlugs, ConnectedAccountIDs,
  Statuses). The Composio v3.1 spec ships these as `*_ids` array params;
  our singular form silently dropped the user-filter on the wire. Also
  surfaces order_by / order_direction / account_type from the same spec.
- ExecuteToolRequest: rename ToolkitVersions -> Version with json tag
  `version` (the actual v3.1 body field). Marks AllowTracing as
  deprecated per the spec.

Nits
- ListToolkitsRequest.SortBy comment: `popular | alphabetical` -> the
  real enum `usage | alphabetically`.
- Client constructor: when Options.HTTPClient is provided, use
  resty.NewWithClient(hc) so the caller's Transport, Jar, CheckRedirect,
  and Timeout all carry through — the prior code only forwarded
  Transport + Timeout despite the field comment promising the full
  *http.Client.

Tests
- TestListConnectedAccounts_QueryString now asserts plural query keys
  (user_ids, auth_config_ids, connected_account_ids, statuses) and
  explicitly guards that the legacy singular keys do not leak.
- TestExecuteTool_Success decodes the body as a raw map and asserts the
  wire key is `version` (not `toolkit_versions`).
- New TestExecuteToolRequest_VersionSerialization locks the json tag.
- New TestNewClient_HonorsInjectedHTTPClient drives a request through a
  recordingTransport and asserts the inbound *http.Client actually
  handled it.

Verification
- go test ./pkg/composio/... -cover -> 85.1% (38 tests; was 82.2% / 36).
- go vet, go build, gofmt -l all clean.

Refs: PR #4603 review, MUL-3720
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-26 15:07:47 +08:00
Naiyuan Qing
714f9b1ab7 fix(editor): keep Tab inside lists instead of escaping focus (MUL-3697) (#4605)
In a list item that cannot indent (first child / max depth), Tab was
returned unhandled, so the browser's native Tab moved focus out of the
editor onto adjacent controls. Decouple "swallow the key" from "did the
indent move anything": best-effort indent, then swallow whenever the
caret is inside the list (editor.isActive(name)) and only fall through
to focus navigation when not in a list. Covers bullet, ordered and task
lists via the shared keymap.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 15:06:28 +08:00
Naiyuan Qing
553419f8ef fix(editor): indent multi-item list selection on Tab (MUL-3697) (#4587)
Stock prosemirror-schema-list `sinkListItem` returns false without
dispatching whenever `range.startIndex === 0`, so selecting a list from
the top and pressing Tab did nothing. Bullet, ordered, and task lists
all routed through the same command and were equally affected.

Wrap the shared Tab keymap (PatchedListItem + PatchedTaskItem) with
`sinkListItemRange`: try stock sink first, and when it bails on a
multi-item range whose first item is the list's first child, re-run the
stock command on a selection narrowed to start inside the second selected
item. The first item stays as an anchor and the rest nest under it
(Notion/GitHub nested-list behaviour), in a single undoable transaction.

Shift-Tab / liftListItem already handles ranges and the first-item case,
so it is unchanged. No schema change.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-26 14:31:56 +08:00
Naiyuan Qing
73b9a41260 feat(docs): reusable VideoEmbed + Chinese intro video on zh docs homepage (#4597)
* feat(docs): add reusable VideoEmbed and embed intro video on zh homepage

Add a provider-agnostic, click-to-load <VideoEmbed> component (Bilibili now,
YouTube reserved) and embed the Chinese intro video (BV1cv7Y6gEg7) at the top
of the Chinese docs homepage. The facade renders on first paint; the
third-party player iframe only mounts on user click, so first paint pays
nothing for an external player or its trackers. Registered in both docs MDX
component maps so it is reusable on any docs page.

Scope is zh docs only — no usecase, no other locales, no analytics, no video
hosting.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* docs(zh): drop duration from intro video title

Use the duration-free title "Multica 中文介绍视频" for the homepage VideoEmbed
instead of a minute-count phrasing. Copy accuracy only; no component, layout,
or provider changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-26 14:25:49 +08:00
Naiyuan Qing
cd6cd9dcd1 fix(editor): keep code-block selection stable during background re-renders (MUL-3621) (#4594)
Selecting text in a readonly code block (comment/issue markdown) lost the
selection within seconds, making copy impossible, whenever the surrounding
view re-rendered — most reliably while a sibling agent task streamed over
WebSocket (a re-render roughly every ~100ms).

Root cause: the `code` renderer emits highlighted HTML via
`dangerouslySetInnerHTML={{ __html }}`, a fresh prop object every render. Each
unrelated parent re-render re-ran react-markdown, and React rewrote the
`<code>` innerHTML even though the HTML string was byte-identical, tearing down
and rebuilding all 161 hljs `<span>` nodes. The native selection is anchored to
those nodes, so it collapsed.

Fix: memoize the entire `<ReactMarkdown>` subtree on its only real inputs
(`processed` + `components`). A stable element reference lets React bail out of
the subtree on unrelated re-renders, so the code-block DOM is never rebuilt
while content is unchanged. Confirmed via an instrumentation probe: zero
`<code>` DOM mutations during streaming after the fix.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 13:49:50 +08:00
29 changed files with 2761 additions and 42 deletions

View File

@@ -90,7 +90,7 @@ pnpm exec playwright test
pnpm ui:add badge # shadcn/Base UI component into packages/ui
```
Worktrees share one PostgreSQL container and get isolated DB names/ports via `.env.worktree`. `make dev` auto-detects this. For manual setup use `make worktree-env`, `make setup-worktree`, and `make start-worktree`.
Worktrees share one PostgreSQL container and get isolated DB names/ports via `.env.worktree`. `make dev` auto-detects this. For manual setup use `make worktree-env`, `make setup-worktree`, and `make start-worktree`. `pnpm dev:desktop` additionally self-isolates per worktree (its own renderer port + app name) automatically, independent of `.env.worktree`.
CI runs Node 22, Go 1.26.1, and a `pgvector/pgvector:pg17` PostgreSQL service.

View File

@@ -489,6 +489,25 @@ VITE_API_URL=http://localhost:<backend-port>
VITE_WS_URL=ws://localhost:<backend-port>/ws
```
#### Running multiple worktrees side-by-side
`pnpm dev:desktop` auto-isolates a worktree so several worktrees can run their
own desktop dev instance at once — no extra setup. From a linked worktree it
derives, from the worktree path (same `cksum % 1000` offset as the backend /
frontend ports in `.env.worktree`):
- `DESKTOP_RENDERER_PORT` = `5174 + offset` — its own Vite dev server (`5174`
base leaves `5173` for the primary checkout, even when `offset` is `0`)
- `DESKTOP_APP_SUFFIX` = `<folder>-<offset>` — its own single-instance lock /
`userData`, and an app named `Multica Canary <folder>-<offset>` so it is
distinguishable in Cmd+Tab. The offset keeps it unique across worktrees that
share a folder name at different paths.
The primary checkout is left untouched (`5173`, `Multica Canary`). Set either
env var explicitly to override the derived value. Which backend each instance
talks to is still controlled only by `apps/desktop/.env*` above — point each
worktree's desktop at its own backend to also isolate the daemon profile.
### Isolation Guarantee
Nothing in this flow touches the system-installed `multica` or the default

View File

@@ -37,6 +37,27 @@ define REQUIRE_ENV
fi
endef
# Self-hosting requires Docker Compose v2 (the `docker compose` CLI plugin).
# The self-host compose files use compose-spec syntax (top-level `name:`, no
# `version:`) that the legacy v1 `docker-compose` standalone cannot parse, so we
# fail early with an actionable message instead of a cryptic CLI parse error
# (e.g. "unknown shorthand flag: 'f' in -f") when the plugin is missing or v1.
# Keep the message short and OS-agnostic: per-OS install steps belong in docs.
define REQUIRE_COMPOSE
@if ! $(COMPOSE) version >/dev/null 2>&1; then \
echo "Docker Compose v2 ('docker compose') was not found."; \
echo "Self-hosting requires the Compose v2 CLI plugin; legacy 'docker-compose' v1 is not supported."; \
echo "Install Docker Compose from https://docs.docker.com/compose/install/ and verify with: docker compose version"; \
exit 1; \
fi; \
if ! $(COMPOSE) version --short 2>/dev/null | grep -Eq '^v?2\.'; then \
echo "'$(COMPOSE)' is not Docker Compose v2."; \
echo "Self-hosting requires the Compose v2 CLI plugin; legacy 'docker-compose' v1 is not supported."; \
echo "Install Docker Compose from https://docs.docker.com/compose/install/ and verify with: docker compose version"; \
exit 1; \
fi
endef
# Default target changed from selfhost to help: bare `make` now prints this help
# instead of launching a full Docker Compose build, which is safer for onboarding.
.DEFAULT_GOAL := help
@@ -54,6 +75,7 @@ makehelp: help ## Alias for `make help`
##@ Self-hosting
selfhost: ## Create .env if needed, then pull and start the official self-hosted images
$(REQUIRE_COMPOSE)
@if [ ! -f .env ]; then \
echo "==> Creating .env from .env.example..."; \
cp .env.example .env; \
@@ -71,7 +93,7 @@ selfhost: ## Create .env if needed, then pull and start the official self-hosted
echo "==> Generated random JWT_SECRET and POSTGRES_PASSWORD"; \
fi
@echo "==> Pulling official Multica images..."
@if ! docker compose -f docker-compose.selfhost.yml pull; then \
@if ! $(COMPOSE) -f docker-compose.selfhost.yml pull; then \
echo ""; \
echo "Official images for tag '$${MULTICA_IMAGE_TAG:-latest}' are not published yet."; \
echo "If this is before the first GHCR release, build from the current checkout:"; \
@@ -79,7 +101,7 @@ selfhost: ## Create .env if needed, then pull and start the official self-hosted
exit 1; \
fi
@echo "==> Starting Multica via Docker Compose..."
docker compose -f docker-compose.selfhost.yml up -d
$(COMPOSE) -f docker-compose.selfhost.yml up -d
@echo "==> Waiting for backend to be ready..."
@for i in $$(seq 1 30); do \
if curl -sf http://localhost:$${PORT:-8080}/health > /dev/null 2>&1; then \
@@ -105,10 +127,11 @@ selfhost: ## Create .env if needed, then pull and start the official self-hosted
else \
echo ""; \
echo "Services are still starting. Check logs:"; \
echo " docker compose -f docker-compose.selfhost.yml logs"; \
echo " $(COMPOSE) -f docker-compose.selfhost.yml logs"; \
fi
selfhost-build: ## Build backend/web from the current checkout and start the self-hosted stack
$(REQUIRE_COMPOSE)
@if [ ! -f .env ]; then \
echo "==> Creating .env from .env.example..."; \
cp .env.example .env; \
@@ -126,7 +149,7 @@ selfhost-build: ## Build backend/web from the current checkout and start the sel
echo "==> Generated random JWT_SECRET and POSTGRES_PASSWORD"; \
fi
@echo "==> Building Multica from the current checkout..."
docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build
$(COMPOSE) -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build
@echo "==> Waiting for backend to be ready..."
@for i in $$(seq 1 30); do \
if curl -sf http://localhost:$${PORT:-8080}/health > /dev/null 2>&1; then \
@@ -152,12 +175,13 @@ selfhost-build: ## Build backend/web from the current checkout and start the sel
else \
echo ""; \
echo "Services are still starting. Check logs:"; \
echo " docker compose -f docker-compose.selfhost.yml logs"; \
echo " $(COMPOSE) -f docker-compose.selfhost.yml logs"; \
fi
selfhost-stop: ## Stop the self-hosted Docker Compose stack
$(REQUIRE_COMPOSE)
@echo "==> Stopping Multica services..."
docker compose -f docker-compose.selfhost.yml down
$(COMPOSE) -f docker-compose.selfhost.yml down
@echo "✓ All services stopped."
# ---------- One-click commands ----------

View File

@@ -19,8 +19,8 @@
"scripts": {
"bundle-cli": "node scripts/bundle-cli.mjs",
"brand-dev-electron": "node scripts/brand-dev-electron.mjs",
"dev": "pnpm run bundle-cli && pnpm run brand-dev-electron && electron-vite dev",
"dev:staging": "pnpm run bundle-cli && pnpm run brand-dev-electron && electron-vite dev --mode staging",
"dev": "node scripts/dev.mjs",
"dev:staging": "node scripts/dev.mjs --mode staging",
"build": "pnpm run bundle-cli && electron-vite build",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",

View File

@@ -9,6 +9,10 @@
// matches. The patch is isolated to this worktree's node_modules — we
// unlink the file before rewriting so we never mutate a pnpm-store inode
// shared with another project.
//
// In a worktree, scripts/dev.mjs sets DESKTOP_APP_SUFFIX so the name becomes
// "Multica Canary <suffix>" — distinguishable in Cmd+Tab and matching the app
// name src/main/index.ts derives from the same env var.
import { createRequire } from "node:module";
import { execFileSync } from "node:child_process";
@@ -17,7 +21,9 @@ import { resolve } from "node:path";
if (process.platform !== "darwin") process.exit(0);
const DESIRED_NAME = "Multica Canary";
const DESIRED_NAME = process.env.DESKTOP_APP_SUFFIX
? `Multica Canary ${process.env.DESKTOP_APP_SUFFIX}`
: "Multica Canary";
const require = createRequire(import.meta.url);
// `require('electron')` returns the path to the executable

View File

@@ -0,0 +1,53 @@
#!/usr/bin/env node
// Dev launcher for `pnpm dev:desktop`.
//
// Derives per-worktree isolation env (renderer port + app name) so multiple
// worktrees can run `pnpm dev:desktop` side-by-side, then runs the same chain
// as before — bundle the CLI, brand the dev Electron, start electron-vite —
// inheriting the augmented env. A plain `&&` chain in package.json can't do
// this: each `&&` step is its own process, so an env tweak in step 1 wouldn't
// reach electron-vite in step 3. Args (e.g. `--mode staging`) pass through to
// electron-vite.
import { spawnSync } from "node:child_process";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import {
applyWorktreeDevEnv,
repoRootFromScriptDir,
} from "./worktree-dev-env.mjs";
const here = dirname(fileURLToPath(import.meta.url));
applyWorktreeDevEnv(process.env, {
root: repoRootFromScriptDir(here),
log: true,
});
function run(command, args, { shell = false } = {}) {
const result = spawnSync(command, args, {
stdio: "inherit",
env: process.env,
shell,
});
if (result.error) {
console.error(`[dev:desktop] failed to run ${command}: ${result.error.message}`);
process.exit(1);
}
if (result.status !== 0) process.exit(result.status ?? 1);
}
const node = process.execPath;
run(node, [join(here, "bundle-cli.mjs")]);
run(node, [join(here, "brand-dev-electron.mjs")]);
const isWin = process.platform === "win32";
const electronVite = join(
here,
"..",
"node_modules",
".bin",
isWin ? "electron-vite.cmd" : "electron-vite",
);
run(electronVite, ["dev", ...process.argv.slice(2)], { shell: isWin });

View File

@@ -0,0 +1,116 @@
// Per-worktree dev isolation for `pnpm dev:desktop`.
//
// Two `pnpm dev:desktop` instances from two different git worktrees collide on
// the renderer Vite port (5173) and the single-instance lock / userData dir
// (keyed by the app name "Multica Canary"). The env hooks to override both
// already exist — electron.vite.config.ts reads DESKTOP_RENDERER_PORT and
// src/main/index.ts reads DESKTOP_APP_SUFFIX — but nothing derives unique
// values per worktree. This module does, mirroring the offset scheme that
// scripts/init-worktree-env.sh already uses for backend/frontend ports.
//
// Backend targeting is deliberately NOT touched here: which backend the desktop
// connects to stays driven by apps/desktop/.env* (VITE_API_URL / VITE_WS_URL),
// exactly as documented. This module only adds the two knobs needed for two
// Electron processes to coexist.
import { statSync } from "node:fs";
import { basename, join } from "node:path";
// Worktree renderer ports start at 5174 so they never reuse 5173 — the primary
// checkout's default — even when a worktree's offset is 0 (e.g. POSIX cksum of
// "/tmp/multica-3494" is 1189739000, and 1189739000 % 1000 === 0). Range 51746173.
const RENDERER_PORT_BASE = 5174;
const OFFSET_MODULO = 1000;
// POSIX cksum (CRC-32), kept byte-compatible with `cksum(1)` so the offset
// matches scripts/init-worktree-env.sh — a worktree's backend (18080+offset),
// frontend (13000+offset) and desktop renderer (5174+offset) ports all share
// one offset. Verified against coreutils: cksum of "/tmp/foo" → 427878967.
function cksumTable() {
const table = new Uint32Array(256);
const POLY = 0x04c11db7;
for (let i = 0; i < 256; i++) {
let crc = i << 24;
for (let bit = 0; bit < 8; bit++) {
crc = crc & 0x80000000 ? (crc << 1) ^ POLY : crc << 1;
}
table[i] = crc >>> 0;
}
return table;
}
const TABLE = cksumTable();
export function cksum(buf) {
let crc = 0;
for (const byte of buf) {
crc = (((crc << 8) >>> 0) ^ TABLE[((crc >>> 24) ^ byte) & 0xff]) >>> 0;
}
// POSIX appends the byte length, least-significant byte first.
let len = buf.length;
while (len > 0) {
crc = (((crc << 8) >>> 0) ^ TABLE[((crc >>> 24) ^ (len & 0xff)) & 0xff]) >>> 0;
len = Math.floor(len / 256);
}
return (~crc) >>> 0;
}
export function offsetForPath(path) {
return cksum(Buffer.from(path)) % OFFSET_MODULO;
}
export function rendererPortForPath(path) {
return RENDERER_PORT_BASE + offsetForPath(path);
}
// Worktree → a readable, unique, filesystem-safe suffix "<folder>-<offset>".
// The dev app then shows e.g. "Multica Canary mul-3724-194" in Cmd+Tab and gets
// its own userData / single-instance lock under that name. The offset is what
// makes the lock unique: the folder name alone collides for worktrees that share
// a basename at different paths (e.g. /a/multica vs /b/multica) or whose names
// slug to the same fallback — those would share one lock and the second Electron
// would still be blocked.
export function appSuffixForPath(path) {
const slug =
basename(path)
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "") || "worktree";
return `${slug}-${offsetForPath(path)}`;
}
// A linked git worktree has a `.git` FILE (a "gitdir:" pointer); the primary
// checkout has a `.git` DIRECTORY. We only auto-isolate linked worktrees, so
// the primary checkout keeps the unchanged 5173 / "Multica Canary" defaults.
export function isLinkedWorktree(root) {
try {
return statSync(join(root, ".git")).isFile();
} catch {
return false;
}
}
// scripts live at <root>/apps/desktop/scripts
export function repoRootFromScriptDir(scriptDir) {
return join(scriptDir, "..", "..", "..");
}
// Populate DESKTOP_RENDERER_PORT / DESKTOP_APP_SUFFIX on `env` for a worktree
// checkout, without overriding values the caller set explicitly. Returns `env`.
export function applyWorktreeDevEnv(env, { root, log = false } = {}) {
const hasPort = Boolean(env.DESKTOP_RENDERER_PORT);
const hasSuffix = Boolean(env.DESKTOP_APP_SUFFIX);
if (hasPort && hasSuffix) return env; // explicit overrides win outright
if (!isLinkedWorktree(root)) return env; // primary checkout → keep defaults
if (!hasPort) env.DESKTOP_RENDERER_PORT = String(rendererPortForPath(root));
if (!hasSuffix) env.DESKTOP_APP_SUFFIX = appSuffixForPath(root);
if (log) {
console.log(
`[dev:desktop] worktree isolation → renderer port ${env.DESKTOP_RENDERER_PORT}, ` +
`app "Multica Canary ${env.DESKTOP_APP_SUFFIX}"`,
);
}
return env;
}

View File

@@ -0,0 +1,101 @@
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
appSuffixForPath,
applyWorktreeDevEnv,
cksum,
offsetForPath,
rendererPortForPath,
} from "./worktree-dev-env.mjs";
const cleanups = [];
afterEach(() => {
while (cleanups.length) cleanups.pop()();
});
function tmpRoot(kind /* "file" | "dir" | "none" */) {
const root = mkdtempSync(join(tmpdir(), "wt-"));
cleanups.push(() => rmSync(root, { recursive: true, force: true }));
if (kind === "file") writeFileSync(join(root, ".git"), "gitdir: /elsewhere\n");
else if (kind === "dir") mkdirSync(join(root, ".git"));
return root;
}
describe("worktree-dev-env", () => {
it("cksum is byte-compatible with coreutils cksum(1)", () => {
// `printf '%s' "/tmp/foo" | cksum` → 427878967 8
expect(cksum(Buffer.from("/tmp/foo"))).toBe(427878967);
// `printf '' | cksum` → 4294967295 0
expect(cksum(Buffer.from(""))).toBe(4294967295);
});
it("derives the offset from the path, mod 1000", () => {
expect(offsetForPath("/tmp/foo")).toBe(427878967 % 1000);
});
it("renderer port is 5174 + offset (5173 reserved for the primary checkout)", () => {
expect(rendererPortForPath("/tmp/foo")).toBe(5174 + (427878967 % 1000));
});
it("never reuses 5173 even when the offset is 0", () => {
// POSIX cksum("/tmp/multica-3494") === 1189739000, % 1000 === 0
expect(offsetForPath("/tmp/multica-3494")).toBe(0);
expect(rendererPortForPath("/tmp/multica-3494")).toBe(5174);
expect(rendererPortForPath("/tmp/multica-3494")).not.toBe(5173);
});
it("suffix is '<folder>-<offset>' so it stays recognizable and unique", () => {
expect(appSuffixForPath("/work/MUL-3724_Desktop")).toBe(
`mul-3724-desktop-${offsetForPath("/work/MUL-3724_Desktop")}`,
);
expect(appSuffixForPath("/work/feat/some thing")).toBe(
`some-thing-${offsetForPath("/work/feat/some thing")}`,
);
// empty/non-ascii slug falls back to "worktree", still disambiguated by offset
expect(appSuffixForPath("/work/___")).toBe(`worktree-${offsetForPath("/work/___")}`);
});
it("disambiguates worktrees that share a folder name at different paths", () => {
// Same basename "multica", different parent dirs → different offsets/suffixes,
// so each gets its own single-instance lock.
expect(offsetForPath("/tmp/a/multica")).not.toBe(offsetForPath("/tmp/b/multica"));
expect(appSuffixForPath("/tmp/a/multica")).not.toBe(
appSuffixForPath("/tmp/b/multica"),
);
});
it("auto-isolates a linked worktree (.git is a file)", () => {
const root = tmpRoot("file");
const env = {};
applyWorktreeDevEnv(env, { root });
expect(env.DESKTOP_RENDERER_PORT).toBe(String(rendererPortForPath(root)));
expect(env.DESKTOP_APP_SUFFIX).toBe(appSuffixForPath(root));
});
it("leaves the primary checkout untouched (.git is a dir)", () => {
const root = tmpRoot("dir");
const env = {};
applyWorktreeDevEnv(env, { root });
expect(env.DESKTOP_RENDERER_PORT).toBeUndefined();
expect(env.DESKTOP_APP_SUFFIX).toBeUndefined();
});
it("respects explicit env overrides", () => {
const root = tmpRoot("file");
const env = { DESKTOP_RENDERER_PORT: "9999", DESKTOP_APP_SUFFIX: "manual" };
applyWorktreeDevEnv(env, { root });
expect(env.DESKTOP_RENDERER_PORT).toBe("9999");
expect(env.DESKTOP_APP_SUFFIX).toBe("manual");
});
it("fills only the missing knob when one is set explicitly", () => {
const root = tmpRoot("file");
const env = { DESKTOP_RENDERER_PORT: "9999" };
applyWorktreeDevEnv(env, { root });
expect(env.DESKTOP_RENDERER_PORT).toBe("9999");
expect(env.DESKTOP_APP_SUFFIX).toBe(appSuffixForPath(root));
});
});

View File

@@ -11,6 +11,7 @@ import type { Metadata } from "next";
import { docsAlternates } from "@/lib/site";
import { i18n, type Lang } from "@/lib/i18n";
import { DocsLocaleProvider, LocaleLink } from "@/components/locale-link";
import { VideoEmbed } from "@/components/video-embed";
import { docsSlugStaticParams } from "@/lib/static-params";
function asLang(lang: string): Lang {
@@ -35,7 +36,9 @@ export default async function Page(props: {
<DocsDescription>{page.data.description}</DocsDescription>
<DocsBody>
<DocsLocaleProvider lang={lang}>
<MDX components={{ ...defaultMdxComponents, a: LocaleLink }} />
<MDX
components={{ ...defaultMdxComponents, a: LocaleLink, VideoEmbed }}
/>
</DocsLocaleProvider>
</DocsBody>
</DocsPage>

View File

@@ -5,6 +5,7 @@ import defaultMdxComponents from "fumadocs-ui/mdx";
import type { Metadata } from "next";
import { DocsHero } from "@/components/hero";
import { Byline, NumberedCards, NumberedCard, NumberedSteps, Step } from "@/components/editorial";
import { VideoEmbed } from "@/components/video-embed";
import { i18n, type Lang } from "@/lib/i18n";
import { homeCopy } from "@/lib/translations";
import { docsAlternates } from "@/lib/site";
@@ -62,6 +63,7 @@ export default async function Page({
NumberedCard,
NumberedSteps,
Step,
VideoEmbed,
}}
/>
</DocsLocaleProvider>

View File

@@ -0,0 +1,116 @@
"use client";
import { useState } from "react";
import { Play } from "lucide-react";
/**
* VideoEmbed — provider-agnostic, click-to-load video embed for docs MDX.
*
* Renders a lightweight facade (no third-party iframe on first paint) and only
* mounts the real player after a user click, so the docs first paint never
* pays for an external player or its trackers. `provider` is abstracted so a
* future English-docs YouTube embed is a one-line MDX change, not a second
* component.
*
* Usage in MDX (registered in the docs MDX components map):
* <VideoEmbed provider="bilibili" id="BV1cv7Y6gEg7" title="Multica 介绍视频" />
*/
type Provider = "bilibili" | "youtube";
interface ProviderConfig {
/** Embeddable player URL. Autoplay is only requested after a user gesture. */
embedUrl: (id: string, autoplay: boolean) => string;
/** Canonical watch page — the load-failure / slow-network fallback link. */
watchUrl: (id: string) => string;
/** Human label for the fallback link ("在 Bilibili 观看"). */
siteName: string;
/** Validates the id shape so a typo renders a notice, not a broken frame. */
isValidId: (id: string) => boolean;
}
const PROVIDERS: Record<Provider, ProviderConfig> = {
bilibili: {
embedUrl: (id, autoplay) =>
`https://player.bilibili.com/player.html?bvid=${id}&autoplay=${autoplay ? 1 : 0}&high_quality=1&danmaku=0`,
watchUrl: (id) => `https://www.bilibili.com/video/${id}/`,
siteName: "Bilibili",
isValidId: (id) => /^BV[0-9A-Za-z]+$/.test(id),
},
// Reserved for a future English-docs YouTube embed. Not wired into any page
// yet, but kept here so the second provider is config, not a new component.
youtube: {
embedUrl: (id, autoplay) =>
`https://www.youtube-nocookie.com/embed/${id}?autoplay=${autoplay ? 1 : 0}&rel=0`,
watchUrl: (id) => `https://www.youtube.com/watch?v=${id}`,
siteName: "YouTube",
isValidId: (id) => /^[0-9A-Za-z_-]{11}$/.test(id),
},
};
export function VideoEmbed({
provider = "bilibili",
id,
title,
}: {
provider?: Provider;
id: string;
title?: string;
}) {
const [active, setActive] = useState(false);
const config = PROVIDERS[provider];
// Bad / missing id → a calm inline notice, never a broken or blank iframe.
if (!config || !id || !config.isValidId(id)) {
return (
<div className="not-prose my-7 rounded-lg border border-border bg-muted/30 p-4 text-sm text-muted-foreground">
{title ? `${title}` : ""}
</div>
);
}
const watchUrl = config.watchUrl(id);
const label = title ?? "观看视频";
return (
<figure className="not-prose my-7">
<div className="relative aspect-video w-full overflow-hidden rounded-lg border border-border bg-muted/40">
{active ? (
<iframe
src={config.embedUrl(id, true)}
title={label}
loading="lazy"
allow="autoplay; fullscreen; encrypted-media; picture-in-picture"
allowFullScreen
className="absolute inset-0 size-full"
/>
) : (
<button
type="button"
onClick={() => setActive(true)}
aria-label={`播放:${label}`}
className="group absolute inset-0 flex size-full flex-col items-center justify-center gap-3 bg-gradient-to-b from-muted/20 to-muted/60 transition-colors hover:from-muted/30 hover:to-muted/70"
>
<span className="flex size-16 items-center justify-center rounded-full bg-[var(--primary)] text-[var(--primary-foreground)] shadow-lg transition-transform group-hover:scale-105">
<Play className="size-7 translate-x-0.5 fill-current" />
</span>
<span className="px-6 text-center text-sm font-medium text-foreground">
{label}
</span>
</button>
)}
</div>
<figcaption className="mt-2 text-xs text-muted-foreground">
<a
href={watchUrl}
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-2 hover:text-foreground"
>
{config.siteName}
</a>
</figcaption>
</figure>
);
}

View File

@@ -7,6 +7,8 @@ import { Callout } from "fumadocs-ui/components/callout";
Multica 是一个任务协作平台,让人类和 AI [智能体](/agents) 在同一个 [工作区](/workspaces) 里共同工作。你可以像给同事派活一样,[把一个任务分配给智能体](/assigning-issues) ——由它去执行、汇报进展、在评论里回复你;也可以[打开聊天窗口直接和它对话](/chat),让它帮你起草任务、回答问题、或完成一次性请求。
<VideoEmbed provider="bilibili" id="BV1cv7Y6gEg7" title="Multica 中文介绍视频" />
这一页讲清楚智能体在哪里运行,以及你有哪几种方式开始使用 Multica。
## 智能体在哪里运行

View File

@@ -1,7 +1,8 @@
import { afterEach, describe, expect, it } from "vitest";
import { Editor } from "@tiptap/core";
import StarterKit from "@tiptap/starter-kit";
import { PatchedListItem } from "./list-item";
import { TaskList } from "@tiptap/extension-list";
import { PatchedListItem, PatchedTaskItem } from "./list-item";
interface JsonNode {
type: string;
@@ -14,7 +15,12 @@ function makeEditor(content: JsonNode) {
document.body.appendChild(element);
return new Editor({
element,
extensions: [StarterKit.configure({ listItem: false }), PatchedListItem],
extensions: [
StarterKit.configure({ listItem: false }),
PatchedListItem,
TaskList,
PatchedTaskItem,
],
content,
});
}
@@ -37,26 +43,100 @@ function listItemTextPos(editor: Editor, index: number): number {
return pos;
}
/** Mimic the editor's Enter keymap: invoke the bound Enter shortcut directly. */
function pressEnter(editor: Editor): boolean {
const listItemExt = editor.extensionManager.extensions.find(
(e) => e.name === "listItem",
);
if (!listItemExt) throw new Error("listItem extension not registered");
/**
* Mimic an editor keymap by invoking a bound shortcut directly. We can't drive
* real key events reliably in jsdom, so we resolve the keymap an extension
* registers and call the entry for `key`. The shared list keymap closes over
* `editor` (not `this`), so the rebind only needs a faithful `this`.
*/
function pressShortcut(editor: Editor, extName: string, key: string): boolean {
const ext = editor.extensionManager.extensions.find((e) => e.name === extName);
if (!ext) throw new Error(`${extName} extension not registered`);
const shortcuts = (
listItemExt.config.addKeyboardShortcuts as
ext.config.addKeyboardShortcuts as
| (() => Record<string, () => boolean>)
| undefined
)?.bind({
editor,
name: "listItem",
options: listItemExt.options,
type: editor.schema.nodes.listItem,
storage: listItemExt.storage,
name: extName,
options: ext.options,
type: editor.schema.nodes[extName],
storage: ext.storage,
} as never)();
const enter = shortcuts?.Enter;
if (!enter) throw new Error("Enter shortcut not bound");
return enter();
const fn = shortcuts?.[key];
if (!fn) throw new Error(`${key} shortcut not bound on ${extName}`);
return fn();
}
/** Mimic the editor's Enter keymap: invoke the bound Enter shortcut directly. */
function pressEnter(editor: Editor): boolean {
return pressShortcut(editor, "listItem", "Enter");
}
/** Indented bullet outline of the doc — nesting depth, item text only. */
const LIST_TYPES = ["bulletList", "orderedList", "taskList"];
const ITEM_TYPES = ["listItem", "taskItem"];
function outline(json: JsonNode): string {
const lines: string[] = [];
function rec(node: JsonNode, depth: number) {
for (const child of node.content ?? []) {
if (LIST_TYPES.includes(child.type)) {
rec(child, depth + 1);
} else if (ITEM_TYPES.includes(child.type)) {
const text = child.content?.[0]?.content?.[0]?.text ?? "";
lines.push(" ".repeat(Math.max(0, depth - 1)) + "- " + text);
for (const gc of child.content ?? []) {
if (LIST_TYPES.includes(gc.type)) rec(gc, depth + 1);
}
} else {
rec(child, depth);
}
}
}
rec(json, 0);
return lines.join("\n");
}
/** Inside-paragraph position of the index-th item of `typeName` (doc order). */
function itemPos(editor: Editor, typeName: string, index: number): number {
const positions: number[] = [];
editor.state.doc.descendants((node, pos) => {
if (node.type.name === typeName) positions.push(pos + 2);
return true;
});
const pos = positions[index];
if (pos === undefined) throw new Error(`no ${typeName} at index ${index}`);
return pos;
}
/** Select from the start of item `fromIdx`'s text to the end of item `toIdx`'s. */
function selectItemRange(
editor: Editor,
typeName: string,
fromIdx: number,
toIdx: number,
itemLen = 3,
) {
editor.commands.setTextSelection({
from: itemPos(editor, typeName, fromIdx),
to: itemPos(editor, typeName, toIdx) + itemLen,
});
}
/** A flat three-item list ("aaa","bbb","ccc") of the given node types. */
function flatList(listType: string, itemType: string): JsonNode {
return {
type: "doc",
content: [
{
type: listType,
content: ["aaa", "bbb", "ccc"].map((t) => ({
type: itemType,
content: [{ type: "paragraph", content: [{ type: "text", text: t }] }],
})),
},
],
};
}
describe("PatchedListItem Enter behaviour", () => {
@@ -179,3 +259,171 @@ describe("PatchedListItem Enter behaviour", () => {
expect(outerText).toBe("outer");
});
});
describe("PatchedListItem Tab indent (MUL-3697)", () => {
let editor: Editor | undefined;
afterEach(() => {
editor?.destroy();
editor = undefined;
document.body.innerHTML = "";
});
const pressTab = (e: Editor) => pressShortcut(e, "listItem", "Tab");
it("leaves the doc unchanged but swallows Tab in the first item (stay put, do not escape focus)", () => {
editor = makeEditor(flatList("bulletList", "listItem"));
editor.commands.setTextSelection(itemPos(editor, "listItem", 0));
// Nothing to nest under, so the structural indent is a no-op — but the caret
// is in a list, so Tab is swallowed (true) instead of leaking to the browser
// and moving focus to other controls. The doc must be untouched.
expect(pressTab(editor)).toBe(true);
expect(outline(editor.getJSON() as JsonNode)).toBe("- aaa\n- bbb\n- ccc");
});
it("indents a single non-first item under its predecessor", () => {
editor = makeEditor(flatList("bulletList", "listItem"));
editor.commands.setTextSelection(itemPos(editor, "listItem", 1));
expect(pressTab(editor)).toBe(true);
expect(outline(editor.getJSON() as JsonNode)).toBe(
"- aaa\n - bbb\n- ccc",
);
});
it("indents a whole-list selection (items 1..3): first stays, 2 and 3 nest", () => {
editor = makeEditor(flatList("bulletList", "listItem"));
selectItemRange(editor, "listItem", 0, 2);
expect(pressTab(editor)).toBe(true);
// The reported bug: this used to be a no-op because range.startIndex === 0.
expect(outline(editor.getJSON() as JsonNode)).toBe(
"- aaa\n - bbb\n - ccc",
);
});
it("indents a mid-list selection (items 2..3) under the first", () => {
editor = makeEditor(flatList("bulletList", "listItem"));
selectItemRange(editor, "listItem", 1, 2);
expect(pressTab(editor)).toBe(true);
expect(outline(editor.getJSON() as JsonNode)).toBe(
"- aaa\n - bbb\n - ccc",
);
});
it("returns false cleanly when the selection is not in a list (C2: no range)", () => {
editor = makeEditor({
type: "doc",
content: [
{ type: "paragraph", content: [{ type: "text", text: "plain" }] },
],
});
editor.commands.setTextSelection(3);
expect(pressTab(editor)).toBe(false);
});
it("indents in a single, undoable transaction (C3)", () => {
editor = makeEditor(flatList("bulletList", "listItem"));
selectItemRange(editor, "listItem", 0, 2);
const view = editor.view;
const original = view.dispatch.bind(view);
let dispatches = 0;
view.dispatch = (tr) => {
dispatches += 1;
original(tr);
};
try {
expect(pressTab(editor)).toBe(true);
} finally {
view.dispatch = original;
}
// One dispatch -> one transaction -> one undo step.
expect(dispatches).toBe(1);
editor.commands.undo();
expect(outline(editor.getJSON() as JsonNode)).toBe("- aaa\n- bbb\n- ccc");
});
});
describe("Tab indent across list types (MUL-3697)", () => {
let editor: Editor | undefined;
afterEach(() => {
editor?.destroy();
editor = undefined;
document.body.innerHTML = "";
});
it("indents an ordered-list whole selection (not only unordered)", () => {
editor = makeEditor(flatList("orderedList", "listItem"));
selectItemRange(editor, "listItem", 0, 2);
expect(pressShortcut(editor, "listItem", "Tab")).toBe(true);
expect(outline(editor.getJSON() as JsonNode)).toBe(
"- aaa\n - bbb\n - ccc",
);
expect((editor.getJSON() as JsonNode).content?.[0]?.type).toBe(
"orderedList",
);
});
it("indents a task-list whole selection via the taskItem keymap", () => {
editor = makeEditor(flatList("taskList", "taskItem"));
selectItemRange(editor, "taskItem", 0, 2);
expect(pressShortcut(editor, "taskItem", "Tab")).toBe(true);
expect(outline(editor.getJSON() as JsonNode)).toBe(
"- aaa\n - bbb\n - ccc",
);
});
});
describe("Shift-Tab dedent regression (MUL-3697)", () => {
let editor: Editor | undefined;
afterEach(() => {
editor?.destroy();
editor = undefined;
document.body.innerHTML = "";
});
it("lifts a multi-item nested selection back to the top level (unchanged)", () => {
editor = makeEditor({
type: "doc",
content: [
{
type: "bulletList",
content: [
{
type: "listItem",
content: [
{ type: "paragraph", content: [{ type: "text", text: "aaa" }] },
{
type: "bulletList",
content: [
{
type: "listItem",
content: [
{
type: "paragraph",
content: [{ type: "text", text: "bbb" }],
},
],
},
{
type: "listItem",
content: [
{
type: "paragraph",
content: [{ type: "text", text: "ccc" }],
},
],
},
],
},
],
},
],
},
],
});
// Select the two nested items (bbb, ccc) and dedent.
selectItemRange(editor, "listItem", 1, 2);
expect(pressShortcut(editor, "listItem", "Shift-Tab")).toBe(true);
expect(outline(editor.getJSON() as JsonNode)).toBe("- aaa\n- bbb\n- ccc");
});
});

View File

@@ -1,5 +1,65 @@
import { type Editor, InputRule } from "@tiptap/core";
import { ListItem, TaskItem } from "@tiptap/extension-list";
import { sinkListItem as pmSinkListItem } from "@tiptap/pm/schema-list";
import { type Command, TextSelection } from "@tiptap/pm/state";
import type { NodeType } from "@tiptap/pm/model";
/**
* Tab indent that also works for a multi-item selection whose first item is the
* first child of its (sub)list.
*
* Stock `sinkListItem` (prosemirror-schema-list) bails — returns false without
* dispatching — whenever `range.startIndex === 0`, because the first item has no
* preceding sibling to nest under. That is correct for a collapsed cursor in the
* first item, but it also kills the natural "select the whole list from the top
* and press Tab" gesture: the command sees the first item at index 0 and does
* nothing (MUL-3697).
*
* The structurally-correct behaviour in a nested-list model (matching Notion /
* GitHub) is: keep the first selected item as an anchor and sink the rest under
* it. We get that by re-running the *stock* command on a selection narrowed to
* start inside the SECOND selected item (so its `startIndex` becomes 1) while
* keeping the original `$to`. The narrowed selection is computed on a derived
* state and never dispatched on its own, so the whole operation is a single
* dispatch / single undo step.
*
* Shift-Tab / `liftListItem` has no equivalent limitation (it handles ranges and
* the first-item case correctly), so only Tab needs this wrapper.
*/
function sinkListItemRange(itemType: NodeType): Command {
return (state, dispatch) => {
// Normal path — cursor or range not starting at the first item. This also
// covers the genuine no-op for a collapsed cursor in the first item: stock
// returns false and the fallback guards below also return false.
if (pmSinkListItem(itemType)(state, dispatch)) return true;
const { $from, $to } = state.selection;
const range = $from.blockRange(
$to,
(node) => node.childCount > 0 && node.firstChild?.type === itemType,
);
// Clean false (no dispatch) when the fallback does not apply: no list range,
// the range does not start at the first item, fewer than two items are
// selected, or the item type does not match (C2).
if (!range) return false;
if (range.startIndex !== 0) return false;
if (range.endIndex - range.startIndex < 2) return false;
if (range.parent.child(range.startIndex).type !== itemType) return false;
// Move $from into the second selected item, keep $to in the last selected
// item (C1 — do not collapse onto the second item). +2 steps over the
// <listItem> + <paragraph> open tokens into inline content; `between` snaps
// to a valid text position if the item does not start with a paragraph.
const secondItemStart =
range.start + range.parent.child(range.startIndex).nodeSize + 2;
const narrowed = state.apply(
state.tr.setSelection(
TextSelection.between(state.doc.resolve(secondItemStart), $to),
),
);
return pmSinkListItem(itemType)(narrowed, dispatch);
};
}
/**
* Shared list keymap with proper "double-Enter exits list" behaviour.
@@ -19,7 +79,16 @@ import { ListItem, TaskItem } from "@tiptap/extension-list";
* empty items are unaffected because `splitListItem` handles them correctly
* and returns true.
*
* Tab / Shift-Tab indent / dedent the item.
* Tab indents the item(s) — see `sinkListItemRange` for the multi-item
* first-item handling. Shift-Tab dedents via the stock command.
*
* Whenever the caret is inside a list item, Tab is the list's indent control
* and must be swallowed even when the structural indent is a no-op (first child
* with nothing to nest under, or already at max depth). Otherwise the unhandled
* Tab falls through to the browser and moves focus out of the editor to the
* next control. So the return value tracks "is the caret in this list?"
* (`editor.isActive(name)`), NOT "did the indent move anything?": indent
* best-effort, then swallow while in a list, fall through (focus nav) when not.
*/
function listItemKeymap(editor: Editor, name: string) {
return {
@@ -28,7 +97,14 @@ function listItemKeymap(editor: Editor, name: string) {
() => commands.splitListItem(name),
() => commands.liftListItem(name),
]),
Tab: () => editor.commands.sinkListItem(name),
Tab: () => {
const itemType = editor.schema.nodes[name];
if (!itemType) return false;
sinkListItemRange(itemType)(editor.state, (tr) =>
editor.view.dispatch(tr),
);
return editor.isActive(name);
},
"Shift-Tab": () => editor.commands.liftListItem(name),
};
}

View File

@@ -425,21 +425,36 @@ export const ReadonlyContent = memo(function ReadonlyContent({
// <Attachment>, which reads the surrounding AttachmentDownloadProvider.
const components = useMemo(() => buildComponents(), []);
// Memoize the whole react-markdown subtree on its only real inputs
// (`processed` + `components`). Unrelated parent re-renders (e.g. a sibling
// agent task streaming over WebSocket fires one every ~100ms) would otherwise
// re-run react-markdown, which hands `<code>` a fresh `dangerouslySetInnerHTML`
// object each time; React then rewrites the highlighted innerHTML even though
// the HTML string is byte-identical, tearing down and rebuilding every hljs
// <span> — which collapses any active text selection inside a code block
// (MUL-3621). A stable element reference lets React bail out of the subtree.
const markdown = useMemo(
() => (
<ReactMarkdown
remarkPlugins={[
[remarkMath, { singleDollarTextMath: false }],
remarkBreaks,
[remarkGfm, { singleTilde: false }],
]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema], rehypeKatex]}
urlTransform={urlTransform}
components={components}
>
{processed}
</ReactMarkdown>
),
[processed, components],
);
return (
<AttachmentDownloadProvider attachments={attachments}>
<div ref={wrapperRef} className={cn("rich-text-editor readonly text-sm", className)}>
<ReactMarkdown
remarkPlugins={[
[remarkMath, { singleDollarTextMath: false }],
remarkBreaks,
[remarkGfm, { singleTilde: false }],
]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema], rehypeKatex]}
urlTransform={urlTransform}
components={components}
>
{processed}
</ReactMarkdown>
{markdown}
<LinkHoverCard {...hover} />
</div>
</AttachmentDownloadProvider>

View File

@@ -10,6 +10,7 @@ require (
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.5
github.com/go-chi/chi/v5 v5.2.5
github.com/go-chi/cors v1.2.2
github.com/go-resty/resty/v2 v2.17.2
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
@@ -61,6 +62,7 @@ require (
github.com/prometheus/procfs v0.16.1 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/net v0.43.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.35.0 // indirect
)

View File

@@ -57,6 +57,8 @@ github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-resty/resty/v2 v2.17.2 h1:FQW5oHYcIlkCNrMD2lloGScxcHJ0gkjshV3qcQAyHQk=
github.com/go-resty/resty/v2 v2.17.2/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA=
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
@@ -137,12 +139,16 @@ go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -0,0 +1,159 @@
# composio
A small, standalone Go SDK for the [Composio v3.1 REST API](https://docs.composio.dev/api-reference).
This package is intentionally self-contained — its only third-party dependency
is [`github.com/go-resty/resty/v2`](https://github.com/go-resty/resty). It does
not import any other Multica package, so it can be reused by other services or
extracted into its own module unchanged.
## Scope (MVP)
Only the endpoints required by the first-stage Composio integration are wired
up. More surface (auth configs, triggers, proxy execute, etc.) can be added
later without changing existing types.
| Capability | Method | REST endpoint |
| --- | --- | --- |
| Create Connect Link (hosted auth flow) | `Client.CreateLink` | `POST /connected_accounts/link` |
| Create MCP / tool-router session | `Client.CreateSession` | `POST /tool_router/session` |
| List connected accounts (per user) | `Client.ListConnectedAccounts` | `GET /connected_accounts` |
| Revoke a connection at the provider | `Client.RevokeConnection` | `POST /connected_accounts/{id}/revoke` |
| Delete a connection record (idempotent) | `Client.DeleteConnectedAccount` | `DELETE /connected_accounts/{id}` |
| List toolkits | `Client.ListToolkits` | `GET /toolkits` |
| Get a toolkit by slug | `Client.GetToolkit` | `GET /toolkits/{slug}` |
| Execute a tool deterministically | `Client.ExecuteTool` | `POST /tools/execute/{slug}` |
| Verify a webhook delivery | `VerifyWebhook` / `VerifyHTTPRequest` | (offline) |
## Quick start
```go
import (
"context"
"os"
"github.com/multica-ai/multica/server/pkg/composio"
)
client, err := composio.NewClient(composio.Options{
APIKey: os.Getenv("COMPOSIO_API_KEY"),
})
if err != nil { /* ... */ }
// 1. Send a user to the hosted Connect Link
link, err := client.CreateLink(ctx, composio.CreateLinkRequest{
AuthConfigID: "ac_xxxxxxxx", // configured in the Composio dashboard
UserID: multicaUserID.String(), // your own user id
CallbackURL: "https://app.multica.ai/api/integrations/composio/callback",
})
// → http.Redirect(w, r, link.RedirectURL, http.StatusFound)
// 2. After Composio creates the account, fetch what the user has connected
accounts, err := client.ListConnectedAccounts(ctx, composio.ListConnectedAccountsRequest{
UserIDs: []string{multicaUserID.String()},
Statuses: []string{"ACTIVE"},
})
// 3. Open an MCP session for the agent runtime
session, err := client.CreateSession(ctx, composio.CreateSessionRequest{
UserID: multicaUserID.String(),
ManageConnections: &composio.ManageConnections{
CallbackURL: "https://app.multica.ai/settings/integrations",
},
})
mcpURL := session.MCP.URL
mcpHdr := client.MCPAuthHeaders() // {"x-api-key": "..."} attach to MCP client
// 4. Disconnect (idempotent — 404 returns nil)
_ = client.RevokeConnection(ctx, "ca_xxxxxxxx")
_ = client.DeleteConnectedAccount(ctx, "ca_xxxxxxxx")
```
## Webhook verification
```go
secret := os.Getenv("COMPOSIO_WEBHOOK_SECRET")
http.HandleFunc("/api/integrations/composio/webhook", func(w http.ResponseWriter, r *http.Request) {
body, err := composio.VerifyHTTPRequest(secret, r, composio.VerifyOptions{})
if err != nil {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
event, err := composio.ParseEvent(body)
if err != nil {
http.Error(w, "bad payload", http.StatusBadRequest)
return
}
switch event.Type {
case "composio.connected_account.expired":
// mark row as expired, notify the user, ...
}
w.WriteHeader(http.StatusOK)
})
```
`VerifyWebhook` enforces a 300-second replay tolerance by default (matching
Composio's official SDKs). Pass `VerifyOptions{Tolerance: ...}` to tune it, or
`-1` to disable the check entirely (only useful when replaying historical
deliveries in tests).
The `webhook-signature` header is parsed as a list of `<version>,<sig>` pairs
so future signing versions don't break verification.
## Errors
All non-2xx responses are returned as a `*composio.APIError` carrying the
upstream status, slug, and message:
```go
_, err := client.CreateLink(ctx, req)
var apiErr *composio.APIError
if errors.As(err, &apiErr) {
if apiErr.IsRateLimited() { /* back off */ }
log.Printf("composio: %d %s (%s) req=%s", apiErr.HTTPStatus, apiErr.Message, apiErr.Slug, apiErr.RequestID)
}
```
`DeleteConnectedAccount` deliberately swallows 404 so the operation is
idempotent — every other error is propagated unchanged.
## Testing
The SDK is exercised entirely against `httptest.NewServer` so unit tests run
offline. Run them with:
```
go test ./server/pkg/composio/...
```
Current coverage: **82.2 %**.
## Design notes
- **Standalone.** Zero coupling to Multica internals — depend on this package
from `server/internal/integrations/composio` (Stage 2 integration glue) or
anywhere else without circular-import risk.
- **`x-api-key`, not Bearer.** Composio's v3.1 REST API authenticates with an
`x-api-key` header. The SDK sets it on every request and exposes
`Client.APIKeyHeader()` / `Client.MCPAuthHeaders()` so callers know
which header to attach when they're reaching Composio outside the SDK
(e.g. the MCP streaming client in the agent runtime).
- **Loose typing for evolving fields.** Session request blocks (`toolkits`,
`auth_configs`, `tools`, `multi_account`, …) and tool execution arguments
use `map[string]any` because their nested schemas are large and likely to
evolve. The frequently-used `manage_connections` block has a typed
wrapper — extend the typed surface as more shapes stabilise.
- **Webhook signing matches the official SDKs.** HMAC-SHA256 over
`{id}.{timestamp}.{rawBody}`, base64-encoded, with a 300-second replay
window. See
[Composio webhook verification](https://docs.composio.dev/docs/setting-up-triggers/subscribing-to-events#verifying-signatures).
## Roadmap (out of scope for v1)
- Auth-config CRUD (`/auth_configs`)
- Triggers (`/triggers`)
- Proxy execute (`/tools/execute/proxy`)
- Session meta-tool / `attach` / `search` endpoints
- Pagination iterators
- Built-in retry middleware on 429 / 5xx

View File

@@ -0,0 +1,165 @@
package composio
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/go-resty/resty/v2"
)
// DefaultBaseURL is the canonical Composio v3.1 REST root.
const DefaultBaseURL = "https://backend.composio.dev/api/v3.1"
// DefaultUserAgent is sent on every request unless overridden via [Options.UserAgent].
const DefaultUserAgent = "multica-composio-go/0.1"
// DefaultTimeout is the per-request timeout applied to the underlying
// resty client when [Options.Timeout] is zero.
const DefaultTimeout = 30 * time.Second
// Options configures a [Client]. Only APIKey is required.
type Options struct {
// APIKey is the Composio project API key, sent as the `x-api-key` header.
APIKey string
// BaseURL overrides the API root. Mostly useful for tests against a
// httptest.Server. Defaults to [DefaultBaseURL].
BaseURL string
// UserAgent overrides the User-Agent header. Defaults to [DefaultUserAgent].
UserAgent string
// Timeout is the per-request timeout. Zero means [DefaultTimeout].
// A negative value disables the timeout entirely.
Timeout time.Duration
// HTTPClient lets callers inject a custom *http.Client (for example with
// a corporate transport, custom CookieJar, redirect policy, or
// observability instrumentation). When non-nil it is adopted in full
// via resty.NewWithClient — the caller's Transport, Jar, CheckRedirect,
// and built-in Timeout all carry through. If both this client's
// Timeout and [Options.Timeout] are set, [Options.Timeout] wins.
HTTPClient *http.Client
// RetryCount is the number of retries resty performs on transient
// failures. Zero means no retries (callers can layer their own).
RetryCount int
// RetryWaitTime is the base delay between retries when RetryCount > 0.
RetryWaitTime time.Duration
}
// Client is the Composio REST client.
//
// It is safe for concurrent use by multiple goroutines.
type Client struct {
rc *resty.Client
baseURL string
apiKey string
userAgent string
}
// NewClient constructs a Client from [Options]. It returns an error when the
// options are obviously broken (empty API key, malformed base URL).
func NewClient(opts Options) (*Client, error) {
if strings.TrimSpace(opts.APIKey) == "" {
return nil, errors.New("composio: APIKey is required")
}
baseURL := opts.BaseURL
if baseURL == "" {
baseURL = DefaultBaseURL
}
if _, err := url.Parse(baseURL); err != nil {
return nil, fmt.Errorf("composio: invalid BaseURL %q: %w", baseURL, err)
}
baseURL = strings.TrimRight(baseURL, "/")
ua := opts.UserAgent
if ua == "" {
ua = DefaultUserAgent
}
timeout := opts.Timeout
switch {
case timeout == 0:
timeout = DefaultTimeout
case timeout < 0:
timeout = 0 // resty treats 0 as "no timeout"
}
rc := newRestyClient(opts.HTTPClient).
SetBaseURL(baseURL).
SetHeader("Content-Type", "application/json").
SetHeader("Accept", "application/json").
SetHeader("User-Agent", ua).
SetHeader("x-api-key", opts.APIKey).
SetTimeout(timeout)
if opts.RetryCount > 0 {
rc = rc.SetRetryCount(opts.RetryCount)
if opts.RetryWaitTime > 0 {
rc = rc.SetRetryWaitTime(opts.RetryWaitTime)
}
}
return &Client{
rc: rc,
baseURL: baseURL,
apiKey: opts.APIKey,
userAgent: ua,
}, nil
}
// newRestyClient constructs a resty.Client honoring an injected *http.Client
// in full when one is provided. resty.NewWithClient adopts the caller's
// http.Client wholesale — so the caller's Transport, Jar, CheckRedirect,
// and Timeout all carry through — which matches the documented contract
// of [Options.HTTPClient].
func newRestyClient(hc *http.Client) *resty.Client {
if hc != nil {
return resty.NewWithClient(hc)
}
return resty.New()
}
// BaseURL returns the resolved API root after defaulting.
func (c *Client) BaseURL() string { return c.baseURL }
// APIKeyHeader returns the header pair callers should attach to MCP
// streaming clients or any other Composio request made outside the SDK.
//
// Returning a copy keeps the internal map immutable.
func (c *Client) APIKeyHeader() map[string]string {
return map[string]string{"x-api-key": c.apiKey}
}
// newRequest returns a resty.Request bound to the given context.
// All endpoint methods funnel through this helper.
func (c *Client) newRequest(ctx context.Context) *resty.Request {
return c.rc.R().SetContext(ctx)
}
// do executes a request and unmarshals a successful body into out.
// On non-2xx it returns a *APIError populated from the response body.
//
// out may be nil if the caller does not care about the body
// (e.g. DELETE / 204).
func (c *Client) do(req *resty.Request, method, path string, out any) error {
if out != nil {
req = req.SetResult(out)
}
resp, err := req.Execute(method, path)
if err != nil {
return fmt.Errorf("composio: %s %s: %w", method, path, err)
}
if resp.IsError() {
return parseAPIError(resp.StatusCode(), resp.Body())
}
return nil
}

View File

@@ -0,0 +1,567 @@
package composio_test
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/multica-ai/multica/server/pkg/composio"
)
// newTestServer wires up a httptest.Server with the provided handler and
// returns a composio.Client pointed at it.
func newTestServer(t *testing.T, h http.HandlerFunc) (*composio.Client, *httptest.Server) {
t.Helper()
srv := httptest.NewServer(h)
t.Cleanup(srv.Close)
c, err := composio.NewClient(composio.Options{
APIKey: "test-key",
BaseURL: srv.URL,
Timeout: 5 * time.Second,
})
if err != nil {
t.Fatalf("NewClient: %v", err)
}
return c, srv
}
func readJSON(t *testing.T, r *http.Request, out any) {
t.Helper()
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
if err := json.Unmarshal(body, out); err != nil {
t.Fatalf("unmarshal body %q: %v", string(body), err)
}
}
func writeJSON(t *testing.T, w http.ResponseWriter, status int, v any) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(v); err != nil {
t.Fatalf("write json: %v", err)
}
}
// ---------------------------------------------------------------------------
// Client construction
// ---------------------------------------------------------------------------
func TestNewClient_Defaults(t *testing.T) {
c, err := composio.NewClient(composio.Options{APIKey: "k"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := c.BaseURL(); got != composio.DefaultBaseURL {
t.Errorf("BaseURL = %q, want %q", got, composio.DefaultBaseURL)
}
}
func TestNewClient_RequiresAPIKey(t *testing.T) {
_, err := composio.NewClient(composio.Options{})
if err == nil {
t.Fatal("expected error when APIKey is empty")
}
}
func TestNewClient_TrimsTrailingSlash(t *testing.T) {
c, err := composio.NewClient(composio.Options{APIKey: "k", BaseURL: "https://x.example.com/"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got, want := c.BaseURL(), "https://x.example.com"; got != want {
t.Errorf("BaseURL = %q, want %q", got, want)
}
}
// recordingTransport observes whether it actually handled a request.
type recordingTransport struct {
mu sync.Mutex
calls int
last *http.Request
status int
}
func (rt *recordingTransport) RoundTrip(r *http.Request) (*http.Response, error) {
rt.mu.Lock()
rt.calls++
rt.last = r
rt.mu.Unlock()
body := io.NopCloser(strings.NewReader(`{"items":[]}`))
return &http.Response{
StatusCode: rt.statusOr(200),
Body: body,
Header: http.Header{"Content-Type": []string{"application/json"}},
Request: r,
}, nil
}
func (rt *recordingTransport) statusOr(d int) int {
if rt.status == 0 {
return d
}
return rt.status
}
// TestNewClient_HonorsInjectedHTTPClient asserts that when Options.HTTPClient
// is non-nil the SDK actually routes requests through *that* client — full
// fidelity, not just transport+timeout. GPT-Boy's PR review against #4603
// caught the partial behavior; this test locks the fix in.
func TestNewClient_HonorsInjectedHTTPClient(t *testing.T) {
rt := &recordingTransport{}
hc := &http.Client{Transport: rt}
c, err := composio.NewClient(composio.Options{
APIKey: "k",
BaseURL: "https://api.example.invalid",
HTTPClient: hc,
})
if err != nil {
t.Fatalf("NewClient: %v", err)
}
if _, err := c.ListToolkits(context.Background(), composio.ListToolkitsRequest{}); err != nil {
t.Fatalf("ListToolkits: %v", err)
}
rt.mu.Lock()
defer rt.mu.Unlock()
if rt.calls != 1 {
t.Fatalf("expected 1 call through injected transport, got %d", rt.calls)
}
if rt.last == nil || rt.last.URL.Host != "api.example.invalid" {
t.Errorf("request did not flow through injected client: %+v", rt.last)
}
if got := rt.last.Header.Get("x-api-key"); got != "k" {
t.Errorf("api key header lost in injected client: %q", got)
}
}
// ---------------------------------------------------------------------------
// Connect Link
// ---------------------------------------------------------------------------
func TestCreateLink_Success(t *testing.T) {
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/connected_accounts/link" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
if got := r.Header.Get("x-api-key"); got != "test-key" {
t.Errorf("missing api key header, got %q", got)
}
var body composio.CreateLinkRequest
readJSON(t, r, &body)
if body.AuthConfigID != "ac_abc" || body.UserID != "u_1" {
t.Errorf("unexpected body: %+v", body)
}
writeJSON(t, w, http.StatusCreated, map[string]any{
"link_token": "ltok_xyz",
"redirect_url": "https://connect.composio.dev/ln_xyz",
"expires_at": "2026-12-31T00:00:00Z",
"connected_account_id": "ca_pending",
})
})
resp, err := c.CreateLink(context.Background(), composio.CreateLinkRequest{
AuthConfigID: "ac_abc",
UserID: "u_1",
CallbackURL: "https://example.com/cb",
})
if err != nil {
t.Fatalf("CreateLink: %v", err)
}
if resp.RedirectURL == "" || resp.LinkToken != "ltok_xyz" || resp.ConnectedAccountID != "ca_pending" {
t.Errorf("unexpected response: %+v", resp)
}
}
func TestCreateLink_ValidatesInputs(t *testing.T) {
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
t.Error("server should not be hit when inputs are invalid")
})
if _, err := c.CreateLink(context.Background(), composio.CreateLinkRequest{UserID: "u"}); err == nil {
t.Error("expected error when AuthConfigID is empty")
}
if _, err := c.CreateLink(context.Background(), composio.CreateLinkRequest{AuthConfigID: "ac"}); err == nil {
t.Error("expected error when UserID is empty")
}
}
func TestCreateLink_APIError(t *testing.T) {
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
writeJSON(t, w, http.StatusBadRequest, map[string]any{
"error": map[string]any{
"message": "bad input",
"code": 400,
"slug": "INVALID_INPUT",
"request_id": "req_1",
},
})
})
_, err := c.CreateLink(context.Background(), composio.CreateLinkRequest{
AuthConfigID: "ac", UserID: "u",
})
if err == nil {
t.Fatal("expected error")
}
var apiErr *composio.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("expected *APIError, got %T: %v", err, err)
}
if apiErr.HTTPStatus != http.StatusBadRequest || apiErr.Slug != "INVALID_INPUT" || apiErr.Message != "bad input" {
t.Errorf("unexpected APIError: %+v", apiErr)
}
}
// ---------------------------------------------------------------------------
// Connected accounts list / revoke / delete
// ---------------------------------------------------------------------------
func TestListConnectedAccounts_QueryString(t *testing.T) {
var seen *http.Request
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
seen = r
writeJSON(t, w, http.StatusOK, map[string]any{
"items": []map[string]any{
{"id": "ca_1", "user_id": "u_1", "status": "ACTIVE",
"toolkit": map[string]any{"slug": "notion"}},
},
"next_cursor": "cur_2",
})
})
resp, err := c.ListConnectedAccounts(context.Background(), composio.ListConnectedAccountsRequest{
UserIDs: []string{"u_1", "u_2"},
ToolkitSlugs: []string{"notion", "slack"},
AuthConfigIDs: []string{"ac_a"},
ConnectedAccountIDs: []string{"ca_x"},
Statuses: []string{"ACTIVE"},
OrderBy: "updated_at",
OrderDirection: "desc",
AccountType: "PRIVATE",
Limit: 25,
})
if err != nil {
t.Fatalf("ListConnectedAccounts: %v", err)
}
if len(resp.Items) != 1 || resp.Items[0].Toolkit.Slug != "notion" || resp.NextCursor != "cur_2" {
t.Errorf("unexpected response: %+v", resp)
}
q := seen.URL.Query()
// Per Composio v3.1 these are plural array params.
if got := q["user_ids"]; len(got) != 2 || got[0] != "u_1" || got[1] != "u_2" {
t.Errorf("user_ids = %v", got)
}
if got := q["toolkit_slugs"]; len(got) != 2 || got[0] != "notion" || got[1] != "slack" {
t.Errorf("toolkit_slugs = %v", got)
}
if got := q["auth_config_ids"]; len(got) != 1 || got[0] != "ac_a" {
t.Errorf("auth_config_ids = %v", got)
}
if got := q["connected_account_ids"]; len(got) != 1 || got[0] != "ca_x" {
t.Errorf("connected_account_ids = %v", got)
}
if got := q["statuses"]; len(got) != 1 || got[0] != "ACTIVE" {
t.Errorf("statuses = %v", got)
}
// Singular legacy keys must NOT appear — guard against regression.
if q.Has("user_id") || q.Has("auth_config_id") {
t.Errorf("legacy singular query keys leaked: %s", seen.URL.RawQuery)
}
if q.Get("order_by") != "updated_at" {
t.Errorf("order_by = %q", q.Get("order_by"))
}
if q.Get("order_direction") != "desc" {
t.Errorf("order_direction = %q", q.Get("order_direction"))
}
if q.Get("account_type") != "PRIVATE" {
t.Errorf("account_type = %q", q.Get("account_type"))
}
if q.Get("limit") != "25" {
t.Errorf("limit = %q", q.Get("limit"))
}
}
func TestRevokeConnection_Success(t *testing.T) {
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/connected_accounts/ca_42/revoke" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
w.WriteHeader(http.StatusNoContent)
})
if err := c.RevokeConnection(context.Background(), "ca_42"); err != nil {
t.Errorf("RevokeConnection: %v", err)
}
}
func TestRevokeConnection_RequiresID(t *testing.T) {
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
t.Error("server should not be hit")
})
if err := c.RevokeConnection(context.Background(), ""); err == nil {
t.Error("expected error for empty id")
}
}
func TestDeleteConnectedAccount_IdempotentOn404(t *testing.T) {
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
t.Errorf("method = %s", r.Method)
}
writeJSON(t, w, http.StatusNotFound, map[string]any{
"error": map[string]any{"message": "not found", "status": 404, "slug": "NOT_FOUND"},
})
})
if err := c.DeleteConnectedAccount(context.Background(), "ca_gone"); err != nil {
t.Errorf("expected nil on 404, got %v", err)
}
}
func TestDeleteConnectedAccount_PropagatesOtherErrors(t *testing.T) {
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
writeJSON(t, w, http.StatusInternalServerError, map[string]any{
"error": map[string]any{"message": "boom", "status": 500, "slug": "INTERNAL"},
})
})
err := c.DeleteConnectedAccount(context.Background(), "ca_1")
if err == nil {
t.Fatal("expected error")
}
var apiErr *composio.APIError
if !errors.As(err, &apiErr) || apiErr.HTTPStatus != http.StatusInternalServerError {
t.Errorf("unexpected error: %v", err)
}
}
// ---------------------------------------------------------------------------
// Sessions
// ---------------------------------------------------------------------------
func TestCreateSession_Success(t *testing.T) {
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/tool_router/session" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
var body composio.CreateSessionRequest
readJSON(t, r, &body)
if body.UserID != "u_1" {
t.Errorf("user_id = %q", body.UserID)
}
if body.ManageConnections == nil || body.ManageConnections.CallbackURL != "https://cb" {
t.Errorf("manage_connections = %+v", body.ManageConnections)
}
writeJSON(t, w, http.StatusCreated, map[string]any{
"session_id": "trs_1",
"mcp": map[string]any{"type": "http", "url": "https://mcp.example/trs_1"},
})
})
enable := true
resp, err := c.CreateSession(context.Background(), composio.CreateSessionRequest{
UserID: "u_1",
ManageConnections: &composio.ManageConnections{
Enable: &enable,
CallbackURL: "https://cb",
},
})
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
if resp.MCP.URL == "" || resp.SessionID != "trs_1" {
t.Errorf("unexpected response: %+v", resp)
}
hdr := c.MCPAuthHeaders()
if hdr["x-api-key"] != "test-key" {
t.Errorf("MCPAuthHeaders = %v", hdr)
}
}
func TestCreateSession_RequiresUserID(t *testing.T) {
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
t.Error("server should not be hit")
})
if _, err := c.CreateSession(context.Background(), composio.CreateSessionRequest{}); err == nil {
t.Error("expected error for empty UserID")
}
}
// ---------------------------------------------------------------------------
// Toolkits / Tools
// ---------------------------------------------------------------------------
func TestListToolkits_Success(t *testing.T) {
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/toolkits" || r.URL.Query().Get("category") != "productivity" {
t.Errorf("unexpected request: %s ?%s", r.URL.Path, r.URL.RawQuery)
}
writeJSON(t, w, http.StatusOK, map[string]any{
"items": []map[string]any{
{"slug": "notion", "name": "Notion"},
{"slug": "slack", "name": "Slack"},
},
})
})
resp, err := c.ListToolkits(context.Background(), composio.ListToolkitsRequest{Category: "productivity"})
if err != nil {
t.Fatalf("ListToolkits: %v", err)
}
if len(resp.Items) != 2 || resp.Items[0].Slug != "notion" {
t.Errorf("unexpected response: %+v", resp)
}
}
func TestGetToolkit_Success(t *testing.T) {
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/toolkits/notion" {
t.Errorf("path = %s", r.URL.Path)
}
writeJSON(t, w, http.StatusOK, map[string]any{"slug": "notion", "name": "Notion"})
})
tk, err := c.GetToolkit(context.Background(), "notion")
if err != nil {
t.Fatalf("GetToolkit: %v", err)
}
if tk.Slug != "notion" {
t.Errorf("slug = %q", tk.Slug)
}
}
func TestGetToolkit_RequiresSlug(t *testing.T) {
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
t.Error("server should not be hit")
})
if _, err := c.GetToolkit(context.Background(), ""); err == nil {
t.Error("expected error for empty slug")
}
}
func TestExecuteTool_Success(t *testing.T) {
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/tools/execute/GITHUB_CREATE_ISSUE" {
t.Errorf("path = %s", r.URL.Path)
}
// Decode into a raw map first so we can assert wire keys directly.
raw, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
var wire map[string]any
if err := json.Unmarshal(raw, &wire); err != nil {
t.Fatalf("unmarshal wire: %v", err)
}
if wire["user_id"] != "u_1" {
t.Errorf("user_id = %v", wire["user_id"])
}
// The spec field is `version`, not `toolkit_versions`.
if wire["version"] != "latest" {
t.Errorf("version = %v (want %q)", wire["version"], "latest")
}
if _, leaked := wire["toolkit_versions"]; leaked {
t.Errorf("legacy toolkit_versions field leaked to wire: %s", raw)
}
args, _ := wire["arguments"].(map[string]any)
if args["title"] != "hi" {
t.Errorf("arguments.title = %v", args["title"])
}
writeJSON(t, w, http.StatusOK, map[string]any{
"successful": true,
"data": map[string]any{"issue_number": float64(42)},
"log_id": "log_1",
})
})
resp, err := c.ExecuteTool(context.Background(), "GITHUB_CREATE_ISSUE", composio.ExecuteToolRequest{
UserID: "u_1",
Arguments: map[string]any{"title": "hi"},
Version: "latest",
})
if err != nil {
t.Fatalf("ExecuteTool: %v", err)
}
if !resp.Successful || resp.Data["issue_number"].(float64) != 42 || resp.LogID != "log_1" {
t.Errorf("unexpected response: %+v", resp)
}
}
// TestExecuteToolRequest_VersionSerialization locks in the json tag for the
// Version field — GPT-Boy's review against PR #4603 caught that the field
// used to serialize as `toolkit_versions`, which is not a v3.1 wire key.
func TestExecuteToolRequest_VersionSerialization(t *testing.T) {
req := composio.ExecuteToolRequest{
UserID: "u_1",
Version: "20251027_00",
}
b, err := json.Marshal(req)
if err != nil {
t.Fatalf("marshal: %v", err)
}
got := string(b)
if !strings.Contains(got, `"version":"20251027_00"`) {
t.Errorf("version not serialized as `version`: %s", got)
}
if strings.Contains(got, "toolkit_versions") {
t.Errorf("legacy toolkit_versions key leaked: %s", got)
}
// Zero-value Version must omit the field entirely (omitempty).
bEmpty, _ := json.Marshal(composio.ExecuteToolRequest{UserID: "u_1"})
if strings.Contains(string(bEmpty), "version") {
t.Errorf("empty Version should omit, got: %s", bEmpty)
}
}
func TestExecuteTool_ValidatesInputs(t *testing.T) {
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
t.Error("server should not be hit")
})
if _, err := c.ExecuteTool(context.Background(), "", composio.ExecuteToolRequest{UserID: "u"}); err == nil {
t.Error("expected error for empty tool slug")
}
if _, err := c.ExecuteTool(context.Background(), "X", composio.ExecuteToolRequest{}); err == nil {
t.Error("expected error when neither UserID nor ConnectedAccountID is set")
}
}
// ---------------------------------------------------------------------------
// Error parsing
// ---------------------------------------------------------------------------
func TestAPIError_FallbackOnNonJSONBody(t *testing.T) {
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadGateway)
_, _ = w.Write([]byte("<html>upstream down</html>"))
})
_, err := c.ListToolkits(context.Background(), composio.ListToolkitsRequest{})
if err == nil {
t.Fatal("expected error")
}
var apiErr *composio.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("expected *APIError, got %T: %v", err, err)
}
if apiErr.HTTPStatus != http.StatusBadGateway {
t.Errorf("status = %d", apiErr.HTTPStatus)
}
if !strings.Contains(string(apiErr.RawBody), "upstream down") {
t.Errorf("raw body lost: %q", apiErr.RawBody)
}
}
func TestAPIError_HelperPredicates(t *testing.T) {
e := &composio.APIError{HTTPStatus: http.StatusNotFound}
if !e.IsNotFound() {
t.Error("IsNotFound() = false")
}
e2 := &composio.APIError{HTTPStatus: http.StatusUnauthorized}
if !e2.IsUnauthorized() {
t.Error("IsUnauthorized() = false")
}
e3 := &composio.APIError{HTTPStatus: http.StatusTooManyRequests}
if !e3.IsRateLimited() {
t.Error("IsRateLimited() = false")
}
}

View File

@@ -0,0 +1,198 @@
package composio
import (
"context"
"errors"
"net/http"
"net/url"
"strconv"
)
// --- Create link --------------------------------------------------------
// CreateLinkRequest is the body of POST /connected_accounts/link.
//
// Spec: https://docs.composio.dev/reference/api-reference/connected-accounts/postConnectedAccountsLink
type CreateLinkRequest struct {
// AuthConfigID is the `ac_…` id of an auth config registered in your
// Composio project (one per toolkit / OAuth client variant).
AuthConfigID string `json:"auth_config_id"`
// UserID is your own user identifier — Composio scopes the resulting
// connected account by it.
UserID string `json:"user_id"`
// CallbackURL is where Composio sends the user after they finish the
// hosted auth flow. Optional; Composio has a default landing page.
CallbackURL string `json:"callback_url,omitempty"`
// Alias is a human-readable label for the connection. Optional but useful
// when the same user connects multiple accounts of the same toolkit.
Alias string `json:"alias,omitempty"`
// ConnectionData lets the caller pre-fill connection fields with default
// values (per the Composio docs). Free-form to avoid coupling to the
// scheme-specific child schemas.
ConnectionData map[string]any `json:"connection_data,omitempty"`
}
// CreateLinkResponse is the body returned by POST /connected_accounts/link.
type CreateLinkResponse struct {
LinkToken string `json:"link_token"`
RedirectURL string `json:"redirect_url"`
ExpiresAt string `json:"expires_at"`
ConnectedAccountID string `json:"connected_account_id"`
}
// CreateLink starts a hosted Composio Connect Link session. The redirect URL
// is what the caller should send the user to (popup, redirect, or
// SFSafariViewController).
func (c *Client) CreateLink(ctx context.Context, req CreateLinkRequest) (*CreateLinkResponse, error) {
if req.AuthConfigID == "" {
return nil, errors.New("composio: CreateLink: AuthConfigID is required")
}
if req.UserID == "" {
return nil, errors.New("composio: CreateLink: UserID is required")
}
var out CreateLinkResponse
if err := c.do(c.newRequest(ctx).SetBody(req), http.MethodPost, "/connected_accounts/link", &out); err != nil {
return nil, err
}
return &out, nil
}
// --- List ---------------------------------------------------------------
// ListConnectedAccountsRequest collects the optional filters supported by
// GET /connected_accounts. Zero values are omitted from the query string.
//
// Per the Composio v3.1 spec all filters are plural array params: the SDK
// sends one query entry per slice element (`user_ids=u1&user_ids=u2`).
// Pass a single-element slice for the common "list by one user" case.
//
// Spec: https://docs.composio.dev/reference/api-reference/connected-accounts/getConnectedAccounts
type ListConnectedAccountsRequest struct {
UserIDs []string
ToolkitSlugs []string
AuthConfigIDs []string
ConnectedAccountIDs []string
Statuses []string // ACTIVE, EXPIRED, INACTIVE, …
OrderBy string // "created_at" (default) | "updated_at"
OrderDirection string // "asc" | "desc" (default)
AccountType string // experimental: PRIVATE | SHARED | ALL
Limit int // 0 = use upstream default
Cursor string
}
// ConnectedAccount mirrors a subset of the Composio response shape. Only the
// fields actually consumed by the MVP are typed; extras live in Extra so
// callers can read them without an SDK update.
type ConnectedAccount struct {
ID string `json:"id"`
UserID string `json:"user_id"`
AuthConfigID string `json:"auth_config_id"`
Toolkit Toolkit `json:"toolkit"`
Status string `json:"status"`
StatusReason string `json:"status_reason,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
LastUsedAt string `json:"last_used_at,omitempty"`
Extra map[string]any `json:"-"`
}
// ListConnectedAccountsResponse is the typed paginated response.
type ListConnectedAccountsResponse struct {
Items []ConnectedAccount `json:"items"`
NextCursor string `json:"next_cursor,omitempty"`
TotalItems int `json:"total_items,omitempty"`
}
// ListConnectedAccounts returns the connections matching the supplied filters.
func (c *Client) ListConnectedAccounts(ctx context.Context, req ListConnectedAccountsRequest) (*ListConnectedAccountsResponse, error) {
q := url.Values{}
for _, v := range req.UserIDs {
if v != "" {
q.Add("user_ids", v)
}
}
for _, v := range req.ToolkitSlugs {
if v != "" {
q.Add("toolkit_slugs", v)
}
}
for _, v := range req.AuthConfigIDs {
if v != "" {
q.Add("auth_config_ids", v)
}
}
for _, v := range req.ConnectedAccountIDs {
if v != "" {
q.Add("connected_account_ids", v)
}
}
for _, v := range req.Statuses {
if v != "" {
q.Add("statuses", v)
}
}
if req.OrderBy != "" {
q.Set("order_by", req.OrderBy)
}
if req.OrderDirection != "" {
q.Set("order_direction", req.OrderDirection)
}
if req.AccountType != "" {
q.Set("account_type", req.AccountType)
}
if req.Limit > 0 {
q.Set("limit", strconv.Itoa(req.Limit))
}
if req.Cursor != "" {
q.Set("cursor", req.Cursor)
}
path := "/connected_accounts"
if encoded := q.Encode(); encoded != "" {
path += "?" + encoded
}
var out ListConnectedAccountsResponse
if err := c.do(c.newRequest(ctx), http.MethodGet, path, &out); err != nil {
return nil, err
}
return &out, nil
}
// --- Revoke / Delete ----------------------------------------------------
// RevokeConnection revokes the OAuth grant at the upstream provider but
// keeps the Composio record. Use this when the user disconnects and you
// want the provider-side tokens invalidated immediately.
func (c *Client) RevokeConnection(ctx context.Context, connectedAccountID string) error {
if connectedAccountID == "" {
return errors.New("composio: RevokeConnection: connectedAccountID is required")
}
return c.do(c.newRequest(ctx),
http.MethodPost, "/connected_accounts/"+url.PathEscape(connectedAccountID)+"/revoke", nil)
}
// DeleteConnectedAccount removes the connection record from Composio. The
// provider tokens are NOT revoked by this call — call [Client.RevokeConnection]
// first if you need them invalidated upstream.
//
// Returns nil for 404 so callers can treat the operation as idempotent.
func (c *Client) DeleteConnectedAccount(ctx context.Context, connectedAccountID string) error {
if connectedAccountID == "" {
return errors.New("composio: DeleteConnectedAccount: connectedAccountID is required")
}
err := c.do(c.newRequest(ctx),
http.MethodDelete, "/connected_accounts/"+url.PathEscape(connectedAccountID), nil)
if err == nil {
return nil
}
var apiErr *APIError
if errors.As(err, &apiErr) && apiErr.IsNotFound() {
return nil
}
return err
}

View File

@@ -0,0 +1,56 @@
// Package composio is a small, standalone Go SDK for the Composio v3.1 REST API.
//
// It is intentionally self-contained: the only third-party dependency is
// [github.com/go-resty/resty/v2]. It does not import any Multica-specific
// package, so it can be reused by other Go services or extracted into its
// own module unchanged.
//
// # MVP surface
//
// The SDK targets the surface required by the Composio integration MVP
// (see MUL-3715 / MUL-3720). It is deliberately minimal — only the
// endpoints actually used by the first-stage product are wired up:
//
// - Connect Link — POST /connected_accounts/link
// - MCP Session — POST /tool_router/session
// - Connected Accounts — GET /connected_accounts,
// POST /connected_accounts/{id}/revoke,
// DELETE /connected_accounts/{id}
// - Toolkits — GET /toolkits, GET /toolkits/{slug}
// - Tool Execute — POST /tools/execute/{tool_slug}
// - Webhook — HMAC-SHA256 signature verification
//
// More surface (auth configs, triggers, proxy execute, etc.) can be
// added later without changing the existing types.
//
// # Quick start
//
// client, err := composio.NewClient(composio.Options{
// APIKey: os.Getenv("COMPOSIO_API_KEY"),
// })
// if err != nil { return err }
//
// link, err := client.CreateLink(ctx, composio.CreateLinkRequest{
// AuthConfigID: "ac_abc",
// UserID: "u_123",
// CallbackURL: "https://app.example.com/composio/callback",
// })
// // redirect user to link.RedirectURL
//
// session, err := client.CreateSession(ctx, composio.CreateSessionRequest{
// UserID: "u_123",
// })
// // agent runtime now consumes session.MCP.URL + composio.MCPAuthHeaders(...)
//
// # Errors
//
// All non-2xx responses come back as a *APIError carrying the upstream
// status, slug, and message. Transport errors come back unwrapped from
// resty so callers can errors.Is/As as usual.
//
// # Webhook verification
//
// [VerifyWebhook] verifies the HMAC-SHA256 signature Composio attaches
// to every webhook delivery, with a configurable replay tolerance.
// See https://docs.composio.dev/docs/setting-up-triggers/subscribing-to-events#verifying-signatures
package composio

View File

@@ -0,0 +1,88 @@
package composio
import (
"encoding/json"
"fmt"
"net/http"
)
// APIError is the canonical error returned by the SDK when Composio responds
// with a non-2xx HTTP status.
//
// The Composio error envelope as of v3.1 looks like:
//
// {
// "error": {
// "message": "...",
// "code": 400,
// "slug": "INVALID_INPUT",
// "status": 400,
// "request_id": "req_...",
// "suggested_fix":"...",
// "errors": ["..."]
// }
// }
//
// HTTPStatus is the transport status as observed locally; the rest mirrors
// the body if Composio returned one. RawBody is preserved verbatim so
// callers can log the full upstream response for debugging.
type APIError struct {
HTTPStatus int `json:"-"`
Message string `json:"message,omitempty"`
Code int `json:"code,omitempty"`
Slug string `json:"slug,omitempty"`
Status int `json:"status,omitempty"`
RequestID string `json:"request_id,omitempty"`
SuggestedFix string `json:"suggested_fix,omitempty"`
Errors []string `json:"errors,omitempty"`
RawBody []byte `json:"-"`
}
// Error implements error. It surfaces the upstream status, slug, and message.
func (e *APIError) Error() string {
if e == nil {
return ""
}
msg := e.Message
if msg == "" {
msg = http.StatusText(e.HTTPStatus)
}
if e.Slug != "" {
return fmt.Sprintf("composio: %d %s (%s)", e.HTTPStatus, msg, e.Slug)
}
return fmt.Sprintf("composio: %d %s", e.HTTPStatus, msg)
}
// IsNotFound reports whether the error is an HTTP 404 — useful for idempotent
// delete/revoke flows.
func (e *APIError) IsNotFound() bool { return e != nil && e.HTTPStatus == http.StatusNotFound }
// IsUnauthorized reports whether the error is an HTTP 401.
func (e *APIError) IsUnauthorized() bool {
return e != nil && e.HTTPStatus == http.StatusUnauthorized
}
// IsRateLimited reports whether the error is an HTTP 429.
func (e *APIError) IsRateLimited() bool {
return e != nil && e.HTTPStatus == http.StatusTooManyRequests
}
// parseAPIError decodes Composio's `{"error": {...}}` envelope. If the body
// is not the expected shape it returns an APIError carrying just HTTPStatus
// and RawBody so callers still see something useful.
func parseAPIError(status int, body []byte) *APIError {
out := &APIError{HTTPStatus: status, RawBody: body}
if len(body) == 0 {
return out
}
var wire struct {
Error APIError `json:"error"`
}
if err := json.Unmarshal(body, &wire); err != nil {
// Body is not the expected envelope — leave RawBody set, message empty.
return out
}
wire.Error.HTTPStatus = status
wire.Error.RawBody = body
return &wire.Error
}

View File

@@ -0,0 +1,99 @@
package composio
import (
"context"
"errors"
"net/http"
)
// --- Session creation ---------------------------------------------------
// CreateSessionRequest is the body of POST /tool_router/session.
//
// The minimum required field is [UserID]. Everything else is optional and
// maps directly to the v3.1 wire schema:
// https://docs.composio.dev/reference/api-reference/tool-router/postToolRouterSession
//
// The schema is intentionally typed loosely (map-based) for the nested
// `toolkits`, `auth_configs`, `tools`, `tags`, `multi_account`, etc. fields
// because they carry many child attributes and are expected to evolve.
// Callers can still construct strongly typed wrappers on top.
type CreateSessionRequest struct {
UserID string `json:"user_id"`
Toolkits map[string]any `json:"toolkits,omitempty"`
AuthConfigs map[string]any `json:"auth_configs,omitempty"`
ConnectedAccounts map[string]any `json:"connected_accounts,omitempty"`
ManageConnections *ManageConnections `json:"manage_connections,omitempty"`
Tools map[string]any `json:"tools,omitempty"`
Tags any `json:"tags,omitempty"`
Workbench map[string]any `json:"workbench,omitempty"`
MultiAccount map[string]any `json:"multi_account,omitempty"`
Preload map[string]any `json:"preload,omitempty"`
Search map[string]any `json:"search,omitempty"`
Execute map[string]any `json:"execute,omitempty"`
Experimental map[string]any `json:"experimental,omitempty"`
}
// ManageConnections is the typed flavor of the `manage_connections` object —
// the field used most often by integrations.
type ManageConnections struct {
Enable *bool `json:"enable,omitempty"`
CallbackURL string `json:"callback_url,omitempty"`
EnableWaitForConnections *bool `json:"enable_wait_for_connections,omitempty"`
EnableConnectionRemoval *bool `json:"enable_connection_removal,omitempty"`
}
// MCPDescriptor is the streamable HTTP entrypoint for the session's MCP.
type MCPDescriptor struct {
Type string `json:"type"`
URL string `json:"url"`
}
// CreateSessionResponse mirrors the subset of the upstream response the SDK
// currently exposes typed. Additional fields can be added without breaking
// callers.
type CreateSessionResponse struct {
SessionID string `json:"session_id"`
MCP MCPDescriptor `json:"mcp"`
ToolRouterTools []string `json:"tool_router_tools,omitempty"`
Config map[string]any `json:"config,omitempty"`
ConfigVersion int `json:"config_version,omitempty"`
Experimental map[string]any `json:"experimental,omitempty"`
Warnings []SessionWarning `json:"warnings,omitempty"`
}
// SessionWarning is a non-fatal warning emitted at session creation time.
type SessionWarning struct {
Code string `json:"code"`
Message string `json:"message"`
}
// CreateSession opens a new tool-router (a.k.a. MCP) session for the given
// user. The returned [CreateSessionResponse.MCP.URL] is the URL an
// MCP-compatible client connects to.
//
// Use [Client.MCPAuthHeaders] to obtain the matching headers — the SDK
// returns these separately rather than baking them into the response so
// that callers don't accidentally leak the secret API key through logs.
func (c *Client) CreateSession(ctx context.Context, req CreateSessionRequest) (*CreateSessionResponse, error) {
if req.UserID == "" {
return nil, errors.New("composio: CreateSession: UserID is required")
}
var out CreateSessionResponse
if err := c.do(c.newRequest(ctx).SetBody(req), http.MethodPost, "/tool_router/session", &out); err != nil {
return nil, err
}
return &out, nil
}
// MCPAuthHeaders returns the headers an MCP client must send when connecting
// to a session URL produced by [Client.CreateSession].
//
// Composio authenticates MCP streaming the same way it authenticates the
// REST API — with the project's `x-api-key` header. Keeping this as a
// dedicated helper makes it explicit at the call site that bearer
// material is leaving the SDK boundary, so callers can route it through
// their secret-redact pipeline (see server/pkg/redact).
func (c *Client) MCPAuthHeaders() map[string]string {
return c.APIKeyHeader()
}

View File

@@ -0,0 +1,78 @@
package composio
import (
"context"
"errors"
"net/http"
"net/url"
"strconv"
)
// Toolkit is the minimal toolkit descriptor used as a nested field inside
// connected accounts, sessions, and the toolkit list endpoint. Only fields
// useful for UI / dispatch decisions are typed.
type Toolkit struct {
Slug string `json:"slug"`
Name string `json:"name,omitempty"`
LogoURL string `json:"logo,omitempty"`
Description string `json:"description,omitempty"`
Categories []string `json:"categories,omitempty"`
AuthSchemes []string `json:"auth_schemes,omitempty"`
Meta map[string]any `json:"meta,omitempty"`
}
// ListToolkitsRequest carries the optional filters of GET /toolkits.
type ListToolkitsRequest struct {
Category string
Limit int
Cursor string
// SortBy is the upstream sort order. Per the v3.1 spec the valid enum
// values are "usage" and "alphabetically".
SortBy string
}
// ListToolkitsResponse is the typed paginated response.
type ListToolkitsResponse struct {
Items []Toolkit `json:"items"`
NextCursor string `json:"next_cursor,omitempty"`
TotalItems int `json:"total_items,omitempty"`
}
// ListToolkits returns toolkits available to the project.
func (c *Client) ListToolkits(ctx context.Context, req ListToolkitsRequest) (*ListToolkitsResponse, error) {
q := url.Values{}
if req.Category != "" {
q.Set("category", req.Category)
}
if req.Limit > 0 {
q.Set("limit", strconv.Itoa(req.Limit))
}
if req.Cursor != "" {
q.Set("cursor", req.Cursor)
}
if req.SortBy != "" {
q.Set("sort_by", req.SortBy)
}
path := "/toolkits"
if encoded := q.Encode(); encoded != "" {
path += "?" + encoded
}
var out ListToolkitsResponse
if err := c.do(c.newRequest(ctx), http.MethodGet, path, &out); err != nil {
return nil, err
}
return &out, nil
}
// GetToolkit fetches a single toolkit by its slug (e.g. "notion", "github").
func (c *Client) GetToolkit(ctx context.Context, slug string) (*Toolkit, error) {
if slug == "" {
return nil, errors.New("composio: GetToolkit: slug is required")
}
var out Toolkit
if err := c.do(c.newRequest(ctx),
http.MethodGet, "/toolkits/"+url.PathEscape(slug), &out); err != nil {
return nil, err
}
return &out, nil
}

View File

@@ -0,0 +1,74 @@
package composio
import (
"context"
"errors"
"net/http"
"net/url"
)
// ExecuteToolRequest is the body for POST /tools/execute/{tool_slug}.
//
// Spec: https://docs.composio.dev/reference/api-reference/tools/postToolsExecuteByToolSlug
//
// Either ConnectedAccountID or (UserID + the tool's toolkit) is required so
// Composio knows which credential set to use. The SDK does not enforce that
// invariant up front; the upstream returns a 422 with a clear message when
// missing.
type ExecuteToolRequest struct {
// Arguments is the structured input to the tool. Shape varies per tool.
Arguments map[string]any `json:"arguments,omitempty"`
// ConnectedAccountID pins execution to a specific connected account.
ConnectedAccountID string `json:"connected_account_id,omitempty"`
// UserID lets Composio resolve the connected account by user when
// the caller does not have the explicit `ca_` id handy.
UserID string `json:"user_id,omitempty"`
// Version pins the tool definition version. Pass "latest" or a dated
// version like "20251027_00"; defaults to "00000000_00" upstream.
//
// The Composio docs note that manual tool execution requires an explicit
// version; setting this avoids unintended drift when Composio promotes
// a new latest.
Version string `json:"version,omitempty"`
// AllowTracing is the upstream-deprecated debug-tracing flag.
//
// Deprecated: marked deprecated on the Composio side (v3.1) — kept here
// only for backward compatibility with existing callers. Will be removed
// once Composio drops the field.
AllowTracing bool `json:"allow_tracing,omitempty"`
}
// ExecuteToolResponse is the typed result. The upstream wire shape varies by
// tool, so [Data] is intentionally generic; callers cast to whatever the
// tool's documented output schema looks like.
type ExecuteToolResponse struct {
Successful bool `json:"successful"`
Data map[string]any `json:"data,omitempty"`
Error string `json:"error,omitempty"`
LogID string `json:"log_id,omitempty"`
SessionInfo map[string]any `json:"session_info,omitempty"`
}
// ExecuteTool calls a Composio tool by its slug
// (SCREAMING_SNAKE_CASE, e.g. GITHUB_CREATE_ISSUE).
//
// This is the deterministic backend path — it skips MCP/session orchestration
// and is the right call for fixed flows like autopilots or built-in skills.
func (c *Client) ExecuteTool(ctx context.Context, toolSlug string, req ExecuteToolRequest) (*ExecuteToolResponse, error) {
if toolSlug == "" {
return nil, errors.New("composio: ExecuteTool: toolSlug is required")
}
if req.ConnectedAccountID == "" && req.UserID == "" {
return nil, errors.New("composio: ExecuteTool: either ConnectedAccountID or UserID must be set")
}
var out ExecuteToolResponse
if err := c.do(c.newRequest(ctx).SetBody(req),
http.MethodPost, "/tools/execute/"+url.PathEscape(toolSlug), &out); err != nil {
return nil, err
}
return &out, nil
}

View File

@@ -0,0 +1,191 @@
package composio
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
)
// Webhook header names Composio sets on every delivery.
const (
HeaderWebhookID = "webhook-id"
HeaderWebhookTimestamp = "webhook-timestamp"
HeaderWebhookSignature = "webhook-signature"
)
// DefaultWebhookTolerance is the default replay window — matches the
// official Composio SDKs (300 s, see Composio webhook docs).
const DefaultWebhookTolerance = 300 * time.Second
// Sentinel errors returned by [VerifyWebhook] so callers can distinguish
// the failure mode with errors.Is.
var (
ErrMissingWebhookHeaders = errors.New("composio: missing webhook headers")
ErrInvalidWebhookSignature = errors.New("composio: invalid webhook signature")
ErrWebhookTimestampStale = errors.New("composio: webhook timestamp outside tolerance")
ErrWebhookSecretMissing = errors.New("composio: webhook secret is empty")
)
// WebhookHeaders carries the three headers that participate in the signature
// computation. Pass these straight from the inbound HTTP request.
type WebhookHeaders struct {
ID string
Timestamp string
Signature string
}
// HeadersFromHTTP pulls the three webhook headers off an http.Header.
// It is case-insensitive (http.Header normalizes its keys).
func HeadersFromHTTP(h http.Header) WebhookHeaders {
return WebhookHeaders{
ID: h.Get(HeaderWebhookID),
Timestamp: h.Get(HeaderWebhookTimestamp),
Signature: h.Get(HeaderWebhookSignature),
}
}
// VerifyOptions tweaks [VerifyWebhook]. Zero values mean defaults.
type VerifyOptions struct {
// Tolerance is how far the webhook-timestamp may drift from `now`.
// Zero means [DefaultWebhookTolerance]; a negative value disables the
// check entirely (useful only for replaying historical deliveries in
// tests).
Tolerance time.Duration
// Now overrides the wall clock used for the tolerance check.
// Tests use this; production should leave it nil.
Now func() time.Time
}
// VerifyWebhook checks the HMAC-SHA256 signature attached by Composio to
// every webhook delivery and enforces a replay-window tolerance.
//
// The signing string is constructed as
//
// "<webhook-id>.<webhook-timestamp>.<rawBody>"
//
// and HMAC-SHA256'd with secret. The result is base64 encoded.
//
// Composio's `webhook-signature` header is a comma-separated list of
// `<version>,<signature>` pairs (e.g. `v1,abc123…`); this function accepts
// any of them whose version starts with "v" so future-proofs work.
//
// secret must be the value from the matching webhook subscription —
// fetch via the Composio dashboard or the
// `GET /webhook_subscriptions/{id}` endpoint.
func VerifyWebhook(secret string, headers WebhookHeaders, rawBody []byte, opts VerifyOptions) error {
if secret == "" {
return ErrWebhookSecretMissing
}
if headers.ID == "" || headers.Timestamp == "" || headers.Signature == "" {
return ErrMissingWebhookHeaders
}
tolerance := opts.Tolerance
if tolerance == 0 {
tolerance = DefaultWebhookTolerance
}
if tolerance > 0 {
ts, err := strconv.ParseInt(headers.Timestamp, 10, 64)
if err != nil {
// Composio's docs show timestamps as Unix seconds, but allow a
// fallback in case future deliveries use RFC3339.
t, terr := time.Parse(time.RFC3339, headers.Timestamp)
if terr != nil {
return fmt.Errorf("composio: invalid webhook-timestamp %q: %w", headers.Timestamp, err)
}
ts = t.Unix()
}
now := time.Now().UTC()
if opts.Now != nil {
now = opts.Now().UTC()
}
delta := now.Sub(time.Unix(ts, 0))
if delta < 0 {
delta = -delta
}
if delta > tolerance {
return fmt.Errorf("%w: drift=%s tolerance=%s", ErrWebhookTimestampStale, delta, tolerance)
}
}
signingString := headers.ID + "." + headers.Timestamp + "." + string(rawBody)
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(signingString))
expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))
// Composio's header takes the form "v1,<sig>[ v2,<sig> ...]" — accept
// any version-tagged signature plus the bare-base64 form for forward-
// compat.
candidates := strings.Fields(strings.ReplaceAll(headers.Signature, ",", " "))
if len(candidates) == 0 {
return ErrInvalidWebhookSignature
}
want := []byte(expected)
for _, cand := range candidates {
// Skip version tags like "v1" / "v2".
if len(cand) <= 3 && strings.HasPrefix(cand, "v") {
continue
}
if hmac.Equal([]byte(cand), want) {
return nil
}
}
return ErrInvalidWebhookSignature
}
// VerifyHTTPRequest is a convenience wrapper that reads & verifies an
// inbound *http.Request in one call. It consumes the body and returns it
// to the caller so the handler can json-decode after a successful verify.
//
// On error the returned body slice is still populated (when read succeeded)
// so handlers can choose to log it.
func VerifyHTTPRequest(secret string, r *http.Request, opts VerifyOptions) ([]byte, error) {
if r == nil || r.Body == nil {
return nil, errors.New("composio: VerifyHTTPRequest: request body is nil")
}
body, err := io.ReadAll(r.Body)
if err != nil {
return nil, fmt.Errorf("composio: read webhook body: %w", err)
}
_ = r.Body.Close()
if verr := VerifyWebhook(secret, HeadersFromHTTP(r.Header), body, opts); verr != nil {
return body, verr
}
return body, nil
}
// --- Event envelope -----------------------------------------------------
// EventEnvelope is the V3 webhook payload as documented by Composio.
//
// Spec: https://docs.composio.dev/docs/setting-up-triggers/subscribing-to-events#webhook-payload-versions
//
// The `data` and `metadata` blocks vary per event; they stay as
// json.RawMessage so callers can decode into a strongly-typed struct
// matching whatever Type they care about.
type EventEnvelope struct {
ID string `json:"id"`
Type string `json:"type"`
Metadata json.RawMessage `json:"metadata,omitempty"`
Data json.RawMessage `json:"data,omitempty"`
Timestamp string `json:"timestamp,omitempty"`
}
// ParseEvent decodes a V3 envelope. It does NOT verify the signature —
// always call [VerifyWebhook] / [VerifyHTTPRequest] first.
func ParseEvent(rawBody []byte) (*EventEnvelope, error) {
var out EventEnvelope
if err := json.Unmarshal(rawBody, &out); err != nil {
return nil, fmt.Errorf("composio: parse webhook envelope: %w", err)
}
return &out, nil
}

View File

@@ -0,0 +1,254 @@
package composio_test
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"errors"
"io"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
"github.com/multica-ai/multica/server/pkg/composio"
)
// helper: produce a valid signature for the given inputs.
func sign(secret, id, ts, body string) string {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(id + "." + ts + "." + body))
return base64.StdEncoding.EncodeToString(mac.Sum(nil))
}
func TestVerifyWebhook_Success(t *testing.T) {
secret := "shh"
body := `{"id":"evt_1","type":"composio.connected_account.expired"}`
id := "msg_abc"
ts := strconv.FormatInt(time.Now().Unix(), 10)
sig := sign(secret, id, ts, body)
err := composio.VerifyWebhook(secret, composio.WebhookHeaders{
ID: id, Timestamp: ts, Signature: "v1," + sig,
}, []byte(body), composio.VerifyOptions{})
if err != nil {
t.Fatalf("VerifyWebhook: %v", err)
}
}
func TestVerifyWebhook_AcceptsBareSignature(t *testing.T) {
secret := "shh"
body := `{}`
id := "msg_b"
ts := strconv.FormatInt(time.Now().Unix(), 10)
sig := sign(secret, id, ts, body)
// No version prefix: just the raw base64
err := composio.VerifyWebhook(secret, composio.WebhookHeaders{
ID: id, Timestamp: ts, Signature: sig,
}, []byte(body), composio.VerifyOptions{})
if err != nil {
t.Fatalf("VerifyWebhook bare: %v", err)
}
}
func TestVerifyWebhook_AcceptsMultipleVersions(t *testing.T) {
secret := "shh"
body := `{}`
id := "msg_c"
ts := strconv.FormatInt(time.Now().Unix(), 10)
good := sign(secret, id, ts, body)
bad := "AAAA" + good[4:]
// One bad sig, one good sig — verify should still pass.
hdr := "v2," + bad + " v1," + good
err := composio.VerifyWebhook(secret, composio.WebhookHeaders{
ID: id, Timestamp: ts, Signature: hdr,
}, []byte(body), composio.VerifyOptions{})
if err != nil {
t.Fatalf("VerifyWebhook multi: %v", err)
}
}
func TestVerifyWebhook_RejectsTamperedBody(t *testing.T) {
secret := "shh"
body := `{"data":"original"}`
id := "msg_d"
ts := strconv.FormatInt(time.Now().Unix(), 10)
sig := sign(secret, id, ts, body)
err := composio.VerifyWebhook(secret, composio.WebhookHeaders{
ID: id, Timestamp: ts, Signature: "v1," + sig,
}, []byte(`{"data":"tampered"}`), composio.VerifyOptions{})
if !errors.Is(err, composio.ErrInvalidWebhookSignature) {
t.Fatalf("expected ErrInvalidWebhookSignature, got %v", err)
}
}
func TestVerifyWebhook_RejectsStaleTimestamp(t *testing.T) {
secret := "shh"
body := `{}`
id := "msg_e"
old := time.Now().Add(-10 * time.Minute).Unix()
ts := strconv.FormatInt(old, 10)
sig := sign(secret, id, ts, body)
err := composio.VerifyWebhook(secret, composio.WebhookHeaders{
ID: id, Timestamp: ts, Signature: "v1," + sig,
}, []byte(body), composio.VerifyOptions{Tolerance: 5 * time.Minute})
if !errors.Is(err, composio.ErrWebhookTimestampStale) {
t.Fatalf("expected ErrWebhookTimestampStale, got %v", err)
}
}
func TestVerifyWebhook_NegativeToleranceDisablesCheck(t *testing.T) {
secret := "shh"
body := `{}`
id := "msg_f"
ts := "1" // ancient
sig := sign(secret, id, ts, body)
err := composio.VerifyWebhook(secret, composio.WebhookHeaders{
ID: id, Timestamp: ts, Signature: "v1," + sig,
}, []byte(body), composio.VerifyOptions{Tolerance: -1})
if err != nil {
t.Fatalf("VerifyWebhook negative tolerance: %v", err)
}
}
func TestVerifyWebhook_HonorsCustomNow(t *testing.T) {
secret := "shh"
body := `{}`
id := "msg_g"
ts := "1700000000"
sig := sign(secret, id, ts, body)
err := composio.VerifyWebhook(secret, composio.WebhookHeaders{
ID: id, Timestamp: ts, Signature: "v1," + sig,
}, []byte(body), composio.VerifyOptions{
Tolerance: 5 * time.Second,
Now: func() time.Time { return time.Unix(1700000003, 0) },
})
if err != nil {
t.Fatalf("expected fresh timestamp, got %v", err)
}
}
func TestVerifyWebhook_MissingHeaders(t *testing.T) {
err := composio.VerifyWebhook("shh", composio.WebhookHeaders{}, []byte(`{}`), composio.VerifyOptions{})
if !errors.Is(err, composio.ErrMissingWebhookHeaders) {
t.Fatalf("expected ErrMissingWebhookHeaders, got %v", err)
}
}
func TestVerifyWebhook_EmptySecret(t *testing.T) {
err := composio.VerifyWebhook("", composio.WebhookHeaders{
ID: "x", Timestamp: "1", Signature: "v1,xyz",
}, []byte(`{}`), composio.VerifyOptions{})
if !errors.Is(err, composio.ErrWebhookSecretMissing) {
t.Fatalf("expected ErrWebhookSecretMissing, got %v", err)
}
}
func TestVerifyWebhook_AcceptsRFC3339Timestamp(t *testing.T) {
secret := "shh"
body := `{}`
id := "msg_h"
now := time.Now().UTC().Format(time.RFC3339)
sig := sign(secret, id, now, body)
err := composio.VerifyWebhook(secret, composio.WebhookHeaders{
ID: id, Timestamp: now, Signature: "v1," + sig,
}, []byte(body), composio.VerifyOptions{})
if err != nil {
t.Fatalf("VerifyWebhook rfc3339: %v", err)
}
}
func TestVerifyHTTPRequest_HappyPath(t *testing.T) {
secret := "shh"
body := `{"x":1}`
id := "msg_req"
ts := strconv.FormatInt(time.Now().Unix(), 10)
sig := sign(secret, id, ts, body)
r := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader([]byte(body)))
r.Header.Set(composio.HeaderWebhookID, id)
r.Header.Set(composio.HeaderWebhookTimestamp, ts)
r.Header.Set(composio.HeaderWebhookSignature, "v1,"+sig)
got, err := composio.VerifyHTTPRequest(secret, r, composio.VerifyOptions{})
if err != nil {
t.Fatalf("VerifyHTTPRequest: %v", err)
}
if string(got) != body {
t.Errorf("body roundtrip mismatch: %q vs %q", got, body)
}
}
func TestVerifyHTTPRequest_ReturnsBodyOnFailure(t *testing.T) {
body := `{"x":1}`
r := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader([]byte(body)))
r.Header.Set(composio.HeaderWebhookID, "id")
r.Header.Set(composio.HeaderWebhookTimestamp, strconv.FormatInt(time.Now().Unix(), 10))
r.Header.Set(composio.HeaderWebhookSignature, "v1,deadbeef")
got, err := composio.VerifyHTTPRequest("shh", r, composio.VerifyOptions{})
if err == nil {
t.Fatal("expected error")
}
if string(got) != body {
t.Errorf("expected body returned for logging, got %q", got)
}
}
func TestVerifyHTTPRequest_NilBody(t *testing.T) {
r := &http.Request{}
_, err := composio.VerifyHTTPRequest("shh", r, composio.VerifyOptions{})
if err == nil {
t.Fatal("expected error for nil body")
}
}
// Sanity check: io.ReadAll still gets the same body bytes via our helper.
func TestVerifyHTTPRequest_BodyReadFully(t *testing.T) {
body := "{}"
r := httptest.NewRequest(http.MethodPost, "/", io.NopCloser(bytes.NewReader([]byte(body))))
ts := strconv.FormatInt(time.Now().Unix(), 10)
r.Header.Set(composio.HeaderWebhookID, "id")
r.Header.Set(composio.HeaderWebhookTimestamp, ts)
r.Header.Set(composio.HeaderWebhookSignature, "v1,"+sign("shh", "id", ts, body))
got, err := composio.VerifyHTTPRequest("shh", r, composio.VerifyOptions{})
if err != nil {
t.Fatalf("VerifyHTTPRequest: %v", err)
}
if string(got) != body {
t.Errorf("body = %q, want %q", got, body)
}
}
// ---------------------------------------------------------------------------
// Event envelope
// ---------------------------------------------------------------------------
func TestParseEvent_V3Envelope(t *testing.T) {
raw := []byte(`{
"id": "evt_1",
"type": "composio.connected_account.expired",
"metadata": {"project_id":"pr_a","user_id":"u_1"},
"data": {"id":"ca_1","status":"EXPIRED"},
"timestamp": "2026-02-06T12:00:00Z"
}`)
ev, err := composio.ParseEvent(raw)
if err != nil {
t.Fatalf("ParseEvent: %v", err)
}
if ev.ID != "evt_1" || ev.Type != "composio.connected_account.expired" {
t.Errorf("unexpected envelope: %+v", ev)
}
if !bytes.Contains(ev.Data, []byte(`"EXPIRED"`)) {
t.Errorf("data lost: %s", ev.Data)
}
}
func TestParseEvent_RejectsGarbage(t *testing.T) {
if _, err := composio.ParseEvent([]byte(`not-json`)); err == nil {
t.Error("expected error for non-JSON body")
}
}

View File

@@ -12,7 +12,8 @@
"COMPOSE_PROJECT_NAME",
"POSTGRES_DB",
"POSTGRES_PORT",
"DESKTOP_RENDERER_PORT"
"DESKTOP_RENDERER_PORT",
"DESKTOP_APP_SUFFIX"
],
"tasks": {
"build": {