mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-10 18:36:45 +08:00
* feat(plugins): skill resources into the existing skill table (MUL-6350 PR 4, 1/3) A plugin's skill resource becomes an ordinary row in the skill table. Not a plugin-owned copy, not a bundle, not an artifact with a digest: the previous plugin system built all of that across fourteen tables and, at the end of it, delivered one SKILL.md that this table could already hold. The only thing the platform has to remember is which installation contributed which skill, so uninstall removes exactly those and nothing a person wrote. That is one nullable column. The upsert is scoped to rows this installation owns, so a plugin claiming a name somebody already used fails the install instead of silently replacing their work — and the test asserts the human's content survives. Upgrade prunes what the new manifest no longer declares, or a renamed skill would leave its predecessor behind with nothing to attribute it to. Fresh install is now a transaction too. It was a single INSERT; skills have to land in the same commit, because an installation whose skills half-arrived is worse than one that failed — the missing half is invisible. Skill resources flip on here rather than with the rest of PR 4: nothing executes, so it does not wait for the daemon-side MCP server that the agent trigger and mcp transport still need. Co-authored-by: multica-agent <[email protected]> * feat(plugins): agent trigger — hooks as MCP tools (MUL-6350 PR 4, 2/3) The fourth call site, and the only one that is not the host deciding to call something: an agent reads a tool description and chooses. That choice is why hooks may be reached from an agent at all — the alternative, a hook that must run before or after every turn, is a third party holding the product's main loop open, and no such position exists anywhere in this design. The daemon renders the tools but does not call the plugin. A tool call goes back to Multica, which makes the signed request. Two reasons, and the second matters more: the signing secret is derived from the deployment key and putting it on every laptop running an agent would hand out a credential that can impersonate the server to every plugin backend; and routing through the server keeps the rate limit, circuit breaker, `net:` destination check and invocation record on one code path for all four triggers instead of a daemon-side copy that would drift. A failing hook is a TOOL error, not a transport error. The agent reads it, works around it, and finishes the issue — an unreachable plugin endpoint must not fail somebody's task. The server returns 200 with a status for the same reason: a non-2xx would look to the daemon like the broker itself is broken. TestPluginToolNameSeparatorCannotBeForged caught a real flaw in my first naming scheme. Sanitizing both halves and joining with an underscore is not injective — a plugin key uses `.` and `-`, both of which have to become `_`, so `a.b` and `a-b` collapse together and `a.b_` + `c` collides with `a.b` + `c`. Two plugins would have been offering the agent one tool name. The prefix now carries a short digest of the full key rather than a lossy transliteration. Co-authored-by: multica-agent <[email protected]> * feat(plugins): mcp transport with pinned tool approval (MUL-6350 PR 4, 3/3) An `mcp` hook adopts tools from an MCP server the plugin author already runs. The difference from an `http` hook is who decides the shape: an http hook declares one endpoint in a manifest an administrator read, while an MCP server decides its own tool list at runtime. So installing the plugin is not the grant — an administrator discovers the tools and pins a specific set by name and schema digest, and a tool that appears later is not adopted until approved. Approved hooks reach the daemon as ordinary remotemcp.Connection values, so the existing broker proxies them and validatePinnedRemoteMCPTools already refuses to start when an approved tool went missing or its schema drifted. No new enforcement path. Plugin connections carry a `plugin:` contribution-id prefix. They share the broker and the resolver with a workspace's own Remote MCP connections but keep their credential in the Plugin's secret storage, which a different route serves, and the contribution id is all the broker hands back at dial time. Flips TransportMCP on in HostCapabilities, completing PR 4's capability set. Co-authored-by: multica-agent <[email protected]> * test(plugins): cover mcp connection startup via the dev-origin seam The `http` transport has had MULTICA_PLUGIN_DEV_ORIGINS since the hook engine landed: an operator names exact origins, and endpoints there skip the public-internet requirement so a plugin author can develop against a server on their own machine. The `mcp` transport never got it. Two symptoms, one cause. A plugin author could not point an mcp hook at a local MCP server, and — for the same reason — the daemon's connection startup could not be reached by a test, because it validates the endpoint as public HTTPS and the repo's MCP fixture serves plain HTTP on loopback. That left the layer carrying the entire approval guarantee at 0% coverage: startTaskRemoteMCPBrokers 0.0% -> 59.5% validatePinnedRemoteMCPTools 0.0% -> 100.0% The seam is off unless the variable is set, and a test asserts that: the same loopback connection is refused when it is empty. It skips only the public-internet requirement — the net: scope still applies, and the dial-time host pin still refuses a redirect that changes host. Tests cover what the approval is for: an approved tool the server dropped, an approved tool whose schema drifted, and an optional connection that fails without failing the task. Each was verified by breaking the guard it covers. Co-authored-by: multica-agent <[email protected]> * feat(plugins): a real example plugin, and the two bugs it found examples/plugins/deploy-sentinel is an incident-response plugin: it correlates an issue with recent deploys, files rollback requests under the team's own rules (refusing deploys past a window, and reasons too short to be evidence), pages on-call on issue events, and adopts an external metrics MCP server. Four triggers, both transports, and a skill that tells an agent when to use any of it. The other examples each demonstrate one mechanism. This one was written to find out whether the mechanisms compose, and it found two things that no unit test was ever going to: 1. mcp discovery could not succeed against ANY server. DiscoverMCPHookTools passed a nil protocol-version list, and Discover checks the server's answer against that same slice — empty means nothing is acceptable. Discover now falls back to SupportedProtocolVersions() when the caller passes none. 2. A hook handler had no way to read its own configuration. The manifest's config block drives a host-rendered form, the Action API has no config endpoint, and the hook body did not carry the values — so a plugin would have had to keep a second copy on its own server. The body now carries non-secret config. Secret-typed fields are excluded; they live in a separate table and are the plugin's credentials for its own services. Also lets an mcp dev origin trust MULTICA_PLUGIN_DEV_CA, mirroring the http transport. The manifest validator requires HTTPS, so without this a local MCP server could not be developed against at all. plugin_example_test.go installs the real manifest from examples/, not a copy, so a broken example fails the build rather than rotting quietly. Migrations renumbered to 368/369 after main advanced. 362 is added to the legacy-duplicate allowlist: two PRs took that prefix within the same hour and both merged, and the ledger keys on the full stem, so renaming one would re-run it everywhere it has already applied. main is red on this today. Co-authored-by: multica-agent <[email protected]> * fix(plugins): the deploy-sentinel example servers must serve HTTPS Both .mjs servers used node:http. A hook's transport URL has to be an https:// URL or the manifest fails validation, so the example as shipped could not actually be installed against a running Multica — the README told you to set MULTICA_PLUGIN_DEV_ORIGINS to https:// origins that nothing was listening on. Found by running it: install, config, skill contribution and live MCP tool discovery all work against the real servers once they speak TLS. They read TLS_CERT / TLS_KEY and the README carries the openssl one-liner. MULTICA_PLUGIN_DEV_CA changes which certificate is trusted; it never disables verification, which is why a self-signed certificate is needed rather than plain HTTP. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Lambda <[email protected]> Co-authored-by: multica-agent <[email protected]>
228 lines
9.5 KiB
JavaScript
228 lines
9.5 KiB
JavaScript
// Deploy Sentinel's own server. Multica never runs this — the plugin author does.
|
|
//
|
|
// Everything a real hook backend has to get right is here and nowhere else:
|
|
// verifying the signature, refusing a replay, applying the team's own rules, and
|
|
// using the per-invocation callback token to write back to the issue.
|
|
//
|
|
// node server/handler.mjs
|
|
//
|
|
// Env: MULTICA_SIGNING_SECRET (the whsec_… shown once when the token was issued)
|
|
// PORT (default 8788)
|
|
|
|
import { createServer } from "node:https";
|
|
import { readFileSync } from "node:fs";
|
|
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
|
|
// HTTPS, not HTTP. A hook's transport URL must be an https:// URL or the
|
|
// manifest will not install, and MULTICA_PLUGIN_DEV_CA only changes WHICH
|
|
// certificate Multica trusts — it never turns verification off. Generate one:
|
|
//
|
|
// openssl req -x509 -newkey rsa:2048 -nodes -days 365 \
|
|
// -keyout dev-key.pem -out dev-cert.pem \
|
|
// -subj "/CN=127.0.0.1" -addext "subjectAltName=IP:127.0.0.1"
|
|
//
|
|
// then point MULTICA_PLUGIN_DEV_CA at dev-cert.pem.
|
|
function tlsOptions() {
|
|
const cert = process.env.TLS_CERT ?? "dev-cert.pem";
|
|
const key = process.env.TLS_KEY ?? "dev-key.pem";
|
|
try {
|
|
return { cert: readFileSync(cert), key: readFileSync(key) };
|
|
} catch (error) {
|
|
console.error(`Could not read ${cert} / ${key}: ${error.message}`);
|
|
console.error("See the README for the openssl one-liner that generates them.");
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
const PORT = Number(process.env.PORT ?? 8788);
|
|
const SIGNING_SECRET = process.env.MULTICA_SIGNING_SECRET ?? "";
|
|
const REPLAY_WINDOW_SECONDS = 300;
|
|
|
|
// Stand-in for the deploy system this plugin would really talk to. Deploy ids
|
|
// are stable so the README's walkthrough is reproducible.
|
|
const DEPLOYS = [
|
|
{ id: "d-4821", service: "checkout-api", minutes_ago: 4, commit: "9f2c1ab", author: "rivera", blast_radius: "all checkout traffic", touches: ["retry path", "timeout config"] },
|
|
{ id: "d-4820", service: "checkout-api", minutes_ago: 51, commit: "1de77c0", author: "okafor", blast_radius: "checkout traffic in EU", touches: ["currency formatting"] },
|
|
{ id: "d-4816", service: "search-api", minutes_ago: 92, commit: "aa30f41", author: "rivera", blast_radius: "search suggestions", touches: ["index warmup"] },
|
|
{ id: "d-4802", service: "checkout-api", minutes_ago: 380, commit: "77b9e2d", author: "chen", blast_radius: "all checkout traffic", touches: ["payment provider client"] },
|
|
];
|
|
|
|
const filedRollbacks = new Map();
|
|
|
|
function verifySignature(rawBody, signature, timestamp) {
|
|
if (!SIGNING_SECRET) return { ok: false, reason: "server has no signing secret configured" };
|
|
if (!signature || !timestamp) return { ok: false, reason: "missing signature headers" };
|
|
|
|
// Reject a replay before spending time on the comparison. Multica signs
|
|
// timestamp + body precisely so an old, validly-signed request cannot be
|
|
// resent later.
|
|
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
|
|
if (!Number.isFinite(age) || age > REPLAY_WINDOW_SECONDS) {
|
|
return { ok: false, reason: "timestamp outside the replay window" };
|
|
}
|
|
|
|
const expected = createHmac("sha256", SIGNING_SECRET).update(`${timestamp}.${rawBody}`).digest("hex");
|
|
const provided = Buffer.from(signature, "utf8");
|
|
const computed = Buffer.from(expected, "utf8");
|
|
// Length check first: timingSafeEqual throws on a mismatch rather than
|
|
// returning false, and the length is not the secret.
|
|
if (provided.length !== computed.length || !timingSafeEqual(provided, computed)) {
|
|
return { ok: false, reason: "signature mismatch" };
|
|
}
|
|
return { ok: true };
|
|
}
|
|
|
|
// The callback token is scoped to THIS invocation and is revoked the moment the
|
|
// hook returns, so anything the handler wants to write has to be written before
|
|
// it replies. That constraint is the reason this is awaited, not fired off.
|
|
async function commentOnIssue(callback, issueId, body) {
|
|
if (!callback?.token || !callback?.base_url || !issueId) return;
|
|
try {
|
|
const response = await fetch(`${callback.base_url}/api/v1/plugin/issues/${issueId}/comments`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${callback.token}` },
|
|
body: JSON.stringify({ body }),
|
|
});
|
|
if (!response.ok) {
|
|
console.warn(`callback comment failed: ${response.status} ${await response.text()}`);
|
|
}
|
|
} catch (error) {
|
|
// A failed write-back must not fail the hook: the agent still gets its
|
|
// answer, and a missing comment is recoverable where a failed tool is not.
|
|
console.warn("callback comment error:", error.message);
|
|
}
|
|
}
|
|
|
|
function correlateDeploys(input, config) {
|
|
const windowMinutes = Number(input.window_minutes ?? 120);
|
|
const service = `${config.service_prefix ?? ""}${input.service ?? ""}`;
|
|
|
|
const matches = DEPLOYS
|
|
.filter((deploy) => `${config.service_prefix ?? ""}${deploy.service}` === service)
|
|
.filter((deploy) => deploy.minutes_ago <= windowMinutes)
|
|
.sort((a, b) => a.minutes_ago - b.minutes_ago);
|
|
|
|
return {
|
|
service,
|
|
window_minutes: windowMinutes,
|
|
deploy_count: matches.length,
|
|
deploys: matches,
|
|
// Said explicitly rather than left for the agent to infer from an empty
|
|
// array. "Nothing deployed" is a real finding and the skill tells the agent
|
|
// to report it rather than keep digging.
|
|
summary: matches.length === 0
|
|
? `No deploys to ${service} in the last ${windowMinutes} minutes. This is unlikely to be a deploy.`
|
|
: `${matches.length} deploy(s) to ${service} in the last ${windowMinutes} minutes; most recent is ${matches[0].id} (${matches[0].minutes_ago}m ago, ${matches[0].commit} by ${matches[0].author}).`,
|
|
};
|
|
}
|
|
|
|
function requestRollback(input, config) {
|
|
const deploy = DEPLOYS.find((entry) => entry.id === input.deploy_id);
|
|
if (!deploy) {
|
|
return { status: "rejected", reason: `Unknown deploy ${input.deploy_id}.` };
|
|
}
|
|
|
|
const reason = String(input.reason ?? "").trim();
|
|
if (reason.length < 20) {
|
|
// The approver reads this at 3am; a one-word reason wastes their time.
|
|
return {
|
|
status: "rejected",
|
|
reason: "A rollback request needs a written reason (at least 20 characters) describing the evidence, not the conclusion.",
|
|
};
|
|
}
|
|
|
|
const windowMinutes = Number(config.rollback_window_minutes ?? 60);
|
|
if (deploy.minutes_ago > windowMinutes) {
|
|
return {
|
|
status: "rejected",
|
|
reason: `Deploy ${deploy.id} landed ${deploy.minutes_ago} minutes ago, past the ${windowMinutes}-minute rollback window. Past that point a fix forward is usually safer, and it needs a human deciding.`,
|
|
};
|
|
}
|
|
|
|
const changeId = `chg-${String(filedRollbacks.size + 1).padStart(4, "0")}`;
|
|
filedRollbacks.set(changeId, { deploy: deploy.id, reason });
|
|
return {
|
|
status: "filed",
|
|
change_id: changeId,
|
|
deploy_id: deploy.id,
|
|
environment: config.environment ?? "production",
|
|
// Stated so the agent does not report the incident as handled.
|
|
next_step: "A human must approve this change request. Nothing has been rolled back yet.",
|
|
};
|
|
}
|
|
|
|
const server = createServer(tlsOptions(), async (req, res) => {
|
|
const chunks = [];
|
|
for await (const chunk of req) chunks.push(chunk);
|
|
const rawBody = Buffer.concat(chunks).toString("utf8");
|
|
|
|
const reply = (status, payload) => {
|
|
res.writeHead(status, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify(payload));
|
|
};
|
|
|
|
const verified = verifySignature(
|
|
rawBody,
|
|
req.headers["x-multica-signature"],
|
|
req.headers["x-multica-timestamp"],
|
|
);
|
|
if (!verified.ok) {
|
|
console.warn(`refused ${req.url}: ${verified.reason}`);
|
|
return reply(401, { error: verified.reason });
|
|
}
|
|
|
|
let payload;
|
|
try {
|
|
payload = JSON.parse(rawBody || "{}");
|
|
} catch {
|
|
return reply(400, { error: "body is not valid JSON" });
|
|
}
|
|
|
|
const { hook_key: hookKey, trigger, input = {}, config = {}, callback, issue_id: issueId } = payload;
|
|
console.log(`${hookKey} via ${trigger}`);
|
|
|
|
switch (hookKey) {
|
|
case "correlate_deploys": {
|
|
const result = correlateDeploys(input, config);
|
|
// A ui/manual click is a person asking; leave the answer where they and
|
|
// their colleagues will see it. An agent call already returns to the
|
|
// agent, which will write its own account of what it found.
|
|
if (trigger === "ui" || trigger === "manual") {
|
|
await commentOnIssue(callback, issueId, `**Deploy Sentinel** — ${result.summary}`);
|
|
}
|
|
return reply(200, result);
|
|
}
|
|
|
|
case "request_rollback": {
|
|
const result = requestRollback(input, config);
|
|
if (result.status === "filed") {
|
|
await commentOnIssue(
|
|
callback,
|
|
issueId,
|
|
`**Deploy Sentinel** filed rollback ${result.change_id} for deploy ${result.deploy_id}.\n\n> ${input.reason}\n\nAwaiting human approval — nothing has been rolled back.`,
|
|
);
|
|
}
|
|
return reply(200, result);
|
|
}
|
|
|
|
case "page_oncall": {
|
|
// An event hook's response is discarded, so there is nothing to return
|
|
// that anybody reads. Acknowledge fast and do the work; blocking here
|
|
// would hold a Multica worker for no benefit.
|
|
const issue = payload.event?.issue ?? {};
|
|
console.log(`paging on-call for ${payload.event?.type}: ${issue.title ?? issueId}`);
|
|
return reply(202, { acknowledged: true });
|
|
}
|
|
|
|
default:
|
|
return reply(404, { error: `unknown hook ${hookKey}` });
|
|
}
|
|
});
|
|
|
|
server.listen(PORT, () => {
|
|
console.log(`Deploy Sentinel listening on https://127.0.0.1:${PORT}`);
|
|
if (!SIGNING_SECRET) {
|
|
console.warn("MULTICA_SIGNING_SECRET is not set — every request will be refused.");
|
|
}
|
|
});
|