mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-01 03:19:13 +02:00
Compare commits
8 Commits
agent/lamb
...
docs/claud
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
977188dac1 | ||
|
|
9c1d8d2659 | ||
|
|
6dcf82a58a | ||
|
|
8d0ea04fb0 | ||
|
|
714f9b1ab7 | ||
|
|
553419f8ef | ||
|
|
73b9a41260 | ||
|
|
cd6cd9dcd1 |
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
36
Makefile
36
Makefile
@@ -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 ----------
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
53
apps/desktop/scripts/dev.mjs
Normal file
53
apps/desktop/scripts/dev.mjs
Normal 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 });
|
||||
116
apps/desktop/scripts/worktree-dev-env.mjs
Normal file
116
apps/desktop/scripts/worktree-dev-env.mjs
Normal 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 5174–6173.
|
||||
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;
|
||||
}
|
||||
101
apps/desktop/scripts/worktree-dev-env.test.mjs
Normal file
101
apps/desktop/scripts/worktree-dev-env.test.mjs
Normal 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));
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
116
apps/docs/components/video-embed.tsx
Normal file
116
apps/docs/components/video-embed.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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。
|
||||
|
||||
## 智能体在哪里运行
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
159
server/pkg/composio/README.md
Normal file
159
server/pkg/composio/README.md
Normal 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
|
||||
165
server/pkg/composio/client.go
Normal file
165
server/pkg/composio/client.go
Normal 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
|
||||
}
|
||||
567
server/pkg/composio/client_test.go
Normal file
567
server/pkg/composio/client_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
198
server/pkg/composio/connected_accounts.go
Normal file
198
server/pkg/composio/connected_accounts.go
Normal 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
|
||||
}
|
||||
56
server/pkg/composio/doc.go
Normal file
56
server/pkg/composio/doc.go
Normal 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
|
||||
88
server/pkg/composio/errors.go
Normal file
88
server/pkg/composio/errors.go
Normal 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
|
||||
}
|
||||
99
server/pkg/composio/sessions.go
Normal file
99
server/pkg/composio/sessions.go
Normal 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()
|
||||
}
|
||||
78
server/pkg/composio/toolkits.go
Normal file
78
server/pkg/composio/toolkits.go
Normal 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
|
||||
}
|
||||
74
server/pkg/composio/tools.go
Normal file
74
server/pkg/composio/tools.go
Normal 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
|
||||
}
|
||||
191
server/pkg/composio/webhook.go
Normal file
191
server/pkg/composio/webhook.go
Normal 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
|
||||
}
|
||||
254
server/pkg/composio/webhook_test.go
Normal file
254
server/pkg/composio/webhook_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,8 @@
|
||||
"COMPOSE_PROJECT_NAME",
|
||||
"POSTGRES_DB",
|
||||
"POSTGRES_PORT",
|
||||
"DESKTOP_RENDERER_PORT"
|
||||
"DESKTOP_RENDERER_PORT",
|
||||
"DESKTOP_APP_SUFFIX"
|
||||
],
|
||||
"tasks": {
|
||||
"build": {
|
||||
|
||||
Reference in New Issue
Block a user