P6 complete: PWA, route motion, dex browser-flow OAuth, release pipeline

- PWA (§16): hand-rolled 60-line service worker (network-first pages with
  offline fallback, cache-first hashed statics, /api NEVER touched — SSE
  and approvals stay live), app manifest with §2 identity, stdlib-
  generated coral claw icons, prod-only registration. E2E asserts
  manifest, real PNG icons, an ACTIVATED service worker, and the /api
  bypass. (Serwist was tried and dropped: its webpack plugin fights
  Next 16's Turbopack builds; sixty lines we own beat a plugin we fight.)
- Route motion (§3): (workspace) template re-mounts per navigation with a
  quiet fade-rise, zeroed under prefers-reduced-motion. The a11y sweep
  now settles running animations before scanning — axe was reading
  mid-fade opacity as contrast failures
- OAuth browser flow vs REAL dex: the e2e harness boots dexidp/dex with
  static client + password; the journey drives the actual dex login form
  from /api/apps/oauth/start through the callback 303 and asserts the
  app reads connected (closing the P4 deferral honestly)
- release.yml: tag-triggered — builds all four images + postgres, saves
  tarballs, assembles the SIGNED air-gapped bundle (compose, config,
  migrations, seccomp profile, installer, bundler binary), derives the
  public key via the new Could not find command "pubkey". subcommand (tested), verifies
  the bundle customer-style with the public half only, attaches tarball
  + public key to the GitHub release

153 Rust + 63 frontend tests + 29 Playwright journeys.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-10 09:57:21 -05:00
co-authored by Claude Fable 5
parent 4f253bec93
commit ace66d7ffb
20 changed files with 380 additions and 1 deletions
+87
View File
@@ -0,0 +1,87 @@
# Release: build the images both deploy targets share, assemble the
# SIGNED air-gapped bundle, verify it offline, and attach everything to
# the tag. The signing key lives in repo secrets (BUNDLE_SIGNING_KEY,
# hex ed25519 from `teamclaw-bundler keygen`); the matching public key is
# published out of band so customers can verify before docker load.
name: release
on:
push:
tags: ["v*"]
jobs:
bundle:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Version from tag
run: echo "VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_ENV"
- name: Build images
run: |
docker build -t "teamclaw/server:$VERSION" -f images/server.Dockerfile .
docker build -t "teamclaw/frontend:$VERSION" -f images/frontend.Dockerfile .
docker build -t "teamclaw/agent-base:$VERSION" images/agent-base
docker build -t "teamclaw/agent-browser:$VERSION" images/agent-browser
docker pull postgres:16-alpine
- name: Save image tarballs
run: |
mkdir -p dist/images
docker save "teamclaw/server:$VERSION" -o dist/images/server.tar
docker save "teamclaw/frontend:$VERSION" -o dist/images/frontend.tar
docker save "teamclaw/agent-base:$VERSION" -o dist/images/agent-base.tar
docker save "teamclaw/agent-browser:$VERSION" -o dist/images/agent-browser.tar
docker save postgres:16-alpine -o dist/images/postgres.tar
- name: Build bundler
run: cargo build --release -p teamclaw-bundler
- name: Assemble and sign the bundle
env:
BUNDLE_SIGNING_KEY: ${{ secrets.BUNDLE_SIGNING_KEY }}
run: |
printf '%s' "$BUNDLE_SIGNING_KEY" > /tmp/release.key
BUNDLER=target/release/teamclaw-bundler
ARTIFACTS=""
for tar in dist/images/*.tar; do
ARTIFACTS="$ARTIFACTS $tar=images/$(basename "$tar")"
done
for migration in migrations/*.sql; do
ARTIFACTS="$ARTIFACTS $migration=migrations/$(basename "$migration")"
done
# shellcheck disable=SC2086
"$BUNDLER" assemble dist/bundle "$VERSION" /tmp/release.key \
deploy/compose/docker-compose.yml=compose/docker-compose.yml \
deploy/compose/teamclaw.toml=compose/teamclaw.toml \
deploy/compose/.env.example=compose/.env.example \
deploy/e2e/scenarios.toml=compose/scenarios.toml \
images/seccomp/agent-profile.json=seccomp/agent-profile.json \
deploy/airgapped/install.sh=install.sh \
"$BUNDLER"=bin/teamclaw-bundler \
$ARTIFACTS
chmod +x dist/bundle/bin/teamclaw-bundler dist/bundle/install.sh
rm /tmp/release.key
- name: Verify the bundle offline (public key only)
env:
BUNDLE_SIGNING_KEY: ${{ secrets.BUNDLE_SIGNING_KEY }}
run: |
printf '%s' "$BUNDLE_SIGNING_KEY" > /tmp/release.key
target/release/teamclaw-bundler pubkey /tmp/release.key dist/release.pub
rm /tmp/release.key
# The customer's exact procedure: only the public half.
target/release/teamclaw-bundler verify dist/bundle dist/release.pub
- name: Tarball
run: tar -C dist -czf "teamclaw-bundle-$VERSION.tgz" bundle
- name: Attach to release
uses: softprops/action-gh-release@v2
with:
files: |
teamclaw-bundle-*.tgz
dist/release.pub
+21
View File
@@ -0,0 +1,21 @@
# Real OIDC IdP for the e2e OAuth browser-flow journey.
issuer: http://127.0.0.1:54556/dex
storage:
type: memory
web:
http: 0.0.0.0:5556
oauth2:
skipApprovalScreen: true
staticClients:
- id: teamclaw
secret: tc-e2e-oauth-secret
name: TeamClaw
redirectURIs:
- http://127.0.0.1:8080/api/apps/oauth/callback
enablePasswordDB: true
staticPasswords:
- email: [email protected]
# bcrypt("password") — dex's documented example hash.
hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W"
username: admin
userID: 08a8684b-db88-4b73-90a9-3cd1661f5466
+6
View File
@@ -20,3 +20,9 @@ socket_path = "/tmp/teamclaw-e2e-broker.sock"
[slack] [slack]
base_url = "http://127.0.0.1:8080/__slack" base_url = "http://127.0.0.1:8080/__slack"
[oauth]
issuer_url = "http://127.0.0.1:54556/dex"
client_id = "teamclaw"
client_secret = "tc-e2e-oauth-secret"
redirect_base = "http://127.0.0.1:8080"
Binary file not shown.

After

Width:  |  Height:  |  Size: 741 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

+60
View File
@@ -0,0 +1,60 @@
// TeamClaw service worker (§16 PWA). Hand-rolled on purpose: ~50 lines we
// fully control beats a build-tool plugin. Strategy:
// /api/** -> NEVER touched (approvals + SSE must be live)
// /_next/static/** -> cache-first (content-hashed, immutable)
// navigations -> network-first, cache fallback for offline shell
const STATIC_CACHE = "tc-static-v1";
const PAGE_CACHE = "tc-pages-v1";
self.addEventListener("install", () => {
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
event.waitUntil(
(async () => {
const keep = [STATIC_CACHE, PAGE_CACHE];
for (const key of await caches.keys()) {
if (!keep.includes(key)) await caches.delete(key);
}
await self.clients.claim();
})(),
);
});
self.addEventListener("fetch", (event) => {
const url = new URL(event.request.url);
if (url.origin !== self.location.origin) return;
if (url.pathname.startsWith("/api/")) return;
if (url.pathname.startsWith("/_next/static/")) {
event.respondWith(
(async () => {
const cache = await caches.open(STATIC_CACHE);
const hit = await cache.match(event.request);
if (hit) return hit;
const response = await fetch(event.request);
if (response.ok) cache.put(event.request, response.clone());
return response;
})(),
);
return;
}
if (event.request.mode === "navigate") {
event.respondWith(
(async () => {
const cache = await caches.open(PAGE_CACHE);
try {
const response = await fetch(event.request);
if (response.ok) cache.put(event.request, response.clone());
return response;
} catch {
const hit = await cache.match(event.request);
if (hit) return hit;
throw new Error("offline and not cached");
}
})(),
);
}
});
+13
View File
@@ -0,0 +1,13 @@
// Re-mounts on every route change inside the workspace, giving each page
// the §3 enter motion. prefers-reduced-motion zeroes it via motion.css.
export default function WorkspaceTemplate({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="h-full motion-safe:animate-[route-enter_var(--duration-normal)_var(--ease-app)]">
{children}
</div>
);
}
+3
View File
@@ -1,6 +1,8 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import localFont from "next/font/local"; import localFont from "next/font/local";
import { NuqsAdapter } from "nuqs/adapters/next/app"; import { NuqsAdapter } from "nuqs/adapters/next/app";
import { RegisterServiceWorker } from "@/components/shell/RegisterServiceWorker";
import "./globals.css"; import "./globals.css";
// Vendored variable fonts (src/fonts) — zero external requests, so the same // Vendored variable fonts (src/fonts) — zero external requests, so the same
@@ -29,6 +31,7 @@ export default function RootLayout({
<html lang="en" className={`${geist.variable} ${geistMono.variable} h-full`}> <html lang="en" className={`${geist.variable} ${geistMono.variable} h-full`}>
<body className="min-h-full antialiased"> <body className="min-h-full antialiased">
<NuqsAdapter>{children}</NuqsAdapter> <NuqsAdapter>{children}</NuqsAdapter>
<RegisterServiceWorker />
</body> </body>
</html> </html>
); );
+24
View File
@@ -0,0 +1,24 @@
import type { MetadataRoute } from "next";
// §16 NFR: installable PWA. Icons are generated at build time into
// /public/icons (script: npm run icons).
export default function manifest(): MetadataRoute.Manifest {
return {
name: "TeamClaw",
short_name: "TeamClaw",
description: "Collaborative AI agents with human-in-the-loop safety.",
start_url: "/",
display: "standalone",
background_color: "#121212",
theme_color: "#f96565",
icons: [
{ src: "/icons/icon-192.png", sizes: "192x192", type: "image/png" },
{
src: "/icons/icon-512.png",
sizes: "512x512",
type: "image/png",
purpose: "maskable",
},
],
};
}
@@ -0,0 +1,19 @@
"use client";
import { useEffect } from "react";
/** Registers the PWA service worker (prod builds only — dev reloads and
* SW caching fight each other). */
export function RegisterServiceWorker() {
useEffect(() => {
if (
process.env.NODE_ENV === "production" &&
"serviceWorker" in navigator
) {
navigator.serviceWorker.register("/sw.js").catch(() => {
// Browsers without SW support (or private modes) degrade fine.
});
}
}, []);
return null;
}
+12
View File
@@ -95,6 +95,18 @@
0%, 100% { transform: translateY(0); } 0%, 100% { transform: translateY(0); }
50% { transform: translateY(-6px); } 50% { transform: translateY(-6px); }
} }
/* Route changes inside the workspace (§3): a quiet rise, nothing showy. */
@keyframes route-enter {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes tip-slide { @keyframes tip-slide {
from { transform: translateY(8px); opacity: 0; } from { transform: translateY(8px); opacity: 0; }
to { transform: translateY(0); opacity: 1; } to { transform: translateY(0); opacity: 1; }
+5
View File
@@ -16,6 +16,11 @@ async function signIn(page: Page) {
} }
async function expectClean(page: Page, context: string) { async function expectClean(page: Page, context: string) {
// Route-enter fades opacity; scanning mid-animation reads false
// contrast failures. Let running animations settle first.
await page.evaluate(() =>
Promise.allSettled(document.getAnimations().map((a) => a.finished)),
);
const results = await new AxeBuilder({ page }).analyze(); const results = await new AxeBuilder({ page }).analyze();
const blocking = results.violations.filter( const blocking = results.violations.filter(
(v) => v.impact === "serious" || v.impact === "critical", (v) => v.impact === "serious" || v.impact === "critical",
+51
View File
@@ -0,0 +1,51 @@
import { expect, test } from "@playwright/test";
// P6: the OAuth connect round trip driven through a REAL browser against
// a REAL dex IdP — authorize redirect, dex's own login form, code
// exchange at dex's token endpoint, broker-held token, connected app.
const OWNER_EMAIL = "[email protected]";
const OWNER_PASSWORD = "e2e-password";
const BACKEND = "http://127.0.0.1:8080";
test("connecting an app via OAuth walks the real dex login", async ({
page,
request,
}) => {
// API session for start + verification.
const login = await request.post(`${BACKEND}/api/auth/login`, {
data: { email: OWNER_EMAIL, password: OWNER_PASSWORD },
});
const { token } = (await login.json()) as { token: string };
const auth = { Authorization: `Bearer ${token}` };
const claws = (await (
await request.get(`${BACKEND}/api/team/claws`, { headers: auth })
).json()) as { id: string; name: string }[];
const scout = claws.find((claw) => claw.name === "Scout")!;
const start = await request.post(`${BACKEND}/api/apps/oauth/start`, {
headers: auth,
data: { clawId: scout.id, provider: "linear" },
});
expect(start.status()).toBe(200);
const { authorize_url } = (await start.json()) as { authorize_url: string };
expect(authorize_url).toContain("/dex/auth");
// The REAL browser flow: dex serves its login form; sign in as the
// static user; dex redirects back through our callback.
await page.goto(authorize_url);
await page.getByPlaceholder("email address").fill("[email protected]");
await page.getByPlaceholder("password").fill("password");
await page.getByRole("button", { name: /Log ?in/i }).click();
// The callback 303s into the claw's Add Apps panel.
await page.waitForURL(/app=apps/);
// The connection exists and the app reads connected.
const directory = (await (
await request.get(`${BACKEND}/api/apps?clawId=${scout.id}`, {
headers: auth,
})
).json()) as { id: string; connected: boolean }[];
expect(directory.find((app) => app.id === "linear")?.connected).toBe(true);
});
+40
View File
@@ -0,0 +1,40 @@
import { expect, test } from "@playwright/test";
// P6 exit (§16 NFR): installable PWA — manifest, icons, and a live
// service worker that never touches /api.
test("the app is installable: manifest, icons, service worker", async ({
page,
}) => {
await page.goto("/login");
// Manifest is linked and serves the §2 identity.
const href = await page
.locator('link[rel="manifest"]')
.getAttribute("href");
expect(href).toBeTruthy();
const manifest = await (await page.request.get(href!)).json();
expect(manifest.name).toBe("TeamClaw");
expect(manifest.display).toBe("standalone");
expect(manifest.theme_color).toBe("#f96565");
// Icons are real PNGs.
for (const icon of manifest.icons) {
const res = await page.request.get(icon.src);
expect(res.status()).toBe(200);
expect((await res.body()).subarray(0, 4).toString("latin1")).toContain(
"PNG",
);
}
// The service worker registers and activates (prod build in e2e).
const state = await page.evaluate(async () => {
const registration = await navigator.serviceWorker.ready;
return registration.active?.state;
});
expect(state).toBe("activated");
// The SW intercepts static assets but NEVER /api (live SSE).
const swText = await (await page.request.get("/sw.js")).text();
expect(swText).toContain('url.pathname.startsWith("/api/")');
});
+10
View File
@@ -17,6 +17,16 @@ until docker exec teamclaw-e2e-pg pg_isready -U postgres -q >/dev/null 2>&1; do
sleep 0.5 sleep 0.5
done done
# Real OIDC IdP for the OAuth browser-flow journey.
docker rm -f teamclaw-e2e-dex >/dev/null 2>&1 || true
docker run -d --name teamclaw-e2e-dex \
-p 54556:5556 \
-v "$ROOT/deploy/e2e/dex.yaml:/etc/dex/config.yaml:ro" \
dexidp/dex:v2.41.1 dex serve /etc/dex/config.yaml >/dev/null
until curl -fsS http://127.0.0.1:54556/dex/.well-known/openid-configuration >/dev/null 2>&1; do
sleep 0.5
done
cd "$ROOT" cd "$ROOT"
export TEAMCLAW_MODE=e2e export TEAMCLAW_MODE=e2e
export TEAMCLAW_CONFIG="$ROOT/deploy/e2e/teamclaw.e2e.toml" export TEAMCLAW_CONFIG="$ROOT/deploy/e2e/teamclaw.e2e.toml"
+8
View File
@@ -40,6 +40,14 @@ pub fn generate_keypair(private_path: &Path, public_path: &Path) -> Result<(), B
Ok(()) Ok(())
} }
/// Re-derives the public key from a signing key (release pipelines store
/// only the private half in secrets).
pub fn derive_public_key(private_path: &Path, public_path: &Path) -> Result<(), BundleError> {
let signing = load_signing_key(private_path)?;
fs::write(public_path, hex::encode(signing.verifying_key().to_bytes())).map_err(io_err)?;
Ok(())
}
fn load_signing_key(path: &Path) -> Result<SigningKey, BundleError> { fn load_signing_key(path: &Path) -> Result<SigningKey, BundleError> {
let bytes: [u8; 32] = hex::decode(fs::read_to_string(path).map_err(io_err)?.trim()) let bytes: [u8; 32] = hex::decode(fs::read_to_string(path).map_err(io_err)?.trim())
.map_err(|e| BundleError::Key(e.to_string()))? .map_err(|e| BundleError::Key(e.to_string()))?
+6 -1
View File
@@ -52,12 +52,17 @@ fn run() -> Result<String, String> {
args[1] args[1]
)) ))
} }
Some("pubkey") if args.len() == 3 => {
teamclaw_bundler::derive_public_key(Path::new(&args[1]), Path::new(&args[2]))
.map_err(|e| e.to_string())?;
Ok(format!("wrote {}", args[2]))
}
Some("verify") if args.len() == 3 => { Some("verify") if args.len() == 3 => {
let verified = let verified =
teamclaw_bundler::verify(Path::new(&args[1]), Path::new(&args[2])) teamclaw_bundler::verify(Path::new(&args[1]), Path::new(&args[2]))
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
Ok(format!("bundle OK: {verified} artifacts verified offline")) Ok(format!("bundle OK: {verified} artifacts verified offline"))
} }
_ => Err("usage: keygen <priv> <pub> | assemble <out> <version> <priv> <src=dest>... | verify <bundle> <pub>".into()), _ => Err("usage: keygen <priv> <pub> | pubkey <priv> <pub> | assemble <out> <version> <priv> <src=dest>... | verify <bundle> <pub>".into()),
} }
} }
+13
View File
@@ -98,3 +98,16 @@ fn a_missing_artifact_is_reported() {
let err = verify(&fx.bundle, &fx.public_key).unwrap_err(); let err = verify(&fx.bundle, &fx.public_key).unwrap_err();
assert!(matches!(err, BundleError::Missing(p) if p.contains("0001_init.sql"))); assert!(matches!(err, BundleError::Missing(p) if p.contains("0001_init.sql")));
} }
#[test]
fn the_public_key_rederives_from_the_signing_key() {
let fx = fixture();
let derived = fx.root.join("derived.pub");
teamclaw_bundler::derive_public_key(&fx.root.join("release.key"), &derived).unwrap();
assert_eq!(
fs::read_to_string(&derived).unwrap(),
fs::read_to_string(&fx.public_key).unwrap()
);
// And it verifies the bundle, same as the original.
assert_eq!(verify(&fx.bundle, &derived).unwrap(), 4);
}