From 5c61371d7e508fef64e6f1ba5df501a8b5fba0b7 Mon Sep 17 00:00:00 2001 From: Jeffrey Wu Date: Mon, 7 Sep 2026 11:34:00 +0800 Subject: [PATCH] feat(swads): add daily image report workflow --- .agents/skills/swads-daily-report/SKILL.md | 27 + .../swads-daily-report/assets/report.css | 141 ++ .../references/report-data-schema.md | 74 + .env.example | 6 + .gitignore | 2 + README.md | 12 + apps/server/src/app.ts | 17 +- apps/server/tests/app.test.ts | 13 +- apps/web/package.json | 15 +- apps/web/src/assistant/store.ts | 41 +- apps/web/src/components/ChatPanel.tsx | 9 +- apps/web/src/components/ToolUIs.tsx | 8 + apps/web/src/styles/app.css | 11 + apps/web/tests/store.test.ts | 18 + apps/web/tests/ui.test.tsx | 33 +- docs/architecture.md | 10 + docs/security-boundaries.md | 8 + packages/harness/package.json | 20 +- packages/harness/src/errors.ts | 16 +- packages/harness/src/event-store.ts | 3 +- packages/harness/src/index.ts | 6 + packages/harness/src/pi-event-adapter.ts | 24 +- packages/harness/src/report-renderer.ts | 108 ++ packages/harness/src/report-schema.ts | 28 + packages/harness/src/report-store.ts | 56 + packages/harness/src/session.ts | 26 +- packages/harness/src/skills.ts | 33 +- packages/harness/src/swads-gateway.ts | 108 ++ packages/harness/src/swads-tools.ts | 134 ++ packages/harness/tests/core.test.ts | 22 + packages/harness/tests/report-fixture.ts | 16 + packages/harness/tests/reports.test.ts | 84 ++ packages/harness/tests/session.test.ts | 24 +- packages/harness/tests/swads.test.ts | 73 + packages/shared/src/events.ts | 7 +- packages/shared/src/schemas.ts | 14 + packages/shared/tests/schemas.test.ts | 10 + pnpm-lock.yaml | 1332 ++++++++++++++++- 38 files changed, 2537 insertions(+), 52 deletions(-) create mode 100644 .agents/skills/swads-daily-report/SKILL.md create mode 100644 .agents/skills/swads-daily-report/assets/report.css create mode 100644 .agents/skills/swads-daily-report/references/report-data-schema.md create mode 100644 packages/harness/src/report-renderer.ts create mode 100644 packages/harness/src/report-schema.ts create mode 100644 packages/harness/src/report-store.ts create mode 100644 packages/harness/src/swads-gateway.ts create mode 100644 packages/harness/src/swads-tools.ts create mode 100644 packages/harness/tests/report-fixture.ts create mode 100644 packages/harness/tests/reports.test.ts create mode 100644 packages/harness/tests/swads.test.ts diff --git a/.agents/skills/swads-daily-report/SKILL.md b/.agents/skills/swads-daily-report/SKILL.md new file mode 100644 index 0000000..de08103 --- /dev/null +++ b/.agents/skills/swads-daily-report/SKILL.md @@ -0,0 +1,27 @@ +--- +name: swads-daily-report +description: Generate a read-only TikTok GMV Max daily report with Markdown analysis and a PNG long image. Invoke explicitly with /swads-daily-report. +disable-model-invocation: true +--- + +# SW Ads daily report + +Use only `swads_cli` to read real SW Ads data and `render_swads_daily_report` to create artifacts. Never substitute mock data, execute platform writes, request tokens in chat, or run shell commands. + +## Preflight and scope + +1. Call `swads_cli` with command `capabilities`, arguments `{}`. Require read-only `swads_whoami`, `metrics_catalog`, `metrics_semantic_query` and TikTok GMV Max support. Inspect returned input schemas; do not infer online capabilities from another installation. If missing, incompatible, or non-read-only, stop before rendering and explain the capability error. +2. Call `whoami` with `{}` to verify identity and authorization. Use its current account, reporting timezone, currency, and business date (use capabilities.server_time converted to that timezone if needed). Default to the most recent complete business day, with the preceding seven complete days as context. Honor account/date overrides from the text after the Slash command without changing the remote current account. Resolve overrides against visible accounts. If identity, timezone, account or platform cannot be established, ask for the missing scope and stop. First version supports TikTok GMV Max only. +3. Call `metrics.catalog` with `{"platform":"tiktok"}`. Require supported TikTok metrics/dimensions and query shapes. Missing capabilities, auth/permission errors, invalid results or unavailable data must stop generation; never render an empty or fabricated report. A missing server token is SWADS_NOT_CONFIGURED; tell the user to configure it on the server, never paste it into chat. + +## Collect and analyze + +- Use `metrics.query` with a nested `query` following the catalog, explicit account/timezone/date scope, and platform tiktok. Query report-day summary (spend, impressions, clicks, CTR, CPI, GMV, orders, ROAS, cost per order), the preceding seven complete daily rows, nonzero-spend campaigns and creatives (ID, title, creator, spend, GMV, orders, product impressions/clicks/CTR). Map every row using returned `columns[].name`; never assume positional order. +- Read available read-only `runtime.account-overview`, `runtime.proposals`, `runtime.config`, and `campaign.list`, passing the same account and platform. Paginate campaign.list only until all queried campaign IDs are mapped or results exhausted. Disclose unavailable optional context. Never call stateful runtime.findings: derive findings from semantic metrics if unavailable/non-read-only. +- Account status includes Spend, CTR, CPI, ROAS (GMV/spend), and simplified advertising ROI ((GMV-spend)/spend, percentage points). Missing inputs/denominators are null/N/A, never zero. Exclude product, commission, refund and fulfillment costs explicitly. +- Rank winners and anomalies by both efficiency and sample size; identify low-sample limitations in reason. Prefer active campaigns and note material disabled spend separately. Creative attribution is correlation, not causality; distinguish account-owned posts from other creators and disclose source/coverage. +- Produce exactly three prioritized, evidence-backed recommendations. List every proposed pause/resume, budget/target, pin/unpin or publishing write for manual confirmation. No platform writes are executed. A partial current day is a labeled monitoring note only. + +## Deliver + +Read [references/report-data-schema.md](references/report-data-schema.md) and normalize the observed data strictly, including ISO report_date. First output concise Markdown: account conclusion, key anomalies, three recommendations, dates/timezone and attribution caveats. Then call `render_swads_daily_report` with `{"data": }` as the final action. The server owns storage paths and rendering. PNG is the main delivery; HTML/JSON downloads provide traceability. If PNG is unavailable, surface the structured warning and available downloads; never claim an image was generated. Do not output another analysis after the render tool. diff --git a/.agents/skills/swads-daily-report/assets/report.css b/.agents/skills/swads-daily-report/assets/report.css new file mode 100644 index 0000000..3fb4c1f --- /dev/null +++ b/.agents/skills/swads-daily-report/assets/report.css @@ -0,0 +1,141 @@ + +:root { + --primary: #9fe870; + --primary-pale: #e2f6d5; + --canvas: #ffffff; + --canvas-soft: #e8ebe6; + --ink: #0e0f0c; + --ink-deep: #163300; + --body: #454745; + --mute: #868685; + --line: #d4dad1; + --positive: #2ead4b; + --positive-deep: #054d28; + --warning: #ffd11a; + --warning-deep: #b86700; + --warning-bg: #fff4c2; + --warning-content: #4a3b1c; + --negative: #d03238; + --negative-deep: #a72027; + --negative-bg: #320707; +} + +* { box-sizing: border-box; } +html { background: var(--canvas-soft); } +body { + width: 100%; + margin: 0; + color: var(--ink); + background: var(--canvas-soft); + font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; + font-feature-settings: "calt"; + font-size: 16px; + line-height: 1.5; +} +.report { width: 100%; max-width: 1320px; margin: 0 auto; padding: 56px 44px; } +.hero { display: grid; grid-template-columns: minmax(0, 1fr) 360px; gap: 48px; align-items: center; } +.eyebrow { display: inline-block; padding: 6px 14px; border-radius: 9999px; color: var(--ink-deep); background: var(--primary); font-size: 12px; font-weight: 600; letter-spacing: .08em; } +h1 { margin: 24px 0 16px; font-size: 72px; font-weight: 900; line-height: .88; letter-spacing: -.055em; } +.subtitle { margin: 0; color: var(--body); } +.status { padding: 28px; border-radius: 24px; color: var(--primary); background: var(--ink); } +.status strong { display: block; font-size: 24px; font-weight: 600; } +.status span { display: block; margin-top: 10px; color: var(--canvas-soft); font-size: 14px; } +.kpis { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; margin-top: 48px; } +.kpi { min-width: 0; min-height: 168px; padding: 24px; border-radius: 24px; background: var(--canvas); } +.kpi.accent { background: var(--primary-pale); } +.kpi-label { color: var(--body); font-size: 14px; font-weight: 600; } +.kpi-value { display: block; margin-top: 22px; font-size: 39px; font-weight: 900; line-height: .98; letter-spacing: -.04em; white-space: nowrap; } +.kpi-note { display: block; margin-top: 10px; color: var(--body); font-size: 14px; } +.good { color: var(--positive-deep); } +.bad { color: var(--negative-deep); } +.warn { color: var(--warning-deep); } +.callout { display: grid; grid-template-columns: 24px 1fr; gap: 12px; margin-top: 16px; padding: 22px 24px; border-radius: 24px; color: var(--warning-content); background: var(--warning-bg); } +.callout strong { font-weight: 600; } +.callout p { margin: 3px 0 0; font-size: 14px; } +section { margin-top: 56px; } +.section-head { display: flex; justify-content: space-between; gap: 32px; align-items: end; margin-bottom: 24px; } +h2 { margin: 0; font-size: 40px; font-weight: 900; line-height: .95; letter-spacing: -.04em; } +.section-head p { max-width: 560px; margin: 0; color: var(--body); font-size: 14px; text-align: right; } +.panel-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 24px; } +.panel { overflow: hidden; border-radius: 24px; background: var(--canvas); } +.panel-head { display: flex; justify-content: space-between; gap: 16px; align-items: center; padding: 22px 24px; border-bottom: 1px solid var(--line); } +.panel-title { font-weight: 600; } +.badge { padding: 4px 12px; border-radius: 9999px; font-size: 12px; font-weight: 600; white-space: nowrap; } +.badge-good { color: var(--positive-deep); background: var(--primary-pale); } +.badge-bad { color: var(--canvas); background: var(--negative-bg); } +.badge-warn { color: var(--warning-content); background: var(--warning); } +.table-wrap { overflow-x: auto; } +table { width: 100%; border-collapse: collapse; font-size: 14px; } +th { padding: 12px 16px; color: var(--body); font-size: 12px; font-weight: 600; text-align: right; } +th:first-child, td:first-child { text-align: left; } +td { padding: 15px 16px; border-top: 1px solid var(--line); text-align: right; vertical-align: top; } +td:first-child { max-width: 250px; font-weight: 600; } +.row-note { display: block; margin-top: 2px; color: var(--mute); font-size: 12px; font-weight: 400; } +.trend-panel { padding: 24px; } +.trend { display: grid; grid-template-columns: repeat(7, 1fr); gap: 12px; min-height: 190px; align-items: end; border-bottom: 1px solid var(--ink); } +.trend-item { display: grid; grid-template-rows: 1fr auto auto; gap: 5px; height: 100%; align-items: end; text-align: center; } +.bar { width: 38px; max-width: 78%; min-height: 20px; margin: 0 auto; border-radius: 12px 12px 0 0; background: var(--primary); } +.bar.partial { background: var(--warning); } +.trend-value { font-size: 12px; font-weight: 600; } +.trend-label { color: var(--mute); font-size: 12px; } +.source-list { padding: 24px; } +.source-row { display: grid; grid-template-columns: 180px 1fr 80px; gap: 12px; align-items: center; margin: 16px 0; font-size: 14px; } +.meter { height: 12px; overflow: hidden; border-radius: 9999px; background: var(--canvas-soft); } +.meter span { display: block; height: 100%; border-radius: inherit; background: var(--primary); } +.source-row:nth-child(2n) .meter span { background: var(--warning); } +.source-value { text-align: right; font-weight: 600; } +.insights { padding: 12px 24px; } +.insight { display: grid; grid-template-columns: 32px 1fr; gap: 12px; padding: 15px 0; border-top: 1px solid var(--line); } +.insight:first-child { border-top: 0; } +.insight-num { display: grid; width: 32px; height: 32px; place-items: center; border-radius: 9999px; color: var(--ink-deep); background: var(--primary); font-size: 14px; font-weight: 600; } +.insight p { margin: 4px 0 0; color: var(--body); font-size: 14px; } +.recommendations { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; } +.recommendation { min-height: 250px; padding: 24px; border-radius: 24px; background: var(--canvas); } +.recommendation-top { display: flex; justify-content: space-between; gap: 12px; align-items: center; } +.recommendation-num { font-size: 40px; font-weight: 900; line-height: 1; } +.recommendation h3 { margin: 24px 0 10px; font-size: 24px; font-weight: 600; line-height: 1.3; } +.recommendation p { margin: 0; color: var(--body); } +.confirmations { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; } +.confirmation { display: grid; grid-template-columns: 28px 1fr; gap: 12px; padding: 22px 24px; border-radius: 24px; background: var(--canvas); } +.confirmation strong { display: block; font-weight: 600; } +.confirmation span { display: block; margin-top: 3px; color: var(--body); font-size: 14px; } +.notes { margin: 48px 0 0; padding: 24px; border-radius: 24px; color: var(--canvas-soft); background: var(--ink); font-size: 13px; } +.notes strong { color: var(--primary); } +.notes ul { margin: 8px 0 0; padding-left: 20px; } + +@media (max-width: 1023px) { + .report { padding: 40px 24px; } + .hero { grid-template-columns: 1fr; gap: 24px; } + .kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .panel-grid { grid-template-columns: 1fr; } + .recommendations { grid-template-columns: 1fr; } + .recommendation { min-height: 0; } +} + +@media (max-width: 767px) { + .report { padding: 32px 16px; } + h1 { font-size: 48px; } + h2 { font-size: 32px; line-height: 1.15; } + .kpis { grid-template-columns: 1fr; } + .kpi { min-height: 0; } + .kpi-value { font-size: 32px; white-space: normal; } + .section-head { display: block; } + .section-head p { margin-top: 8px; text-align: left; } + .confirmations { grid-template-columns: 1fr; } + .source-row { grid-template-columns: 120px 1fr 60px; } +} + +@media print { + body { -webkit-print-color-adjust: exact; print-color-adjust: exact; } +} +/* Wrap identifiers without hiding content. */ +h1, h3, td, p, span, strong { overflow-wrap: anywhere; } +.report { padding-bottom: 100px; } + +/* Reserve readable evidence columns even for the maximum row length. */ +table { table-layout: fixed; } +th, td { padding-left: 10px; padding-right: 10px; } +th:first-child, td:first-child { width: 52%; max-width: none; } +.source-card { padding: 16px 24px; border-bottom: 1px solid var(--line); } +.source-card p { margin: 8px 0 0; color: var(--body); font-size: 14px; } +.kpi-value { white-space: normal; } diff --git a/.agents/skills/swads-daily-report/references/report-data-schema.md b/.agents/skills/swads-daily-report/references/report-data-schema.md new file mode 100644 index 0000000..ecdc629 --- /dev/null +++ b/.agents/skills/swads-daily-report/references/report-data-schema.md @@ -0,0 +1,74 @@ +# Normalized report data + +The renderer consumes normalized report data, not raw MCP responses. Build each semantic row by zipping `columns[].name` with the row values, then populate this schema. Ratios such as ROAS use multiplier units (`6.31` means `6.31×`); percentage fields use percentage points (`4.18` means `4.18%`). Money values use the account currency. + +## Required shape + +```json +{ + "meta": { + "report_date": "2026-08-19", + "account_name": "Demo Commerce", + "account_id": "demo-account-001", + "currency": "MYR", + "reporting_timezone": "Asia/Kuala_Lumpur", + "period_label": "2026-08-19", + "comparison_label": "Previous 7 complete days", + "freshness_label": "2026-08-20 14:48 MYT", + "attribution_label": "TikTok GMV Max native attribution", + "status_label": "Cautiously positive" + }, + "summary": { + "spend": 1837.84, + "revenue": 11593.35, + "orders": 663, + "ctr_percent": null, + "cpi": null, + "roas": 6.31, + "simple_roi_percent": 530.8, + "cost_per_order": 2.77 + }, + "monitoring_note": "Optional partial-day or data-quality note.", + "trend": [ + {"label": "08/14", "roas": 6.45, "partial": false} + ], + "campaigns": { + "winners": [ + {"name": "Campaign name", "id": "external id", "status": "ENABLE", "spend": 10, "revenue": 80, "orders": 5, "roas": 8, "reason": "Balanced scale and efficiency"} + ], + "anomalies": [] + }, + "creative": { + "row_count": 692, + "coverage_percent": 75.2, + "source_groups": [ + {"label": "Affiliate / creator", "spend": 1232.55, "revenue": 7535.94, "orders": 463, "roas": 6.11}, + {"label": "Account-owned", "spend": 150.2, "revenue": 121, "orders": 8, "roas": 0.81} + ], + "winners": [ + {"name": "Creative title", "id": "creative id", "creator": "creator", "spend": 20, "revenue": 350, "orders": 20, "roas": 17.5, "clicks": 89, "product_ctr_percent": 7.0} + ], + "anomalies": [], + "insights": ["Short evidence-backed attribution clue"] + }, + "recommendations": [ + {"title": "Action title", "detail": "Observed evidence and bounded next step.", "confirmation": "required"} + ], + "manual_confirmations": [ + {"action": "Pause or change budget", "required": true, "reason": "Changes live delivery"} + ], + "notes": ["Attribution and unavailable-data caveats"] +} +``` + +## Completion rules + +- `recommendations` contains exactly three items. `confirmation` is `required`, `partial`, or `none`. +- Winner and anomaly tables should each contain three to five rows when sufficient data exists. Keep low-sample rows but identify the sample limitation in `reason`. +- Missing CTR/CPI/ROI inputs are JSON `null`. Never encode missing data as numeric zero. +- `creative.source_groups` may be empty when creator identity is unavailable. Keep `creative.insights` evidence-based and avoid causal language. +- `manual_confirmations` covers every recommended platform write. Read-only checks may use `required: false`. + +## Runtime bounds + +All objects are strict. report_date must be a real ISO YYYY-MM-DD date; timezone an IANA timezone; currency a three-letter code. Text fields: maximum 500 characters (names/IDs/labels 160). Arrays: trend 7, each winner/anomaly table 5, source_groups 6, insights 4, notes and manual_confirmations 10. Metrics are finite numbers or null; counts are nonnegative integers. Campaign reason is required; creative reason is optional for sample caveats. Required/partial recommendations need a required manual confirmation entry. Reports require non-null spend and at least one other observed summary metric. diff --git a/.env.example b/.env.example index 52d72f7..1f410d2 100644 --- a/.env.example +++ b/.env.example @@ -14,3 +14,9 @@ SESSION_RUN_TIMEOUT_MS=120000 APPROVAL_TIMEOUT_MS=60000 MAX_IMAGE_BYTES=5242880 MAX_IMAGES_PER_MESSAGE=4 +# Server-only read-only SW Ads MCP Gateway. Missing token only disables SW Ads tools. +SWADS_MCP_URL=https://ads.mincode.cn/mcp +SWADS_MCP_TOKEN= +SWADS_MCP_TIMEOUT_MS=30000 +# Optional absolute Chrome/Chromium executable; otherwise fixed system paths are tried. +CHROME_BIN= diff --git a/.gitignore b/.gitignore index 8cc8814..8e6f707 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ workspace/sessions/ workspace/model-credentials.enc.json *.log .DS_Store +workspace/reports/ +workspace/.pi-catalog/ diff --git a/README.md b/README.md index 4a4350a..a07de67 100644 --- a/README.md +++ b/README.md @@ -195,3 +195,15 @@ Composer 支持选择、拖拽、粘贴、上传状态、移除与对话框预 课堂版服务端仍是单进程:重启会丢 Session/Event/Approval,但模型 API Key 可通过本地 AES-256-GCM 密文恢复;浏览器只保留非敏感模型元数据。当前文件 vault 没有多用户/租户隔离、远程 KMS、密钥轮换或分布式锁。SSE 无跨实例协调;没有用户身份、CSRF、分布式 rate limit、持久审计、对象存储/KMS 或 OS Sandbox。自定义 provider 的 Vision 声明会被服务器注册成精确 Pi Model 并按该模型检查,但课堂版不会主动发远端 capability probe。 生产化路线见 [docs/production-roadmap.md](docs/production-roadmap.md):Redis/Postgres、对象存储、KMS、身份鉴权与 CSRF、速率/配额、容器/VM Sandbox、出口代理、不可变审计日志、队列/worker、分布式 SSE,以及完整可观测性。 + +## SW Ads 图片日报 + +在服务器 `.env` 配置 `SWADS_MCP_URL`(默认 `https://ads.mincode.cn/mcp`)、`SWADS_MCP_TOKEN` 和可选 `SWADS_MCP_TIMEOUT_MS`(默认 30000,范围 100–120000)。Token 只在服务端保存,勿粘贴到聊天或提交 Git。URL 启动时校验;缺少 Token 不影响现有 mock ads、附件和报告草稿功能,首次 SW Ads 调用返回 `SWADS_NOT_CONFIGURED`。 + +发送 `/swads-daily-report`,或 `/swads-daily-report 账户 demo-account,日期 2026-09-06`。该内置 Skill 仅显式调用,保留原始用户消息。首版仅支持 TikTok GMV Max:默认当前账户最近完整业务日,之前七个完整业务日作比较;账户时区、币种和账户范围来自真实 Gateway。查询前探测线上 `tools/list`,要求只读 whoami、metrics catalog、semantic query 及实际 TikTok 指标能力;能力缺失、鉴权失败或空数据时停止生成。 + +Agent 先输出简洁 Markdown 分析,再生成 PNG 长图,图片可点击预览;Tool 卡提供 PNG、HTML、JSON 下载。服务器通过固定 Chrome CLI 参数和 Sharp 截图检查生成图片,不需要 Playwright/Puppeteer。可用 `CHROME_BIN` 指定绝对可执行路径,否则尝试固定系统 Chrome/Chromium 路径。浏览器缺失、超时或达到画布上限仍不完整时保留 HTML/JSON,并显示 `CHROME_UNAVAILABLE`、`PNG_TIMEOUT`、`PNG_RENDER_FAILED` 或 `PNG_TRUNCATED`。 + +产物原子保存于 `workspace/reports////`,不随 Session reset/delete 清理,不进入 Git。报告 URL 没有目录列表,PNG inline,HTML/JSON attachment。当前版本没有用户认证,随机 ID 只是内部/本地部署下的非枚举地址;持有 URL 即可访问。公网多用户部署需要另行实现认证、授权与租户隔离。存储保留与容量清理由部署方管理。 + +`swads_cli` 是服务端受控 CLI facade:Agent 不接收 Gateway 的逐项 MCP Tools,也不能执行 shell,但 facade 仍使用官方 MCP Streamable HTTP client 连接 MCP Gateway。该功能不执行任何平台写操作;所有预算、状态、目标和发布建议都列入人工确认。 diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index edd5aeb..d411671 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -13,6 +13,9 @@ import { InMemorySessionRegistry, PiAgentSessionFactory, SkillCatalog, + SwadsGateway, + ReportStore, + REPORT_CSP, safeErrorMessage, type AgentSessionFactory, } from "@agent-studio/harness"; @@ -25,7 +28,7 @@ const eventQuery = z.object({ afterSequence: z.coerce.number().int().nonnegative function parse(schema: z.ZodType, value: unknown): T { return schema.parse(value); } -export interface AppServices { sessions: InMemorySessionRegistry; events: InMemoryEventStore; attachments: FileAttachmentStore; models: InMemoryModelConnectionStore; skills: SkillCatalog } +export interface AppServices { sessions: InMemorySessionRegistry; events: InMemoryEventStore; attachments: FileAttachmentStore; models: InMemoryModelConnectionStore; skills: SkillCatalog; reports: ReportStore } export interface AppOptions { projectRoot?: string; runTimeoutMs?: number; approvalTimeoutMs?: number; heartbeatMs?: number; agentFactory?: AgentSessionFactory; onReady?: (services: AppServices) => void } export async function buildApp(options: AppOptions = {}): Promise { @@ -35,6 +38,8 @@ export async function buildApp(options: AppOptions = {}): Promise `req_${randomUUID()}`, @@ -65,6 +70,12 @@ export async function buildApp(options: AppOptions = {}): Promise { + const params = request.params as Record; + const result = await reports.read({ accountKey: params.accountKey, reportDate: params.reportDate, reportId: params.reportId }, params.format); + const mime = { png: "image/png", html: "text/html; charset=utf-8", json: "application/json; charset=utf-8" }; + return reply.header("content-type", mime[result.format]).header("content-disposition", `${result.format === "png" ? "inline" : "attachment"}; filename="swads-daily-report.${result.format}"`).header("x-content-type-options", "nosniff").header("content-security-policy", REPORT_CSP).header("cache-control", "private, no-store").send(result.bytes); + }); app.get("/health", async (request) => ({ ok: true, requestId: request.id })); app.get("/api/skills", async (request) => ({ skills: skills.list(), requestId: request.id })); app.post("/api/sessions", async (request, reply) => { const session = await sessions.create(); return reply.code(201).send({ sessionId: session.sessionId, requestId: request.id }); }); diff --git a/apps/server/tests/app.test.ts b/apps/server/tests/app.test.ts index 43c2f79..7a3b44b 100644 --- a/apps/server/tests/app.test.ts +++ b/apps/server/tests/app.test.ts @@ -1,9 +1,10 @@ -import { mkdtemp, mkdir } from "node:fs/promises"; +import { mkdtemp, mkdir, copyFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { buildApp, type AppServices } from "../src/app.js"; import type { AgentSessionFactory, AgentSessionPort } from "@agent-studio/harness"; +import { sampleReport } from "../../../packages/harness/tests/report-fixture.js"; import type { FastifyInstance } from "fastify"; class ServerFakePort implements AgentSessionPort { @@ -20,7 +21,7 @@ afterEach(async () => { while (apps.length) await apps.pop()?.close(); }); async function fixture() { const root = await mkdtemp(path.join(tmpdir(), "studio-server-")); await mkdir(path.join(root, ".agents/skills"), { recursive: true }); await mkdir(path.join(root, ".pi/skills"), { recursive: true }); await mkdir(path.join(root, "fixtures")); - const factory = new ServerFakeFactory(); let services: AppServices | undefined; const app = await buildApp({ projectRoot: root, agentFactory: factory, heartbeatMs: 20, onReady: (value) => { services = value; } }); apps.push(app); return { app, factory, services: services! }; + const factory = new ServerFakeFactory(); let services: AppServices | undefined; const app = await buildApp({ projectRoot: root, agentFactory: factory, heartbeatMs: 20, onReady: (value) => { services = value; } }); apps.push(app); return { app, factory, root, services: services! }; } async function createSession(app: FastifyInstance) { const response = await app.inject({ method: "POST", url: "/api/sessions" }); return (response.json() as { sessionId: string }).sessionId; } async function configure(app: FastifyInstance, sessionId: string, supportsImages = true) { return app.inject({ method: "PUT", url: `/api/sessions/${sessionId}/model-config`, payload: { providerId: "fake", api: "openai-responses", modelId: "fake-model", apiKey: "sk-test-never-return", supportsImages } }); } @@ -36,6 +37,14 @@ describe("Fastify API", () => { it("supports approve and deny endpoints with conflict detection", async () => { const { app, services } = await fixture(); const sessionId = await createSession(app); const controller = new AbortController(); const pending = services.sessions.approvals.request({ sessionId, runId: "run_test", toolCallId: "tool_test", toolName: "save_report_draft", arguments: {}, riskLevel: "medium" }, controller.signal); const event = services.events.listAfter(sessionId).events.find((item) => item.type === "approval.required"); if (!event || event.type !== "approval.required") throw new Error("missing approval"); const approved = await app.inject({ method: "POST", url: `/api/approvals/${event.payload.approvalId}/approve` }); expect(approved.statusCode).toBe(200); expect((await pending).status).toBe("approved"); const conflict = await app.inject({ method: "POST", url: `/api/approvals/${event.payload.approvalId}/deny` }); expect(conflict.statusCode).toBe(409); }); it("accepts a valid multipart PNG and rejects SVG", async () => { const { app } = await fixture(); const sessionId = await createSession(app); const png = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", "base64"); const accepted = await app.inject({ method: "POST", url: `/api/sessions/${sessionId}/attachments`, headers: { "content-type": "multipart/form-data; boundary=studio" }, payload: multipart("studio", "image.png", "image/png", png) }); expect(accepted.statusCode).toBe(201); expect(accepted.json()).toMatchObject({ mediaType: "image/png", width: 1, status: "ready" }); const rejected = await app.inject({ method: "POST", url: `/api/sessions/${sessionId}/attachments`, headers: { "content-type": "multipart/form-data; boundary=studio" }, payload: multipart("studio", "x.svg", "image/svg+xml", Buffer.from("")) }); expect(rejected.statusCode).toBe(415); }); it("serves SSE events and heartbeat comments", async () => { const { app } = await fixture(); const sessionId = await createSession(app); const address = await app.listen({ host: "127.0.0.1", port: 0 }); const controller = new AbortController(); const response = await fetch(`${address}/api/sessions/${sessionId}/events`, { signal: controller.signal }); expect(response.headers.get("content-type")).toContain("text/event-stream"); const reader = response.body!.getReader(); let text = ""; for (let count = 0; count < 4 && !text.includes(": heartbeat"); count++) { const chunk = await reader.read(); text += new TextDecoder().decode(chunk.value); } controller.abort(); expect(text).toContain("event: session.started"); expect(text).toContain(": heartbeat"); }); + it("keeps reports downloadable after session deletion and guards artifact routes", async () => { + const { app, root, services } = await fixture(); const assets = path.join(root, ".agents/skills/swads-daily-report/assets"); await mkdir(assets, { recursive: true }); await copyFile(path.resolve(import.meta.dirname, "../../../.agents/skills/swads-daily-report/assets/report.css"), path.join(assets, "report.css")); + const artifact = await services.reports.create(sampleReport(), new AbortController().signal); const session = await createSession(app); await app.inject({ method: "DELETE", url: `/api/sessions/${session}` }); + for (const [format, url] of Object.entries(artifact.downloads)) { const response = await app.inject({ method: "GET", url }); expect(response.statusCode).toBe(200); expect(response.headers["content-disposition"]).toContain(format === "png" ? "inline" : "attachment"); expect(response.headers["x-content-type-options"]).toBe("nosniff"); expect(response.headers["content-security-policy"]).toContain("default-src 'none'"); expect(response.body).not.toContain(root); expect(response.headers["content-type"]).toContain(format === "png" ? "image/png" : format === "json" ? "application/json" : "text/html"); } + const base = artifact.downloads.json.slice(0, -4); + for (const url of [base + "secret", base.replace(artifact.reportId, "00000000-0000-4000-8000-000000000000") + "json", "/api/reports/%2e%2e/x/y/json", "/api/reports/", base + "%2e%2e%2fsecret"]) { const response = await app.inject({ method: "GET", url }); expect(response.statusCode).toBe(404); expect(response.body).not.toContain(root); } + }, 60_000); + }); function multipart(boundary: string, filename: string, mime: string, data: Buffer): Buffer { return Buffer.concat([Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${filename}"\r\nContent-Type: ${mime}\r\n\r\n`), data, Buffer.from(`\r\n--${boundary}--\r\n`)]); } diff --git a/apps/web/package.json b/apps/web/package.json index 281077f..da0faad 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -3,15 +3,26 @@ "version": "0.1.0", "private": true, "type": "module", - "scripts": { "dev": "vite --host 127.0.0.1", "typecheck": "tsc --noEmit", "build": "vite build" }, + "scripts": { + "dev": "vite --host 127.0.0.1", + "typecheck": "tsc --noEmit", + "build": "vite build" + }, "dependencies": { "@agent-studio/shared": "workspace:*", "@assistant-ui/react": "0.15.17", + "@assistant-ui/react-markdown": "^0.14.14", "@radix-ui/react-dialog": "^1.1.15", "lucide-react": "^0.555.0", "react": "^19.2.0", "react-dom": "^19.2.0", "zod": "^4.1.13" }, - "devDependencies": { "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.1", "typescript": "^5.9.3", "vite": "^7.2.6" } + "devDependencies": { + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "typescript": "^5.9.3", + "vite": "^7.2.6" + } } diff --git a/apps/web/src/assistant/store.ts b/apps/web/src/assistant/store.ts index 64b6a85..396f91a 100644 --- a/apps/web/src/assistant/store.ts +++ b/apps/web/src/assistant/store.ts @@ -15,7 +15,7 @@ export interface ClientState { interface LocalAttachment extends ImageAttachment { objectUrl: string } type Listener = () => void; -const eventTypes: HarnessEvent["type"][] = ["session.started", "session.ended", "user.message", "assistant.delta", "assistant.completed", "skill.requested", "skill.loaded", "tool.requested", "tool.completed", "tool.failed", "approval.required", "approval.resolved", "attachment.uploaded", "attachment.rejected", "model.configured", "model.validation_failed", "run.started", "run.cancelled", "run.completed", "run.failed"]; +const eventTypes: HarnessEvent["type"][] = ["session.started", "session.ended", "user.message", "assistant.delta", "assistant.completed", "report.generated", "skill.requested", "skill.loaded", "tool.requested", "tool.completed", "tool.failed", "approval.required", "approval.resolved", "attachment.uploaded", "attachment.rejected", "model.configured", "model.validation_failed", "run.started", "run.cancelled", "run.completed", "run.failed"]; const modelStorageKey = "agent-studio-mini:model-connection:v1"; export class HarnessClientStore { @@ -23,6 +23,8 @@ export class HarnessClientStore { private readonly listeners = new Set(); private readonly seen = new Set(); private readonly attachments = new Map(); + private readonly reportImages = new Map(); + private generation = 0; private source?: EventSource; private lastSequence = 0; private initializing: Promise | undefined; @@ -95,7 +97,13 @@ export class HarnessClientStore { } else if (event.type === "assistant.delta") { messages = updateAssistant(messages, event.payload.messageId, event.timestamp, (parts) => appendText(parts, event.payload.delta), { type: "running" }, event.runId); } else if (event.type === "assistant.completed") { - messages = updateAssistant(messages, event.payload.messageId, event.timestamp, (parts) => parts, { type: "complete", reason: "stop" }); + messages = updateAssistant(messages, event.payload.messageId, event.timestamp, (parts) => parts, { type: "complete", reason: "stop" }, event.runId); + } else if (event.type === "report.generated") { + const { artifact, assistantMessageId } = event.payload; + if (artifact.previewUrl) { + messages = updateAssistant(messages, assistantMessageId, event.timestamp, (parts) => parts, { type: "running" }, event.runId); + void this.loadReportImage(event); + } } else if (event.type === "tool.requested") { const id = assistantIdForRun(messages, event.runId); messages = updateAssistant(messages, id, event.timestamp, (parts) => upsertToolCall(parts, event.payload.toolCallId, event.payload.toolName, asArgs(event.payload.arguments)), { type: "running" }, event.runId); @@ -113,6 +121,28 @@ export class HarnessClientStore { this.update({ events, messages, isRunning }); } + private async loadReportImage(event: Extract): Promise { + const { artifact, assistantMessageId } = event.payload; + const url = artifact.previewUrl; + if (!url || this.reportImages.has(url)) return; + this.reportImages.set(url, undefined); + const generation = this.generation; + try { + const response = await fetch(url); + if (!response.ok || !response.headers.get("content-type")?.startsWith("image/png")) throw new Error("图片加载失败,可通过下载链接重试。"); + const blob = await response.blob(); + if (generation !== this.generation) return; + const image = URL.createObjectURL(blob); + this.reportImages.set(url, image); + const existing = this.state.messages.find((message) => message.id === assistantMessageId); + const status = this.state.isRunning ? existing?.status ?? { type: "running" as const } : { type: "complete" as const, reason: "stop" as const }; + const messages = updateAssistant(this.state.messages, assistantMessageId, event.timestamp, (parts) => [...parts, { type: "image", image, filename: `SW Ads ${artifact.reportDate}` }], status, event.runId); + this.update({ messages }); + } catch { + if (generation === this.generation) this.update({ error: "日报图片预览加载失败,可通过下载链接重试。" }); + } + } + onNew = async (message: AppendMessage): Promise => { const sessionId = await this.requireSession(); const text = message.content.filter((part) => part.type === "text").map((part) => part.text).join("\n"); @@ -125,6 +155,9 @@ export class HarnessClientStore { async reset(): Promise { const old = this.state.sessionId; + this.generation++; + for (const image of this.reportImages.values()) if (image) URL.revokeObjectURL(image); + this.reportImages.clear(); this.source?.close(); if (old) await api(`/api/sessions/${old}`, { method: "DELETE" }); for (const item of this.attachments.values()) URL.revokeObjectURL(item.objectUrl); @@ -177,9 +210,9 @@ export class HarnessClientStore { type ContentParts = Exclude; function updateAssistant(messages: ThreadMessageLike[], id: string, timestamp: string, change: (parts: ContentParts) => ContentParts, status: ThreadMessageLike["status"], runId?: string): ThreadMessageLike[] { - const index = messages.findIndex((item) => item.id === id); + const index = messages.findIndex((item) => item.id === id || (runId && item.role === "assistant" && item.metadata?.custom?.runId === runId)); if (index < 0) return [...messages, { id, role: "assistant", createdAt: new Date(timestamp), content: change([]), status, metadata: { custom: runId ? { runId } : {} } }]; - return messages.map((item, current) => current === index ? { ...item, content: change(typeof item.content === "string" ? [{ type: "text", text: item.content }] : item.content), status } : item); + return messages.map((item, current) => current === index ? { ...item, id, content: change(typeof item.content === "string" ? [{ type: "text", text: item.content }] : item.content), status } : item); } function appendText(parts: ContentParts, delta: string): ContentParts { const last = parts.at(-1); return last?.type === "text" ? [...parts.slice(0, -1), { ...last, text: last.text + delta }] : [...parts, { type: "text", text: delta }]; } function upsertToolCall(parts: ContentParts, toolCallId: string, toolName: string, args: Record): ContentParts { diff --git a/apps/web/src/components/ChatPanel.tsx b/apps/web/src/components/ChatPanel.tsx index 2ad6a32..f4ff7a0 100644 --- a/apps/web/src/components/ChatPanel.tsx +++ b/apps/web/src/components/ChatPanel.tsx @@ -1,3 +1,4 @@ +import { MarkdownTextPrimitive } from "@assistant-ui/react-markdown"; import { useState } from "react"; import { AttachmentPrimitive, ComposerPrimitive, MessagePrimitive, ThreadPrimitive, useAui, useAuiState } from "@assistant-ui/react"; import { ImagePlus, Send, Square, X } from "lucide-react"; @@ -7,14 +8,14 @@ import { useHarnessState } from "../assistant/HarnessContext"; const demoPrompt = "请使用 ads-analysis Skill,分析账户 demo-account 最近 7 天数据,找出异常 Campaign,并输出账户总结、关键指标和 3 条优化建议。最后使用 report-writer Skill 生成一份报告草稿。"; -function TextPart({ text }: { text: string }) { return
{text}
; } -function ImagePart({ image, filename }: { image: string; filename?: string }) { return ; } +function TextPart() { return ; } +function ImagePart({ image, filename }: { image: string; filename?: string }) { return ; } const partComponents = { Text: TextPart, Image: ImagePart, tools: { Fallback: ToolFallback } }; function UserMessage() { return
You
{() => null}
; } function AssistantMessage() { return
Agent
; } -function ImagePreview({ src, name }: { src: string; name: string }) { - return {name}{name}/; +function ImagePreview({ src, name, report = false }: { src: string; name: string; report?: boolean }) { + return {name}{name}/; } function ComposerAttachment() { const preview = useAuiState((state) => { diff --git a/apps/web/src/components/ToolUIs.tsx b/apps/web/src/components/ToolUIs.tsx index 3fd8151..21a2623 100644 --- a/apps/web/src/components/ToolUIs.tsx +++ b/apps/web/src/components/ToolUIs.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { AuiConfig, AuiProvider, defineToolkit, Tools, useAui, type Toolkit, type ToolCallMessagePartProps } from "@assistant-ui/react"; import type { PropsWithChildren } from "react"; import { z } from "zod"; +import { reportArtifactSchema } from "@agent-studio/shared"; import { useHarnessStore } from "../assistant/HarnessContext"; type JsonObject = Record; @@ -32,11 +33,18 @@ export function SaveReportDraftUI(props: ToolCallMessagePartProps
save_report_draft{stateLabel(props)}

将写入当前 Session 的固定文件 report.md

{content.slice(0, 180)}{content.length > 180 ? "…" : ""}

{props.approval && }; } +export function SwadsReportUI(props: ToolCallMessagePartProps) { + const parsed = reportArtifactSchema.safeParse(props.result); + return
SW Ads 图片日报{stateLabel(props)}
{parsed.success && <>{parsed.data.warning &&

{parsed.data.warning.message}

}
{Object.entries(parsed.data.downloads).map(([format, url]) => 下载 {format.toUpperCase()})}
}
; +} +export function SwadsCliUI(props: ToolCallMessagePartProps) { return
SW Ads · {String(props.args.command ?? "read")}{stateLabel(props)}
; } export function ToolFallback(props: ToolCallMessagePartProps) { return
{props.toolName}{stateLabel(props)}
{JSON.stringify(props.args ?? {}, null, 2)}
; } function Metric({ label, value, suffix = "" }: { label: string; value: unknown; suffix?: string }) { return
{label}{typeof value === "number" ? value.toLocaleString() : "—"}{suffix}
; } function asRecord(value: unknown): JsonObject { return value && typeof value === "object" && !Array.isArray(value) ? value as JsonObject : {}; } const authoredToolkit = defineToolkit({ + swads_cli: { description: "显示 SW Ads 只读查询状态", parameters: z.object({ command: z.string() }), render: SwadsCliUI }, + render_swads_daily_report: { description: "日报状态与下载", parameters: z.object({}), render: SwadsReportUI }, mock_ads_metrics: { description: "显示服务端广告指标工具结果", parameters: z.object({ accountId: z.string(), days: z.number() }), render: MockAdsMetricsUI }, save_report_draft: { description: "显示服务端报告保存审批与结果", parameters: z.object({ content: z.string(), title: z.string().optional() }), render: SaveReportDraftUI }, }); diff --git a/apps/web/src/styles/app.css b/apps/web/src/styles/app.css index 7f296f8..bab4317 100644 --- a/apps/web/src/styles/app.css +++ b/apps/web/src/styles/app.css @@ -104,3 +104,14 @@ button:disabled { opacity: .46; cursor: not-allowed; } html, body, #root { overflow: hidden; }.app-shell { grid-template-rows: 58px 44px 1fr; }.brand small, .model-badge, .vision-badge, .session-summary > .run-dot { display: none; }.topbar { padding: 0 10px; }.settings-button { width: 42px; padding: 0; font-size: 0; }.settings-button svg { width: 18px; }.mobile-tabs { display: grid; grid-template-columns: repeat(3, 1fr); border-bottom: 1px solid var(--border); background: var(--surface-1); }.mobile-tabs button { border: 0; background: transparent; color: var(--muted); text-transform: capitalize; }.mobile-tabs button.active { color: var(--cyan); box-shadow: inset 0 -2px var(--cyan); }.workspace-grid { display: block; min-height: 0; }.mobile-panel { display: none !important; height: 100%; }.mobile-panel.shown { display: block !important; }.side-panel { border: 0; }.chat-toolbar { padding: 6px 10px; }.text-button { padding: 0 8px; }.composer-footer { padding: 10px; }.message { width: calc(100% - 20px); }.metrics { grid-template-columns: repeat(2, 1fr); }.form-grid { grid-template-columns: 1fr; }.span-2 { grid-column: span 1 !important; }.presets { overflow-x: auto; }.presets button { flex: 0 0 auto; }.dialog-actions { flex-wrap: wrap; }.dialog-actions button { flex: 1; }.dialog-actions .danger { margin: 0; }.composer-hint { display: none; } } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; scroll-behavior: auto !important; } } + +.report-downloads { display: flex; flex-wrap: wrap; gap: 16px; margin-top: 12px; } +.message-text.markdown { white-space: normal; overflow-wrap: anywhere; } +.markdown p { margin: 0.5em 0; } +.markdown pre { overflow-x: auto; white-space: pre; } +.markdown table { display: block; overflow-x: auto; border-collapse: collapse; } +.markdown th, .markdown td { padding: 6px 10px; border: 1px solid var(--line, #ddd); } + +.report-image-preview { width: min(640px, 100%); } +.report-image-preview img { width: 100%; height: auto; object-fit: contain; } +.report-image-dialog img { width: 100%; max-height: none; } diff --git a/apps/web/tests/store.test.ts b/apps/web/tests/store.test.ts index 5bd2978..a38204c 100644 --- a/apps/web/tests/store.test.ts +++ b/apps/web/tests/store.test.ts @@ -46,3 +46,21 @@ describe("HarnessClientStore reducer", () => { await expect(store.saveModel({ providerId: "openai", api: "openai-responses", apiKey: "top-secret", modelId: "gpt", supportsImages: true })).rejects.toThrow("Session 初始化失败:Failed to fetch"); }); }); + +describe("generated report events", () => { + it("deduplicates images and merges provisional tool messages with the correct assistant", async () => { + vi.stubGlobal("fetch", vi.fn(async () => new Response(new Blob(["png"], { type: "image/png" }), { headers: { "content-type": "image/png" } }))); + Object.defineProperty(URL, "createObjectURL", { configurable: true, value: vi.fn(() => "blob:report-image") }); + const store = new HarnessClientStore(); const base = { sessionId: "ses_report", runId: "run_report", timestamp: new Date().toISOString() }; + 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: { json: `${url}/json`, html: `${url}/html`, png: `${url}/png` } }; + store.reduce({ ...base, eventId: "report_1", sequence: 1, type: "tool.requested", payload: { toolCallId: "render", toolName: "render_swads_daily_report", arguments: {} } }); + store.reduce({ ...base, eventId: "report_2", sequence: 2, type: "assistant.delta", payload: { messageId: "real_message", delta: "分析" } }); + const event: HarnessEvent = { ...base, eventId: "report_3", sequence: 3, type: "report.generated", payload: { assistantMessageId: "real_message", artifact } }; + store.reduce(event); store.reduce(event); store.reduce({ ...event, eventId: "report_4", sequence: 4 }); + await vi.waitFor(() => expect(store.getSnapshot().messages[0]?.content).toEqual(expect.arrayContaining([expect.objectContaining({ type: "image" })]))); + const messages = store.getSnapshot().messages; expect(messages).toHaveLength(1); expect(messages[0]?.id).toBe("real_message"); expect(messages[0]?.content).toEqual(expect.arrayContaining([{ type: "image", image: "blob:report-image", filename: "SW Ads 2026-09-06" }])); + expect(Array.isArray(messages[0]?.content) && messages[0].content.filter((part) => part.type === "image")).toHaveLength(1); + }); +}); diff --git a/apps/web/tests/ui.test.tsx b/apps/web/tests/ui.test.tsx index fe05484..2be3264 100644 --- a/apps/web/tests/ui.test.tsx +++ b/apps/web/tests/ui.test.tsx @@ -1,15 +1,15 @@ // @vitest-environment jsdom -import { render, screen } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { HarnessEvent } from "@agent-studio/shared"; import { App } from "../src/App"; import { HarnessRuntime } from "../src/assistant/HarnessRuntime"; import { HarnessClientStore } from "../src/assistant/store"; -import { MockAdsMetricsUI, SaveReportDraftUI } from "../src/components/ToolUIs"; +import { MockAdsMetricsUI, SaveReportDraftUI, SwadsReportUI } from "../src/components/ToolUIs"; import { AppErrorBoundary } from "../src/components/AppErrorBoundary"; -afterEach(() => { localStorage.clear(); sessionStorage.clear(); vi.restoreAllMocks(); }); +afterEach(() => { localStorage.clear(); sessionStorage.clear(); vi.restoreAllMocks(); vi.unstubAllGlobals(); }); describe("web UI", () => { it("renders the Skills, Chat, and Timeline operating areas", () => { const store = new HarnessClientStore(); render(); expect(screen.getByLabelText("Skills Catalog")).toBeInTheDocument(); expect(screen.getByLabelText("Streaming Chat")).toBeInTheDocument(); expect(screen.getByLabelText("Event Timeline")).toBeInTheDocument(); expect(screen.getByRole("button", { name: /尚未配置模型/ })).toBeInTheDocument(); }); it("renders the mock metrics Tool UI", () => { render(); expect(screen.getByText("demo-account", { exact: false })).toBeInTheDocument(); expect(screen.getByText("ROAS")).toBeInTheDocument(); }); @@ -36,7 +36,34 @@ describe("web UI", () => { expect(screen.getByText("ROAS")).toBeInTheDocument(); expect(screen.getByRole("heading", { name: "允许保存报告草稿?" })).toBeInTheDocument(); }); + it("shows Markdown, generated PNG preview and downloads for a text-only model", async () => { + const store = new HarnessClientStore(); store.getSnapshot().model.supportsImages = false; + vi.stubGlobal("fetch", vi.fn(async () => new Response(new Blob(["png"], { type: "image/png" }), { headers: { "content-type": "image/png" } }))); + Object.defineProperty(URL, "createObjectURL", { configurable: true, value: vi.fn(() => "blob:report-image") }); + const artifact = reportArtifact(); + store.reduce(harnessEvent(1, "tool.requested", { toolCallId: "render", toolName: "render_swads_daily_report", arguments: {} })); + store.reduce(harnessEvent(2, "assistant.delta", { messageId: "assistant-real", delta: "**账户结论**\n\n- 建议一\n- 建议二\n- 建议三" })); + store.reduce(harnessEvent(3, "tool.completed", { toolCallId: "render", toolName: "render_swads_daily_report", result: artifact })); + store.reduce(harnessEvent(4, "report.generated", { assistantMessageId: "assistant-real", artifact })); + store.reduce(harnessEvent(5, "run.completed", {})); + await waitFor(() => expect(store.getSnapshot().messages[0]?.content).toEqual(expect.arrayContaining([expect.objectContaining({ type: "image" })]))); + render(); + expect(screen.getByText("账户结论").tagName).toBe("STRONG"); expect(screen.getAllByRole("listitem")).toHaveLength(3); expect(screen.getByRole("link", { name: "下载 JSON" })).toHaveAttribute("href", artifact.downloads.json); + await userEvent.setup().click(screen.getByRole("button", { name: "预览 SW Ads 2026-09-06" })); expect(screen.getByRole("dialog")).toBeInTheDocument(); + expect(store.getSnapshot().messages.filter((message) => message.role === "assistant")).toHaveLength(1); + }); + it("shows a no-PNG warning and only available downloads", () => { + const artifact = reportArtifact(); const result = { ...artifact, previewUrl: undefined, downloads: { json: artifact.downloads.json, html: artifact.downloads.html }, warning: { code: "CHROME_UNAVAILABLE", message: "Chrome unavailable; HTML/JSON available" } }; + render(); expect(screen.getByRole("status")).toHaveTextContent("Chrome unavailable"); expect(screen.queryByRole("link", { name: "下载 PNG" })).not.toBeInTheDocument(); expect(screen.queryByRole("img")).not.toBeInTheDocument(); + }); + }); function toolProps(toolName: string, args: Record, result?: unknown, approval?: { id: string }) { return { type: "tool-call" as const, toolCallId: "t1", toolName, args, argsText: JSON.stringify(args), result, isError: false, status: { type: "complete" as const }, addResult: vi.fn(), resume: vi.fn(), respondToApproval: vi.fn(), ...(approval ? { approval } : {}) }; } function harnessEvent(sequence: number, type: T, payload: Extract["payload"]): HarnessEvent { return { eventId: `evt_${sequence}`, sessionId: "ses_1", runId: "run_1", timestamp: new Date().toISOString(), sequence, type, payload } as HarnessEvent; } + +function reportArtifact() { + const accountKey = "acct_0123456789abcdef01234567", reportDate = "2026-09-06", reportId = "12345678-1234-4234-8234-123456789abc"; + const base = `/api/reports/${accountKey}/${reportDate}/${reportId}`; + return { accountKey, reportDate, reportId, previewUrl: `${base}/png`, downloads: { png: `${base}/png`, html: `${base}/html`, json: `${base}/json` } }; +} diff --git a/docs/architecture.md b/docs/architecture.md index 88f0ab7..f331a18 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -53,3 +53,13 @@ flowchart TB ## Failure and shutdown Run timeout 和用户 cancel 使用不同 reason;Approval wait 监听同一 AbortSignal。删除先 abort,再 cancel pending approval,关闭订阅与 SSE,调用 Pi `dispose()`,清凭证/附件,最后在 canonical sessions root 校验后删除 cwd。单个 SSE 断开不取消 Run。 + +## SW Ads daily report + +`SkillCatalog.expandCommand` 将精确 `/swads-daily-report` 按需转换为 Pi `` block,自由文本参数保留,`user.message` 保持原文。Catalog/模型自动匹配不加载该 Skill 正文;`disable-model-invocation` 排除自动发现提示。`skill.requested/loaded` 的 `source` 区分 command 和 read,command 不伪造 toolCallId。只读 Skill resources 在已发现根目录内校验 realpath、普通文件和 symlink 边界。 + +Pi tools 增加 `swads_cli`、`render_swads_daily_report`,原有工具和审批不变。`SwadsGateway` 在服务启动时读取并校验环境配置,每个命令创建独立 MCP client/transport,初始化后使用短时能力缓存(60 秒,capabilities 强制刷新),调用结束关闭连接。命令 enum 映射固定远端名,参数先经过本地 strict schema/64 KiB/深度 10 检查,再经远端 inputSchema 验证;需要显式 readOnlyHint,拒绝 destructiveHint。单次调用 30 秒默认超时,响应 2 MiB 上限,AbortSignal 来自当前 Run。Gateway 工具不会注册到 Agent。 + +每个 Session 的 `SwadsReportTools` 记录当前 Run 的 whoami/catalog/query 成功证据,缺失或必需查询失败禁止 render。Skill 根据返回 columns 名称归一化真实指标,日期、账户、归因和样本限制写入 strict `ReportData`。ReportStore 拥有 accountKey/hash、reportId/UUID 和输出路径,临时目录完整写入后 rename。Chrome 从环境或固定系统路径发现,通过 execFile 启动,5200/9000/24000 三档画布,检查底边与结尾标记后裁切;不完整则降级。 + +render tool completion 由 Pi adapter 映射到 `report.generated { assistantMessageId, artifact }`。事件只含相对 URL/安全标识/warning,既没有文件正文也没有绝对路径。Web reducer 合并同 Run 的临时 assistant ID,从同源接口获取 PNG 并创建 blob URL,再追加到指定消息(兼容本地 HTTP 部署下 assistant-ui 的图片 URL 校验);Markdown、图片 Dialog 与下载 Tool UI 分工呈现。生成图片是展示产物,不进入模型输入,不受 vision capability 限制。报告 Store 位于 Session Store 之外,重置和删除会话不会删除报告。 diff --git a/docs/security-boundaries.md b/docs/security-boundaries.md index 567dd6a..95effd4 100644 --- a/docs/security-boundaries.md +++ b/docs/security-boundaries.md @@ -39,3 +39,11 @@ multipart 流有字节上限;扩展名、MIME、magic bytes 三重匹配。Sha ## Required production isolation 不可信 Skill、仓库、脚本、附件解析或无人值守 Agent 必须在 Docker(配合 seccomp/AppArmor 和无特权用户)、VM、micro-VM 或远程 Sandbox 运行,并施加 CPU/内存/磁盘/时间/网络配额。Project Trust 不能替代这些措施。 + +## SW Ads read-only facade and artifacts + +- `SWADS_MCP_TOKEN` 仅存在服务端配置和出站 Authorization Header。URL 不允许凭据/query/fragment;fetch 禁止 redirect,避免 Header 转发。Gateway 原始错误正文、Headers 和 Token 不进入模型/SSE/日志/下载,返回固定 `SWADS_*` 错误分类。响应中的 Token 精确值及敏感键在模型交付前脱敏。 +- `swads_cli` 只允许约定九个 command。参数拒绝任意 URL/Header/Token/tool name 和未知顶层字段;语义 query 允许有界嵌套 JSON,但不能提供传输控制项。Tool 必须存在并声明只读;非只读 `runtime_findings` 禁止执行。缺少所需 TikTok 能力时 fail closed,不用 mock 数据兜底。 +- `render_swads_daily_report` 不接收路径、命令或 Chrome 参数。strict schema 限制字符串/数组长度、真实 ISO 日期、有限数值/null、倍率/百分比与恰好三条建议。动态 HTML 字段全部转义,CSS 无外部字体;CSP 禁止脚本/网络资源,Chrome 使用独立临时 profile、固定参数和进程超时。 +- Artifact URL 仅按 accountKey、ISO 日期、UUID 和 png/html/json enum 定位;逐段拒绝 symlink,无目录枚举。PNG inline、HTML/JSON attachment,准确 MIME、nosniff、限制性 CSP、private no-store。随机 ID 不是身份授权,现有单进程无认证应用仅适合内部/本地边界;公网多租户部署需要独立授权设计。 +- JSON/HTML 即使 PNG 失败仍保留。PNG 超时、缺浏览器、截断分别返回 warning;取消 Run 则清理未发布临时目录。报告不随 Session 删除,部署方需管理持久目录容量。 diff --git a/packages/harness/package.json b/packages/harness/package.json index e004455..742793b 100644 --- a/packages/harness/package.json +++ b/packages/harness/package.json @@ -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" + } } diff --git a/packages/harness/src/errors.ts b/packages/harness/src/errors.ts index de2f6b3..32666ac 100644 --- a/packages/harness/src/errors.ts +++ b/packages/harness/src/errors.ts @@ -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(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; +} diff --git a/packages/harness/src/event-store.ts b/packages/harness/src/event-store.ts index ff11c51..1dea8e9 100644 --- a/packages/harness/src/event-store.ts +++ b/packages/harness/src/event-store.ts @@ -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(sessionId: string, input: EventInput): Extract { const bucket = this.bucket(sessionId); const value = { - ...input, + ...redactServerSecret(input), eventId: `evt_${randomUUID()}`, sessionId, timestamp: new Date().toISOString(), diff --git a/packages/harness/src/index.ts b/packages/harness/src/index.ts index e2046e3..b8387c3 100644 --- a/packages/harness/src/index.ts +++ b/packages/harness/src/index.ts @@ -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"; diff --git a/packages/harness/src/pi-event-adapter.ts b/packages/harness/src/pi-event-adapter.ts index bca8f94..c429b35 100644 --- a/packages/harness/src/pi-event-adapter.ts +++ b/packages/harness/src/pi-event-adapter.ts @@ -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"; +} diff --git a/packages/harness/src/report-renderer.ts b/packages/harness/src/report-renderer.ts new file mode 100644 index 0000000..ea54502 --- /dev/null +++ b/packages/harness/src/report-renderer.ts @@ -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) => `
${escape(label)}${escape(value)}${escape(note)}
`; + type Row = ReportData["campaigns"]["winners"][number] | ReportData["creative"]["winners"][number]; + const table = (rows: Row[], creativeTable = false) => `
${rows.length ? rows.map((row) => ``).join("") : ''}
${creativeTable ? "素材 / 发布账号" : "Campaign"}SpendGMV订单ROAS
${escape(row.name)}${escape([row.id, "creator" in row ? row.creator : row.status, row.reason].filter(Boolean).join(" · "))}${"clicks" in row ? `点击 ${number(row.clicks)} · Product CTR ${number(row.product_ctr_percent, "%")}` : ""}${number(row.spend)}${number(row.revenue)}${number(row.orders)}${number(row.roas, "×")}
暂无足够样本
`; + const panel = (title: string, content: string) => `
${escape(title)}
${content}
`; + const section = (title: string, subtitle: string, content: string) => `

${escape(title)}

${escape(subtitle)}

${content}
`; + const maxRoas = Math.max(1, ...data.trend.map((item) => item.roas ?? 0)); + const trend = data.trend.map((item) => `
${number(item.roas, "×")}${escape(item.label)}${item.partial ? " (partial)" : ""}
`).join(""); + const labels = { required: "需人工确认", partial: "部分需确认", none: "只读检查" }; + const recommendations = data.recommendations.map((item, index) => `
0${index + 1}${labels[item.confirmation]}

${escape(item.title)}

${escape(item.detail)}

`).join(""); + const confirmations = data.manual_confirmations.map((item) => `
${item.required ? "✓" : "○"}
${escape(item.action)} · ${item.required ? "需确认" : "只读"}${escape(item.reason)}
`).join(""); + const sources = creative.source_groups.map((item) => `
${escape(item.label)} · ${number(item.roas, "×")}

Spend ${escape(money(item.spend))} · GMV ${escape(money(item.revenue))} · ${number(item.orders)} 单

`).join("") || "发布账号归因信息不可用。"; + const insights = creative.insights.map((item, index) => `
${index + 1}
归因线索

${escape(item)}

`).join("") || "样本不足以形成稳定判断。"; + return `${escape(meta.account_name)} 每日投放报告
+
SW ADS · DELIVERY INTELLIGENCE

${escape(meta.account_name)}
每日投放报告

${escape(meta.report_date)} · ${escape(meta.period_label)} · ${escape(meta.reporting_timezone)} · ${escape(meta.attribution_label)}

整体状态:${escape(meta.status_label)}${escape(meta.comparison_label)} · ${escape(meta.freshness_label)} · ${escape(meta.account_id)}
+
${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")}
+${data.monitoring_note ? `
监控提示

${escape(data.monitoring_note)}

` : ""} +${section("七日 ROAS 趋势", meta.comparison_label, `
${trend}
`)} +${section("Campaign 分层", `效率、消耗与订单样本共同判断;金额单位 ${meta.currency}。`, `
${panel("相对优质 Campaign", table(data.campaigns.winners))}${panel("异常 / 低效 Campaign", table(data.campaigns.anomalies))}
`)} +${section("素材表现与归因线索", `${number(creative.row_count)} 条有消耗素材;消耗覆盖 ${number(creative.coverage_percent, "%")};相关性不代表因果。`, `
${panel("发布账号贡献对比", sources)}${panel("归因线索", insights)}
${panel("高价值素材", table(creative.winners, true))}${panel("高消耗低转化素材", table(creative.anomalies, true))}
`)} +${section("三条优化建议", "本报告不执行平台写入。", `
${recommendations}
`)} +${section("人工确认清单", "预算、目标、状态、授权与发布均需人工控制。", `
${confirmations}
`)} +
数据与口径
    ${[meta.attribution_label, ...data.notes].map((note) => `
  • ${escape(note)}
  • `).join("")}
`; +} + +export interface PngResult { png?: Buffer; warning?: ReportArtifact["warning"] } +export interface ChromeRunner { (executable: string, args: string[], signal: AbortSignal): Promise } +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 { + 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["code"]): PngResult => ({ warning: { code, message: `${code}: PNG unavailable; HTML and JSON are available.` } }); +export async function renderReportPng(htmlPath: string, signal: AbortSignal, options: { discover?: () => Promise; runner?: ChromeRunner } = {}): Promise { + 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 }); } +} diff --git a/packages/harness/src/report-schema.ts b/packages/harness/src/report-schema.ts new file mode 100644 index 0000000..9c5d3eb --- /dev/null +++ b/packages/harness/src/report-schema.ts @@ -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; diff --git a/packages/harness/src/report-store.ts b/packages/harness/src/report-store.ts new file mode 100644 index 0000000..d86c162 --- /dev/null +++ b/packages/harness/src/report-store.ts @@ -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; +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 { + 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); } diff --git a/packages/harness/src/session.ts b/packages/harness/src/session.ts index 22e5d7e..8af7415 100644 --- a/packages/harness/src/session.ts +++ b/packages/harness/src/session.ts @@ -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 { 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(); 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).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)); diff --git a/packages/harness/src/skills.ts b/packages/harness/src/skills.ts index 8ed18e6..d19cdb7 100644 --- a/packages/harness/src/skills.ts +++ b/packages/harness/src/skills.ts @@ -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(); 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 { - 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: `\nReferences are relative to ${path.dirname(skill.canonicalPath)}.\n\n${body}\n${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 : `/${path.basename(absolute)}`; } } + +function inside(root: string, target: string): boolean { const relative = path.relative(root, target); return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative); } diff --git a/packages/harness/src/swads-gateway.ts b/packages/harness/src/swads-gateway.ts new file mode 100644 index 0000000..8d19cf3 --- /dev/null +++ b/packages/harness/src/swads-gateway.ts @@ -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; + call(name: string, args: Record, signal: AbortSignal): Promise; + close(): Promise; +} +export type GatewayConnector = (signal: AbortSignal) => Promise; +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({ + 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[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(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(operation: (connection: GatewayConnection, signal: AbortSignal) => Promise, runSignal: AbortSignal): Promise { + 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((_, 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 { + 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; + } +} diff --git a/packages/harness/src/swads-tools.ts b/packages/harness/src/swads-tools.ts new file mode 100644 index 0000000..762c89d --- /dev/null +++ b/packages/harness/src/swads-tools.ts @@ -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]), 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; + 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 { + let params: ReturnType; + 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[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>({ 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.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 }; }, + })]; + } +} diff --git a/packages/harness/tests/core.test.ts b/packages/harness/tests/core.test.ts index a973d33..8896f06 100644 --- a/packages/harness/tests/core.test.ts +++ b/packages/harness/tests/core.test.ts @@ -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", () => { diff --git a/packages/harness/tests/report-fixture.ts b/packages/harness/tests/report-fixture.ts new file mode 100644 index 0000000..5db6bb4 --- /dev/null +++ b/packages/harness/tests/report-fixture.ts @@ -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。"], + }; +} diff --git a/packages/harness/tests/reports.test.ts b/packages/harness/tests/reports.test.ts new file mode 100644 index 0000000..3f20a7e --- /dev/null +++ b/packages/harness/tests/reports.test.ts @@ -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) => { d.recommendations.pop(); }, (d: ReturnType) => { d.meta.report_date = "2026-02-30"; }, (d: ReturnType) => { d.summary.spend = 0; }, (d: ReturnType) => { 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 = '