diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3afc6817d..01f0c653e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1082,7 +1082,7 @@ jobs: changed_files="$(git diff --name-only "$base_sha" "$head_sha")" deploy_relevant=false - if printf '%s\n' "$changed_files" | grep -E '^(\.github/workflows/ci\.yml|runtime-parent-origins\.json|vercel\.(json|examples\.json)|apps/website/.*|cockpit/.*|examples/chat/.*|libs/.*|scripts/(assemble-examples|demo-middleware|generate-runtime-parent-origins|langgraph-proxy|rate-limit)\.ts|scripts/assemble-demo\.ts)$' >/dev/null; then + if printf '%s\n' "$changed_files" | grep -E '^(\.github/workflows/ci\.yml|runtime-parent-origins\.json|vercel\.(json|examples\.json)|apps/website/.*|cockpit/.*|examples/chat/.*|libs/.*|scripts/(assemble-examples|prepare-example-html|demo-middleware|generate-runtime-parent-origins|langgraph-proxy|rate-limit)\.ts|scripts/assemble-demo\.ts)$' >/dev/null; then deploy_relevant=true fi @@ -1101,7 +1101,7 @@ jobs: if printf '%s\n' "$changed_files" | grep -E '^cockpit/.*/angular/' >/dev/null; then examples_changed=true fi - if printf '%s\n' "$changed_files" | grep -E '^(runtime-parent-origins\.json|vercel\.examples\.json|scripts/(assemble-examples|examples-middleware|generate-runtime-parent-origins|langgraph-proxy|upstash-rate-limit)\.ts)$' >/dev/null; then + if printf '%s\n' "$changed_files" | grep -E '^(runtime-parent-origins\.json|vercel\.examples\.json|scripts/(assemble-examples|prepare-example-html|examples-middleware|generate-runtime-parent-origins|langgraph-proxy|upstash-rate-limit)\.ts)$' >/dev/null; then examples_changed=true fi if printf '%s\n' "$changed_files" | grep -E '^(\.github/workflows/ci\.yml|vercel\.json|apps/website/.*)$' >/dev/null; then diff --git a/apps/website/e2e/platform-production-smoke.spec.ts b/apps/website/e2e/platform-production-smoke.spec.ts index bcffe7b89..5f259163a 100644 --- a/apps/website/e2e/platform-production-smoke.spec.ts +++ b/apps/website/e2e/platform-production-smoke.spec.ts @@ -200,8 +200,11 @@ test.describe('Production: unified runtime embedding policy', () => { } } expect(policy).toContain( - "connect-src 'self' https: http://localhost:* http://127.0.0.1:* http://[::1]:*" + "connect-src 'self' https: http://localhost:* http://127.0.0.1:*" ); + expect(policy).not.toContain('http://[::1]:*'); + expect(policy).toContain("script-src 'self';"); + expect(await response.text()).not.toContain('onload="this.media='); expect(frameAncestors).not.toContain('*'); expect(frameAncestors).not.toContain('cockpit.threadplane.ai'); expect(headers['referrer-policy']).toBe('origin'); diff --git a/libs/cockpit-telemetry/src/lib/cockpit-telemetry.service.spec.ts b/libs/cockpit-telemetry/src/lib/cockpit-telemetry.service.spec.ts index 2fbc68c83..126d8e30e 100644 --- a/libs/cockpit-telemetry/src/lib/cockpit-telemetry.service.spec.ts +++ b/libs/cockpit-telemetry/src/lib/cockpit-telemetry.service.spec.ts @@ -82,6 +82,10 @@ describe('CockpitTelemetryService', () => { bootstrap: { distinctID: 'd1' }, autocapture: false, capture_pageview: false, + advanced_disable_flags: true, + disable_surveys: true, + disable_session_recording: true, + disable_external_dependency_loading: true, }), ); }); diff --git a/libs/cockpit-telemetry/src/lib/cockpit-telemetry.service.ts b/libs/cockpit-telemetry/src/lib/cockpit-telemetry.service.ts index 8d00aaeae..c9e49e90a 100644 --- a/libs/cockpit-telemetry/src/lib/cockpit-telemetry.service.ts +++ b/libs/cockpit-telemetry/src/lib/cockpit-telemetry.service.ts @@ -24,6 +24,12 @@ export class CockpitTelemetryService { bootstrap: { distinctID: this.config.distinctId }, autocapture: false, capture_pageview: false, + // This client only sends explicit lifecycle events. Optional downloaded + // features are unnecessary and conflict with the embedded runtime CSP. + advanced_disable_flags: true, + disable_surveys: true, + disable_session_recording: true, + disable_external_dependency_loading: true, }); this.subscribeChat(); diff --git a/scripts/assemble-examples.ts b/scripts/assemble-examples.ts index c37cd5e0e..bf20283d2 100644 --- a/scripts/assemble-examples.ts +++ b/scripts/assemble-examples.ts @@ -19,6 +19,7 @@ import { } from 'fs'; import { resolve } from 'path'; import { capabilities as registryCapabilities } from '@threadplane/cockpit-registry'; +import { prepareExampleHtml } from './prepare-example-html'; import { GENERATED_RUNTIME_PARENT_ORIGINS_MODULE, generateRuntimeParentOriginPolicy, @@ -97,10 +98,7 @@ for (const cap of capabilities) { const indexPath = resolve(dest, 'index.html'); if (existsSync(indexPath)) { const html = readFileSync(indexPath, 'utf-8'); - const fixed = html.replace( - '', - `` - ); + const fixed = prepareExampleHtml(html, cap.product, cap.topic); writeFileSync(indexPath, fixed); } diff --git a/scripts/ci-workflow.spec.mjs b/scripts/ci-workflow.spec.mjs index 509315d95..1f22d6a60 100644 --- a/scripts/ci-workflow.spec.mjs +++ b/scripts/ci-workflow.spec.mjs @@ -247,6 +247,19 @@ describe('CI workflow', () => { ); }); + it('redeploys examples when their HTML preparation changes', async () => { + const deployJob = await readDeployJob(); + const path = 'scripts/prepare-example-html.ts'; + const preflight = deployJob.match(/grep -E '([^']+)' >\/dev\/null/); + assert.match(path, new RegExp(preflight?.[1] ?? '(?!)')); + const examplesDetection = deployJob.slice( + deployJob.indexOf('Check if examples changed'), + deployJob.indexOf('- uses: actions/setup-node') + ); + assert.ok([...examplesDetection.matchAll(/grep -E '([^']+)'/g)] + .some((match) => new RegExp(match[1]).test(path))); + }); + it('isolates langgraph coverage before the remaining bounded library tests', async () => { const libraryJob = await readLibraryJob(); diff --git a/scripts/generate-runtime-parent-origins.spec.ts b/scripts/generate-runtime-parent-origins.spec.ts index 1fa6b2abd..4974f2911 100644 --- a/scripts/generate-runtime-parent-origins.spec.ts +++ b/scripts/generate-runtime-parent-origins.spec.ts @@ -96,8 +96,10 @@ describe('generate runtime parent origins', () => { 'Referrer-Policy': 'origin', }); expect(policy.deploymentHeaders['Content-Security-Policy']).toContain( - "connect-src 'self' https: http://localhost:* http://127.0.0.1:* http://[::1]:*" + "connect-src 'self' https: http://localhost:* http://127.0.0.1:*" ); + expect(policy.deploymentHeaders['Content-Security-Policy']).not.toContain('[::1]'); + expect(policy.deploymentHeaders['Content-Security-Policy']).toContain("script-src 'self';"); expect(JSON.stringify(policy)).not.toMatch( /apiKey|authorization|secret|token/i ); diff --git a/scripts/generate-runtime-parent-origins.ts b/scripts/generate-runtime-parent-origins.ts index 42f9ed19e..b1f3ae16b 100644 --- a/scripts/generate-runtime-parent-origins.ts +++ b/scripts/generate-runtime-parent-origins.ts @@ -8,7 +8,6 @@ const RUNTIME_CONNECT_SOURCES = [ 'https:', 'http://localhost:*', 'http://127.0.0.1:*', - 'http://[::1]:*', ] as const; export interface RuntimeParentOriginPolicy { diff --git a/scripts/prepare-example-html.spec.ts b/scripts/prepare-example-html.spec.ts new file mode 100644 index 000000000..c5b71aab3 --- /dev/null +++ b/scripts/prepare-example-html.spec.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { prepareExampleHtml } from './prepare-example-html'; + +describe('assembled example HTML under script-src self', () => { + it('activates Angular deferred CSS without executing an inline handler', () => { + const html = ``; + const prepared = prepareExampleHtml(html, 'ag-ui', 'interrupts'); + expect(prepared).toContain(''); + expect(prepared).toContain( + '' + ); + expect(prepared).not.toContain('onload='); + expect(prepared).toContain( + '' + ); + expect(prepareExampleHtml(prepared, 'ag-ui', 'interrupts')).toBe(prepared); + }); + + it('preserves genuine print styles and script assets', () => { + const html = + ''; + expect(prepareExampleHtml(html, 'chat', 'threads')).toBe(html); + }); +}); diff --git a/scripts/prepare-example-html.ts b/scripts/prepare-example-html.ts new file mode 100644 index 000000000..50808663f --- /dev/null +++ b/scripts/prepare-example-html.ts @@ -0,0 +1,21 @@ +/** Prepare Angular output for subpath hosting under script-src 'self'. */ +export function prepareExampleHtml( + html: string, + product: string, + topic: string +): string { + return html + .replace('', ``) + .replace(/]*>/g, (tag) => { + // Angular's critical-CSS optimization defers the full stylesheet with an + // inline handler. Load it normally because the runtime CSP forbids handlers. + if ( + !tag.includes('rel="stylesheet"') || + !tag.includes('onload="this.media=\'all\'"') + ) + return tag; + return tag + .replace('media="print"', 'media="all"') + .replace(' onload="this.media=\'all\'"', ''); + }); +}