From 7e8ecc3185b482a7e5427861392b2b6471524a3a Mon Sep 17 00:00:00 2001 From: yujiezhang-ops Date: Wed, 3 Jun 2026 14:44:17 +0800 Subject: [PATCH] Add Multica read-only client Co-authored-by: multica-agent --- src/launch-review.js | 2 +- src/multica-client.js | 244 +++++++++++++++++++++++++++++++++++++ src/multica-client.test.js | 242 ++++++++++++++++++++++++++++++++++++ 3 files changed, 487 insertions(+), 1 deletion(-) create mode 100644 src/multica-client.js create mode 100644 src/multica-client.test.js diff --git a/src/launch-review.js b/src/launch-review.js index 55fe24d..6d81d8a 100644 --- a/src/launch-review.js +++ b/src/launch-review.js @@ -2,7 +2,7 @@ import { createHash } from "node:crypto"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname } from "node:path"; -const SECRET_ENV_PATTERNS = [ +export const SECRET_ENV_PATTERNS = [ "TOKEN", "SECRET", "PASSWORD", diff --git a/src/multica-client.js b/src/multica-client.js new file mode 100644 index 0000000..69f61e3 --- /dev/null +++ b/src/multica-client.js @@ -0,0 +1,244 @@ +import { spawn } from "node:child_process"; + +import { SECRET_ENV_PATTERNS } from "./launch-review.js"; + +export function createMulticaClient({ exec, cliPath = "multica", timeoutMs = 15000 } = {}) { + const run = exec ?? createDefaultExec({ cliPath, timeoutMs }); + + return { + getIssue(issueId) { + return readJson(run, ["issue", "get", issueId, "--output", "json"], (raw, warnings) => ( + normalizeIssue(raw, warnings) + )); + }, + getAgent(agentId) { + return readJson(run, ["agent", "get", agentId, "--output", "json"], (raw, warnings) => ( + normalizeAgent(raw, warnings) + )); + }, + getRuntime(runtimeId) { + return readJson(run, ["runtime", "list", "--output", "json"], (raw, warnings) => { + const runtimes = extractCollection(raw, ["runtimes", "items", "data"]); + const runtime = runtimes.find((item) => item?.id === runtimeId || item?.runtime_id === runtimeId); + if (!runtime) { + warnings.push("missing:runtime"); + } + return normalizeRuntime(runtime ?? {}, warnings); + }); + }, + getSkills(agentId) { + return readJson(run, ["agent", "skills", "list", agentId, "--output", "json"], (raw, warnings) => ( + extractCollection(raw, ["skills", "items", "data"]).map((skill, index) => ( + normalizeSkill(unwrapSkill(skill), warnings, `skills.${index}`) + )) + )); + }, + }; +} + +function createDefaultExec({ cliPath, timeoutMs }) { + return (args) => new Promise((resolve, reject) => { + const child = spawn(cliPath, args, { windowsHide: true }); + let stdout = ""; + let stderr = ""; + let timedOut = false; + + const timer = setTimeout(() => { + timedOut = true; + child.kill(); + }, timeoutMs); + + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + child.on("error", reject); + child.on("close", (code) => { + clearTimeout(timer); + resolve({ + stdout, + stderr: timedOut ? `command timed out after ${timeoutMs}ms` : stderr, + code: timedOut ? 124 : code, + }); + }); + }); +} + +async function readJson(exec, args, normalize) { + let result; + try { + result = await exec(args); + } catch (error) { + return hardFailure(error.message || String(error)); + } + + if (result.code !== 0) { + return hardFailure(result.stderr?.trim() || result.stdout?.trim() || `multica exited with code ${result.code}`); + } + + let raw; + try { + raw = JSON.parse(result.stdout); + } catch (error) { + return hardFailure(`invalid json: ${error.message}`); + } + + const warnings = []; + return { + ok: true, + data: normalize(raw, warnings), + warnings, + error: null, + }; +} + +function hardFailure(error) { + return { + ok: false, + data: null, + warnings: [], + error, + }; +} + +function normalizeIssue(raw, warnings) { + return { + id: readString(raw, ["id"], "issue.id", warnings), + identifier: readString(raw, ["identifier", "key"], "issue.identifier", warnings), + number: readValue(raw, ["number"], "issue.number", warnings, ""), + title: readString(raw, ["title"], "issue.title", warnings), + description: readString(raw, ["description"], "issue.description", warnings), + status: readString(raw, ["status"], "issue.status", warnings), + priority: readString(raw, ["priority"], "issue.priority", warnings), + assigneeId: readString(raw, ["assigneeId", "assignee_id"], "issue.assigneeId", warnings), + assigneeType: readString(raw, ["assigneeType", "assignee_type"], "issue.assigneeType", warnings), + parentIssueId: readString(raw, ["parentIssueId", "parent_issue_id"], "issue.parentIssueId", warnings), + projectId: readString(raw, ["projectId", "project_id"], "issue.projectId", warnings), + workspaceId: readString(raw, ["workspaceId", "workspace_id"], "issue.workspaceId", warnings), + labels: readArray(raw, ["labels"], "issue.labels", warnings), + metadata: readObject(raw, ["metadata"], "issue.metadata", warnings), + createdAt: readString(raw, ["createdAt", "created_at"], "issue.createdAt", warnings), + updatedAt: readString(raw, ["updatedAt", "updated_at"], "issue.updatedAt", warnings), + }; +} + +function normalizeAgent(raw, warnings) { + return { + id: readString(raw, ["id"], "agent.id", warnings), + name: readString(raw, ["name"], "agent.name", warnings), + provider: readString(raw, ["provider"], "agent.provider", warnings), + model: readString(raw, ["model"], "agent.model", warnings), + runtimeId: readString(raw, ["runtimeId", "runtime_id"], "agent.runtimeId", warnings), + instructions: readString(raw, ["instructions"], "agent.instructions", warnings), + skills: readArray(raw, ["skills"], "agent.skills", warnings).map((skill, index) => ( + normalizeSkill(unwrapSkill(skill), warnings, `agent.skills.${index}`) + )), + env: normalizeEnv(readObject(raw, ["customEnv", "custom_env", "env"], "agent.env", warnings)), + mcpServers: normalizeMcpServers(readValue(raw, ["mcpServers", "mcp_servers"], "agent.mcpServers", warnings, [])), + }; +} + +function normalizeRuntime(raw, warnings) { + return { + id: readString(raw, ["id", "runtime_id", "runtimeId"], "runtime.id", warnings), + provider: readString(raw, ["provider"], "runtime.provider", warnings), + model: readString(raw, ["model"], "runtime.model", warnings), + name: readString(raw, ["name"], "runtime.name", warnings), + }; +} + +function normalizeSkill(raw, warnings, path) { + return { + name: readString(raw, ["name"], `${path}.name`, warnings), + version: readString(raw, ["version"], `${path}.version`, warnings), + description: readString(raw, ["description"], `${path}.description`, warnings), + permissions: readArray(raw, ["permissions"], `${path}.permissions`, warnings), + riskLevel: readString(raw, ["riskLevel", "risk_level"], `${path}.riskLevel`, warnings), + }; +} + +function normalizeEnv(env) { + const keys = Object.keys(env).sort(); + return { + keys, + secretKeys: keys.filter(isSecretEnvKey), + }; +} + +function normalizeMcpServers(servers) { + if (Array.isArray(servers)) { + return servers.slice().sort(); + } + if (servers && typeof servers === "object") { + return Object.keys(servers).sort(); + } + return []; +} + +function unwrapSkill(value) { + if (value?.skill && typeof value.skill === "object") { + return value.skill; + } + return value ?? {}; +} + +function extractCollection(raw, keys) { + if (Array.isArray(raw)) { + return raw; + } + if (!raw || typeof raw !== "object") { + return []; + } + for (const key of keys) { + if (Array.isArray(raw[key])) { + return raw[key]; + } + } + return []; +} + +function readString(raw, candidates, path, warnings) { + const value = readValue(raw, candidates, path, warnings, ""); + return value == null ? "" : String(value); +} + +function readArray(raw, candidates, path, warnings) { + const value = readValue(raw, candidates, path, warnings, []); + if (Array.isArray(value)) { + return value.slice(); + } + warnings.push(`missing:${path}`); + return []; +} + +function readObject(raw, candidates, path, warnings) { + const value = readValue(raw, candidates, path, warnings, {}); + if (value && typeof value === "object" && !Array.isArray(value)) { + return { ...value }; + } + warnings.push(`missing:${path}`); + return {}; +} + +function readValue(raw, candidates, path, warnings, fallback) { + if (!raw || typeof raw !== "object") { + warnings.push(`missing:${path}`); + return fallback; + } + for (const key of candidates) { + if (Object.hasOwn(raw, key) && raw[key] !== undefined) { + return raw[key] ?? fallback; + } + } + warnings.push(`missing:${path}`); + return fallback; +} + +function isSecretEnvKey(key) { + const upper = key.toUpperCase(); + return SECRET_ENV_PATTERNS.some((pattern) => upper.includes(pattern)); +} diff --git a/src/multica-client.test.js b/src/multica-client.test.js new file mode 100644 index 0000000..f72657a --- /dev/null +++ b/src/multica-client.test.js @@ -0,0 +1,242 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createMulticaClient } from "./multica-client.js"; + +test("normalizes issue, agent, runtime, and skills from read-only CLI responses", async () => { + const calls = []; + const exec = async (args) => { + calls.push(args); + if (args[0] === "issue") { + return jsonResult({ + id: "issue-1", + identifier: "SPA-4", + number: 4, + title: "Build client", + description: "Read Multica data.", + status: "in_progress", + priority: "high", + assignee_id: "agent-1", + assignee_type: "agent", + parent_issue_id: "parent-1", + project_id: "project-1", + workspace_id: "workspace-1", + labels: [{ name: "m1" }], + metadata: { depends_on: "none" }, + created_at: "2026-06-01T00:00:00Z", + updated_at: "2026-06-02T00:00:00Z", + }); + } + if (args[0] === "agent" && args[1] === "get") { + return jsonResult({ + id: "agent-1", + name: "Codex Worker", + provider: "openai", + model: "gpt-5", + runtime_id: "runtime-1", + instructions: "Work carefully.", + skills: [ + { + name: "test-driven-development", + version: "1.0.0", + description: "Write tests first.", + permissions: ["shell:read"], + risk_level: "low", + }, + ], + custom_env: { + OPENAI_API_KEY: "sk-should-not-leak", + FEATURE_FLAG: "enabled", + }, + mcp_servers: { filesystem: {} }, + }); + } + if (args[0] === "runtime") { + return jsonResult([ + { id: "runtime-1", provider: "openai", model: "gpt-5", name: "Local Runtime" }, + { id: "runtime-2", provider: "anthropic", model: "claude", name: "Other Runtime" }, + ]); + } + if (args[0] === "agent" && args[1] === "skills") { + return jsonResult([ + { + name: "systematic-debugging", + version: "2.0.0", + description: "Debug in order.", + permissions: ["shell:read"], + riskLevel: "medium", + }, + ]); + } + throw new Error(`unexpected call: ${args.join(" ")}`); + }; + + const client = createMulticaClient({ exec }); + + assert.deepEqual(await client.getIssue("issue-1"), { + ok: true, + data: { + id: "issue-1", + identifier: "SPA-4", + number: 4, + title: "Build client", + description: "Read Multica data.", + status: "in_progress", + priority: "high", + assigneeId: "agent-1", + assigneeType: "agent", + parentIssueId: "parent-1", + projectId: "project-1", + workspaceId: "workspace-1", + labels: [{ name: "m1" }], + metadata: { depends_on: "none" }, + createdAt: "2026-06-01T00:00:00Z", + updatedAt: "2026-06-02T00:00:00Z", + }, + warnings: [], + error: null, + }); + + const agent = await client.getAgent("agent-1"); + assert.deepEqual(agent, { + ok: true, + data: { + id: "agent-1", + name: "Codex Worker", + provider: "openai", + model: "gpt-5", + runtimeId: "runtime-1", + instructions: "Work carefully.", + skills: [ + { + name: "test-driven-development", + version: "1.0.0", + description: "Write tests first.", + permissions: ["shell:read"], + riskLevel: "low", + }, + ], + env: { + keys: ["FEATURE_FLAG", "OPENAI_API_KEY"], + secretKeys: ["OPENAI_API_KEY"], + }, + mcpServers: ["filesystem"], + }, + warnings: [], + error: null, + }); + assert.equal(JSON.stringify(agent).includes("sk-should-not-leak"), false); + assert.equal(JSON.stringify(agent).includes("enabled"), false); + + assert.deepEqual(await client.getRuntime("runtime-1"), { + ok: true, + data: { + id: "runtime-1", + provider: "openai", + model: "gpt-5", + name: "Local Runtime", + }, + warnings: [], + error: null, + }); + + assert.deepEqual(await client.getSkills("agent-1"), { + ok: true, + data: [ + { + name: "systematic-debugging", + version: "2.0.0", + description: "Debug in order.", + permissions: ["shell:read"], + riskLevel: "medium", + }, + ], + warnings: [], + error: null, + }); + + assert.deepEqual(calls, [ + ["issue", "get", "issue-1", "--output", "json"], + ["agent", "get", "agent-1", "--output", "json"], + ["runtime", "list", "--output", "json"], + ["agent", "skills", "list", "agent-1", "--output", "json"], + ]); +}); + +test("uses safe defaults and warnings when response fields are missing", async () => { + const client = createMulticaClient({ + exec: async () => jsonResult({ id: "issue-1" }), + }); + + const result = await client.getIssue("issue-1"); + + assert.equal(result.ok, true); + assert.deepEqual(result.data, { + id: "issue-1", + identifier: "", + number: "", + title: "", + description: "", + status: "", + priority: "", + assigneeId: "", + assigneeType: "", + parentIssueId: "", + projectId: "", + workspaceId: "", + labels: [], + metadata: {}, + createdAt: "", + updatedAt: "", + }); + assert.deepEqual(result.warnings, [ + "missing:issue.identifier", + "missing:issue.number", + "missing:issue.title", + "missing:issue.description", + "missing:issue.status", + "missing:issue.priority", + "missing:issue.assigneeId", + "missing:issue.assigneeType", + "missing:issue.parentIssueId", + "missing:issue.projectId", + "missing:issue.workspaceId", + "missing:issue.labels", + "missing:issue.metadata", + "missing:issue.createdAt", + "missing:issue.updatedAt", + ]); + assert.equal(result.error, null); +}); + +test("returns a hard-failure envelope for non-json stdout", async () => { + const client = createMulticaClient({ + exec: async () => ({ stdout: "not json", stderr: "", code: 0 }), + }); + + const result = await client.getAgent("agent-1"); + + assert.equal(result.ok, false); + assert.equal(result.data, null); + assert.deepEqual(result.warnings, []); + assert.match(result.error, /invalid json/i); +}); + +test("returns a hard-failure envelope for non-zero exit", async () => { + const client = createMulticaClient({ + exec: async () => ({ stdout: "", stderr: "agent not found", code: 1 }), + }); + + const result = await client.getSkills("agent-1"); + + assert.deepEqual(result, { + ok: false, + data: null, + warnings: [], + error: "agent not found", + }); +}); + +function jsonResult(value) { + return { stdout: JSON.stringify(value), stderr: "", code: 0 }; +}