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()