Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
fa87777
feat(github): add one-time install states
iscekic Aug 3, 2026
6ad82c3
feat(github): verify installation claims
iscekic Aug 3, 2026
837a9b3
feat(device-auth): split polling secret from code
iscekic Aug 3, 2026
86dc0b1
feat(auth): add refreshable device sessions
iscekic Aug 3, 2026
105b61d
fix(auth): revoke blocked device credentials
iscekic Aug 3, 2026
628b59b
fix(auth): reserve sign-in codes before settlement
iscekic Aug 3, 2026
545bf1d
fix(auth): isolate sign-in code attempts
iscekic Aug 4, 2026
cb27251
fix(auth): equalize native OTP responses
iscekic Aug 4, 2026
7aec9c3
fix(device-auth): ignore expired pending requests
iscekic Aug 4, 2026
4898e2b
merge: integrate origin main
iscekic Aug 4, 2026
2b4c1f4
Merge remote-tracking branch 'origin/main' into audit-w2b-device-auth…
iscekic Aug 4, 2026
890d4c9
fix(github): return delete query row count
iscekic Aug 4, 2026
59277f2
fix(db): regenerate device auth migrations
iscekic Aug 4, 2026
0743de4
fix(auth): defer native sign-in analytics
iscekic Aug 4, 2026
5119437
feat(mobile): support refreshable device auth
iscekic Aug 4, 2026
4dc8c0b
feat(github): return installations to mobile
iscekic Aug 4, 2026
a933531
test(mobile): remove superseded auth tests
iscekic Aug 4, 2026
1767ce8
feat(auth): verify native identity proofs
iscekic Aug 4, 2026
bb4b1db
feat(auth): add native admission checks
iscekic Aug 4, 2026
e2e7eb5
feat(mobile): attach native admission
iscekic Aug 4, 2026
dbe0c06
test(auth): prove token verifier compatibility
iscekic Aug 4, 2026
0e65c7a
test(web): accept device session claims
iscekic Aug 4, 2026
e75f7b8
chore(format): normalize admission files
iscekic Aug 4, 2026
669c095
test(auth): fix admission test types
iscekic Aug 4, 2026
8901546
fix(types): satisfy strict type checks
iscekic Aug 4, 2026
1a0ad5d
refactor(mobile): satisfy lint rules
iscekic Aug 4, 2026
1fee94d
test(cron): cover admission cleanup counts
iscekic Aug 4, 2026
eabe9a4
fix(auth): restore failed device auth issuance
iscekic Aug 4, 2026
d2fd68b
chore(format): normalize cleanup test
iscekic Aug 4, 2026
8532306
fix(mobile): repair auth type checks
iscekic Aug 4, 2026
20c79c0
fix(dev): target tmux pane breaks by session
iscekic Aug 5, 2026
67926df
fix(github): redact callback state telemetry
iscekic Aug 5, 2026
cef3b99
fix(auth): atomically advance admission counters
iscekic Aug 5, 2026
072fe19
fix(auth): preserve refresh rotation recovery
iscekic Aug 5, 2026
c01a948
fix(github): repair mobile install outcomes
iscekic Aug 5, 2026
57de70b
fix(sentry): redact callback state requests
iscekic Aug 5, 2026
ef00495
fix(auth): target Play Integrity package
iscekic Aug 5, 2026
9cfbae3
fix(auth): validate Apple attestation chains
iscekic Aug 5, 2026
8a3bb39
test(auth): cover refresh revoke races
iscekic Aug 5, 2026
7747d91
fix(sentry): redact install state telemetry
iscekic Aug 5, 2026
82eda2c
fix(auth): use configured Apple audience
iscekic Aug 5, 2026
1914ca5
fix(github): scope webhook mutations by app
iscekic Aug 5, 2026
cd68a18
fix(github): scope lifecycle mutations by installation
iscekic Aug 5, 2026
cf8df99
chore: retrigger review
iscekic Aug 5, 2026
86dfd2d
fix(mobile): remove unused auth type exports
iscekic Aug 5, 2026
74bbfac
test(services): update worker auth mocks
iscekic Aug 5, 2026
da44fa5
fix(auth): resolve Kilobot findings
iscekic Aug 5, 2026
bc1e291
fix(auth): reject blocked users in KiloClaw, allow members to mint in…
iscekic Aug 5, 2026
2892037
refactor(auth): parse App Attest with libraries instead of hand-rolle…
iscekic Aug 5, 2026
0678890
feat(auth): implement native admission with @expo/app-integrity
iscekic Aug 5, 2026
7b14543
Merge remote-tracking branch 'origin/main' into audit-w2b-device-auth…
iscekic Aug 5, 2026
a231ed8
Merge remote-tracking branch 'origin/main' into audit-w2b-device-auth…
iscekic Aug 5, 2026
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
7 changes: 7 additions & 0 deletions ENVIRONMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,13 @@ Manage shared web env var additions and rotations with `pnpm web:env set <VARIAB
- `APPLE_IAP_ISSUER_ID` - Apple IAP issuer (team) ID. `[SECRET]`
- `APPLE_IAP_PRIVATE_KEY` - Apple IAP private key (PEM/ES256) for receipt validation. `[SECRET]`
- `APPLE_ROOT_CERTIFICATES_PEM` - Apple root CA certs (PEM) for validating IAP receipts. [SERVER]
- `APPLE_APP_BUNDLE_ID` - iOS app bundle ID for Apple App Attest and Sign In verification. [SERVER]
- `NATIVE_ADMISSION_MODE` - Native admission enforcement mode: `off` (default), `report`, or `enforce`. [SERVER]
- `NATIVE_ADMISSION_SIMULATOR_BYPASS` - When `true` in non-production, bypasses Play Integrity API verification. [SERVER]
- `GOOGLE_PLAY_INTEGRITY_PROJECT_NUMBER` - Google Cloud project number for Play Integrity API. Also read by the mobile build, where it ships in the bundle; it is an identifier, not a secret. [SERVER]
- `GOOGLE_PLAY_INTEGRITY_SERVICE_ACCOUNT_KEY` - Service account JSON key for Play Integrity API. `[SECRET]`
- `GOOGLE_PLAY_INTEGRITY_PACKAGE_NAME` - Expected Android package name (e.g., `com.kilocode.app`), verified against the Play Integrity verdict. [SERVER]
- `GOOGLE_PLAY_INTEGRITY_CERT_DIGESTS` - Comma-separated SHA-256 signing certificate digests (hex) accepted for the Android app. [SERVER]

### Ablation / Experimentation

Expand Down
7 changes: 7 additions & 0 deletions apps/mobile/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@ const config: ExpoConfig = {
supportsTablet: true,
usesAppleSignIn: true,
associatedDomains: ['applinks:app.kilo.ai'],
entitlements: {
// App Attest, used by @expo/app-integrity for native admission. `production`
// is required for App Store builds; a development build against the
// production environment still attests, it just uses Apple's dev servers
// when the app is signed with a development profile.
'com.apple.developer.devicecheck.appattest-environment': 'production',
},
infoPlist: {
ITSAppUsesNonExemptEncryption: false,
NSAdvertisingAttributionReportEndpoint: 'https://appsflyer-skadnetwork.com/',
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
},
"dependencies": {
"@expo-google-fonts/jetbrains-mono": "0.4.1",
"@expo/app-integrity": "57.0.1",
"@expo/react-native-action-sheet": "4.1.1",
"@kilocode/app-shared": "workspace:*",
"@kilocode/cloud-agent-sdk": "workspace:*",
Expand Down
299 changes: 299 additions & 0 deletions apps/mobile/src/app/(app)/(tabs)/(2_agents)/index.mounted.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,299 @@
/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest (node env, no jsdom); its React 19 deprecation notice points to the DOM-based Testing Library, which cannot render this app's non-DOM tree, and @testing-library/react-native cannot be transformed by the current vitest pipeline (react-native ships Flow). See src/test/render-with-providers.tsx. */
import { createElement } from 'react';
import TestRenderer, { act } from 'react-test-renderer';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import AgentSessionList, { buildGitHubInstallOutcomeAlert } from './index';
import {
getGitHubInstallReturnOutcome,
setGitHubInstallReturnOutcome,
} from '@/lib/github-install-return';

const alertMock = vi.hoisted(() => vi.fn());
const platformMock = vi.hoisted(() => ({ OS: 'ios' }));
const mintInstallStateMock = vi.hoisted(() => vi.fn());
const openAuthSessionMock = vi.hoisted(() => vi.fn());
const openBrowserMock = vi.hoisted(() => vi.fn());

vi.mock('react-native', () => ({
Alert: { alert: alertMock },
Platform: platformMock,
}));

vi.mock('expo-web-browser', () => ({
openAuthSessionAsync: openAuthSessionMock,
openBrowserAsync: openBrowserMock,
}));

vi.mock('@/lib/config', () => ({
WEB_BASE_URL: 'https://web.test',
}));

vi.mock('@/lib/trpc', () => ({
trpcClient: {
githubApps: {
mintInstallState: { mutate: mintInstallStateMock },
},
},
}));

vi.mock('@/components/agents/session-list-screen', () => ({
AgentSessionListScreen: () => null,
}));

type AlertButton = { text: string; onPress?: () => void };

const noOp = () => undefined;

function mountRoute() {
const ref: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined };
act(() => {
ref.current = TestRenderer.create(createElement(AgentSessionList));
});
if (!ref.current) {
throw new Error('route did not render');
}
return ref.current;
}

async function flushMicrotasks() {
await act(async () => {
await new Promise(resolve => {
setTimeout(resolve, 0);
});
});
}

function lastAlertButtons(): AlertButton[] | undefined {
return alertMock.mock.calls.at(-1)?.[2] as AlertButton[] | undefined;
}

describe('buildGitHubInstallOutcomeAlert (C13 outcome states)', () => {
it('returns null for the empty state (no return outcome)', () => {
expect(buildGitHubInstallOutcomeAlert(null, () => undefined)).toBeNull();
});

it('happy state: connected message and Continue, no recovery action', () => {
expect(buildGitHubInstallOutcomeAlert({ kind: 'success' }, () => undefined)).toEqual({
title: 'GitHub App installed',
message: 'Your repositories are now connected.',
buttons: [{ text: 'Continue' }],
});
});

it('pending state: admin approval message and Done', () => {
expect(buildGitHubInstallOutcomeAlert({ kind: 'pending' }, () => undefined)).toEqual({
title: 'Awaiting admin approval',
message: 'An organization admin must approve the installation request.',
buttons: [{ text: 'Done' }],
});
});

it('retryable state: Try again wires the recovery callback', () => {
const alert = buildGitHubInstallOutcomeAlert(
{ kind: 'error', code: 'installation_failed' },
noOp
);
expect(alert?.title).toBe('Installation did not complete');
expect(alert?.buttons).toHaveLength(1);
expect(alert?.buttons[0]?.text).toBe('Try again');
expect(alert?.buttons[0]?.onPress).toBe(noOp);
});

it('non-retryable states: show the reason and Back, never retry', () => {
for (const code of [
'install_state_user_mismatch',
'not_installation_admin',
'installation_already_claimed',
] as const) {
const alert = buildGitHubInstallOutcomeAlert({ kind: 'error', code }, () => undefined);
expect(alert?.buttons).toEqual([{ text: 'Back' }]);
expect(alert?.buttons[0]?.onPress).toBeUndefined();
}
expect(
buildGitHubInstallOutcomeAlert(
{ kind: 'error', code: 'install_state_user_mismatch' },
() => undefined
)?.title
).toBe('Account mismatch');
expect(
buildGitHubInstallOutcomeAlert(
{ kind: 'error', code: 'not_installation_admin' },
() => undefined
)?.title
).toBe('Cannot complete installation');
});
});

describe('Agents tab return-outcome rendering', () => {
beforeEach(() => {
alertMock.mockReset();
mintInstallStateMock.mockReset();
mintInstallStateMock.mockResolvedValue({ token: 'fresh-token' });
openAuthSessionMock.mockReset();
openAuthSessionMock.mockResolvedValue(undefined);
openBrowserMock.mockReset();
openBrowserMock.mockResolvedValue(undefined);
platformMock.OS = 'ios';
setGitHubInstallReturnOutcome(null);
});

it('empty state: renders the agent list without an outcome alert', () => {
const renderer = mountRoute();
expect(alertMock).not.toHaveBeenCalled();
act(() => {
renderer.unmount();
});
});

it('happy state: shows the connected alert on mount and consumes the outcome', () => {
setGitHubInstallReturnOutcome({ kind: 'success' });
const renderer = mountRoute();

expect(alertMock).toHaveBeenCalledTimes(1);
expect(alertMock).toHaveBeenCalledWith(
'GitHub App installed',
'Your repositories are now connected.',
[{ text: 'Continue' }]
);
expect(getGitHubInstallReturnOutcome()).toBeNull();
act(() => {
renderer.unmount();
});
});

it('retryable state: pressing Try again mints a fresh token and reopens the flow', async () => {
setGitHubInstallReturnOutcome({ kind: 'error', code: 'installation_failed' });
const renderer = mountRoute();

const tryAgain = lastAlertButtons()?.find(button => button.text === 'Try again');
expect(tryAgain?.onPress).toBeDefined();

act(() => {
tryAgain?.onPress?.();
});
await flushMicrotasks();

expect(mintInstallStateMock).toHaveBeenCalledWith({ returnTo: '/cloud/sessions' });
expect(openAuthSessionMock).toHaveBeenCalledWith(
'https://web.test/github-app?installState=fresh-token&fromApp=1'
);
expect(openBrowserMock).not.toHaveBeenCalled();
act(() => {
renderer.unmount();
});
});

it('retryable state: org-scoped outcome retries with the original organizationId', async () => {
setGitHubInstallReturnOutcome({
kind: 'error',
code: 'installation_failed',
organizationId: 'org-123',
});
const renderer = mountRoute();

const tryAgain = lastAlertButtons()?.find(button => button.text === 'Try again');
expect(tryAgain?.onPress).toBeDefined();

act(() => {
tryAgain?.onPress?.();
});
await flushMicrotasks();

expect(mintInstallStateMock).toHaveBeenCalledWith({
organizationId: 'org-123',
returnTo: '/cloud/sessions',
});
expect(openAuthSessionMock).toHaveBeenCalledWith(
'https://web.test/github-app?organizationId=org-123&installState=fresh-token&fromApp=1'
);
act(() => {
renderer.unmount();
});
});

it('retryable state: mint failure keeps a working Try again in the failure alert', async () => {
setGitHubInstallReturnOutcome({ kind: 'error', code: 'installation_failed' });
const renderer = mountRoute();

mintInstallStateMock.mockRejectedValueOnce(new Error('network'));
const tryAgain = lastAlertButtons()?.find(button => button.text === 'Try again');
act(() => {
tryAgain?.onPress?.();
});
await flushMicrotasks();

const failureButtons = lastAlertButtons();
expect(failureButtons?.[0]?.text).toBe('Try again');
expect(failureButtons?.[0]?.onPress).toBeDefined();

// Pressing the failure alert retry re-mints and reopens the flow.
act(() => {
failureButtons?.[0]?.onPress?.();
});
await flushMicrotasks();
expect(mintInstallStateMock).toHaveBeenCalledTimes(2);
expect(openAuthSessionMock).toHaveBeenCalledWith(
'https://web.test/github-app?installState=fresh-token&fromApp=1'
);
act(() => {
renderer.unmount();
});
});

it('pending state: shows the awaiting-approval alert with Done on mount', () => {
setGitHubInstallReturnOutcome({ kind: 'pending' });
const renderer = mountRoute();

expect(alertMock).toHaveBeenCalledTimes(1);
expect(alertMock).toHaveBeenCalledWith(
'Awaiting admin approval',
'An organization admin must approve the installation request.',
[{ text: 'Done' }]
);
expect(getGitHubInstallReturnOutcome()).toBeNull();
act(() => {
renderer.unmount();
});
});

it('non-retryable state: shows the reason with Back and never offers retry', () => {
setGitHubInstallReturnOutcome({ kind: 'error', code: 'not_installation_admin' });
const renderer = mountRoute();

expect(alertMock).toHaveBeenCalledTimes(1);
expect(alertMock).toHaveBeenCalledWith(
'Cannot complete installation',
'Only a GitHub admin of that account can connect it. Ask an organization admin to install Kilo.',
[{ text: 'Back' }]
);
const backButton = lastAlertButtons()?.[0];
expect(backButton?.text).toBe('Back');
expect(backButton?.onPress).toBeUndefined();
expect(getGitHubInstallReturnOutcome()).toBeNull();
act(() => {
renderer.unmount();
});
});

it('retryable state on Android: reopening uses the browser launcher', async () => {
setGitHubInstallReturnOutcome({ kind: 'error', code: 'installation_failed' });
platformMock.OS = 'android';
const renderer = mountRoute();

const tryAgain = lastAlertButtons()?.find(button => button.text === 'Try again');

act(() => {
tryAgain?.onPress?.();
});
await flushMicrotasks();

expect(openBrowserMock).toHaveBeenCalledWith(
'https://web.test/github-app?installState=fresh-token&fromApp=1'
);
expect(openAuthSessionMock).not.toHaveBeenCalled();
act(() => {
renderer.unmount();
});
});
});
Loading
Loading