diff --git a/.github/dependabot.yml b/.github/dependabot.yml index b3ea5c483887..7b492ca70468 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -70,6 +70,22 @@ updates: - "minor" - "patch" + - package-ecosystem: "gradle" + directory: "/autogpt_platform/mobile/android" + schedule: + interval: "monthly" + open-pull-requests-limit: 3 + target-branch: "dev" + commit-message: + prefix: "dx(platform)" + groups: + android-minor-and-patch: + patterns: + - "*" + update-types: + - "minor" + - "patch" + # infra (Terraform) - package-ecosystem: "terraform" directory: "autogpt_platform/infra" diff --git a/.github/workflows/platform-mobile-ci.yml b/.github/workflows/platform-mobile-ci.yml new file mode 100644 index 000000000000..fd48bc4fbb77 --- /dev/null +++ b/.github/workflows/platform-mobile-ci.yml @@ -0,0 +1,156 @@ +name: AutoGPT Platform - Mobile CI + +on: + pull_request: + paths: + - ".github/workflows/platform-mobile-ci.yml" + - "autogpt_platform/mobile/**" + push: + branches: [dev, master] + paths: + - ".github/workflows/platform-mobile-ci.yml" + - "autogpt_platform/mobile/**" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: mobile-${{ github.ref }} + cancel-in-progress: true + +jobs: + ios: + runs-on: macos-15 + timeout-minutes: 20 + defaults: + run: + working-directory: autogpt_platform/mobile/ios + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Install project generator + run: brew install xcodegen + - name: Lint all Swift sources + run: xcrun swift-format lint --strict --recursive App Sources Tests UITests Package.swift + - name: Test origin and authentication policies + run: swift test + - name: Generate Xcode project + run: xcodegen generate + - name: Build iOS simulator app without signing + run: >- + xcodebuild -project AutoGPT.xcodeproj -target AutoGPT + -sdk iphonesimulator -arch arm64 -configuration Debug + SYMROOT="$RUNNER_TEMP/autogpt-ios" CODE_SIGNING_ALLOWED=NO build + - name: Compile native UI regression tests + run: >- + xcodebuild -project AutoGPT.xcodeproj -target AutoGPTUITests + -sdk iphonesimulator -arch arm64 -configuration Debug + SYMROOT="$RUNNER_TEMP/autogpt-ios" CODE_SIGNING_ALLOWED=NO build + - name: Build unsigned iPhone app + run: >- + xcodebuild -project AutoGPT.xcodeproj -target AutoGPT + -sdk iphoneos -arch arm64 -configuration Release + SYMROOT="$RUNNER_TEMP/autogpt-ios" CODE_SIGNING_ALLOWED=NO build + - name: Package simulator app + run: >- + ditto -c -k --keepParent + "$RUNNER_TEMP/autogpt-ios/Debug-iphonesimulator/AutoGPT.app" + "$RUNNER_TEMP/AutoGPT-iOS-simulator-arm64.zip" + - name: Upload simulator app + uses: actions/upload-artifact@v7 + with: + name: autogpt-ios-simulator-arm64 + path: ${{ runner.temp }}/AutoGPT-iOS-simulator-arm64.zip + retention-days: 7 + compression-level: 0 + if-no-files-found: error + - name: Run native UI regression tests + id: ui_tests + timeout-minutes: 10 + run: | + set -euo pipefail + ios_sdk="$(xcrun --sdk iphonesimulator --show-sdk-version)" + xcrun simctl list --json > "$RUNNER_TEMP/ios-simulators.json" + ios_runtime="$(jq -r --arg sdk "$ios_sdk" ' + [.runtimes[] | select( + .isAvailable and + (.identifier | startswith("com.apple.CoreSimulator.SimRuntime.iOS-")) and + ((.version | split(".") | .[0:2]) == ($sdk | split(".") | .[0:2])) + )] | first | .identifier // empty + ' "$RUNNER_TEMP/ios-simulators.json")" + if [[ -z "$ios_runtime" ]]; then + echo "::error::No installed iOS runtime matches simulator SDK $ios_sdk." + exit 1 + fi + ios_simulator="$(jq -r --arg runtime "$ios_runtime" ' + [.devices[$runtime][]? | select( + .isAvailable and + (.deviceTypeIdentifier | startswith("com.apple.CoreSimulator.SimDeviceType.iPhone-")) + )] | sort_by([if .name == "iPhone 16 Pro" then 0 else 1 end, .name]) + | first | .udid // empty + ' "$RUNNER_TEMP/ios-simulators.json")" + if [[ -z "$ios_simulator" ]]; then + echo "::error::No available iPhone simulator is installed for $ios_runtime." + exit 1 + fi + echo "UI tests: SDK $ios_sdk, runtime $ios_runtime, simulator $ios_simulator" + echo "preflight_passed=true" >> "$GITHUB_OUTPUT" + xcrun simctl bootstatus "$ios_simulator" -b + xcodebuild -project AutoGPT.xcodeproj -scheme AutoGPT -configuration Debug \ + -destination "platform=iOS Simulator,id=$ios_simulator" \ + -destination-timeout 60 \ + -derivedDataPath "$RUNNER_TEMP/autogpt-ui-derived" \ + -resultBundlePath "$RUNNER_TEMP/autogpt-ui.xcresult" \ + -parallel-testing-enabled NO -test-timeouts-enabled YES \ + -default-test-execution-time-allowance 60 \ + -maximum-test-execution-time-allowance 120 \ + -only-testing:AutoGPTUITests CODE_SIGNING_ALLOWED=NO test + - name: Upload native UI test results + if: always() && steps.ui_tests.outcome != 'skipped' + uses: actions/upload-artifact@v7 + with: + name: ios-ui-test-results + path: ${{ runner.temp }}/autogpt-ui.xcresult + retention-days: 7 + if-no-files-found: ${{ steps.ui_tests.outputs.preflight_passed == 'true' && 'error' || 'ignore' }} + + android: + runs-on: ubuntu-latest + timeout-minutes: 20 + defaults: + run: + working-directory: autogpt_platform/mobile/android + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: actions/setup-java@v6 + with: + distribution: temurin + java-version: "17" + - uses: gradle/actions/setup-gradle@v6 + - name: Test, lint, and build both Android variants + run: ./gradlew --no-daemon testDebugUnitTest lintDebug lintRelease assembleDebug assembleRelease assembleDebugAndroidTest + - name: Upload Android test build + uses: actions/upload-artifact@v7 + with: + name: autogpt-android-debug + path: autogpt_platform/mobile/android/app/build/outputs/apk/debug/app-debug.apk + retention-days: 7 + compression-level: 0 + if-no-files-found: error + + fixture: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: actions/setup-node@v6 + with: + node-version: "24.18.0" + - name: Test native integration fixture + run: node --test autogpt_platform/mobile/testing/server.test.mjs diff --git a/autogpt_platform/frontend/src/app/(no-navbar)/auth/mobile/__tests__/MobileAuthConsent.test.tsx b/autogpt_platform/frontend/src/app/(no-navbar)/auth/mobile/__tests__/MobileAuthConsent.test.tsx new file mode 100644 index 000000000000..5614f688bed2 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(no-navbar)/auth/mobile/__tests__/MobileAuthConsent.test.tsx @@ -0,0 +1,112 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { MobileAuthConsent } from "../components/MobileAuthConsent"; + +const CHALLENGE = "A".repeat(43); +const STATE = "B".repeat(43); +const CODE = "C".repeat(43); +const CALLBACK = `autogpt://auth/callback?code=${CODE}&state=${STATE}`; + +describe("mobile app sign-in consent", () => { + const fetchMock = vi.fn(); + + beforeEach(() => { + vi.stubGlobal("fetch", fetchMock); + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ url: CALLBACK }), + }); + vi.spyOn(window.location, "assign").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + fetchMock.mockReset(); + }); + + function renderConsent() { + return render( + , + ); + } + + it("shows the account and waits for explicit approval", () => { + renderConsent(); + expect(screen.getByText("tester@agpt.co")).not.toBeNull(); + expect( + screen.getByRole("button", { name: "Connect AutoGPT" }), + ).not.toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + expect(window.location.assign).not.toHaveBeenCalled(); + }); + + it("binds consent to the request and returns the one-time code to the app", async () => { + renderConsent(); + await userEvent.click( + screen.getByRole("button", { name: "Connect AutoGPT" }), + ); + expect(fetchMock).toHaveBeenCalledWith("/api/auth/mobile/authorize", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "same-origin", + body: JSON.stringify({ + code_challenge: CHALLENGE, + state: STATE, + expected_user_id: "user-123", + }), + }); + await waitFor(() => { + expect(window.location.assign).toHaveBeenCalledWith(CALLBACK); + }); + }); + + it("offers retry when authorization fails", async () => { + fetchMock.mockResolvedValueOnce({ ok: false }); + renderConsent(); + await userEvent.click( + screen.getByRole("button", { name: "Connect AutoGPT" }), + ); + expect((await screen.findByRole("alert")).textContent).toContain( + "Could not connect AutoGPT", + ); + expect(window.location.assign).not.toHaveBeenCalled(); + expect( + ( + screen.getByRole("button", { + name: "Connect AutoGPT", + }) as HTMLButtonElement + ).disabled, + ).toBe(false); + }); + + it("returns a state-bound cancellation without authorizing a session", async () => { + renderConsent(); + await userEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(fetchMock).not.toHaveBeenCalled(); + expect(window.location.assign).toHaveBeenCalledWith( + `autogpt://auth/callback?error=access_denied&state=${STATE}`, + ); + }); + + it.each([ + `https://attacker.example/?code=${CODE}&state=${STATE}`, + `autogpt://other/callback?code=${CODE}&state=${STATE}`, + `autogpt://auth/callback?code=${CODE}&state=${"D".repeat(43)}`, + `autogpt://auth/callback?code=${CODE}&state=${STATE}&token=unexpected`, + ])("rejects an unexpected callback", async (url) => { + fetchMock.mockResolvedValueOnce({ ok: true, json: async () => ({ url }) }); + renderConsent(); + await userEvent.click( + screen.getByRole("button", { name: "Connect AutoGPT" }), + ); + expect(await screen.findByRole("alert")).not.toBeNull(); + expect(window.location.assign).not.toHaveBeenCalled(); + }); +}); diff --git a/autogpt_platform/frontend/src/app/(no-navbar)/auth/mobile/components/MobileAuthConsent.tsx b/autogpt_platform/frontend/src/app/(no-navbar)/auth/mobile/components/MobileAuthConsent.tsx new file mode 100644 index 000000000000..101a1437346e --- /dev/null +++ b/autogpt_platform/frontend/src/app/(no-navbar)/auth/mobile/components/MobileAuthConsent.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { AuthCard } from "@/components/auth/AuthCard"; +import { Button } from "@/components/atoms/Button/Button"; +import { Text } from "@/components/atoms/Text/Text"; +import { useMobileAuthConsent } from "./useMobileAuthConsent"; + +interface Props { + userID: string; + email: string; + codeChallenge: string; + state: string; +} + +export function MobileAuthConsent({ + userID, + email, + codeChallenge, + state, +}: Props) { + const { isConnecting, callbackURL, error, connect, returnToApp, cancel } = + useMobileAuthConsent(codeChallenge, state, userID); + + return ( + +
+ Sign in to the AutoGPT app as + + {email} + + + Your chats, files, and connected tools will be available in the app. + Continue only if you started this sign-in on your phone. + +
+ {error ? ( + + {error} + + ) : null} +
+ {callbackURL ? ( + + ) : ( + + )} + +
+
+ ); +} diff --git a/autogpt_platform/frontend/src/app/(no-navbar)/auth/mobile/components/helpers.ts b/autogpt_platform/frontend/src/app/(no-navbar)/auth/mobile/components/helpers.ts new file mode 100644 index 000000000000..ae57baa7931a --- /dev/null +++ b/autogpt_platform/frontend/src/app/(no-navbar)/auth/mobile/components/helpers.ts @@ -0,0 +1,23 @@ +import { MOBILE_AUTH_CALLBACK } from "@/lib/auth/mobile-auth-helpers"; + +export function readMobileCallback(value: unknown, state: string) { + if (!value || typeof value !== "object" || !("url" in value)) return null; + if (typeof value.url !== "string") return null; + try { + const url = new URL(value.url); + if ( + `${url.protocol}//${url.host}${url.pathname}` !== MOBILE_AUTH_CALLBACK + ) { + return null; + } + if (url.username || url.password || url.hash) return null; + if (url.searchParams.size !== 2) return null; + if (url.searchParams.get("state") !== state) return null; + if (!/^[A-Za-z0-9_-]{43}$/.test(url.searchParams.get("code") ?? "")) { + return null; + } + return url.toString(); + } catch { + return null; + } +} diff --git a/autogpt_platform/frontend/src/app/(no-navbar)/auth/mobile/components/useMobileAuthConsent.ts b/autogpt_platform/frontend/src/app/(no-navbar)/auth/mobile/components/useMobileAuthConsent.ts new file mode 100644 index 000000000000..1c6d1bb0881e --- /dev/null +++ b/autogpt_platform/frontend/src/app/(no-navbar)/auth/mobile/components/useMobileAuthConsent.ts @@ -0,0 +1,57 @@ +"use client"; + +import { useState } from "react"; +import { MOBILE_AUTH_CALLBACK } from "@/lib/auth/mobile-auth-helpers"; +import { readMobileCallback } from "./helpers"; + +export function useMobileAuthConsent( + codeChallenge: string, + state: string, + userID: string, +) { + const [isConnecting, setIsConnecting] = useState(false); + const [callbackURL, setCallbackURL] = useState(null); + const [error, setError] = useState(null); + + async function connect() { + if (isConnecting) return; + setIsConnecting(true); + setError(null); + try { + const response = await fetch("/api/auth/mobile/authorize", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "same-origin", + body: JSON.stringify({ + code_challenge: codeChallenge, + state, + expected_user_id: userID, + }), + }); + if (!response.ok) throw new Error("Authorization failed"); + const callback = readMobileCallback(await response.json(), state); + if (!callback) throw new Error("Invalid authorization callback"); + setCallbackURL(callback); + window.location.assign(callback); + } catch { + setError( + "Could not connect AutoGPT. Please try again or restart sign-in in the app.", + ); + } finally { + setIsConnecting(false); + } + } + + function returnToApp() { + if (callbackURL) window.location.assign(callbackURL); + } + + function cancel() { + const callback = new URL(MOBILE_AUTH_CALLBACK); + callback.searchParams.set("error", "access_denied"); + callback.searchParams.set("state", state); + window.location.assign(callback.toString()); + } + + return { isConnecting, callbackURL, error, connect, returnToApp, cancel }; +} diff --git a/autogpt_platform/frontend/src/app/(no-navbar)/auth/mobile/page.tsx b/autogpt_platform/frontend/src/app/(no-navbar)/auth/mobile/page.tsx new file mode 100644 index 000000000000..20733f1fc77f --- /dev/null +++ b/autogpt_platform/frontend/src/app/(no-navbar)/auth/mobile/page.tsx @@ -0,0 +1,56 @@ +import { AuthCard } from "@/components/auth/AuthCard"; +import { Text } from "@/components/atoms/Text/Text"; +import { + mobileAuthConsentPath, + mobileAuthRequestSchema, +} from "@/lib/auth/mobile-auth-helpers"; +import { auth } from "@/lib/auth/auth"; +import type { Metadata } from "next"; +import { headers } from "next/headers"; +import { redirect } from "next/navigation"; +import { MobileAuthConsent } from "./components/MobileAuthConsent"; + +export const metadata: Metadata = { + title: "Connect AutoGPT mobile", + robots: { index: false, follow: false }, + referrer: "no-referrer", +}; + +interface Props { + searchParams: Promise>; +} + +export default async function MobileAuthPage({ searchParams }: Props) { + const request = mobileAuthRequestSchema.safeParse(await searchParams); + if (!request.success) { + return ( +
+ + + This sign-in link is invalid. Open AutoGPT on your phone and start + sign-in again. + + +
+ ); + } + const session = await auth.api.getSession({ + headers: await headers(), + query: { disableCookieCache: true }, + }); + if (!session) { + redirect( + `/login?next=${encodeURIComponent(mobileAuthConsentPath(request.data))}`, + ); + } + return ( +
+ +
+ ); +} diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ArtifactPanel/downloadArtifact.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ArtifactPanel/downloadArtifact.ts index 6ff3e264de3b..91864758ff12 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ArtifactPanel/downloadArtifact.ts +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ArtifactPanel/downloadArtifact.ts @@ -1,4 +1,5 @@ import type { ArtifactRef } from "../../store"; +import { saveBlob } from "@/lib/utils/save-blob"; const MAX_RETRIES = 2; const RETRY_DELAY_MS = 500; @@ -50,14 +51,7 @@ export function downloadArtifact(artifact: ArtifactRef): Promise { return fetchWithRetry(artifact.sourceUrl, MAX_RETRIES) .then((res) => res.blob()) - .then((blob) => { - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = safeName && hasVisibleName ? safeName : "download"; - document.body.appendChild(a); - a.click(); - a.remove(); - URL.revokeObjectURL(url); - }); + .then((blob) => + saveBlob(blob, safeName && hasVisibleName ? safeName : "download"), + ); } diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ContextPanel/components/FilesTab/helpers.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ContextPanel/components/FilesTab/helpers.ts index 97257a66f11b..ca27085ea622 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ContextPanel/components/FilesTab/helpers.ts +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ContextPanel/components/FilesTab/helpers.ts @@ -2,6 +2,7 @@ import JSZip from "jszip"; import { getGetWorkspaceDownloadFileByIdUrl } from "@/app/api/__generated__/endpoints/workspace/workspace"; import type { WorkspaceFileItem } from "@/app/api/__generated__/models/workspaceFileItem"; import type { ArtifactRef } from "../../../../store"; +import { saveBlob } from "@/lib/utils/save-blob"; export function fileDownloadUrl(fileId: string): string { return `/api/proxy${getGetWorkspaceDownloadFileByIdUrl(fileId)}`; @@ -54,18 +55,7 @@ interface ZipEntry { interface DownloadZipDeps { fetchImpl?: (url: string) => Promise; - save?: (blob: Blob, filename: string) => void; -} - -function triggerDownload(blob: Blob, filename: string): void { - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = filename; - document.body.appendChild(a); - a.click(); - a.remove(); - URL.revokeObjectURL(url); + save?: (blob: Blob, filename: string) => void | Promise; } export async function downloadFilesAsZip( @@ -73,7 +63,7 @@ export async function downloadFilesAsZip( deps: DownloadZipDeps = {}, ): Promise { const fetchImpl = deps.fetchImpl ?? ((url: string) => fetch(url)); - const save = deps.save ?? triggerDownload; + const save = deps.save ?? saveBlob; const zip = new JSZip(); const used = new Set(); let added = 0; @@ -90,5 +80,5 @@ export async function downloadFilesAsZip( throw new Error("No files could be downloaded."); } const blob = await zip.generateAsync({ type: "blob" }); - save(blob, "workspace-files.zip"); + await save(blob, "workspace-files.zip"); } diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/helpers/exportChatAsMarkdown.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/helpers/exportChatAsMarkdown.ts index eb9128f2fc3f..657d7ebe2095 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/helpers/exportChatAsMarkdown.ts +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/helpers/exportChatAsMarkdown.ts @@ -1,3 +1,5 @@ +import { saveBlob } from "@/lib/utils/save-blob"; + interface SessionChatMessage { role: string; content: string | null; @@ -45,7 +47,7 @@ export function exportChatAsMarkdown( _sessionId: string, title: string | null | undefined, messages: SessionChatMessage[], -): void { +): Promise { const displayTitle = title || "Untitled chat"; const date = new Date().toISOString().slice(0, 10); @@ -68,14 +70,7 @@ export function exportChatAsMarkdown( const markdown = lines.join("\n"); const blob = new Blob([markdown], { type: "text/markdown;charset=utf-8" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = `chat-${sanitizeFilename(displayTitle)}-${date}.md`; - document.body.appendChild(a); - a.click(); - a.remove(); - URL.revokeObjectURL(url); + return saveBlob(blob, `chat-${sanitizeFilename(displayTitle)}-${date}.md`); } const EXPORT_PAGE_SIZE = 200; @@ -121,5 +116,5 @@ export async function fetchAndExportChat( ); } - exportChatAsMarkdown(id, title, allMessages); + await exportChatAsMarkdown(id, title, allMessages); } diff --git a/autogpt_platform/frontend/src/components/organisms/NeedsAttentionList/__tests__/NeedsAttentionList.test.tsx b/autogpt_platform/frontend/src/components/organisms/NeedsAttentionList/__tests__/NeedsAttentionList.test.tsx index bbfaad065413..04f96f85b7d1 100644 --- a/autogpt_platform/frontend/src/components/organisms/NeedsAttentionList/__tests__/NeedsAttentionList.test.tsx +++ b/autogpt_platform/frontend/src/components/organisms/NeedsAttentionList/__tests__/NeedsAttentionList.test.tsx @@ -1,6 +1,7 @@ import userEvent from "@testing-library/user-event"; import { http, HttpResponse } from "msw"; -import { expect, test } from "vitest"; +import { afterEach, expect, test } from "vitest"; +import { toast } from "sonner"; import { getGetV2GetPendingReviewsMockHandler200, getPostV2ProcessReviewActionMockHandler200, @@ -11,6 +12,19 @@ import { server } from "@/mocks/mock-server"; import { render, screen, waitFor } from "@/tests/integrations/test-utils"; import { NeedsAttentionList } from "../NeedsAttentionList"; +afterEach(() => { + for (const notification of toast.getToasts()) toast.dismiss(notification.id); +}); + +async function waitForDecisionsToFinish() { + await waitFor(() => { + const buttons = screen.getAllByRole("button", { name: /^Approve:/ }); + for (const button of buttons) { + expect((button as HTMLButtonElement).disabled).toBe(false); + } + }); +} + const review: PendingHumanReviewModel = { node_exec_id: "ne-1", node_id: "n-1", @@ -51,6 +65,7 @@ test("renders attributed rows and approves in one tap", async () => { reviews: [{ node_exec_id: "ne-1", approved: true }], }), ); + await waitForDecisionsToFinish(); }); test("only the acted row locks while its decision is in flight", async () => { @@ -82,6 +97,7 @@ test("only the acted row locks while its decision is in flight", async () => { expect(first.disabled).toBe(true); expect(second.disabled).toBe(false); }); + await waitForDecisionsToFinish(); }); test("confirms a successful decision with a toast", async () => { @@ -135,6 +151,7 @@ test("decline sends a rejection", async () => { // No canned reason: this surface has no field to write one in, so nothing // should reach the agent context / audit trail as if the user typed it. expect(actionBody?.reviews[0].message).toBeUndefined(); + await waitForDecisionsToFinish(); }); test("armed decline is announced and visually distinct, not just relabelled", async () => { @@ -247,4 +264,5 @@ test("a second row's decision does not unlock the first one mid-flight", async ( expect(first.disabled).toBe(true); expect(second.disabled).toBe(true); }); + await waitForDecisionsToFinish(); }); diff --git a/autogpt_platform/frontend/src/lib/auth/__tests__/auth-config.test.ts b/autogpt_platform/frontend/src/lib/auth/__tests__/auth-config.test.ts index 9de91578f1a6..889d21d5cf16 100644 --- a/autogpt_platform/frontend/src/lib/auth/__tests__/auth-config.test.ts +++ b/autogpt_platform/frontend/src/lib/auth/__tests__/auth-config.test.ts @@ -118,11 +118,17 @@ describe("auth config", () => { expect(hashed).toMatch(/^\$2[aby]\$10\$/); expect( - await verify({ hash: hashed, password: "correct horse battery staple" }), + await verify({ + hash: hashed, + password: "correct horse battery staple", // pragma: allowlist secret + }), ).toBe(true); - expect(await verify({ hash: hashed, password: "wrong password" })).toBe( - false, - ); + expect( + await verify({ + hash: hashed, + password: "wrong password", // pragma: allowlist secret + }), + ).toBe(false); }); it("configures the JWT plugin with the Supabase-compatible audience and expiry", async () => { @@ -194,7 +200,7 @@ describe("auth config", () => { expect(options.socialProviders).toEqual({ google: { clientId: "google-client-id", - clientSecret: "google-client-secret", + clientSecret: "google-client-secret", // pragma: allowlist secret }, }); }); @@ -237,6 +243,7 @@ describe("auth config", () => { "admin", "jwt", "supabase-bridge", + "mobile-auth", "next-cookies", ]); }); diff --git a/autogpt_platform/frontend/src/lib/auth/__tests__/mobile-auth-fixtures.ts b/autogpt_platform/frontend/src/lib/auth/__tests__/mobile-auth-fixtures.ts new file mode 100644 index 000000000000..36ff31d1e3fa --- /dev/null +++ b/autogpt_platform/frontend/src/lib/auth/__tests__/mobile-auth-fixtures.ts @@ -0,0 +1,49 @@ +import { createHash, randomBytes } from "node:crypto"; +import { betterAuth } from "better-auth"; +import { memoryAdapter } from "better-auth/adapters/memory"; +import { admin } from "better-auth/plugins"; +import { mobileAuth } from "../mobile-auth"; + +export const ORIGIN = "https://platform.agpt.co"; +export const VERIFIER = randomBytes(32).toString("base64url"); +export const CHALLENGE = createHash("sha256") + .update(VERIFIER) + .digest("base64url"); +export const STATE = randomBytes(32).toString("base64url"); + +export function createTestAuth( + allowSession?: () => boolean | Promise, + requireEmailVerification = false, +) { + return betterAuth({ + baseURL: ORIGIN, + secret: "mobile-auth-test-secret-at-least-32-characters", // pragma: allowlist secret + database: memoryAdapter({ + user: [], + session: [], + account: [], + verification: [], + }), + advanced: { disableOriginCheck: false }, + emailAndPassword: { enabled: true, requireEmailVerification }, + rateLimit: { enabled: false }, + session: { cookieCache: { enabled: true } }, + plugins: [admin(), mobileAuth()], + databaseHooks: { + session: { + create: { + async before() { + if (allowSession && !(await allowSession())) return false; + }, + }, + }, + }, + }); +} + +export function cookieHeader(response: Response) { + return response.headers + .getSetCookie() + .map((cookie) => cookie.split(";")[0]) + .join("; "); +} diff --git a/autogpt_platform/frontend/src/lib/auth/__tests__/mobile-auth-policy.test.ts b/autogpt_platform/frontend/src/lib/auth/__tests__/mobile-auth-policy.test.ts new file mode 100644 index 000000000000..85230e9a161d --- /dev/null +++ b/autogpt_platform/frontend/src/lib/auth/__tests__/mobile-auth-policy.test.ts @@ -0,0 +1,197 @@ +// @vitest-environment node +import { beforeEach, describe, expect, it } from "vitest"; +import { + CHALLENGE, + ORIGIN, + STATE, + VERIFIER, + cookieHeader, + createTestAuth, +} from "./mobile-auth-fixtures"; + +describe("mobile handoff session policy", () => { + let auth: ReturnType; + let cookies: string; + let userID: string; + let allowNewSession = true; + let beforeNewSession: (() => Promise) | null = null; + + beforeEach(async () => { + allowNewSession = true; + beforeNewSession = null; + auth = createTestAuth(async () => { + await beforeNewSession?.(); + return allowNewSession; + }); + const response = await auth.api.signUpEmail({ + body: { + name: "Mobile Tester", + email: "mobile-tester@agpt.co", + password: "a-long-test-password", // pragma: allowlist secret + }, + asResponse: true, + }); + cookies = cookieHeader(response); + userID = (await response.json()).user.id; + }); + + function post(path: string, body: Record, cookie = "") { + return auth.handler( + new Request(`${ORIGIN}/api/auth/mobile/${path}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: ORIGIN, + ...(cookie ? { Cookie: cookie } : {}), + }, + body: JSON.stringify( + path === "authorize" ? { expected_user_id: userID, ...body } : body, + ), + }), + ); + } + + async function issueCode() { + const response = await post( + "authorize", + { code_challenge: CHALLENGE, state: STATE }, + cookies, + ); + expect(response.status).toBe(200); + return new URL((await response.json()).url).searchParams.get("code")!; + } + + it("does not turn admin impersonation into a lasting user session", async () => { + const context = await auth.$context; + const source = await auth.api.getSession({ + headers: new Headers({ Cookie: cookies }), + }); + await context.adapter.update({ + model: "session", + where: [{ field: "id", value: source!.session.id }], + update: { impersonatedBy: "admin-user-id" }, + }); + const response = await post( + "authorize", + { code_challenge: CHALLENGE, state: STATE }, + cookies, + ); + expect(response.status).toBe(403); + }); + + it("honors database session creation policy during exchange", async () => { + const code = await issueCode(); + allowNewSession = false; + const response = await post("exchange", { code, code_verifier: VERIFIER }); + expect(response.status).toBe(401); + expect(response.headers.has("set-cookie")).toBe(false); + }); + + it("requires the source browser session to remain unexpired", async () => { + const code = await issueCode(); + const context = await auth.$context; + const source = await auth.api.getSession({ + headers: new Headers({ Cookie: cookies }), + }); + await context.adapter.update({ + model: "session", + where: [{ field: "id", value: source!.session.id }], + update: { expiresAt: new Date(Date.now() - 1000) }, + }); + const response = await post("exchange", { code, code_verifier: VERIFIER }); + expect(response.status).toBe(401); + expect(response.headers.has("set-cookie")).toBe(false); + }); + + it("permits an expired ban through the normal session policy", async () => { + const code = await issueCode(); + const context = await auth.$context; + await context.internalAdapter.updateUser(userID, { + banned: true, + banExpires: new Date(Date.now() - 1000), + }); + const response = await post("exchange", { code, code_verifier: VERIFIER }); + expect(response.status).toBe(200); + }); + + it("rejects cookie-free exchange with a missing or foreign origin", async () => { + const code = await issueCode(); + for (const origin of ["", "https://attacker.example"]) { + const response = await auth.handler( + new Request(`${ORIGIN}/api/auth/mobile/exchange`, { + method: "POST", + headers: { "Content-Type": "application/json", Origin: origin }, + body: JSON.stringify({ code, code_verifier: VERIFIER }), + }), + ); + expect(response.status).toBe(403); + expect(response.headers.has("set-cookie")).toBe(false); + } + expect( + (await post("exchange", { code, code_verifier: VERIFIER })).status, + ).toBe(200); + }); + + it.each([true, false])( + "applies current email verification requirement %s to existing unverified sessions", + async (required) => { + const code = await issueCode(); + const context = await auth.$context; + context.options.emailAndPassword = { + ...context.options.emailAndPassword, + enabled: true, + requireEmailVerification: required, + }; + const consent = await post( + "authorize", + { code_challenge: CHALLENGE, state: STATE }, + cookies, + ); + expect(consent.status).toBe(required ? 403 : 200); + const exchange = await post("exchange", { + code, + code_verifier: VERIFIER, + }); + expect(exchange.status).toBe(required ? 403 : 200); + if (required) expect(exchange.headers.has("set-cookie")).toBe(false); + }, + ); + + it("revokes the new app session if its browser session disappears during creation", async () => { + const code = await issueCode(); + const context = await auth.$context; + const source = await auth.api.getSession({ + headers: new Headers({ Cookie: cookies }), + }); + beforeNewSession = async () => { + await context.internalAdapter.deleteSession(source!.session.token); + }; + const exchange = await post("exchange", { code, code_verifier: VERIFIER }); + expect(exchange.status).toBe(401); + expect(exchange.headers.has("set-cookie")).toBe(false); + expect(await context.adapter.findMany({ model: "session" })).toHaveLength( + 0, + ); + }); + + it("does not authorize a different account than the consent page displayed", async () => { + const other = await auth.api.signUpEmail({ + body: { + name: "Other", + email: "other@agpt.co", + password: "a-long-test-password", // pragma: allowlist secret + }, + asResponse: true, + }); + const response = await post( + "authorize", + { code_challenge: CHALLENGE, state: STATE }, + cookieHeader(other), + ); + expect(response.status).toBe(403); + const context = await auth.$context; + expect( + await context.adapter.findMany({ model: "verification" }), + ).toHaveLength(0); + }); +}); diff --git a/autogpt_platform/frontend/src/lib/auth/__tests__/mobile-auth.test.ts b/autogpt_platform/frontend/src/lib/auth/__tests__/mobile-auth.test.ts new file mode 100644 index 000000000000..88f1f98d4179 --- /dev/null +++ b/autogpt_platform/frontend/src/lib/auth/__tests__/mobile-auth.test.ts @@ -0,0 +1,239 @@ +// @vitest-environment node +import { randomBytes } from "node:crypto"; +import { beforeEach, describe, expect, it } from "vitest"; +import { + CHALLENGE, + ORIGIN, + STATE, + VERIFIER, + cookieHeader, + createTestAuth, +} from "./mobile-auth-fixtures"; + +describe("mobile browser authentication handoff", () => { + let auth: ReturnType; + let cookies: string; + let userID: string; + + beforeEach(async () => { + auth = createTestAuth(); + const response = await auth.api.signUpEmail({ + body: { + name: "Mobile Tester", + email: "mobile-tester@agpt.co", + password: "a-long-test-password", // pragma: allowlist secret + }, + asResponse: true, + }); + cookies = cookieHeader(response); + const body = await response.json(); + userID = body.user.id; + }); + + function request( + path: string, + body?: Record, + headers: Record = {}, + ) { + return auth.handler( + new Request(`${ORIGIN}/api/auth/mobile/${path}`, { + method: body ? "POST" : "GET", + headers: { + ...(body + ? { "Content-Type": "application/json", Origin: ORIGIN } + : {}), + ...headers, + }, + ...(body + ? { + body: JSON.stringify( + path === "authorize" + ? { expected_user_id: userID, ...body } + : body, + ), + } + : {}), + }), + ); + } + + async function authorize(headers = { Cookie: cookies, Origin: ORIGIN }) { + return request( + "authorize", + { code_challenge: CHALLENGE, state: STATE }, + headers, + ); + } + + async function issueCode() { + const response = await authorize(); + expect(response.status).toBe(200); + const body = await response.json(); + const callback = new URL(body.url); + expect(callback.origin).toBe("null"); + expect(callback.protocol).toBe("autogpt:"); + expect(callback.host).toBe("auth"); + expect(callback.pathname).toBe("/callback"); + expect(callback.searchParams.get("state")).toBe(STATE); + return callback.searchParams.get("code")!; + } + + it("preserves a validated handoff through the existing login flow", async () => { + const response = await request( + `start?code_challenge=${CHALLENGE}&state=${STATE}`, + ); + expect(response.status).toBe(302); + const login = new URL(response.headers.get("location")!, ORIGIN); + expect(login.pathname).toBe("/login"); + const next = new URL(login.searchParams.get("next")!, ORIGIN); + expect(next.pathname).toBe("/auth/mobile"); + expect(next.searchParams.get("code_challenge")).toBe(CHALLENGE); + expect(next.searchParams.get("state")).toBe(STATE); + }); + + it("requires explicit authenticated consent before issuing a ticket", async () => { + const response = await request( + `start?code_challenge=${CHALLENGE}&state=${STATE}`, + undefined, + { Cookie: cookies }, + ); + expect(response.headers.get("location")).toBe( + `${ORIGIN}/auth/mobile?code_challenge=${CHALLENGE}&state=${STATE}`, + ); + expect(await response.text()).not.toContain("autogpt://"); + const unauthorized = await authorize({ Cookie: "", Origin: ORIGIN }); + expect(unauthorized.status).toBe(401); + }); + + it("rejects cross-origin consent and missing origin with browser cookies", async () => { + expect( + (await authorize({ Cookie: cookies, Origin: "https://attacker.example" })) + .status, + ).toBe(403); + expect((await authorize({ Cookie: cookies, Origin: "" })).status).toBe(403); + }); + + it.each([ + ["short", STATE], + ["A".repeat(44), STATE], + ["!".repeat(43), STATE], + [CHALLENGE, "short"], + [CHALLENGE, "A".repeat(129)], + [CHALLENGE, "https://attacker.example"], + ])("rejects malformed challenge or state", async (challenge, state) => { + const response = await request( + `start?code_challenge=${encodeURIComponent(challenge)}&state=${encodeURIComponent(state)}`, + ); + expect(response.status).toBe(400); + }); + + it("never accepts an arbitrary callback destination", async () => { + const response = await request( + "authorize", + { + code_challenge: CHALLENGE, + state: STATE, + redirect_uri: "https://attacker.example", + }, + { Cookie: cookies, Origin: ORIGIN }, + ); + expect(response.status).toBe(400); + }); + + it("sets a fresh secure HttpOnly session without disclosing it in JSON", async () => { + const code = await issueCode(); + const response = await request("exchange", { + code, + code_verifier: VERIFIER, + }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ success: true }); + expect(response.headers.get("cache-control")).toContain("no-store"); + const setCookies = response.headers.getSetCookie(); + const sessionCookie = setCookies.find((value) => + value.startsWith("__Secure-better-auth.session_token="), + ); + expect(sessionCookie).toContain("HttpOnly"); + expect(sessionCookie).toContain("Secure"); + expect(sessionCookie).toContain("SameSite=Lax"); + expect(cookieHeader(response)).not.toBe(cookies); + const session = await auth.api.getSession({ + headers: new Headers({ Cookie: cookieHeader(response) }), + }); + expect(session?.user.id).toBe(userID); + }); + + it("rejects the wrong verifier without consuming the legitimate ticket", async () => { + const code = await issueCode(); + const wrong = await request("exchange", { + code, + code_verifier: randomBytes(32).toString("base64url"), + }); + expect(wrong.status).toBe(400); + expect(wrong.headers.has("set-cookie")).toBe(false); + const valid = await request("exchange", { code, code_verifier: VERIFIER }); + expect(valid.status).toBe(200); + }); + + it("allows exactly one concurrent exchange and rejects replay", async () => { + const code = await issueCode(); + const results = await Promise.all([ + request("exchange", { code, code_verifier: VERIFIER }), + request("exchange", { code, code_verifier: VERIFIER }), + ]); + expect(results.map((response) => response.status).sort()).toEqual([ + 200, 400, + ]); + expect( + (await request("exchange", { code, code_verifier: VERIFIER })).status, + ).toBe(400); + }); + + it("rejects an expired handoff", async () => { + const code = await issueCode(); + const context = await auth.$context; + const records = await context.adapter.findMany<{ + id: string; + identifier: string; + }>({ model: "verification" }); + const handoff = records.find((row) => + String(row.identifier).startsWith("mobile-auth:"), + ); + expect(handoff).toBeDefined(); + await context.adapter.update({ + model: "verification", + where: [{ field: "id", value: handoff!.id }], + update: { expiresAt: new Date(Date.now() - 1000) }, + }); + expect( + (await request("exchange", { code, code_verifier: VERIFIER })).status, + ).toBe(400); + }); + + it("rechecks browser session revocation before creating an app session", async () => { + const code = await issueCode(); + await auth.api.signOut({ headers: new Headers({ Cookie: cookies }) }); + const response = await request("exchange", { + code, + code_verifier: VERIFIER, + }); + expect(response.status).toBe(401); + expect(response.headers.has("set-cookie")).toBe(false); + }); + + it("rejects banned users even when their session cookie cache remains valid", async () => { + const code = await issueCode(); + const context = await auth.$context; + await context.internalAdapter.updateUser(userID, { + banned: true, + banExpires: new Date(Date.now() + 60000), + }); + expect((await authorize()).status).toBe(403); + const response = await request("exchange", { + code, + code_verifier: VERIFIER, + }); + expect(response.status).toBe(403); + expect(response.headers.has("set-cookie")).toBe(false); + }); +}); diff --git a/autogpt_platform/frontend/src/lib/auth/auth.ts b/autogpt_platform/frontend/src/lib/auth/auth.ts index 51a410bc8b50..8ff636293c83 100644 --- a/autogpt_platform/frontend/src/lib/auth/auth.ts +++ b/autogpt_platform/frontend/src/lib/auth/auth.ts @@ -13,6 +13,7 @@ import { import { JWKS_ALG } from "./service-token"; import { isSignupAllowed, readSignupGateConfig } from "./signup-gate"; import { supabaseBridge } from "./supabase-bridge"; +import { mobileAuth } from "./mobile-auth"; const baseURL = process.env.BETTER_AUTH_URL || @@ -225,6 +226,7 @@ export const auth = betterAuth({ }, }), supabaseBridge(), + mobileAuth(), // Must be last so cookies set inside server actions stick. nextCookies(), ], diff --git a/autogpt_platform/frontend/src/lib/auth/helpers.ts b/autogpt_platform/frontend/src/lib/auth/helpers.ts index 159622c73318..204ee21ad847 100644 --- a/autogpt_platform/frontend/src/lib/auth/helpers.ts +++ b/autogpt_platform/frontend/src/lib/auth/helpers.ts @@ -4,6 +4,7 @@ import { Key, storage } from "@/services/storage/local-storage"; export const PROTECTED_PAGES = [ "/auth/authorize", "/auth/integrations", + "/auth/mobile", "/copilot", "/home", "/monitor", diff --git a/autogpt_platform/frontend/src/lib/auth/mobile-auth-helpers.ts b/autogpt_platform/frontend/src/lib/auth/mobile-auth-helpers.ts new file mode 100644 index 000000000000..6a08dd2f3f2a --- /dev/null +++ b/autogpt_platform/frontend/src/lib/auth/mobile-auth-helpers.ts @@ -0,0 +1,62 @@ +import { z } from "zod"; + +export const MOBILE_AUTH_CALLBACK = "autogpt://auth/callback"; +export const MOBILE_AUTH_TTL_SECONDS = 90; + +export const mobileAuthRequestSchema = z + .object({ + code_challenge: z.string().regex(/^[A-Za-z0-9_-]{43}$/), + state: z.string().regex(/^[A-Za-z0-9_-]{32,128}$/), + }) + .strict(); + +export const mobileAuthAuthorizeSchema = mobileAuthRequestSchema.extend({ + expected_user_id: z.string().min(1).max(128), +}); + +export const mobileAuthExchangeSchema = z + .object({ + code: z.string().regex(/^[A-Za-z0-9_-]{43}$/), + code_verifier: z.string().regex(/^[A-Za-z0-9._~-]{43,128}$/), + }) + .strict(); + +export function mobileAuthConsentPath( + request: z.infer, +) { + return `/auth/mobile?${new URLSearchParams(request)}`; +} + +export function isMobileAuthUserBanned(user: { + id: string; + banned?: boolean | null; + banExpires?: Date | string | null; +}) { + if (!user.banned) return false; + if (!user.banExpires) return true; + const expires = new Date(user.banExpires).getTime(); + return !Number.isFinite(expires) || expires > Date.now(); +} + +export function isMobileAuthImpersonation(session: { + userId: string; + impersonatedBy?: unknown; +}) { + return Boolean(session.impersonatedBy); +} + +export function isMobileAuthSessionBlocked( + source: { + user: Parameters[0] & { + emailVerified: boolean; + }; + session: Parameters[0]; + }, + requireEmailVerification: boolean | undefined, +) { + return ( + isMobileAuthUserBanned(source.user) || + isMobileAuthImpersonation(source.session) || + Boolean(requireEmailVerification && !source.user.emailVerified) + ); +} diff --git a/autogpt_platform/frontend/src/lib/auth/mobile-auth.ts b/autogpt_platform/frontend/src/lib/auth/mobile-auth.ts new file mode 100644 index 000000000000..d5c22c75d90a --- /dev/null +++ b/autogpt_platform/frontend/src/lib/auth/mobile-auth.ts @@ -0,0 +1,191 @@ +import { createHash, randomBytes } from "node:crypto"; +import type { BetterAuthPlugin } from "better-auth"; +import { + APIError, + createAuthEndpoint, + getAuthoritativeSessionFromCtx, +} from "better-auth/api"; +import { setSessionCookie } from "better-auth/cookies"; +import { + isMobileAuthSessionBlocked, + MOBILE_AUTH_CALLBACK, + MOBILE_AUTH_TTL_SECONDS, + mobileAuthConsentPath, + mobileAuthAuthorizeSchema, + mobileAuthExchangeSchema, + mobileAuthRequestSchema, +} from "./mobile-auth-helpers"; + +function sha256(value: string) { + return createHash("sha256").update(value).digest("base64url"); +} + +function ticketIdentifier(code: string, challenge: string) { + return `mobile-auth:${sha256(code)}:${challenge}`; +} + +function invalidSession() { + return new APIError("UNAUTHORIZED", { + code: "MOBILE_AUTH_SESSION_REQUIRED", + message: "Sign in again in the browser to connect AutoGPT.", + }); +} + +function blockedSession() { + return new APIError("FORBIDDEN", { + code: "MOBILE_AUTH_NOT_ALLOWED", + message: "This account cannot connect a mobile app session.", + }); +} + +function requireMobileOrigin( + origin: string | null | undefined, + baseURL: string, +) { + if (origin !== new URL(baseURL).origin) { + throw new APIError("FORBIDDEN", { + code: "MOBILE_AUTH_INVALID_ORIGIN", + message: "Start sign-in from the AutoGPT app.", + }); + } +} + +export function mobileAuth() { + return { + id: "mobile-auth", + rateLimit: [ + { + pathMatcher: (path) => path.startsWith("/mobile/"), + window: 60, + max: 20, + }, + ], + endpoints: { + startMobileAuth: createAuthEndpoint( + "/mobile/start", + { method: "GET", query: mobileAuthRequestSchema }, + async (ctx) => { + ctx.setHeader("Cache-Control", "no-store"); + ctx.setHeader("Referrer-Policy", "no-referrer"); + const consentPath = mobileAuthConsentPath(ctx.query); + const session = await getAuthoritativeSessionFromCtx(ctx); + const path = session + ? consentPath + : `/login?next=${encodeURIComponent(consentPath)}`; + const origin = new URL(ctx.context.baseURL).origin; + throw ctx.redirect(`${origin}${path}`); + }, + ), + authorizeMobileAuth: createAuthEndpoint( + "/mobile/authorize", + { method: "POST", body: mobileAuthAuthorizeSchema }, + async (ctx) => { + requireMobileOrigin(ctx.headers?.get("origin"), ctx.context.baseURL); + ctx.setHeader("Cache-Control", "no-store"); + ctx.setHeader("Referrer-Policy", "no-referrer"); + const session = await getAuthoritativeSessionFromCtx(ctx); + if (!session) throw invalidSession(); + if ( + isMobileAuthSessionBlocked( + session, + ctx.context.options.emailAndPassword?.requireEmailVerification, + ) + ) { + throw blockedSession(); + } + if (session.user.id !== ctx.body.expected_user_id) { + throw new APIError("FORBIDDEN", { + code: "MOBILE_AUTH_ACCOUNT_CHANGED", + message: + "Your browser account changed. Restart sign-in in the app.", + }); + } + const code = randomBytes(32).toString("base64url"); + await ctx.context.internalAdapter.createVerificationValue({ + identifier: ticketIdentifier(code, ctx.body.code_challenge), + value: session.session.token, + expiresAt: new Date(Date.now() + MOBILE_AUTH_TTL_SECONDS * 1000), + }); + const callback = new URL(MOBILE_AUTH_CALLBACK); + callback.searchParams.set("code", code); + callback.searchParams.set("state", ctx.body.state); + return ctx.json({ url: callback.toString() }); + }, + ), + exchangeMobileAuth: createAuthEndpoint( + "/mobile/exchange", + { + method: "POST", + body: mobileAuthExchangeSchema, + metadata: { + allowedMediaTypes: [ + "application/json", + "application/x-www-form-urlencoded", + ], + }, + }, + async (ctx) => { + requireMobileOrigin(ctx.headers?.get("origin"), ctx.context.baseURL); + ctx.setHeader("Cache-Control", "no-store"); + ctx.setHeader("Referrer-Policy", "no-referrer"); + const identifier = ticketIdentifier( + ctx.body.code, + sha256(ctx.body.code_verifier), + ); + const ticket = + await ctx.context.internalAdapter.consumeVerificationValue( + identifier, + ); + if (!ticket) { + throw new APIError("BAD_REQUEST", { + code: "MOBILE_AUTH_INVALID_CODE", + message: "This sign-in request expired or was already used.", + }); + } + const source = await ctx.context.internalAdapter.findSession( + ticket.value, + ); + if (!source || source.session.expiresAt.getTime() <= Date.now()) { + throw invalidSession(); + } + if ( + isMobileAuthSessionBlocked( + source, + ctx.context.options.emailAndPassword?.requireEmailVerification, + ) + ) { + throw blockedSession(); + } + const session = await ctx.context.internalAdapter.createSession( + source.user.id, + ); + if (!session) throw invalidSession(); + try { + const currentSource = await ctx.context.internalAdapter.findSession( + ticket.value, + ); + if ( + !currentSource || + currentSource.session.expiresAt.getTime() <= Date.now() + ) { + throw invalidSession(); + } + if ( + isMobileAuthSessionBlocked( + currentSource, + ctx.context.options.emailAndPassword?.requireEmailVerification, + ) + ) { + throw blockedSession(); + } + await setSessionCookie(ctx, { session, user: currentSource.user }); + } catch (error) { + await ctx.context.internalAdapter.deleteSession(session.token); + throw error; + } + return ctx.json({ success: true }); + }, + ), + }, + } satisfies BetterAuthPlugin; +} diff --git a/autogpt_platform/frontend/src/lib/utils/__tests__/native-download.test.ts b/autogpt_platform/frontend/src/lib/utils/__tests__/native-download.test.ts new file mode 100644 index 000000000000..799bfd00881d --- /dev/null +++ b/autogpt_platform/frontend/src/lib/utils/__tests__/native-download.test.ts @@ -0,0 +1,214 @@ +import { waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { saveBlob } from "../save-blob"; +import { installDownloadBridge } from "./save-blob-fixtures"; + +afterEach(() => { + delete window.AutoGPTDownloads; + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe("Android blob download bridge", () => { + it("waits for the picker and every chunk ACK before completing", async () => { + const { messages, reply, bridge, previousHandler } = + installDownloadBridge(); + const bytes = Uint8Array.from( + { length: 48 * 1024 + 1 }, + (_, index) => index % 256, + ); + const download = saveBlob( + new Blob([bytes], { type: "text/markdown;charset=utf-8" }), + "report.md", + ); + const start = messages[0]; + expect(start).toMatchObject({ + type: "start", + filename: "report.md", + mimeType: "text/markdown", + size: bytes.length, + }); + expect(messages).toHaveLength(1); + reply({ type: "ready", id: start.id }); + await waitFor(() => expect(messages).toHaveLength(2)); + expect(messages[1]).toMatchObject({ type: "chunk", index: 0 }); + expect(messages[1].data).toHaveLength(64 * 1024); + reply({ type: "ack", id: start.id, index: 99 }); + await Promise.resolve(); + expect(messages).toHaveLength(2); + reply({ type: "ack", id: start.id, index: 0 }); + await waitFor(() => expect(messages).toHaveLength(3)); + expect(messages[2]).toMatchObject({ type: "chunk", index: 1 }); + const restored = Uint8Array.from( + atob(messages[1].data!) + atob(messages[2].data!), + (character) => character.charCodeAt(0), + ); + expect(restored).toEqual(bytes); + reply({ type: "ack", id: start.id, index: 1 }); + await waitFor(() => + expect(messages[3]).toMatchObject({ type: "finish", id: start.id }), + ); + reply({ type: "complete", id: start.id }); + await download; + expect(bridge.onmessage).toBe(previousHandler); + }); + + it("supports empty files and forwards unrelated replies to the prior handler", async () => { + const { messages, reply, previousHandler } = installDownloadBridge(); + const download = saveBlob(new Blob([]), "empty.txt"); + const id = messages[0].id; + reply({ type: "ready", id: "another-download" }); + expect(previousHandler).toHaveBeenCalledTimes(1); + expect(messages).toHaveLength(1); + reply({ type: "ready", id }); + await waitFor(() => + expect(messages[1]).toMatchObject({ type: "finish", id }), + ); + reply({ type: "complete", id }); + await download; + }); + + it("rejects native errors and restores the previous message handler", async () => { + const { messages, reply, bridge, previousHandler } = + installDownloadBridge(); + const download = saveBlob(new Blob(["x"]), "x.txt"); + reply({ type: "error", id: messages[0].id, message: "Storage full" }); + await expect(download).rejects.toThrow("Storage full"); + expect(bridge.onmessage).toBe(previousHandler); + expect(messages).toHaveLength(1); + }); + + it("treats picker cancellation as cancellation without sending file content", async () => { + const { messages, reply, bridge, previousHandler } = + installDownloadBridge(); + const download = saveBlob(new Blob(["private content"]), "x.txt"); + reply({ type: "cancelled", id: messages[0].id }); + await expect(download).rejects.toMatchObject({ name: "AbortError" }); + expect(messages).toHaveLength(1); + expect(bridge.onmessage).toBe(previousHandler); + }); + + it("aborts the native transfer on caller cancellation and permits another download", async () => { + const { messages, reply, bridge, previousHandler } = + installDownloadBridge(); + const controller = new AbortController(); + const download = saveBlob(new Blob(["x"]), "x.txt", { + signal: controller.signal, + }); + const id = messages[0].id; + controller.abort(); + await expect(download).rejects.toMatchObject({ name: "AbortError" }); + expect(messages[1]).toEqual({ type: "cancel", id }); + expect(bridge.onmessage).toBe(previousHandler); + const retry = saveBlob(new Blob([]), "retry.txt"); + const retryID = messages[2].id; + reply({ type: "cancelled", id: retryID }); + await expect(retry).rejects.toMatchObject({ name: "AbortError" }); + }); + + it("rejects a concurrent transfer without taking over its handler", async () => { + const { messages, reply, bridge } = installDownloadBridge(); + const first = saveBlob(new Blob(["first"]), "first.txt"); + const handler = bridge.onmessage; + await expect(saveBlob(new Blob(["second"]), "second.txt")).rejects.toThrow( + "already in progress", + ); + expect(bridge.onmessage).toBe(handler); + expect(messages).toHaveLength(1); + reply({ type: "cancelled", id: messages[0].id }); + await expect(first).rejects.toMatchObject({ name: "AbortError" }); + }); + + it("times out a stalled picker and cancels its native request", async () => { + vi.useFakeTimers(); + const { messages, bridge, previousHandler } = installDownloadBridge(); + const download = saveBlob(new Blob(["x"]), "x.txt"); + const outcome = expect(download).rejects.toThrow("timed out"); + await vi.advanceTimersByTimeAsync(120000); + expect(messages).toHaveLength(1); + await vi.advanceTimersByTimeAsync(10000); + await outcome; + expect(messages[1]).toEqual({ type: "cancel", id: messages[0].id }); + expect(bridge.onmessage).toBe(previousHandler); + }); + + it("allows the provider copy to run for 120 seconds before timing out at 130 seconds", async () => { + vi.useFakeTimers(); + const { messages, reply, bridge, previousHandler } = + installDownloadBridge(); + const download = saveBlob(new Blob([]), "empty.txt"); + const outcome = expect(download).rejects.toThrow("timed out"); + const id = messages[0].id; + reply({ type: "ready", id }); + await vi.advanceTimersByTimeAsync(0); + expect(messages[1]).toEqual({ type: "finish", id }); + await vi.advanceTimersByTimeAsync(120000); + expect(messages).toHaveLength(2); + await vi.advanceTimersByTimeAsync(10000); + await outcome; + expect(messages[2]).toEqual({ type: "cancel", id }); + expect(bridge.onmessage).toBe(previousHandler); + }); + + it("still times out an unacknowledged chunk after 30 seconds", async () => { + vi.useFakeTimers(); + vi.spyOn(Blob.prototype, "arrayBuffer").mockResolvedValue( + Uint8Array.of(120).buffer, + ); + const { messages, reply, bridge, previousHandler } = + installDownloadBridge(); + const download = saveBlob(new Blob(["x"]), "x.txt"); + const outcome = expect(download).rejects.toThrow("timed out"); + const id = messages[0].id; + reply({ type: "ready", id }); + await vi.advanceTimersByTimeAsync(0); + expect(messages[1]).toMatchObject({ type: "chunk", id, index: 0 }); + await vi.advanceTimersByTimeAsync(29999); + expect(messages).toHaveLength(2); + await vi.advanceTimersByTimeAsync(1); + await outcome; + expect(messages[2]).toEqual({ type: "cancel", id }); + expect(bridge.onmessage).toBe(previousHandler); + }); + + it("rejects over-50-MiB files before requesting a save location", async () => { + const { messages, bridge, previousHandler } = installDownloadBridge(); + const blob = new Blob(["x"]); + Object.defineProperty(blob, "size", { value: 50 * 1024 * 1024 + 1 }); + await expect(saveBlob(blob, "large.bin")).rejects.toThrow("50 MiB"); + expect(messages).toHaveLength(0); + expect(bridge.onmessage).toBe(previousHandler); + }); + + it("cancels when the web page leaves", async () => { + const { messages } = installDownloadBridge(); + const download = saveBlob(new Blob(["x"]), "x.txt"); + window.dispatchEvent(new Event("pagehide")); + await expect(download).rejects.toMatchObject({ name: "AbortError" }); + expect(messages[1]).toEqual({ type: "cancel", id: messages[0].id }); + }); + + it("retains a native failure that arrives between acknowledged steps", async () => { + const { messages, reply } = installDownloadBridge(); + const download = saveBlob(new Blob(["x"]), "x.txt"); + const id = messages[0].id; + reply({ type: "ready", id }); + reply({ type: "error", id, message: "Save destination disappeared" }); + await expect(download).rejects.toThrow("Save destination disappeared"); + expect(messages).toHaveLength(1); + }); + + it("does not overwrite a new handler installed by another consumer", async () => { + const { messages, bridge } = installDownloadBridge(); + const controller = new AbortController(); + const download = saveBlob(new Blob(["x"]), "x.txt", { + signal: controller.signal, + }); + const nextHandler = vi.fn(); + bridge.onmessage = nextHandler; + controller.abort(); + await expect(download).rejects.toMatchObject({ name: "AbortError" }); + expect(messages[1]).toEqual({ type: "cancel", id: messages[0].id }); + expect(bridge.onmessage).toBe(nextHandler); + }); +}); diff --git a/autogpt_platform/frontend/src/lib/utils/__tests__/save-blob-fixtures.ts b/autogpt_platform/frontend/src/lib/utils/__tests__/save-blob-fixtures.ts new file mode 100644 index 000000000000..81966a74454b --- /dev/null +++ b/autogpt_platform/frontend/src/lib/utils/__tests__/save-blob-fixtures.ts @@ -0,0 +1,33 @@ +import { vi } from "vitest"; + +export interface NativeTestMessage { + type: string; + id: string; + filename?: string; + mimeType?: string; + size?: number; + index?: number; + data?: string; + message?: string; +} + +export function installDownloadBridge() { + const messages: NativeTestMessage[] = []; + const previousHandler = vi.fn(); + const bridge = { + onmessage: previousHandler as ((event: { data: unknown }) => void) | null, + postMessage: vi.fn((data: string) => { + messages.push(JSON.parse(data)); + }), + }; + Object.defineProperty(window, "AutoGPTDownloads", { + configurable: true, + value: bridge, + }); + + function reply(message: NativeTestMessage) { + bridge.onmessage?.({ data: JSON.stringify(message) }); + } + + return { bridge, messages, previousHandler, reply }; +} diff --git a/autogpt_platform/frontend/src/lib/utils/__tests__/save-blob.test.ts b/autogpt_platform/frontend/src/lib/utils/__tests__/save-blob.test.ts new file mode 100644 index 000000000000..b51e634a7c6f --- /dev/null +++ b/autogpt_platform/frontend/src/lib/utils/__tests__/save-blob.test.ts @@ -0,0 +1,59 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { saveBlob } from "../save-blob"; +import { nativeDownloadMimeType } from "../native-download-protocol"; + +describe("browser blob download", () => { + beforeEach(() => { + delete window.AutoGPTDownloads; + vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:test-download"); + vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("retains the filename and cleans the attached anchor and object URL", async () => { + let filename = ""; + let connected = false; + vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function ( + this: HTMLAnchorElement, + ) { + filename = this.download; + connected = this.isConnected; + }); + const blob = new Blob(["Hello"], { type: "text/plain" }); + await saveBlob(blob, "report.txt"); + expect(filename).toBe("report.txt"); + expect(connected).toBe(true); + expect(URL.createObjectURL).toHaveBeenCalledWith(blob); + expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:test-download"); + expect(document.querySelector('a[href="blob:test-download"]')).toBeNull(); + }); + + it("cleans up when the browser download throws", async () => { + vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => { + throw new Error("Download failed"); + }); + await expect(saveBlob(new Blob(["x"]), "x.txt")).rejects.toThrow( + "Download failed", + ); + expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:test-download"); + expect(document.querySelector('a[href="blob:test-download"]')).toBeNull(); + }); + + it("does not start an already cancelled download", async () => { + const controller = new AbortController(); + controller.abort(); + await expect( + saveBlob(new Blob(["x"]), "x.txt", { signal: controller.signal }), + ).rejects.toMatchObject({ name: "AbortError" }); + expect(URL.createObjectURL).not.toHaveBeenCalled(); + }); +}); + +it("replaces overlong MIME metadata with the native download fallback", () => { + expect(nativeDownloadMimeType(`application/${"a".repeat(130)}`)).toBe( + "application/octet-stream", + ); +}); diff --git a/autogpt_platform/frontend/src/lib/utils/download-outputs.ts b/autogpt_platform/frontend/src/lib/utils/download-outputs.ts index 8dbf51ef670c..0340d698e2db 100644 --- a/autogpt_platform/frontend/src/lib/utils/download-outputs.ts +++ b/autogpt_platform/frontend/src/lib/utils/download-outputs.ts @@ -2,6 +2,7 @@ import type { OutputRenderer, OutputMetadata, } from "@/components/contextual/OutputRenderers/types"; +import { saveBlob } from "./save-blob"; export interface DownloadItem { value: unknown; @@ -262,21 +263,10 @@ export async function downloadOutputs(items: DownloadItem[]) { const onlyFilename = Object.keys(zip.files)[0]; const entry = zip.files[onlyFilename]; const content = await entry.async("blob"); - downloadBlob(content, onlyFilename); + await saveBlob(content, onlyFilename); return; } const zipBlob = await zip.generateAsync({ type: "blob" }); - downloadBlob(zipBlob, "outputs.zip"); -} - -function downloadBlob(blob: Blob, filename: string) { - const url = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.download = filename; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(url); + await saveBlob(zipBlob, "outputs.zip"); } diff --git a/autogpt_platform/frontend/src/lib/utils/native-download-channel.ts b/autogpt_platform/frontend/src/lib/utils/native-download-channel.ts new file mode 100644 index 000000000000..ec01059eed3d --- /dev/null +++ b/autogpt_platform/frontend/src/lib/utils/native-download-channel.ts @@ -0,0 +1,118 @@ +import { + NativeDownloadBridge, + readNativeDownloadReply, +} from "./native-download-protocol"; + +interface PendingReply { + type: string; + index?: number; + resolve: () => void; + reject: (error: Error) => void; + timer: ReturnType; +} + +export class NativeDownloadChannel { + private pending?: PendingReply; + private failure?: Error; + private started = false; + private remoteClosed = false; + private cancellationSent = false; + private previousHandler: NativeDownloadBridge["onmessage"]; + private readonly onMessage = this.receive.bind(this); + private readonly onAbort = this.abort.bind(this); + + constructor( + private bridge: NativeDownloadBridge, + private id: string, + private signal?: AbortSignal, + ) { + this.previousHandler = bridge.onmessage; + bridge.onmessage = this.onMessage; + window.addEventListener("pagehide", this.onAbort); + signal?.addEventListener("abort", this.onAbort, { once: true }); + if (signal?.aborted) this.abort(); + } + + request(message: Record, type: string, timeout: number) { + if (this.failure) return Promise.reject(this.failure); + if (this.pending) + return Promise.reject(new Error("A download reply is still pending.")); + return new Promise((resolve, reject) => { + this.pending = { + type, + index: + type === "ack" && typeof message.index === "number" + ? message.index + : undefined, + resolve, + reject, + timer: setTimeout(() => { + this.fail(new Error("Download timed out. Please try again.")); + this.cancel(); + }, timeout), + }; + try { + if (message.type === "start") this.started = true; + this.bridge.postMessage(JSON.stringify({ ...message, id: this.id })); + } catch { + this.fail(new Error("Could not start the native download.")); + } + }); + } + + cancel() { + if (!this.started || this.remoteClosed || this.cancellationSent) return; + this.cancellationSent = true; + try { + this.bridge.postMessage(JSON.stringify({ type: "cancel", id: this.id })); + } catch {} + } + + close() { + window.removeEventListener("pagehide", this.onAbort); + this.signal?.removeEventListener("abort", this.onAbort); + if (this.bridge.onmessage === this.onMessage) { + this.bridge.onmessage = this.previousHandler; + } + if (this.pending) clearTimeout(this.pending.timer); + this.pending = undefined; + } + + private abort() { + this.fail(new DOMException("Download cancelled.", "AbortError")); + this.cancel(); + } + + private fail(error: Error) { + this.failure ??= error; + const pending = this.pending; + this.pending = undefined; + if (!pending) return; + clearTimeout(pending.timer); + pending.reject(this.failure); + } + + private receive(event: { data: unknown }) { + const reply = readNativeDownloadReply(event.data); + if (!reply || reply.id !== this.id) { + this.previousHandler?.call(this.bridge, event); + return; + } + if (reply.type === "error" || reply.type === "cancelled") { + this.remoteClosed = true; + this.fail( + reply.type === "cancelled" + ? new DOMException("Download cancelled.", "AbortError") + : new Error(reply.message || "Could not save the file."), + ); + return; + } + const pending = this.pending; + if (!pending || reply.type !== pending.type) return; + if (pending.type === "ack" && reply.index !== pending.index) return; + if (reply.type === "complete") this.remoteClosed = true; + clearTimeout(pending.timer); + this.pending = undefined; + pending.resolve(); + } +} diff --git a/autogpt_platform/frontend/src/lib/utils/native-download-protocol.ts b/autogpt_platform/frontend/src/lib/utils/native-download-protocol.ts new file mode 100644 index 000000000000..65898528a33b --- /dev/null +++ b/autogpt_platform/frontend/src/lib/utils/native-download-protocol.ts @@ -0,0 +1,65 @@ +export interface NativeDownloadBridge { + postMessage(message: string): void; + onmessage?: ((event: { data: unknown }) => void) | null; +} + +declare global { + interface Window { + AutoGPTDownloads?: NativeDownloadBridge; + } +} + +export const NATIVE_DOWNLOAD_MAX_BYTES = 50 * 1024 * 1024; +export const NATIVE_DOWNLOAD_CHUNK_BYTES = 48 * 1024; +export const NATIVE_DOWNLOAD_PICKER_TIMEOUT_MS = 130000; +export const NATIVE_DOWNLOAD_FINISH_TIMEOUT_MS = 130000; +export const NATIVE_DOWNLOAD_STEP_TIMEOUT_MS = 30000; + +export function readNativeDownloadReply(data: unknown) { + if (typeof data !== "string" || data.length > 16384) return null; + try { + const value: unknown = JSON.parse(data); + if (!value || typeof value !== "object") return null; + if (!("id" in value) || typeof value.id !== "string") return null; + if (!("type" in value) || typeof value.type !== "string") return null; + return { + id: value.id, + type: value.type, + index: + "index" in value && typeof value.index === "number" + ? value.index + : undefined, + message: + "message" in value && typeof value.message === "string" + ? value.message.slice(0, 500) + : undefined, + }; + } catch { + return null; + } +} + +export function nativeDownloadMimeType(mimeType: string) { + const bare = mimeType.split(";")[0].trim(); + return bare.length <= 128 && + /^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/i.test(bare) + ? bare + : "application/octet-stream"; +} + +export function nativeDownloadFilename(filename: string) { + let result = ""; + for (const character of filename.trim()) { + const code = character.charCodeAt(0); + const safe = + character === "/" || + character === "\\" || + code < 32 || + (code >= 127 && code <= 159) + ? "_" + : character; + if (result.length + safe.length > 240) break; + result += safe; + } + return !result || result === "." || result === ".." ? "download" : result; +} diff --git a/autogpt_platform/frontend/src/lib/utils/save-blob.ts b/autogpt_platform/frontend/src/lib/utils/save-blob.ts new file mode 100644 index 000000000000..d70c6777e442 --- /dev/null +++ b/autogpt_platform/frontend/src/lib/utils/save-blob.ts @@ -0,0 +1,100 @@ +import { NativeDownloadChannel } from "./native-download-channel"; +import { + NATIVE_DOWNLOAD_CHUNK_BYTES, + NATIVE_DOWNLOAD_FINISH_TIMEOUT_MS, + NATIVE_DOWNLOAD_MAX_BYTES, + NATIVE_DOWNLOAD_PICKER_TIMEOUT_MS, + NATIVE_DOWNLOAD_STEP_TIMEOUT_MS, + nativeDownloadFilename, + nativeDownloadMimeType, +} from "./native-download-protocol"; + +let nativeDownloadActive = false; + +function saveBrowserBlob(blob: Blob, filename: string) { + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + try { + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + } finally { + link.remove(); + URL.revokeObjectURL(url); + } +} + +function downloadID() { + return Array.from(crypto.getRandomValues(new Uint8Array(16)), (byte) => + byte.toString(16).padStart(2, "0"), + ).join(""); +} + +async function sendBlob(channel: NativeDownloadChannel, blob: Blob) { + for ( + let offset = 0, index = 0; + offset < blob.size; + offset += NATIVE_DOWNLOAD_CHUNK_BYTES, index++ + ) { + const bytes = new Uint8Array( + await blob + .slice(offset, offset + NATIVE_DOWNLOAD_CHUNK_BYTES) + .arrayBuffer(), + ); + const data = btoa(String.fromCharCode(...bytes)); + await channel.request( + { type: "chunk", index, data }, + "ack", + NATIVE_DOWNLOAD_STEP_TIMEOUT_MS, + ); + } + await channel.request( + { type: "finish" }, + "complete", + NATIVE_DOWNLOAD_FINISH_TIMEOUT_MS, + ); +} + +export async function saveBlob( + blob: Blob, + filename: string, + options: { signal?: AbortSignal } = {}, +) { + if (options.signal?.aborted) + throw new DOMException("Download cancelled.", "AbortError"); + const bridge = window.top === window ? window.AutoGPTDownloads : undefined; + if (!bridge || typeof bridge.postMessage !== "function") { + saveBrowserBlob(blob, filename); + return; + } + if (blob.size > NATIVE_DOWNLOAD_MAX_BYTES) { + throw new Error( + "Files larger than 50 MiB must be downloaded in your browser.", + ); + } + if (nativeDownloadActive) + throw new Error("A download is already in progress."); + nativeDownloadActive = true; + let channel: NativeDownloadChannel | undefined; + try { + channel = new NativeDownloadChannel(bridge, downloadID(), options.signal); + await channel.request( + { + type: "start", + filename: nativeDownloadFilename(filename), + mimeType: nativeDownloadMimeType(blob.type), + size: blob.size, + }, + "ready", + NATIVE_DOWNLOAD_PICKER_TIMEOUT_MS, + ); + await sendBlob(channel, blob); + } catch (error) { + channel?.cancel(); + throw error; + } finally { + channel?.close(); + nativeDownloadActive = false; + } +} diff --git a/autogpt_platform/frontend/src/providers/onboarding/__tests__/onboarding-provider-routing.test.tsx b/autogpt_platform/frontend/src/providers/onboarding/__tests__/onboarding-provider-routing.test.tsx index 6d8dbe73ae57..bcd083a7386b 100644 --- a/autogpt_platform/frontend/src/providers/onboarding/__tests__/onboarding-provider-routing.test.tsx +++ b/autogpt_platform/frontend/src/providers/onboarding/__tests__/onboarding-provider-routing.test.tsx @@ -172,4 +172,16 @@ describe("OnboardingProvider routing — logged-in user", () => { await new Promise((r) => setTimeout(r, 30)); expect(routerReplace).not.toHaveBeenCalled(); }); + + test("mobile auth consent completes before onboarding redirects", async () => { + mockPathname = "/auth/mobile"; + render( + +
+ , + ); + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(routerReplace).not.toHaveBeenCalled(); + expect(completedCallCount.value).toBe(0); + }); }); diff --git a/autogpt_platform/frontend/src/providers/onboarding/onboarding-provider.tsx b/autogpt_platform/frontend/src/providers/onboarding/onboarding-provider.tsx index bf84ddff24da..28c0ca60fa74 100644 --- a/autogpt_platform/frontend/src/providers/onboarding/onboarding-provider.tsx +++ b/autogpt_platform/frontend/src/providers/onboarding/onboarding-provider.tsx @@ -124,6 +124,7 @@ export default function OnboardingProvider({ // bouncing an un-onboarded account to /onboarding would skip the password // change and turn the reset link into a one-time sign-in link. const isOnPasswordResetRoute = pathname.startsWith("/reset-password"); + const isOnMobileAuthRoute = pathname === "/auth/mobile"; // Logged-in users sitting on the auth pages need to be routed onward by us; // otherwise the signup/login pages show their `isLoggedIn` loader forever. // Handling them here (instead of in useSignupPage/useLoginPage) avoids the @@ -165,7 +166,8 @@ export default function OnboardingProvider({ hasInitialized.current || !isLoggedIn || isOnPublicTour || - isOnPasswordResetRoute + isOnPasswordResetRoute || + isOnMobileAuthRoute ) { return; } @@ -233,6 +235,7 @@ export default function OnboardingProvider({ pathname, isOnPublicTour, isOnPasswordResetRoute, + isOnMobileAuthRoute, ]); const handleOnboardingNotification = useCallback( diff --git a/autogpt_platform/mobile/README.md b/autogpt_platform/mobile/README.md new file mode 100644 index 000000000000..1d042393ed7b --- /dev/null +++ b/autogpt_platform/mobile/README.md @@ -0,0 +1,46 @@ +# AutoGPT mobile apps + +Native iOS and Android hosts for AutoGPT's existing chat interfaces. The default entry point is `https://platform.agpt.co/copilot`; navigation to other AutoGPT pages stays on the same configured website. Conversations, streaming, agents, tools, history, account settings, and onboarding remain implemented by the web application. Web improvements reach the apps without copying components or issuing an app update. + +- [Build and test iOS](ios/README.md) +- [Build and test Android](android/README.md) +- [Native integration fixture and PostgreSQL checks](testing/README.md) +- [Progress screenshots](screenshots/README.md) + +This is a development prototype. Store publication, production deployment, release signing identities, and store privacy declarations are not configured by this change. + +## Native responsibilities + +The shells manage persistent website sessions, safe URL handling, system-browser sign-in, keyboard and safe areas, Back navigation, loading/recovery, file selection, file export, and user-controlled microphone permission. General responsive-web design remains in the separate web workstream. + +The iOS app uses UIKit, WebKit, AuthenticationServices, and a local Swift package. Android uses the platform WebView and small AndroidX components. There is no separate chat API client, copied message renderer, or React Native/Capacitor runtime. + +HTTPS origins can be selected explicitly for self-hosted and preview deployments. Changing servers clears the app's website session. Debug builds allow loopback HTTP for local tests; release builds require HTTPS. Only the chosen origin is trusted inside the main WebView. External web links use the system browser, and unsupported schemes are rejected. + +## Browser sign-in + +The selected web deployment must include the mobile authentication endpoints from this PR. Until those endpoints are deployed, the existing hosted website can be used through **Open in browser**, but the app's browser-to-app sign-in cannot complete against that deployment. + +1. The app creates a random proof and state, then opens the existing web login in the system authentication browser. +2. The signed-in browser asks the user to connect the app, displaying and binding the current account. +3. The website returns a 90-second, single-use code through the fixed `autogpt://auth/callback` URL. +4. The app validates its pending state and exchanges the code with its proof. The server rechecks account policy and source-session validity and creates a fresh session. +5. The app clears previous website identity/cache data, installs the new HttpOnly cookies, and loads the hosted chat. + +Long-lived session tokens and provider credentials are never put in the callback URL. The browser and app retain separate sessions. Ticket storage reuses Better Auth's existing verification table; no database migration or new package is required. + +## Files and voice + +File inputs use the operating system's picker without broad storage permissions. iOS uses WebKit's download support, including generated blobs, followed by the system share/export sheet. + +Android cannot download browser `blob:` URLs directly. The existing web export functions use one shared `saveBlob` helper that detects a narrow, origin-scoped `AutoGPTDownloads` capability. It asks the user where to save, transfers at most 50 MiB in acknowledged chunks, and stages bytes privately before copying the completed file. It cannot read native files, expose cookies, or invoke arbitrary native methods. Other browsers and iOS retain the normal browser download path. Selected Android document URIs are never automatically deleted on cancellation; provider failures can leave partial output at the user-selected destination. + +Microphone requests are restricted to the current trusted page and require platform/user permission. The fixture's microphone probe stops tracks immediately and records or uploads no audio. + +## Maintenance and verification + +Keep `/api/auth/mobile/*`, the fixed callback, and the `AutoGPTDownloads` message contract backward compatible when changing the web application. The [platform build instructions](android/README.md#files-and-navigation) document the Android protocol and limits. App packages still need occasional operating-system, security, signing, and dependency maintenance; hosting the UI removes the separate chat-feature implementation. + +The mobile CI workflow builds the native projects, runs policy/fixture tests and native iOS UI regressions, and retains downloadable Android debug APKs and arm64 iOS simulator apps for seven days. Simulator apps can be installed with `simctl`; physical iPhone builds still require a development signing team. Monthly Dependabot checks cover Android dependencies, grouping minor and patch updates to keep the review queue small; major updates remain separate. Dependency updates still require passing CI and review. Existing frontend CI covers the shared web changes. The optional PostgreSQL harness uses an isolated disposable container and multiple worker processes to verify one-use redemption and session policy against the real database adapter. + +Current evidence is recorded separately for compilation, policy tests, native UI checks, and real hosted behavior. Local fixtures are labelled explicitly and do not prove a live account, model response, provider login, or Android device behavior. Target devices are iPhone 17 Pro and Pixel 11 Pro; the initially available local iOS runtime is iPhone 16 Pro / iOS 18.3. diff --git a/autogpt_platform/mobile/android/.gitignore b/autogpt_platform/mobile/android/.gitignore new file mode 100644 index 000000000000..19c2b5bca815 --- /dev/null +++ b/autogpt_platform/mobile/android/.gitignore @@ -0,0 +1,7 @@ +/.gradle/ +/.kotlin/ +/local.properties +/build/ +/app/build/ +*.jks +*.keystore diff --git a/autogpt_platform/mobile/android/README.md b/autogpt_platform/mobile/android/README.md new file mode 100644 index 000000000000..c5accd0f27e7 --- /dev/null +++ b/autogpt_platform/mobile/android/README.md @@ -0,0 +1,106 @@ +# AutoGPT for Android + +A small native host for the existing AutoGPT website. Chat rendering, streaming, conversation history, agents, tools, and account screens remain in the web application. The app defaults to `https://platform.agpt.co/copilot`. + +The primary device target is Pixel 11 Pro, the compact flagship Android peer of iPhone 17 Pro. This project compiles against Android API 36, targets API 36, and supports Android 10/API 29 or later with an updated Android System WebView. No emulator image or Android Studio installation is required to build it. + +## Build and install + +Requirements: + +- Java 17. +- Android SDK platform 36, build tools 35.0.0, and platform tools. +- `ANDROID_HOME` pointing to that SDK. Alternatively, put `sdk.dir=/absolute/path/to/sdk` in the ignored `local.properties` file. + +From this directory: + +```sh +./gradlew --no-daemon assembleDebug testDebugUnitTest lint +adb install -r app/build/outputs/apk/debug/app-debug.apk +``` + +The Gradle wrapper pins Gradle 8.13 and verifies its distribution SHA-256. Android Gradle Plugin 8.13.2 and Kotlin 2.2.21 are pinned. Builds use at most two Gradle workers and a 1.5 GiB Java heap. The Gradle/AndroidX versions are deliberately pinned to the API 36 toolchain; lint may report newer available dependencies. + +The debug APK uses the normal local Android debug signing key. It is suitable for development and device testing. Production signing, Play distribution, and release credentials are not configured. + +## Connect to a server + +Open the app menu → **Server settings**, enter a bare HTTPS origin, and confirm the destination. Examples are `https://platform.agpt.co` and `https://my-autogpt.example:8443`. Paths, credentials, query strings, fragments, and ambiguous hostnames are rejected. Changing the server clears the app's existing website data before opening the new server. + +Debug builds additionally allow HTTP only on `localhost`, `127.0.0.1`, and the Android emulator host address `10.0.2.2`. For the mobile fixture running on port 8765: + +- Emulator: configure `http://10.0.2.2:8765`. +- USB device: run `adb reverse tcp:8765 tcp:8765`, then configure `http://127.0.0.1:8765`. + +The fixture is a labeled test website, not evidence that production authentication or real chat APIs work. The new mobile authentication routes must be deployed on a chosen AutoGPT server before browser sign-in works there. They are not assumed to exist on the public production server yet. + +## Browser sign-in + +The app's **Sign in** action opens a system Custom Tab at `/api/auth/mobile/start` with a fresh S256 PKCE challenge and random state. A matching `autogpt://auth/callback` exchanges the single-use code at `/api/auth/mobile/exchange`. Redirects are disabled for that HTTP exchange; requests include the exact configured `Origin` header. + +Successful exchanges must return HTTP 200 and a usable HttpOnly BetterAuth session-token cookie, alongside any session-cache cookies. The native client ignores response bodies and enforces an absolute 30-second exchange deadline; cancellation disconnects the owned HTTP request. HTTPS session cookies must also be Secure. Explicit cookie domains must exactly match the configured host; accepted domains are removed before inserting host-only WebView cookies. Before installing the complete fresh cookie family, the app destroys the old page and waits for `WebStorageCompat.deleteBrowsingData` to finish. This removes stale session-cache cookies, local storage, IndexedDB, and cache. Server changes and account replacement are serialized through a retained ViewModel, including activity recreation. + +PKCE verifiers and pending cookies stay in memory and survive rotation. They are never placed in URLs, preferences, saved-instance bundles, logs, or a credential store. If Android kills the whole app during sign-in, the callback is rejected and the user starts again. Pending sign-in expires after ten minutes. A state-bound `access_denied` response cancels without clearing the previously signed-in account. + +An updated System WebView with the `DELETE_BROWSING_DATA` feature is required for signing in, switching servers, and clearing the session. Older WebViews display an update message. App backup and device-transfer backup are disabled for website/session data. + +## Files and navigation + +Web file inputs use Android's document picker without broad storage permissions. Each selection is bound to its original WebView, document generation, and server; late results after navigation or recreation are discarded. The native activity validates content-URI shape without opening cloud files on the main thread, leaving file reads to WebView. The existing web voice recorder can request Android microphone permission; only audio capture from the configured origin in the current WebView is eligible. Camera and unrelated web permissions remain denied, and pending microphone grants are discarded after navigation or account/server changes. HTTPS downloads from the configured origin can be saved to temporary app storage and exported through the system share sheet. Such requests carry cookies only to the same origin, including every redirect, and are limited to 50 MiB. Temporary exports are pruned after a day or beyond a 100 MiB retained budget and cleared during account/server changes. Active requests are cancelled when their WebView is destroyed; they cannot present old-account share results after a switch. + +Generated chat files use the web `saveBlob` helper and the narrow `AutoGPTDownloads` WebMessage interface. The object exists only when the WebView supports origin-scoped web messages. The native receiver verifies the configured source origin, the current top-level page, and `isMainFrame` for every request. It offers only a write-only download flow: + +1. `{type:"start", id, filename, mimeType, size}` opens Android's **Create document** picker. `ready` is sent after a destination is selected and private staging storage is opened. The chosen destination is untouched while chunks arrive. +2. `{type:"chunk", id, index, data}` streams standard base64 bytes into an app-owned temporary file and receives `{type:"ack", id, index}` after the write completes. +3. `{type:"finish", id}` succeeds only when exactly the declared number of bytes has been written, copies that completed file to the chosen destination, then replies `complete`. +4. `{type:"cancel", id}` replies `cancelled`. Errors reply `error` with a short message. + +There is one transfer at a time, a 50 MiB file limit, a 64 KiB decoded-chunk limit, and a 90 KiB JSON-message limit. Chunks must be sequential and acknowledgement-paced. Inactivity for two minutes, full-page navigation, server changes, activity destruction, or renderer death aborts the transfer and removes app-owned staging. Selected document URIs are never automatically deleted: Android's Save picker can return an existing file after overwrite confirmation. Cancellation before the final copy leaves the selected destination untouched; interruption during that copy can leave partial output, which the app reports. Provider writes use a separate pool with at most two active copies, cancellable descriptors, and a cancellation signal. An unresponsive provider or kernel operation cannot be forcibly terminated; exhausted copy slots return a busy error without opening more provider handles. The interface exposes no native reads, cookies, arbitrary file paths, or commands. No DOM or browser-API monkeypatches are injected. + +Same-origin links stay in the WebView. HTTPS video embeds and configured-origin blob previews remain available in subframes, which never receive native download privileges. External HTTPS links and user-initiated popups go to the system browser; `mailto:` and `tel:` links use their normal handlers. Invalid or unsolicited external navigation is blocked. Certificate errors always fail closed. Android Back follows web history and returns to the operating system at the root. The shell handles system-bar/cutout insets once, forwards keyboard insets to modern WebView, restores bounded navigation history, and presents recovery after renderer termination. + +## Formatting + +All Kotlin sources and Gradle Kotlin scripts use ktfmt 0.64 with Kotlin language style. With the [official formatter](https://github.com/Kotlin/ktfmt/releases/tag/v0.64) downloaded, format the entire project from this directory: + +```sh +rg --files -g '*.kt' -g '*.kts' -0 | xargs -0 java -jar /path/to/ktfmt.jar --kotlinlang-style +``` + +The launcher image reuses the web app's notification icon. The themed monochrome launcher uses the existing `AutoGPTLogoWhite` SVG geometry, without its wordmark. Native status screens support system light/dark appearance, large text, and scrolling when the available height is short. + +## Verification + +Automated checks cover exact-origin matching and spoof attempts, debug HTTP restrictions, the RFC 7636 challenge vector, callback state/expiry/cancellation, session-cookie protection, download message bounds, untrusted-frame rejection, exact byte counts, chunk ordering, and cancellation. Android lint and the debug APK build run for the entire project. + +Device verification is still required for real browser sign-in and switching between two accounts, Custom Tabs callbacks, file pickers and cloud document providers, portrait/landscape keyboard behavior, predictive Back, background process death, TalkBack, large text, and real streaming chats. An APK build and JVM tests do not substitute for those runtime checks. + +## Disposable-device runtime probe + +The dependency-free instrumentation suite checks the installed WebView's required capabilities, completion ordering of full browsing-data deletion, and the real native download listener. Its top-frame request gets a simulated picker cancellation, malformed metadata is rejected, a same-origin iframe cannot launch a picker, and an unrelated origin receives no bridge object. + +With `--fixture-origin`, it also verifies the real `AuthViewModel` against the labelled local fixture: PKCE callback and native HTTP exchange, both token and cache cookies, old-account cookie removal before success, authenticated fixture requests, and preservation of the current cookies when sign-in is cancelled. The fixture origin must be a loopback host or Android's emulator host alias; the runner checks `/health` identifies the native fixture first. + +**The probe clears this app's WebView data. Use only a disposable emulator without real accounts.** It requires an explicit test-only argument. Start the local server using `../testing/README.md`, then run: + +```sh +./gradlew assembleDebug assembleDebugAndroidTest +adb -s emulator-5554 install -r app/build/outputs/apk/debug/app-debug.apk +adb -s emulator-5554 install -r app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk +adb -s emulator-5554 reverse tcp:8765 tcp:8765 +python3 scripts/run-runtime-probe.py --serial emulator-5554 --disposable \ + --fixture-origin http://127.0.0.1:8765 --output runtime-probe.txt +adb -s emulator-5554 reverse --remove tcp:8765 +``` + +Pass `--adb /absolute/path/to/adb` if it is not on `PATH`. Omit `--fixture-origin` to run only the three Android platform checks. The wrapper enforces a three-minute deadline and checks the explicit `PASS` status, suite name, expected check count, and `INSTRUMENTATION_CODE: -1` (`Activity.RESULT_OK`). An `adb shell` exit status of zero alone is insufficient: failed instrumentation can still return zero. For direct inspection, the full-fixture command is: + +```sh +adb -s emulator-5554 shell am instrument -w -r -e disposable true \ + -e fixtureOrigin http://127.0.0.1:8765 \ + com.agpt.mobile.test/com.agpt.mobile.RuntimeProbe +``` + +All four checks passed on the API 36 Google APIs arm64 revision 7 image with a Pixel 9 Pro profile and Google WebView 133.0.6943.137. Omitting the disposable-device argument was verified to refuse execution; the wrapper was also verified to fail on an actual instrumentation failure. This is API 36 emulator evidence, distinct from the intended Pixel 11 Pro device target. Browser sign-in UI, real system save/upload pickers, and physical-device microphone quality still need separate validation. + +Screenshots, exact environment, APK hash, and raw results are in [Android runtime evidence](docs/evidence/README.md). diff --git a/autogpt_platform/mobile/android/app/build.gradle.kts b/autogpt_platform/mobile/android/app/build.gradle.kts new file mode 100644 index 000000000000..e35ae52a9172 --- /dev/null +++ b/autogpt_platform/mobile/android/app/build.gradle.kts @@ -0,0 +1,46 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "com.agpt.mobile" + compileSdk = 36 + + defaultConfig { + applicationId = "com.agpt.mobile" + minSdk = 29 + targetSdk = 36 + versionCode = 1 + versionName = "0.1.0" + testInstrumentationRunner = "com.agpt.mobile.RuntimeProbe" + } + + buildFeatures { + buildConfig = true + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + lint { + abortOnError = true + checkReleaseBuilds = true + } +} + +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } +} + +dependencies { + implementation("androidx.activity:activity-ktx:1.10.1") + implementation("androidx.browser:browser:1.9.0") + implementation("androidx.core:core-ktx:1.16.0") + implementation("androidx.webkit:webkit:1.14.0") + implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.9.1") + testImplementation("junit:junit:4.13.2") + testImplementation("org.json:json:20250517") +} diff --git a/autogpt_platform/mobile/android/app/src/androidTest/java/com/agpt/mobile/RuntimeAuthProbe.kt b/autogpt_platform/mobile/android/app/src/androidTest/java/com/agpt/mobile/RuntimeAuthProbe.kt new file mode 100644 index 000000000000..0462c3113410 --- /dev/null +++ b/autogpt_platform/mobile/android/app/src/androidTest/java/com/agpt/mobile/RuntimeAuthProbe.kt @@ -0,0 +1,145 @@ +package com.agpt.mobile + +import android.app.Application +import android.content.Context +import android.net.Uri +import android.webkit.CookieManager +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStore +import java.net.HttpURLConnection +import java.net.URL +import java.util.concurrent.CompletableFuture +import java.util.concurrent.TimeUnit +import org.json.JSONObject + +class RuntimeAuthProbe(private val context: Context, private val runtime: RuntimeSupport) { + fun run(value: String) { + val origin = requireNotNull(ServerOrigin.parse(value, true)) + check(origin.value == value) + check(Uri.parse(value).host in setOf("localhost", "127.0.0.1", "10.0.2.2")) { + "Runtime authentication only supports the disposable local fixture" + } + check(request(origin, "/health").optBoolean("fixture")) { + "The target is not the labelled native integration fixture" + } + val store = ViewModelStore() + val model = runtime.main { + ViewModelProvider( + store, + ViewModelProvider.AndroidViewModelFactory( + context.applicationContext as Application + ), + )[AuthViewModel::class.java] + } + var cookies: String? = null + try { + val start = Uri.parse(runtime.main { model.start(origin) }) + val callback = + request( + origin, + "/api/auth/mobile/authorize", + JSONObject() + .put("code_challenge", start.getQueryParameter("code_challenge")) + .put("state", start.getQueryParameter("state")) + .put("expected_user_id", "fixture-user"), + ) + .getString("url") + check( + runtime.callback { completed -> + CookieManager.getInstance().setCookie( + origin.value, + "runtime_old_account=disposable; Path=/; HttpOnly; Max-Age=120", + ) { + completed.complete(it) + } + } + ) + val installed = CompletableFuture() + runtime.main { + model.listener = { + when (model.status) { + AuthViewModel.Status.READY_TO_INSTALL -> model.installReadySession() + AuthViewModel.Status.SUCCESS -> installed.complete(Unit) + AuthViewModel.Status.FAILED, + AuthViewModel.Status.EXPIRED -> + installed.completeExceptionally( + AssertionError("Native fixture exchange failed") + ) + else -> Unit + } + } + model.acceptCallback(callback, origin) + } + installed.get(35, TimeUnit.SECONDS) + cookies = runtime.main { CookieManager.getInstance().getCookie(origin.value) } + check(!cookies.orEmpty().contains("runtime_old_account=")) { + "Old-account cookie survived successful installation" + } + val session = request(origin, "/api/fixture/session", cookie = cookies) + check(session.getBoolean("authenticated")) + check(session.getBoolean("cacheCookieReceived")) + runtime.main { model.acknowledge() } + val cancelledStart = Uri.parse(runtime.main { model.start(origin) }) + runtime.main { + model.acceptCallback( + "autogpt://auth/callback?error=access_denied&state=${cancelledStart.getQueryParameter("state")}", + origin, + ) + } + check(runtime.main { model.status } == AuthViewModel.Status.CANCELED) + check(runtime.main { CookieManager.getInstance().getCookie(origin.value) } == cookies) { + "Cancelled sign-in replaced the prior session" + } + } finally { + runtime.main { store.clear() } + cookies?.let { request(origin, "/api/fixture/logout", JSONObject(), it) } + check( + runtime.callback { completed -> + BrowserSession.clear { completed.complete(it) } + } + ) + } + } + + private fun request( + origin: ServerOrigin, + path: String, + body: JSONObject? = null, + cookie: String? = null, + ): JSONObject { + val connection = URL(origin.value + path).openConnection() as HttpURLConnection + try { + connection.instanceFollowRedirects = false + connection.connectTimeout = 5_000 + connection.readTimeout = 5_000 + connection.setRequestProperty("Origin", origin.value) + cookie?.let { connection.setRequestProperty("Cookie", it) } + if (body != null) { + connection.requestMethod = "POST" + connection.doOutput = true + connection.setRequestProperty("Content-Type", "application/json") + val data = body.toString().toByteArray() + connection.setFixedLengthStreamingMode(data.size) + connection.outputStream.use { it.write(data) } + } + check(connection.responseCode == 200) { + "Local fixture returned HTTP ${connection.responseCode}" + } + val bytes = ByteArray(16 * 1024) + val length = + connection.inputStream.use { input -> + var total = 0 + while (total < bytes.size) { + val count = input.read(bytes, total, bytes.size - total) + if (count < 0) break + total += count + } + total + } + check(length < bytes.size) { "Unexpectedly large fixture response" } + return JSONObject(String(bytes, 0, length, Charsets.UTF_8)) + } finally { + connection.disconnect() + } + } +} diff --git a/autogpt_platform/mobile/android/app/src/androidTest/java/com/agpt/mobile/RuntimeDownloadProbe.kt b/autogpt_platform/mobile/android/app/src/androidTest/java/com/agpt/mobile/RuntimeDownloadProbe.kt new file mode 100644 index 000000000000..5f11ff9dd108 --- /dev/null +++ b/autogpt_platform/mobile/android/app/src/androidTest/java/com/agpt/mobile/RuntimeDownloadProbe.kt @@ -0,0 +1,78 @@ +package com.agpt.mobile + +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import android.webkit.WebView + +class RuntimeDownloadProbe(private val context: Context, private val runtime: RuntimeSupport) { + @SuppressLint("SetJavaScriptEnabled") + fun run() { + val origin = requireNotNull(ServerOrigin.parse("https://android-runtime.invalid", false)) + val view = runtime.main { WebView(context).apply { settings.javaScriptEnabled = true } } + var pickerRequests = 0 + lateinit var downloads: NativeDownloads + runtime.main { + downloads = + NativeDownloads(context, view, origin) { intent -> + check(intent.action == Intent.ACTION_CREATE_DOCUMENT) + check(intent.getStringExtra(Intent.EXTRA_TITLE) == "runtime-probe.txt") + pickerRequests++ + downloads.onDocumentCreated(null) + } + downloads.attach() + } + try { + runtime.load(view, origin.chatUrl) + check(runtime.main { origin.contains(view.url.orEmpty()) }) { + "Offscreen document URL is ${runtime.main { view.url }}" + } + check(runtime.script(view, "typeof AutoGPTDownloads") == "\"object\"") + runtime.script( + view, + """ + window.probeReply = null; + AutoGPTDownloads.onmessage = event => { window.probeReply = JSON.parse(event.data).type; }; + AutoGPTDownloads.postMessage(JSON.stringify({type:'start', id:'top', filename:'runtime-probe.txt', mimeType:'text/plain', size:1})); + """ + .trimIndent(), + ) + runtime.awaitScript(view, "window.probeReply", "\"cancelled\"") + check(runtime.main { pickerRequests } == 1) + runtime.script( + view, + """ + window.probeReply = null; + AutoGPTDownloads.postMessage(JSON.stringify({type:'start', id:'bad', filename:'runtime-probe.txt', mimeType:'text/plain', size:-1})); + """ + .trimIndent(), + ) + runtime.awaitScript(view, "window.probeReply", "\"error\"") + check(runtime.main { pickerRequests } == 1) + runtime.script( + view, + """ + window.frameDone = false; + window.addEventListener('message', event => { if (event.data === 'frame-sent') window.frameDone = true; }); + const frame = document.createElement('iframe'); + frame.srcdoc = ` + + +
+

Native integration fixture

+

Ready for a device check.

+

+ Deterministic native shell probes. This is a local test page, with no + live chat or account. +

+ +
+

Browser sign-in & session

+

+ Use the app's native Sign in action to complete its PKCE browser + handoff, then check the cookie here. +

+ Checking fixture session… +
+ +
+ +
+
+

Keyboard & attachments

+ + +

+ Check keyboard dismissal, rotation, safe areas, text selection, and + long input. +

+ + + + + No attachments selected. Files stay on this device. +

Microphone capability

+

+ This button requests microphone access, then immediately stops all + tracks. It does not record or upload audio. +

+ + + Not checked. A system permission prompt may appear. + +
+
+

Streaming transport

+

+ Fetch reads five SSE test-text chunks as they arrive, about 600 ms + apart. This validates transport behavior without a live chat. +

+
+ + + + +
+ + Ready. Test text only; nothing is sent to an AI model. + +

+        

+ The dropped-stream probe intentionally disconnects after chunk 2. + Partial text must remain visible with an error, not a completed state. +

+
+
+ + +

+
+
+

Downloads & recovery

+ +

+

+ For a true offline recovery check, stop this fixture server, reload in + the native app, restart the server, and retry. +

+
+
AutoGPT mobile · local integration evidence only
+
+ + diff --git a/autogpt_platform/mobile/testing/fixture.js b/autogpt_platform/mobile/testing/fixture.js new file mode 100644 index 000000000000..22cea982f3d4 --- /dev/null +++ b/autogpt_platform/mobile/testing/fixture.js @@ -0,0 +1,193 @@ +async function refreshSession() { + const result = document.querySelector("#session-result"); + try { + const response = await fetch("/api/fixture/session", { + credentials: "same-origin", + }); + const session = await response.json(); + result.textContent = session.authenticated + ? session.cacheCookieReceived + ? "Fixture session connected · token + cache cookies received" + : "Fixture session connected · cache cookie missing" + : "No fixture session · use native Sign in"; + result.dataset.connected = String(session.authenticated); + } catch { + result.textContent = "Fixture server unreachable"; + } + document.querySelector("#cookie-result").textContent = + document.cookie.includes("better-auth.session_") + ? "Unexpected: fixture session is visible to page JavaScript." + : "HttpOnly check: fixture session cookie is not readable by page JavaScript."; +} + +function showFiles(event) { + const names = Array.from( + event.target.files, + (file) => `${file.name} (${file.size} bytes)`, + ); + document.querySelector("#file-result").textContent = names.length + ? names.join(" · ") + : "Selection canceled or empty."; +} + +document + .querySelector("#refresh-session") + .addEventListener("click", refreshSession); +document.querySelector("#clear-session").addEventListener("click", async () => { + await fetch("/api/fixture/logout", { method: "POST" }); + await refreshSession(); +}); +document.querySelector("#single-file").addEventListener("change", showFiles); +document.querySelector("#multiple-files").addEventListener("change", showFiles); +document + .querySelector("#popup") + .addEventListener("click", () => + window.open("https://example.com/", "_blank", "noopener"), + ); +document.querySelector("#location-result").textContent = + `Current route: ${location.pathname}${location.search}`; +document.querySelector("#transport-result").textContent = + `Attachment transport: ${location.protocol === "https:" ? "HTTPS" : "local HTTP"}. Repeat with the optional trusted HTTPS fixture to compare download policy.`; +refreshSession(); + +document.querySelector("#blob-download").addEventListener("click", () => { + const blob = new Blob( + [ + "# Native integration fixture\n\nGenerated download; no live chat data.\n", + ], + { type: "text/markdown;charset=utf-8" }, + ); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = "native-fixture-generated.md"; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(url); +}); + +let streamController; +let lastStreamDropped = false; + +async function runStream(drop = false) { + streamController?.abort(); + const controller = new AbortController(); + streamController = controller; + lastStreamDropped = drop; + const result = document.querySelector("#stream-result"); + const text = document.querySelector("#stream-text"); + const startedAt = performance.now(); + let received = 0; + let textDeliveries = 0; + let completed = false; + let reader; + result.textContent = drop + ? "Opening fixture stream · intentional drop after chunk 2…" + : "Opening fixture stream…"; + result.dataset.connected = "false"; + text.textContent = ""; + for (const id of ["stream-start", "stream-drop", "stream-retry"]) + document.getElementById(id).disabled = true; + document.querySelector("#stream-cancel").disabled = false; + try { + const response = await fetch( + `/api/fixture/stream${drop ? "?drop=1" : ""}`, + { + signal: controller.signal, + cache: "no-store", + }, + ); + if (!response.ok || !response.body) + throw new Error("Streaming response is unavailable."); + reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + while (true) { + const chunk = await reader.read(); + if (streamController !== controller) return; + if (chunk.done) break; + const previousCount = received; + buffer += decoder.decode(chunk.value, { stream: true }); + let boundary; + while ((boundary = buffer.indexOf("\n\n")) !== -1) { + const frame = buffer.slice(0, boundary); + buffer = buffer.slice(boundary + 2); + const lines = frame.split("\n"); + const event = lines + .find((line) => line.startsWith("event: ")) + ?.slice(7); + const data = JSON.parse( + lines.find((line) => line.startsWith("data: "))?.slice(6) ?? "null", + ); + if (data?.fixture !== true) throw new Error("Unexpected stream data."); + if (event === "chunk" && data.index === received + 1) { + received++; + const elapsed = ((performance.now() - startedAt) / 1000).toFixed(1); + text.textContent += `${received} · ${elapsed}s · ${data.text}\n`; + result.textContent = `Receiving fixture text · ${received}/5 chunks · response still open`; + } else if (event === "done" && data.chunks === received) { + completed = true; + } else throw new Error("Unexpected stream sequence."); + } + if (received > previousCount) textDeliveries++; + } + if (!completed) throw new Error("Stream ended without a completion event."); + result.textContent = `Complete · ${received}/5 fixture chunks · ${textDeliveries} text deliveries · ${textDeliveries > 1 ? "incremental arrival" : "text buffered in one delivery"}`; + result.dataset.connected = "true"; + } catch (error) { + if (streamController !== controller) return; + result.textContent = controller.signal.aborted + ? `Canceled · ${received}/5 chunks received. Retry starts a fresh request.` + : `Stream interrupted · ${received}/5 chunks received; partial text retained. ${drop ? "This disconnect is intentional." : error.message} Retry is available.`; + } finally { + reader?.releaseLock(); + if (streamController === controller) { + streamController = undefined; + for (const id of ["stream-start", "stream-drop", "stream-retry"]) + document.getElementById(id).disabled = false; + document.querySelector("#stream-cancel").disabled = true; + } + } +} + +document + .querySelector("#stream-start") + .addEventListener("click", () => runStream()); +document + .querySelector("#stream-drop") + .addEventListener("click", () => runStream(true)); +document + .querySelector("#stream-retry") + .addEventListener("click", () => runStream(lastStreamDropped)); +document + .querySelector("#stream-cancel") + .addEventListener("click", () => streamController?.abort()); +window.addEventListener("pagehide", () => streamController?.abort()); + +document + .querySelector("#microphone") + .addEventListener("click", async (event) => { + const button = event.currentTarget; + const result = document.querySelector("#microphone-result"); + if (!navigator.mediaDevices?.getUserMedia) { + result.textContent = + "Microphone API unavailable. Use a secure context and a supported webview."; + return; + } + button.disabled = true; + result.textContent = "Waiting for microphone permission…"; + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + for (const track of stream.getTracks()) track.stop(); + result.textContent = + "Allowed · all tracks stopped immediately. No audio recorded or uploaded."; + } catch (error) { + result.textContent = + error.name === "NotAllowedError" + ? "Denied · microphone permission was not granted. No audio recorded or uploaded." + : `Microphone unavailable (${error.name}). No audio recorded or uploaded.`; + } finally { + button.disabled = false; + } + }); diff --git a/autogpt_platform/mobile/testing/postgres-auth-worker.mjs b/autogpt_platform/mobile/testing/postgres-auth-worker.mjs new file mode 100644 index 000000000000..9e676b35a20f --- /dev/null +++ b/autogpt_platform/mobile/testing/postgres-auth-worker.mjs @@ -0,0 +1,157 @@ +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { createInterface } from "node:readline"; +import { once } from "node:events"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const frontend = new URL("../../frontend/", import.meta.url); +const frontendRequire = createRequire(new URL("package.json", frontend)); + +export async function loadAuthRuntime() { + const typescript = frontendRequire("typescript"); + const resolve = (specifier) => + specifier.startsWith("node:") + ? specifier + : pathToFileURL(frontendRequire.resolve(specifier)).href; + const allowedSources = new Map([ + ["./mobile-auth-helpers", "src/lib/auth/mobile-auth-helpers.ts"], + ["./mobile-auth", "src/lib/auth/mobile-auth.ts"], + ]); + const compiled = new Map(); + function compile(specifier) { + if (compiled.has(specifier)) return compiled.get(specifier); + const sourcePath = allowedSources.get(specifier); + if (!sourcePath) + throw new Error(`Unexpected frontend source import: ${specifier}`); + let source = typescript.transpileModule( + readFileSync(new URL(sourcePath, frontend), "utf8"), + { + compilerOptions: { + module: typescript.ModuleKind.ESNext, + target: typescript.ScriptTarget.ES2022, + }, + }, + ).outputText; + source = source.replace(/from\s+["']([^"']+)["']/g, (_, dependency) => { + const target = dependency.startsWith(".") + ? compile(dependency) + : resolve(dependency); + return `from ${JSON.stringify(target)}`; + }); + const moduleURL = `data:text/javascript;base64,${Buffer.from(source).toString("base64")}`; + compiled.set(specifier, moduleURL); + return moduleURL; + } + const [{ betterAuth }, { admin }, { mobileAuth }, { getMigrations }, pg] = + await Promise.all([ + import(resolve("better-auth")), + import(resolve("better-auth/plugins")), + import(compile("./mobile-auth")), + import(resolve("better-auth/db/migration")), + import(resolve("pg")), + ]); + function createAuth(connection, sessionBefore) { + const pool = new (pg.Pool ?? pg.default.Pool)({ + ...connection, + options: "-c search_path=platform", + max: 6, + }); + const options = { + baseURL: "https://platform.agpt.co", + secret: connection.authSecret, + database: pool, + telemetry: { enabled: false }, + logger: { disabled: true }, + rateLimit: { enabled: false }, + advanced: { database: { generateId: () => crypto.randomUUID() } }, + user: { + modelName: "UserAuthIdentity", + additionalFields: { + preferredName: { type: "string", required: false }, + }, + }, + session: { + modelName: "UserAuthSession", + expiresIn: 60 * 60 * 24 * 30, + cookieCache: { enabled: true, maxAge: 300 }, + }, + account: { modelName: "UserAuthAccount" }, + verification: { modelName: "UserAuthVerification" }, + emailAndPassword: { enabled: true }, + plugins: [admin(), mobileAuth()], + ...(sessionBefore + ? { databaseHooks: { session: { create: { before: sessionBefore } } } } + : {}), + }; + return { auth: betterAuth(options), options, pool }; + } + return { createAuth, getMigrations }; +} + +export function cookieHeader(response) { + return response.headers + .getSetCookie() + .map((cookie) => cookie.split(";")[0]) + .join("; "); +} + +export async function post(auth, endpoint, body, cookie = "") { + return auth.handler( + new Request(`https://platform.agpt.co/api/auth/mobile/${endpoint}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: "https://platform.agpt.co", + ...(cookie ? { Cookie: cookie } : {}), + }, + body: JSON.stringify(body), + }), + ); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const input = createInterface({ input: process.stdin, terminal: false }); + let pool; + try { + const [configuration] = await once(input, "line"); + const { connection, code, verifier } = JSON.parse(configuration); + const runtime = await loadAuthRuntime(); + const instance = runtime.createAuth(connection); + pool = instance.pool; + await instance.auth.$context; + const start = once(input, "line"); + console.log(JSON.stringify({ ready: true })); + await start; + const response = await post(instance.auth, "exchange", { + code, + code_verifier: verifier, + }); + const setCookies = response.headers.getSetCookie(); + const session = + response.status === 200 + ? await instance.auth.api.getSession({ + headers: new Headers({ Cookie: cookieHeader(response) }), + }) + : null; + console.log( + JSON.stringify({ + status: response.status, + cookieCount: setCookies.length, + userId: session?.user.id ?? null, + }), + ); + } catch (error) { + console.log( + JSON.stringify({ + error: error.message.replace( + /data:text\/javascript;base64,[A-Za-z0-9+/=]+/g, + "[compiled frontend module]", + ), + }), + ); + process.exitCode = 1; + } finally { + input.close(); + if (pool) await pool.end(); + } +} diff --git a/autogpt_platform/mobile/testing/postgres-auth.mjs b/autogpt_platform/mobile/testing/postgres-auth.mjs new file mode 100644 index 000000000000..bbffed7b4a10 --- /dev/null +++ b/autogpt_platform/mobile/testing/postgres-auth.mjs @@ -0,0 +1,379 @@ +import assert from "node:assert/strict"; +import { createHash, randomBytes } from "node:crypto"; +import { execFile, spawn } from "node:child_process"; +import { once } from "node:events"; +import { createInterface } from "node:readline"; +import { fileURLToPath } from "node:url"; +import { promisify, parseArgs } from "node:util"; +import { + cookieHeader, + loadAuthRuntime, + post, +} from "./postgres-auth-worker.mjs"; + +const execute = promisify(execFile); +const { values } = parseArgs({ + options: { image: { type: "string", default: "postgres:17" } }, +}); +const containerName = `autogpt-native-auth-${randomBytes(6).toString("hex")}`; +const password = randomBytes(32).toString("base64url"); +const workers = []; +let containerID; +let pool; +let cleaning; + +async function docker(args, options = {}) { + return ( + await execute("docker", args, { + timeout: 30_000, + maxBuffer: 1024 * 1024, + ...options, + }) + ).stdout.trim(); +} + +async function cleanup() { + if (cleaning) return cleaning; + cleaning = (async () => { + for (const worker of workers) + if (worker.exitCode === null) worker.kill("SIGTERM"); + if (pool) await pool.end(); + if (containerID) { + await docker(["rm", "--force", containerID]); + console.log( + "Removed the disposable PostgreSQL container and its tmpfs data.", + ); + } + })(); + return cleaning; +} + +for (const signal of ["SIGINT", "SIGTERM"]) { + process.once(signal, async () => { + try { + await cleanup(); + } finally { + process.exit(signal === "SIGINT" ? 130 : 143); + } + }); +} + +function handoff() { + const verifier = randomBytes(32).toString("base64url"); + return { + verifier, + challenge: createHash("sha256").update(verifier).digest("base64url"), + state: randomBytes(32).toString("base64url"), + }; +} + +async function createUser(auth, label) { + const response = await auth.api.signUpEmail({ + body: { + name: "Native PostgreSQL fixture", + email: `${label}@example.invalid`, + password: randomBytes(24).toString("base64url"), + }, + asResponse: true, + }); + assert.equal(response.status, 200, `Could not create ${label} fixture user`); + return { + id: (await response.json()).user.id, + cookie: cookieHeader(response), + }; +} + +async function issueCode(auth, user, request = handoff()) { + const response = await post( + auth, + "authorize", + { + code_challenge: request.challenge, + state: request.state, + expected_user_id: user.id, + }, + user.cookie, + ); + assert.equal(response.status, 200, "Could not issue fixture handoff"); + return { + ...request, + code: new URL((await response.json()).url).searchParams.get("code"), + }; +} + +async function prepareWorker(connection, request) { + const worker = spawn( + process.execPath, + [fileURLToPath(new URL("postgres-auth-worker.mjs", import.meta.url))], + { stdio: ["pipe", "pipe", "pipe"] }, + ); + workers.push(worker); + let stderr = ""; + worker.stderr.on("data", (data) => { + stderr += data; + }); + const output = createInterface({ input: worker.stdout, terminal: false }); + const messages = []; + const pending = []; + output.on("line", (line) => { + const message = JSON.parse(line); + if (pending.length) pending.shift()(message); + else messages.push(message); + }); + function nextMessage() { + if (messages.length) return Promise.resolve(messages.shift()); + return new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error("Exchange worker timed out")), + 30_000, + ); + pending.push((message) => { + clearTimeout(timeout); + resolve(message); + }); + }); + } + const exited = once(worker, "exit"); + worker.stdin.write( + `${JSON.stringify({ connection, code: request.code, verifier: request.verifier })}\n`, + ); + assert.deepEqual(await nextMessage(), { ready: true }); + return { + async exchange() { + const result = nextMessage(); + worker.stdin.end("exchange\n"); + const message = await result; + const [exitCode] = await exited; + output.close(); + assert.equal(exitCode, 0, `Exchange worker failed: ${stderr}`); + assert.equal(message.error, undefined); + return message; + }, + }; +} + +try { + await docker(["info", "--format", "{{.ServerVersion}}"]); + await docker(["image", "inspect", values.image, "--format", "{{.Id}}"]); + console.log( + `Using cached ${values.image}; no image download or existing database access.`, + ); + containerID = await docker( + [ + "run", + "--detach", + "--rm", + "--pull", + "never", + "--name", + containerName, + "--label", + "com.agpt.purpose=native-auth-fixture", + "--publish", + "127.0.0.1::5432", + "--memory", + "256m", + "--cpus", + "1", + "--tmpfs", + "/var/lib/postgresql/data:rw,size=256m", + "--env", + "POSTGRES_PASSWORD", + "--env", + "POSTGRES_USER=native_auth_fixture", + "--env", + "POSTGRES_DB=native_auth_fixture", + values.image, + "postgres", + "-c", + "shared_buffers=32MB", + "-c", + "max_connections=32", + ], + { env: { ...process.env, POSTGRES_PASSWORD: password } }, + ); + const binding = await docker(["port", containerID, "5432/tcp"]); + const port = Number(/^127\.0\.0\.1:(\d+)$/.exec(binding)?.[1]); + assert.ok(port, "Test database did not bind exclusively to loopback"); + const connection = { + host: "127.0.0.1", + port, + user: "native_auth_fixture", + password, + database: "native_auth_fixture", + authSecret: randomBytes(32).toString("base64url"), + }; + const runtime = await loadAuthRuntime(); + let revokeDuringCreate = null; + const instance = runtime.createAuth(connection, async (_, context) => { + if (revokeDuringCreate) { + const source = revokeDuringCreate; + revokeDuringCreate = null; + await context.context.internalAdapter.deleteSession(source); + } + }); + pool = instance.pool; + const { auth } = instance; + let ready = false; + for (let attempt = 0; attempt < 60; attempt += 1) { + try { + await pool.query("SELECT 1"); + ready = true; + break; + } catch { + await new Promise((resolve) => setTimeout(resolve, 250)); + } + } + assert.ok(ready, "Disposable PostgreSQL did not become ready"); + await pool.query("CREATE SCHEMA platform"); + await (await runtime.getMigrations(instance.options)).runMigrations(); + const context = await auth.$context; + console.log( + "Created fresh platform.UserAuth* tables using Better Auth migrations.", + ); + + const user = await createUser(auth, "concurrent"); + const request = await issueCode(auth, user); + const contenders = await Promise.all( + Array.from({ length: 4 }, () => prepareWorker(connection, request)), + ); + const results = await Promise.all( + contenders.map((worker) => worker.exchange()), + ); + assert.deepEqual( + results.map((result) => result.status).sort(), + [200, 400, 400, 400], + ); + const winner = results.find((result) => result.status === 200); + assert.equal(winner.userId, user.id); + assert.ok(winner.cookieCount >= 2); + assert.ok( + results + .filter((result) => result.status !== 200) + .every((result) => result.cookieCount === 0), + ); + assert.equal( + ( + await post(auth, "exchange", { + code: request.code, + code_verifier: request.verifier, + }) + ).status, + 400, + ); + console.log( + "PASS: four separate Node processes exchanged one ticket; exactly one won and replay failed.", + ); + + const cookieRequest = await issueCode(auth, user); + const cookieResponse = await post(auth, "exchange", { + code: cookieRequest.code, + code_verifier: cookieRequest.verifier, + }); + assert.equal(cookieResponse.status, 200); + assert.deepEqual(await cookieResponse.json(), { success: true }); + const secureCookie = cookieResponse.headers + .getSetCookie() + .find((cookie) => cookie.startsWith("__Secure-better-auth.session_token=")); + for (const attribute of ["HttpOnly", "Secure", "SameSite=Lax"]) + assert.ok(secureCookie.includes(attribute)); + assert.ok(cookieResponse.headers.get("cache-control").includes("no-store")); + console.log( + "PASS: secure HttpOnly cookie output authenticates the expected user without JSON token disclosure.", + ); + + const revokedUser = await createUser(auth, "revoked"); + const revokedRequest = await issueCode(auth, revokedUser); + await auth.api.signOut({ + headers: new Headers({ Cookie: revokedUser.cookie }), + }); + const revokedResponse = await post(auth, "exchange", { + code: revokedRequest.code, + code_verifier: revokedRequest.verifier, + }); + assert.equal(revokedResponse.status, 401); + assert.equal(revokedResponse.headers.has("set-cookie"), false); + console.log( + "PASS: a revoked browser session cannot establish a mobile session.", + ); + + const racingUser = await createUser(auth, "revoked-during-create"); + const racingSource = await auth.api.getSession({ + headers: new Headers({ Cookie: racingUser.cookie }), + query: { disableCookieCache: true }, + }); + const racingRequest = await issueCode(auth, racingUser); + revokeDuringCreate = racingSource.session.token; + const racingResponse = await post(auth, "exchange", { + code: racingRequest.code, + code_verifier: racingRequest.verifier, + }); + assert.equal(racingResponse.status, 401); + assert.equal(racingResponse.headers.has("set-cookie"), false); + const remaining = await pool.query( + 'SELECT COUNT(*)::int AS count FROM "UserAuthSession" WHERE "userId" = $1', + [racingUser.id], + ); + assert.equal(remaining.rows[0].count, 0); + console.log( + "PASS: revocation during app-session creation rolls back the new session before cookies are returned.", + ); + + const otherUser = await createUser(auth, "other-account"); + const staleConsent = handoff(); + const changedAccount = await post( + auth, + "authorize", + { + code_challenge: staleConsent.challenge, + state: staleConsent.state, + expected_user_id: user.id, + }, + otherUser.cookie, + ); + assert.equal(changedAccount.status, 403); + console.log( + "PASS: displayed consent identity cannot authorize a different current account.", + ); + + const unverified = await createUser(auth, "unverified"); + const unverifiedRequest = await issueCode(auth, unverified); + context.options.emailAndPassword.requireEmailVerification = true; + const deniedConsent = await post( + auth, + "authorize", + { + code_challenge: unverifiedRequest.challenge, + state: unverifiedRequest.state, + expected_user_id: unverified.id, + }, + unverified.cookie, + ); + assert.equal(deniedConsent.status, 403); + const deniedExchange = await post(auth, "exchange", { + code: unverifiedRequest.code, + code_verifier: unverifiedRequest.verifier, + }); + assert.equal(deniedExchange.status, 403); + assert.equal(deniedExchange.headers.has("set-cookie"), false); + console.log( + "PASS: enabling email verification denies unverified consent and outstanding handoff exchange.", + ); + console.log( + "PostgreSQL native-auth validation passed. This is isolated integration evidence, not production validation.", + ); +} catch (error) { + console.error( + `PostgreSQL native-auth validation failed: ${error.message.replace(/data:text\/javascript;base64,[A-Za-z0-9+/=]+/g, "[compiled frontend module]")}`, + ); + process.exitCode = 1; +} finally { + try { + await cleanup(); + } catch (error) { + console.error( + `Cleanup failed for ${containerID ?? containerName}: ${error.message.replace(/data:text\/javascript;base64,[A-Za-z0-9+/=]+/g, "[compiled frontend module]")}`, + ); + process.exitCode = 1; + } +} diff --git a/autogpt_platform/mobile/testing/server.mjs b/autogpt_platform/mobile/testing/server.mjs new file mode 100644 index 000000000000..9d60ba72b78f --- /dev/null +++ b/autogpt_platform/mobile/testing/server.mjs @@ -0,0 +1,311 @@ +import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { createServer as createHttpServer } from "node:http"; +import { createServer as createHttpsServer } from "node:https"; +import { setTimeout as delay } from "node:timers/promises"; +import { fileURLToPath } from "node:url"; +import { parseArgs } from "node:util"; + +const directory = new URL(".", import.meta.url); +const callbackUrl = "autogpt://auth/callback"; +const html = readFileSync(new URL("fixture.html", directory), "utf8"); +const script = readFileSync(new URL("fixture.js", directory), "utf8"); +const authorizationScript = readFileSync( + new URL("authorize.js", directory), + "utf8", +); +const stylesheet = readFileSync(new URL("fixture.css", directory), "utf8"); + +function send( + response, + status, + content, + type = "application/json", + headers = {}, +) { + response.writeHead(status, { + "Content-Type": `${type}; charset=utf-8`, + "Cache-Control": "no-store", + "X-Content-Type-Options": "nosniff", + ...headers, + }); + response.end(typeof content === "string" ? content : JSON.stringify(content)); +} + +async function readBody(request) { + let body = ""; + for await (const chunk of request) { + body += chunk; + if (body.length > 8192) throw new Error("Request body is too large"); + } + return body; +} + +export function createFixtureServer({ + now = Date.now, + codeLifetimeMs = 90_000, + streamDelay = (signal) => delay(600, undefined, { signal }), + tls, +} = {}) { + const cookieName = tls + ? "__Secure-better-auth.session_token" + : "better-auth.session_token"; + const cacheCookieName = cookieName.replace("session_token", "session_data"); + const codes = new Map(); + const sessions = new Map(); + + async function handle(request, response) { + const origin = `${tls ? "https" : "http"}://${request.headers.host}`; + const url = new URL(request.url, origin); + const method = request.method; + for (const [code, entry] of codes) + if (entry.expiresAt <= now()) codes.delete(code); + for (const [token, expiresAt] of sessions) + if (expiresAt <= now()) sessions.delete(token); + + if (url.pathname === "/api/fixture/stream" && method === "GET") { + const controller = new AbortController(); + const close = () => controller.abort(); + response.once("close", close); + response.writeHead(200, { + "Content-Type": "text/event-stream; charset=utf-8", + "Cache-Control": "no-store, no-transform", + "X-Content-Type-Options": "nosniff", + "X-Accel-Buffering": "no", + }); + response.flushHeaders(); + const chunks = [ + "Fixture stream opened.", + "Incremental text reached the native webview.", + "The response is still in progress.", + "No live chat or account is connected.", + "All five test chunks arrived.", + ]; + try { + for (const [index, text] of chunks.entries()) { + if (controller.signal.aborted) return; + response.write( + `event: chunk\ndata: ${JSON.stringify({ fixture: true, index: index + 1, text })}\n\n`, + ); + await streamDelay(controller.signal); + if (url.searchParams.get("drop") === "1" && index === 1) { + response.destroy(); + return; + } + } + if (!controller.signal.aborted) + response.end( + `event: done\ndata: ${JSON.stringify({ fixture: true, chunks: chunks.length })}\n\n`, + ); + } catch (error) { + if (!controller.signal.aborted) throw error; + } finally { + response.removeListener("close", close); + } + return; + } + + if (url.pathname === "/api/auth/mobile/start" && method === "GET") { + const challenge = url.searchParams.get("code_challenge") ?? ""; + const state = url.searchParams.get("state") ?? ""; + if ( + !/^[A-Za-z0-9_-]{43}$/.test(challenge) || + !/^[A-Za-z0-9_-]{32,128}$/.test(state) + ) { + return send(response, 400, { + error: "Supply a SHA-256 PKCE challenge and nonempty state", + }); + } + return send(response, 302, "", "text/plain", { + Location: `/auth/mobile?${new URLSearchParams({ code_challenge: challenge, state })}`, + }); + } + + if (url.pathname === "/auth/mobile" && method === "GET") { + return send( + response, + 200, + `Fixture authorization

Native integration fixture

Connect AutoGPT

This local fixture simulates the browser handoff. No real account is signed in.

Ready to connect this fixture session.

Completing this returns to the native app through its registered callback.

`, + "text/html", + ); + } + + if (url.pathname === "/api/auth/mobile/authorize" && method === "POST") { + if (request.headers.origin !== origin) + return send(response, 403, { + error: "Fixture authorization requires its configured Origin", + }); + const body = JSON.parse(await readBody(request)); + const challenge = body?.code_challenge ?? ""; + const state = body?.state ?? ""; + if ( + !/^[A-Za-z0-9_-]{43}$/.test(challenge) || + !/^[A-Za-z0-9_-]{32,128}$/.test(state) + ) { + return send(response, 400, { + error: "Invalid fixture authorization request", + }); + } + if (body.expected_user_id !== "fixture-user") + return send(response, 403, { + error: "Fixture consent account changed", + }); + const code = randomBytes(32).toString("base64url"); + codes.set(code, { challenge, expiresAt: now() + codeLifetimeMs }); + const callback = new URL(callbackUrl); + callback.searchParams.set("code", code); + callback.searchParams.set("state", state); + return send(response, 200, { url: callback.href }); + } + + if (url.pathname === "/api/auth/mobile/exchange" && method === "POST") { + if (request.headers.origin !== origin) + return send(response, 403, { + error: "Fixture exchange requires its configured Origin", + }); + let body; + try { + body = JSON.parse(await readBody(request)); + } catch { + return send(response, 400, { error: "Expected a small JSON request" }); + } + const entry = codes.get(body?.code); + const verifier = body?.code_verifier; + if ( + !entry || + typeof verifier !== "string" || + !/^[A-Za-z0-9._~-]{43,128}$/.test(verifier) + ) { + return send(response, 400, { + error: "Invalid or expired fixture code", + }); + } + const supplied = createHash("sha256").update(verifier).digest(); + if ( + !timingSafeEqual(supplied, Buffer.from(entry.challenge, "base64url")) + ) { + return send(response, 400, { + error: "Fixture PKCE verification failed", + }); + } + codes.delete(body.code); + const token = randomBytes(24).toString("base64url"); + sessions.set(token, now() + 86_400_000); + return send(response, 200, { success: true }, "application/json", { + "Set-Cookie": [ + `${cookieName}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400${tls ? "; Secure" : ""}`, + `${cacheCookieName}=fixture-cache; Path=/; HttpOnly; SameSite=Lax; Max-Age=300; Expires=${new Date(now() + 300_000).toUTCString()}${tls ? "; Secure" : ""}`, + ], + }); + } + + if (url.pathname === "/api/fixture/session" && method === "GET") { + const cookie = (request.headers.cookie ?? "") + .split(";") + .map((part) => part.trim()) + .find((part) => part.startsWith(`${cookieName}=`)); + return send(response, 200, { + fixture: true, + cacheCookieReceived: (request.headers.cookie ?? "") + .split(";") + .some((part) => part.trim() === `${cacheCookieName}=fixture-cache`), + authenticated: sessions.has(cookie?.slice(cookieName.length + 1)), + }); + } + + if (url.pathname === "/api/fixture/logout" && method === "POST") { + const cookie = (request.headers.cookie ?? "") + .split(";") + .map((part) => part.trim()) + .find((part) => part.startsWith(`${cookieName}=`)); + sessions.delete(cookie?.slice(cookieName.length + 1)); + return send(response, 200, { success: true }, "application/json", { + "Set-Cookie": [cookieName, cacheCookieName].map( + (name) => + `${name}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${tls ? "; Secure" : ""}`, + ), + }); + } + + if (url.pathname === "/offline") return request.socket.destroy(); + if (url.pathname === "/error") + return send( + response, + 503, + "Fixture HTTP 503

Native integration fixture

Intentional HTTP 503. An HTTP error page is different from a transport failure.

Return to fixture", + "text/html", + ); + if (url.pathname === "/redirect") + return send(response, 302, "", "text/plain", { + Location: "/copilot?redirected=1", + }); + if (url.pathname === "/redirect/external") + return send(response, 302, "", "text/plain", { + Location: "https://example.com/", + }); + if (url.pathname === "/attachment.txt") + return send( + response, + 200, + "Native integration fixture attachment.\nThis file contains no account data.\n", + "text/plain", + { "Content-Disposition": 'attachment; filename="native-fixture.txt"' }, + ); + if (url.pathname === "/authorize.js") + return send(response, 200, authorizationScript, "application/javascript"); + if (url.pathname === "/fixture.js") + return send(response, 200, script, "application/javascript"); + if (url.pathname === "/fixture.css") + return send(response, 200, stylesheet, "text/css"); + if (url.pathname === "/health") + return send(response, 200, { fixture: true, ok: true }); + if ( + url.pathname === "/" || + url.pathname === "/copilot" || + url.pathname === "/slow" + ) { + if (url.pathname === "/slow") + await new Promise((resolve) => setTimeout(resolve, 2500)); + return send(response, 200, html, "text/html"); + } + return send(response, 404, { error: "Unknown native fixture route" }); + } + + const handler = (request, response) => + handle(request, response).catch(() => { + if (!response.headersSent && !response.destroyed) + send(response, 400, { error: "Invalid fixture request" }); + }); + return tls ? createHttpsServer(tls, handler) : createHttpServer(handler); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const { values } = parseArgs({ + options: { + host: { type: "string", default: "127.0.0.1" }, + port: { type: "string", default: "8765" }, + "tls-cert": { type: "string" }, + "tls-key": { type: "string" }, + }, + }); + if (Boolean(values["tls-cert"]) !== Boolean(values["tls-key"])) + throw new Error("Supply both --tls-cert and --tls-key"); + const tls = values["tls-cert"] + ? { + cert: readFileSync(values["tls-cert"]), + key: readFileSync(values["tls-key"]), + } + : undefined; + const port = Number(values.port); + if (!Number.isInteger(port) || port < 1 || port > 65535) + throw new Error("Port must be between 1 and 65535"); + const server = createFixtureServer({ tls }); + server.listen(port, values.host, () => { + console.log( + `Native integration fixture: ${tls ? "https" : "http"}://${values.host}:${port}/copilot`, + ); + console.log( + "Test-only content. No live AutoGPT account, chat, or backend is connected.", + ); + }); +} diff --git a/autogpt_platform/mobile/testing/server.test.mjs b/autogpt_platform/mobile/testing/server.test.mjs new file mode 100644 index 000000000000..f55f996e9e5c --- /dev/null +++ b/autogpt_platform/mobile/testing/server.test.mjs @@ -0,0 +1,248 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { once } from "node:events"; +import { test } from "node:test"; +import { createFixtureServer } from "./server.mjs"; + +const verifier = + "native-fixture-verifier-0123456789-abcdefghijklmnopqrstuvwxyz"; // pragma: allowlist secret +const state = "fixture-state-0123456789-abcdefghijklmnopqrstuvwxyz"; // pragma: allowlist secret +const challenge = createHash("sha256").update(verifier).digest("base64url"); + +async function startFixture(t, options = {}) { + const server = createFixtureServer(options); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + t.after(() => new Promise((resolve) => server.close(resolve))); + return `http://127.0.0.1:${server.address().port}`; +} + +async function startAuth(origin, overrides = {}) { + const query = new URLSearchParams({ + code_challenge: challenge, + state, + ...overrides, + }); + return fetch(`${origin}/api/auth/mobile/start?${query}`, { + redirect: "manual", + }); +} + +async function exchange(origin, code, codeVerifier = verifier) { + return fetch(`${origin}/api/auth/mobile/exchange`, { + method: "POST", + headers: { "Content-Type": "application/json", Origin: origin }, + body: JSON.stringify({ code, code_verifier: codeVerifier }), + }); +} + +async function authorize(origin) { + const start = await startAuth(origin); + assert.equal(start.status, 302); + const consent = await fetch(new URL(start.headers.get("location"), origin)); + assert.equal(consent.status, 200); + assert.match(await consent.text(), /Connect AutoGPT/); + const response = await fetch(`${origin}/api/auth/mobile/authorize`, { + method: "POST", + headers: { "Content-Type": "application/json", Origin: origin }, + body: JSON.stringify({ + code_challenge: challenge, + state, + expected_user_id: "fixture-user", + }), + }); + assert.equal(response.status, 200); + return new URL((await response.json()).url); +} + +test("native callback exchanges PKCE for a persistent HttpOnly fixture session", async (t) => { + const origin = await startFixture(t); + const callback = await authorize(origin); + assert.equal( + `${callback.protocol}//${callback.host}${callback.pathname}`, + "autogpt://auth/callback", + ); + assert.equal(callback.searchParams.get("state"), state); + const exchanged = await exchange(origin, callback.searchParams.get("code")); + assert.equal(exchanged.status, 200); + assert.deepEqual(await exchanged.json(), { success: true }); + const setCookies = exchanged.headers.getSetCookie(); + assert.equal(setCookies.length, 2); + assert.match( + setCookies[1], + /^better-auth\.session_data=.*Expires=[A-Z][a-z]{2}, /, + ); + const cookieHeader = setCookies + .map((cookie) => cookie.split(";")[0]) + .join("; "); + const setCookie = setCookies[0]; + assert.match(setCookie, /^better-auth\.session_token=/); + assert.match(setCookie, /HttpOnly/); + assert.match(setCookie, /Max-Age=86400/); + const session = await fetch(`${origin}/api/fixture/session`, { + headers: { Cookie: cookieHeader }, + }); + const sessionResult = await session.json(); + assert.equal(sessionResult.authenticated, true); + assert.equal(sessionResult.cacheCookieReceived, true); + const page = await fetch(`${origin}/copilot`, { + headers: { Cookie: cookieHeader }, + }); + assert.match(await page.text(), /Native integration fixture/); +}); + +test("a successful authorization code cannot be replayed", async (t) => { + const origin = await startFixture(t); + const callback = await authorize(origin); + const code = callback.searchParams.get("code"); + assert.equal((await exchange(origin, code)).status, 200); + assert.equal((await exchange(origin, code)).status, 400); +}); + +test("an expired authorization code cannot establish a session", async (t) => { + let now = 1000; + const origin = await startFixture(t, { now: () => now, codeLifetimeMs: 100 }); + const callback = await authorize(origin); + now += 101; + const response = await exchange(origin, callback.searchParams.get("code")); + assert.equal(response.status, 400); + assert.equal(response.headers.get("set-cookie"), null); +}); + +test("a mismatched verifier cannot establish a session", async (t) => { + const origin = await startFixture(t); + const callback = await authorize(origin); + const response = await exchange( + origin, + callback.searchParams.get("code"), + "different-verifier-0123456789-abcdefghijklmnopqrstuvwxyz", // pragma: allowlist secret + ); + assert.equal(response.status, 400); + assert.equal(response.headers.get("set-cookie"), null); +}); + +test("start rejects incomplete PKCE input and ignores callback override", async (t) => { + const origin = await startFixture(t); + assert.equal( + (await startAuth(origin, { code_challenge: "short" })).status, + 400, + ); + assert.equal((await startAuth(origin, { state: "" })).status, 400); + const response = await startAuth(origin, { + redirect_uri: "https://example.com", + }); + assert.equal( + new URL(response.headers.get("location"), origin).pathname, + "/auth/mobile", + ); + assert.equal( + new URL(response.headers.get("location"), origin).searchParams.has( + "redirect_uri", + ), + false, + ); +}); + +test("fixtures expose deterministic download, HTTP error, and transport failure probes", async (t) => { + const origin = await startFixture(t); + const download = await fetch(`${origin}/attachment.txt`); + assert.match(download.headers.get("content-disposition"), /attachment/); + assert.match(await download.text(), /Native integration fixture/); + assert.equal((await fetch(`${origin}/error`)).status, 503); + await assert.rejects(fetch(`${origin}/offline`), /fetch failed/); +}); + +function controlledStream() { + let release; + return { + delay(signal) { + return new Promise((resolve, reject) => { + const abort = () => reject(signal.reason); + signal.addEventListener("abort", abort, { once: true }); + release = () => { + signal.removeEventListener("abort", abort); + resolve(); + }; + }); + }, + next() { + assert.equal(typeof release, "function"); + const advance = release; + release = undefined; + advance(); + }, + }; +} + +test("SSE chunks arrive incrementally before the response completes", async (t) => { + const steps = controlledStream(); + const origin = await startFixture(t, { streamDelay: steps.delay }); + const response = await fetch(`${origin}/api/fixture/stream`); + assert.match(response.headers.get("content-type"), /text\/event-stream/); + assert.match(response.headers.get("cache-control"), /no-transform/); + const reader = response.body.getReader(); + try { + for (let index = 1; index <= 5; index++) { + const chunk = await reader.read(); + assert.equal(chunk.done, false); + const frame = new TextDecoder().decode(chunk.value); + assert.match(frame, /event: chunk/); + assert.match(frame, new RegExp(`"index":${index},`)); + assert.doesNotMatch(frame, /event: done/); + steps.next(); + } + const final = await reader.read(); + assert.equal(final.done, false); + assert.match(new TextDecoder().decode(final.value), /event: done/); + assert.equal((await reader.read()).done, true); + } finally { + await reader.cancel(); + reader.releaseLock(); + } +}); + +test("a dropped SSE response exposes partial chunks then fails without completion", async (t) => { + const steps = controlledStream(); + const origin = await startFixture(t, { streamDelay: steps.delay }); + const response = await fetch(`${origin}/api/fixture/stream?drop=1`); + const reader = response.body.getReader(); + try { + for (let index = 1; index <= 2; index++) { + const chunk = await reader.read(); + assert.equal(chunk.done, false); + assert.match( + new TextDecoder().decode(chunk.value), + new RegExp(`"index":${index},`), + ); + steps.next(); + } + await assert.rejects(reader.read(), /terminated/); + } finally { + reader.releaseLock(); + } +}); + +test("canceling a streaming fetch allows a fresh retry", async (t) => { + const steps = controlledStream(); + const origin = await startFixture(t, { streamDelay: steps.delay }); + const controller = new AbortController(); + const response = await fetch(`${origin}/api/fixture/stream`, { + signal: controller.signal, + }); + const reader = response.body.getReader(); + assert.match( + new TextDecoder().decode((await reader.read()).value), + /"index":1,/, + ); + controller.abort(); + await assert.rejects(reader.read(), { name: "AbortError" }); + reader.releaseLock(); + const retry = await fetch(`${origin}/api/fixture/stream`); + const retryReader = retry.body.getReader(); + assert.match( + new TextDecoder().decode((await retryReader.read()).value), + /"index":1,/, + ); + await retryReader.cancel(); + retryReader.releaseLock(); +});