diff --git a/.github/workflows/proto-drift-admin.yml b/.github/disabled-workflows/proto-drift-admin.yml similarity index 100% rename from .github/workflows/proto-drift-admin.yml rename to .github/disabled-workflows/proto-drift-admin.yml diff --git a/.github/workflows/proto-drift-web.yml b/.github/disabled-workflows/proto-drift-web.yml similarity index 100% rename from .github/workflows/proto-drift-web.yml rename to .github/disabled-workflows/proto-drift-web.yml diff --git a/.github/workflows/apple-validation.yml b/.github/workflows/apple-validation.yml index 6f9261ed6..d77266388 100644 --- a/.github/workflows/apple-validation.yml +++ b/.github/workflows/apple-validation.yml @@ -15,7 +15,23 @@ concurrency: cancel-in-progress: true jobs: + changes: + name: Select Apple validation + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + selected: ${{ steps.plan.outputs.selected }} + run: ${{ steps.plan.outputs.run }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - id: plan + run: node scripts/ci/validation-gates.mjs select-apple + contracts: + needs: changes + if: needs.changes.outputs.run == 'true' name: Apple source, scripts, and Swift protocol runs-on: xcode-27 timeout-minutes: 30 @@ -42,6 +58,8 @@ jobs: test -z "$(git ls-files --others --exclude-standard apple/InlineKit/Sources/InlineProtocol)" swift-main: + needs: changes + if: needs.changes.outputs.run == 'true' name: Swift packages (main) runs-on: xcode-27 timeout-minutes: 75 @@ -65,6 +83,8 @@ jobs: retention-days: 7 swift-utilities: + needs: changes + if: needs.changes.outputs.run == 'true' name: Swift packages (utilities) runs-on: xcode-27 timeout-minutes: 75 @@ -90,6 +110,8 @@ jobs: retention-days: 7 macos-app: + needs: changes + if: needs.changes.outputs.run == 'true' name: macOS app Debug and Release runs-on: xcode-27 timeout-minutes: 75 @@ -108,6 +130,8 @@ jobs: retention-days: 7 ios-app: + needs: changes + if: needs.changes.outputs.run == 'true' name: iOS device app Debug and Release runs-on: xcode-27 timeout-minutes: 75 @@ -124,3 +148,17 @@ jobs: name: ios-app-build-logs path: ${{ runner.temp }}/ios-app-reports/ retention-days: 7 + + required: + name: Apple validation required + if: ${{ always() }} + needs: [changes, contracts, swift-main, swift-utilities, macos-app, ios-app] + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v6 + - name: Require every selected job to pass + env: + RESULTS_JSON: ${{ toJSON(needs) }} + SELECTED_JOBS: ${{ needs.changes.outputs.selected }} + run: node scripts/ci/validation-gates.mjs apple diff --git a/.github/workflows/cli-check.yml b/.github/workflows/cli-check.yml index 11c2fab81..37aa8d008 100644 --- a/.github/workflows/cli-check.yml +++ b/.github/workflows/cli-check.yml @@ -65,7 +65,7 @@ jobs: run: bash scripts/ci/ensure-public-workspaces.sh - name: Setup Rust - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@1.96.0 - name: Install Linux musl Toolchain working-directory: cli @@ -84,11 +84,7 @@ jobs: uses: Swatinem/rust-cache@v2 with: workspaces: | - cli -> target - - - name: Build - working-directory: cli - run: cargo build --locked + . -> target - name: Build Linux musl working-directory: cli @@ -102,6 +98,7 @@ jobs: - name: Test installer with Debian sh run: python3 scripts/ci/test-cli-installer.py - - name: Test + - name: Test native ARM64 CLI + if: matrix.target == 'aarch64-unknown-linux-musl' working-directory: cli run: cargo test --locked --profile ci-test diff --git a/.github/workflows/integrations.yml b/.github/workflows/integrations.yml index 79b6b79b5..2495f02fe 100644 --- a/.github/workflows/integrations.yml +++ b/.github/workflows/integrations.yml @@ -9,10 +9,15 @@ on: permissions: contents: read +concurrency: + group: integrations-${{ github.ref }} + cancel-in-progress: true + jobs: landing: name: Landing and browser client runs-on: ubuntu-latest + timeout-minutes: 20 steps: - name: Checkout uses: actions/checkout@v4 @@ -37,10 +42,28 @@ jobs: bun run --cwd landing lint bun run --cwd landing test bun run --cwd landing/packages/client test + for package in auth ids log protocol; do + bun run --cwd "landing/packages/$package" test + done + - name: Install Chromium for browser acceptance + working-directory: landing + run: bun x playwright install --with-deps chromium + - name: Test built browser login and persisted session + run: bun run --cwd landing test:e2e + - name: Upload browser report and failure traces + if: always() + uses: actions/upload-artifact@v4 + with: + name: browser-e2e-report + path: | + landing/playwright-report/ + landing/test-results/ + retention-days: 7 codex-plugin: name: Codex plugin runs-on: ubuntu-latest + timeout-minutes: 20 steps: - name: Checkout uses: actions/checkout@v4 @@ -56,35 +79,10 @@ jobs: - name: Check plugin bundle run: bun run check:codex-plugin - cli: - name: CLI - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Rust - uses: dtolnay/rust-toolchain@1.96.0 - with: - components: clippy, rustfmt - - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - with: - bun-version: 1.4.0 - - - name: Install protoc - run: sudo apt-get update && sudo apt-get install -y protobuf-compiler systemd - - - name: Install dependencies - run: bun install --frozen-lockfile - - - name: Check CLI, including generated systemd units - run: bun run --cwd cli ci - mcp: name: MCP runs-on: ubuntu-latest + timeout-minutes: 20 steps: - name: Checkout uses: actions/checkout@v4 @@ -112,8 +110,18 @@ jobs: - name: Lint MCP run: bun run --cwd packages/mcp lint - - name: Test MCP - run: bun run --cwd packages/mcp test + - name: Test MCP and report coverage (threshold gate pending) + env: + MCP_COVERAGE_REPORT_ONLY: '1' + run: bun run --cwd packages/mcp test --coverage + + - name: Upload MCP coverage + if: always() + uses: actions/upload-artifact@v4 + with: + name: mcp-coverage + path: packages/mcp/coverage/ + retention-days: 7 - name: Initialize compiled MCP app with local OAuth introspection run: bun --no-env-file scripts/ci/check-mcp-assembled.mjs @@ -121,6 +129,7 @@ jobs: openclaw: name: OpenClaw (${{ matrix.host }}) runs-on: ubuntu-latest + timeout-minutes: 20 continue-on-error: ${{ matrix.host == 'latest' }} strategy: fail-fast: false @@ -165,6 +174,7 @@ jobs: openclaw-source: name: OpenClaw source, tests, and bundle runs-on: ubuntu-latest + timeout-minutes: 20 steps: - uses: actions/checkout@v6 - uses: oven-sh/setup-bun@v2 @@ -200,6 +210,7 @@ jobs: chat-sdk: name: Vercel Chat SDK adapter runs-on: ubuntu-latest + timeout-minutes: 20 steps: - name: Checkout uses: actions/checkout@v4 @@ -271,6 +282,7 @@ jobs: workflow-and-release-contracts: name: Workflow and release contracts runs-on: ubuntu-latest + timeout-minutes: 20 steps: - uses: actions/checkout@v6 - uses: oven-sh/setup-bun@v2 @@ -280,7 +292,7 @@ jobs: - name: Validate active workflows run: | go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.10 -color - bun test scripts/ci/workflow-policy.test.ts scripts/ci/ios-early-testers.test.ts + cd scripts && bun --no-env-file test ci && cd .. python3 -m unittest discover -s scripts/ci -p 'test_nightly_tip_gate.py' - name: Check CI shell scripts run: | @@ -453,6 +465,11 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: bash scripts/ci/install-hermes-host.sh "$RUNNER_TEMP/hermes-host" latest + - name: Build compiled MCP app for real transport checks + run: | + for package in protocol oauth-core sdk mcp; do + bun run --cwd "packages/$package" build + done - name: Run packed adapters and Hermes against source server env: TEST_DATABASE_URL: postgres://postgres:postgres@127.0.0.1:5432/test_db @@ -460,3 +477,25 @@ jobs: HERMES_BIN: ${{ steps.hermes-host.outputs.hermes-bin }} HERMES_PYTHON_BIN: ${{ steps.hermes-host.outputs.python-bin }} run: bun --no-env-file scripts/ci/local-bot-flow.mjs "$RUNNER_TEMP/inline-packages" + - name: Upload scenario receipts + if: always() + uses: actions/upload-artifact@v4 + with: + name: local-integration-receipts + path: | + ${{ runner.temp }}/inline-packages/*receipt.json + ${{ runner.temp }}/inline-packages/manifest.json + retention-days: 7 + + required: + name: Integrations required + if: ${{ always() }} + needs: [landing, codex-plugin, mcp, openclaw, openclaw-source, hermes, chat-sdk, shared-packages, rust-workspace, workflow-and-release-contracts, candidate-packages, packed-consumers, openclaw-admission, hermes-admission, local-integration] + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v6 + - name: Require every selected job to pass + env: + RESULTS_JSON: ${{ toJSON(needs) }} + run: node scripts/ci/validation-gates.mjs integrations diff --git a/.github/workflows/openclaw-compatibility.yml b/.github/workflows/openclaw-compatibility.yml new file mode 100644 index 000000000..08710eafa --- /dev/null +++ b/.github/workflows/openclaw-compatibility.yml @@ -0,0 +1,34 @@ +name: OpenClaw stable compatibility + +on: + schedule: + - cron: '43 */6 * * *' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: openclaw-stable-compatibility + cancel-in-progress: false + +jobs: + stable: + name: npm latest / OpenClaw latest + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v4 + with: + node-version: '26' + - name: Resolve published plugin and its SDK dependencies + run: node scripts/ci/pack-published-openclaw.mjs "$RUNNER_TEMP/published-inline" + - name: Test published package through current real OpenClaw CLI + run: node scripts/ci/check-openclaw-admission.mjs "$RUNNER_TEMP/published-inline" latest + - uses: actions/upload-artifact@v4 + if: always() + with: + name: published-openclaw-manifest + path: ${{ runner.temp }}/published-inline/manifest.json + retention-days: 7 diff --git a/.github/workflows/server-test.yml b/.github/workflows/server-test.yml index e67e7c5a3..9d76de0d9 100644 --- a/.github/workflows/server-test.yml +++ b/.github/workflows/server-test.yml @@ -254,3 +254,16 @@ jobs: include-hidden-files: true if-no-files-found: ignore retention-days: 14 + + required: + name: Server validation required + if: ${{ always() }} + needs: [container, test] + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v6 + - name: Require every selected job to pass + env: + RESULTS_JSON: ${{ toJSON(needs) }} + run: node scripts/ci/validation-gates.mjs server diff --git a/.gitignore b/.gitignore index 8802dbf1b..ce95c2e9a 100644 --- a/.gitignore +++ b/.gitignore @@ -174,6 +174,8 @@ docs/superpowers .running # Consolidated tooling outputs and local coordination +landing/playwright-report/ +landing/test-results/ target/ **/__pycache__/ *.py[cod] diff --git a/apple/Inline.xcodeproj/xcshareddata/xcschemes/Inline (iOS).xcscheme b/apple/Inline.xcodeproj/xcshareddata/xcschemes/Inline (iOS).xcscheme index 70dc5e2b8..8a337bd08 100644 --- a/apple/Inline.xcodeproj/xcshareddata/xcschemes/Inline (iOS).xcscheme +++ b/apple/Inline.xcodeproj/xcshareddata/xcschemes/Inline (iOS).xcscheme @@ -30,39 +30,6 @@ shouldUseLaunchSchemeArgsEnv = "YES" shouldAutocreateTestPlan = "YES"> - - - - - - - - - - - - - - - - - - - - { + // No fixture may reach a production API, websocket, analytics or mail provider. + await context.routeWebSocket("**/*", (socket) => socket.close()) + await context.route("**/*", async (route) => { + const url = new URL(route.request().url()) + if (url.pathname.startsWith("/v1/")) { + const headers = { "access-control-allow-origin": "http://127.0.0.1:4173", "access-control-allow-headers": "*" } + if (route.request().method() === "OPTIONS") return route.fulfill({ status: 204, headers }) + if (url.pathname === "/v1/sendEmailCode") { + expect(url.searchParams.get("email")).toBe("ci@example.invalid") + return route.fulfill({ headers, json: { ok: true, result: { challengeToken: "ci-challenge-1" } } }) + } + if (url.pathname === "/v1/verifyEmailCode") { + expect(url.searchParams.get("email")).toBe("ci@example.invalid") + expect(url.searchParams.get("challengeToken")).toBe("ci-challenge-1") + expect(url.searchParams.get("code")).toBe("123456") + return route.fulfill({ headers, json: { ok: true, result: { userId: 42, token: "42:ci-browser-token", user: { id: 42, firstName: "CI" } } } }) + } + return route.abort() + } + if (url.origin === "http://127.0.0.1:4173") return route.continue() + return route.abort() + }) +}) + +test("logged-out app hydrates and routes to the login flow", async ({ page }) => { + await page.goto("/app") + await expect(page).toHaveURL(/\/app\/login\/welcome$/) + await page.getByRole("link", { name: "Continue", exact: true }).click() + await expect(page).toHaveURL(/\/app\/login\/email$/) + await expect(page.getByRole("button", { name: "Continue", exact: true })).toBeDisabled() +}) + +test("email challenge reaches login and persists across reload while offline", async ({ page }) => { + await page.goto("/app/login/email") + await page.getByPlaceholder("Enter your email").fill("ci@example.invalid") + await page.getByRole("button", { name: "Continue", exact: true }).click() + await expect(page.getByText("We sent a code to")).toBeVisible() + await page.getByPlaceholder("Enter the code").fill("123456") + await page.getByRole("button", { name: "Verify", exact: true }).click() + await expect(page.getByText("Logged in as user 42")).toBeVisible() + await page.reload() + await expect(page.getByText("Logged in as user 42")).toBeVisible() + await expect(page.getByText("Chats", { exact: true })).toBeVisible() +}) + +test("missing code details cannot submit or resend", async ({ page }) => { + await page.goto("/app/login/code") + await expect(page.getByText("Missing login details. Please return to start again.")).toBeVisible() + await expect(page.getByPlaceholder("Enter the code")).toBeDisabled() + await expect(page.getByRole("button", { name: "Verify", exact: true })).toBeDisabled() + await expect(page.getByRole("button", { name: "Resend code", exact: true })).toBeDisabled() +}) + +test("legacy browser credentials migrate and remain authenticated after reload", async ({ page }) => { + await page.goto("/app/login/email") + await page.evaluate(() => { + localStorage.setItem("auth-store:token", "42:ci-legacy-token") + localStorage.setItem("auth-store:user-id", "42") + }) + await page.goto("/app") + await expect(page.getByText("Logged in as user 42")).toBeVisible() + expect(await page.evaluate(() => localStorage.getItem("auth-store:token"))).toBeNull() + await page.reload() + await expect(page.getByText("Logged in as user 42")).toBeVisible() +}) diff --git a/landing/package.json b/landing/package.json index d704ee307..57ae8f144 100644 --- a/landing/package.json +++ b/landing/package.json @@ -11,6 +11,7 @@ "typecheck": "tsc -p tsconfig.json --noEmit", "lint": "oxlint --ignore-path ../.oxlintignore src packages vite.config.ts vitest.config.ts", "test": "vitest run", + "test:e2e": "playwright test", "test:watch": "vitest" }, "dependencies": { @@ -35,6 +36,7 @@ "@inline/config": "workspace:*" }, "devDependencies": { + "@playwright/test": "1.63.0", "@stylexjs/unplugin": "0.19.1", "@types/node": "catalog:", "@types/react": "catalog:", diff --git a/landing/packages/client/src/realtime/__tests__/realtime.test.ts b/landing/packages/client/src/realtime/__tests__/realtime.test.ts index 46b9a3ddb..5ae4e16a2 100644 --- a/landing/packages/client/src/realtime/__tests__/realtime.test.ts +++ b/landing/packages/client/src/realtime/__tests__/realtime.test.ts @@ -8,6 +8,7 @@ import { import { chatId, dialogId, messageId, userId } from "@inline/ids" import { AuthStore, + MemoryAuthSessionPersistence, Db, DbObjectKind, DbQueryPlanType, @@ -166,6 +167,42 @@ describe("realtime connection flow", () => { expect(client.connection.state).toBe("stopped") }) + it.each([ + ConnectionError_Reason.UNAUTHORIZED, + ConnectionError_Reason.REASON_UNSPECIFIED, + ConnectionError_Reason.INVALID_AUTH, + ])("preserves stored credentials for non-revocation connection errors (%s)", async (reason) => { + const transport = new MockTransport() + const storage = new MemoryAuthSessionPersistence() + const auth = new AuthStore({ storage }) + const logout = vi.spyOn(auth, "logout") + const session = { token: "valid-token", userId: userId(1) } + const client = new RealtimeClient({ + auth, db: new Db({ autoHydrate: false, persistence: false }), transport, + url: "ws://example.test", sync: false, + connection: { backoffDelayMs: () => 10 }, + }) + const starts = vi.spyOn(transport, "start") + try { + await client.startSession(session) + await transport.connect() + await transport.emitMessage(ServerProtocolMessage.create({ body: { + oneofKind: "connectionError", connectionError: { reason }, + } })) + if (reason === ConnectionError_Reason.INVALID_AUTH) { + await waitFor(() => client.connection.state === "stopped") + } else { + await waitFor(() => starts.mock.calls.length === 2) + } + expect(logout).not.toHaveBeenCalled() + expect(auth.isLoggedIn()).toBe(true) + expect(await storage.load()).toEqual({ status: "ready", session }) + } finally { + await client.stop() + auth.dispose() + } + }) + it("executes getMe transaction and updates db", async () => { const transport = new MockTransport() const auth = new AuthStore() diff --git a/landing/packages/client/src/realtime/connection/connection-manager.ts b/landing/packages/client/src/realtime/connection/connection-manager.ts index 27fcf31f5..ab699c4d6 100644 --- a/landing/packages/client/src/realtime/connection/connection-manager.ts +++ b/landing/packages/client/src/realtime/connection/connection-manager.ts @@ -1,4 +1,5 @@ import { Log, type LogLevel } from "@inline/log" +import { ConnectionError_Reason } from "@inline-chat/protocol/core" import { AsyncChannel } from "../../utils/async-channel" import type { ProtocolClient } from "../client/protocol-client" import type { ClientEvent } from "../types" @@ -287,6 +288,14 @@ export class ConnectionManager { return case "connectionError": + // The server uses UNAUTHORIZED for transient authentication failures too. + if ( + event.reason === ConnectionError_Reason.UNAUTHORIZED || + event.reason === ConnectionError_Reason.REASON_UNSPECIFIED + ) { + await this.scheduleReconnect(`server-auth-${event.reason}`) + return + } this.authAvailable = false await this.handleConstraintLoss( `server-auth-${event.reason}`, diff --git a/landing/packages/client/src/realtime/realtime.ts b/landing/packages/client/src/realtime/realtime.ts index 9c58ccba5..b9849bdb7 100644 --- a/landing/packages/client/src/realtime/realtime.ts +++ b/landing/packages/client/src/realtime/realtime.ts @@ -1,4 +1,4 @@ -import type { ConnectionInit, RpcError, RpcResult } from "@inline-chat/protocol/core" +import { ConnectionError_Reason, type ConnectionInit, type RpcError, type RpcResult } from "@inline-chat/protocol/core" import { parseInlineId, protocolId, @@ -888,7 +888,9 @@ export class RealtimeClient { this.log.warn("realtime.auth.invalidated", { reason: event.reason, }) - void this.stopSession().catch((error: unknown) => { + // Only the explicit revocation signal may discard durable credentials. + const stop = event.reason === ConnectionError_Reason.SESSION_REVOKED ? this.stopSession() : this.stop() + void stop.catch((error: unknown) => { this.log.error("realtime.auth.stop_failed", { error }) }) break diff --git a/landing/playwright.config.ts b/landing/playwright.config.ts new file mode 100644 index 000000000..e41a94d4a --- /dev/null +++ b/landing/playwright.config.ts @@ -0,0 +1,24 @@ +import { defineConfig, devices } from "@playwright/test" + +export default defineConfig({ + testDir: "./e2e", + fullyParallel: true, + forbidOnly: Boolean(process.env.CI), + retries: 0, + workers: 2, + reporter: [["list"], ["html", { open: "never" }]], + use: { + baseURL: "http://127.0.0.1:4173", + trace: "retain-on-failure", + screenshot: "only-on-failure", + serviceWorkers: "block", + }, + projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], + webServer: { + command: "bun --no-env-file .output/server/index.mjs", + env: { PORT: "4173", HOST: "127.0.0.1" }, + url: "http://127.0.0.1:4173", + reuseExistingServer: false, + gracefulShutdown: { signal: "SIGTERM", timeout: 5_000 }, + }, +}) diff --git a/landing/src/routes/__root.tsx b/landing/src/routes/__root.tsx index 94066df87..e84132af4 100644 --- a/landing/src/routes/__root.tsx +++ b/landing/src/routes/__root.tsx @@ -6,6 +6,7 @@ import { type ReactNode, useState } from "react" import { createRootRoute, HeadContent, Outlet, Scripts, useRouterState } from "@tanstack/react-router" import { AuthStore, + BrowserAuthSessionPersistence, Db, InlineClientProvider, RealtimeClient, @@ -68,7 +69,11 @@ function RootComponent() { function AppRoot() { const [client] = useState(() => { - const auth = new AuthStore() + const auth = new AuthStore( + typeof window === "undefined" ? undefined : { + storage: new BrowserAuthSessionPersistence("auth-store"), + }, + ) const db = new Db() return { auth, diff --git a/landing/src/routes/app/login/code.tsx b/landing/src/routes/app/login/code.tsx index 251b3eb37..2eff2ca65 100644 --- a/landing/src/routes/app/login/code.tsx +++ b/landing/src/routes/app/login/code.tsx @@ -92,7 +92,7 @@ function RouteComponent() { return } - login({ token: result.token, userId: userId(result.userId) }) + await login({ token: result.token, userId: userId(result.userId) }) ApiClient.setToken(result.token) await navigate({ to: "/app" }) } catch (error) { diff --git a/packages/mcp/src/server/mcp/server.test.ts b/packages/mcp/src/server/mcp/server.test.ts index d272f1444..7048062be 100644 --- a/packages/mcp/src/server/mcp/server.test.ts +++ b/packages/mcp/src/server/mcp/server.test.ts @@ -1963,3 +1963,60 @@ describe("mcp tool server", () => { expect(JSON.stringify(audit)).not.toContain("hi") }) }) + +describe("submission-v2 authorization boundary", () => { + const calls = [ + ["spaces.list", {}], + ["people.search", {}], + ["conversations.list", {}], + ["conversations.get", { chatId: "7" }], + ["conversations.create", { title: "Test", spaceId: "10" }], + ["files.upload", { sourceType: "base64", source: "aGVsbG8=", fileName: "hello.txt", contentType: "text/plain" }], + ["files.get", { chatId: "7", messageIds: ["44"] }], + ["messages.list", { chatId: "7" }], + ["messages.search", { chatId: "7", query: "invoice" }], + ["messages.context", { chatId: "7", anchorMessageId: "44" }], + ["messages.unread", {}], + ["messages.send", { chatId: "7", text: "hello" }], + ["messages.send_media", { chatId: "7", mediaKind: "photo", mediaId: "501" }], + ["messages.send_batch", { chatId: "7", items: [{ type: "text", content: "hello" }] }], + ] as const + + it.each(calls)("%s rejects missing scopes before accessing Inline", async (name, args) => { + const inline = createInlineStub({}) + const accesses = Object.entries(inline).filter(([method]) => method !== "close") + .map(([method]) => vi.spyOn(inline, method as keyof InlineApi)) + const server = createInlineMcpServer({ inline, grant, contractVersion: "submission-v2" }) + try { + const authInfo = createAuthInfo([]) + const { transport, sent } = await connectAndInitialize(server, authInfo) + await sendRequest(transport, { jsonrpc: "2.0", id: 2, method: "tools/call", params: { name, arguments: args } } as any, { authInfo }) + const response = await waitForResponse(sent, 2) + expect(response.result.isError).toBe(true) + expect(response.result.content[0].text).toMatch(/scope/i) + for (const access of accesses) expect(access).not.toHaveBeenCalled() + } finally { + await server.close() + vi.restoreAllMocks() + } + }) + + it("surfaces a transport failure and audits it without reporting a successful send", async () => { + const info = vi.spyOn(console, "info").mockImplementation(() => {}) + const sendMessage = vi.fn().mockRejectedValue(new Error("Inline transport unavailable")) + const server = createInlineMcpServer({ inline: createInlineStub({ sendMessage }), grant, contractVersion: "submission-v2" }) + try { + const authInfo = createAuthInfo(["messages:write"]) + const { transport, sent } = await connectAndInitialize(server, authInfo) + await sendRequest(transport, { jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "messages.send", arguments: { chatId: "7", text: "hello" } } } as any, { authInfo }) + const response = await waitForResponse(sent, 2) + expect(response.result.isError).toBe(true) + expect(response.result.content[0].text).toContain("Inline transport unavailable") + expect(sendMessage).toHaveBeenCalledTimes(1) + expect(lastMessagesSendAuditRecord(info)).toMatchObject({ outcome: "failure", chatId: "7", messageId: null }) + } finally { + await server.close() + vi.restoreAllMocks() + } + }) +}) diff --git a/packages/mcp/vitest.config.ts b/packages/mcp/vitest.config.ts index 057bf88f5..914dcbe68 100644 --- a/packages/mcp/vitest.config.ts +++ b/packages/mcp/vitest.config.ts @@ -12,7 +12,8 @@ export default defineConfig({ exclude: ["src/**/*.test.ts", "src/server/inline/inline-api.ts", "dist/**"], // Keep this reasonably high while the package is under heavy construction. // Tighten back toward 100% once the endpoint surface stabilizes. - thresholds: { + // CI publishes coverage now; the default coverage command retains the strict gate. + thresholds: process.env.MCP_COVERAGE_REPORT_ONLY === "1" ? undefined : { lines: 95, functions: 95, statements: 90, diff --git a/scripts/ci/README.md b/scripts/ci/README.md new file mode 100644 index 000000000..3e07d5934 --- /dev/null +++ b/scripts/ci/README.md @@ -0,0 +1,58 @@ +# CI validation + +`Apple validation required`, `Integrations required`, and `Server validation required` +are stable aggregate checks. They fail when any selected dependency fails, is +cancelled, or is unexpectedly skipped. Their dependency inventories are tested +against the workflow YAML. Configure these as required checks **after this PR is +merged and each has completed on main**; path-filtered CLI jobs are unsuitable +as universal required checks. + +Apple runs all five lanes on main and manual dispatches. On PRs, only changes +entirely within known unrelated directories may skip the Apple lanes. Changes to +Apple, canonical protocol and trust roots, scripts, workflows, root JS manifests, +unknown paths, or unavailable diffs select all five. The selector writes its +decision to the Actions summary. + +Rust workspace CI owns formatting, all-target compilation, tests, and Clippy. CLI +Build adds AMD64/ARM64 musl executable and installer checks, plus native ARM64 CLI +tests to retain architecture coverage. Server Tests owns the +TypeScript protocol drift check; the previous duplicate workflows are preserved +under `.github/disabled-workflows`. + +The assembled integration lane verifies all candidate hashes and the source SHA. +It tests Bot HTTP message bodies, SDK recipient WebSocket delivery, idempotent +sends, history on a new connection, Chat SDK real-update dispatch and persisted +replies, duplicate webhook delivery, webhook authentication, compiled MCP +send/history, MCP grant revocation, and the existing real Hermes host round trip. +Scenario receipts and the candidate manifest are retained for seven days. OAuth +introspection is synthetic and Chat SDK webhook delivery is driven by the fixture; +this does not qualify the production OAuth issuer or webhook worker. + +Hermes and OpenClaw scheduled compatibility checks exercise published packages +against moving hosts independently of source changes. OpenClaw admission proves +installation and registration; an actual OpenClaw host message handler against +Inline remains a separate qualification gap. + +Native app builds and Swift package tests do not establish UI acceptance. Shared +scheme references are checked for missing targets. `Inline Message Tests` remains +the existing iOS device test scheme; no app UI test targets currently exist. +Before accepting a native release, use a physical iOS device and the macOS app to +check sign-in, send/reply, reconnect, logout only after explicit token revocation, +and correct message identity after optimistic-to-confirmed reconciliation. Record +the build SHA and test outcome. Do not substitute simulator or unsigned build +success for this acceptance. + +Chromium tests run against the built browser app: logged-out routing, email code +challenge handling, persisted login across a reload with realtime offline, and +legacy-session migration and missing login details. API responses are controlled, +all external HTTP is blocked, and WebSockets are intercepted. These qualify browser UI/auth persistence rather +than server authentication or a chat composer, which the current shell lacks. +Failure traces/screenshots and the HTML report are retained for seven days. + +MCP coverage is collected and published on every CI run. Assertions remain a merge +gate; coverage percentages are explicitly informational through +`MCP_COVERAGE_REPORT_ONLY=1`. The default coverage command still enforces the +existing 95% line/function thresholds. They are currently unmet; enabling that +threshold gate in CI requires further focused tests, especially remote-file +transport branches. The suite now also verifies every v2 tool's missing-scope +boundary and failed-send reporting. diff --git a/scripts/ci/apple-schemes.test.ts b/scripts/ci/apple-schemes.test.ts new file mode 100644 index 000000000..5b103667d --- /dev/null +++ b/scripts/ci/apple-schemes.test.ts @@ -0,0 +1,17 @@ +import { expect, it } from "bun:test" +import { readFileSync, readdirSync } from "node:fs" +import path from "node:path" + +it("every shared scheme build and test reference resolves to an existing project target", () => { + const project = path.resolve(import.meta.dir, "../../apple/Inline.xcodeproj") + const source = readFileSync(path.join(project, "project.pbxproj"), "utf8") + const targets = [...source.matchAll(/([A-F0-9]{24}) \/\*[^\n]+\*\/ = \{\s*isa = PBX(?:Native|Aggregate)Target;/g)].map((match) => match[1]) + expect(targets.length).toBeGreaterThan(0) + const schemes = path.join(project, "xcshareddata/xcschemes") + for (const name of readdirSync(schemes).filter((name) => name.endsWith(".xcscheme"))) { + const scheme = readFileSync(path.join(schemes, name), "utf8") + for (const match of scheme.matchAll(/BlueprintIdentifier\s*=\s*"([A-F0-9]{24})"/g)) { + expect(targets, `${name}: ${match[1]} must resolve to an actual target`).toContain(match[1]) + } + } +}) diff --git a/scripts/ci/check-mcp-assembled.mjs b/scripts/ci/check-mcp-assembled.mjs index db847a6d6..c78e2da95 100644 --- a/scripts/ci/check-mcp-assembled.mjs +++ b/scripts/ci/check-mcp-assembled.mjs @@ -1,23 +1,27 @@ import assert from "node:assert/strict" import path from "node:path" +import { writeFile } from "node:fs/promises" import { fileURLToPath } from "node:url" const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..") const { createApp } = await import(path.join(root, "packages/mcp/dist/index.js")) +let active = true +const realServer = Boolean(process.env.INLINE_E2E_BASE_URL) +const scenarios = [] const introspection = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => Response.json({ - active: true, grant_id: "ci-grant", client_id: "ci-client", scope: "messages:read spaces:read messages:write", + active, grant_id: "ci-grant", client_id: "ci-client", scope: "messages:read spaces:read messages:write", aud: "http://127.0.0.1:8791", exp: Math.floor(Date.now() / 1000) + 3600, - inline_user_id: "1", space_ids: [], allow_dms: false, allow_home_threads: false, - inline_token: "1:ci-local-token", + inline_user_id: process.env.INLINE_E2E_BOT_ID ?? "1", space_ids: [], allow_dms: realServer, allow_home_threads: false, + inline_token: process.env.INLINE_E2E_TOKEN ?? "1:ci-local-token", }), }) let server try { const app = createApp({ issuer: "http://127.0.0.1:8791", - inlineApiBaseUrl: "http://127.0.0.1:8792", + inlineApiBaseUrl: process.env.INLINE_E2E_BASE_URL ?? "http://127.0.0.1:8792", oauthIssuer: "http://127.0.0.1:8791", oauthProxyBaseUrl: "http://127.0.0.1:8791", oauthIntrospectionUrl: `http://127.0.0.1:${introspection.port}/introspect`, @@ -49,7 +53,40 @@ try { }) assert.equal(tools.status, 200) assert.match(await tools.text(), /tools/) - console.log("Compiled MCP app initialized and listed tools with synthetic local introspection") + let id = 2 + const request = async (method, params) => fetch(endpoint, { + method: "POST", headers: { ...headers, "mcp-session-id": session }, signal: AbortSignal.timeout(15_000), + body: JSON.stringify({ jsonrpc: "2.0", id: ++id, method, ...(params ? { params } : {}) }), + }) + const call = async (name, args) => { + const response = await request("tools/call", { name, arguments: args }) + assert.equal(response.status, 200) + const text = await response.text() + const data = text.split("\n").find((line) => line.startsWith("data: ")) + assert.ok(data, "MCP response must contain an SSE result") + const result = JSON.parse(data.slice(6)).result + assert.ok(result && !result.isError, `MCP ${name} must succeed`) + return result.structuredContent + } + assert.equal((await call("account.me", {})).user.id, process.env.INLINE_E2E_BOT_ID ?? "1") + scenarios.push("mcp-assembled-account-context") + if (realServer) { + const chatId = process.env.INLINE_E2E_CHAT_ID + assert.match(chatId ?? "", /^[1-9]\d*$/) + const sent = await call("messages.send", { chatId, text: "ci-mcp-persisted-message" }) + assert.equal(sent.ok, true) + assert.match(sent.messageId, /^[1-9]\d*$/) + const history = await call("messages.list", { chatId, limit: 20 }) + assert.ok(history.messages.some((message) => message.id === sent.messageId && message.text === "ci-mcp-persisted-message")) + scenarios.push("mcp-real-server-send-and-history") + } + active = false + assert.equal((await request("tools/list")).status, 401, "revoked grant must reject an existing MCP session") + scenarios.push("mcp-revoked-grant-existing-session") + active = true + assert.equal((await fetch(endpoint, { method: "DELETE", headers: { ...headers, "mcp-session-id": session }, signal: AbortSignal.timeout(10_000) })).status, 200) + if (process.env.INLINE_E2E_RECEIPT) await writeFile(process.env.INLINE_E2E_RECEIPT, JSON.stringify({ sourceSha: process.env.GITHUB_SHA, scenarios: scenarios.map((scenario) => ({ scenario, status: "passed" })) }, null, 2) + "\n") + console.log(`Compiled MCP passed ${scenarios.length} scenarios with synthetic local introspection`) } finally { server?.stop(true) introspection.stop(true) diff --git a/scripts/ci/local-bot-flow.mjs b/scripts/ci/local-bot-flow.mjs index 6ab978d9c..06c9654c5 100644 --- a/scripts/ci/local-bot-flow.mjs +++ b/scripts/ci/local-bot-flow.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict" import { execFileSync } from "node:child_process" -import { randomUUID } from "node:crypto" +import { createHash, randomUUID } from "node:crypto" import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises" import { createRequire } from "node:module" import os from "node:os" @@ -103,8 +103,14 @@ try { assert.equal(health.status, 200, `server readiness ${health.status}`) const manifest = JSON.parse(await readFile(path.join(artifactDir, "manifest.json"), "utf8")) + assert.equal(manifest.sourceSha, execFileSync("git", ["rev-parse", "HEAD"], { cwd: repoRoot, encoding: "utf8" }).trim(), "candidate source SHA") + assert.equal(manifest.packages.length, 7, "candidate inventory") + for (const pkg of manifest.packages) { + assert.equal(path.basename(pkg.file), pkg.file, "artifact filename must stay within candidate directory") + assert.equal(createHash("sha256").update(await readFile(path.join(artifactDir, pkg.file))).digest("hex"), pkg.sha256, `${pkg.name} candidate hash`) + } const names = ["@inline-chat/protocol", "@inline-chat/bot-api-types", "@inline-chat/bot-client", "@inline-chat/realtime-sdk", "@inline-chat/chat-sdk", "@inline-chat/hermes-agent-adapter"] - const dependencies = { chat: "4.40.0" } + const dependencies = { chat: "4.40.0", "@chat-adapter/state-memory": "4.40.0" } for (const name of names) { const pkg = manifest.packages.find((entry) => entry.name === name) assert.ok(pkg, `missing ${name} candidate`) @@ -116,38 +122,19 @@ try { cwd: consumer, stdio: "inherit", timeout: 180_000, }) const flow = path.join(consumer, "flow.mjs") - await writeFile(flow, ` -import assert from 'node:assert/strict' -import { InlineBotClient } from '@inline-chat/bot-client' -import { InlineSdkClient } from '@inline-chat/realtime-sdk' -import { InlineAdapter } from '@inline-chat/chat-sdk' -const bot = new InlineBotClient({ token: process.env.INLINE_E2E_TOKEN, baseUrl: process.env.INLINE_E2E_BASE_URL }) -const me = await bot.getMe() -assert.equal(me.ok, true) -assert.equal(me.result.user.is_bot, true) -const target = { user_id: Number(process.env.INLINE_E2E_HUMAN_ID) } -const chat = await bot.getChat(target) -assert.equal(chat.ok, true) -const sent = await bot.sendMessage({ ...target, text: 'ci-packed-bot-round-trip' }) -assert.equal(sent.ok, true) -assert.ok(sent.result.message.message_id > 0) -const history = await bot.getMessages({ chat_id: chat.result.chat.chat_id, message_ids: [sent.result.message.message_id] }) -assert.equal(history.ok, true) -assert.equal(history.result.messages[0].message_id, sent.result.message.message_id) -const adapter = new InlineAdapter({ token: process.env.INLINE_E2E_TOKEN, webhookSecret: 'ci-secret', baseUrl: process.env.INLINE_E2E_BASE_URL }) -await adapter.initialize({ getUserName: () => 'ci-bot' }) -assert.equal(adapter.botUserId, String(me.result.user.id)) -const sdk = new InlineSdkClient({ token: process.env.INLINE_E2E_TOKEN, baseUrl: process.env.INLINE_E2E_BASE_URL }) -try { - await sdk.connect() - const sdkMe = await sdk.getMe() - assert.equal(sdkMe.userId, BigInt(me.result.user.id)) -} finally { await sdk.close() } -console.log('Packed Bot Client, Chat SDK adapter, and realtime SDK reached the local Inline server') -`) + await writeFile(flow, await readFile(path.join(repoRoot, "scripts/ci/local-packed-flow.mjs"))) execFileSync("bun", ["--no-env-file", flow], { cwd: consumer, stdio: "inherit", timeout: 60_000, - env: { ...process.env, INLINE_E2E_BASE_URL: baseUrl, INLINE_E2E_TOKEN: token, INLINE_E2E_HUMAN_ID: String(human.id) }, + env: { ...process.env, INLINE_E2E_BASE_URL: baseUrl, INLINE_E2E_TOKEN: token, INLINE_E2E_HUMAN_ID: String(human.id), + INLINE_E2E_BOT_ID: String(bot.id), INLINE_E2E_HUMAN_TOKEN: humanToken, + INLINE_E2E_RECEIPT: path.join(artifactDir, "local-flow-receipt.json"), INLINE_E2E_SOURCE_SHA: manifest.sourceSha }, + }) + const flowReceipt = JSON.parse(await readFile(path.join(artifactDir, "local-flow-receipt.json"), "utf8")) + execFileSync("bun", ["--no-env-file", path.join(repoRoot, "scripts/ci/check-mcp-assembled.mjs")], { + cwd: repoRoot, stdio: "inherit", timeout: 60_000, + env: { ...process.env, INLINE_E2E_BASE_URL: baseUrl, INLINE_E2E_TOKEN: token, + INLINE_E2E_BOT_ID: String(bot.id), INLINE_E2E_CHAT_ID: String(flowReceipt.chatId), + INLINE_E2E_RECEIPT: path.join(artifactDir, "mcp-flow-receipt.json") }, }) const hermesHome = path.join(consumer, "hermes-home") await mkdir(hermesHome) @@ -167,6 +154,8 @@ console.log('Packed Bot Client, Chat SDK adapter, and realtime SDK reached the l runHermes(hermesBin, ["plugins", "enable", "inline-platform"]) await writeFile(path.join(consumer, "hermes-human.mjs"), await readFile(path.join(repoRoot, "scripts/ci/hermes-local-human.mjs"))) runHermes(hermesPython, [path.join(repoRoot, "scripts/ci/hermes-local-flow.py")]) + await writeFile(path.join(artifactDir, "hermes-flow-receipt.json"), JSON.stringify({ sourceSha: manifest.sourceSha, + scenarios: [{ scenario: "hermes-real-host-inbound-and-persisted-reply", status: "passed" }] }, null, 2) + "\n") await closeDb() closeDb = undefined await stopServer() diff --git a/scripts/ci/local-packed-flow.mjs b/scripts/ci/local-packed-flow.mjs new file mode 100644 index 000000000..94dfdab2f --- /dev/null +++ b/scripts/ci/local-packed-flow.mjs @@ -0,0 +1,107 @@ +// Copied into an isolated npm consumer: imports must resolve to candidate tarballs. +import assert from "node:assert/strict" +import { writeFile } from "node:fs/promises" +import { InlineBotClient } from "@inline-chat/bot-client" +import { InlineSdkClient } from "@inline-chat/realtime-sdk" +import { InlineAdapter } from "@inline-chat/chat-sdk" +import { Chat } from "chat" +import { createMemoryState } from "@chat-adapter/state-memory" + +const baseUrl = process.env.INLINE_E2E_BASE_URL +const humanId = Number(process.env.INLINE_E2E_HUMAN_ID) +const botId = Number(process.env.INLINE_E2E_BOT_ID) +const bot = new InlineBotClient({ token: process.env.INLINE_E2E_TOKEN, baseUrl }) +const receipts = [] +let chatId +const record = (scenario) => receipts.push({ scenario, status: "passed" }) +const waitFor = async (read, label) => { + const deadline = Date.now() + 15_000 + while (Date.now() < deadline) { + const value = await read() + if (value) return value + await new Promise((resolve) => setTimeout(resolve, 20)) + } + throw new Error(`${label} timed out`) +} +let receiver, human, chat +try { + const me = await bot.getMe() + assert.equal(me.ok, true) + assert.equal(me.result.user.is_bot, true) + assert.equal(me.result.user.id, botId) + // Projection begins when the bot creates its polling stream, before messages arrive. + assert.equal((await bot.getUpdates({ timeout: 0 })).ok, true) + const conversation = await bot.getChat({ user_id: humanId }) + assert.equal(conversation.ok, true) + chatId = conversation.result.chat.chat_id + const sent = await bot.sendMessage({ user_id: humanId, text: "ci-packed-bot-round-trip" }) + assert.equal(sent.ok, true) + const history = await bot.getMessages({ chat_id: chatId, message_ids: [sent.result.message.message_id] }) + assert.equal(history.ok, true) + assert.equal(history.result.messages[0].text, "ci-packed-bot-round-trip") + record("bot-http-message-body") + + const received = [] + receiver = new InlineSdkClient({ token: process.env.INLINE_E2E_TOKEN, baseUrl }) + const consume = (async () => { for await (const event of receiver.events()) received.push(event) })() + await receiver.connect() + assert.equal((await receiver.getMe()).userId, BigInt(botId)) + human = new InlineSdkClient({ token: process.env.INLINE_E2E_HUMAN_TOKEN, baseUrl }) + const humanReceived = [] + const humanConsumer = (async () => { for await (const event of human.events()) humanReceived.push(event) })() + await human.connect() + const input = { userId: botId, text: "ci-packed-sdk-inbound", randomId: 734901n } + const inbound = await human.sendMessage(input) + assert.ok(inbound.messageId) + const delivered = await waitFor(() => received.find((event) => event.kind === "message.new" && event.message.id === inbound.messageId), "SDK recipient message") + assert.equal(delivered.message.message, input.text) + record("sdk-recipient-websocket-message") + const repeated = await human.sendMessage(input) + assert.equal(repeated.messageId, inbound.messageId, "same idempotency key must return the persisted message") + const stored = await receiver.getMessages({ userId: humanId, messageIds: [inbound.messageId] }) + assert.equal(stored.messages.length, 1) + assert.equal(stored.messages[0].message, input.text) + record("sdk-idempotent-send") + await receiver.close() + await consume + receiver = new InlineSdkClient({ token: process.env.INLINE_E2E_TOKEN, baseUrl }) + await receiver.connect() + assert.equal((await receiver.getMessages({ userId: humanId, messageIds: [inbound.messageId] })).messages[0].message, input.text) + record("sdk-new-connection-persisted-history") + + const adapter = new InlineAdapter({ token: process.env.INLINE_E2E_TOKEN, webhookSecret: "ci-secret", baseUrl }) + chat = new Chat({ userName: "ci-bot", adapters: { inline: adapter }, state: createMemoryState(), logger: "silent" }) + let dispatches = 0 + chat.onDirectMessage(async (thread, message) => { + if (message.text !== "ci-chat-sdk-inbound") return + dispatches++ + await thread.post("ci-chat-sdk-persisted-reply") + }) + await chat.initialize() + const trigger = await human.sendMessage({ userId: botId, text: "ci-chat-sdk-inbound" }) + const update = await waitFor(async () => { + const updates = await bot.getUpdates({ timeout: 0, limit: 100 }) + assert.equal(updates.ok, true) + return updates.result.find((item) => item.message?.message_id === Number(trigger.messageId) && item.message?.text === "ci-chat-sdk-inbound") + }, "real Bot API update") + const webhook = (secret) => new Request("http://localhost/inline", { method: "POST", headers: { + "content-type": "application/json", "x-inline-bot-api-secret-token": secret, + }, body: JSON.stringify(update) }) + assert.equal((await adapter.handleWebhook(webhook("wrong-secret"))).status, 401) + assert.equal(dispatches, 0) + assert.equal((await adapter.handleWebhook(webhook("ci-secret"))).status, 200) + assert.equal((await adapter.handleWebhook(webhook("ci-secret"))).status, 200) + assert.equal(dispatches, 1, "retrying the same webhook must not run the host handler twice") + const reply = await waitFor(() => humanReceived.find((event) => event.kind === "message.new" && event.message.message === "ci-chat-sdk-persisted-reply"), "Chat SDK reply") + const replyHistory = await human.getMessages({ userId: botId, messageIds: [reply.message.id] }) + assert.equal(replyHistory.messages[0].message, "ci-chat-sdk-persisted-reply") + record("chat-sdk-real-update-handler-and-reply") + record("chat-sdk-webhook-secret-and-duplicate-delivery") + await human.close() + await humanConsumer +} finally { + await chat?.shutdown() + await receiver?.close() + await human?.close() + await writeFile(process.env.INLINE_E2E_RECEIPT, JSON.stringify({ sourceSha: process.env.INLINE_E2E_SOURCE_SHA, chatId, scenarios: receipts }, null, 2) + "\n") +} diff --git a/scripts/ci/pack-published-openclaw.mjs b/scripts/ci/pack-published-openclaw.mjs new file mode 100644 index 000000000..11acc685f --- /dev/null +++ b/scripts/ci/pack-published-openclaw.mjs @@ -0,0 +1,20 @@ +import { execFileSync } from "node:child_process" +import { createHash } from "node:crypto" +import { mkdir, readFile, writeFile } from "node:fs/promises" +import path from "node:path" + +const directory = path.resolve(process.argv[2] ?? "") +if (!process.argv[2]) throw new Error("usage: pack-published-openclaw.mjs OUTPUT_DIR") +await mkdir(directory, { recursive: true }) +const packages = [] +async function pack(spec) { + const packed = JSON.parse(execFileSync("npm", ["pack", spec, "--ignore-scripts", "--json", "--pack-destination", directory], { encoding: "utf8", timeout: 60_000 }))[0] + const archive = path.join(directory, packed.filename) + packages.push({ name: packed.name, version: packed.version, file: packed.filename, + sha256: createHash("sha256").update(await readFile(archive)).digest("hex") }) + return JSON.parse(execFileSync("tar", ["-xOzf", archive, "package/package.json"], { encoding: "utf8" })) +} +const plugin = await pack("@inline-openclaw/inline@latest") +const sdk = await pack(`@inline-chat/realtime-sdk@${plugin.dependencies["@inline-chat/realtime-sdk"]}`) +await pack(`@inline-chat/protocol@${sdk.dependencies["@inline-chat/protocol"]}`) +await writeFile(path.join(directory, "manifest.json"), JSON.stringify({ sourceSha: "published-npm", packages }, null, 2) + "\n") diff --git a/scripts/ci/validation-gates.mjs b/scripts/ci/validation-gates.mjs new file mode 100644 index 000000000..f5ab598f9 --- /dev/null +++ b/scripts/ci/validation-gates.mjs @@ -0,0 +1,59 @@ +import { execFileSync } from "node:child_process" +import { appendFileSync, readFileSync } from "node:fs" +import { pathToFileURL } from "node:url" + +export const validationJobs = { + apple: ["contracts", "swift-main", "swift-utilities", "macos-app", "ios-app"], + integrations: ["landing", "codex-plugin", "mcp", "openclaw", "openclaw-source", "hermes", "chat-sdk", "shared-packages", "rust-workspace", "workflow-and-release-contracts", "candidate-packages", "packed-consumers", "openclaw-admission", "hermes-admission", "local-integration"], + server: ["container", "test"], +} + +// Only known unrelated paths may skip Apple validation. Unknown inputs fail open +// to the full suite; main and manual runs always qualify every Apple lane. +export function selectAppleJobs(eventName, paths) { + if (eventName !== "pull_request" || !paths?.length) return validationJobs.apple + // Swift secure-transport tests consume canonical trust roots from this package. + if (paths.some((file) => file.startsWith("packages/protocol/"))) return validationJobs.apple + const unrelated = /^(server\/|landing\/|packages\/|plugins\/|cli\/|crates\/|vendor\/|skills\/|docs\/|admin\/|\.cargo\/|\.codex\/|\.agents\/|Cargo\.(toml|lock)$|rust-toolchain\.toml$|[^/]+\.md$)/ + return paths.every((file) => unrelated.test(file)) ? [] : validationJobs.apple +} + +export function checkResults(kind, needs, selected = validationJobs[kind]) { + const jobs = validationJobs[kind] + if (!jobs) throw new Error(`unknown validation workflow: ${kind}`) + if (!Array.isArray(selected) || new Set(selected).size !== selected.length || selected.some((job) => !jobs.includes(job))) { + throw new Error("invalid selected job inventory") + } + const expected = kind === "apple" ? ["changes", ...jobs] : jobs + if (JSON.stringify(Object.keys(needs).sort()) !== JSON.stringify([...expected].sort())) throw new Error("validation dependency inventory differs from policy") + for (const job of expected) { + const result = needs[job]?.result + const intentionalSkip = kind === "apple" && job !== "changes" && !selected.includes(job) + if (result !== "success" && !(intentionalSkip && result === "skipped")) throw new Error(`${job}: ${result ?? "missing result"}`) + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + if (process.argv[2] === "select-apple") { + let paths + try { + const event = JSON.parse(readFileSync(process.env.GITHUB_EVENT_PATH, "utf8")) + const { base, head } = event.pull_request ?? {} + if (/^[a-f0-9]{40}$/.test(base?.sha) && /^[a-f0-9]{40}$/.test(head?.sha)) { + paths = execFileSync("git", ["diff", "--name-only", "-z", "--no-renames", `${base.sha}...${head.sha}`], { encoding: "utf8" }).split("\0").filter(Boolean) + } + } catch { + console.log("Diff unavailable; selecting all Apple jobs") + } + const selected = selectAppleJobs(process.env.GITHUB_EVENT_NAME, paths) + appendFileSync(process.env.GITHUB_OUTPUT, `selected=${JSON.stringify(selected)}\nrun=${selected.length > 0}\n`) + const summary = `Apple validation: ${selected.length ? "all five lanes selected" : "known unrelated PR paths; five lanes skipped"}. Changed paths: ${paths?.length ?? "unavailable"}.\n` + console.log(summary) + if (process.env.GITHUB_STEP_SUMMARY) appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary) + } else { + const kind = process.argv[2] + const selected = process.env.SELECTED_JOBS === undefined ? validationJobs[kind] : JSON.parse(process.env.SELECTED_JOBS) + checkResults(kind, JSON.parse(process.env.RESULTS_JSON ?? "{}"), selected) + console.log(`${kind}: every selected validation job passed`) + } +} diff --git a/scripts/ci/validation-gates.test.ts b/scripts/ci/validation-gates.test.ts new file mode 100644 index 000000000..5303546f0 --- /dev/null +++ b/scripts/ci/validation-gates.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "bun:test" +import { readFileSync } from "node:fs" +import path from "node:path" +import { parse } from "yaml" +import { checkResults, selectAppleJobs, validationJobs } from "./validation-gates.mjs" + +describe("Apple selection", () => { + it("always qualifies main, manual runs, missing and empty diffs", () => { + for (const event of ["push", "workflow_dispatch", "pull_request"]) { + expect(selectAppleJobs(event, undefined)).toEqual(validationJobs.apple) + expect(selectAppleJobs(event, [])).toEqual(validationJobs.apple) + } + expect(selectAppleJobs("push", ["server/src/index.ts"])).toEqual(validationJobs.apple) + }) + it("skips only known unrelated PR paths", () => { + expect(selectAppleJobs("pull_request", ["server/src/index.ts", "plugins/hermes-agent/package.json", "cli/src/main.rs", "README.md"])).toEqual([]) + for (const file of ["apple/InlineKit/Package.swift", "proto/core.proto", "packages/protocol/trust-roots/inline-protocol-production.json", "scripts/ci/validation-gates.mjs", "scripts/apple/build-ci-app.sh", ".github/workflows/apple-validation.yml", "bun.lock", "package.json", "new-system/source.ts"]) { + expect(selectAppleJobs("pull_request", ["server/src/index.ts", file]), file).toEqual(validationJobs.apple) + } + }) +}) + +describe("required validation results", () => { + for (const [kind, jobs] of Object.entries(validationJobs)) { + const needs = Object.fromEntries([...(kind === "apple" ? ["changes"] : []), ...jobs].map((job) => [job, { result: "success" }])) + it(`${kind}: matches the entire workflow dependency inventory`, () => { + const filename = { apple: "apple-validation", integrations: "integrations", server: "server-test" }[kind] + const workflow = parse(readFileSync(path.resolve(import.meta.dir, `../../.github/workflows/${filename}.yml`), "utf8")) + expect(Object.keys(workflow.jobs).filter((job) => job !== "required").sort()).toEqual(Object.keys(needs).sort()) + expect([...workflow.jobs.required.needs].sort()).toEqual(Object.keys(needs).sort()) + expect(workflow.jobs.required.if).toBe("${{ always() }}") + expect(() => checkResults(kind, needs)).not.toThrow() + }) + it(`${kind}: rejects a failed, cancelled, skipped or missing selected job`, () => { + for (const job of Object.keys(needs)) { + for (const result of ["failure", "cancelled", "skipped", undefined]) { + expect(() => checkResults(kind, { ...needs, [job]: { result } })).toThrow() + } + } + expect(() => checkResults(kind, { ...needs, unknown: { result: "success" } })).toThrow() + }) + } + it("accepts Apple skips only with a successful selector and an explicit valid plan", () => { + const skipped = Object.fromEntries(validationJobs.apple.map((job) => [job, { result: "skipped" }])) + const needs = { changes: { result: "success" }, ...skipped } + expect(() => checkResults("apple", needs, [])).not.toThrow() + expect(() => checkResults("apple", needs, ["contracts"])).toThrow() + expect(() => checkResults("apple", needs, ["unknown"])).toThrow() + expect(() => checkResults("apple", { ...needs, changes: { result: "failure" } }, [])).toThrow() + expect(() => checkResults("apple", { ...needs, contracts: { result: "cancelled" } }, [])).toThrow() + }) +}) diff --git a/scripts/ci/workflow-policy.test.ts b/scripts/ci/workflow-policy.test.ts index 2a7d0f1eb..79b1d51a9 100644 --- a/scripts/ci/workflow-policy.test.ts +++ b/scripts/ci/workflow-policy.test.ts @@ -36,7 +36,7 @@ describe("public CI contracts", () => { it("keeps all selected package and app gates visible", () => { const apple = workflow("apple-validation.yml") - expect(Object.keys(apple.jobs).sort()).toEqual(["contracts", "ios-app", "macos-app", "swift-main", "swift-utilities"]) + expect(Object.keys(apple.jobs).sort()).toEqual(["changes", "contracts", "ios-app", "macos-app", "required", "swift-main", "swift-utilities"]) const source = read(".github/workflows/apple-validation.yml") for (const pkg of ["InlineKit", "InlineUI", "InlineIOSUI", "InlineMacUI", "InlineRealtimeCore", "InlineMacSidebarModel", "InlineThumbnailing", "InlineSyntaxHighlighting", "InlineMacScripting", @@ -50,6 +50,16 @@ describe("public CI contracts", () => { } }) + it("retains ARM64 native CLI tests while the workspace owns AMD64 tests", () => { + const cli = read(".github/workflows/cli-check.yml") + expect(cli).toContain("dtolnay/rust-toolchain@1.96.0") + expect(cli).toContain(". -> target") + expect(cli).toContain("if: matrix.target == 'aarch64-unknown-linux-musl'") + expect(cli).toContain("cargo test --locked --profile ci-test") + expect(read(".github/workflows/integrations.yml")).toContain("cargo test --workspace --all-targets --locked --profile ci-test") + expect(workflow("integrations.yml").jobs.cli).toBeUndefined() + }) + it("does not expose publication workflows to pull requests", () => { for (const name of ["npm-publish.yml", "cli-release.yml", "server-deploy.yml", "macos-tip-nightly.yml", "ios-early-testers.yml"]) { expect(workflow(name).on.pull_request, name).toBeUndefined()