Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
156 changes: 156 additions & 0 deletions .github/workflows/platform-mobile-ci.yml
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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(
<MobileAuthConsent
userID="user-123"
email="tester@agpt.co"
codeChallenge={CHALLENGE}
state={STATE}
/>,
);
}

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();
});
});
Original file line number Diff line number Diff line change
@@ -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 (
<AuthCard
title="Connect AutoGPT on your phone"
className="gap-6 p-6 sm:p-8"
>
<div className="space-y-3 text-center">
<Text variant="body">Sign in to the AutoGPT app as</Text>
<Text variant="body" unmask={false} className="break-all font-semibold">
{email}
</Text>
<Text variant="small" tone="secondary">
Your chats, files, and connected tools will be available in the app.
Continue only if you started this sign-in on your phone.
</Text>
</div>
{error ? (
<Text variant="small" tone="danger" role="alert">
{error}
</Text>
) : null}
<div className="flex w-full flex-col items-center gap-4">
{callbackURL ? (
<Button className="w-full" onClick={returnToApp}>
Return to AutoGPT
</Button>
) : (
<Button className="w-full" loading={isConnecting} onClick={connect}>
Connect AutoGPT
</Button>
)}
<Button variant="link" disabled={isConnecting} onClick={cancel}>
Cancel
</Button>
</div>
</AuthCard>
);
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading
Loading