feat(swads): add daily image report workflow

This commit is contained in:
Jeffrey Wu
2026-09-07 11:34:00 +08:00
parent f39f6ac881
commit 5c61371d7e
38 changed files with 2537 additions and 52 deletions
+17 -3
View File
@@ -3,15 +3,29 @@
"version": "0.1.0",
"private": true,
"type": "module",
"exports": { ".": { "types": "./src/index.ts", "development": "./src/index.ts", "import": "./dist/index.js", "default": "./dist/index.js" } },
"scripts": { "typecheck": "tsc --noEmit", "build": "tsc -p tsconfig.json" },
"exports": {
".": {
"types": "./src/index.ts",
"development": "./src/index.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
}
},
"scripts": {
"typecheck": "tsc --noEmit",
"build": "tsc -p tsconfig.json"
},
"dependencies": {
"@agent-studio/shared": "workspace:*",
"@earendil-works/pi-ai": "0.84.3",
"@earendil-works/pi-coding-agent": "0.84.3",
"@modelcontextprotocol/sdk": "^1.30.0",
"sharp": "^0.34.5",
"typebox": "1.3.7",
"zod": "^4.1.13"
},
"devDependencies": { "@types/node": "^24.10.1", "typescript": "^5.9.3" }
"devDependencies": {
"@types/node": "^24.10.1",
"typescript": "^5.9.3"
}
}
+15 -1
View File
@@ -12,7 +12,8 @@ export class HarnessError extends Error {
export function safeErrorMessage(error: unknown): string {
if (!(error instanceof Error)) return "Unexpected error";
return error.message
const message = process.env.SWADS_MCP_TOKEN ? error.message.split(process.env.SWADS_MCP_TOKEN).join("[REDACTED]") : error.message;
return message
.replace(/(?:sk|pk|key)-[A-Za-z0-9_-]{8,}/g, "[REDACTED]")
.replace(/Bearer\s+\S+/gi, "Bearer [REDACTED]")
.replace(/[A-Za-z0-9+/]{200,}={0,2}/g, "[REDACTED_DATA]");
@@ -29,3 +30,16 @@ export function redactValue(value: unknown, depth = 0): unknown {
}
return value;
}
/** Remove the configured server secret without truncating event or report data. */
export function redactServerSecret<T>(value: T): T {
const token = process.env.SWADS_MCP_TOKEN;
if (!token) return value;
const scrub = (item: unknown): unknown => {
if (typeof item === "string") return item.split(token).join("[REDACTED]");
if (Array.isArray(item)) return item.map(scrub);
if (item && typeof item === "object") return Object.fromEntries(Object.entries(item).map(([key, val]) => [String(scrub(key)), scrub(val)]));
return item;
};
return scrub(value) as T;
}
+2 -1
View File
@@ -1,3 +1,4 @@
import { redactServerSecret } from "./errors.js";
import { randomUUID } from "node:crypto";
import type { EventInput, HarnessEvent, HarnessEventType } from "@agent-studio/shared";
@@ -29,7 +30,7 @@ export class InMemoryEventStore implements EventStore {
append<T extends HarnessEventType>(sessionId: string, input: EventInput<T>): Extract<HarnessEvent, { type: T }> {
const bucket = this.bucket(sessionId);
const value = {
...input,
...redactServerSecret(input),
eventId: `evt_${randomUUID()}`,
sessionId,
timestamp: new Date().toISOString(),
+6
View File
@@ -9,3 +9,9 @@ export * from "./safe-workspace.js";
export * from "./session.js";
export * from "./skills.js";
export * from "./tools.js";
export * from "./swads-gateway.js";
export * from "./swads-tools.js";
export * from "./report-schema.js";
export * from "./report-renderer.js";
export * from "./report-store.js";
+21 -3
View File
@@ -1,5 +1,6 @@
import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
import type { EventInput, HarnessEventType } from "@agent-studio/shared";
import { reportArtifactSchema } from "@agent-studio/shared";
import { redactValue } from "./errors.js";
export interface PiAdapterContext { runId: string; assistantMessageId: string }
@@ -14,11 +15,20 @@ export function mapPiEvent(event: AgentSessionEvent, context: PiAdapterContext):
if (event.message.role === "assistant") return [{ type: "assistant.completed", runId: context.runId, payload: { messageId: context.assistantMessageId } }];
return [];
case "tool_execution_start":
return [{ type: "tool.requested", runId: context.runId, payload: { toolCallId: event.toolCallId, toolName: event.toolName, arguments: redactValue(event.args) } }];
case "tool_execution_end":
return [{ type: "tool.requested", runId: context.runId, payload: { toolCallId: event.toolCallId, toolName: event.toolName, arguments: event.toolName === "render_swads_daily_report" ? { status: "requested" } : redactValue(event.args) } }];
case "tool_execution_end": {
if (!event.isError && event.toolName === "render_swads_daily_report") {
const artifact = reportArtifactSchema.safeParse((event.result as { details?: unknown })?.details);
if (artifact.success) return [
{ type: "tool.completed", runId: context.runId, payload: { toolCallId: event.toolCallId, toolName: event.toolName, result: artifact.data } },
{ type: "report.generated", runId: context.runId, payload: { assistantMessageId: context.assistantMessageId, artifact: artifact.data } },
];
return [{ type: "tool.failed", runId: context.runId, payload: { toolCallId: event.toolCallId, toolName: event.toolName, error: "SWADS_INVALID_REPORT" } }];
}
return event.isError
? [{ type: "tool.failed", runId: context.runId, payload: { toolCallId: event.toolCallId, toolName: event.toolName, error: "Tool execution failed" } }]
? [{ type: "tool.failed", runId: context.runId, payload: { toolCallId: event.toolCallId, toolName: event.toolName, error: toolFailure(event.result) } }]
: [{ type: "tool.completed", runId: context.runId, payload: { toolCallId: event.toolCallId, toolName: event.toolName, result: summarizeToolResult(event.toolName, event.result) } }];
}
default:
return [];
}
@@ -26,5 +36,13 @@ export function mapPiEvent(event: AgentSessionEvent, context: PiAdapterContext):
function summarizeToolResult(toolName: string, result: unknown): unknown {
if (["read", "grep", "find", "ls"].includes(toolName)) return { status: "success" };
if (toolName === "swads_cli") return redactValue((result as { details?: unknown })?.details ?? { status: "success" });
return redactValue(result);
}
function toolFailure(result: unknown): string {
const content = (result as { content?: Array<{ type?: string; text?: string }> } | null)?.content;
const text = content?.filter((item) => item.type === "text").map((item) => item.text ?? "").join(" ") ?? "";
const codes = ["NO_DATA", "NOT_CONFIGURED", "INVALID_CONFIG", "INVALID_ARGUMENTS", "AUTH_FAILED", "FORBIDDEN", "TOOL_MISSING", "TOOL_DRIFT", "NOT_READ_ONLY", "CAPABILITY_MISSING", "INVALID_RESPONSE", "UPSTREAM_ERROR", "RESPONSE_TOO_LARGE", "TIMEOUT", "ABORTED", "UNAVAILABLE", "PREFLIGHT_REQUIRED", "INVALID_REPORT", "REPORT_FAILED"];
return codes.map((code) => `SWADS_${code}`).find((code) => text === code || text === `Error: ${code}`) ?? "Tool execution failed";
}
+108
View File
@@ -0,0 +1,108 @@
import { execFile } from "node:child_process";
import { access, mkdtemp, readFile, rm, stat } from "node:fs/promises";
import { constants } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import sharp from "sharp";
import type { ReportArtifact } from "@agent-studio/shared";
import type { ReportData } from "./report-schema.js";
const escape = (value: unknown) => String(value ?? "").replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
const number = (value: number | null, suffix = "") => value === null ? "N/A" : `${value.toLocaleString("en-US", { maximumFractionDigits: 2 })}${suffix}`;
export const REPORT_CSP = "default-src 'none'; style-src 'unsafe-inline'; img-src 'none'; font-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; sandbox";
export function renderReportHtml(data: ReportData, css: string): string {
const { meta, summary, creative } = data;
if (/@import|url\s*\(|<\/style/i.test(css)) throw new Error("Report CSS must be local and self-contained");
const money = (value: number | null) => value === null ? "N/A" : `${meta.currency} ${number(value)}`;
const kpi = (label: string, value: string, note: string, accent = false) => `<div class="kpi${accent ? " accent" : ""}"><span class="kpi-label">${escape(label)}</span><span class="kpi-value">${escape(value)}</span><span class="kpi-note">${escape(note)}</span></div>`;
type Row = ReportData["campaigns"]["winners"][number] | ReportData["creative"]["winners"][number];
const table = (rows: Row[], creativeTable = false) => `<div class="table-wrap"><table><thead><tr><th>${creativeTable ? "素材 / 发布账号" : "Campaign"}</th><th>Spend</th><th>GMV</th><th>订单</th><th>ROAS</th></tr></thead><tbody>${rows.length ? rows.map((row) => `<tr><td>${escape(row.name)}<span class="row-note">${escape([row.id, "creator" in row ? row.creator : row.status, row.reason].filter(Boolean).join(" · "))}</span>${"clicks" in row ? `<span class="row-note">点击 ${number(row.clicks)} · Product CTR ${number(row.product_ctr_percent, "%")}</span>` : ""}</td><td>${number(row.spend)}</td><td>${number(row.revenue)}</td><td>${number(row.orders)}</td><td><strong>${number(row.roas, "×")}</strong></td></tr>`).join("") : '<tr><td colspan="5">暂无足够样本</td></tr>'}</tbody></table></div>`;
const panel = (title: string, content: string) => `<div class="panel"><div class="panel-head"><span class="panel-title">${escape(title)}</span></div>${content}</div>`;
const section = (title: string, subtitle: string, content: string) => `<section><div class="section-head"><h2>${escape(title)}</h2><p>${escape(subtitle)}</p></div>${content}</section>`;
const maxRoas = Math.max(1, ...data.trend.map((item) => item.roas ?? 0));
const trend = data.trend.map((item) => `<div class="trend-item"><div class="bar${item.partial ? " partial" : ""}" style="height:${item.roas === null ? 0 : 24 + 126 * item.roas / maxRoas}px;min-height:0"></div><span class="trend-value">${number(item.roas, "×")}</span><span class="trend-label">${escape(item.label)}${item.partial ? " (partial)" : ""}</span></div>`).join("");
const labels = { required: "需人工确认", partial: "部分需确认", none: "只读检查" };
const recommendations = data.recommendations.map((item, index) => `<article class="recommendation"><div class="recommendation-top"><span class="recommendation-num">0${index + 1}</span><span class="badge badge-warn">${labels[item.confirmation]}</span></div><h3>${escape(item.title)}</h3><p>${escape(item.detail)}</p></article>`).join("");
const confirmations = data.manual_confirmations.map((item) => `<div class="confirmation"><span>${item.required ? "✓" : "○"}</span><div><strong>${escape(item.action)} · ${item.required ? "需确认" : "只读"}</strong><span>${escape(item.reason)}</span></div></div>`).join("");
const sources = creative.source_groups.map((item) => `<div class="source-card"><div><strong>${escape(item.label)} · ${number(item.roas, "×")}</strong><p>Spend ${escape(money(item.spend))} · GMV ${escape(money(item.revenue))} · ${number(item.orders)} 单</p></div></div>`).join("") || "发布账号归因信息不可用。";
const insights = creative.insights.map((item, index) => `<div class="insight"><span class="insight-num">${index + 1}</span><div><strong>归因线索</strong><p>${escape(item)}</p></div></div>`).join("") || "样本不足以形成稳定判断。";
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="${escape(REPORT_CSP)}"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${escape(meta.account_name)} 每日投放报告</title><style>${css}</style></head><body><main class="report">
<header class="hero"><div><span class="eyebrow">SW ADS · DELIVERY INTELLIGENCE</span><h1>${escape(meta.account_name)}<br>每日投放报告</h1><p class="subtitle">${escape(meta.report_date)} · ${escape(meta.period_label)} · ${escape(meta.reporting_timezone)} · ${escape(meta.attribution_label)}</p></div><div class="status"><strong>整体状态:${escape(meta.status_label)}</strong><span>${escape(meta.comparison_label)} · ${escape(meta.freshness_label)} · ${escape(meta.account_id)}</span></div></header>
<div class="kpis">${kpi("Spend", money(summary.spend), "报告期总消耗")}${kpi("GMV", money(summary.revenue), `${number(summary.orders)} 单 · Cost/order ${money(summary.cost_per_order)}`)}${kpi("ROAS", number(summary.roas, "×"), "GMV ÷ Spend", true)}${kpi("简化广告 ROI", number(summary.simple_roi_percent, "%"), "未扣商品、佣金、退款与履约成本", true)}${kpi("CTR", number(summary.ctr_percent, "%"), "缺失分母时 N/A")}${kpi("CPI", money(summary.cpi), "缺失指标时 N/A")}</div>
${data.monitoring_note ? `<div class="callout"><span>⚠</span><div><strong>监控提示</strong><p>${escape(data.monitoring_note)}</p></div></div>` : ""}
${section("七日 ROAS 趋势", meta.comparison_label, `<div class="panel trend-panel"><div class="trend">${trend}</div></div>`)}
${section("Campaign 分层", `效率、消耗与订单样本共同判断;金额单位 ${meta.currency}`, `<div class="panel-grid">${panel("相对优质 Campaign", table(data.campaigns.winners))}${panel("异常 / 低效 Campaign", table(data.campaigns.anomalies))}</div>`)}
${section("素材表现与归因线索", `${number(creative.row_count)} 条有消耗素材;消耗覆盖 ${number(creative.coverage_percent, "%")};相关性不代表因果。`, `<div class="panel-grid">${panel("发布账号贡献对比", sources)}${panel("归因线索", insights)}</div><div class="panel-grid" style="margin-top:24px">${panel("高价值素材", table(creative.winners, true))}${panel("高消耗低转化素材", table(creative.anomalies, true))}</div>`)}
${section("三条优化建议", "本报告不执行平台写入。", `<div class="recommendations">${recommendations}</div>`)}
${section("人工确认清单", "预算、目标、状态、授权与发布均需人工控制。", `<div class="confirmations">${confirmations}</div>`)}
<footer class="notes"><strong>数据与口径</strong><ul>${[meta.attribution_label, ...data.notes].map((note) => `<li>${escape(note)}</li>`).join("")}</ul></footer></main><div id="report-end" style="height:8px;background:#1234ab"></div></body></html>`;
}
export interface PngResult { png?: Buffer; warning?: ReportArtifact["warning"] }
export interface ChromeRunner { (executable: string, args: string[], signal: AbortSignal): Promise<void> }
export const runChrome: ChromeRunner = (executable, args, signal) => new Promise((resolve, reject) => {
const screenshot = args.find((arg) => arg.startsWith("--screenshot="))!.slice("--screenshot=".length);
let captured = false;
let inspecting = false;
const stop = () => { child.kill("SIGKILL"); };
const env = Object.fromEntries(["PATH", "LANG", "LC_ALL", "TMPDIR", "SYSTEMROOT", "DISPLAY", "XDG_RUNTIME_DIR"].flatMap((key) => process.env[key] ? [[key, process.env[key]!]] : []));
const child = execFile(executable, args, { signal, env, timeout: 30_000, killSignal: "SIGKILL", maxBuffer: 1024 * 1024 }, (error) => {
clearInterval(poll); signal.removeEventListener("abort", stop); stop();
if (signal.aborted) reject(signal.reason);
else if (error && !captured) reject(error);
else resolve();
});
signal.addEventListener("abort", stop, { once: true });
// Some desktop Chrome builds keep background children alive after writing the
// screenshot. Once the entire PNG decodes, stop our isolated Chrome process.
// Content completeness is checked separately by renderReportPng.
const poll = setInterval(() => {
if (inspecting) return;
inspecting = true;
void readFile(screenshot).then((bytes) => sharp(bytes).raw().toBuffer()).then(() => { captured = true; stop(); }).catch(() => undefined).finally(() => { inspecting = false; });
}, 200);
});
export async function discoverChrome(explicit = process.env.CHROME_BIN): Promise<string | undefined> {
const candidates = explicit ? [explicit] : ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Chromium.app/Contents/MacOS/Chromium", "/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium", "/usr/bin/chromium-browser"];
for (const candidate of candidates) { if (!path.isAbsolute(candidate)) continue; try { await access(candidate, constants.X_OK); if ((await stat(candidate)).isFile()) return candidate; } catch { /* Try the next fixed system path. */ } }
return undefined;
}
const warning = (code: NonNullable<ReportArtifact["warning"]>["code"]): PngResult => ({ warning: { code, message: `${code}: PNG unavailable; HTML and JSON are available.` } });
export async function renderReportPng(htmlPath: string, signal: AbortSignal, options: { discover?: () => Promise<string | undefined>; runner?: ChromeRunner } = {}): Promise<PngResult> {
signal.throwIfAborted();
const executable = await (options.discover ?? discoverChrome)();
if (!executable) return warning("CHROME_UNAVAILABLE");
const temp = await mkdtemp(path.join(tmpdir(), "swads-chrome-"));
try {
for (const height of [5200, 9000, 24000]) {
signal.throwIfAborted();
const pngPath = path.join(temp, "capture.png");
await rm(pngPath, { force: true });
await (options.runner ?? runChrome)(executable, ["--headless=new", "--disable-gpu", "--password-store=basic", "--use-mock-keychain", "--disable-breakpad", "--hide-scrollbars", "--no-first-run", "--disable-extensions", "--disable-background-networking", "--disable-sync", "--no-default-browser-check", "--host-resolver-rules=MAP * ~NOTFOUND", "--force-device-scale-factor=1", `--user-data-dir=${path.join(temp, "profile")}`, `--window-size=1440,${height}`, `--screenshot=${pngPath}`, pathToFileURL(htmlPath).href], signal);
const input = await readFile(pngPath);
const { data, info } = await sharp(input, { limitInputPixels: 1440 * 24000 }).removeAlpha().raw().toBuffer({ resolveWithObject: true });
if (info.width !== 1440 || info.height !== height) return warning("PNG_RENDER_FAILED");
// Require the full-width end marker and a background-only bottom edge.
// This also detects a blank/error page and clipping through whitespace.
let end = -1;
for (let y = 0; y < height; y++) {
const at = (y * info.width + 20) * info.channels;
if (data[at] === 18 && data[at + 1] === 52 && data[at + 2] === 171) { end = y; break; }
}
let bottomClear = true;
for (let x = 0; x < info.width; x++) {
const at = ((height - 1) * info.width + x) * info.channels;
if (Math.abs(data[at]! - 232) > 3 || Math.abs(data[at + 1]! - 235) > 3 || Math.abs(data[at + 2]! - 230) > 3) { bottomClear = false; break; }
}
if (end < 1 || !bottomClear || end + 16 >= height) continue;
const cropped = await sharp(input).extract({ left: 0, top: 0, width: info.width, height: end }).toBuffer();
const png = await sharp(cropped).trim({ background: "#e8ebe6", threshold: 4 }).extend({ top: 24, bottom: 24, left: 24, right: 24, background: "#e8ebe6" }).png().toBuffer();
return { png };
}
return warning("PNG_TRUNCATED");
} catch (error) {
signal.throwIfAborted();
return warning((error as { killed?: boolean; code?: string }).killed || (error as { code?: string }).code === "ETIMEDOUT" ? "PNG_TIMEOUT" : "PNG_RENDER_FAILED");
} finally { await rm(temp, { recursive: true, force: true }); }
}
+28
View File
@@ -0,0 +1,28 @@
import { z } from "zod";
export const reportDateSchema = z.iso.date();
const label = z.string().min(1).max(160);
const text = z.string().min(1).max(500);
const metric = z.number().finite().nonnegative().nullable();
const count = z.number().int().nonnegative().nullable();
const percent = z.number().min(0).max(100).nullable();
const performance = { spend: metric, revenue: metric, orders: count, roas: metric };
const campaign = z.object({ name: label, id: label, status: label, ...performance, reason: text }).strict();
const creative = z.object({ name: label, id: label, creator: label, ...performance, clicks: count, product_ctr_percent: percent, reason: text.optional() }).strict();
export const reportDataSchema = z.object({
meta: z.object({ report_date: reportDateSchema, account_name: label, account_id: label, currency: z.string().regex(/^[A-Z]{3}$/), reporting_timezone: label.refine((value) => { try { new Intl.DateTimeFormat("en", { timeZone: value }); return true; } catch { return false; } }, "Invalid reporting timezone"), period_label: label, comparison_label: label, freshness_label: label, attribution_label: label, status_label: label }).strict(),
summary: z.object({ ...performance, ctr_percent: percent, cpi: metric, simple_roi_percent: z.number().finite().min(-100).nullable(), cost_per_order: metric }).strict(),
monitoring_note: text.optional(),
trend: z.array(z.object({ label, roas: metric, partial: z.boolean() }).strict()).max(7),
campaigns: z.object({ winners: z.array(campaign).max(5), anomalies: z.array(campaign).max(5) }).strict(),
creative: z.object({ row_count: count, coverage_percent: percent, source_groups: z.array(z.object({ label, ...performance }).strict()).max(6), winners: z.array(creative).max(5), anomalies: z.array(creative).max(5), insights: z.array(text).max(4) }).strict(),
recommendations: z.array(z.object({ title: label, detail: text, confirmation: z.enum(["required", "partial", "none"]) }).strict()).length(3),
manual_confirmations: z.array(z.object({ action: label, required: z.boolean(), reason: text }).strict()).max(10),
notes: z.array(text).max(10),
}).strict().superRefine((data, ctx) => {
if (data.summary.spend === null || Object.entries(data.summary).every(([key, value]) => key === "spend" || value === null)) ctx.addIssue({ code: "custom", message: "Observed summary data is required", path: ["summary"] });
if (data.recommendations.some((r) => r.confirmation !== "none") && !data.manual_confirmations.some((r) => r.required)) ctx.addIssue({ code: "custom", message: "Platform changes require manual confirmation", path: ["manual_confirmations"] });
if ((data.summary.spend === 0 || data.summary.revenue === null) && (data.summary.roas !== null || data.summary.simple_roi_percent !== null)) ctx.addIssue({ code: "custom", message: "Missing revenue or zero spend requires null ROAS and ROI", path: ["summary"] });
if ((data.summary.orders === 0 || data.summary.orders === null) && data.summary.cost_per_order !== null) ctx.addIssue({ code: "custom", message: "Missing order denominator requires null cost per order", path: ["summary"] });
});
export type ReportData = z.infer<typeof reportDataSchema>;
+56
View File
@@ -0,0 +1,56 @@
import { createHash, randomUUID } from "node:crypto";
import { lstat, mkdir, readFile, realpath, rename, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { reportArtifactSchema, reportFormatSchema, reportSegmentsSchema, type ReportArtifact } from "@agent-studio/shared";
import { HarnessError, redactServerSecret } from "./errors.js";
import { reportDataSchema, type ReportData } from "./report-schema.js";
import { renderReportHtml, renderReportPng, type PngResult } from "./report-renderer.js";
type PngRenderer = (htmlPath: string, signal: AbortSignal) => Promise<PngResult>;
const files = { json: "report-data.json", html: "report.html", png: "report.png" } as const;
export class ReportStore {
constructor(private readonly root: string, private readonly cssPath: string, private readonly pngRenderer: PngRenderer = renderReportPng) {}
async create(input: ReportData, signal: AbortSignal): Promise<ReportArtifact> {
const data = reportDataSchema.parse(redactServerSecret(input));
const accountKey = `acct_${createHash("sha256").update(`tiktok:${data.meta.account_id}`).digest("hex").slice(0, 24)}`;
const reportDate = data.meta.report_date; const reportId = randomUUID();
const root = await this.ensureRoot();
const parent = await this.ensureDirectory(root, [accountKey, reportDate]);
const temp = path.join(parent, `.tmp-${reportId}`); const final = path.join(parent, reportId);
await mkdir(temp, { mode: 0o700 });
try {
signal.throwIfAborted();
const html = renderReportHtml(data, await readFile(this.cssPath, "utf8"));
await writeFile(path.join(temp, files.json), JSON.stringify(data, null, 2), { mode: 0o600, flag: "wx" });
await writeFile(path.join(temp, files.html), html, { mode: 0o600, flag: "wx" });
const rendered = await this.pngRenderer(path.join(temp, files.html), signal);
signal.throwIfAborted();
if (rendered.png) await writeFile(path.join(temp, files.png), rendered.png, { mode: 0o600, flag: "wx" });
signal.throwIfAborted();
await rename(temp, final);
const base = `/api/reports/${accountKey}/${reportDate}/${reportId}`;
return reportArtifactSchema.parse({ accountKey, reportDate, reportId, downloads: { json: `${base}/json`, html: `${base}/html`, ...(rendered.png ? { png: `${base}/png` } : {}) }, ...(rendered.png ? { previewUrl: `${base}/png` } : {}), ...(rendered.warning ? { warning: rendered.warning } : {}) });
} finally { await rm(temp, { recursive: true, force: true }); }
}
async read(segments: unknown, formatInput: unknown): Promise<{ bytes: Buffer; format: keyof typeof files }> {
const parsed = reportSegmentsSchema.safeParse(segments); const format = reportFormatSchema.safeParse(formatInput);
if (!parsed.success || !format.success) throw this.notFound();
const { accountKey, reportDate, reportId } = parsed.data;
try {
const root = await realpath(this.root);
if ((await lstat(this.root)).isSymbolicLink()) throw this.notFound();
let current = root;
for (const segment of [accountKey, reportDate, reportId, files[format.data]]) { current = path.join(current, segment); if ((await lstat(current)).isSymbolicLink()) throw this.notFound(); }
if (!(await lstat(current)).isFile() || !inside(root, await realpath(current))) throw this.notFound();
return { bytes: await readFile(current), format: format.data };
} catch { throw this.notFound(); }
}
private notFound() { return new HarnessError("REPORT_NOT_FOUND", "Report artifact not found", 404); }
private async ensureRoot() { await mkdir(this.root, { recursive: true, mode: 0o700 }); if ((await lstat(this.root)).isSymbolicLink()) throw new HarnessError("REPORT_STORE_UNSAFE", "Report storage is unavailable", 500); return realpath(this.root); }
private async ensureDirectory(root: string, segments: string[]) {
let current = root;
for (const segment of segments) { current = path.join(current, segment); await mkdir(current, { mode: 0o700 }).catch((error: NodeJS.ErrnoException) => { if (error.code !== "EEXIST") throw error; }); if ((await lstat(current)).isSymbolicLink() || !inside(root, await realpath(current))) throw new HarnessError("REPORT_STORE_UNSAFE", "Report storage is unavailable", 500); }
return current;
}
}
function inside(root: string, target: string) { const relative = path.relative(root, target); return relative && !relative.startsWith("..") && !path.isAbsolute(relative); }
+18 -8
View File
@@ -14,13 +14,16 @@ import {
import type { ModelConnectionInput, SendMessageInput } from "@agent-studio/shared";
import { InMemoryApprovalBroker, type ApprovalBroker } from "./approval.js";
import type { FileAttachmentStore } from "./attachments.js";
import { HarnessError, redactValue, safeErrorMessage } from "./errors.js";
import { HarnessError, redactValue, redactServerSecret, safeErrorMessage } from "./errors.js";
import type { EventStore } from "./event-store.js";
import type { ModelConnectionStore } from "./model-store.js";
import { mapPiEvent } from "./pi-event-adapter.js";
import { SafeWorkspace } from "./safe-workspace.js";
import type { SkillCatalog } from "./skills.js";
import type { SkillIndexEntry } from "./skills.js";
import { SwadsGateway } from "./swads-gateway.js";
import { SwadsReportTools } from "./swads-tools.js";
import { ReportStore } from "./report-store.js";
import { createMockAdsMetricsTool, createSaveReportDraftTool } from "./tools.js";
export interface AgentSessionPort {
@@ -66,16 +69,19 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
private readonly catalog: SkillCatalog,
private readonly models: ModelConnectionStore,
private readonly approvals: ApprovalBroker,
private readonly gateway = new SwadsGateway(),
private readonly reports = new ReportStore(path.resolve(fixturePath, "../../workspace/reports"), path.resolve(fixturePath, "../../.agents/skills/swads-daily-report/assets/report.css")),
) {}
async create(input: AgentSessionFactoryInput): Promise<AgentSessionPort> {
const resolved = await this.models.createRuntime(input.sessionId);
const swads = new SwadsReportTools(this.gateway, this.reports, input.getRun);
const extension: InlineExtension = { name: "agent-studio-safety", hidden: true, factory: (pi) => {
const pendingSkills = new Map<string, SkillIndexEntry>();
pi.on("tool_call", async (event) => {
const run = input.getRun();
if (!run) return { block: true, reason: "No active Harness run" };
const known = new Set(["read", "grep", "find", "ls", "mock_ads_metrics", "save_report_draft"]);
const known = new Set(["read", "grep", "find", "ls", "mock_ads_metrics", "save_report_draft", "swads_cli", "render_swads_daily_report"]);
if (!known.has(event.toolName)) {
input.toolRequested(event.toolCallId, event.toolName, publicToolArguments(event.input, input.cwd));
return { block: true, reason: "Tool is not allowed by the Harness policy", terminate: true };
@@ -92,7 +98,7 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
const maybePath = (event.input as Record<string, unknown>).path;
if (typeof maybePath === "string") await input.workspace.assertReadPath(maybePath);
}
input.toolRequested(event.toolCallId, event.toolName, publicToolArguments(event.input, input.cwd, matchedSkill));
input.toolRequested(event.toolCallId, event.toolName, event.toolName === "render_swads_daily_report" ? { status: "requested" } : publicToolArguments(event.input, input.cwd, matchedSkill));
if (event.toolName === "save_report_draft") {
const decision = await this.approvals.request({ sessionId: input.sessionId, runId: run.runId, toolCallId: event.toolCallId, toolName: event.toolName, arguments: event.input, riskLevel: "medium" }, run.signal);
if (decision.status !== "approved") return { block: true, reason: `Human approval ${decision.status}` };
@@ -114,8 +120,8 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
agentDir: path.join(input.cwd, ".pi-agent"),
modelRuntime: resolved.runtime,
model: resolved.model,
tools: ["read", "grep", "find", "ls", "mock_ads_metrics", "save_report_draft"],
customTools: [createMockAdsMetricsTool(this.fixturePath), createSaveReportDraftTool(input.workspace)],
tools: ["read", "grep", "find", "ls", "mock_ads_metrics", "save_report_draft", "swads_cli", "render_swads_daily_report"],
customTools: [createMockAdsMetricsTool(this.fixturePath), createSaveReportDraftTool(input.workspace), ...swads.definitions()],
resourceLoader: loader,
sessionManager: SessionManager.inMemory(input.cwd),
settingsManager: SettingsManager.inMemory(),
@@ -197,6 +203,7 @@ export class InMemorySessionRegistry implements SessionRegistry {
if (!item) throw new HarnessError("ATTACHMENT_NOT_FOUND", "Attachment not found for this session", 404);
return item;
});
const expanded = await this.catalog.expandCommand(message.text);
const runId = `run_${randomUUID()}`;
const assistantMessageId = `msg_${randomUUID()}`;
const controller = new AbortController();
@@ -214,7 +221,10 @@ export class InMemorySessionRegistry implements SessionRegistry {
this.events.append(id, { type: "run.started", runId, payload: { modelId: agent.modelId } });
const images: ImageContent[] = await Promise.all(selected.map(async (item) => ({ type: "image" as const, mimeType: item.mediaType, data: await this.attachments.readBase64(id, item.attachmentId) })));
session.timeout = setTimeout(() => void this.cancel(id, "timeout"), this.runTimeoutMs);
void this.executeRun(session, message.text, images);
if (expanded.skill) {
for (const type of ["skill.requested", "skill.loaded"] as const) this.events.append(id, { type, runId, payload: { name: expanded.skill.name, filePath: expanded.skill.filePath, source: "command" } });
}
void this.executeRun(session, redactServerSecret(expanded.text), images);
return { runId };
}
@@ -226,8 +236,8 @@ export class InMemorySessionRegistry implements SessionRegistry {
cwd: session.cwd,
workspace: session.workspace,
getRun: () => session.currentRun ? { runId: session.currentRun.runId, signal: session.currentRun.controller.signal, assistantMessageId: session.currentRun.assistantMessageId } : undefined,
skillRequested: (skill, toolCallId) => { const run = session.currentRun; if (run) this.events.append(session.sessionId, { type: "skill.requested", runId: run.runId, payload: { name: skill.name, filePath: skill.filePath, toolCallId } }); },
skillLoaded: (skill, toolCallId) => { const run = session.currentRun; if (run) this.events.append(session.sessionId, { type: "skill.loaded", runId: run.runId, payload: { name: skill.name, filePath: skill.filePath, toolCallId } }); },
skillRequested: (skill, toolCallId) => { const run = session.currentRun; if (run) this.events.append(session.sessionId, { type: "skill.requested", runId: run.runId, payload: { name: skill.name, filePath: skill.filePath, toolCallId, source: "read" } }); },
skillLoaded: (skill, toolCallId) => { const run = session.currentRun; if (run) this.events.append(session.sessionId, { type: "skill.loaded", runId: run.runId, payload: { name: skill.name, filePath: skill.filePath, toolCallId, source: "read" } }); },
toolRequested: (toolCallId, toolName, argumentsValue) => { const run = session.currentRun; if (run && !session.requestedToolCalls.has(toolCallId)) { session.requestedToolCalls.add(toolCallId); this.events.append(session.sessionId, { type: "tool.requested", runId: run.runId, payload: { toolCallId, toolName, arguments: redactValue(argumentsValue) } }); } },
});
session.unsubscribe = session.agent.subscribe((event) => this.onPiEvent(session, event as AgentSessionEvent));
+30 -3
View File
@@ -1,4 +1,4 @@
import { access, realpath, stat } from "node:fs/promises";
import { access, lstat, readFile, realpath, stat } from "node:fs/promises";
import path from "node:path";
import { DefaultResourceLoader, SettingsManager, type InlineExtension, type ResourceDiagnostic, type Skill } from "@earendil-works/pi-coding-agent";
import type { SkillCatalogItem } from "@agent-studio/shared";
@@ -41,6 +41,7 @@ export class SkillCatalog {
const discovered = this.loader.getSkills();
const byPath = new Map<string, SkillIndexEntry>();
for (const skill of discovered.skills) {
if ((await lstat(skill.filePath)).isSymbolicLink() || (await lstat(path.dirname(skill.filePath))).isSymbolicLink()) continue;
const skillPath = await realpath(skill.filePath).catch(() => path.resolve(skill.filePath));
const related = discovered.diagnostics.filter((item) => item.path && path.resolve(item.path) === path.resolve(skill.filePath));
byPath.set(skillPath, this.toItem(skill, skillPath, related));
@@ -77,8 +78,32 @@ export class SkillCatalog {
list(): SkillCatalogItem[] { return this.items.map((item) => ({ name: item.name, description: item.description, source: item.source, filePath: item.filePath, diagnostics: item.diagnostics })); }
findCanonical(filePath: string): SkillIndexEntry | undefined { return this.items.find((item) => item.canonicalPath === filePath); }
async matchReadPath(inputPath: string, cwd: string): Promise<SkillIndexEntry | undefined> {
const canonical = await realpath(path.resolve(cwd, inputPath)).catch(() => undefined);
return canonical ? this.findCanonical(canonical) : undefined;
if (inputPath.split(/[\\/]/).includes("..")) return undefined;
let target = path.resolve(cwd, inputPath);
const projectRelative = path.relative(path.resolve(this.projectRoot), target);
if (projectRelative && !projectRelative.startsWith("..") && !path.isAbsolute(projectRelative)) target = path.resolve(await realpath(this.projectRoot), projectRelative);
const skill = this.items.find((item) => !item.canonicalPath.startsWith("diagnostic:") && inside(path.dirname(item.canonicalPath), target));
if (!skill) return undefined;
const root = path.dirname(skill.canonicalPath);
try {
let current = root;
if ((await lstat(root)).isSymbolicLink()) return undefined;
for (const segment of path.relative(root, target).split(path.sep).filter(Boolean)) {
current = path.join(current, segment);
if ((await lstat(current)).isSymbolicLink()) return undefined;
}
if (!(await stat(target)).isFile() || !inside(root, await realpath(target))) return undefined;
return skill;
} catch { return undefined; }
}
async expandCommand(text: string): Promise<{ text: string; skill?: SkillIndexEntry }> {
const match = /^\/swads-daily-report(?:\s+([\s\S]*))?$/.exec(text);
if (!match) return { text };
const skill = this.items.find((item) => item.name === "swads-daily-report" && !item.canonicalPath.startsWith("diagnostic:"));
if (!skill || !await this.matchReadPath(skill.canonicalPath, this.projectRoot)) throw new Error("Built-in swads-daily-report Skill is unavailable");
const body = (await readFile(skill.canonicalPath, "utf8")).replace(/^---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/, "").trim();
const escape = (value: string) => value.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
return { skill, text: `<skill name="${escape(skill.name)}" location="${escape(skill.canonicalPath)}">\nReferences are relative to ${path.dirname(skill.canonicalPath)}.\n\n${body}\n</skill>${match[1] ? `\n\n${match[1]}` : ""}` };
}
private toItem(skill: Skill, canonicalPath: string, diagnostics: ResourceDiagnostic[]): SkillIndexEntry {
return { name: skill.name, description: skill.description, source: skill.sourceInfo.source, filePath: this.displayPath(canonicalPath), canonicalPath, diagnostics: diagnostics.map((item) => ({ severity: item.type === "warning" ? "warning" : "error", message: item.message })) };
@@ -89,3 +114,5 @@ export class SkillCatalog {
return relative && !relative.startsWith("..") && !path.isAbsolute(relative) ? relative : `<external>/${path.basename(absolute)}`;
}
}
function inside(root: string, target: string): boolean { const relative = path.relative(root, target); return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative); }
+108
View File
@@ -0,0 +1,108 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
import { HarnessError } from "./errors.js";
export interface GatewayConnection {
list(signal: AbortSignal): Promise<Tool[]>;
call(name: string, args: Record<string, unknown>, signal: AbortSignal): Promise<unknown>;
close(): Promise<void>;
}
export type GatewayConnector = (signal: AbortSignal) => Promise<GatewayConnection>;
export interface GatewayConfig { url: URL; token: string | undefined; timeoutMs: number }
export const MAX_GATEWAY_BYTES = 2 * 1024 * 1024;
export function gatewayConfig(env: NodeJS.ProcessEnv = process.env): GatewayConfig {
let url: URL;
try { url = new URL(env.SWADS_MCP_URL || "https://ads.mincode.cn/mcp"); } catch { throw new HarnessError("SWADS_INVALID_CONFIG", "SWADS_MCP_URL must be a valid HTTP(S) URL"); }
if (!["https:", "http:"].includes(url.protocol) || url.username || url.password || url.search || url.hash) throw new HarnessError("SWADS_INVALID_CONFIG", "SWADS_MCP_URL must not contain credentials, query or fragment");
const timeoutMs = Number(env.SWADS_MCP_TIMEOUT_MS ?? 30_000);
if (!Number.isInteger(timeoutMs) || timeoutMs < 100 || timeoutMs > 120_000) throw new HarnessError("SWADS_INVALID_CONFIG", "SWADS_MCP_TIMEOUT_MS must be between 100 and 120000");
return { url, token: env.SWADS_MCP_TOKEN || undefined, timeoutMs };
}
// Read the wire body with a byte bound before handing it to the official SDK.
// A connection belongs to one command, so cancellation never affects another run.
export function mcpConnector(config: GatewayConfig, fetcher: typeof fetch = fetch): GatewayConnector {
return async (signal) => {
const boundedFetch: typeof fetch = async (input, init) => {
const response = await fetcher(input, { ...init, redirect: "error", signal: AbortSignal.any([signal, ...(init?.signal ? [init.signal] : [])]) });
if (!response.ok) { await response.body?.cancel(); throw new GatewayHttpError(response.status); }
if (!response.body) return response;
if (Number(response.headers.get("content-length")) > MAX_GATEWAY_BYTES) { await response.body.cancel(); throw failure("SWADS_RESPONSE_TOO_LARGE"); }
const reader = response.body.getReader(); let total = 0;
const body = new ReadableStream<Uint8Array>({
async pull(controller) {
try {
const { value, done } = await reader.read();
if (done) { controller.close(); return; }
total += value.byteLength;
if (total > MAX_GATEWAY_BYTES) { await reader.cancel(); controller.error(failure("SWADS_RESPONSE_TOO_LARGE")); return; }
controller.enqueue(value);
} catch (error) { controller.error(error); }
},
cancel: () => reader.cancel(),
});
return new Response(body, { status: response.status, headers: response.headers });
};
const client = new Client({ name: "agent-studio-swads-cli", version: "1.0.0" });
const transport = new StreamableHTTPClientTransport(config.url, { fetch: boundedFetch, requestInit: { headers: { Authorization: `Bearer ${config.token}` } }, reconnectionOptions: { maxRetries: 0, initialReconnectionDelay: 1000, maxReconnectionDelay: 1000, reconnectionDelayGrowFactor: 1 } });
const close = async () => { await client.close().catch(() => undefined); };
const abort = () => { void close(); };
signal.addEventListener("abort", abort, { once: true });
try { await client.connect(transport as Parameters<Client["connect"]>[0], { signal, timeout: config.timeoutMs }); } catch (error) { signal.removeEventListener("abort", abort); await close(); throw error; }
return {
async list(requestSignal) {
const all: Tool[] = []; let cursor: string | undefined;
do {
const page = await client.listTools(cursor ? { cursor } : {}, { signal: requestSignal, timeout: config.timeoutMs });
all.push(...page.tools); cursor = page.nextCursor;
if (all.length > 500 || Buffer.byteLength(JSON.stringify(all)) > MAX_GATEWAY_BYTES) throw failure("SWADS_RESPONSE_TOO_LARGE");
} while (cursor);
return all;
},
call: (name, args, requestSignal) => client.callTool({ name, arguments: args }, undefined, { signal: requestSignal, timeout: config.timeoutMs }),
async close() { signal.removeEventListener("abort", abort); await close(); },
};
};
}
class GatewayHttpError extends Error { constructor(readonly status: number) { super("Gateway HTTP request failed"); } }
export function failure(code: string): HarnessError { return new HarnessError(code, code, 422); }
export function gatewayError(error: unknown, signal?: AbortSignal): HarnessError {
if (error instanceof HarnessError && error.code.startsWith("SWADS_")) return failure(error.code);
if (signal?.aborted) return failure(signal.reason?.name === "TimeoutError" ? "SWADS_TIMEOUT" : "SWADS_ABORTED");
const status = error instanceof GatewayHttpError ? error.status : (error as { code?: number; status?: number } | null)?.status ?? (error as { code?: number } | null)?.code;
return failure(status === 401 ? "SWADS_AUTH_FAILED" : status === 403 ? "SWADS_FORBIDDEN" : status === -32601 ? "SWADS_TOOL_MISSING" : status === -32602 ? "SWADS_TOOL_DRIFT" : status === -32001 ? "SWADS_TIMEOUT" : "SWADS_UNAVAILABLE");
}
export class SwadsGateway {
private capabilities: { expires: number; tools: Tool[] } | undefined;
private readonly connect: GatewayConnector;
constructor(private readonly config = gatewayConfig(), connector?: GatewayConnector) { this.connect = connector ?? mcpConnector(config); }
sanitize<T>(value: T): T {
if (!this.config.token) return value;
const scrub = (item: unknown): unknown => typeof item === "string" ? item.split(this.config.token!).join("[REDACTED]") : Array.isArray(item) ? item.map(scrub) : item && typeof item === "object" ? Object.fromEntries(Object.entries(item).map(([key, val]) => [String(scrub(key)), /token|authorization|secret|password/i.test(key) ? "[REDACTED]" : scrub(val)])) : item;
return scrub(value) as T;
}
async execute<T>(operation: (connection: GatewayConnection, signal: AbortSignal) => Promise<T>, runSignal: AbortSignal): Promise<T> {
if (!this.config.token) throw failure("SWADS_NOT_CONFIGURED");
const signal = AbortSignal.any([runSignal, AbortSignal.timeout(this.config.timeoutMs)]);
let connection: GatewayConnection | undefined;
let abort: (() => void) | undefined;
try {
signal.throwIfAborted();
const cancelled = new Promise<never>((_, reject) => { abort = () => reject(gatewayError(undefined, signal)); signal.addEventListener("abort", abort, { once: true }); });
const work = (async () => {
connection = await this.connect(signal);
if (signal.aborted) { await connection.close(); throw gatewayError(undefined, signal); }
return operation(connection, signal);
})();
return this.sanitize(await Promise.race([work, cancelled]));
} catch (error) { throw gatewayError(error, signal); }
finally { if (abort) signal.removeEventListener("abort", abort); await connection?.close().catch(() => undefined); }
}
async list(connection: GatewayConnection, signal: AbortSignal, refresh = false): Promise<Tool[]> {
if (!refresh && this.capabilities && this.capabilities.expires > Date.now()) return this.capabilities.tools;
const tools = await connection.list(signal);
if (Buffer.byteLength(JSON.stringify(tools)) > MAX_GATEWAY_BYTES) throw failure("SWADS_RESPONSE_TOO_LARGE");
this.capabilities = { tools, expires: Date.now() + 60_000 }; return tools;
}
}
+134
View File
@@ -0,0 +1,134 @@
import { defineTool } from "@earendil-works/pi-coding-agent";
import { AjvJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/ajv";
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
import { Type } from "typebox";
import { z } from "zod";
import { MAX_GATEWAY_BYTES, SwadsGateway, failure } from "./swads-gateway.js";
import { reportDataSchema } from "./report-schema.js";
import type { ReportStore } from "./report-store.js";
export const commands = {
capabilities: "tools/list", whoami: "swads_whoami", "metrics.catalog": "metrics_catalog", "metrics.query": "metrics_semantic_query",
"runtime.account-overview": "runtime_account_overview", "runtime.proposals": "runtime_list_proposals", "runtime.config": "runtime_get_config", "runtime.findings": "runtime_findings", "campaign.list": "campaign_list",
} as const;
const id = z.string().min(1).max(160);
const scope = { external_account_id: id.optional(), platform: z.literal("tiktok").optional(), tenant_id: id.optional() };
const schemas = {
capabilities: z.object({}).strict(), whoami: z.object({}).strict(),
"metrics.catalog": z.object({ platform: z.literal("tiktok") }).strict(),
"metrics.query": z.object({ ...scope, platform: z.literal("tiktok"), query: z.record(z.string(), z.unknown()) }).strict(),
"runtime.account-overview": z.object({ external_account_id: id.optional(), platform: z.literal("tiktok") }).strict(),
"runtime.proposals": z.object({ ...scope, platform: z.literal("tiktok"), external_account_id: id, status: id.optional() }).strict(),
"runtime.config": z.object({ ...scope, platform: z.literal("tiktok"), external_account_id: id }).strict(),
"runtime.findings": z.object({ ...scope, platform: z.literal("tiktok"), external_account_id: id, metric: id.optional(), window: id.optional(), min_confidence: z.number().min(0).max(1).optional(), include_entities: z.boolean().optional() }).strict(),
"campaign.list": z.object({ ...scope, platform: z.literal("tiktok"), page: z.number().int().min(1).max(1000).optional(), page_size: z.number().int().min(1).max(100).optional(), cursor: z.string().max(1000).optional(), status: id.optional() }).strict(),
};
export const swadsCliSchema = z.object({ command: z.enum(Object.keys(commands) as [keyof typeof commands, ...Array<keyof typeof commands>]), arguments: z.record(z.string(), z.unknown()) }).strict();
export function validateCli(input: unknown) {
assertBounds(input);
const parsed = swadsCliSchema.safeParse(input);
if (!parsed.success) throw failure("SWADS_INVALID_ARGUMENTS");
const args = schemas[parsed.data.command].safeParse(parsed.data.arguments);
if (!args.success) throw failure("SWADS_INVALID_ARGUMENTS");
if (parsed.data.command === "metrics.query") {
const query = parsed.data.arguments.query as Record<string, unknown>;
if (query.platform !== undefined && query.platform !== "tiktok") throw failure("SWADS_INVALID_ARGUMENTS");
}
return { command: parsed.data.command, arguments: args.data };
}
function assertBounds(input: unknown): void {
let nodes = 0;
const visit = (value: unknown, depth: number) => {
if (++nodes > 4000 || depth > 10) throw failure("SWADS_INVALID_ARGUMENTS");
if (value && typeof value === "object") for (const [key, child] of Object.entries(value)) {
if (/^(?:url|headers?|token|authorization|tool_?name|__proto__|constructor|prototype)$/i.test(key)) throw failure("SWADS_INVALID_ARGUMENTS");
visit(child, depth + 1);
}
};
visit(input, 0);
if (Buffer.byteLength(JSON.stringify(input) ?? "") > 64 * 1024) throw failure("SWADS_INVALID_ARGUMENTS");
}
export function isReadOnly(tool: Tool): boolean { return tool.annotations?.readOnlyHint === true && tool.annotations.destructiveHint !== true; }
const required = ["swads_whoami", "metrics_catalog", "metrics_semantic_query"];
function assertCapabilities(tools: Tool[]): void {
for (const name of required) {
const tool = tools.find((item) => item.name === name);
if (!tool) throw failure("SWADS_TOOL_MISSING");
if (!isReadOnly(tool)) throw failure("SWADS_NOT_READ_ONLY");
}
}
function unwrap(result: unknown): unknown {
if (Buffer.byteLength(JSON.stringify(result)) > MAX_GATEWAY_BYTES) throw failure("SWADS_RESPONSE_TOO_LARGE");
const envelope = result as { isError?: boolean; structuredContent?: unknown; content?: Array<{ type: string; text?: string }> };
let data = envelope.structuredContent;
if (data === undefined) {
const content = envelope.content?.filter((part) => part.type === "text");
if (content?.length !== 1 || !content[0]?.text) throw failure("SWADS_INVALID_RESPONSE");
try { data = JSON.parse(content[0].text); } catch { throw failure(envelope.isError ? "SWADS_UPSTREAM_ERROR" : "SWADS_INVALID_RESPONSE"); }
}
const error = data as { error?: unknown; error_code?: string; upstream_status?: number; status?: number } | null;
if (envelope.isError || error?.error || error?.error_code) {
const status = error?.upstream_status ?? error?.status;
throw failure(status === 401 ? "SWADS_AUTH_FAILED" : status === 403 ? "SWADS_FORBIDDEN" : error?.error_code === "platform_not_supported" ? "SWADS_CAPABILITY_MISSING" : "SWADS_UPSTREAM_ERROR");
}
return data;
}
// Per-session, run-scoped evidence prevents rendering after failed/missing preflight.
export class SwadsReportTools {
private evidence: { runId: string; whoami: boolean; catalog: boolean; query: boolean } | undefined;
constructor(private readonly gateway: SwadsGateway, private readonly reports: ReportStore, private readonly getRun: () => { runId: string; signal: AbortSignal } | undefined) {}
async cli(input: unknown, signal: AbortSignal): Promise<unknown> {
let params: ReturnType<typeof validateCli>;
try { params = validateCli(input); } catch (error) { this.evidence = undefined; throw error; }
const run = this.getRun(); if (!run) throw failure("SWADS_NO_ACTIVE_RUN");
if (this.evidence?.runId !== run.runId) this.evidence = { runId: run.runId, whoami: false, catalog: false, query: false };
try {
return await this.gateway.execute(async (connection, requestSignal) => {
const tools = await this.gateway.list(connection, requestSignal, params.command === "capabilities");
assertCapabilities(tools);
if (params.command === "capabilities") return { server_time: new Date().toISOString(), tools: tools.filter((tool) => Object.values(commands).includes(tool.name as typeof commands[keyof typeof commands])) };
if (params.command === "metrics.query" && (!this.evidence?.whoami || !this.evidence.catalog)) throw failure("SWADS_PREFLIGHT_REQUIRED");
const tool = tools.find((item) => item.name === commands[params.command]);
if (!tool) throw failure("SWADS_TOOL_MISSING");
if (!isReadOnly(tool)) throw failure("SWADS_NOT_READ_ONLY");
try { if (!new AjvJsonSchemaValidator().getValidator(tool.inputSchema as Parameters<AjvJsonSchemaValidator["getValidator"]>[0])(params.arguments).valid) throw failure("SWADS_TOOL_DRIFT"); } catch { throw failure("SWADS_TOOL_DRIFT"); }
const data = unwrap(await connection.call(tool.name, params.arguments, requestSignal));
if (data === null || typeof data !== "object") throw failure("SWADS_INVALID_RESPONSE");
if (params.command === "metrics.query") {
const rows = data as { columns?: Array<{ name?: unknown }>; rows?: unknown[] };
if (!Array.isArray(rows.columns) || !rows.columns.length || !rows.columns.every((column) => typeof column.name === "string") || !Array.isArray(rows.rows)) throw failure("SWADS_INVALID_RESPONSE");
if (!rows.rows.length) throw failure("SWADS_NO_DATA");
}
if (params.command === "metrics.catalog" && Object.keys(data).length === 0) throw failure("SWADS_CAPABILITY_MISSING");
if (params.command === "whoami" && Object.keys(data).length === 0) throw failure("SWADS_INVALID_RESPONSE");
if (params.command === "whoami") this.evidence!.whoami = true;
if (params.command === "metrics.catalog") this.evidence!.catalog = true;
if (params.command === "metrics.query") this.evidence!.query = true;
return data;
}, AbortSignal.any([signal, run.signal]));
} catch (error) {
if (["capabilities", "whoami", "metrics.catalog", "metrics.query"].includes(params.command)) this.evidence = undefined;
throw error;
}
}
async render(input: unknown, signal: AbortSignal) {
const run = this.getRun();
if (!run || this.evidence?.runId !== run.runId || !this.evidence.whoami || !this.evidence.catalog || !this.evidence.query) throw failure("SWADS_PREFLIGHT_REQUIRED");
const parsed = reportDataSchema.safeParse(this.gateway.sanitize(input));
if (!parsed.success) throw failure("SWADS_INVALID_REPORT");
try { return await this.reports.create(parsed.data, AbortSignal.any([signal, run.signal])); } catch { throw failure(signal.aborted || run.signal.aborted ? "SWADS_ABORTED" : "SWADS_REPORT_FAILED"); }
}
definitions() {
return [defineTool({
name: "swads_cli", label: "SW Ads read-only CLI", description: "Read SW Ads via fixed commands. First capabilities then whoami and metrics.catalog. Arguments must follow the remote input schema and the local command schema; only platform tiktok is supported.",
parameters: Type.Unsafe<z.infer<typeof swadsCliSchema>>({ type: "object", properties: { command: { type: "string", enum: Object.keys(commands) }, arguments: { type: "object" } }, required: ["command", "arguments"], additionalProperties: false, oneOf: Object.entries(schemas).map(([command, schema]) => ({ type: "object", properties: { command: { const: command }, arguments: z.toJSONSchema(schema) }, required: ["command", "arguments"], additionalProperties: false })) }),
executionMode: "sequential",
execute: async (_id, params, signal) => { const data = await this.cli(params, signal ?? new AbortController().signal); return { content: [{ type: "text" as const, text: JSON.stringify(data) }], details: { status: "success", command: params.command } }; },
}), defineTool({
name: "render_swads_daily_report", label: "SW Ads daily report", description: "After real SW Ads preflight and queries, render normalized data to persistent PNG/HTML/JSON. Output Markdown analysis first, then call this as the final action. No model-selected paths or executable arguments.",
parameters: Type.Object({ data: Type.Unsafe<z.infer<typeof reportDataSchema>>(z.toJSONSchema(reportDataSchema)) }, { additionalProperties: false }), executionMode: "sequential",
execute: async (_id, params, signal) => { const artifact = await this.render(params.data, signal ?? new AbortController().signal); return { content: [{ type: "text" as const, text: JSON.stringify(artifact) }], details: artifact }; },
})];
}
}
+22
View File
@@ -40,6 +40,12 @@ describe("InMemoryEventStore", () => {
it("unsubscribes listeners", () => {
const store = new InMemoryEventStore(); const listener = vi.fn(); const unsubscribe = store.subscribe("s", listener); unsubscribe(); store.append("s", { type: "run.completed", payload: {} }); expect(listener).not.toHaveBeenCalled();
});
it("removes the configured server token from SSE payloads", () => {
vi.stubEnv("SWADS_MCP_TOKEN", "server-sentinel-secret");
try { const store = new InMemoryEventStore(); store.append("s", { type: "assistant.delta", payload: { messageId: "m", delta: "Value server-sentinel-secret" } }); expect(JSON.stringify(store.listAfter("s"))).not.toContain("server-sentinel-secret"); }
finally { vi.unstubAllEnvs(); }
});
});
describe("ApprovalBroker", () => {
@@ -106,6 +112,22 @@ describe("SkillCatalog", () => {
it("matches requested skill reads by canonical path", async () => {
const root = await mkdtemp(path.join(tmpdir(), "studio-skills-")); await createSkill(root, ".agents/skills/one", "one", "First skill"); await mkdir(path.join(root, ".pi/skills"), { recursive: true }); const catalog = new SkillCatalog(root, path.join(root, "agent-dir")); await catalog.reload(); expect((await catalog.matchReadPath(path.join(root, ".agents/skills/one/SKILL.md"), root))?.name).toBe("one"); expect(await catalog.matchReadPath("SKILL.md", root)).toBeUndefined();
});
it("expands only the exact Slash command on demand and guards reference paths", async () => {
const root = await mkdtemp(path.join(tmpdir(), "studio-slash-")); const dir = path.join(root, ".agents/skills/swads-daily-report");
await createSkill(root, ".agents/skills/swads-daily-report", "swads-daily-report", "Daily report"); await mkdir(path.join(dir, "references")); await writeFile(path.join(dir, "references/schema.md"), "schema");
await mkdir(path.join(root, ".pi/skills"), { recursive: true }); const catalog = new SkillCatalog(root, path.join(root, "agent")); await catalog.reload();
expect(JSON.stringify(catalog.list())).not.toContain("SECRET BODY");
await writeFile(path.join(dir, "SKILL.md"), "---\nname: swads-daily-report\ndescription: Daily report\n---\nLATEST BODY");
const command = await catalog.expandCommand("/swads-daily-report account=abc date=2026-09-06"); expect(command.text).toContain("LATEST BODY"); expect(command.text).not.toContain("description:"); expect(command.text).toContain("account=abc date=2026-09-06");
expect((await catalog.expandCommand("/swads-daily-report-other")).skill).toBeUndefined(); expect((await catalog.expandCommand("mention /swads-daily-report")).skill).toBeUndefined();
expect((await catalog.matchReadPath(path.join(dir, "references/schema.md"), root))?.name).toBe("swads-daily-report");
expect(await catalog.matchReadPath(path.join(dir, "references"), root)).toBeUndefined();
expect(await catalog.matchReadPath(`${dir}/references/../SKILL.md`, root)).toBeUndefined();
await writeFile(path.join(root, ".agents/skills/outside.md"), "outside"); expect(await catalog.matchReadPath(path.join(root, ".agents/skills/outside.md"), root)).toBeUndefined();
await symlink(path.join(dir, "references/schema.md"), path.join(dir, "references/link.md")); expect(await catalog.matchReadPath(path.join(dir, "references/link.md"), root)).toBeUndefined();
await symlink(path.join(root, ".agents/skills/outside.md"), path.join(dir, "references/escape.md")); expect(await catalog.matchReadPath(path.join(dir, "references/escape.md"), root)).toBeUndefined();
});
});
describe("Base URL safety", () => {
+16
View File
@@ -0,0 +1,16 @@
import type { ReportData } from "../src/report-schema.js";
export function sampleReport(): ReportData {
const campaign = { id: "campaign-1", name: "Demo campaign", status: "ENABLE", spend: 100, revenue: 600, orders: 30, roas: 6, reason: "30 orders; compare sample size before scaling." };
const creative = { id: "creative-1", name: "Demo product", creator: "demo-creator", spend: 20, revenue: 120, orders: 6, roas: 6, clicks: 50, product_ctr_percent: 2, reason: "Only six orders; small sample." };
return {
meta: { report_date: "2026-09-06", account_name: "Demo Commerce", account_id: "demo-account", currency: "MYR", reporting_timezone: "Asia/Kuala_Lumpur", period_label: "2026-09-06", comparison_label: "2026-08-30 to 2026-09-05 · seven complete business days", freshness_label: "2026-09-07 08:00 MYT", attribution_label: "TikTok GMV Max native attribution", status_label: "稳健观察" },
summary: { spend: 100, revenue: 600, orders: 30, ctr_percent: null, cpi: null, roas: 6, simple_roi_percent: 500, cost_per_order: 3.33 },
monitoring_note: "示例数据仅用于测试,不代表真实账户。",
trend: Array.from({ length: 7 }, (_, i) => ({ label: `Day ${i + 1}`, roas: i === 2 ? null : 3 + i, partial: false })),
campaigns: { winners: [campaign], anomalies: [{ ...campaign, name: "Low sample campaign", orders: 2, roas: 1, reason: "Only two orders; uncertainty remains." }] },
creative: { row_count: 100, coverage_percent: 80, source_groups: [{ label: "Creator", spend: 20, revenue: 120, orders: 6, roas: 6 }], winners: [creative], anomalies: [], insights: ["Creator attribution is correlated with efficiency; not a causal claim."] },
recommendations: [{ title: "观察高效 Campaign", detail: "以 30 单样本为依据,先检查下一完整业务日。", confirmation: "none" }, { title: "检查异常素材", detail: "低样本不足以支持立即暂停,核对归因。", confirmation: "none" }, { title: "预算调整候选", detail: "预算调整前人工复核订单与成本。", confirmation: "required" }],
manual_confirmations: [{ action: "预算调整", required: true, reason: "Changes live delivery" }],
notes: ["简化广告 ROI 未扣商品、佣金、退款与履约成本。", "CTR 与 CPI 缺失为 N/A。"],
};
}
+84
View File
@@ -0,0 +1,84 @@
import { mkdtemp, readFile, readdir, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import sharp from "sharp";
import { reportDataSchema } from "../src/report-schema.js";
import { ReportStore } from "../src/report-store.js";
import { discoverChrome, renderReportHtml, renderReportPng, type ChromeRunner } from "../src/report-renderer.js";
import { sampleReport } from "./report-fixture.js";
const cssPath = path.resolve(import.meta.dirname, "../../../.agents/skills/swads-daily-report/assets/report.css");
const signal = () => new AbortController().signal;
const root = () => mkdtemp(path.join(tmpdir(), "swads-report-test-"));
describe("normalized report", () => {
it("keeps null, multiplier and percentage units and exactly three recommendations", () => {
const data = reportDataSchema.parse(sampleReport()); expect(data.summary).toMatchObject({ ctr_percent: null, roas: 6, simple_roi_percent: 500 });
for (const mutate of [(d: ReturnType<typeof sampleReport>) => { d.recommendations.pop(); }, (d: ReturnType<typeof sampleReport>) => { d.meta.report_date = "2026-02-30"; }, (d: ReturnType<typeof sampleReport>) => { d.summary.spend = 0; }, (d: ReturnType<typeof sampleReport>) => { d.manual_confirmations = []; }]) { const invalid = sampleReport(); mutate(invalid); expect(reportDataSchema.safeParse(invalid).success).toBe(false); }
expect(reportDataSchema.safeParse({ ...data, outputPath: "/tmp/x" }).success).toBe(false);
expect(reportDataSchema.safeParse({ ...data, summary: { ...data.summary, roas: "6x" } }).success).toBe(false);
});
it("escapes all dynamic HTML and excludes external resources", async () => {
const data = sampleReport(); data.meta.account_name = '<script src="https://evil">&'; data.creative.insights = ["</style><img src=x onerror=alert(1)>"];
const html = renderReportHtml(data, await readFile(cssPath, "utf8")); expect(html).toContain("&lt;script"); expect(html).not.toContain('<script'); expect(html).not.toContain('<img'); expect(html).not.toContain("fonts.googleapis"); expect(html).toContain("N/A"); expect(html).toContain("500%"); expect(html).toContain("6×");
});
});
describe("PNG renderer", () => {
it("degrades when Chrome is missing without invoking a process", async () => { const runner = vi.fn(); expect((await renderReportPng("/tmp/report.html", signal(), { discover: async () => undefined, runner })).warning?.code).toBe("CHROME_UNAVAILABLE"); expect(runner).not.toHaveBeenCalled(); });
it("uses fixed args, retries truncation, crops only a verified complete image", async () => {
const heights: number[] = [];
const runner: ChromeRunner = async (exe, args) => {
expect(exe).toBe("/trusted/chrome"); expect(args).toContain("--host-resolver-rules=MAP * ~NOTFOUND"); expect(args.at(-1)).toBe("file:///tmp/report%20$(x).html");
const height = Number(args.find((s) => s.startsWith("--window-size="))!.split(",")[1]); heights.push(height);
const output = args.find((s) => s.startsWith("--screenshot="))!.slice(13);
const overlays = height > 5200 ? [{ input: await sharp({ create: { width: 1440, height: 8, channels: 3, background: "#1234ab" } }).png().toBuffer(), top: 6000, left: 0 }] : [];
await sharp({ create: { width: 1440, height, channels: 3, background: "#e8ebe6" } }).composite([{ input: await sharp({ create: { width: 1200, height: 2000, channels: 3, background: "white" } }).png().toBuffer(), top: 24, left: 120 }, ...overlays]).png().toFile(output);
};
const result = await renderReportPng('/tmp/report $(x).html', signal(), { discover: async () => "/trusted/chrome", runner });
expect(heights).toEqual([5200, 9000]); expect(result.warning).toBeUndefined(); expect((await sharp(result.png!).metadata()).height).toBe(2048);
});
it("returns safe timeout/failure warnings and honors abort", async () => {
const options = { discover: async () => "/trusted/chrome", runner: async () => { throw Object.assign(new Error("/private/token-secret"), { killed: true }); } };
expect(await renderReportPng("/tmp/x", signal(), options)).toMatchObject({ warning: { code: "PNG_TIMEOUT" } });
const controller = new AbortController(); controller.abort(); await expect(renderReportPng("/tmp/x", controller.signal, options)).rejects.toThrow();
});
it("renders a real sample and longest allowed report when Chrome is installed", async () => {
const chrome = await discoverChrome(); if (!chrome) return;
const folder = await root(); const css = await readFile(cssPath, "utf8");
const longest = sampleReport(); const long = "最长允许内容,检查换行与画布完整性。".repeat(30).slice(0, 500);
longest.campaigns.winners = Array.from({ length: 5 }, () => ({ ...longest.campaigns.winners[0]!, name: long.slice(0, 160), id: long.slice(0, 160), reason: long })); longest.campaigns.anomalies = structuredClone(longest.campaigns.winners);
longest.creative.winners = Array.from({ length: 5 }, () => ({ ...longest.creative.winners[0]!, name: long.slice(0, 160), id: long.slice(0, 160), reason: long })); longest.creative.anomalies = structuredClone(longest.creative.winners);
longest.creative.winners.forEach((row) => { row.creator = long.slice(0, 160); }); longest.creative.anomalies = structuredClone(longest.creative.winners);
longest.creative.source_groups = Array.from({ length: 6 }, () => ({ label: long.slice(0, 160), spend: 100, revenue: 600, orders: 30, roas: 6 }));
longest.meta.account_name = long.slice(0, 160); longest.monitoring_note = long;
longest.creative.insights = Array(4).fill(long); longest.notes = Array(10).fill(long); longest.manual_confirmations = Array.from({ length: 10 }, () => ({ action: long.slice(0, 160), required: true, reason: long })); longest.recommendations.forEach((r) => { r.detail = long; r.title = long.slice(0, 160); });
for (const [name, data] of [["sample", sampleReport()], ["longest", longest]] as const) {
const htmlPath = path.join(folder, `${name}.html`); await writeFile(htmlPath, renderReportHtml(data, css)); const result = await renderReportPng(htmlPath, signal());
expect(result.warning, JSON.stringify(result.warning)).toBeUndefined(); expect(result.png).toBeDefined(); const metadata = await sharp(result.png!).metadata(); expect(metadata.width).toBeGreaterThan(1200); expect(metadata.height).toBeGreaterThan(1500); expect(metadata.height).toBeLessThan(24000); await writeFile(path.join(folder, `${name}.png`), result.png!);
}
}, 120_000);
});
describe("ReportStore", () => {
it("atomically persists independent reports and allows only strict artifact paths", async () => {
const folder = await root(); const store = new ReportStore(folder, cssPath, async () => ({ warning: { code: "CHROME_UNAVAILABLE", message: "HTML/JSON available" } }));
const reports = await Promise.all([store.create(sampleReport(), signal()), store.create(sampleReport(), signal())]); expect(reports[0]!.reportId).not.toBe(reports[1]!.reportId);
const { accountKey, reportDate, reportId } = reports[0]!; const segments = { accountKey, reportDate, reportId };
expect(JSON.stringify(reports)).not.toContain(folder); expect((await store.read(segments, "json")).bytes.toString()).toContain('"roas": 6'); await expect(store.read(segments, "png")).rejects.toMatchObject({ code: "REPORT_NOT_FOUND" });
await expect(store.read({ ...segments, accountKey: ".." }, "json")).rejects.toMatchObject({ code: "REPORT_NOT_FOUND" }); await expect(store.read(segments, "../../x")).rejects.toThrow();
const external = path.join(await root(), "secret"); await writeFile(external, "secret"); await symlink(external, path.join(folder, accountKey, reportDate, reportId, "report.png")); await expect(store.read(segments, "png")).rejects.toThrow();
expect((await readdir(path.join(folder, accountKey, reportDate))).some((name) => name.startsWith(".tmp"))).toBe(false);
});
it("does not persist the configured server token in JSON or HTML", async () => {
vi.stubEnv("SWADS_MCP_TOKEN", "server-sentinel-secret");
try {
const store = new ReportStore(await root(), cssPath, async () => ({ warning: { code: "CHROME_UNAVAILABLE", message: "HTML/JSON available" } }));
const data = sampleReport(); data.notes.push("server-sentinel-secret"); const { accountKey, reportDate, reportId } = await store.create(data, signal());
for (const format of ["html", "json"]) expect((await store.read({ accountKey, reportDate, reportId }, format)).bytes.toString()).not.toContain("server-sentinel-secret");
} finally { vi.unstubAllEnvs(); }
});
it("cleans temporary files on failure and blocks symlink storage", async () => {
const folder = await root(); const store = new ReportStore(folder, cssPath, async () => { throw new Error("failed"); }); await expect(store.create(sampleReport(), signal())).rejects.toThrow();
const account = (await readdir(folder))[0]!; const date = path.join(folder, account, "2026-09-06"); expect(await readdir(date)).toEqual([]);
const linked = path.join(await root(), "reports"); await symlink(folder, linked); await expect(new ReportStore(linked, cssPath).create(sampleReport(), signal())).rejects.toMatchObject({ code: "REPORT_STORE_UNSAFE" });
});
});
+22 -2
View File
@@ -1,4 +1,4 @@
import { mkdtemp, mkdir, readFile, stat } from "node:fs/promises";
import { mkdtemp, mkdir, readFile, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
@@ -45,7 +45,7 @@ async function setup(supportsImages = true) {
const events = new InMemoryEventStore(); const attachments = new FileAttachmentStore(sessionsRoot); const models = new InMemoryModelConnectionStore({}); const factory = new FakeFactory(supportsImages);
const registry = new InMemorySessionRegistry(sessionsRoot, events, attachments, models, catalog, factory, 500, 5_000);
const session = await registry.create(); await registry.configureModel(session.sessionId, { providerId: "fake", api: "openai-responses", apiKey: "test-secret", modelId: "fake-model", supportsImages });
return { root, sessionsRoot, events, attachments, models, factory, registry, session };
return { root, sessionsRoot, events, attachments, models, catalog, factory, registry, session };
}
describe("session lifecycle", () => {
@@ -80,6 +80,26 @@ describe("session lifecycle", () => {
it("derives skill requested/loaded callbacks with the same toolCallId", async () => {
const context = await setup(); await context.registry.send(context.session.sessionId, { text: "hello", attachmentIds: [] }); const skill = { name: "ads-analysis", description: "x", source: "project", filePath: ".agents/skills/ads-analysis/SKILL.md", canonicalPath: "/tmp/SKILL.md", diagnostics: [] }; context.factory.input?.skillRequested(skill, "read_1"); context.factory.input?.skillLoaded(skill, "read_1"); const skillEvents = context.events.listAfter(context.session.sessionId).events.filter((event) => event.type.startsWith("skill.")); expect(skillEvents.map((event) => event.type)).toEqual(["skill.requested", "skill.loaded"]); context.factory.port.complete();
});
it("preserves Slash user text and emits command activation without a fake toolCallId", async () => {
const context = await setup(); const dir = path.join(context.root, ".agents/skills/swads-daily-report"); await mkdir(dir); await writeFile(path.join(dir, "SKILL.md"), "---\nname: swads-daily-report\ndescription: report\ndisable-model-invocation: true\n---\nINVOKED BODY");
await context.catalog.reload();
const prompt = vi.spyOn(context.factory.port, "prompt"); const original = "/swads-daily-report account=demo date=2026-09-06";
await context.registry.send(context.session.sessionId, { text: original, attachmentIds: [] });
await vi.waitFor(() => expect(prompt).toHaveBeenCalled()); expect(prompt.mock.calls[0]![0]).toContain("<skill name="); expect(prompt.mock.calls[0]![0]).toContain("account=demo date=2026-09-06");
const events = context.events.listAfter(context.session.sessionId).events; expect(events.find((e) => e.type === "user.message")?.payload).toMatchObject({ text: original });
const activations = events.filter((e) => e.type === "skill.loaded" || e.type === "skill.requested"); expect(activations).toHaveLength(2); for (const e of activations) { expect(e.payload.source).toBe("command"); expect(e.payload.toolCallId).toBeUndefined(); }
context.factory.port.complete();
});
it("emits only report metadata on completion and preserves classified tool failures", async () => {
const context = await setup(false); await context.registry.send(context.session.sessionId, { text: "report", attachmentIds: [] });
const accountKey = "acct_0123456789abcdef01234567", reportDate = "2026-09-06", reportId = "12345678-1234-4234-8234-123456789abc"; const url = `/api/reports/${accountKey}/${reportDate}/${reportId}`;
const artifact = { accountKey, reportDate, reportId, previewUrl: `${url}/png`, downloads: { png: `${url}/png`, html: `${url}/html`, json: `${url}/json` } };
context.factory.port.emit({ type: "tool_execution_end", toolCallId: "render", toolName: "render_swads_daily_report", isError: false, result: { content: [{ type: "text", text: "/private/SHOULD_NOT_LEAK full report body" }], details: artifact } });
context.factory.port.emit({ type: "tool_execution_end", toolCallId: "whoami", toolName: "swads_cli", isError: true, result: { content: [{ type: "text", text: "SWADS_AUTH_FAILED" }] } });
const events = context.events.listAfter(context.session.sessionId).events; const generated = events.find((e) => e.type === "report.generated"); expect(generated?.payload).toMatchObject({ assistantMessageId: context.session.currentRun!.assistantMessageId, artifact }); expect(JSON.stringify(events)).not.toContain("SHOULD_NOT_LEAK"); expect(events.find((e) => e.type === "tool.failed")?.payload).toMatchObject({ error: "SWADS_AUTH_FAILED" }); context.factory.port.complete();
});
});
describe("multimodal session path", () => {
+73
View File
@@ -0,0 +1,73 @@
import { describe, expect, it, vi } from "vitest";
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
import { SwadsGateway, gatewayConfig, mcpConnector, MAX_GATEWAY_BYTES, type GatewayConnection } from "../src/swads-gateway.js";
import { SwadsReportTools, commands, validateCli } from "../src/swads-tools.js";
import type { ReportStore } from "../src/report-store.js";
import { sampleReport } from "./report-fixture.js";
const signal = () => new AbortController().signal;
const token = "private-sentinel-swads-value";
const config = () => gatewayConfig({ SWADS_MCP_TOKEN: token, SWADS_MCP_TIMEOUT_MS: "1000" });
const capabilities = (): Tool[] => Object.values(commands).filter((name) => name !== "tools/list").map((name) => ({ name, inputSchema: { type: "object" }, annotations: { readOnlyHint: true, destructiveHint: false } }));
function fixture(tools = capabilities()) {
const connection: GatewayConnection = { list: vi.fn(async () => tools), call: vi.fn(async (name) => ({ content: [{ type: "text", text: JSON.stringify(name === "metrics_semantic_query" ? { columns: [{ name: "spend" }], rows: [[100]] } : { platform: "tiktok", account_id: "demo", metrics: ["spend"], echo: token }) }] })), close: vi.fn(async () => undefined) };
const gateway = new SwadsGateway(config(), async () => connection); const create = vi.fn(async () => ({ ok: true }));
let runId = "run_1";
const toolsFacade = new SwadsReportTools(gateway, { create } as unknown as ReportStore, () => ({ runId, signal: signal() }));
return { connection, gateway, tools: toolsFacade, create, nextRun: () => { runId = "run_2"; } };
}
async function preflight(tools: SwadsReportTools) { await tools.cli({ command: "capabilities", arguments: {} }, signal()); await tools.cli({ command: "whoami", arguments: {} }, signal()); await tools.cli({ command: "metrics.catalog", arguments: { platform: "tiktok" } }, signal()); await tools.cli({ command: "metrics.query", arguments: { platform: "tiktok", query: { metrics: ["spend"] } } }, signal()); }
describe("swads CLI boundary", () => {
it("rejects unknown commands, arbitrary control parameters and oversized/deep inputs", () => {
for (const input of [{ command: "tools/call", arguments: {} }, { command: "whoami", arguments: { url: "https://evil" } }, { command: "metrics.catalog", arguments: { platform: "vk" } }, { command: "metrics.query", arguments: { platform: "tiktok", query: { platform: "vk" } } }, { command: "metrics.query", arguments: { platform: "tiktok", query: { headers: { x: "y" } } } }, { command: "whoami", arguments: { name: "x" } }]) expect(() => validateCli(input)).toThrow();
let nested: unknown = {}; for (let i = 0; i < 12; i++) nested = { child: nested }; expect(() => validateCli({ command: "metrics.query", arguments: { platform: "tiktok", query: nested } })).toThrow();
expect(() => validateCli({ command: "metrics.query", arguments: { platform: "tiktok", query: { large: "x".repeat(70_000) } } })).toThrow();
});
it("maps only fixed tools, unwraps JSON, caches capabilities and redacts token before model delivery", async () => {
const f = fixture(); const result = await f.tools.cli({ command: "whoami", arguments: {} }, signal()); expect(result).toMatchObject({ echo: "[REDACTED]" });
await f.tools.cli({ command: "metrics.catalog", arguments: { platform: "tiktok" } }, signal()); expect(f.connection.list).toHaveBeenCalledTimes(1); expect(f.connection.call).toHaveBeenCalledWith("metrics_catalog", { platform: "tiktok" }, expect.any(AbortSignal));
expect(JSON.stringify(f.tools.definitions())).not.toContain(token);
});
it("refuses missing tools, non-read-only annotations and remote input drift", async () => {
const f = fixture(capabilities().filter((tool) => tool.name !== "metrics_semantic_query")); await expect(f.tools.cli({ command: "capabilities", arguments: {} }, signal())).rejects.toMatchObject({ code: "SWADS_TOOL_MISSING" }); await expect(f.tools.render(sampleReport(), signal())).rejects.toMatchObject({ code: "SWADS_PREFLIGHT_REQUIRED" }); expect(f.create).not.toHaveBeenCalled();
const list = capabilities(); list.find((tool) => tool.name === "runtime_findings")!.annotations = { readOnlyHint: false, destructiveHint: true }; const unsafe = fixture(list); await expect(unsafe.tools.cli({ command: "runtime.findings", arguments: { platform: "tiktok", external_account_id: "demo" } }, signal())).rejects.toMatchObject({ code: "SWADS_NOT_READ_ONLY" }); expect(unsafe.connection.call).not.toHaveBeenCalled();
const drift = capabilities(); drift.find((tool) => tool.name === "metrics_catalog")!.inputSchema = { type: "object", properties: { platform: { enum: ["vk", "yandex"] } } }; await expect(fixture(drift).tools.cli({ command: "metrics.catalog", arguments: { platform: "tiktok" } }, signal())).rejects.toMatchObject({ code: "SWADS_TOOL_DRIFT" });
});
it("requires successful real preflight and rows in this run to render", async () => {
const f = fixture(); await expect(f.tools.render(sampleReport(), signal())).rejects.toThrow(); await preflight(f.tools); const report = sampleReport(); report.notes.push(token); await f.tools.render(report, signal()); expect(JSON.stringify(vi.mocked(f.create).mock.calls)).not.toContain(token); f.nextRun(); await expect(f.tools.render(report, signal())).rejects.toThrow();
});
it("invalidates render evidence after query failure and rejects empty data", async () => {
const f = fixture(); await preflight(f.tools); vi.mocked(f.connection.call).mockResolvedValue({ structuredContent: { columns: [{ name: "spend" }], rows: [] } }); await expect(f.tools.cli({ command: "metrics.query", arguments: { platform: "tiktok", query: {} } }, signal())).rejects.toMatchObject({ code: "SWADS_NO_DATA" }); await expect(f.tools.render(sampleReport(), signal())).rejects.toThrow();
});
it("classifies upstream permission envelopes and response limits without their contents", async () => {
for (const [status, code] of [[401, "SWADS_AUTH_FAILED"], [403, "SWADS_FORBIDDEN"]] as const) { const f = fixture(); vi.mocked(f.connection.call).mockResolvedValue({ structuredContent: { error_code: "upstream", upstream_status: status, detail: token } }); await expect(f.tools.cli({ command: "whoami", arguments: {} }, signal())).rejects.toMatchObject({ code, message: code }); }
const f = fixture(); vi.mocked(f.connection.call).mockResolvedValue({ structuredContent: { data: "x".repeat(MAX_GATEWAY_BYTES + 1) } }); await expect(f.tools.cli({ command: "whoami", arguments: {} }, signal())).rejects.toMatchObject({ code: "SWADS_RESPONSE_TOO_LARGE" });
});
it("validates startup URL but defers missing credentials to first call", async () => {
expect(() => gatewayConfig({ SWADS_MCP_URL: "file:///tmp/x" })).toThrow(); expect(() => gatewayConfig({ SWADS_MCP_URL: "https://u:p@example.test" })).toThrow(); const gateway = new SwadsGateway(gatewayConfig({})); await expect(gateway.execute(async () => true, signal())).rejects.toMatchObject({ code: "SWADS_NOT_CONFIGURED" });
});
it("cancels and times out blocked transport calls, closing connections", async () => {
const connection: GatewayConnection = { list: async () => [], call: async () => new Promise(() => undefined), close: vi.fn(async () => undefined) };
const gateway = new SwadsGateway({ ...config(), timeoutMs: 100 }, async () => connection); await expect(gateway.execute((conn, s) => conn.call("x", {}, s), signal())).rejects.toMatchObject({ code: "SWADS_TIMEOUT" });
const controller = new AbortController(); const pending = gateway.execute((conn, s) => conn.call("x", {}, s), controller.signal); controller.abort(); await expect(pending).rejects.toMatchObject({ code: "SWADS_ABORTED" }); expect(connection.close).toHaveBeenCalled();
});
});
describe("official MCP Streamable HTTP transport with fake fetch", () => {
it("initializes, lists and calls over MCP with server-only Authorization", async () => {
const methods: string[] = [];
const fetcher: typeof fetch = async (_url, init) => {
expect(new Headers(init?.headers).get("authorization")).toBe(`Bearer ${token}`);
if (init?.method === "GET") return new Response(null, { status: 405 });
const message = JSON.parse(String(init?.body)); methods.push(message.method);
if (message.id === undefined) return new Response(null, { status: 202 });
const result = message.method === "initialize" ? { protocolVersion: "2025-03-26", capabilities: { tools: {} }, serverInfo: { name: "fake", version: "1" } } : message.method === "tools/list" ? { tools: capabilities() } : { content: [{ type: "text", text: '{"ok":true}' }] };
return Response.json({ jsonrpc: "2.0", id: message.id, result });
};
const gateway = new SwadsGateway(config(), mcpConnector(config(), fetcher));
await gateway.execute(async (conn, s) => { expect(await conn.list(s)).toHaveLength(8); await conn.call("swads_whoami", {}, s); }, signal());
expect(methods).toEqual(expect.arrayContaining(["initialize", "notifications/initialized", "tools/list", "tools/call"]));
});
it.each([[401, "SWADS_AUTH_FAILED"], [403, "SWADS_FORBIDDEN"], [503, "SWADS_UNAVAILABLE"]])("classifies HTTP %s without leaking body", async (status, code) => {
const fetcher: typeof fetch = async () => new Response(token, { status: Number(status) }); const gateway = new SwadsGateway(config(), mcpConnector(config(), fetcher)); await expect(gateway.execute(async () => true, signal())).rejects.toMatchObject({ code, message: code });
});
it("bounds response bytes before JSON parsing", async () => { const fetcher: typeof fetch = async () => new Response("x".repeat(MAX_GATEWAY_BYTES + 1), { headers: { "content-type": "application/json", "content-length": String(MAX_GATEWAY_BYTES + 1) } }); const gateway = new SwadsGateway(config(), mcpConnector(config(), fetcher)); await expect(gateway.execute(async () => true, signal())).rejects.toMatchObject({ code: "SWADS_RESPONSE_TOO_LARGE" }); });
});
+4 -3
View File
@@ -1,5 +1,5 @@
import { z } from "zod";
import { approvalRequestSchema, imageAttachmentSchema, llmApiSchema } from "./schemas.js";
import { approvalRequestSchema, imageAttachmentSchema, llmApiSchema, reportArtifactSchema } from "./schemas.js";
const baseShape = {
eventId: z.string(),
@@ -22,8 +22,9 @@ export const harnessEventSchema = z.discriminatedUnion("type", [
event("user.message", z.object({ messageId: z.string(), text: z.string(), attachmentIds: z.array(z.string()) }).strict()),
event("assistant.delta", z.object({ messageId: z.string(), delta: z.string() }).strict()),
event("assistant.completed", z.object({ messageId: z.string() }).strict()),
event("skill.requested", z.object({ name: z.string(), filePath: z.string(), toolCallId: z.string() }).strict()),
event("skill.loaded", z.object({ name: z.string(), filePath: z.string(), toolCallId: z.string() }).strict()),
event("skill.requested", z.object({ name: z.string(), filePath: z.string(), toolCallId: z.string().optional(), source: z.enum(["read", "command"]) }).strict()),
event("skill.loaded", z.object({ name: z.string(), filePath: z.string(), toolCallId: z.string().optional(), source: z.enum(["read", "command"]) }).strict()),
event("report.generated", z.object({ assistantMessageId: z.string(), artifact: reportArtifactSchema }).strict()),
event("tool.requested", toolBase),
event("tool.completed", z.object({ toolCallId: z.string(), toolName: z.string(), result: z.unknown().optional() }).strict()),
event("tool.failed", z.object({ toolCallId: z.string(), toolName: z.string(), error: z.string() }).strict()),
+14
View File
@@ -89,3 +89,17 @@ export const skillCatalogItemSchema = z.object({
diagnostics: z.array(skillDiagnosticSchema),
}).strict();
export type SkillCatalogItem = z.infer<typeof skillCatalogItemSchema>;
export const reportSegmentsSchema = z.object({
accountKey: z.string().regex(/^acct_[a-f0-9]{24}$/),
reportDate: z.iso.date(),
reportId: z.string().uuid(),
}).strict();
export const reportFormatSchema = z.enum(["png", "html", "json"]);
const reportUrl = z.string().regex(/^\/api\/reports\/acct_[a-f0-9]{24}\/\d{4}-\d{2}-\d{2}\/[a-f0-9-]{36}\/(png|html|json)$/);
export const reportArtifactSchema = reportSegmentsSchema.extend({
previewUrl: reportUrl.optional(),
downloads: z.object({ json: reportUrl, html: reportUrl, png: reportUrl.optional() }).strict(),
warning: z.object({ code: z.enum(["CHROME_UNAVAILABLE", "PNG_RENDER_FAILED", "PNG_TIMEOUT", "PNG_TRUNCATED"]), message: z.string().max(300) }).strict().optional(),
}).strict();
export type ReportArtifact = z.infer<typeof reportArtifactSchema>;
+10
View File
@@ -31,3 +31,13 @@ describe("shared boundary schemas", () => {
expect(apiErrorSchema.parse({ error: { code: "SESSION_NOT_FOUND", message: "Session not found", requestId: "req_1", details: {} } }).error.code).toBe("SESSION_NOT_FOUND");
});
});
it("accepts report metadata and rejects paths and malformed URL formats", () => {
const base = { eventId: "report", sessionId: "s", timestamp: new Date().toISOString(), sequence: 1, type: "report.generated" };
const accountKey = "acct_0123456789abcdef01234567", reportDate = "2026-09-06", reportId = "12345678-1234-4234-8234-123456789abc";
const url = `/api/reports/${accountKey}/${reportDate}/${reportId}`;
const artifact = { accountKey, reportDate, reportId, downloads: { json: `${url}/json`, html: `${url}/html` } };
expect(harnessEventSchema.safeParse({ ...base, payload: { assistantMessageId: "m", artifact } }).success).toBe(true);
expect(harnessEventSchema.safeParse({ ...base, payload: { assistantMessageId: "m", artifact: { ...artifact, absolutePath: "/private/report" } } }).success).toBe(false);
expect(harnessEventSchema.safeParse({ ...base, payload: { assistantMessageId: "m", artifact: { ...artifact, previewUrl: "https://evil.test/report.png" } } }).success).toBe(false);
});