feat(swads): add daily image report workflow
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
||||
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 }); }
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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); }
|
||||
@@ -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));
|
||||
|
||||
@@ -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("&", "&").replaceAll('"', """).replaceAll("<", "<").replaceAll(">", ">");
|
||||
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); }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 }; },
|
||||
})];
|
||||
}
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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。"],
|
||||
};
|
||||
}
|
||||
@@ -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("<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" });
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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" }); });
|
||||
});
|
||||
@@ -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()),
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user