diff --git a/.changeset/modular-skills-artifacts.md b/.changeset/modular-skills-artifacts.md new file mode 100644 index 00000000..f7d9bea1 --- /dev/null +++ b/.changeset/modular-skills-artifacts.md @@ -0,0 +1,5 @@ +--- +"@agent-commons/sdk": minor +--- + +Add per-agent skill assignments, agent-scoped library items, configurable capability providers, portable skill imports, and sandboxed UI plugin management. diff --git a/.github/workflows/deploy-commons-api-aws.yml b/.github/workflows/deploy-commons-api-aws.yml index 6d729504..645e8331 100644 --- a/.github/workflows/deploy-commons-api-aws.yml +++ b/.github/workflows/deploy-commons-api-aws.yml @@ -72,7 +72,29 @@ jobs: --query 'builds[0].buildStatus' --output text) case "$status" in SUCCEEDED) exit 0 ;; - FAILED|FAULT|STOPPED|TIMED_OUT) exit 1 ;; + FAILED|FAULT|STOPPED|TIMED_OUT) + echo "CodeBuild finished with status: $status" + aws codebuild batch-get-builds \ + --ids "${{ steps.build.outputs.id }}" \ + --query 'builds[0].phases[?phaseStatus==`FAILED`].{phase:phaseType,contexts:contexts}' \ + --output json || true + + log_group=$(aws codebuild batch-get-builds \ + --ids "${{ steps.build.outputs.id }}" \ + --query 'builds[0].logs.groupName' --output text) + log_stream=$(aws codebuild batch-get-builds \ + --ids "${{ steps.build.outputs.id }}" \ + --query 'builds[0].logs.streamName' --output text) + if [ -n "$log_group" ] && [ "$log_group" != "None" ] && \ + [ -n "$log_stream" ] && [ "$log_stream" != "None" ]; then + aws logs get-log-events \ + --log-group-name "$log_group" \ + --log-stream-name "$log_stream" \ + --limit 300 \ + --query 'events[].message' --output text || true + fi + exit 1 + ;; *) sleep 15 ;; esac done diff --git a/.github/workflows/diagnose-codebuild.yml b/.github/workflows/diagnose-codebuild.yml index 59488c8a..d3ce39bb 100644 --- a/.github/workflows/diagnose-codebuild.yml +++ b/.github/workflows/diagnose-codebuild.yml @@ -131,10 +131,11 @@ jobs: --max-results 20 \ --query '{serviceDeployments:serviceDeployments[*].{arn:serviceDeploymentArn,status:status,statusReason:statusReason,createdAt:createdAt,startedAt:startedAt,finishedAt:finishedAt,targetRevision:targetServiceRevisionArn}}' \ --output json) - available_secret_keys=$(aws secretsmanager get-secret-value \ + available_secret_json=$(aws secretsmanager get-secret-value \ --secret-id "$RUNTIME_SECRET_ARN" \ --query SecretString \ - --output text | jq -c 'keys | sort') + --output text) + available_secret_keys=$(jq -c 'keys | sort' <<<"$available_secret_json") required_secret_keys=$(sed \ -e '/^[[:space:]]*#/d' \ -e '/^[[:space:]]*$/d' \ @@ -143,13 +144,33 @@ jobs: --argjson required "$required_secret_keys" \ --argjson available "$available_secret_keys" \ '$required - $available') + + # Re-run the pending migrations from the uploaded source and + # capture the real error through the diagnostic + # S3 channel. The GitHub deploy role intentionally cannot read + # CodeBuild's CloudWatch stream. + corepack enable pnpm + corepack prepare pnpm@9.15.3 --activate + pnpm install --frozen-lockfile --filter commons-api... + set +e + POSTGRES_HOST="$(jq -r .POSTGRES_HOST <<<"$available_secret_json")" \ + POSTGRES_PORT="$(jq -r .POSTGRES_PORT <<<"$available_secret_json")" \ + POSTGRES_DATABASE="$(jq -r .POSTGRES_DATABASE <<<"$available_secret_json")" \ + POSTGRES_USER="$(jq -r .POSTGRES_USER <<<"$available_secret_json")" \ + POSTGRES_PASSWORD="$(jq -r .POSTGRES_PASSWORD <<<"$available_secret_json")" \ + POSTGRES_SSL="$(jq -r '.POSTGRES_SSL // "disable"' <<<"$available_secret_json")" \ + pnpm --filter commons-api migrate > /tmp/migration-probe.log 2>&1 + migration_exit=$? + set -e jq -n \ --argjson stack "$stack_json" \ --argjson service "$service_json" \ --argjson deploymentList "$deployment_list" \ --argjson availableSecretKeys "$available_secret_keys" \ --argjson missingSecretKeys "$missing_secret_keys" \ - '{stack: $stack, service: $service, deploymentList: $deploymentList, runtimeSecret: {availableKeys: $availableSecretKeys, missingKeys: $missingSecretKeys}}' \ + --argjson migrationExit "$migration_exit" \ + --rawfile migrationLog /tmp/migration-probe.log \ + '{stack: $stack, service: $service, deploymentList: $deploymentList, runtimeSecret: {availableKeys: $availableSecretKeys, missingKeys: $missingSecretKeys}, migrationProbe: {exitCode: $migrationExit, log: $migrationLog}}' \ > /tmp/service-deployment.json curl --fail-with-body --silent --show-error -X PUT -H 'Content-Type:' --data-binary @/tmp/service-deployment.json "$DIAGNOSTIC_PUT_URL" YAML diff --git a/apps/commons-api-gateway/scripts/smoke.ts b/apps/commons-api-gateway/scripts/smoke.ts index 0ec732b2..3c525d85 100644 --- a/apps/commons-api-gateway/scripts/smoke.ts +++ b/apps/commons-api-gateway/scripts/smoke.ts @@ -8,11 +8,40 @@ delete process.env.AGENT_COMMONS_INTERNAL_URL; delete process.env.COMMON_OS_INTERNAL_URL; export {}; -const { createGatewayApp } = await import("../src/index.js"); +const { createGatewayApp, publicAssetRequestHeaders } = await import( + "../src/index.js" +); const app = createGatewayApp(); const failures: string[] = []; +const sanitizedPublicHeaders = publicAssetRequestHeaders({ + authorization: "Bearer should-not-leave-the-gateway", + cookie: "session=should-not-leave-the-gateway", + "proxy-authorization": "Basic should-not-leave-the-gateway", + "x-owner-id": "owner-1", + "x-initiator": "user-1", + "x-commons-actor-id": "actor-1", + "x-commons-signature": "forged", + accept: "text/html", +}); +for (const sensitiveHeader of [ + "authorization", + "cookie", + "proxy-authorization", + "x-owner-id", + "x-initiator", + "x-commons-actor-id", + "x-commons-signature", +]) { + if (sanitizedPublicHeaders.has(sensitiveHeader)) { + failures.push(`public asset proxy leaked ${sensitiveHeader}`); + } +} +if (sanitizedPublicHeaders.get("accept") !== "text/html") { + failures.push("public asset proxy removed a harmless content header"); +} + async function expectStatus( label: string, request: Promise | Response, @@ -20,9 +49,7 @@ async function expectStatus( ) { const response = await request; if (response.status !== expected) { - failures.push( - `${label}: expected ${expected}, got ${response.status}`, - ); + failures.push(`${label}: expected ${expected}, got ${response.status}`); } } @@ -43,10 +70,19 @@ const publicRoutes: Array<[string, RequestInit?]> = [ ["/v1/oauth/providers/google"], ["/v1/oauth/callback/google"], ["/v1/billing/webhook", { method: "POST" }], + ["/v1/previews/example-project/"], + ["/v1/previews/example-project/assets/index.js"], + [ + "/v1/ui-plugin-host?entry=https%3A%2F%2Fapi.agentcommons.io%2Fv1%2Fpreviews%2Fexample-project%2Fdeployments%2F00000000-0000-4000-8000-000000000000%2F&commonsHostOrigin=https%3A%2F%2Fagentcommons.io", + ], ]; for (const [path, init] of publicRoutes) { - await expectStatus(`public ${init?.method ?? "GET"} ${path}`, app.request(path, init), 503); + await expectStatus( + `public ${init?.method ?? "GET"} ${path}`, + app.request(path, init), + 503, + ); } /** Routes that carry user data and must stay behind the credential check. */ @@ -61,6 +97,30 @@ for (const path of protectedRoutes) { await expectStatus(`protected GET ${path}`, app.request(path), 401); } +const previewResponse = await app.request("/v1/previews/example-project/"); +if (previewResponse.headers.has("x-frame-options")) { + failures.push("public preview: gateway must not add x-frame-options"); +} +if (previewResponse.headers.get("access-control-allow-origin") !== "*") { + failures.push("public preview: sandboxed modules require wildcard CORS"); +} +if (previewResponse.headers.has("access-control-allow-credentials")) { + failures.push("public preview: public assets must not allow credentials"); +} + +const pluginHostResponse = await app.request( + "/v1/ui-plugin-host?entry=https%3A%2F%2Fapi.agentcommons.io%2Fv1%2Fpreviews%2Fexample-project%2Fdeployments%2F00000000-0000-4000-8000-000000000000%2F&commonsHostOrigin=https%3A%2F%2Fagentcommons.io", +); +if (pluginHostResponse.headers.has("x-frame-options")) { + failures.push("UI plugin host: gateway must not add x-frame-options"); +} +if (pluginHostResponse.headers.get("access-control-allow-origin") !== "*") { + failures.push("UI plugin host: opaque sandbox relay requires wildcard CORS"); +} +if (pluginHostResponse.headers.has("access-control-allow-credentials")) { + failures.push("UI plugin host: must not allow credentials"); +} + if (failures.length > 0) { console.error("Gateway smoke test failed:"); for (const failure of failures) console.error(` - ${failure}`); diff --git a/apps/commons-api-gateway/src/index.ts b/apps/commons-api-gateway/src/index.ts index 52d405cb..642b09b8 100644 --- a/apps/commons-api-gateway/src/index.ts +++ b/apps/commons-api-gateway/src/index.ts @@ -14,25 +14,37 @@ export function createGatewayApp() { const app = new Hono<{ Variables: Variables }>(); const counters = new Map(); - app.use("*", secureHeaders()); - app.use( - "*", - cors({ - origin: (origin) => { - const allowed = (process.env.CORS_ORIGINS ?? "") - .split(",") - .map((value) => value.trim()); - return allowed.includes(origin) ? origin : allowed[0] ?? ""; - }, - allowHeaders: [ - "authorization", - "content-type", - "idempotency-key", - "x-request-id", - ], - exposeHeaders: ["x-request-id", "x-commons-service"], - }), - ); + const securityHeaders = secureHeaders(); + app.use("*", (c, next) => { + // Published code-project previews provide their own deliberately strict + // sandbox policy. The gateway default includes X-Frame-Options: + // SAMEORIGIN, which prevents these cross-origin previews from loading in + // the Commons plugin iframe. + if (isPluginFramePath(c.req.path)) return next(); + return securityHeaders(c, next); + }); + const crossOriginHeaders = cors({ + origin: (origin) => { + const allowed = (process.env.CORS_ORIGINS ?? "") + .split(",") + .map((value) => value.trim()); + return allowed.includes(origin) ? origin : (allowed[0] ?? ""); + }, + allowHeaders: [ + "authorization", + "content-type", + "idempotency-key", + "x-request-id", + ], + exposeHeaders: ["x-request-id", "x-commons-service"], + }); + app.use("*", (c, next) => { + // A sandboxed plugin frame intentionally has an opaque `null` origin. + // Public preview modules therefore need wildcard CORS and no credentials; + // the preview proxy below applies that narrower policy. + if (isPluginFramePath(c.req.path)) return next(); + return crossOriginHeaders(c, next); + }); app.use("*", async (c, next) => { const requestId = c.req.header("x-request-id") ?? `req_${randomUUID()}`; c.set("requestId", requestId); @@ -57,6 +69,11 @@ export function createGatewayApp() { baseUrl: string | undefined, targetPath: string, ) { + const isPluginFrame = isPluginFramePath(targetPath); + if (isPluginFrame) { + c.header("access-control-allow-origin", "*"); + c.header("cross-origin-resource-policy", "cross-origin"); + } if (!baseUrl) { return c.json( { @@ -72,7 +89,9 @@ export function createGatewayApp() { const url = new URL(targetPath, `${baseUrl.replace(/\/$/, "")}/`); const incoming = new URL(c.req.url); url.search = incoming.search; - const headers = new Headers(c.req.raw.headers); + const headers = isPluginFrame + ? publicAssetRequestHeaders(c.req.raw.headers) + : new Headers(c.req.raw.headers); headers.delete("host"); headers.delete("content-length"); headers.delete("authorization"); @@ -88,6 +107,11 @@ export function createGatewayApp() { duplex: "half", } as RequestInit); const outputHeaders = new Headers(response.headers); + if (isPluginFrame) { + outputHeaders.set("access-control-allow-origin", "*"); + outputHeaders.delete("access-control-allow-credentials"); + outputHeaders.set("cross-origin-resource-policy", "cross-origin"); + } outputHeaders.set("x-request-id", c.get("requestId")); outputHeaders.set("x-commons-service", service); return new Response(response.body, { @@ -142,6 +166,30 @@ export function createGatewayApp() { c.req.path, ), ); + // Published code projects are intentionally public, unguessable preview + // assets. The upstream only serves projects whose visibility is public and + // whose latest deployment is ready. They must bypass credential auth so an + // isolated iframe can load HTML and relative assets without receiving a + // Commons bearer token. + app.get("/v1/previews/*", (c) => + publicProxy( + c, + "agent-commons", + process.env.AGENT_COMMONS_INTERNAL_URL, + c.req.path, + ), + ); + // Trusted relay around an opaque generated-app iframe. It carries no user + // data or credential and must be frameable from the configured Commons app + // origin, just like the immutable preview it contains. + app.get("/v1/ui-plugin-host", (c) => + publicProxy( + c, + "agent-commons", + process.env.AGENT_COMMONS_INTERNAL_URL, + c.req.path, + ), + ); app.use("/v1/*", async (c, next) => { const principal = await authenticate(c.req.header("authorization")); @@ -356,6 +404,47 @@ if (process.env.COMMONS_GATEWAY_NO_LISTEN !== "true") { export default app; +function isPluginFramePath(path: string) { + return ( + path === "/v1/ui-plugin-host" || + path === "/v1/previews" || + path.startsWith("/v1/previews/") + ); +} + +/** + * Public preview and relay responses never need a Commons identity. Strip all + * ambient browser credentials and gateway delegation headers before the + * request reaches the API service. This keeps the public content origin + * cookieless even when it currently shares the public API hostname. + */ +export function publicAssetRequestHeaders(input: HeadersInit) { + const headers = new Headers(input); + for (const name of [ + "host", + "content-length", + "authorization", + "proxy-authorization", + "cookie", + "x-owner-id", + "x-initiator", + "x-user-id", + "x-user-email", + "x-commons-actor-id", + "x-commons-actor-type", + "x-commons-workspace-id", + "x-commons-project-id", + "x-commons-scopes", + "x-commons-request-id", + "x-commons-timestamp", + "x-commons-signature", + "x-commons-internal-secret", + ]) { + headers.delete(name); + } + return headers; +} + function requiredScope(method: string, path: string) { if (path.includes("/activity")) return "activity:read"; if (path.startsWith("/v1/compute")) { diff --git a/apps/commons-api/.env.example b/apps/commons-api/.env.example index 53415afb..5a25886b 100644 --- a/apps/commons-api/.env.example +++ b/apps/commons-api/.env.example @@ -102,6 +102,16 @@ BRAVE_SEARCH_COST_USD_PER_CALL="0.005" # SEARXNG_API_KEY="" # SEARXNG_SEARCH_COST_USD_PER_CALL="0" OPENAI_TRANSCRIPTION_COST_USD_PER_MINUTE="0.003" +# Video uploads are sampled with ffmpeg and summarized by a multimodal model. +# Set to false to retain videos without automatic understanding. +AGENT_FILE_VIDEO_UNDERSTANDING_ENABLED="true" +AGENT_FILE_VIDEO_UNDERSTANDING_MODEL="gpt-5.4-mini" +AGENT_FILE_VIDEO_MAX_FRAMES="8" +FFMPEG_PATH="ffmpeg" +# Audio-only transcription remains opt-in. Video audio is transcribed as part +# of enabled video understanding. +AGENT_FILE_AUDIO_TRANSCRIPTION_ENABLED="false" +AGENT_FILE_AUDIO_TRANSCRIPTION_MODEL="gpt-4o-mini-transcribe" OPENAI_TTS_COST_USD_PER_1K_CHARACTERS="0.015" ELEVENLABS_TTS_COST_USD_PER_1K_CHARACTERS="0.10" # Optional JSON override keyed by quality then size; defaults track GPT Image 2. @@ -113,3 +123,20 @@ BILLING_ENFORCEMENT="true" # Optional JSON override of credits/min per profile, e.g. # {"starter":2,"standard":7,"performance":14,"gpu":70} COMPUTE_CREDITS_PER_MIN="" + +# ── Provenance ─────────────────────────────────────────────────────────── +# Metadata capture writes hashes, sizes, timings and attribution without raw +# prompt/output text. Full capture is a per-run user choice. Hidden model +# reasoning is never persisted by this subsystem. +PROVENANCE_DEFAULT_MODE="metadata" +PROVENANCE_FULL_CAPTURE_ENABLED="true" +# On-chain submission is both environment-gated and explicitly requested. +PROVENANCE_ONCHAIN_ENABLED="false" +# Optional asynchronous ProvenanceKit sink. Local trajectory recording works +# without it and never waits on this service. +PROVENANCEKIT_EXPORT_ENABLED="false" +PROVENANCEKIT_API_URL="" +PROVENANCEKIT_API_KEY="" +PROVENANCE_BATCH_SIZE="200" +PROVENANCE_FLUSH_MS="40" +PROVENANCE_QUEUE_LIMIT="5000" diff --git a/apps/commons-api/Dockerfile b/apps/commons-api/Dockerfile index 141b0d54..dff5a741 100644 --- a/apps/commons-api/Dockerfile +++ b/apps/commons-api/Dockerfile @@ -21,50 +21,55 @@ RUN pnpm --filter commons-api build \ && pnpm --filter commons-api deploy --prod --ignore-scripts /deploy \ && cp -r apps/commons-api/dist /deploy/dist -FROM public.ecr.aws/docker/library/node:22.11.0-bookworm-slim AS runtime +FROM cgr.dev/chainguard/wolfi-base:latest AS browser -# Chromium and its runtime libraries are required by the web capture service. -RUN apt-get update && apt-get install -y \ - chromium \ - chromium-sandbox \ - fonts-liberation \ - fonts-noto-color-emoji \ - libatk-bridge2.0-0 \ - libatk1.0-0 \ - libatspi2.0-0 \ - libcairo2 \ - libcups2 \ - libdbus-1-3 \ - libdrm2 \ - libgbm1 \ - libglib2.0-0 \ - libgtk-3-0 \ - libnspr4 \ - libnss3 \ - libpango-1.0-0 \ - libx11-6 \ - libxcb1 \ - libxcomposite1 \ - libxdamage1 \ - libxext6 \ - libxfixes3 \ - libxkbcommon0 \ - libxrandr2 \ - libxshmfence1 \ - xdg-utils \ - --no-install-recommends \ - && rm -rf /var/lib/apt/lists/* +# Wolfi's public Chromium package can lag the browser security channel. Fetch a +# pinned Chrome for Testing build from Google's release bucket and verify its +# digest, so the runtime is current and reproducible without silently accepting +# a mutable download. +ARG CHROME_VERSION=152.0.7977.64 +ARG CHROME_SHA256=8b592f066af71f054aab2cc80fc26f73c775c6d44ebb99d16ade924b24756c2e +RUN apk add --no-cache ca-certificates curl unzip \ + && curl --fail --show-error --silent --location \ + "https://storage.googleapis.com/chrome-for-testing-public/${CHROME_VERSION}/linux64/chrome-linux64.zip" \ + --output /tmp/chrome.zip \ + && echo "${CHROME_SHA256} /tmp/chrome.zip" | sha256sum -c - \ + && unzip -q /tmp/chrome.zip -d /opt \ + && rm /tmp/chrome.zip + +FROM cgr.dev/chainguard/node:latest-dev AS runtime + +# Chrome's runtime libraries are required by the pluggable web capture service. +USER root +RUN apk add --no-cache \ + ffmpeg \ + font-liberation \ + font-noto-emoji \ + font-opensans \ + fontconfig \ + gtk-3 \ + icu-data-full \ + libnss \ + mesa \ + mesa-glx \ + nss \ + systemd \ + xdg-utils + +COPY --from=browser /opt/chrome-linux64 /opt/chrome-linux64 WORKDIR /app ENV NODE_ENV=production \ PORT=8080 \ + HOME=/home/node \ PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \ - PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium \ - CHROME_PATH=/usr/bin/chromium \ + PUPPETEER_EXECUTABLE_PATH=/opt/chrome-linux64/chrome \ + CHROME_PATH=/opt/chrome-linux64/chrome \ PUPPETEER_CACHE_DIR=/tmp/.cache -RUN mkdir -p /tmp/.cache && chmod 777 /tmp/.cache +RUN mkdir -p /tmp/.cache /home/node \ + && chown -R node:node /tmp/.cache /home/node /app COPY --from=build /deploy ./ COPY --from=build /workspace/apps/commons-api/migrations ./migrations @@ -72,6 +77,9 @@ COPY --from=build /workspace/apps/commons-api/migrations ./migrations COPY --from=build /workspace/apps/commons-api/scripts ./scripts COPY --from=build /workspace/apps/commons-api/platform-skills ./platform-skills +USER node + EXPOSE 8080 -CMD ["node", "dist/nest/src/main.js"] +ENTRYPOINT ["node"] +CMD ["dist/nest/src/main.js"] diff --git a/apps/commons-api/migrations/versioned/019_agent_skill_assignments.sql b/apps/commons-api/migrations/versioned/019_agent_skill_assignments.sql new file mode 100644 index 00000000..d69546e8 --- /dev/null +++ b/apps/commons-api/migrations/versioned/019_agent_skill_assignments.sql @@ -0,0 +1,36 @@ +CREATE TABLE IF NOT EXISTS agent_skill ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + agent_id text NOT NULL REFERENCES agent(agent_id) ON DELETE CASCADE, + skill_id text NOT NULL REFERENCES skill(skill_id) ON DELETE CASCADE, + is_enabled boolean NOT NULL DEFAULT true, + assigned_by text, + config jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT timezone('utc', now()), + updated_at timestamptz NOT NULL DEFAULT timezone('utc', now()) +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_skill_agent_skill + ON agent_skill (agent_id, skill_id); + +CREATE INDEX IF NOT EXISTS idx_agent_skill_skill + ON agent_skill (skill_id, agent_id); + +-- Preserve existing agent-owned skills as explicit assignments. +INSERT INTO agent_skill (agent_id, skill_id, assigned_by) +SELECT a.agent_id, s.skill_id, COALESCE(a.owner_user_id, a.owner) +FROM skill s +INNER JOIN agent a ON a.agent_id = s.owner_id +WHERE s.owner_type = 'agent' +ON CONFLICT (agent_id, skill_id) DO NOTHING; + +-- Bundled Commons skills start on each account's Commons Copilot. Other +-- agents receive them only when the user explicitly enables them. +INSERT INTO agent_skill (agent_id, skill_id, assigned_by) +SELECT a.agent_id, s.skill_id, COALESCE(a.owner_user_id, a.owner) +FROM agent a +CROSS JOIN skill s +WHERE a.is_default = true + AND a.is_system_managed = true + AND s.owner_type = 'platform' + AND s.is_active = true +ON CONFLICT (agent_id, skill_id) DO NOTHING; diff --git a/apps/commons-api/migrations/versioned/020_capability_providers.sql b/apps/commons-api/migrations/versioned/020_capability_providers.sql new file mode 100644 index 00000000..ead62154 --- /dev/null +++ b/apps/commons-api/migrations/versioned/020_capability_providers.sql @@ -0,0 +1,28 @@ +CREATE TABLE IF NOT EXISTS capability_provider ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + owner_id text NOT NULL, + workspace_id text, + capability text NOT NULL, + provider text NOT NULL, + display_name text, + endpoint_url text, + settings jsonb NOT NULL DEFAULT '{}'::jsonb, + encrypted_credentials text, + credentials_iv text, + credentials_tag text, + status text NOT NULL DEFAULT 'active', + created_at timestamptz NOT NULL DEFAULT timezone('utc', now()), + updated_at timestamptz NOT NULL DEFAULT timezone('utc', now()), + CONSTRAINT capability_provider_status_check + CHECK (status IN ('active', 'disabled', 'error')) +); + +CREATE UNIQUE INDEX IF NOT EXISTS capability_provider_owner_capability_idx + ON capability_provider (owner_id, capability); + +CREATE INDEX IF NOT EXISTS capability_provider_provider_idx + ON capability_provider (capability, provider); + +ALTER TABLE agent_wallet + ADD COLUMN IF NOT EXISTS provider text NOT NULL DEFAULT 'commons_mpc', + ADD COLUMN IF NOT EXISTS provider_wallet_id text; diff --git a/apps/commons-api/migrations/versioned/021_ui_plugins.sql b/apps/commons-api/migrations/versioned/021_ui_plugins.sql new file mode 100644 index 00000000..6147373d --- /dev/null +++ b/apps/commons-api/migrations/versioned/021_ui_plugins.sql @@ -0,0 +1,21 @@ +CREATE TABLE IF NOT EXISTS "ui_plugin" ( + "plugin_id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "owner_user_id" text NOT NULL, + "workspace_id" text, + "created_by_agent_id" text REFERENCES "agent"("agent_id") ON DELETE SET NULL, + "code_project_id" uuid NOT NULL REFERENCES "code_project"("project_id") ON DELETE CASCADE, + "name" text NOT NULL, + "slug" text NOT NULL, + "description" text, + "version" text DEFAULT '1.0.0' NOT NULL, + "entry_url" text NOT NULL, + "manifest" jsonb NOT NULL, + "status" text DEFAULT 'draft' NOT NULL, + "created_at" timestamptz DEFAULT timezone('utc', now()) NOT NULL, + "updated_at" timestamptz DEFAULT timezone('utc', now()) NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS "idx_ui_plugin_owner_slug" + ON "ui_plugin" ("owner_user_id", "slug"); +CREATE INDEX IF NOT EXISTS "idx_ui_plugin_owner_status" + ON "ui_plugin" ("owner_user_id", "status", "updated_at"); diff --git a/apps/commons-api/migrations/versioned/022_ui_plugin_deployment_pins.sql b/apps/commons-api/migrations/versioned/022_ui_plugin_deployment_pins.sql new file mode 100644 index 00000000..60926c22 --- /dev/null +++ b/apps/commons-api/migrations/versioned/022_ui_plugin_deployment_pins.sql @@ -0,0 +1,51 @@ +ALTER TABLE "ui_plugin" + ADD COLUMN IF NOT EXISTS "deployment_id" uuid; + +UPDATE "ui_plugin" AS plugin +SET "deployment_id" = deployment."deployment_id" +FROM "code_project" AS project +JOIN "code_project_deployment" AS deployment + ON deployment."deployment_id" = project."latest_deployment_id" + AND deployment."project_id" = project."project_id" +WHERE plugin."code_project_id" = project."project_id" + AND plugin."deployment_id" IS NULL + AND deployment."status" = 'ready' + AND deployment."public_url" IS NOT NULL + AND (deployment."verification" ->> 'passed') = 'true'; + +-- Migrations run before the new ECS tasks are installed. Keep the legacy +-- entry_url intact during this expand release so old tasks continue serving +-- active apps. New code derives the immutable route from deployment_id. +-- Unreviewed or unpublished legacy plugins are quarantined until republished, +-- verified, and registered again. +UPDATE "ui_plugin" +SET "status" = 'disabled', "updated_at" = timezone('utc', now()) +WHERE "deployment_id" IS NULL + AND "status" <> 'disabled'; + +-- A stale task from the rolling deployment must not be able to re-enable an +-- unpinned row after the quarantine update has run. +ALTER TABLE "ui_plugin" + ADD CONSTRAINT "ui_plugin_active_deployment_check" + CHECK ("status" <> 'active' OR "deployment_id" IS NOT NULL); + +CREATE UNIQUE INDEX IF NOT EXISTS "idx_code_project_deployment_id_project" + ON "code_project_deployment" ("deployment_id", "project_id"); + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'ui_plugin_deployment_project_fk' + ) THEN + ALTER TABLE "ui_plugin" + ADD CONSTRAINT "ui_plugin_deployment_project_fk" + FOREIGN KEY ("deployment_id", "code_project_id") + REFERENCES "code_project_deployment"("deployment_id", "project_id") + ON DELETE CASCADE; + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS "idx_ui_plugin_deployment" + ON "ui_plugin" ("deployment_id"); diff --git a/apps/commons-api/migrations/versioned/023_agent_provenance.sql b/apps/commons-api/migrations/versioned/023_agent_provenance.sql new file mode 100644 index 00000000..a7fa8dd6 --- /dev/null +++ b/apps/commons-api/migrations/versioned/023_agent_provenance.sql @@ -0,0 +1,76 @@ +CREATE TABLE IF NOT EXISTS provenance_run ( + trace_id uuid PRIMARY KEY, + session_id uuid REFERENCES session(session_id) ON DELETE SET NULL, + agent_id text NOT NULL REFERENCES agent(agent_id) ON DELETE CASCADE, + initiator text, + workspace_id text, + status text NOT NULL DEFAULT 'running', + capture_mode text NOT NULL DEFAULT 'metadata' + CHECK (capture_mode IN ('metadata', 'full')), + provider text, + model_id text, + onchain_requested boolean NOT NULL DEFAULT false, + event_count integer NOT NULL DEFAULT 0, + dropped_event_count integer NOT NULL DEFAULT 0, + input_tokens integer NOT NULL DEFAULT 0, + output_tokens integer NOT NULL DEFAULT 0, + cached_tokens integer NOT NULL DEFAULT 0, + cost_usd real NOT NULL DEFAULT 0, + duration_ms integer, + bundle_hash text, + anchor_provider text, + anchor_status text NOT NULL DEFAULT 'not_requested', + anchor_ref text, + anchor_metadata jsonb, + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + started_at timestamptz NOT NULL, + ended_at timestamptz, + created_at timestamptz NOT NULL DEFAULT timezone('utc', now()), + updated_at timestamptz NOT NULL DEFAULT timezone('utc', now()) +); + +CREATE INDEX IF NOT EXISTS idx_provenance_run_session_started + ON provenance_run(session_id, started_at); +CREATE INDEX IF NOT EXISTS idx_provenance_run_agent_started + ON provenance_run(agent_id, started_at); +CREATE INDEX IF NOT EXISTS idx_provenance_run_anchor_status + ON provenance_run(anchor_status, updated_at); + +CREATE TABLE IF NOT EXISTS provenance_event ( + event_id uuid PRIMARY KEY DEFAULT extensions.uuid_generate_v4(), + trace_id uuid NOT NULL REFERENCES provenance_run(trace_id) ON DELETE CASCADE, + session_id uuid REFERENCES session(session_id) ON DELETE SET NULL, + sequence integer NOT NULL, + category text NOT NULL, + event_type text NOT NULL, + name text NOT NULL, + phase text, + status text NOT NULL DEFAULT 'completed', + span_id text, + parent_span_id text, + summary text, + payload jsonb, + result jsonb, + content_hash text, + input_tokens integer, + output_tokens integer, + cached_tokens integer, + cost_usd real, + duration_ms integer, + eaa_action jsonb, + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + started_at timestamptz NOT NULL, + ended_at timestamptz, + created_at timestamptz NOT NULL DEFAULT timezone('utc', now()), + CONSTRAINT uq_provenance_event_trace_sequence UNIQUE (trace_id, sequence) +); + +CREATE INDEX IF NOT EXISTS idx_provenance_event_trace_started + ON provenance_event(trace_id, started_at); +CREATE INDEX IF NOT EXISTS idx_provenance_event_session_started + ON provenance_event(session_id, started_at); +CREATE INDEX IF NOT EXISTS idx_provenance_event_category + ON provenance_event(category, started_at); + +COMMENT ON TABLE provenance_event IS + 'Append-only Agent Commons trajectory facts; raw payloads exist only for explicit full capture.'; diff --git a/apps/commons-api/migrations/versioned/024_generalized_provenance.sql b/apps/commons-api/migrations/versioned/024_generalized_provenance.sql new file mode 100644 index 00000000..8047c887 --- /dev/null +++ b/apps/commons-api/migrations/versioned/024_generalized_provenance.sql @@ -0,0 +1,17 @@ +-- Generalize trajectories beyond chat runs so workflows, automations and +-- delegated executions can use the same append-only provenance substrate. +ALTER TABLE provenance_run + ALTER COLUMN agent_id DROP NOT NULL; + +ALTER TABLE provenance_run + ADD COLUMN IF NOT EXISTS scope_type text NOT NULL DEFAULT 'agent_run', + ADD COLUMN IF NOT EXISTS scope_id text; + +CREATE INDEX IF NOT EXISTS idx_provenance_run_scope_started + ON provenance_run(scope_type, scope_id, started_at); + +CREATE INDEX IF NOT EXISTS idx_provenance_event_session_created + ON provenance_event(session_id, created_at); + +COMMENT ON COLUMN provenance_run.scope_type IS + 'Execution surface: agent_run, workflow, task, cli, sdk, or another namespaced scope.'; diff --git a/apps/commons-api/migrations/versioned/025_workflow_human_approval_status.sql b/apps/commons-api/migrations/versioned/025_workflow_human_approval_status.sql new file mode 100644 index 00000000..11a9d41c --- /dev/null +++ b/apps/commons-api/migrations/versioned/025_workflow_human_approval_status.sql @@ -0,0 +1,24 @@ +-- Human-approval nodes pause a workflow durably until an authenticated +-- reviewer approves or rejects it. Keep the database constraint aligned with +-- the execution state machine; older databases only allowed terminal/running +-- states and rejected the pause transition. +ALTER TABLE workflow_execution + DROP CONSTRAINT IF EXISTS workflow_execution_status_check; + +ALTER TABLE workflow_execution + ADD CONSTRAINT workflow_execution_status_check + CHECK ( + status = ANY ( + ARRAY[ + 'pending'::text, + 'running'::text, + 'completed'::text, + 'failed'::text, + 'cancelled'::text, + 'awaiting_approval'::text + ] + ) + ); + +COMMENT ON COLUMN workflow_execution.status IS + 'Workflow state: pending, running, awaiting_approval, completed, failed, or cancelled.'; diff --git a/apps/commons-api/models/schema.ts b/apps/commons-api/models/schema.ts index e835fc66..ce0f42ec 100644 --- a/apps/commons-api/models/schema.ts +++ b/apps/commons-api/models/schema.ts @@ -12,6 +12,8 @@ import { uniqueIndex, index, vector, + foreignKey, + check, } from 'drizzle-orm/pg-core'; import { sql } from 'drizzle-orm'; import { relations } from 'drizzle-orm'; @@ -158,6 +160,8 @@ export const agentWallet = pgTable('agent_wallet', { // 'erc4337' — ERC-4337 smart account with session key // 'external' — owner-connected wallet (platform holds no key) walletType: text('wallet_type').notNull().default('eoa'), + provider: text('provider').notNull().default('commons_mpc'), + providerWalletId: text('provider_wallet_id'), // The public wallet address (safe to store in plaintext) address: text('address').notNull(), @@ -297,6 +301,93 @@ export const codeProjectDeployment = pgTable( table.projectId, table.createdAt, ), + deploymentProjectIdx: uniqueIndex( + 'idx_code_project_deployment_id_project', + ).on(table.deploymentId, table.projectId), + }), +); + +/* ───────────────────────── UI PLUGINS ───────────────────────── */ + +export const uiPlugin = pgTable( + 'ui_plugin', + { + pluginId: uuid('plugin_id') + .default(sql`uuid_generate_v4()`) + .primaryKey(), + ownerUserId: text('owner_user_id').notNull(), + workspaceId: text('workspace_id'), + createdByAgentId: text('created_by_agent_id').references( + () => agent.agentId, + { onDelete: 'set null' }, + ), + codeProjectId: uuid('code_project_id') + .notNull() + .references(() => codeProject.projectId, { onDelete: 'cascade' }), + // Nullable only for rollout-safe legacy rows. New registrations always pin + // a reviewed deployment; unpinned legacy rows are disabled by migration. + deploymentId: uuid('deployment_id'), + name: text('name').notNull(), + slug: text('slug').notNull(), + description: text('description'), + version: text('version').default('1.0.0').notNull(), + entryUrl: text('entry_url').notNull(), + manifest: jsonb('manifest') + .$type<{ + schemaVersion: '1' | '2'; + surfaces: Array<{ + type: 'page' | 'widget'; + title?: string; + width?: number; + height?: number; + }>; + permissions: Array<'theme.read' | 'navigation' | 'storage'>; + capabilities?: Array<{ + name: + | 'agents.read' + | 'tasks.read' + | 'tasks.write' + | 'workflows.read' + | 'workflows.execute' + | 'library.read' + | 'tools.read' + | 'copilot.prompt'; + resourceIds?: string[]; + }>; + networkAccess?: { allowedDomains: string[] }; + }>() + .notNull(), + status: text('status').default('draft').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }) + .default(sql`timezone('utc', now())`) + .notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }) + .default(sql`timezone('utc', now())`) + .notNull(), + }, + (table) => ({ + ownerSlugIdx: uniqueIndex('idx_ui_plugin_owner_slug').on( + table.ownerUserId, + table.slug, + ), + ownerStatusIdx: index('idx_ui_plugin_owner_status').on( + table.ownerUserId, + table.status, + table.updatedAt, + ), + deploymentIdx: index('idx_ui_plugin_deployment').on(table.deploymentId), + activeDeploymentCheck: check( + 'ui_plugin_active_deployment_check', + sql`${table.status} <> 'active' OR ${table.deploymentId} IS NOT NULL`, + ), + deploymentProjectFk: foreignKey({ + columns: [table.deploymentId, table.codeProjectId], + foreignColumns: [ + codeProjectDeployment.deploymentId, + codeProjectDeployment.projectId, + ], + name: 'ui_plugin_deployment_project_fk', + }).onDelete('cascade'), }), ); @@ -1969,6 +2060,141 @@ export const usageEvent = pgTable('usage_event', { .notNull(), }); +/* ─────────────────────── AGENT PROVENANCE ─────────────────────── */ + +/** + * One durable trajectory per top-level agent invocation. The trace id is the + * same id already emitted by runAgent(), so usage, logs, SSE events and + * provenance can be correlated without another lookup. + */ +export const provenanceRun = pgTable( + 'provenance_run', + { + traceId: uuid('trace_id').primaryKey(), + sessionId: uuid('session_id').references(() => session.sessionId, { + onDelete: 'set null', + }), + agentId: text('agent_id').references(() => agent.agentId, { + onDelete: 'cascade', + }), + scopeType: text('scope_type').notNull().default('agent_run'), + scopeId: text('scope_id'), + initiator: text('initiator'), + workspaceId: text('workspace_id'), + status: text('status').notNull().default('running'), + captureMode: text('capture_mode').notNull().default('metadata'), + provider: text('provider'), + modelId: text('model_id'), + onchainRequested: pgBoolean('onchain_requested').notNull().default(false), + eventCount: integer('event_count').notNull().default(0), + droppedEventCount: integer('dropped_event_count').notNull().default(0), + inputTokens: integer('input_tokens').notNull().default(0), + outputTokens: integer('output_tokens').notNull().default(0), + cachedTokens: integer('cached_tokens').notNull().default(0), + costUsd: real('cost_usd').notNull().default(0), + durationMs: integer('duration_ms'), + bundleHash: text('bundle_hash'), + anchorProvider: text('anchor_provider'), + anchorStatus: text('anchor_status').notNull().default('not_requested'), + anchorRef: text('anchor_ref'), + anchorMetadata: jsonb('anchor_metadata').$type>(), + metadata: jsonb('metadata').$type>().default({}), + startedAt: timestamp('started_at', { withTimezone: true }).notNull(), + endedAt: timestamp('ended_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }) + .default(sql`timezone('utc', now())`) + .notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }) + .default(sql`timezone('utc', now())`) + .notNull(), + }, + (table) => ({ + sessionStartedIdx: index('idx_provenance_run_session_started').on( + table.sessionId, + table.startedAt, + ), + agentStartedIdx: index('idx_provenance_run_agent_started').on( + table.agentId, + table.startedAt, + ), + anchorStatusIdx: index('idx_provenance_run_anchor_status').on( + table.anchorStatus, + table.updatedAt, + ), + scopeStartedIdx: index('idx_provenance_run_scope_started').on( + table.scopeType, + table.scopeId, + table.startedAt, + ), + }), +); + +/** + * Append-only facts within a run. Payload/result are deliberately nullable: + * metadata capture stores hashes, sizes and safe summaries while full capture + * is an explicit user choice. + */ +export const provenanceEvent = pgTable( + 'provenance_event', + { + eventId: uuid('event_id') + .default(sql`uuid_generate_v4()`) + .primaryKey(), + traceId: uuid('trace_id') + .notNull() + .references(() => provenanceRun.traceId, { onDelete: 'cascade' }), + sessionId: uuid('session_id').references(() => session.sessionId, { + onDelete: 'set null', + }), + sequence: integer('sequence').notNull(), + category: text('category').notNull(), + eventType: text('event_type').notNull(), + name: text('name').notNull(), + phase: text('phase'), + status: text('status').notNull().default('completed'), + spanId: text('span_id'), + parentSpanId: text('parent_span_id'), + summary: text('summary'), + payload: jsonb('payload').$type>(), + result: jsonb('result').$type>(), + contentHash: text('content_hash'), + inputTokens: integer('input_tokens'), + outputTokens: integer('output_tokens'), + cachedTokens: integer('cached_tokens'), + costUsd: real('cost_usd'), + durationMs: integer('duration_ms'), + eaaAction: jsonb('eaa_action').$type>(), + metadata: jsonb('metadata').$type>().default({}), + startedAt: timestamp('started_at', { withTimezone: true }).notNull(), + endedAt: timestamp('ended_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }) + .default(sql`timezone('utc', now())`) + .notNull(), + }, + (table) => ({ + traceSequenceUnique: uniqueIndex('uq_provenance_event_trace_sequence').on( + table.traceId, + table.sequence, + ), + traceStartedIdx: index('idx_provenance_event_trace_started').on( + table.traceId, + table.startedAt, + ), + sessionStartedIdx: index('idx_provenance_event_session_started').on( + table.sessionId, + table.startedAt, + ), + sessionCreatedIdx: index('idx_provenance_event_session_created').on( + table.sessionId, + table.createdAt, + ), + categoryIdx: index('idx_provenance_event_category').on( + table.category, + table.startedAt, + ), + }), +); + /* ───────────────────────── RELATIONS ───────────────────────── */ // session @@ -2545,6 +2771,78 @@ export const skill = pgTable('skill', { .notNull(), }); +/** Explicit availability of a reusable skill on a particular agent. */ +export const agentSkill = pgTable( + 'agent_skill', + { + id: uuid('id') + .default(sql`gen_random_uuid()`) + .primaryKey(), + agentId: text('agent_id') + .notNull() + .references(() => agent.agentId, { onDelete: 'cascade' }), + skillId: text('skill_id') + .notNull() + .references(() => skill.skillId, { onDelete: 'cascade' }), + isEnabled: pgBoolean('is_enabled').default(true).notNull(), + assignedBy: text('assigned_by'), + config: jsonb('config').$type>().default({}), + createdAt: timestamp('created_at', { withTimezone: true }) + .default(sql`timezone('utc', now())`) + .notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }) + .default(sql`timezone('utc', now())`) + .notNull(), + }, + (table) => ({ + agentSkillIdx: uniqueIndex('idx_agent_skill_agent_skill').on( + table.agentId, + table.skillId, + ), + skillIdx: index('idx_agent_skill_skill').on(table.skillId, table.agentId), + }), +); + +/** + * Account-level adapters for swappable platform capabilities. Credentials are + * encrypted as one JSON envelope so provider-specific secret shapes never + * leak into the public settings document. + */ +export const capabilityProvider = pgTable( + 'capability_provider', + { + id: uuid('id') + .default(sql`gen_random_uuid()`) + .primaryKey(), + ownerId: text('owner_id').notNull(), + workspaceId: text('workspace_id'), + capability: text('capability').notNull(), + provider: text('provider').notNull(), + displayName: text('display_name'), + endpointUrl: text('endpoint_url'), + settings: jsonb('settings').$type>().default({}), + encryptedCredentials: text('encrypted_credentials'), + credentialsIv: text('credentials_iv'), + credentialsTag: text('credentials_tag'), + status: text('status').notNull().default('active'), + createdAt: timestamp('created_at', { withTimezone: true }) + .default(sql`timezone('utc', now())`) + .notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }) + .default(sql`timezone('utc', now())`) + .notNull(), + }, + (table) => ({ + ownerCapabilityIdx: uniqueIndex( + 'capability_provider_owner_capability_idx', + ).on(table.ownerId, table.capability), + providerIdx: index('capability_provider_provider_idx').on( + table.capability, + table.provider, + ), + }), +); + /* ───────────────────────── CREDIT LEDGER ───────────────────────── */ export const creditLedgerEntry = pgTable( diff --git a/apps/commons-api/package.json b/apps/commons-api/package.json index eb43f4e0..25bddbc1 100644 --- a/apps/commons-api/package.json +++ b/apps/commons-api/package.json @@ -25,6 +25,7 @@ "dependencies": { "@aws-sdk/client-s3": "^3.1073.0", "@aws-sdk/s3-request-presigner": "^3.1073.0", + "@fontsource-variable/space-grotesk": "^5.3.0", "@img/sharp-libvips-linux-x64": "1.0.6", "@img/sharp-linux-x64": "0.33.5", "@koush/wrtc": "^0.5.3", @@ -51,6 +52,13 @@ "@nestjs/swagger": "^11.2.0", "@nestjs/websockets": "^11.1.6", "@posthog/ai": "^3.3.2", + "@provenancekit/eaa-types": "^0.1.5", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.15", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-tooltip": "^1.2.8", + "@react-three/fiber": "^9.7.0", "@samchon/openapi": "^4.0.0", "@supabase/supabase-js": "^2.49.4", "@types/lodash": "^4.17.16", @@ -59,8 +67,10 @@ "@types/ws": "^8.18.1", "@vercel/oidc-aws-credentials-provider": "^3.2.1", "@xenova/transformers": "^2.17.2", + "axe-core": "^4.10.3", "class-transformer": "^0.5.1", "class-validator": "^0.14.2", + "clsx": "^2.1.1", "cron": "^4.3.4", "cron-parser": "^5.5.0", "dedent": "^1.5.3", @@ -68,6 +78,7 @@ "drizzle-kit": "^0.30.6", "drizzle-orm": "^0.41.0", "esbuild": "^0.25.0", + "framer-motion": "^12.0.11", "got": "^14.4.7", "graphql": "^16.10.0", "graphql-request": "^7.1.2", @@ -76,18 +87,24 @@ "jszip": "^3.10.1", "langchain": "^1.2.34", "lodash": "^4.17.21", + "lucide-react": "^0.474.0", "mammoth": "^1.12.0", "multer": "1.4.5-lts.2", "onnxruntime-node": "1.21.0", "openai": "^4.93.0", "pdf-lib": "^1.17.1", "pdfjs-dist": "5.4.296", + "phaser": "^4.2.1", "pinata-web3": "^0.5.4", "playwright": "^1.54.2", + "postcss": "^8.5.17", "postgres": "^3.4.5", "posthog-node": "^4.11.3", "pptxgenjs": "^4.0.1", "puppeteer": "^24.16.2", + "react": "19.0.0", + "react-dom": "19.0.0", + "recharts": "^2.15.3", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", "sharp": "0.33.5", @@ -95,6 +112,9 @@ "socket.io": "^4.8.1", "socket.io-client": "^4.8.1", "stripe": "^17.5.0", + "tailwind-merge": "^3.0.1", + "tailwindcss": "^3.4.17", + "three": "^0.185.1", "type-fest": "^4.39.1", "typia": "^9.1.0", "uuid": "^11.1.0", diff --git a/apps/commons-api/platform-skills/build-commons-ui-plugin/SKILL.md b/apps/commons-api/platform-skills/build-commons-ui-plugin/SKILL.md new file mode 100644 index 00000000..89fae569 --- /dev/null +++ b/apps/commons-api/platform-skills/build-commons-ui-plugin/SKILL.md @@ -0,0 +1,318 @@ +--- +name: build-commons-ui-plugin +description: Build a sandboxed page or floating widget inside Agent Commons when a user asks for a custom dashboard, control surface, visualization, game, or other embedded app. Do not use for changes to the Commons host UI itself. +--- + +# Build a Commons UI plugin + +Create a polished, working React app inside an isolated code project. The result +must feel at home in Commons, work at its real page or widget size, and use only +the host access the user actually needs. Match the Commons visual language by +default; depart from it only when the user explicitly requests another look and +feel. Never edit or reach into the Commons host DOM. + +## Establish the app contract + +Before writing code, identify: + +- the primary job and the few actions that make it useful; +- whether it needs a `page`, a `widget`, or both; +- the exact widget width and height when applicable; +- which Commons data and actions it needs; and +- what success looks like when tested. + +Infer sensible details from the request instead of blocking on minor choices. +For a page and widget pair, build one adaptive interface and branch on +`useCommonsContext().surface` only where the information density must differ. + +## Use the supported UI platform + +Create or replace complete files with `createCodeProject` and +`writeCodeProjectFiles`. Use `app/page.tsx` as the entry and +`app/globals.css` for authored CSS. Global CSS and Tailwind utilities are +compiled into the published app; do not load Tailwind, fonts, scripts, images, +or packages from a browser CDN. + +The builder bundles these libraries: + +- `@agent-commons/ui` for Commons primitives, context, and the host bridge; +- `lucide-react` for icons; +- Radix Dialog, Dropdown Menu, Select, Tabs, and Tooltip for accessible + interaction primitives; +- `recharts` for data visualization; +- `framer-motion` for purposeful motion; +- `clsx` and `tailwind-merge` for class composition; +- `three` and `@react-three/fiber` for a genuinely 3D experience; and +- `phaser` for a genuine game loop, physics, or game scene. + +Use the smallest appropriate stack. Ordinary product interfaces do not need +3D or a game engine. Use local SVG, image, or font files when an asset is +essential. Remote imports, arbitrary npm packages, direct external requests, +and browser CDN dependencies are unavailable; move work that truly needs them +to a persistent computer. + +Start with the source-owned primitives rather than recreating basic controls: + +```tsx +import { + AppShell, + Badge, + Button, + Card, + EmptyState, + MetricCard, + Skeleton, + commons, + useCommonsContext, +} from '@agent-commons/ui'; +``` + +`@agent-commons/ui` also exports `PageHeader`, `CommonsProvider`, and `cn`. +Compose more specialized components locally with Tailwind and Radix; there is +no runtime `shadcn` package to import. + +## Meet the visual and interaction bar + +- By default, match Commons: Space Grotesk typography, warm stone semantic + neutrals, restrained pastel brand accents, compact product density, subtle + borders and elevation, rounded controls, and clear light/dark states. A + user-requested visual direction may override the aesthetic, but never the + accessibility, responsiveness, or host-integration rules. +- Use the semantic Tailwind colors `background`, `foreground`, `card`, + `card-foreground`, `muted`, `muted-foreground`, `border`, `primary`, + `primary-foreground`, `accent`, `destructive`, and `ring`. Avoid a hard-coded + black canvas or an all-default browser-style interface. +- Establish a clear hierarchy, restrained palette, consistent spacing and + radii, legible type scale, and useful density. Prefer a small number of strong + sections over a wall of cards. +- Use Lucide icons consistently. Give icon-only controls accessible names and + tooltips when their meaning is not obvious. +- Support light and dark themes through the semantic tokens. Do not infer the + theme once and forget it; the host context can change while the app is open. +- Design from the container, not only the browser viewport. Use responsive grid + and flex layouts, container-query utilities where useful, `min-w-0`, and + fluid measurements. +- A widget must fit snugly inside its declared width and height: make its root + height `100%`, avoid fixed desktop widths, and produce no outer horizontal or + vertical scrollbar. If content can grow, scroll one clearly bounded inner + region while keeping primary controls visible. +- Render only widget content. Commons owns the outer title bar, movement, + placement, and window controls; do not build a second draggable frame. +- Include intentional loading, empty, error, and populated states for live + data. Never present sample values as real Commons data. +- Preserve keyboard operation, visible focus, semantic headings and controls, + meaningful labels, sufficient contrast, reduced-motion behavior, and useful + error feedback. Do not make hover the only way to discover an action. +- Keep animation subtle and functional. Size Three/R3F or Phaser canvases from + their container, clean up loops/listeners, pause when hidden, and keep input + usable at the widget size. + +## Connect to Commons through the bridge + +The app receives `{ theme, surface, viewport, capabilities }` through +`useCommonsContext()`. The `commons` client exposes correlated host calls; it +never exposes cookies, credentials, or raw host APIs. + +| App call | Manifest grant | +| -------------------------------------------------- | ----------------------- | +| `commons.agents.list(params)` | `agents.read` | +| `commons.tasks.list(params)` | `tasks.read` | +| `commons.tasks.create(params)` / `.update(params)` | `tasks.write` | +| `commons.workflows.list(params)` | `workflows.read` | +| `commons.workflows.execute(params)` | `workflows.execute` | +| `commons.library.list(params)` | `library.read` | +| `commons.tools.list(params)` | `tools.read` | +| `commons.copilot.open({ prompt })` | `copilot.prompt` | +| `commons.navigation.open({ path })` | `navigation` permission | +| `commons.storage.get/set/remove({ key, value })` | `storage` permission | +| `commons.ui.resize({ width, height })` | no additional grant | + +Use the bridge's narrow wire contracts instead of guessing at host APIs: + +- List calls return `{ items, total }`. They accept `query` and `limit` (1–100). + `tasks.list` also accepts `status` and `agentId`; `workflows.list` accepts + `triggerType`; `library.list` accepts `view`, `source`, and `favorite`; and + `tools.list` accepts `category` and `visibility`. +- Sanitized records expose display-safe IDs, names/titles, descriptions, + statuses, and relevant summary fields plus safe Commons paths. They do not + contain credentials, arbitrary metadata, file contents, or signed URLs. +- A task item has `taskId`, `title`, and optional `description`, `status`, + `progress`, `priority`, `scheduledFor`, `nextRunAt`, `isRecurring`, + `cronExpression`, `agentId`, `workflowId`, `createdAt`, `updatedAt`, and + `studioPath` fields. +- `tasks.create` requires `title` and an owned `agentId`. It may receive an + owned `sessionId`; when omitted, the host creates a task session after the + user confirms. Workflow task creation also requires `workflows.execute`, and + tool assignment requires `tools.read`; every referenced resource is checked + against the current user and the manifest scope. +- `tasks.update` requires `taskId` and at least one of `title`, `description`, + or `priority`. It cannot complete, resolve, cancel, or delete a task. For an + unsupported task action, navigate to its `studioPath` or open Copilot; never + pretend the update succeeded. +- `workflows.execute` requires `workflowId` and accepts a small JSON + `inputData` object. Task writes, workflow execution, and Copilot prompts + always require host confirmation. +- `navigation.open` accepts only a safe internal Commons path. `ui.resize` + accepts widget sizes between 280–520 px wide and 240–720 px high. +- Schema-v2 app code runs at an opaque origin. Do not use `localStorage`, + cookies, or IndexedDB directly. For small preferences or game progress, + request the `storage` permission and use the namespaced `commons.storage` + bridge (string keys/values, up to 32 keys and 64 KB per app). + +Request only the grants used by the code. Restrict a grant with `resourceIds` +when the app only needs specific agents, workflows, or other records. Keep +write and execution actions explicit, user-initiated, and accompanied by clear +pending, success, and failure feedback. Use `copilot.open` to continue work +with the user's connected external tools rather than attempting direct network +access. + +Catch bridge errors and render a useful unavailable or retry state. A published +preview can run outside the Commons host during testing, so it must remain +coherent when host data is unavailable. + +## Build with complete project files + +Pass complete source through `createCodeProject.files`, or create the starter +and replace `app/page.tsx` and `app/globals.css` together with +`writeCodeProjectFiles`. A compact `app/page.tsx` starting shape is: + +```tsx +// app/page.tsx +'use client'; + +import { useEffect, useState } from 'react'; +import { ListChecks } from 'lucide-react'; +import { + AppShell, + Card, + EmptyState, + PageHeader, + Skeleton, + commons, + useCommonsContext, +} from '@agent-commons/ui'; + +type QueueTask = { taskId: string; title: string }; +type QueueState = { + status: 'loading' | 'ready' | 'error'; + items: QueueTask[]; +}; + +export default function Page() { + const { surface } = useCommonsContext(); + const [state, setState] = useState({ + status: 'loading', + items: [], + }); + + useEffect(() => { + let current = true; + commons.tasks + .list({ limit: surface === 'widget' ? 5 : 20 }) + .then( + (result) => + current && setState({ status: 'ready', items: result.items ?? [] }), + ) + .catch(() => current && setState({ status: 'error', items: [] })); + return () => { + current = false; + }; + }, [surface]); + + return ( + + + {state.status === 'loading' ? : null} + {state.status === 'error' ? ( + } + title="Tasks are unavailable" + description="Open this app in Commons or try again." + /> + ) : null} + {state.status === 'ready' && state.items.length === 0 ? ( + } + title="Nothing queued" + description="New tasks will appear here." + /> + ) : null} + {state.items.map((item) => ( + + {item.title} + + ))} + + ); +} +``` + +Keep `app/globals.css` focused on app-specific composition; the platform already +provides a reset, semantic tokens, Tailwind, and the `ac-*` primitive styles. + +## Publish, test, inspect, and fix + +Publishing proves only that the project compiled. After every meaningful +change: + +1. Call `publishCodeProject`. +2. Call `testCodeProject` with every intended surface and actions for each + locally testable control. Use the exact widget dimensions that will be + registered: + +```json +{ + "projectId": "", + "surfaces": [ + { "type": "page" }, + { "type": "widget", "width": 380, "height": 480 } + ], + "capabilities": [{ "name": "tasks.read" }], + "actions": [ + { "type": "click", "text": "Open tasks" }, + { "type": "expectText", "text": "Work queue" } + ] +} +``` + +3. Inspect the returned screenshots and every console, page, request, + interaction, embedding, overflow, style, and accessibility result. Testing + covers page desktop/mobile and light/dark modes plus each widget at its exact + size in light/dark mode. +4. Read the project, fix the cause, publish a new deployment, and test again. + Continue until `passed` is `true` and the screenshots are visually coherent. + +`testCodeProject` runs the preview inside the real opaque-origin sandbox and a +synthetic Commons host. It supplies deterministic, display-safe fixture records +for only the capabilities passed to the test and simulates writes without side +effects. Exercise every requested capability through the app's actual controls; +registration rejects grants that the passing verification did not exercise. +This proves the bridge contract and UI behavior, while real owner data and +mutations remain protected until the owner enables and confirms them. Never add +hard-coded sample values to the app or call a failing path complete. + +## Register the verified deployment + +Register with manifest v2 capabilities and exactly the tested surfaces. For +example: + +```json +{ + "codeProjectId": "", + "name": "Work Queue", + "slug": "work-queue", + "description": "A focused view of tasks that need attention.", + "version": "1.0.0", + "surfaces": [ + { "type": "page", "title": "Work Queue" }, + { "type": "widget", "title": "Work Queue", "width": 380, "height": 480 } + ], + "permissions": ["theme.read"], + "capabilities": [{ "name": "tasks.read" }] +} +``` + +Omit unused permissions and capabilities. Registration pins the verified +deployment and creates a draft; the owner must review its access and enable it +in Studio Customize → Apps. Report the created page/widget, its tested sizes +and interactions, requested access, review status, and any honest limitation. diff --git a/apps/commons-api/src/a2a/a2a.service.ts b/apps/commons-api/src/a2a/a2a.service.ts index 8e3ba7f8..ed3f3884 100644 --- a/apps/commons-api/src/a2a/a2a.service.ts +++ b/apps/commons-api/src/a2a/a2a.service.ts @@ -1,4 +1,10 @@ -import { Injectable, Logger, NotFoundException, Inject, forwardRef } from '@nestjs/common'; +import { + Injectable, + Logger, + NotFoundException, + Inject, + forwardRef, +} from '@nestjs/common'; import { eq } from 'drizzle-orm'; import { randomUUID } from 'crypto'; import { filter, firstValueFrom } from 'rxjs'; @@ -42,14 +48,16 @@ export class A2aService { }); if (!agent) throw new NotFoundException(`Agent ${agentId} not found`); - const skills: A2ASkill[] = ((agent as any).a2aSkills ?? []).map((s: any) => ({ - id: s.id, - name: s.name, - description: s.description, - tags: s.tags ?? [], - inputModes: s.inputModes ?? ['text/plain'], - outputModes: s.outputModes ?? ['text/plain'], - })); + const skills: A2ASkill[] = ((agent as any).a2aSkills ?? []).map( + (s: any) => ({ + id: s.id, + name: s.name, + description: s.description, + tags: s.tags ?? [], + inputModes: s.inputModes ?? ['text/plain'], + outputModes: s.outputModes ?? ['text/plain'], + }), + ); return { name: agent.name, @@ -104,7 +112,12 @@ export class A2aService { await this.updateState(taskId, 'working'); try { - const { text, artifacts } = await this.dispatchToAgent(params.agentId, params.message, taskId, params.callerId); + const { text, artifacts } = await this.dispatchToAgent( + params.agentId, + params.message, + taskId, + params.callerId, + ); const outputMessage: A2AMessage = { role: 'agent', @@ -113,39 +126,59 @@ export class A2aService { contextId: params.contextId, }; - await this.db.update(schema.a2aTask) + await this.db + .update(schema.a2aTask) .set({ state: 'completed', outputMessages: [outputMessage] as any, - artifacts: artifacts as any ?? null, + artifacts: (artifacts as any) ?? null, completedAt: new Date(), updatedAt: new Date(), } as any) .where(eq(schema.a2aTask.taskId, taskId)); - this.emit(taskId, { type: 'task', task: await this.buildTaskResponse(taskId) }); + this.emit(taskId, { + type: 'task', + task: await this.buildTaskResponse(taskId), + }); this.emit(taskId, { type: 'close' }); return this.buildTaskFromRow( - taskId, params.agentId, 'completed', params.contextId, - params.message, outputMessage, artifacts, + taskId, + params.agentId, + 'completed', + params.contextId, + params.message, + outputMessage, + artifacts, ); } catch (error: any) { this.logger.error(`A2A task ${taskId} failed: ${error.message}`); - await this.db.update(schema.a2aTask) + await this.db + .update(schema.a2aTask) .set({ state: 'failed', - error: { code: RPC_ERRORS.INTERNAL_ERROR.code, message: error.message } as any, + error: { + code: RPC_ERRORS.INTERNAL_ERROR.code, + message: error.message, + } as any, completedAt: new Date(), updatedAt: new Date(), } as any) .where(eq(schema.a2aTask.taskId, taskId)); - this.emit(taskId, { type: 'task', task: await this.buildTaskResponse(taskId) }); + this.emit(taskId, { + type: 'task', + task: await this.buildTaskResponse(taskId), + }); this.emit(taskId, { type: 'close' }); return this.buildTaskFromRow( - taskId, params.agentId, 'failed', params.contextId, params.message, + taskId, + params.agentId, + 'failed', + params.contextId, + params.message, ); } } @@ -153,7 +186,11 @@ export class A2aService { // ── tasks/sendSubscribe ──────────────────────────────────────────────────── /** Terminal states — no more events will follow. */ - private static readonly TERMINAL_STATES: A2ATaskState[] = ['completed', 'failed', 'canceled']; + private static readonly TERMINAL_STATES: A2ATaskState[] = [ + 'completed', + 'failed', + 'canceled', + ]; /** * Create a task and start streaming updates to the caller. @@ -189,9 +226,25 @@ export class A2aService { const task = await this.buildTaskResponse(taskId); const isFinal = true; if (task.artifacts?.length) { - yield { type: 'TaskArtifactUpdateEvent', data: { taskId, contextId: params.contextId, artifact: task.artifacts[0], final: isFinal } }; + yield { + type: 'TaskArtifactUpdateEvent', + data: { + taskId, + contextId: params.contextId, + artifact: task.artifacts[0], + final: isFinal, + }, + }; } - yield { type: 'TaskStatusUpdateEvent', data: { taskId, contextId: params.contextId, status: task.status, final: isFinal } }; + yield { + type: 'TaskStatusUpdateEvent', + data: { + taskId, + contextId: params.contextId, + status: task.status, + final: isFinal, + }, + }; return; } } @@ -236,27 +289,55 @@ export class A2aService { } if (event.type === 'task') { const task: A2ATask = event.task; - const isFinal = A2aService.TERMINAL_STATES.includes(task.status.state); + const isFinal = A2aService.TERMINAL_STATES.includes( + task.status.state, + ); if (task.artifacts?.length) { - yield { type: 'TaskArtifactUpdateEvent', data: { taskId, contextId: params.contextId, artifact: task.artifacts[0], final: isFinal } }; + yield { + type: 'TaskArtifactUpdateEvent', + data: { + taskId, + contextId: params.contextId, + artifact: task.artifacts[0], + final: isFinal, + }, + }; } - yield { type: 'TaskStatusUpdateEvent', data: { taskId, contextId: params.contextId, status: task.status, final: isFinal } }; + yield { + type: 'TaskStatusUpdateEvent', + data: { + taskId, + contextId: params.contextId, + status: task.status, + final: isFinal, + }, + }; } } if (!done) { // DB-polling fallback: if no in-memory event for STALL_TIMEOUT_MS, poll the DB directly if (Date.now() - lastEventAt > STALL_TIMEOUT_MS) { - const row = await this.db.query.a2aTask.findFirst({ - where: (t: any) => eq(t.taskId, taskId), - } as any).catch(() => null); + const row = await this.db.query.a2aTask + .findFirst({ + where: (t: any) => eq(t.taskId, taskId), + } as any) + .catch(() => null); if (row) { const state = (row as any).state as A2ATaskState; if (A2aService.TERMINAL_STATES.includes(state)) { const task = await this.buildTaskResponse(taskId); const isFinal = true; - yield { type: 'TaskStatusUpdateEvent', data: { taskId, contextId: params.contextId, status: task.status, final: isFinal } }; + yield { + type: 'TaskStatusUpdateEvent', + data: { + taskId, + contextId: params.contextId, + status: task.status, + final: isFinal, + }, + }; this.unsubscribe(taskId, push); return; } @@ -302,18 +383,31 @@ export class A2aService { // ── Push notification config ─────────────────────────────────────────────── - async setPushNotificationConfig(taskId: string, config: PushNotificationConfig): Promise { - await this.db.update(schema.a2aTask) - .set({ pushUrl: config.url, pushToken: config.token ?? null, updatedAt: new Date() } as any) + async setPushNotificationConfig( + taskId: string, + config: PushNotificationConfig, + ): Promise { + await this.db + .update(schema.a2aTask) + .set({ + pushUrl: config.url, + pushToken: config.token ?? null, + updatedAt: new Date(), + } as any) .where(eq(schema.a2aTask.taskId, taskId)); } - async getPushNotificationConfig(taskId: string): Promise { + async getPushNotificationConfig( + taskId: string, + ): Promise { const row = await this.db.query.a2aTask.findFirst({ where: (t: any) => eq(t.taskId, taskId), } as any); if (!row || !(row as any).pushUrl) return null; - return { url: (row as any).pushUrl, token: (row as any).pushToken ?? undefined }; + return { + url: (row as any).pushUrl, + token: (row as any).pushToken ?? undefined, + }; } // ── List tasks ───────────────────────────────────────────────────────────── @@ -329,8 +423,12 @@ export class A2aService { // ── Internal helpers ─────────────────────────────────────────────────────── - private async updateState(taskId: string, state: A2ATaskState): Promise { - await this.db.update(schema.a2aTask) + private async updateState( + taskId: string, + state: A2ATaskState, + ): Promise { + await this.db + .update(schema.a2aTask) .set({ state, updatedAt: new Date() } as any) .where(eq(schema.a2aTask.taskId, taskId)); } @@ -409,6 +507,25 @@ export class A2aService { // Fall back to agentId itself (self-triggered) when no caller is provided. initiator: callerId ?? agentId, stream: false, + provenanceContext: { + metadata: { + protocol: 'a2a', + a2aTaskId: taskId, + callerId, + }, + lineage: { + schemaVersion: 1, + kind: 'delegation', + delegation: { + fromAgentId: callerId, + toAgentId: agentId, + role: 'a2a_responder', + architecture: 'a2a', + handoffPolicy: 'request_response', + contextPolicy: 'message', + }, + }, + }, }) .pipe(filter((event: any) => event.type === 'final')), ); @@ -431,7 +548,9 @@ export class A2aService { text = JSON.stringify(payload); } - this.logger.log(`A2A task ${taskId}: agent completed (${text.length} chars)`); + this.logger.log( + `A2A task ${taskId}: agent completed (${text.length} chars)`, + ); return { text }; } @@ -444,12 +563,17 @@ export class A2aService { private unsubscribe(taskId: string, fn: (event: any) => void): void { this.subscribers.get(taskId)?.delete(fn); - if (this.subscribers.get(taskId)?.size === 0) this.subscribers.delete(taskId); + if (this.subscribers.get(taskId)?.size === 0) + this.subscribers.delete(taskId); } private emit(taskId: string, event: any): void { for (const fn of this.subscribers.get(taskId) ?? []) { - try { fn(event); } catch (e) { /* subscriber error — ignore */ } + try { + fn(event); + } catch (e) { + /* subscriber error — ignore */ + } } } } diff --git a/apps/commons-api/src/agent/agent.controller.ts b/apps/commons-api/src/agent/agent.controller.ts index d1cabd9e..7202bcc6 100644 --- a/apps/commons-api/src/agent/agent.controller.ts +++ b/apps/commons-api/src/agent/agent.controller.ts @@ -40,6 +40,7 @@ import { RuntimeManagementService } from './runtime/runtime-management.service'; import { normalizeRuntimeType } from './runtime/runtime.types'; import { CopilotUiContext } from './copilot-platform-guide'; import { CommonToolService } from '~/tool/tools/common-tool.service'; +import type { ProvenanceRunOptions } from '~/provenance'; interface RunBody { agentId: string; @@ -62,6 +63,8 @@ interface RunBody { }; /** User-selected thinking depth for this turn (none|minimal|low|medium|high|xhigh). */ reasoningEffort?: string; + /** Privacy and anchoring policy for this run. Metadata-only is the default. */ + provenance?: ProvenanceRunOptions; } @Controller({ version: '1', path: 'agents' }) diff --git a/apps/commons-api/src/agent/agent.service.ts b/apps/commons-api/src/agent/agent.service.ts index e06dd15d..a9ff29d7 100644 --- a/apps/commons-api/src/agent/agent.service.ts +++ b/apps/commons-api/src/agent/agent.service.ts @@ -75,6 +75,7 @@ import { CopilotUiContext, } from './copilot-platform-guide'; import { SkillService } from '~/skill/skill.service'; +import { ProvenanceService, ProvenanceRunOptions } from '~/provenance'; import { durableRole, restoreSessionMessages } from '~/session/session-history'; import { filterPlatformToolsForAgent } from './copilot-tool-policy'; @@ -211,6 +212,7 @@ export class AgentService implements OnModuleInit { private filesService: FilesService, private computerService: ComputerService, private skillService: SkillService, + private provenanceService: ProvenanceService, @Inject(forwardRef(() => TaskService)) private tasks: TaskService, @Inject(forwardRef(() => TaskExecutionService)) private taskExecution: TaskExecutionService, @@ -334,7 +336,9 @@ export class AgentService implements OnModuleInit { const httpc: any = (gotMod as any).default || gotMod; // Prefer search endpoint when query provided; otherwise list voices const url = q - ? `https://api.elevenlabs.io/v1/voices/search?query=${encodeURIComponent(q)}` + ? `https://api.elevenlabs.io/v1/voices/search?query=${encodeURIComponent( + q, + )}` : `https://api.elevenlabs.io/v1/voices`; const res = await httpc.get(url, { headers: { 'xi-api-key': apiKey }, @@ -475,7 +479,11 @@ export class AgentService implements OnModuleInit { ### Agent-to-agent interaction - **interactWithAgent** — send a message to another agent and get a response. Pass the returned sessionId to continue the same conversation across calls. - - To coordinate groups of agents, use **Spaces** (see below).${childSessionsInfo ? '' : '\n - You currently have no active agent conversations.'} + - To coordinate groups of agents, use **Spaces** (see below).${ + childSessionsInfo + ? '' + : '\n - You currently have no active agent conversations.' + } ### Spaces (multi-agent collaboration) Spaces are shared channels where multiple agents and humans can communicate. @@ -524,10 +532,13 @@ export class AgentService implements OnModuleInit { For React prototypes, landing pages, dashboards, and other static frontend experiences, use lightweight code projects first. They do not require a computer and publish to durable low-cost public URLs. - **createCodeProject** — create a React project with initial files. **writeCodeProjectFiles** — write complete files directly; never squeeze source code into shell commands. - **readCodeProject** — inspect the current files and latest deployment. **publishCodeProject** — compile and publish the project. - - **testCodeProject** — run desktop/mobile Chromium checks, inspect runtime/console/network failures, and test important interactions. A successful build is not enough: test it, fix every reported error, republish, and re-test before saying it works. + - **registerUiPlugin** — register a verified deployment as a sandboxed Commons page/widget draft. Request only the capabilities the app uses; the owner reviews and enables it. + - **testCodeProject** — run Chromium checks at the intended page/widget surfaces and exact widget size, in responsive light/dark scenarios. Inspect screenshots, runtime/console/network/overflow/accessibility failures, and important interactions. A successful build is not enough: fix, republish, and re-test until it passes before saying it works. - **exportCodeProjectToComputer** — move the project into the persistent computer when the work needs a backend, arbitrary packages, repository operations, ML/GPU compute, or unrestricted tooling. - - Lightweight projects support React, CSS, local modules/assets, lucide-react, framer-motion, recharts, clsx, and tailwind-merge. They do not execute a Next.js server or arbitrary build plugins. - - Prefer one purposeful write containing the complete related files. Iterate until the UI is polished, responsive, interactive, and publicly shareable. + - Lightweight projects compile Tailwind and app/globals.css and bundle React, @agent-commons/ui, Lucide, supported Radix primitives, Recharts, Framer Motion, clsx/tailwind-merge, Three/R3F, and Phaser. Use 3D/game libraries only when the task calls for them. Browser CDNs, remote imports, direct external requests, arbitrary packages, Next.js servers, and arbitrary build plugins are unavailable. + - Unless the user explicitly requests another visual direction, Commons pages/widgets must feel native to Agent Commons: use @agent-commons/ui semantic tokens and primitives, Space Grotesk, Lucide icons, restrained neutral surfaces, compact controls, and the host light/dark theme. Do not invent a generic black dashboard, browser-default typography, neon gradients, or a disconnected visual system. + - Generated code runs in an opaque sandbox. It cannot use cookies, IndexedDB, direct localStorage, or arbitrary network requests. Use the least-privilege Commons host bridge for data, actions, navigation, theme, and permissioned namespaced storage; request only capabilities the app actually exercises. + - Design from the exact container dimensions, fit widgets without outer scrolling, handle loading/empty/error states, and prefer one purposeful write containing the complete related files. Test the important interactions on every requested surface in both themes, then fix, republish, and re-test until the verifier passes. ### Goals Goals track high-level objectives across multiple tasks. @@ -593,7 +604,14 @@ export class AgentService implements OnModuleInit { ]); const childSessionsInfo = childSessions.length > 0 - ? `\n\nEXISTING CHILD SESSIONS:\nYou have the following ongoing conversations with other agents. Use these sessionIds to continue existing conversations instead of starting new ones:\n${childSessions.map((cs) => `- Agent ${cs.childAgentId}: ${cs.title || 'Untitled conversation'} (sessionId=${cs.childSessionId}, started: ${cs.createdAt})`).join('\n')}` + ? `\n\nEXISTING CHILD SESSIONS:\nYou have the following ongoing conversations with other agents. Use these sessionIds to continue existing conversations instead of starting new ones:\n${childSessions + .map( + (cs) => + `- Agent ${cs.childAgentId}: ${ + cs.title || 'Untitled conversation' + } (sessionId=${cs.childSessionId}, started: ${cs.createdAt})`, + ) + .join('\n')}` : ''; const messages: ChatCompletionMessageParam[] = [ @@ -758,6 +776,13 @@ export class AgentService implements OnModuleInit { }; /** User-selected thinking depth for this turn; overrides the adaptive hint. */ reasoningEffort?: string; + /** Per-run provenance policy. Defaults to metadata-only and off-chain. */ + provenance?: ProvenanceRunOptions; + /** Internal/public caller context used to join delegated and A2A runs. */ + provenanceContext?: { + lineage?: import('../provenance/provenance.types').ProvenanceLineageMetadata; + metadata?: Record; + }; }): Observable { return new Observable((subscriber) => { // Keep SSE connection alive through proxies @@ -797,6 +822,15 @@ export class AgentService implements OnModuleInit { detail?: string, payload?: Record, ) => { + this.provenanceService.recordEvent(traceId, { + category: 'system', + eventType: `status.${stage}`, + name: message, + phase: 'commentary', + status, + summary: detail ?? message, + payload, + }); if (!stream) return; subscriber.next({ type: 'status', @@ -969,6 +1003,26 @@ export class AgentService implements OnModuleInit { ); if (requestedEffort) effectiveModel.reasoningEffort = requestedEffort; + this.provenanceService.startRun({ + traceId, + sessionId: currentSessionId, + agentId, + initiator, + workspaceId: props.workspaceId ?? agent.workspaceId ?? undefined, + provider: effectiveModel.provider, + modelId: effectiveModel.modelId, + options: props.provenance, + input: props.messages?.filter((message) => message.role === 'user'), + metadata: { + spaceId, + parentSessionId, + reasoningEffort: effectiveModel.reasoningEffort, + attachmentCount: props.attachments?.length ?? 0, + ...props.provenanceContext?.metadata, + }, + lineage: props.provenanceContext?.lineage, + }); + const billingOwnerId = agent.ownerUserId ?? agent.owner; if ( !billingOwnerId && @@ -1069,7 +1123,10 @@ export class AgentService implements OnModuleInit { computerPreparationBlock = [ '## COMPUTER PREPARATION', 'The user selected Agent Computer for this turn, but the runtime could not be prepared.', - `Reason: ${started?.errorMessage ?? 'Unknown computer provisioning error'}`, + `Reason: ${ + started?.errorMessage ?? + 'Unknown computer provisioning error' + }`, 'Explain the limitation and continue without claiming computer access. Do not call computer tools again this turn unless the user changes the computer settings.', ].join('\n'); } @@ -1163,6 +1220,12 @@ export class AgentService implements OnModuleInit { }[] = []; const executedCalls: any[] = []; const llmRunStartedAt = new Map(); + const llmRunInputs = new Map(); + const toolRunStartedAt = new Map(); + const toolRunInputs = new Map< + string, + { name: string; input: unknown; parentRunId?: string } + >(); const usageContext = { provider: effectiveModel.provider, modelId: effectiveModel.modelId, @@ -1189,7 +1252,10 @@ export class AgentService implements OnModuleInit { maxOutputTokens: effectiveModel.maxTokens, isByok: usageContext.isByok, }); - if (runId) llmRunStartedAt.set(runId, performance.now()); + if (runId) { + llmRunStartedAt.set(runId, performance.now()); + llmRunInputs.set(runId, _prompts); + } emitStatus('model', 'running', 'Thinking'); }, handleLLMNewToken: async (token: string) => { @@ -1202,7 +1268,20 @@ export class AgentService implements OnModuleInit { }); } }, - handleToolStart: async (tool: any, input: string) => { + handleToolStart: async ( + tool: any, + input: string, + runId: string, + parentRunId?: string, + ) => { + if (runId) { + toolRunStartedAt.set(runId, performance.now()); + toolRunInputs.set(runId, { + name: tool.name ?? 'tool', + input, + parentRunId, + }); + } subscriber.next({ type: 'toolStart', phase: 'commentary', @@ -1212,7 +1291,40 @@ export class AgentService implements OnModuleInit { timestamp: new Date().toISOString(), }); }, - handleToolEnd: async (output: any) => { + handleToolEnd: async ( + output: any, + runId: string, + parentRunId?: string, + ) => { + const started = runId ? toolRunStartedAt.get(runId) : undefined; + const detail = runId ? toolRunInputs.get(runId) : undefined; + const durationMs = + started !== undefined + ? Math.round(performance.now() - started) + : undefined; + this.provenanceService.recordEvent(traceId, { + category: 'tool', + eventType: 'tool.execute', + name: detail?.name ?? 'Tool call', + phase: 'commentary', + status: 'completed', + spanId: runId, + parentSpanId: parentRunId ?? detail?.parentRunId, + summary: `${detail?.name ?? 'Tool'} completed`, + payload: detail?.input, + result: output, + content: output, + startedAt: + durationMs !== undefined + ? new Date(Date.now() - durationMs) + : undefined, + endedAt: new Date(), + durationMs, + }); + if (runId) { + toolRunStartedAt.delete(runId); + toolRunInputs.delete(runId); + } subscriber.next({ type: 'toolEnd', phase: 'commentary', @@ -1237,6 +1349,25 @@ export class AgentService implements OnModuleInit { ); if (!usage) { + this.provenanceService.recordEvent(traceId, { + category: 'model', + eventType: 'gen_ai.chat', + name: effectiveModel.modelId, + phase: 'commentary', + status: 'completed', + spanId: runId, + summary: + 'Model step completed; provider did not report token usage', + payload: runId ? llmRunInputs.get(runId) : undefined, + result, + durationMs, + startedAt: + durationMs !== undefined + ? new Date(Date.now() - durationMs) + : undefined, + endedAt: new Date(), + }); + if (runId) llmRunInputs.delete(runId); console.log( JSON.stringify({ level: 'warn', @@ -1266,6 +1397,30 @@ export class AgentService implements OnModuleInit { usageTotals.cachedTokens += usage.cachedTokens; usageTotals.costUsd += costUsd; + this.provenanceService.recordEvent(traceId, { + category: 'model', + eventType: 'gen_ai.chat', + name: effectiveModel.modelId, + phase: 'commentary', + status: 'completed', + spanId: runId, + summary: `${usage.totalTokens} tokens`, + payload: runId ? llmRunInputs.get(runId) : undefined, + result, + durationMs, + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + cachedTokens: usage.cachedTokens, + costUsd, + startedAt: + durationMs !== undefined + ? new Date(Date.now() - durationMs) + : undefined, + endedAt: new Date(), + metadata: { usageSource: usage.source }, + }); + if (runId) llmRunInputs.delete(runId); + await this.usageService.record({ agentId, sessionId: currentSessionId as any, @@ -1700,7 +1855,9 @@ export class AgentService implements OnModuleInit { const timer = setTimeout(() => { cleanup(); resolve( - `Error: CLI tool timed out after ${CLI_TOOL_TIMEOUT_MS / 1_000}s`, + `Error: CLI tool timed out after ${ + CLI_TOOL_TIMEOUT_MS / 1_000 + }s`, ); }, CLI_TOOL_TIMEOUT_MS); @@ -1975,7 +2132,16 @@ export class AgentService implements OnModuleInit { ]); const childSessionsInfo = childSessions.length > 0 - ? `\n\nEXISTING CHILD SESSIONS:\nYou have the following ongoing conversations with other agents. Use these sessionIds to continue existing conversations instead of starting new ones:\n${childSessions.map((cs) => `- Agent ${cs.childAgentId}: ${cs.title || 'Untitled conversation'} (sessionId=${cs.childSessionId}, started: ${cs.createdAt})`).join('\n')}` + ? `\n\nEXISTING CHILD SESSIONS:\nYou have the following ongoing conversations with other agents. Use these sessionIds to continue existing conversations instead of starting new ones:\n${childSessions + .map( + (cs) => + `- Agent ${cs.childAgentId}: ${ + cs.title || 'Untitled conversation' + } (sessionId=${cs.childSessionId}, started: ${ + cs.createdAt + })`, + ) + .join('\n')}` : ''; messages.push({ @@ -2014,7 +2180,9 @@ export class AgentService implements OnModuleInit { role: 'system', content: ` You are currently in the following space: - - Space ${space.spaceId}: ${space.name || 'Untitled space'} (created: ${space.createdAt}) + - Space ${space.spaceId}: ${ + space.name || 'Untitled space' + } (created: ${space.createdAt}) Remember your agent Id is : ${agentId} You are receiving this message because you are subscribed to this space. @@ -2245,7 +2413,11 @@ export class AgentService implements OnModuleInit { messages.push({ type: 'user', role: 'user', - content: `##TASK_INSTRUCTION[${nextTask.taskId}]: ${nextTask.title}\n\n${nextTask.description ?? ''}${taskContextStr}${taskToolsStr}${taskToolInstructionsStr}`, + content: `##TASK_INSTRUCTION[${nextTask.taskId}]: ${ + nextTask.title + }\n\n${ + nextTask.description ?? '' + }${taskContextStr}${taskToolsStr}${taskToolInstructionsStr}`, } as any); } @@ -2587,6 +2759,16 @@ export class AgentService implements OnModuleInit { tools: toolUsage, }); + this.provenanceService.finishRun(traceId, { + status: 'completed', + output: finalText, + durationMs: Math.round(performance.now() - tStart), + inputTokens: usageTotals.inputTokens, + outputTokens: usageTotals.outputTokens, + cachedTokens: usageTotals.cachedTokens, + costUsd: usageTotals.costUsd, + }); + if (!stream) { subscriber.next({ type: 'final', @@ -2646,6 +2828,12 @@ export class AgentService implements OnModuleInit { clearInterval(keepalive); unsubscribeProgress(); const message = err instanceof Error ? err.message : String(err); + this.provenanceService.finishRun(traceId, { + status: 'failed', + output: { error: message }, + durationMs: Math.round(performance.now() - tStart), + error: message, + }); if (stream) { subscriber.next({ type: 'error', @@ -2989,13 +3177,22 @@ export class AgentService implements OnModuleInit { }) .where(eq(schema.agent.agentId, existing.agentId)) .returning(); - return updated ?? existing; + const resolved = updated ?? existing; + await this.skillService.ensurePlatformSkillsForCopilot( + resolved.agentId, + userId, + ); + return resolved; } + await this.skillService.ensurePlatformSkillsForCopilot( + existing.agentId, + userId, + ); return existing; } try { - return await this.createAgent({ + const created = await this.createAgent({ value: { name: 'Commons Copilot', owner: userId, @@ -3016,13 +3213,25 @@ export class AgentService implements OnModuleInit { modelId: 'gpt-5.4-mini', }, }); + await this.skillService.ensurePlatformSkillsForCopilot( + created.agentId, + userId, + ); + return created; } catch (error: any) { // Concurrent first requests may race; the partial unique index makes the // loser harmless, so return the row created by the winner. if (error?.code === '23505') { - return this.db.query.agent.findFirst({ + const created = await this.db.query.agent.findFirst({ where: (t) => and(eq(t.ownerUserId, userId), eq(t.isDefault, true)), }); + if (created) { + await this.skillService.ensurePlatformSkillsForCopilot( + created.agentId, + userId, + ); + } + return created; } throw error; } diff --git a/apps/commons-api/src/app.module.ts b/apps/commons-api/src/app.module.ts index 7c36b2df..749d2d19 100644 --- a/apps/commons-api/src/app.module.ts +++ b/apps/commons-api/src/app.module.ts @@ -30,6 +30,9 @@ import { FilesModule } from './files'; import { ComputerModule } from './computer'; import { AudioModule } from './audio'; import { CodeProjectModule } from './code-project'; +import { CapabilityProviderModule } from './provider'; +import { UiPluginModule } from './ui-plugin'; +import { ProvenanceModule } from './provenance'; @Module({ imports: [ @@ -39,6 +42,7 @@ import { CodeProjectModule } from './code-project'; EncryptionModule, ModelProviderModule, // Global model provider factory PinataModule, + ProvenanceModule, // Feature modules AgentModule, @@ -58,6 +62,8 @@ import { CodeProjectModule } from './code-project'; FilesModule, ComputerModule, CodeProjectModule, + CapabilityProviderModule, + UiPluginModule, AudioModule, MemoryModule, WalletModule, diff --git a/apps/commons-api/src/code-project/code-project.browser-integration.spec.ts b/apps/commons-api/src/code-project/code-project.browser-integration.spec.ts new file mode 100644 index 00000000..1860ae53 --- /dev/null +++ b/apps/commons-api/src/code-project/code-project.browser-integration.spec.ts @@ -0,0 +1,1053 @@ +import { once } from 'node:events'; +import { createServer, type Server, type ServerResponse } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { CodeProjectBuilder } from './code-project.builder'; +import { + PublicCodeProjectController, + PublicUiPluginHostController, +} from './code-project.controller'; +import type { BuiltAsset } from './code-project.types'; +import { CodeProjectVerifier } from './code-project.verifier'; +import { chromium, type Browser } from 'playwright'; + +const describeBrowser = + process.env.RUN_BROWSER_INTEGRATION === '1' ? describe : describe.skip; + +describeBrowser('generated Commons app browser integration', () => { + jest.setTimeout(180_000); + + it('builds and verifies native page and widget surfaces in the opaque Commons host', async () => { + const builder = new CodeProjectBuilder(); + const verifier = new CodeProjectVerifier(); + const build = await builder.build({ + name: 'Commons team pulse', + entryFile: 'src/main.tsx', + files: [ + { + path: 'src/main.tsx', + content: `import React, { useEffect, useState } from 'react'; +import { createRoot } from 'react-dom/client'; +import { Users } from 'lucide-react'; +import { AppShell, Badge, Card, PageHeader, commons, useCommonsContext } from '@agent-commons/ui'; + +function App() { + const { surface } = useCommonsContext(); + const [agents, setAgents] = useState([]); + const [error, setError] = useState(''); + + useEffect(() => { + let mounted = true; + commons.agents.list({ limit: 2 }) + .then((response) => { + if (mounted) setAgents(response.items || []); + }) + .catch((reason) => { + if (mounted) setError(reason instanceof Error ? reason.message : 'Could not load agents'); + }); + return () => { mounted = false; }; + }, []); + + return ( + + Live fixture} + /> + +
+
+ {error ?

{error}

: null} +
+ {agents.length === 0 && !error ?

Loading agents…

: null} + {agents.map((agent) => ( +
+
+

{agent.name}

+

{agent.description}

+
+ {agent.status} +
+ ))} +
+
+
+ ); +} + +createRoot(document.getElementById('root')).render();`, + }, + ], + }); + + const previousFrameAncestors = process.env.PLUGIN_FRAME_ANCESTORS; + process.env.PLUGIN_FRAME_ANCESTORS = 'http://127.0.0.1:41737'; + let preview: + | Awaited> + | undefined; + + try { + preview = await startProductionPreviewServer(build.assets); + const previewResponse = await fetch(preview.url); + expect(previewResponse.status).toBe(200); + expect(previewResponse.headers.get('access-control-allow-origin')).toBe( + '*', + ); + expect(previewResponse.headers.get('x-frame-options')).toBeNull(); + const productionCsp = [ + "default-src 'self'", + "script-src 'self' 'unsafe-inline'", + "style-src 'self' 'unsafe-inline'", + "font-src 'self' data:", + "img-src 'self' data: blob:", + "connect-src 'none'", + "frame-src 'none'", + "frame-ancestors 'self' http://127.0.0.1:41737", + "form-action 'none'", + "base-uri 'none'", + "object-src 'none'", + ].join('; '); + expect(preview.productionPolicies).toContain(productionCsp); + expect(previewResponse.headers.get('content-security-policy')).toBe( + productionCsp.replace( + "connect-src 'none'", + `connect-src ${preview.origin}`, + ), + ); + expect(await previewResponse.text()).toContain( + 'name="agent-commons-runtime" content="2"', + ); + + const result = await verifier.verify( + preview.url, + [], + [{ type: 'page' }, { type: 'widget', width: 380, height: 480 }], + ['agents.read', 'tasks.read'], + ); + + expect({ + passed: result.passed, + consoleErrors: result.consoleErrors, + pageErrors: result.pageErrors, + actionErrors: result.actionErrors, + embeddingErrors: result.embeddingErrors, + qualityErrors: result.qualityErrors, + accessibilityViolations: result.accessibilityViolations, + requestFailures: result.requestFailures, + checks: result.checks.map((check) => ({ + viewport: check.viewport, + passed: check.passed, + bodyText: check.bodyText, + stylesheets: check.stylesheets, + bridgeCalls: check.bridgeCalls, + })), + }).toEqual({ + passed: true, + consoleErrors: [], + pageErrors: [], + actionErrors: [], + embeddingErrors: [], + qualityErrors: [], + accessibilityViolations: [], + requestFailures: [], + checks: expect.arrayContaining([ + expect.objectContaining({ passed: true }), + ]), + }); + expect(result.verifiedSurfaces).toEqual([ + { type: 'page' }, + { type: 'widget', width: 380, height: 480 }, + ]); + expect(result.grantedCapabilities).toEqual(['agents.read', 'tasks.read']); + expect(result.verifiedCapabilities).toEqual(['agents.read']); + expect(result.checks).toHaveLength(5); + expect( + result.checks.map(({ surface, theme, width, height }) => ({ + surface, + theme, + width, + height, + })), + ).toEqual([ + { surface: 'page', theme: 'light', width: 1440, height: 900 }, + { surface: 'page', theme: 'light', width: 390, height: 844 }, + { surface: 'page', theme: 'dark', width: 1440, height: 900 }, + { surface: 'widget', theme: 'light', width: 380, height: 480 }, + { surface: 'widget', theme: 'dark', width: 380, height: 480 }, + ]); + expect(result.checks.every((check) => check.passed)).toBe(true); + expect( + result.checks.every( + (check) => + check.bridgeCalls.length === 1 && + check.bridgeCalls[0]?.method === 'agents.list' && + check.bridgeCalls[0]?.outcome === 'fixture', + ), + ).toBe(true); + expect( + result.checks.every( + (check) => + !check.horizontalOverflow && + !check.verticalOverflow && + check.clippedContainers.length === 0 && + check.stylesheets > 0 && + !/^(?:serif|times(?: new roman)?)$/i.test(check.fontFamily) && + !['', 'transparent', 'rgba(0, 0, 0, 0)'].includes( + check.backgroundColor, + ), + ), + ).toBe(true); + expect( + result.checks.find( + (check) => check.surface === 'widget' && check.theme === 'light', + )?.backgroundColor, + ).not.toBe( + result.checks.find( + (check) => check.surface === 'widget' && check.theme === 'dark', + )?.backgroundColor, + ); + expect(result.consoleErrors).toEqual([]); + expect(result.pageErrors).toEqual([]); + expect(result.actionErrors).toEqual([]); + expect(result.embeddingErrors).toEqual([]); + expect(result.qualityErrors).toEqual([]); + expect(result.accessibilityViolations).toEqual([]); + expect(result.requestFailures).toEqual([]); + expect(result.screenshots).toHaveLength(5); + expect( + result.screenshots.every((screenshot) => screenshot.content.length > 0), + ).toBe(true); + } finally { + if (previousFrameAncestors === undefined) { + delete process.env.PLUGIN_FRAME_ANCESTORS; + } else { + process.env.PLUGIN_FRAME_ANCESTORS = previousFrameAncestors; + } + await preview?.close(); + } + }); + + it('rejects root content clipped by the fixed widget viewport', async () => { + const builder = new CodeProjectBuilder(); + const verifier = new CodeProjectVerifier(); + const build = await builder.build({ + name: 'Overflowing widget fixture', + entryFile: 'src/main.tsx', + files: [ + { + path: 'src/main.tsx', + content: `import React from 'react'; +import { createRoot } from 'react-dom/client'; +import { AppShell, Card } from '@agent-commons/ui'; +function App() { return

Clipped widget

This content cannot fit the requested widget height.

; } +createRoot(document.getElementById('root')).render();`, + }, + ], + }); + + const previousFrameAncestors = process.env.PLUGIN_FRAME_ANCESTORS; + process.env.PLUGIN_FRAME_ANCESTORS = 'http://127.0.0.1:41737'; + let preview: + | Awaited> + | undefined; + + try { + preview = await startProductionPreviewServer(build.assets); + const result = await verifier.verify( + preview.url, + [], + [{ type: 'widget', width: 380, height: 240 }], + ); + + expect(result.passed).toBe(false); + expect(result.checks).toHaveLength(2); + expect( + result.checks.every( + (check) => + check.surface === 'widget' && + !check.verticalOverflow && + check.clippedContainers.some( + (container) => + container.selector === '.ac-app-shell' && container.vertical, + ) && + !check.passed, + ), + ).toBe(true); + expect(result.qualityErrors).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /\.ac-app-shell clips content vertically \(\d+px > \d+px\)/, + ), + ]), + ); + } finally { + if (previousFrameAncestors === undefined) { + delete process.env.PLUGIN_FRAME_ANCESTORS; + } else { + process.env.PLUGIN_FRAME_ANCESTORS = previousFrameAncestors; + } + await preview?.close(); + } + }); + + it('allows a deliberate nested widget scroll region', async () => { + const builder = new CodeProjectBuilder(); + const verifier = new CodeProjectVerifier(); + const build = await builder.build({ + name: 'Scrollable widget fixture', + entryFile: 'src/main.tsx', + files: [ + { + path: 'src/main.tsx', + content: `import React from 'react'; +import { createRoot } from 'react-dom/client'; +import { AppShell, Card } from '@agent-commons/ui'; +function App() { return

Scrollable activity

The user can intentionally scroll this nested feed.

; } +createRoot(document.getElementById('root')).render();`, + }, + ], + }); + + const previousFrameAncestors = process.env.PLUGIN_FRAME_ANCESTORS; + process.env.PLUGIN_FRAME_ANCESTORS = 'http://127.0.0.1:41737'; + let preview: + | Awaited> + | undefined; + + try { + preview = await startProductionPreviewServer(build.assets); + const result = await verifier.verify( + preview.url, + [], + [{ type: 'widget', width: 380, height: 240 }], + ); + + expect({ + passed: result.passed, + qualityErrors: result.qualityErrors, + accessibilityViolations: result.accessibilityViolations, + checks: result.checks.map((check) => ({ + viewport: check.viewport, + passed: check.passed, + verticalOverflow: check.verticalOverflow, + clippedContainers: check.clippedContainers, + })), + }).toEqual({ + passed: true, + qualityErrors: [], + accessibilityViolations: [], + checks: expect.arrayContaining([ + expect.objectContaining({ passed: true }), + ]), + }); + expect( + result.checks.every( + (check) => + check.surface === 'widget' && + !check.verticalOverflow && + check.clippedContainers.length === 0 && + check.passed, + ), + ).toBe(true); + } finally { + if (previousFrameAncestors === undefined) { + delete process.env.PLUGIN_FRAME_ANCESTORS; + } else { + process.env.PLUGIN_FRAME_ANCESTORS = previousFrameAncestors; + } + await preview?.close(); + } + }); + + it('relays only exact parent and opaque-child messages for pinned v1 and v2 manifests', async () => { + const builder = new CodeProjectBuilder(); + const runtimeV2 = await builder.build({ + name: 'Pinned relay app', + entryFile: 'src/main.tsx', + files: [ + { + path: 'src/main.tsx', + content: `import React, { useEffect, useState } from 'react'; +import { createRoot } from 'react-dom/client'; +import { AppShell, Badge, Card, commons, useCommonsContext } from '@agent-commons/ui'; + +function App() { + const context = useCommonsContext(); + const [agents, setAgents] = useState([]); + + useEffect(() => { + commons.agents.list({ limit: 1 }).then((response) => setAgents(response.items || [])); + }, []); + + return ( + + + Manifest v2 +

Pinned relay app

+

{context.pluginId || 'Waiting for Commons'}

+

{agents[0]?.name || 'Loading agent'}

+
+
+ ); +} + +createRoot(document.getElementById('root')).render();`, + }, + ], + }); + const legacyV1 = legacyManifestV1Assets(); + const previews = [ + { + schemaVersion: '2' as const, + slug: 'relay-runtime-v2', + deploymentId: '123e4567-e89b-12d3-a456-426614174002', + assets: runtimeV2.assets, + }, + { + schemaVersion: '1' as const, + slug: 'relay-legacy-v1', + deploymentId: '123e4567-e89b-12d3-a456-426614174001', + assets: legacyV1, + }, + ]; + + const previousFrameAncestors = process.env.PLUGIN_FRAME_ANCESTORS; + let relay: + | Awaited> + | undefined; + let commons: + | Awaited> + | undefined; + let browser: Browser | undefined; + + try { + relay = await startPublicPluginRelayServer(previews); + commons = await startCommonsRelayHarness({ + pluginOrigin: relay.origin, + entries: relay.entries, + }); + process.env.PLUGIN_FRAME_ANCESTORS = commons.origin; + + const hostUrl = publicPluginHostUrl({ + pluginOrigin: relay.origin, + entryUrl: relay.entries['2'], + parentOrigin: commons.origin, + schemaVersion: '2', + }); + const hostResponse = await fetch(hostUrl); + expect(hostResponse.status).toBe(200); + expect(hostResponse.headers.get('access-control-allow-origin')).toBe('*'); + expect(hostResponse.headers.get('x-frame-options')).toBeNull(); + expect(hostResponse.headers.get('content-security-policy')).toBe( + [ + "default-src 'none'", + "script-src 'unsafe-inline'", + "style-src 'unsafe-inline'", + `frame-src ${relay.origin}`, + `frame-ancestors ${commons.origin}`, + "connect-src 'none'", + "img-src 'none'", + "font-src 'none'", + "form-action 'none'", + "base-uri 'none'", + "object-src 'none'", + ].join('; '), + ); + expect(await hostResponse.text()).toContain('sandbox="allow-scripts"'); + + browser = await launchIntegrationBrowser(); + const context = await browser.newContext({ + viewport: { width: 900, height: 700 }, + colorScheme: 'light', + }); + const page = await context.newPage(); + const consoleErrors: string[] = []; + const pageErrors: string[] = []; + const requestFailures: string[] = []; + page.on('console', (message) => { + if (message.type() === 'error') consoleErrors.push(message.text()); + }); + page.on('pageerror', (error) => pageErrors.push(error.message)); + page.on('requestfailed', (request) => + requestFailures.push( + `${request.method()} ${request.url()}: ${request.failure()?.errorText || 'failed'}`, + ), + ); + await page.goto(commons.url, { waitUntil: 'domcontentloaded' }); + + for (const schemaVersion of ['2', '1'] as const) { + await page.evaluate( + (version) => (globalThis as any).__loadManifest(version), + schemaVersion, + ); + const outer = page.frameLocator('#plugin'); + const generated = outer.frameLocator('#app'); + await generated + .locator('[data-testid="plugin-id"]') + .waitFor({ state: 'visible', timeout: 15_000 }); + expect( + await generated.locator('[data-testid="plugin-id"]').innerText(), + ).toBe(`trusted-v${schemaVersion}`); + expect(await outer.locator('#app').getAttribute('sandbox')).toBe( + 'allow-scripts', + ); + expect( + await generated + .locator('body') + .evaluate(() => (globalThis as any).origin), + ).toBe('null'); + + if (schemaVersion === '2') { + await generated + .locator('[data-testid="agent-name"]') + .getByText('Trusted relay agent') + .waitFor({ state: 'visible', timeout: 15_000 }); + } else { + await page.waitForFunction(() => + (globalThis as any).__relay.accepted.some( + (message: any) => + message.data?.type === 'commons:navigate' && + message.data?.path === '/studio/agents', + ), + ); + } + + const accepted = await page.evaluate( + () => (globalThis as any).__relay.accepted, + ); + expect(accepted.length).toBeGreaterThan(0); + expect( + accepted.every( + (message: any) => + message.fromPlugin === true && message.origin === relay?.origin, + ), + ).toBe(true); + expect( + accepted.some( + (message: any) => message.data?.type === 'commons:ready', + ), + ).toBe(true); + if (schemaVersion === '2') { + expect( + accepted.some( + (message: any) => message.data?.method === 'agents.list', + ), + ).toBe(true); + } + + await page.evaluate((version) => { + (globalThis as any).__sendRelaySpoofs(version); + }, schemaVersion); + await page.waitForFunction( + () => (globalThis as any).__relay.attackAcks.length === 2, + ); + await page.waitForTimeout(150); + + expect( + await generated.locator('[data-testid="plugin-id"]').innerText(), + ).toBe(`trusted-v${schemaVersion}`); + const afterSpoof = await page.evaluate( + () => (globalThis as any).__relay, + ); + expect([...afterSpoof.attackAcks].sort()).toEqual( + [ + 'expected-parent-origin-wrong-source', + 'opaque-child-origin-wrong-source', + ].sort(), + ); + expect( + afterSpoof.accepted.some( + (message: any) => + message.data?.marker === 'forged-parent-context' || + message.data?.marker === 'forged-opaque-child', + ), + ).toBe(false); + } + + expect(consoleErrors).toEqual([]); + expect(pageErrors).toEqual([]); + expect(requestFailures).toEqual([]); + await context.close(); + } finally { + try { + await browser?.close(); + } finally { + await Promise.allSettled([commons?.close(), relay?.close()]); + if (previousFrameAncestors === undefined) { + delete process.env.PLUGIN_FRAME_ANCESTORS; + } else { + process.env.PLUGIN_FRAME_ANCESTORS = previousFrameAncestors; + } + } + } + }); +}); + +async function startProductionPreviewServer(assets: BuiltAsset[]) { + const slug = 'native-verifier'; + const deploymentId = '123e4567-e89b-12d3-a456-426614174000'; + const rootPath = `/v1/previews/${slug}/deployments/${deploymentId}/`; + const byPath = new Map(assets.map((asset) => [asset.path, asset])); + const productionPolicies: string[] = []; + const controller = new PublicCodeProjectController({ + publicAsset: async ( + requestedSlug: string, + requestedPath: string | undefined, + requestedDeploymentId: string | undefined, + ) => { + if (requestedSlug !== slug || requestedDeploymentId !== deploymentId) { + throw new Error('Unexpected preview deployment request'); + } + const asset = byPath.get(requestedPath || 'index.html'); + if (!asset) throw new Error(`Unknown built asset: ${requestedPath}`); + return { + bytes: + typeof asset.content === 'string' + ? Buffer.from(asset.content) + : Buffer.from(asset.content), + contentType: asset.contentType, + cacheControl: asset.cacheControl, + }; + }, + } as any); + + const server = createServer(async (request, response) => { + const requestUrl = new URL( + request.url || '/', + `http://${request.headers.host || '127.0.0.1'}`, + ); + if (!requestUrl.pathname.startsWith(rootPath)) { + response.statusCode = 404; + response.end('Not found'); + return; + } + + const path = decodeURIComponent(requestUrl.pathname.slice(rootPath.length)); + const adapter = responseAdapter(response, requestUrl.origin, (policy) => + productionPolicies.push(policy), + ); + const controllerRequest = { + originalUrl: `${requestUrl.pathname}${requestUrl.search}`, + } as any; + try { + if (!path) { + await controller.deploymentIndex( + slug, + deploymentId, + controllerRequest, + adapter as any, + ); + } else { + await controller.deploymentAsset( + slug, + deploymentId, + path, + controllerRequest, + adapter as any, + ); + } + } catch (error: any) { + if (!response.headersSent) response.statusCode = 500; + response.end(error?.message || String(error)); + } + }); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const address = server.address() as AddressInfo; + + return { + url: `http://127.0.0.1:${address.port}${rootPath}`, + origin: `http://127.0.0.1:${address.port}`, + productionPolicies, + close: () => closeServer(server), + }; +} + +function legacyManifestV1Assets(): BuiltAsset[] { + return [ + { + path: 'index.html', + content: ` + + + + + Legacy pinned relay app + + + +
+

Manifest v1 compatibility

+

Waiting for Commons

+
+ + +`, + contentType: 'text/html; charset=utf-8', + cacheControl: 'no-cache, no-store, must-revalidate', + }, + ]; +} + +async function startPublicPluginRelayServer( + previews: Array<{ + schemaVersion: '1' | '2'; + slug: string; + deploymentId: string; + assets: BuiltAsset[]; + }>, +) { + const byDeployment = new Map( + previews.map((preview) => [ + `${preview.slug}:${preview.deploymentId}`, + new Map(preview.assets.map((asset) => [asset.path, asset])), + ]), + ); + const previewController = new PublicCodeProjectController({ + publicAsset: async ( + slug: string, + path: string | undefined, + deploymentId: string | undefined, + ) => { + const assets = byDeployment.get(`${slug}:${deploymentId}`); + const asset = assets?.get(path || 'index.html'); + if (!asset) { + throw new Error(`Unknown pinned preview asset: ${slug}/${path || ''}`); + } + return { + bytes: + typeof asset.content === 'string' + ? Buffer.from(asset.content) + : Buffer.from(asset.content), + contentType: asset.contentType, + cacheControl: asset.cacheControl, + }; + }, + } as any); + const hostController = new PublicUiPluginHostController(); + + const server = createServer(async (request, response) => { + const requestUrl = new URL( + request.url || '/', + `http://${request.headers.host || '127.0.0.1'}`, + ); + try { + if (requestUrl.pathname === '/v1/ui-plugin-host') { + await hostController.host( + { query: Object.fromEntries(requestUrl.searchParams) } as any, + responseAdapter( + response, + requestUrl.origin, + () => undefined, + false, + ) as any, + ); + return; + } + + const match = requestUrl.pathname.match( + /^\/v1\/previews\/([^/]+)\/deployments\/([0-9a-f-]+)\/(.*)$/i, + ); + if (!match) { + response.statusCode = 404; + response.end('Not found'); + return; + } + const [, slug, deploymentId, rawPath] = match; + const adapter = responseAdapter( + response, + requestUrl.origin, + () => undefined, + true, + ); + const controllerRequest = { + originalUrl: `${requestUrl.pathname}${requestUrl.search}`, + } as any; + if (!rawPath) { + await previewController.deploymentIndex( + slug, + deploymentId, + controllerRequest, + adapter as any, + ); + } else { + await previewController.deploymentAsset( + slug, + deploymentId, + decodeURIComponent(rawPath), + controllerRequest, + adapter as any, + ); + } + } catch (error: any) { + if (!response.headersSent) response.statusCode = 500; + response.end(error?.message || String(error)); + } + }); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const address = server.address() as AddressInfo; + const origin = `http://127.0.0.1:${address.port}`; + const entries = Object.fromEntries( + previews.map((preview) => [ + preview.schemaVersion, + `${origin}/v1/previews/${preview.slug}/deployments/${preview.deploymentId}/`, + ]), + ) as Record<'1' | '2', string>; + + return { + origin, + entries, + close: () => closeServer(server), + }; +} + +async function startCommonsRelayHarness(args: { + pluginOrigin: string; + entries: Record<'1' | '2', string>; +}) { + const config = JSON.stringify(args).replace(/`; + const harness = ` + + + + + Commons plugin relay integration host + + + + + + + + +`; + const server = createServer((request, response) => { + const requestUrl = new URL( + request.url || '/', + `http://${request.headers.host || '127.0.0.1'}`, + ); + response.setHeader('Content-Type', 'text/html; charset=utf-8'); + response.setHeader('Cache-Control', 'no-store'); + response.statusCode = 200; + response.end(requestUrl.pathname === '/attacker' ? attacker : harness); + }); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const address = server.address() as AddressInfo; + const origin = `http://127.0.0.1:${address.port}`; + return { + origin, + url: `${origin}/`, + close: () => closeServer(server), + }; +} + +function publicPluginHostUrl(args: { + pluginOrigin: string; + entryUrl: string; + parentOrigin: string; + schemaVersion: '1' | '2'; +}) { + const url = new URL('/v1/ui-plugin-host', args.pluginOrigin); + url.searchParams.set('entry', args.entryUrl); + url.searchParams.set('commonsSurface', 'widget'); + url.searchParams.set('commonsHostOrigin', args.parentOrigin); + url.searchParams.set( + 'commonsTheme', + args.schemaVersion === '2' ? 'dark' : 'light', + ); + return url; +} + +async function launchIntegrationBrowser() { + const executablePath = + process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || + process.env.PUPPETEER_EXECUTABLE_PATH || + process.env.CHROME_PATH; + return chromium.launch({ + headless: true, + executablePath: executablePath || undefined, + args: [ + '--disable-features=LocalNetworkAccessChecks,PrivateNetworkAccessForNavigations', + ], + }); +} + +function responseAdapter( + response: ServerResponse, + localAssetOrigin: string, + onProductionCsp: (policy: string) => void, + allowOpaqueLoopbackAssets = true, +) { + const adapter = { + removeHeader(name: string) { + response.removeHeader(name); + return adapter; + }, + setHeader(name: string, value: string) { + if ( + allowOpaqueLoopbackAssets && + name.toLocaleLowerCase() === 'content-security-policy' + ) { + onProductionCsp(value); + // A sandboxed document has an opaque origin. Chromium consequently + // classifies its temporary loopback stylesheet fetch as connect-src. + // Production stays `connect-src 'none'`; this opt-in local harness + // permits only its ephemeral asset origin and asserts the unmodified + // production policy above before running the browser. + response.setHeader( + name, + value.replace( + "connect-src 'none'", + `connect-src ${localAssetOrigin}`, + ), + ); + } else { + response.setHeader(name, value); + } + return adapter; + }, + type(contentType: string) { + response.setHeader('Content-Type', contentType); + return adapter; + }, + status(code: number) { + response.statusCode = code; + return adapter; + }, + send(body: string | Buffer | Uint8Array) { + response.end(body); + return adapter; + }, + redirect(code: number, location: string) { + response.statusCode = code; + response.setHeader('Location', location); + response.end(); + return adapter; + }, + }; + return adapter; +} + +async function closeServer(server: Server) { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + server.closeIdleConnections?.(); + server.closeAllConnections?.(); + }); +} diff --git a/apps/commons-api/src/code-project/code-project.builder.spec.ts b/apps/commons-api/src/code-project/code-project.builder.spec.ts index 4b69d2fb..d7583800 100644 --- a/apps/commons-api/src/code-project/code-project.builder.spec.ts +++ b/apps/commons-api/src/code-project/code-project.builder.spec.ts @@ -29,7 +29,9 @@ createRoot(document.getElementById('root')!).render();`, }); const html = result.assets.find((asset) => asset.path === 'index.html'); - expect(String(html?.content)).toContain('type="importmap"'); + expect(String(html?.content)).not.toContain('type="importmap"'); + expect(String(html?.content)).not.toContain('esm.sh'); + expect(String(html?.content)).toContain('href="./assets/commons-ui.css"'); expect(String(html?.content)).toContain('Small prototype'); expect(result.assets.some((asset) => asset.path.endsWith('.js'))).toBe( true, @@ -40,6 +42,149 @@ createRoot(document.getElementById('root')!).render();`, expect(result.bytes).toBeGreaterThan(100); }); + it('compiles Tailwind utilities and bundles the Commons UI runtime and curated dependencies', async () => { + const result = await builder.build({ + name: 'Commons dashboard', + entryFile: 'src/main.tsx', + files: [ + { + path: 'src/main.tsx', + content: `import React from 'react'; +import { createRoot } from 'react-dom/client'; +import { Sparkles } from 'lucide-react'; +import { AppShell, Card } from '@agent-commons/ui'; +function App() { return ; } +createRoot(document.getElementById('root')!).render();`, + }, + ], + }); + + const commonsCss = result.assets.find( + (asset) => asset.path === 'assets/commons-ui.css', + ); + const javaScript = result.assets.find((asset) => + asset.path.endsWith('.js'), + ); + const html = result.assets.find((asset) => asset.path === 'index.html'); + + expect(String(commonsCss?.content)).toContain('.bg-primary'); + expect(String(commonsCss?.content)).toContain('.text-primary-foreground'); + expect(String(commonsCss?.content)).toContain('.grid'); + const bundledJavaScript = assetText(javaScript?.content); + expect(bundledJavaScript).toContain('Compiled UI'); + expect(bundledJavaScript).not.toMatch( + /from["'](?:@agent-commons\/ui|lucide-react)["']/, + ); + expect(String(html?.content)).not.toContain('importmap'); + }); + + it('accepts a native Commons UI project without authored CSS or utility classes', async () => { + const result = await builder.build({ + name: 'Native Commons card', + entryFile: 'src/main.tsx', + files: [ + { + path: 'src/main.tsx', + content: `import React from 'react'; +import { createRoot } from 'react-dom/client'; +import { AppShell, Card, PageHeader } from '@agent-commons/ui'; +function App() { return Commons primitives provide the styling.; } +createRoot(document.getElementById('root')!).render();`, + }, + ], + }); + + expect( + assetText( + result.assets.find((asset) => asset.path === 'assets/commons-ui.css') + ?.content, + ), + ).toContain('.ac-card'); + }); + + it('rejects a nonexistent class as the only styling signal', async () => { + await expect( + builder.build({ + name: 'Unstyled project', + entryFile: 'src/main.tsx', + files: [ + { + path: 'src/main.tsx', + content: `import React from 'react'; +import { createRoot } from 'react-dom/client'; +function App() { return
No effective styles
; } +createRoot(document.getElementById('root')!).render();`, + }, + ], + }), + ).rejects.toMatchObject({ response: { code: 'project_styles_required' } }); + }); + + it('accepts compiled Tailwind utilities without a project stylesheet', async () => { + const result = await builder.build({ + name: 'Tailwind-only project', + entryFile: 'src/main.tsx', + files: [ + { + path: 'src/main.tsx', + content: `import React from 'react'; +import { createRoot } from 'react-dom/client'; +function App() { return
Tailwind styles
; } +createRoot(document.getElementById('root')!).render();`, + }, + ], + }); + + const compiledCss = assetText( + result.assets.find((asset) => asset.path === 'assets/commons-ui.css') + ?.content, + ); + expect(compiledCss).toContain('.min-h-screen'); + expect(compiledCss).toContain('.p-4'); + }); + + it('rejects a blank or comment-only project stylesheet', async () => { + await expect( + builder.build({ + name: 'Blank stylesheet project', + entryFile: 'src/main.tsx', + files: [ + { + path: 'src/main.tsx', + content: `import React from 'react'; +import { createRoot } from 'react-dom/client'; +import './styles.css'; +function App() { return
No effective styles
; } +createRoot(document.getElementById('root')!).render();`, + }, + { + path: 'src/styles.css', + content: '/* Styling will be added later. */', + }, + ], + }), + ).rejects.toMatchObject({ response: { code: 'project_styles_required' } }); + }); + + it('rejects an unused Commons UI import as the only styling signal', async () => { + await expect( + builder.build({ + name: 'Unused native import', + entryFile: 'src/main.tsx', + files: [ + { + path: 'src/main.tsx', + content: `import React from 'react'; +import { createRoot } from 'react-dom/client'; +import { Card } from '@agent-commons/ui'; +function App() { return
No effective styles
; } +createRoot(document.getElementById('root')!).render();`, + }, + ], + }), + ).rejects.toMatchObject({ response: { code: 'project_styles_required' } }); + }); + it('rejects packages outside the lightweight allowlist', async () => { await expect( builder.build({ @@ -48,7 +193,7 @@ createRoot(document.getElementById('root')!).render();`, files: [ { path: 'src/main.tsx', - content: `import childProcess from 'node:child_process'; console.log(childProcess);`, + content: `import childProcess from 'node:child_process'; export default function App() { return
{String(childProcess)}
; }`, }, ], }), @@ -60,12 +205,29 @@ createRoot(document.getElementById('root')!).render();`, name: 'Next app', entryFile: 'app/page.tsx', files: [ - { path: 'app/page.tsx', content: `'use client'; import './globals.css'; export default function Page() { return
Next works
; }` }, - { path: 'app/globals.css', content: `body { background: white; color: black; }` }, + { + path: 'app/page.tsx', + content: `'use client'; export default function Page() { return
Next works
; }`, + }, + { + path: 'app/globals.css', + content: `@tailwind base; @tailwind components; @tailwind utilities; .next-shell { @apply p-4 font-semibold; background: rgb(1, 2, 3); color: white; }`, + }, ], }); - expect(result.assets.some((asset) => asset.path.endsWith('.js'))).toBe(true); - expect(result.assets.some((asset) => asset.path.endsWith('.css'))).toBe(true); + expect(result.assets.some((asset) => asset.path.endsWith('.js'))).toBe( + true, + ); + expect(result.assets.some((asset) => asset.path.endsWith('.css'))).toBe( + true, + ); + const compiledCss = result.assets.find( + (asset) => asset.path === 'assets/commons-ui.css', + ); + expect(assetText(compiledCss?.content)).toContain('.next-shell'); + expect(assetText(compiledCss?.content)).toContain('#010203'); + expect(assetText(compiledCss?.content)).toContain('padding: 1rem'); + expect(assetText(compiledCss?.content)).toContain('font-weight: 600'); }); it('returns a bounded build error for missing local files', async () => { @@ -76,10 +238,15 @@ createRoot(document.getElementById('root')!).render();`, files: [ { path: 'src/main.tsx', - content: `import './missing';`, + content: `import './missing'; export default function App() { return
Broken
; }`, }, ], }), ).rejects.toMatchObject({ response: { code: 'project_build_failed' } }); }); }); + +function assetText(content: string | Uint8Array | undefined) { + if (typeof content === 'string') return content; + return content ? Buffer.from(content).toString('utf8') : ''; +} diff --git a/apps/commons-api/src/code-project/code-project.builder.ts b/apps/commons-api/src/code-project/code-project.builder.ts index 50068d57..b5327e64 100644 --- a/apps/commons-api/src/code-project/code-project.builder.ts +++ b/apps/commons-api/src/code-project/code-project.builder.ts @@ -1,22 +1,36 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { build, type Loader, type Message, type Plugin } from 'esbuild'; import { posix } from 'node:path'; +import postcss from 'postcss'; +import tailwindcss from 'tailwindcss'; import type { BuildResult, CodeProjectFileInput } from './code-project.types'; +import { + COMMONS_UI_MODULE, + COMMONS_UI_RUNTIME_SOURCE, + COMMONS_UI_STYLES, +} from './code-project.ui-runtime'; -const ALLOWED_IMPORTS: Record = { - react: 'https://esm.sh/react@19.0.0', - 'react/jsx-runtime': 'https://esm.sh/react@19.0.0/jsx-runtime', - 'react/jsx-dev-runtime': 'https://esm.sh/react@19.0.0/jsx-dev-runtime', - 'react-dom': 'https://esm.sh/react-dom@19.0.0?external=react', - 'react-dom/client': 'https://esm.sh/react-dom@19.0.0/client?external=react', - 'lucide-react': - 'https://esm.sh/lucide-react@0.474.0?external=react,react-dom', - 'framer-motion': - 'https://esm.sh/framer-motion@12.0.11?external=react,react-dom', - recharts: 'https://esm.sh/recharts@2.15.3?external=react,react-dom', - clsx: 'https://esm.sh/clsx@2.1.1', - 'tailwind-merge': 'https://esm.sh/tailwind-merge@3.0.1', -}; +const ALLOWED_IMPORTS = new Set([ + COMMONS_UI_MODULE, + 'react', + 'react/jsx-runtime', + 'react/jsx-dev-runtime', + 'react-dom', + 'react-dom/client', + 'lucide-react', + 'framer-motion', + 'recharts', + 'clsx', + 'tailwind-merge', + '@radix-ui/react-dialog', + '@radix-ui/react-dropdown-menu', + '@radix-ui/react-select', + '@radix-ui/react-tabs', + '@radix-ui/react-tooltip', + '@react-three/fiber', + 'three', + 'phaser', +]); const LOADERS: Record = { '.js': 'jsx', @@ -31,6 +45,9 @@ const LOADERS: Record = { '.jpeg': 'dataurl', '.gif': 'dataurl', '.webp': 'dataurl', + '.woff': 'file', + '.woff2': 'file', + '.ttf': 'file', }; const RESOLVE_EXTENSIONS = Object.keys(LOADERS); @@ -46,16 +63,20 @@ export class CodeProjectBuilder { const resolvedEntry = resolveProjectFile(files, args.entryFile); const nextEntry = '__agent_commons_entry.tsx'; if (resolvedEntry === 'app/page.tsx') { - files.set(nextEntry, `import React from 'react';\nimport { createRoot } from 'react-dom/client';\nimport Page from './app/page';\ncreateRoot(document.getElementById('root')!).render();`); + files.set( + nextEntry, + `import React from 'react';\nimport { createRoot } from 'react-dom/client';\n${files.has('app/globals.css') ? "import './app/globals.css';\n" : ''}import Page from './app/page';\ncreateRoot(document.getElementById('root')!).render();`, + ); } - const entryFile = resolvedEntry === 'app/page.tsx' ? nextEntry : resolvedEntry; + const entryFile = + resolvedEntry === 'app/page.tsx' ? nextEntry : resolvedEntry; if (!entryFile) { throw new BadRequestException(`Entry file not found: ${args.entryFile}`); } try { const result = await build({ - absWorkingDir: '/', + absWorkingDir: process.cwd(), bundle: true, entryPoints: [entryFile], entryNames: 'assets/app', @@ -71,6 +92,7 @@ export class CodeProjectBuilder { sourcemap: false, splitting: false, logLevel: 'silent', + loader: LOADERS, plugins: [virtualProjectPlugin(files)], }); @@ -86,10 +108,19 @@ export class CodeProjectBuilder { const js = outputAssets.find((asset) => asset.path.endsWith('.js')); if (!js) throw new Error('Build did not produce a JavaScript entry'); const css = outputAssets.find((asset) => asset.path.endsWith('.css')); + const projectCss = css ? Buffer.from(css.content).toString('utf8') : ''; + const commonsCss = await compileCommonsStyles(files, projectCss); + assertVisualSource(files, projectCss, commonsCss); + const commonsCssAsset = { + path: 'assets/commons-ui.css', + content: commonsCss, + contentType: 'text/css; charset=utf-8', + cacheControl: 'public, max-age=31536000, immutable', + }; const html = renderHtml({ name: args.name, jsPath: js.path, - cssPath: css?.path, + cssPaths: [commonsCssAsset.path], }); const assets = [ { @@ -98,7 +129,8 @@ export class CodeProjectBuilder { contentType: 'text/html; charset=utf-8', cacheControl: 'no-cache, no-store, must-revalidate', }, - ...outputAssets, + commonsCssAsset, + ...outputAssets.filter((asset) => asset !== css), ]; return { assets, @@ -133,34 +165,50 @@ function virtualProjectPlugin(files: Map): Plugin { if (args.kind === 'entry-point') { return { path: args.path, namespace: 'project' }; } - if (isBareImport(args.path)) { - if ( - !Object.prototype.hasOwnProperty.call(ALLOWED_IMPORTS, args.path) - ) { + return undefined; + }); + buildApi.onResolve( + { filter: /.*/, namespace: 'project' }, + async (args) => { + if (isBareImport(args.path)) { + if (!isAllowedImport(args.path)) { + return { + errors: [ + { + text: `Package "${args.path}" is not available in lightweight prototypes`, + }, + ], + }; + } + if (args.path === COMMONS_UI_MODULE) { + return { path: COMMONS_UI_MODULE, namespace: 'commons-ui' }; + } + return buildApi.resolve(args.path, { + kind: args.kind, + resolveDir: process.cwd(), + }); + } + if (/^(https?:|data:|node:)/i.test(args.path)) { return { - errors: [ - { - text: `Package "${args.path}" is not available in lightweight prototypes`, - }, - ], + errors: [{ text: 'Remote and Node.js imports are not allowed' }], }; } - return { path: args.path, external: true }; - } - if (/^(https?:|data:|node:)/i.test(args.path)) { - return { - errors: [{ text: 'Remote and Node.js imports are not allowed' }], - }; - } - const candidate = posix.normalize( - posix.join(args.resolveDir, args.path), - ); - const resolved = resolveProjectFile(files, candidate); - if (!resolved) { - return { errors: [{ text: `Could not resolve "${args.path}"` }] }; - } - return { path: resolved, namespace: 'project' }; - }); + const candidate = posix.normalize( + posix.join(args.resolveDir, args.path), + ); + const resolved = resolveProjectFile(files, candidate); + if (!resolved) { + return { errors: [{ text: `Could not resolve "${args.path}"` }] }; + } + return { path: resolved, namespace: 'project' }; + }, + ); + + buildApi.onLoad({ filter: /.*/, namespace: 'commons-ui' }, () => ({ + contents: COMMONS_UI_RUNTIME_SOURCE, + loader: 'tsx', + resolveDir: process.cwd(), + })); buildApi.onLoad({ filter: /.*/, namespace: 'project' }, (args) => { const contents = files.get(args.path); @@ -175,7 +223,7 @@ function virtualProjectPlugin(files: Map): Plugin { return { contents, loader, - resolveDir: posix.dirname(args.path), + resolveDir: posix.join('/', posix.dirname(args.path)), }; }); }, @@ -198,6 +246,10 @@ function isBareImport(path: string) { return !path.startsWith('.') && !path.startsWith('/'); } +function isAllowedImport(path: string) { + return ALLOWED_IMPORTS.has(path) || path.startsWith('three/'); +} + function formatMessage(message: Message) { return { message: message.text, @@ -214,21 +266,34 @@ function contentTypeFor(path: string) { if (path.endsWith('.png')) return 'image/png'; if (path.endsWith('.jpg') || path.endsWith('.jpeg')) return 'image/jpeg'; if (path.endsWith('.webp')) return 'image/webp'; + if (path.endsWith('.woff')) return 'font/woff'; + if (path.endsWith('.woff2')) return 'font/woff2'; + if (path.endsWith('.ttf')) return 'font/ttf'; return 'application/octet-stream'; } -function renderHtml(args: { name: string; jsPath: string; cssPath?: string }) { - const importMap = JSON.stringify({ imports: ALLOWED_IMPORTS }); +function renderHtml(args: { + name: string; + jsPath: string; + cssPaths: string[]; +}) { return ` - + + ${escapeHtml(args.name)} - ${args.cssPath ? `` : ''} - + ${args.cssPaths.map((path) => ``).join('\n ')} +
@@ -237,6 +302,206 @@ function renderHtml(args: { name: string; jsPath: string; cssPath?: string }) { `; } +async function compileCommonsStyles( + files: Map, + projectCss: string, +) { + const source = [...files.entries()] + .filter(([path]) => /\.(?:[jt]sx?|html|mdx?)$/i.test(path)) + .map(([, content]) => content) + .concat(COMMONS_UI_RUNTIME_SOURCE) + .join('\n'); + const result = await postcss([ + tailwindcss({ + content: [{ raw: source, extension: 'tsx' }], + darkMode: ['class', '[data-theme="dark"]'], + theme: { + extend: { + colors: { + page: '#fcfcfb', + background: 'hsl(var(--background) / )', + foreground: 'hsl(var(--foreground) / )', + card: 'hsl(var(--card) / )', + 'card-foreground': 'hsl(var(--card-foreground) / )', + popover: 'hsl(var(--popover) / )', + 'popover-foreground': + 'hsl(var(--popover-foreground) / )', + secondary: 'hsl(var(--secondary) / )', + 'secondary-foreground': + 'hsl(var(--secondary-foreground) / )', + muted: 'hsl(var(--muted) / )', + 'muted-foreground': 'hsl(var(--muted-foreground) / )', + border: 'hsl(var(--border) / )', + input: 'hsl(var(--input) / )', + primary: 'hsl(var(--primary) / )', + 'primary-foreground': + 'hsl(var(--primary-foreground) / )', + accent: 'hsl(var(--accent) / )', + 'accent-foreground': + 'hsl(var(--accent-foreground) / )', + destructive: 'hsl(var(--destructive) / )', + 'destructive-foreground': + 'hsl(var(--destructive-foreground) / )', + ring: 'hsl(var(--ring) / )', + 'brand-yellow': 'var(--brand-yellow)', + 'brand-pink': 'var(--brand-pink)', + 'brand-mint': 'var(--brand-mint)', + 'brand-cyan': 'var(--brand-cyan)', + 'brand-blue': 'var(--brand-blue)', + 'brand-lilac': 'var(--brand-lilac)', + 'chart-1': 'hsl(var(--chart-1) / )', + 'chart-2': 'hsl(var(--chart-2) / )', + 'chart-3': 'hsl(var(--chart-3) / )', + 'chart-4': 'hsl(var(--chart-4) / )', + 'chart-5': 'hsl(var(--chart-5) / )', + }, + borderRadius: { + lg: 'var(--radius)', + md: 'calc(var(--radius) - 2px)', + sm: 'calc(var(--radius) - 4px)', + }, + boxShadow: { + composer: + '0 12px 32px -12px rgba(28, 25, 23, .14), 0 4px 12px -4px rgba(28, 25, 23, .08), 0 1px 3px rgba(28, 25, 23, .05)', + card: '0 2px 8px -2px rgba(28, 25, 23, .06), 0 1px 2px rgba(28, 25, 23, .04)', + floating: + '0 8px 24px -8px rgba(28, 25, 23, .12), 0 2px 6px -2px rgba(28, 25, 23, .05)', + }, + fontFamily: { + sans: [ + 'Space Grotesk Variable', + 'Space Grotesk', + 'Helvetica', + 'Arial', + 'sans-serif', + ], + mono: ['ui-monospace', 'SFMono-Regular', 'Menlo', 'monospace'], + }, + }, + }, + plugins: [], + }), + ]).process( + `${COMMONS_UI_STYLES}\n${projectCss.replace(/@tailwind\s+(?:base|components|utilities)\s*;/gi, '')}`, + { from: undefined }, + ); + return result.css; +} + +const COMMONS_UI_VISUAL_PRIMITIVES = new Set([ + 'AppShell', + 'PageHeader', + 'Card', + 'Button', + 'Badge', + 'MetricCard', + 'EmptyState', + 'Skeleton', + 'ScrollArea', +]); + +function assertVisualSource( + files: Map, + projectCss: string, + compiledCss: string, +) { + const source = [...files.values()].join('\n'); + const hasAuthoredCss = hasEffectiveCssRule(projectCss); + const rendersCommonsUi = rendersCommonsUiPrimitive(source); + const hasEffectiveClass = extractStaticClassTokens(source).some((token) => + stylesheetContainsClass(compiledCss, token), + ); + if (!hasAuthoredCss && !rendersCommonsUi && !hasEffectiveClass) { + throw new BadRequestException({ + code: 'project_styles_required', + message: + 'UI projects need effective styling. Add compiled Tailwind className values, render @agent-commons/ui primitives, or import a stylesheet with authored rules.', + }); + } +} + +function hasEffectiveCssRule(projectCss: string) { + if (!projectCss.trim()) return false; + try { + let declarations = 0; + postcss.parse(projectCss).walkDecls(() => { + declarations += 1; + }); + return declarations > 0; + } catch { + return false; + } +} + +function rendersCommonsUiPrimitive(source: string) { + const namedImports = + /import\s*\{([\s\S]*?)\}\s*from\s*['"]@agent-commons\/ui['"]/g; + for (const match of source.matchAll(namedImports)) { + for (const specifier of (match[1] || '').split(',')) { + const parts = specifier + .trim() + .replace(/^type\s+/, '') + .split(/\s+as\s+/); + const imported = parts[0]?.trim(); + const local = parts.at(-1)?.trim(); + if ( + imported && + local && + COMMONS_UI_VISUAL_PRIMITIVES.has(imported) && + new RegExp(`<\\s*${escapeRegExp(local)}(?:[\\s/>])`).test(source) + ) { + return true; + } + } + } + + const namespaceImports = + /import\s*\*\s*as\s*([A-Za-z_$][\w$]*)\s*from\s*['"]@agent-commons\/ui['"]/g; + for (const match of source.matchAll(namespaceImports)) { + const namespace = match[1]; + if (!namespace) continue; + for (const primitive of COMMONS_UI_VISUAL_PRIMITIVES) { + if ( + new RegExp( + `<\\s*${escapeRegExp(namespace)}\\.${primitive}(?:[\\s/>])`, + ).test(source) + ) { + return true; + } + } + } + return false; +} + +function extractStaticClassTokens(source: string) { + const tokens = new Set(); + const attributes = /\bclass(?:Name)?\s*=\s*(?:\{\s*)?(['"`])([\s\S]*?)\1/g; + for (const match of source.matchAll(attributes)) { + for (const token of (match[2] || '').split(/\s+/)) { + if (token && !token.includes('${')) tokens.add(token); + } + } + return [...tokens]; +} + +function stylesheetContainsClass(stylesheet: string, token: string) { + const escaped = token.replace(/[^a-zA-Z0-9_-]/g, (character) => + character === ',' ? '\\2c ' : `\\${character}`, + ); + const needle = `.${escaped}`; + let cursor = stylesheet.indexOf(needle); + while (cursor !== -1) { + const next = stylesheet[cursor + needle.length]; + if (!next || /[\s,{.:#>+~\[]/.test(next)) return true; + cursor = stylesheet.indexOf(needle, cursor + needle.length); + } + return false; +} + +function escapeRegExp(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + function escapeHtml(value: string) { return value .replaceAll('&', '&') diff --git a/apps/commons-api/src/code-project/code-project.controller.spec.ts b/apps/commons-api/src/code-project/code-project.controller.spec.ts new file mode 100644 index 00000000..9bcb3c29 --- /dev/null +++ b/apps/commons-api/src/code-project/code-project.controller.spec.ts @@ -0,0 +1,209 @@ +import { + PublicCodeProjectController, + PublicUiPluginHostController, +} from './code-project.controller'; + +describe('public code project security headers', () => { + const originalPluginFrameAncestors = process.env.PLUGIN_FRAME_ANCESTORS; + const publicAsset = jest.fn(); + const previewController = new PublicCodeProjectController({ + publicAsset, + } as any); + + beforeEach(() => { + jest.clearAllMocks(); + process.env.PLUGIN_FRAME_ANCESTORS = 'https://commons.example'; + }); + + afterAll(() => { + if (originalPluginFrameAncestors === undefined) { + delete process.env.PLUGIN_FRAME_ANCESTORS; + } else { + process.env.PLUGIN_FRAME_ANCESTORS = originalPluginFrameAncestors; + } + }); + + it('uses the strict CSP for runtime-v2 HTML previews', async () => { + publicAsset.mockResolvedValue({ + bytes: Buffer.from( + '', + ), + contentType: 'text/html; charset=utf-8', + cacheControl: 'public, max-age=60', + }); + const response = responseMock(); + + await previewController.asset( + 'weather', + 'index.html', + {} as any, + response.value as any, + ); + + expect(response.headers.get('Content-Security-Policy')).toBe( + [ + "default-src 'self'", + "script-src 'self' 'unsafe-inline'", + "style-src 'self' 'unsafe-inline'", + "font-src 'self' data:", + "img-src 'self' data: blob:", + "connect-src 'none'", + "frame-src 'none'", + "frame-ancestors 'self' https://commons.example", + "form-action 'none'", + "base-uri 'none'", + "object-src 'none'", + ].join('; '), + ); + }); + + it('keeps the compatibility CSP for legacy HTML previews', async () => { + publicAsset.mockResolvedValue({ + bytes: Buffer.from('Legacy preview'), + contentType: 'text/html; charset=utf-8', + cacheControl: 'public, max-age=60', + }); + const response = responseMock(); + + await previewController.asset( + 'legacy', + 'index.html', + {} as any, + response.value as any, + ); + + expect(response.headers.get('Content-Security-Policy')).toBe( + [ + "default-src 'self'", + "script-src 'self' 'unsafe-inline' https://esm.sh", + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", + "font-src 'self' data: https://fonts.gstatic.com", + "img-src 'self' data: blob:", + "connect-src https://esm.sh", + "frame-src 'none'", + "frame-ancestors 'self' https://commons.example", + "form-action 'none'", + "base-uri 'none'", + "object-src 'none'", + ].join('; '), + ); + }); + + describe('plugin outer host', () => { + const hostController = new PublicUiPluginHostController(); + const immutableEntry = + 'https://preview.example/v1/previews/weather/deployments/' + + '123e4567-e89b-12d3-a456-426614174000/'; + + it('accepts an immutable preview URL and renders a nested opaque-origin sandbox', () => { + const response = responseMock(); + + hostController.host( + { + query: { + entry: `${immutableEntry}?untrusted=1#fragment`, + commonsHostOrigin: 'https://commons.example', + commonsSurface: 'widget', + commonsTheme: 'dark', + }, + } as any, + response.value as any, + ); + + expect(response.value.status).toHaveBeenCalledWith(200); + expect(response.headers.get('Content-Security-Policy')).toContain( + 'frame-src https://preview.example', + ); + expect(response.headers.get('Content-Security-Policy')).toContain( + 'frame-ancestors https://commons.example', + ); + + const html = response.value.send.mock.calls[0][0] as string; + const sandbox = html.match(/]+sandbox="([^"]+)"/)?.[1]; + expect(sandbox).toBe('allow-scripts'); + expect(sandbox).not.toContain('allow-same-origin'); + expect(html).toContain('commonsSurface=widget'); + expect(html).toContain('commonsTheme=dark'); + expect(html).toContain('commonsHostOrigin=https%3A%2F%2Fpreview.example'); + expect(html).not.toContain('untrusted=1'); + expect(html).not.toContain('#fragment'); + }); + + it.each([ + ['a mutable preview URL', 'https://preview.example/v1/previews/weather/'], + ['a deployment asset URL', `${immutableEntry}index.html`], + [ + 'a credentialed URL', + immutableEntry.replace('https://', 'https://user:secret@'), + ], + ['a non-HTTP URL', 'javascript:alert(1)'], + ])('rejects %s', (_label, entry) => { + const response = responseMock(); + + hostController.host( + { + query: { + entry, + commonsHostOrigin: 'https://commons.example', + }, + } as any, + response.value as any, + ); + + expect(response.value.status).toHaveBeenCalledWith(400); + expect(response.value.send).toHaveBeenCalledWith( + 'Invalid plugin host request', + ); + }); + + it('requires an exact configured parent origin', () => { + const accepted = responseMock(); + const rejected = responseMock(); + + hostController.host( + { + query: { + entry: immutableEntry, + commonsHostOrigin: 'https://commons.example', + }, + } as any, + accepted.value as any, + ); + hostController.host( + { + query: { + entry: immutableEntry, + commonsHostOrigin: 'https://commons.example.evil.test', + }, + } as any, + rejected.value as any, + ); + + expect(accepted.value.status).toHaveBeenCalledWith(200); + expect(rejected.value.status).toHaveBeenCalledWith(400); + }); + }); +}); + +function responseMock() { + const headers = new Map(); + const value = { + removeHeader: jest.fn(), + setHeader: jest.fn(), + status: jest.fn(), + type: jest.fn(), + send: jest.fn(), + redirect: jest.fn(), + }; + value.setHeader.mockImplementation( + (name: string, header: string | number | readonly string[]) => { + headers.set(name, header); + return value; + }, + ); + value.status.mockReturnValue(value); + value.type.mockReturnValue(value); + value.send.mockReturnValue(value); + value.redirect.mockReturnValue(value); + return { headers, value }; +} diff --git a/apps/commons-api/src/code-project/code-project.controller.ts b/apps/commons-api/src/code-project/code-project.controller.ts index 4d6d7e9c..95b5a762 100644 --- a/apps/commons-api/src/code-project/code-project.controller.ts +++ b/apps/commons-api/src/code-project/code-project.controller.ts @@ -14,6 +14,8 @@ import { OwnerGuard, OwnerOnly, Public, RateLimit } from '~/modules/auth'; import { CodeProjectService } from './code-project.service'; import type { BrowserCheckAction, + BrowserCheckCapability, + BrowserCheckSurface, CodeProjectFileInput, } from './code-project.types'; @@ -86,13 +88,20 @@ export class CodeProjectController { async verify( @Param('agentId') agentId: string, @Param('projectId') projectId: string, - @Body() body: { actions?: BrowserCheckAction[] } = {}, + @Body() + body: { + actions?: BrowserCheckAction[]; + surfaces?: BrowserCheckSurface[]; + capabilities?: BrowserCheckCapability[]; + } = {}, ) { return { data: await this.projects.verify({ agentId, projectId, actions: body.actions, + surfaces: body.surfaces, + capabilities: body.capabilities, }), }; } @@ -120,7 +129,9 @@ export class CodeProjectController { @Param('projectId') projectId: string, @Body() body: { repositoryName?: string; private?: boolean } = {}, ) { - return { data: await this.projects.exportToGitHub({ agentId, projectId, ...body }) }; + return { + data: await this.projects.exportToGitHub({ agentId, projectId, ...body }), + }; } } @@ -144,6 +155,39 @@ export class PublicCodeProjectController { return this.serve(slug, undefined, req, res); } + @Get(':slug/deployments/:deploymentId') + async deploymentIndex( + @Param('slug') slug: string, + @Param('deploymentId') deploymentId: string, + @Req() req: Request, + @Res() res: Response, + ) { + if (!req.originalUrl.split('?')[0].endsWith('/')) { + const query = req.originalUrl.includes('?') + ? req.originalUrl.slice(req.originalUrl.indexOf('?')) + : ''; + return res.redirect(308, `${req.originalUrl.split('?')[0]}/${query}`); + } + return this.serve(slug, undefined, req, res, deploymentId); + } + + @Get(':slug/deployments/:deploymentId/*path') + async deploymentAsset( + @Param('slug') slug: string, + @Param('deploymentId') deploymentId: string, + @Param('path') path: string | string[], + @Req() req: Request, + @Res() res: Response, + ) { + return this.serve( + slug, + Array.isArray(path) ? path.join('/') : path, + req, + res, + deploymentId, + ); + } + @Get(':slug/*path') async asset( @Param('slug') slug: string, @@ -164,29 +208,232 @@ export class PublicCodeProjectController { path: string | undefined, _req: Request, res: Response, + deploymentId?: string, ) { - const asset = await this.projects.publicAsset(slug, path); + const asset = await this.projects.publicAsset(slug, path, deploymentId); res.removeHeader('X-Frame-Options'); res.removeHeader('Cross-Origin-Opener-Policy'); + res.removeHeader('Access-Control-Allow-Credentials'); + res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'); res.setHeader('Content-Type', asset.contentType); res.setHeader('Cache-Control', asset.cacheControl); res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin'); + res.setHeader( + 'Content-Security-Policy', + previewContentSecurityPolicy(asset), + ); + res.status(200).send(asset.bytes); + } +} + +@Public() +@Controller({ version: '1', path: 'ui-plugin-host' }) +export class PublicUiPluginHostController { + @Get() + host(@Req() req: Request, @Res() res: Response) { + const entry = singleQueryValue(req.query.entry); + const parentOrigin = singleQueryValue(req.query.commonsHostOrigin); + const surface = singleQueryValue(req.query.commonsSurface) === 'widget' + ? 'widget' + : 'page'; + const theme = singleQueryValue(req.query.commonsTheme) === 'dark' + ? 'dark' + : 'light'; + const entryUrl = safeImmutablePreviewUrl(entry); + if (!entryUrl || !isAllowedPluginParent(parentOrigin)) { + return res.status(400).type('text/plain').send('Invalid plugin host request'); + } + const allowedParentOrigin = parentOrigin!; + entryUrl.searchParams.set('commonsSurface', surface); + entryUrl.searchParams.set('commonsTheme', theme); + entryUrl.searchParams.set('commonsHostOrigin', entryUrl.origin); + + res.removeHeader('X-Frame-Options'); + res.removeHeader('Cross-Origin-Opener-Policy'); + res.removeHeader('Access-Control-Allow-Credentials'); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'); + res.setHeader('Cache-Control', 'no-store'); + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('Referrer-Policy', 'no-referrer'); res.setHeader( 'Content-Security-Policy', [ - "default-src 'self'", - "script-src 'self' 'unsafe-inline' https://esm.sh", - "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", - "font-src 'self' data: https://fonts.gstatic.com", - "img-src 'self' data: blob: https:", - "connect-src 'self' https://esm.sh", - 'frame-ancestors *', + "default-src 'none'", + "script-src 'unsafe-inline'", + "style-src 'unsafe-inline'", + `frame-src ${entryUrl.origin}`, + `frame-ancestors ${pluginFrameAncestors(false)}`, + "connect-src 'none'", + "img-src 'none'", + "font-src 'none'", + "form-action 'none'", "base-uri 'none'", "object-src 'none'", ].join('; '), ); - res.status(200).send(asset.bytes); + return res + .status(200) + .type('text/html; charset=utf-8') + .send(renderPluginHost(entryUrl.toString(), allowedParentOrigin, surface)); + } +} + +function previewContentSecurityPolicy(asset: { + bytes: Buffer; + contentType: string; +}) { + const runtimeV2 = + asset.contentType.startsWith('text/html') && + asset.bytes + .subarray(0, Math.min(asset.bytes.length, 16_384)) + .toString('utf8') + .includes('name="agent-commons-runtime" content="2"'); + const common = [ + `frame-ancestors ${pluginFrameAncestors(true)}`, + "form-action 'none'", + "base-uri 'none'", + "object-src 'none'", + ]; + if (!runtimeV2 && asset.contentType.startsWith('text/html')) { + return [ + "default-src 'self'", + "script-src 'self' 'unsafe-inline' https://esm.sh", + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", + "font-src 'self' data: https://fonts.gstatic.com", + "img-src 'self' data: blob:", + "connect-src https://esm.sh", + "frame-src 'none'", + ...common, + ].join('; '); } + return [ + "default-src 'self'", + "script-src 'self' 'unsafe-inline'", + "style-src 'self' 'unsafe-inline'", + "font-src 'self' data:", + "img-src 'self' data: blob:", + "connect-src 'none'", + "frame-src 'none'", + ...common, + ].join('; '); +} + +function pluginFrameAncestors(includeSelf: boolean) { + const configured = pluginParentOrigins(); + const sources = [ + ...(includeSelf ? ["'self'"] : []), + ...configured, + ]; + return sources.length + ? [...new Set(sources)].join(' ') + : "'none'"; +} + +function pluginParentOrigins() { + const raw = + process.env.PLUGIN_FRAME_ANCESTORS || + [process.env.APP_ORIGIN, process.env.CORS_ORIGIN] + .filter(Boolean) + .join(','); + const configured = raw + .split(/[\s,]+/) + .map((value) => value.trim()) + .filter(Boolean) + .flatMap((value) => { + try { + const url = new URL(value); + return ['http:', 'https:'].includes(url.protocol) ? [url.origin] : []; + } catch { + return []; + } + }); + if (configured.length) return [...new Set(configured)]; + return ['http://localhost:*', 'http://127.0.0.1:*']; +} + +function isAllowedPluginParent(value: string | undefined) { + if (!value) return false; + let origin: string; + try { + origin = new URL(value).origin; + } catch { + return false; + } + if (/^http:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?$/.test(origin)) { + return true; + } + return pluginParentOrigins().includes(origin); +} + +function safeImmutablePreviewUrl(value: string | undefined) { + if (!value) return null; + try { + const url = new URL(value); + if ( + !['http:', 'https:'].includes(url.protocol) || + url.username || + url.password || + !/\/v1\/previews\/[^/]+\/deployments\/[0-9a-f-]+\/?$/i.test( + url.pathname, + ) + ) { + return null; + } + url.search = ''; + url.hash = ''; + return url; + } catch { + return null; + } +} + +function singleQueryValue(value: unknown) { + return typeof value === 'string' ? value : undefined; +} + +function renderPluginHost( + entryUrl: string, + parentOrigin: string, + surface: 'page' | 'widget', +) { + const config = JSON.stringify({ entryUrl, parentOrigin }).replace( + / + + + + + Agent Commons app sandbox + + + + + + +`; } diff --git a/apps/commons-api/src/code-project/code-project.module.ts b/apps/commons-api/src/code-project/code-project.module.ts index 058ae493..662ed6ed 100644 --- a/apps/commons-api/src/code-project/code-project.module.ts +++ b/apps/commons-api/src/code-project/code-project.module.ts @@ -5,6 +5,7 @@ import { CodeProjectBuilder } from './code-project.builder'; import { CodeProjectController, PublicCodeProjectController, + PublicUiPluginHostController, } from './code-project.controller'; import { CodeProjectService } from './code-project.service'; import { CodeProjectStorage } from './code-project.storage'; @@ -12,7 +13,11 @@ import { CodeProjectVerifier } from './code-project.verifier'; @Module({ imports: [ComputerModule, OAuthModule], - controllers: [CodeProjectController, PublicCodeProjectController], + controllers: [ + CodeProjectController, + PublicCodeProjectController, + PublicUiPluginHostController, + ], providers: [ CodeProjectService, CodeProjectBuilder, diff --git a/apps/commons-api/src/code-project/code-project.service.spec.ts b/apps/commons-api/src/code-project/code-project.service.spec.ts new file mode 100644 index 00000000..40d5a03b --- /dev/null +++ b/apps/commons-api/src/code-project/code-project.service.spec.ts @@ -0,0 +1,66 @@ +import { CodeProjectService } from './code-project.service'; + +describe('CodeProjectService verification persistence', () => { + it('stores a failed verification and disables pinned active plugins atomically', async () => { + const deployment = { + deploymentId: 'deployment-1', + projectId: 'project-1', + } as any; + const verification = { + schemaVersion: 2, + passed: false, + verifiedSurfaces: [{ type: 'page' }], + verifiedCapabilities: [], + }; + const setValues: any[] = []; + const deploymentRowLock = jest.fn().mockResolvedValue([deployment]); + const deploymentLimit = jest + .fn() + .mockReturnValue({ for: deploymentRowLock }); + const deploymentWhere = jest + .fn() + .mockReturnValue({ limit: deploymentLimit }); + const deploymentFrom = jest + .fn() + .mockReturnValue({ where: deploymentWhere }); + const pluginRowLock = jest.fn().mockResolvedValue([ + { + pluginId: 'plugin-1', + manifest: { schemaVersion: '2', surfaces: [{ type: 'page' }] }, + }, + ]); + const pluginWhere = jest.fn().mockReturnValue({ for: pluginRowLock }); + const pluginFrom = jest.fn().mockReturnValue({ where: pluginWhere }); + const select = jest + .fn() + .mockReturnValueOnce({ from: deploymentFrom }) + .mockReturnValueOnce({ from: pluginFrom }); + const update = jest.fn().mockImplementation(() => ({ + set: jest.fn((value) => { + setValues.push(value); + return { where: jest.fn().mockResolvedValue([]) }; + }), + })); + const tx = { select, update }; + const transaction = jest.fn((callback) => callback(tx)); + const service = new CodeProjectService( + { transaction } as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + ); + + await (service as any).persistVerification(deployment, verification); + + expect(transaction).toHaveBeenCalledTimes(1); + expect(deploymentRowLock).toHaveBeenCalledWith('update'); + expect(pluginRowLock).toHaveBeenCalledWith('update'); + expect(setValues).toEqual([ + { verification }, + expect.objectContaining({ status: 'disabled' }), + ]); + expect(select).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/commons-api/src/code-project/code-project.service.ts b/apps/commons-api/src/code-project/code-project.service.ts index 6071099f..6e8ca8f1 100644 --- a/apps/commons-api/src/code-project/code-project.service.ts +++ b/apps/commons-api/src/code-project/code-project.service.ts @@ -4,7 +4,7 @@ import { NotFoundException, } from '@nestjs/common'; import { createHash } from 'node:crypto'; -import { and, desc, eq, notInArray, sql } from 'drizzle-orm'; +import { and, desc, eq, inArray, notInArray, sql } from 'drizzle-orm'; import { v4 as uuidv4 } from 'uuid'; import * as schema from '#/models/schema'; import { agentRunProgress } from '~/agent/run-progress'; @@ -14,10 +14,13 @@ import { CodeProjectBuilder } from './code-project.builder'; import { CodeProjectStorage } from './code-project.storage'; import type { BrowserCheckAction, + BrowserCheckCapability, + BrowserCheckSurface, CodeProjectFileInput, } from './code-project.types'; import { CodeProjectVerifier } from './code-project.verifier'; import { OAuthTokenInjectionService } from '~/oauth/oauth-token-injection.service'; +import { verificationCoversManifest } from '~/ui-plugin/ui-plugin.policy'; const MAX_FILES = 80; const MAX_FILE_BYTES = 250_000; @@ -49,21 +52,25 @@ export class CodeProjectService { const projectId = uuidv4(); const slug = `${slugify(name)}-${projectId.slice(0, 8)}`; const ownerUserId = agent.ownerUserId ?? agent.owner; - if (!ownerUserId) throw new BadRequestException('A verified project owner is required'); - const [libraryItem] = await this.db.insert(schema.libraryItem).values({ - ownerUserId, - workspaceId: agent.workspaceId, - sourceAgentId: args.agentId, - sourceSessionId: args.sessionId, - kind: 'app', - name, - description: args.description?.trim().slice(0, 1_000), - mimeType: 'application/vnd.agent-commons.nextjs-project', - sizeBytes: 0, - sha256: checksum(projectId), - source: 'code_project', - metadata: { projectId, framework: 'nextjs' }, - }).returning(); + if (!ownerUserId) + throw new BadRequestException('A verified project owner is required'); + const [libraryItem] = await this.db + .insert(schema.libraryItem) + .values({ + ownerUserId, + workspaceId: agent.workspaceId, + sourceAgentId: args.agentId, + sourceSessionId: args.sessionId, + kind: 'app', + name, + description: args.description?.trim().slice(0, 1_000), + mimeType: 'application/vnd.agent-commons.nextjs-project', + sizeBytes: 0, + sha256: checksum(projectId), + source: 'code_project', + metadata: { projectId, framework: 'nextjs' }, + }) + .returning(); const [project] = await this.db .insert(schema.codeProject) .values({ @@ -81,8 +88,20 @@ export class CodeProjectService { }) .returning(); await this.db.insert(schema.libraryLink).values([ - { itemId: libraryItem.itemId, scopeType: 'code_project', scopeId: projectId }, - ...(args.sessionId ? [{ itemId: libraryItem.itemId, scopeType: 'session', scopeId: args.sessionId }] : []), + { + itemId: libraryItem.itemId, + scopeType: 'code_project', + scopeId: projectId, + }, + ...(args.sessionId + ? [ + { + itemId: libraryItem.itemId, + scopeType: 'session', + scopeId: args.sessionId, + }, + ] + : []), ]); const files = args.files?.length ? args.files : starterFiles(name); @@ -125,7 +144,9 @@ export class CodeProjectService { if (!connection) { throw new BadRequestException('Connect GitHub before exporting this app'); } - const token = await this.oauthTokens.getFreshAccessToken(connection.connectionId); + const token = await this.oauthTokens.getFreshAccessToken( + connection.connectionId, + ); const headers = { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', @@ -136,27 +157,63 @@ export class CodeProjectService { let fullName: string; let repositoryUrl = project.repositoryUrl; if (!repositoryUrl) { - const repositoryName = slugify(args.repositoryName || project.name).slice(0, 100); + const repositoryName = slugify(args.repositoryName || project.name).slice( + 0, + 100, + ); const created = await fetch('https://api.github.com/user/repos', { - method: 'POST', headers, - body: JSON.stringify({ name: repositoryName, private: args.private !== false, description: project.description || `Next.js app created with Agent Commons`, auto_init: false }), + method: 'POST', + headers, + body: JSON.stringify({ + name: repositoryName, + private: args.private !== false, + description: + project.description || `Next.js app created with Agent Commons`, + auto_init: false, + }), }); - const payload = await created.json() as any; - if (!created.ok) throw new BadRequestException(payload?.message || 'GitHub repository creation failed'); + const payload = (await created.json()) as any; + if (!created.ok) + throw new BadRequestException( + payload?.message || 'GitHub repository creation failed', + ); fullName = payload.full_name; repositoryUrl = payload.html_url; } else { const parsed = new URL(repositoryUrl); - fullName = parsed.pathname.replace(/^\/+|\/+$/g, '').replace(/\.git$/, ''); + fullName = parsed.pathname + .replace(/^\/+|\/+$/g, '') + .replace(/\.git$/, ''); } - const files = await this.db.query.codeProjectFile.findMany({ where: (table) => eq(table.projectId, args.projectId) }); + const files = await this.db.query.codeProjectFile.findMany({ + where: (table) => eq(table.projectId, args.projectId), + }); for (const file of files) { const endpoint = `https://api.github.com/repos/${fullName}/contents/${file.path.split('/').map(encodeURIComponent).join('/')}`; - const existing = await fetch(endpoint, { headers }).then(async (response) => response.ok ? response.json() as Promise : null); - const response = await fetch(endpoint, { method: 'PUT', headers, body: JSON.stringify({ message: `Update ${file.path} from Agent Commons`, content: Buffer.from(file.content).toString('base64'), sha: existing?.sha }) }); - if (!response.ok) { const payload = await response.json() as any; throw new BadRequestException(payload?.message || `Could not push ${file.path}`); } + const existing = await fetch(endpoint, { headers }).then( + async (response) => + response.ok ? (response.json() as Promise) : null, + ); + const response = await fetch(endpoint, { + method: 'PUT', + headers, + body: JSON.stringify({ + message: `Update ${file.path} from Agent Commons`, + content: Buffer.from(file.content).toString('base64'), + sha: existing?.sha, + }), + }); + if (!response.ok) { + const payload = (await response.json()) as any; + throw new BadRequestException( + payload?.message || `Could not push ${file.path}`, + ); + } } - await this.db.update(schema.codeProject).set({ repositoryUrl, updatedAt: new Date() }).where(eq(schema.codeProject.projectId, args.projectId)); + await this.db + .update(schema.codeProject) + .set({ repositoryUrl, updatedAt: new Date() }) + .where(eq(schema.codeProject.projectId, args.projectId)); return { repositoryUrl, repository: fullName, files: files.length }; } @@ -263,13 +320,16 @@ export class CodeProjectService { }); if (project?.libraryItemId) { const contents = [...merged.values()].join('\n'); - await tx.update(schema.libraryItem).set({ - sizeBytes: Buffer.byteLength(contents), - sha256: checksum(contents), - textPreview: contents.slice(0, 2_000), - extractedTextChars: contents.length, - updatedAt: new Date(), - }).where(eq(schema.libraryItem.itemId, project.libraryItemId)); + await tx + .update(schema.libraryItem) + .set({ + sizeBytes: Buffer.byteLength(contents), + sha256: checksum(contents), + textPreview: contents.slice(0, 2_000), + extractedTextChars: contents.length, + updatedAt: new Date(), + }) + .where(eq(schema.libraryItem.itemId, project.libraryItemId)); } }); @@ -331,7 +391,10 @@ export class CodeProjectService { deployment.deploymentId, ); await this.storage.publish(storagePrefix, built.assets); - const publicUrl = this.publicUrl(project.slug); + const publicUrl = this.deploymentUrl( + project.slug, + deployment.deploymentId, + ); await this.db.transaction(async (tx) => { await tx .update(schema.codeProjectDeployment) @@ -416,6 +479,8 @@ export class CodeProjectService { agentId: string; projectId: string; actions?: BrowserCheckAction[]; + surfaces?: BrowserCheckSurface[]; + capabilities?: BrowserCheckCapability[]; runId?: string; toolCallId?: string; }) { @@ -444,24 +509,31 @@ export class CodeProjectService { const result = await this.verifier.verify( deployment.publicUrl, args.actions ?? [], + args.surfaces ?? [{ type: 'page' }], + args.capabilities ?? [], ); - let screenshotUrl: string | undefined; - if (result.screenshot) { + const screenshotUrls: Array<{ name: string; url: string }> = []; + const verificationRunId = uuidv4(); + for (const screenshot of result.screenshots) { + const path = `verification/${verificationRunId}/${screenshot.name.replace(/[^a-z0-9-]+/gi, '-')}.png`; await this.storage.put(deployment.storagePrefix, { - path: 'verification.png', - content: result.screenshot, + path, + content: screenshot.content, contentType: 'image/png', cacheControl: 'no-cache', }); - screenshotUrl = `${deployment.publicUrl.replace(/\/$/, '')}/verification.png`; + screenshotUrls.push({ + name: screenshot.name, + url: `${deployment.publicUrl.replace(/\/$/, '')}/${path}`, + }); } - const verification = { ...result, screenshot: undefined, screenshotUrl }; - await this.db - .update(schema.codeProjectDeployment) - .set({ verification }) - .where( - eq(schema.codeProjectDeployment.deploymentId, deployment.deploymentId), - ); + const verification = { + ...result, + screenshots: undefined, + screenshotUrl: screenshotUrls[0]?.url, + screenshotUrls, + }; + await this.persistVerification(deployment, verification); this.emit( args.runId, result.passed ? 'completed' : 'failed', @@ -477,6 +549,82 @@ export class CodeProjectService { return verification; } + private async persistVerification( + deployment: typeof schema.codeProjectDeployment.$inferSelect, + verification: Record, + ) { + await this.db.transaction(async (tx) => { + const [lockedDeployment] = await tx + .select({ + deploymentId: schema.codeProjectDeployment.deploymentId, + projectId: schema.codeProjectDeployment.projectId, + }) + .from(schema.codeProjectDeployment) + .where( + and( + eq( + schema.codeProjectDeployment.deploymentId, + deployment.deploymentId, + ), + eq(schema.codeProjectDeployment.projectId, deployment.projectId), + ), + ) + .limit(1) + .for('update'); + if (!lockedDeployment) { + throw new NotFoundException('Code project deployment not found'); + } + + await tx + .update(schema.codeProjectDeployment) + .set({ verification }) + .where( + and( + eq( + schema.codeProjectDeployment.deploymentId, + lockedDeployment.deploymentId, + ), + eq( + schema.codeProjectDeployment.projectId, + lockedDeployment.projectId, + ), + ), + ); + + const activePlugins = await tx + .select() + .from(schema.uiPlugin) + .where( + and( + eq(schema.uiPlugin.deploymentId, lockedDeployment.deploymentId), + eq(schema.uiPlugin.codeProjectId, lockedDeployment.projectId), + eq(schema.uiPlugin.status, 'active'), + ), + ) + .for('update'); + const invalidIds = activePlugins + .filter( + (plugin) => + verification.passed !== true || + verification.schemaVersion !== 2 || + !verificationCoversManifest(verification, plugin.manifest), + ) + .map((plugin) => plugin.pluginId); + if (!invalidIds.length) return; + await tx + .update(schema.uiPlugin) + .set({ status: 'disabled', updatedAt: new Date() }) + .where( + and( + inArray(schema.uiPlugin.pluginId, invalidIds), + eq(schema.uiPlugin.deploymentId, lockedDeployment.deploymentId), + eq(schema.uiPlugin.codeProjectId, lockedDeployment.projectId), + eq(schema.uiPlugin.status, 'active'), + ), + ); + }); + } + async exportToComputer(args: { agentId: string; projectId: string; @@ -530,16 +678,22 @@ export class CodeProjectService { }; } - async publicAsset(slug: string, requestedPath?: string) { + async publicAsset( + slug: string, + requestedPath?: string, + deploymentId?: string, + ) { const project = await this.db.query.codeProject.findFirst({ where: (table) => and(eq(table.slug, slug), eq(table.visibility, 'public')), }); if (!project?.latestDeploymentId) throw new NotFoundException(); + const selectedDeploymentId = deploymentId ?? project.latestDeploymentId; const deployment = await this.db.query.codeProjectDeployment.findFirst({ where: (table) => and( - eq(table.deploymentId, project.latestDeploymentId as string), + eq(table.deploymentId, selectedDeploymentId), + eq(table.projectId, project.projectId), eq(table.status, 'ready'), ), }); @@ -576,6 +730,10 @@ export class CodeProjectService { return `http://localhost:${port}/v1/previews/${slug}/`; } + private deploymentUrl(slug: string, deploymentId: string) { + return `${this.publicUrl(slug)}deployments/${deploymentId}/`; + } + private async assertAgent(agentId: string) { const agent = await this.db.query.agent.findFirst({ where: (table) => eq(table.agentId, agentId), @@ -753,8 +911,33 @@ h1 { margin: 0; font-size: clamp(42px, 8vw, 88px); line-height: 0.95; } .lede { margin: 0; color: #52525b; font-size: 18px; } `, }, - { path: 'app/layout.tsx', content: `import type { ReactNode } from 'react';\nexport default function RootLayout({ children }: { children: ReactNode }) { return {children}; }\n` }, - { path: 'next.config.ts', content: `import type { NextConfig } from 'next';\nconst config: NextConfig = { output: 'export' };\nexport default config;\n` }, - { path: 'package.json', content: JSON.stringify({ scripts: { dev: 'next dev', build: 'next build' }, dependencies: { next: '^15.5.0', react: '^19.0.0', 'react-dom': '^19.0.0' }, devDependencies: { typescript: '^5.0.0', '@types/react': '^19.0.0', '@types/node': '^20.0.0' } }, null, 2) }, + { + path: 'app/layout.tsx', + content: `import type { ReactNode } from 'react';\nexport default function RootLayout({ children }: { children: ReactNode }) { return {children}; }\n`, + }, + { + path: 'next.config.ts', + content: `import type { NextConfig } from 'next';\nconst config: NextConfig = { output: 'export' };\nexport default config;\n`, + }, + { + path: 'package.json', + content: JSON.stringify( + { + scripts: { dev: 'next dev', build: 'next build' }, + dependencies: { + next: '^15.5.0', + react: '^19.0.0', + 'react-dom': '^19.0.0', + }, + devDependencies: { + typescript: '^5.0.0', + '@types/react': '^19.0.0', + '@types/node': '^20.0.0', + }, + }, + null, + 2, + ), + }, ]; } diff --git a/apps/commons-api/src/code-project/code-project.types.ts b/apps/commons-api/src/code-project/code-project.types.ts index e16909aa..e488e575 100644 --- a/apps/commons-api/src/code-project/code-project.types.ts +++ b/apps/commons-api/src/code-project/code-project.types.ts @@ -9,6 +9,30 @@ export type BrowserCheckAction = | { type: 'press'; selector?: string; key: string } | { type: 'expectText'; text: string }; +export type BrowserCheckSurface = + | { type: 'page' } + | { type: 'widget'; width?: number; height?: number }; + +export type BrowserCheckCapabilityName = + | 'agents.read' + | 'tasks.read' + | 'tasks.write' + | 'workflows.read' + | 'workflows.execute' + | 'library.read' + | 'tools.read' + | 'copilot.prompt'; + +/** + * Browser verification accepts the short capability name used by the host + * context as well as the manifest grant shape used by registerUiPlugin. + * Resource scoping is enforced by the real host; the verifier only needs the + * declared names to expose the matching, side-effect-free fixture methods. + */ +export type BrowserCheckCapability = + | BrowserCheckCapabilityName + | { name: BrowserCheckCapabilityName; resourceIds?: string[] }; + export type BuiltAsset = { path: string; content: Uint8Array | string; diff --git a/apps/commons-api/src/code-project/code-project.ui-runtime.ts b/apps/commons-api/src/code-project/code-project.ui-runtime.ts new file mode 100644 index 00000000..e54144d0 --- /dev/null +++ b/apps/commons-api/src/code-project/code-project.ui-runtime.ts @@ -0,0 +1,333 @@ +export const COMMONS_UI_MODULE = '@agent-commons/ui'; + +/** + * Bundled into lightweight projects when they import `@agent-commons/ui`. + * The host remains the security boundary: this client only speaks correlated + * JSON-RPC to the parent frame and never receives Commons credentials. + */ +export const COMMONS_UI_RUNTIME_SOURCE = String.raw` +import React, { useEffect, useState } from 'react'; +import { clsx } from 'clsx'; +import { twMerge } from 'tailwind-merge'; +import '@fontsource-variable/space-grotesk/wght.css'; + +const listeners = new Set(); +const pending = new Map(); +let sequence = 0; +const hostOrigin = (() => { + const configured = new URLSearchParams(window.location.search).get('commonsHostOrigin'); + if (configured) { + try { return new URL(configured).origin; } catch {} + } + if (document.referrer) { + try { return new URL(document.referrer).origin; } catch {} + } + return null; +})(); +let context = { + theme: new URLSearchParams(window.location.search).get('commonsTheme') === 'dark' ? 'dark' : 'light', + surface: new URLSearchParams(window.location.search).get('commonsSurface') === 'widget' ? 'widget' : 'page', + viewport: { width: window.innerWidth, height: window.innerHeight }, + capabilities: [], +}; + +function applyContext(next) { + context = { ...context, ...next }; + document.documentElement.dataset.theme = context.theme; + document.documentElement.dataset.commonsSurface = context.surface; + document.documentElement.style.colorScheme = context.theme; + listeners.forEach((listener) => listener(context)); +} + +function ready() { + if (!hostOrigin || window.parent === window) return; + window.parent.postMessage({ type: 'commons:ready' }, hostOrigin); +} + +window.addEventListener('message', (event) => { + if ( + !hostOrigin || + event.source !== window.parent || + event.origin !== hostOrigin || + !event.data || + typeof event.data !== 'object' + ) return; + const message = event.data; + if (message.type === 'commons:context') { + applyContext(message); + return; + } + if (message.jsonrpc === '2.0' && (typeof message.id === 'string' || typeof message.id === 'number')) { + const request = pending.get(String(message.id)); + if (!request) return; + pending.delete(String(message.id)); + window.clearTimeout(request.timeout); + if (message.error) request.reject(new Error(message.error.message || 'Commons request failed')); + else request.resolve(message.result); + } +}); + +window.addEventListener('resize', () => { + applyContext({ viewport: { width: window.innerWidth, height: window.innerHeight } }); +}); + +if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', ready, { once: true }); +else queueMicrotask(ready); +applyContext(context); + +function request(method, params = {}) { + if (!hostOrigin || window.parent === window) { + return Promise.reject(new Error('Open this app inside Agent Commons to use live data and actions')); + } + const id = 'commons-' + Date.now().toString(36) + '-' + (++sequence).toString(36); + return new Promise((resolve, reject) => { + const timeout = window.setTimeout(() => { + pending.delete(id); + reject(new Error('Commons request timed out')); + }, 300000); + pending.set(id, { resolve, reject, timeout }); + window.parent.postMessage({ jsonrpc: '2.0', id, method, params }, hostOrigin); + }); +} + +export const commons = { + ready, + request, + getContext: () => context, + onContext(listener) { + listeners.add(listener); + listener(context); + return () => listeners.delete(listener); + }, + agents: { list: (params) => request('agents.list', params) }, + tasks: { + list: (params) => request('tasks.list', params), + create: (params) => request('tasks.create', params), + update: (params) => request('tasks.update', params), + }, + workflows: { + list: (params) => request('workflows.list', params), + execute: (params) => request('workflows.execute', params), + }, + library: { list: (params) => request('library.list', params) }, + tools: { list: (params) => request('tools.list', params) }, + copilot: { open: (params) => request('copilot.open', params) }, + navigation: { open: (params) => request('navigation.open', params) }, + storage: { + get: (params) => request('storage.get', params), + set: (params) => request('storage.set', params), + remove: (params) => request('storage.remove', params), + }, + ui: { resize: (params) => request('ui.resize', params) }, +}; + +export function useCommonsContext() { + const [value, setValue] = useState(context); + useEffect(() => commons.onContext(setValue), []); + return value; +} + +export function CommonsProvider({ children }) { + useCommonsContext(); + return <>{children}; +} + +export function cn(...inputs) { + return twMerge(clsx(inputs)); +} + +export function AppShell({ className, children, ...props }) { + return
{children}
; +} + +export function PageHeader({ eyebrow, title, description, actions, className }) { + return ( +
+
+ {eyebrow ?

{eyebrow}

: null} +

{title}

+ {description ?

{description}

: null} +
+ {actions ?
{actions}
: null} +
+ ); +} + +export function Card({ className, children, ...props }) { + return
{children}
; +} + +export function Button({ className, variant = 'primary', size = 'default', type = 'button', ...props }) { + return