From 40cf433b34858e10ae446bc1683bde1a76fd875c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 20 Aug 2026 00:31:58 +0200 Subject: [PATCH 1/3] feat(mobile): universal-link routes for home, preferences, and session detail E2E drives navigation by deep link instead of taps. Adds /home, /profile/preferences, and /cloud/sessions/* (agent-chat by id). --- apps/mobile/src/lib/deep-link-handler.test.ts | 32 ++++++++++++++ apps/mobile/src/lib/deep-link-handler.ts | 2 + apps/mobile/src/lib/deep-link-launch.test.ts | 4 +- apps/mobile/src/lib/deep-link-launch.ts | 3 ++ apps/mobile/src/lib/universal-link-paths.js | 3 ++ .../.well-known/apple-app-site-association | 3 ++ .../src/universal-links/routes.test.ts | 43 +++++++++++++------ .../app-shared/src/universal-links/routes.ts | 3 ++ 8 files changed, 78 insertions(+), 15 deletions(-) diff --git a/apps/mobile/src/lib/deep-link-handler.test.ts b/apps/mobile/src/lib/deep-link-handler.test.ts index 86b7d88642..39204899e6 100644 --- a/apps/mobile/src/lib/deep-link-handler.test.ts +++ b/apps/mobile/src/lib/deep-link-handler.test.ts @@ -9,6 +9,7 @@ import { captureLaunchDeepLink, getPendingDeepLink, } from './deep-link-launch'; +import { _resetDevSessionInjectForTests, consumePendingDevSession } from './dev-session-inject'; import { setGitHubInstallReturnOutcome } from './github-install-return'; const mocks = vi.hoisted(() => ({ @@ -36,10 +37,22 @@ vi.mock('@kilocode/app-shared/universal-links', async importOriginal => { }); const MAPPED_CASES = [ + { + path: 'https://app.kilo.ai/home', + href: '/(app)/(tabs)/(0_home)', + }, { path: 'https://app.kilo.ai/profile', href: '/(app)/(tabs)/(3_profile)', }, + { + path: 'https://app.kilo.ai/profile/preferences', + href: '/(app)/(tabs)/(3_profile)/preferences', + }, + { + path: 'https://app.kilo.ai/cloud/sessions/ses_1', + href: '/(app)/agent-chat/ses_1', + }, { path: 'https://app.kilo.ai/security-agent/findings', href: '/(app)/(tabs)/(3_profile)/security-agent/personal/findings', @@ -53,15 +66,19 @@ const MAPPED_CASES = [ describe('redirectSystemPath', () => { beforeEach(() => { _resetDeepLinkLaunchForTests(); + _resetDevSessionInjectForTests(); setGitHubInstallReturnOutcome(null); mocks.navigate.mockReset(); mocks.shouldThrow = false; + vi.stubGlobal('__DEV__', true); }); afterEach(() => { _resetDeepLinkLaunchForTests(); + _resetDevSessionInjectForTests(); setGitHubInstallReturnOutcome(null); mocks.shouldThrow = false; + vi.unstubAllGlobals(); }); describe('cold invariant', () => { @@ -104,6 +121,21 @@ describe('redirectSystemPath', () => { }); }); + describe('dev session inject', () => { + it('stashes credentials from a kiloapp URL in a dev build', () => { + const path = + 'kiloapp:///home?dev_session_token=tok&dev_session_refresh=ref&dev_session_expires_in=3600'; + const result = redirectSystemPath({ path, initial: true }); + expect(result).toBeNull(); + expect(getPendingDeepLink()).toBe('/(app)/(tabs)/(0_home)'); + expect(consumePendingDevSession()).toEqual({ + token: 'tok', + refreshToken: 'ref', + expiresIn: 3600, + }); + }); + }); + describe('kiloapp:// forms', () => { it('cold kiloapp:///profile stashes group href and returns null', () => { const result = redirectSystemPath({ path: 'kiloapp:///profile', initial: true }); diff --git a/apps/mobile/src/lib/deep-link-handler.ts b/apps/mobile/src/lib/deep-link-handler.ts index 7dc0bcb285..eb34df414c 100644 --- a/apps/mobile/src/lib/deep-link-handler.ts +++ b/apps/mobile/src/lib/deep-link-handler.ts @@ -3,6 +3,7 @@ import { type Href, router } from 'expo-router'; import { resolveIncomingUrl } from '@kilocode/app-shared/universal-links'; import { setPendingDeepLink, wasLaunchLinkHandled } from './deep-link-launch'; +import { takeDevSessionFromUrl } from './dev-session-inject'; import { parseGitHubReturnParams, setGitHubInstallReturnOutcome } from './github-install-return'; /** Target app path for the /cloud/sessions universal-link route. */ @@ -45,6 +46,7 @@ export function redirectSystemPath({ initial: boolean; }): string | null { try { + takeDevSessionFromUrl(path); const href = resolveIncomingUrl(path); // Untouched → default handling (and future share intent). if (href == null) { diff --git a/apps/mobile/src/lib/deep-link-launch.test.ts b/apps/mobile/src/lib/deep-link-launch.test.ts index 977915694d..22c1fc10d6 100644 --- a/apps/mobile/src/lib/deep-link-launch.test.ts +++ b/apps/mobile/src/lib/deep-link-launch.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { _resetDeepLinkLaunchForTests, @@ -11,10 +11,12 @@ import { describe('deep-link-launch', () => { beforeEach(() => { _resetDeepLinkLaunchForTests(); + vi.stubGlobal('__DEV__', true); }); afterEach(() => { _resetDeepLinkLaunchForTests(); + vi.unstubAllGlobals(); }); describe('pending slot', () => { diff --git a/apps/mobile/src/lib/deep-link-launch.ts b/apps/mobile/src/lib/deep-link-launch.ts index 1244ffaee5..66ca2d6a68 100644 --- a/apps/mobile/src/lib/deep-link-launch.ts +++ b/apps/mobile/src/lib/deep-link-launch.ts @@ -1,5 +1,7 @@ import { resolveIncomingUrl } from '@kilocode/app-shared/universal-links'; +import { takeDevSessionFromUrl } from './dev-session-inject'; + type DeepLinkSource = 'universal-link' | 'notification'; type GetLinkingURL = () => string | null; @@ -74,6 +76,7 @@ export function captureLaunchDeepLink(): void { if (!url) { return; } + takeDevSessionFromUrl(url); const href = resolveIncomingUrl(url); if (href) { setPendingDeepLink(href, 'universal-link'); diff --git a/apps/mobile/src/lib/universal-link-paths.js b/apps/mobile/src/lib/universal-link-paths.js index 74b465b1db..a8738d300c 100644 --- a/apps/mobile/src/lib/universal-link-paths.js +++ b/apps/mobile/src/lib/universal-link-paths.js @@ -5,9 +5,12 @@ * universal-link-paths.test.ts — keep the two in sync there, never silently. * @type {string[]} */ export const UNIVERSAL_LINK_PATH_PATTERNS = [ + '/home', '/profile', + '/profile/preferences', '/claw', '/cloud/sessions', + '/cloud/sessions/.*', '/security-agent', '/security-agent/findings', '/code-reviews', diff --git a/apps/web/public/.well-known/apple-app-site-association b/apps/web/public/.well-known/apple-app-site-association index 9c5c500bff..3c4f768a81 100644 --- a/apps/web/public/.well-known/apple-app-site-association +++ b/apps/web/public/.well-known/apple-app-site-association @@ -4,9 +4,12 @@ { "appIDs": ["X96D76J65Z.com.kilocode.kiloapp"], "components": [ + { "/": "/home" }, { "/": "/profile" }, + { "/": "/profile/preferences" }, { "/": "/claw" }, { "/": "/cloud/sessions" }, + { "/": "/cloud/sessions/*" }, { "/": "/security-agent" }, { "/": "/security-agent/findings" }, { "/": "/code-reviews" }, diff --git a/packages/app-shared/src/universal-links/routes.test.ts b/packages/app-shared/src/universal-links/routes.test.ts index e8ca02e54c..2724d0adec 100644 --- a/packages/app-shared/src/universal-links/routes.test.ts +++ b/packages/app-shared/src/universal-links/routes.test.ts @@ -11,12 +11,20 @@ import { const WEB = 'https://app.kilo.ai'; -/** Expected targets for the 11 table rows (concrete ids where wildcards). */ +/** Expected targets for the 14 table rows (concrete ids where wildcards). */ const ROW_CASES = [ + { + path: '/home', + app: '/(app)/(tabs)/(0_home)', + }, { path: '/profile', app: '/(app)/(tabs)/(3_profile)', }, + { + path: '/profile/preferences', + app: '/(app)/(tabs)/(3_profile)/preferences', + }, { path: '/claw', app: '/(app)/(tabs)/(1_kiloclaw)', @@ -25,6 +33,10 @@ const ROW_CASES = [ path: '/cloud/sessions', app: '/(app)/(tabs)/(2_agents)', }, + { + path: '/cloud/sessions/ses_1', + app: '/(app)/agent-chat/ses_1', + }, { path: '/security-agent', app: '/(app)/(tabs)/(3_profile)/security-agent/personal', @@ -60,8 +72,8 @@ const ROW_CASES = [ ] as const; describe('UNIVERSAL_LINK_ROUTES', () => { - it('has exactly 11 rows', () => { - expect(UNIVERSAL_LINK_ROUTES).toHaveLength(11); + it('has exactly 14 rows', () => { + expect(UNIVERSAL_LINK_ROUTES).toHaveLength(14); }); }); @@ -274,8 +286,8 @@ describe('parseKiloWebPath', () => { }); describe('aasaComponents', () => { - it('returns 13 entries (11 rows + 2 exclusions)', () => { - expect(aasaComponents()).toHaveLength(13); + it('returns 16 entries (14 rows + 2 exclusions)', () => { + expect(aasaComponents()).toHaveLength(16); }); it('every entry has a "/" key', () => { @@ -287,20 +299,20 @@ describe('aasaComponents', () => { it('emits exclusion immediately before row 7 (/code-reviews/*)', () => { const components = aasaComponents(); - // Rows 1–6 are exact (indices 0–5). Row 7 exclusion then row 7 → indices 6–7. - expect(components[6]).toEqual({ '/': '/code-reviews/review-md', exclude: true }); - expect(components[7]).toEqual({ '/': '/code-reviews/*' }); + // Rows 1–9 are exact (indices 0–8). Row 10 exclusion then row 10 → indices 9–10. + expect(components[9]).toEqual({ '/': '/code-reviews/review-md', exclude: true }); + expect(components[10]).toEqual({ '/': '/code-reviews/*' }); }); - it('emits exclusion immediately before row 11 (/organizations/*/code-reviews/*)', () => { + it('emits exclusion immediately before row 14 (/organizations/*/code-reviews/*)', () => { const components = aasaComponents(); - // After row 7 pair: rows 8–10 (3 exact) → indices 8,9,10. - // Row 11 exclusion + row 11 → indices 11–12. - expect(components[11]).toEqual({ + // After row 10 pair: rows 11–13 (3 exact) → indices 11,12,13. + // Row 14 exclusion + row 14 → indices 14–15. + expect(components[14]).toEqual({ '/': '/organizations/*/code-reviews/review-md', exclude: true, }); - expect(components[12]).toEqual({ '/': '/organizations/*/code-reviews/*' }); + expect(components[15]).toEqual({ '/': '/organizations/*/code-reviews/*' }); }); it('keeps table * verbatim (Apple glob crosses /)', () => { @@ -312,11 +324,14 @@ describe('aasaComponents', () => { }); describe('androidPathPatterns', () => { - it('deep-equals the expected 11-string list', () => { + it('deep-equals the expected 14-string list', () => { expect(androidPathPatterns()).toEqual([ + '/home', '/profile', + '/profile/preferences', '/claw', '/cloud/sessions', + '/cloud/sessions/.*', '/security-agent', '/security-agent/findings', '/code-reviews', diff --git a/packages/app-shared/src/universal-links/routes.ts b/packages/app-shared/src/universal-links/routes.ts index 7c76167c41..3c140e9b48 100644 --- a/packages/app-shared/src/universal-links/routes.ts +++ b/packages/app-shared/src/universal-links/routes.ts @@ -22,9 +22,12 @@ export type UniversalLinkRoute = { }; export const UNIVERSAL_LINK_ROUTES: readonly UniversalLinkRoute[] = [ + { webPath: '/home', appPath: '/(app)/(tabs)/(0_home)' }, { webPath: '/profile', appPath: '/(app)/(tabs)/(3_profile)' }, + { webPath: '/profile/preferences', appPath: '/(app)/(tabs)/(3_profile)/preferences' }, { webPath: '/claw', appPath: '/(app)/(tabs)/(1_kiloclaw)' }, { webPath: '/cloud/sessions', appPath: '/(app)/(tabs)/(2_agents)' }, + { webPath: '/cloud/sessions/*', appPath: '/(app)/agent-chat/<1>' }, { webPath: '/security-agent', appPath: '/(app)/(tabs)/(3_profile)/security-agent/personal', From 67954392a425b7b8a44150e937b53aa13dba2820 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 20 Aug 2026 00:31:58 +0200 Subject: [PATCH 2/3] feat(mobile): dev-build session injection from deep-link query params dev_session_* params sign the dev build in without the email-code flow. Parser no-ops outside __DEV__. kiloTokenPayload gains an optional deviceSessionId so minted pairs match issueSessionCredentials. --- .../src/components/app-root-providers.tsx | 2 + .../src/components/dev-session-injector.tsx | 26 +++++++ .../mobile/src/lib/dev-session-inject.test.ts | 59 +++++++++++++++ apps/mobile/src/lib/dev-session-inject.ts | 71 +++++++++++++++++++ packages/worker-utils/src/kilo-token.ts | 2 + 5 files changed, 160 insertions(+) create mode 100644 apps/mobile/src/components/dev-session-injector.tsx create mode 100644 apps/mobile/src/lib/dev-session-inject.test.ts create mode 100644 apps/mobile/src/lib/dev-session-inject.ts diff --git a/apps/mobile/src/components/app-root-providers.tsx b/apps/mobile/src/components/app-root-providers.tsx index 57881c15cc..c95ab7c725 100644 --- a/apps/mobile/src/components/app-root-providers.tsx +++ b/apps/mobile/src/components/app-root-providers.tsx @@ -6,6 +6,7 @@ import { type ReactNode } from 'react'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { Toaster } from 'sonner-native'; +import { DevSessionInjector } from '@/components/dev-session-injector'; import { OfflineBanner } from '@/components/offline-banner'; import { AuthProvider } from '@/lib/auth/auth-context'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; @@ -23,6 +24,7 @@ export function AppRootProviders({ children }: { readonly children: ReactNode }) + {__DEV__ ? : null} <> diff --git a/apps/mobile/src/components/dev-session-injector.tsx b/apps/mobile/src/components/dev-session-injector.tsx new file mode 100644 index 0000000000..01efd1d9c6 --- /dev/null +++ b/apps/mobile/src/components/dev-session-injector.tsx @@ -0,0 +1,26 @@ +import { useEffect } from 'react'; + +import { useAuth } from '@/lib/auth/auth-context'; +import { consumePendingDevSession, subscribePendingDevSession } from '@/lib/dev-session-inject'; + +export function DevSessionInjector() { + const { signIn } = useAuth(); + + useEffect(() => { + const apply = (): void => { + const credentials = consumePendingDevSession(); + if (!credentials) { + return; + } + void signIn(credentials.token, credentials.refreshToken, credentials.expiresIn); + }; + + apply(); + const unsubscribe = subscribePendingDevSession(apply); + return () => { + unsubscribe(); + }; + }, [signIn]); + + return null; +} diff --git a/apps/mobile/src/lib/dev-session-inject.test.ts b/apps/mobile/src/lib/dev-session-inject.test.ts new file mode 100644 index 0000000000..674f7feb7f --- /dev/null +++ b/apps/mobile/src/lib/dev-session-inject.test.ts @@ -0,0 +1,59 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + _resetDevSessionInjectForTests, + consumePendingDevSession, + parseDevSessionQuery, + subscribePendingDevSession, + takeDevSessionFromUrl, +} from './dev-session-inject'; + +const URL = + 'kiloapp:///home?dev_session_token=tok&dev_session_refresh=ref&dev_session_expires_in=3600'; + +describe('dev-session-inject', () => { + beforeEach(() => { + _resetDevSessionInjectForTests(); + vi.stubGlobal('__DEV__', true); + }); + + afterEach(() => { + _resetDevSessionInjectForTests(); + vi.unstubAllGlobals(); + }); + + it('parses a complete query in a dev build', () => { + expect(parseDevSessionQuery(URL)).toEqual({ + token: 'tok', + refreshToken: 'ref', + expiresIn: 3600, + }); + }); + + it('ignores an incomplete query', () => { + expect(parseDevSessionQuery('kiloapp:///home?dev_session_token=tok')).toBeNull(); + }); + + it('is a no-op outside a dev build', () => { + vi.stubGlobal('__DEV__', false); + expect(parseDevSessionQuery(URL)).toBeNull(); + takeDevSessionFromUrl(URL); + expect(consumePendingDevSession()).toBeNull(); + }); + + it('notifies a subscriber and is single-shot', () => { + const listener = vi.fn(); + const unsubscribe = subscribePendingDevSession(() => { + listener(); + }); + takeDevSessionFromUrl(URL); + expect(listener).toHaveBeenCalledOnce(); + expect(consumePendingDevSession()).toEqual({ + token: 'tok', + refreshToken: 'ref', + expiresIn: 3600, + }); + expect(consumePendingDevSession()).toBeNull(); + unsubscribe(); + }); +}); diff --git a/apps/mobile/src/lib/dev-session-inject.ts b/apps/mobile/src/lib/dev-session-inject.ts new file mode 100644 index 0000000000..907b9825ee --- /dev/null +++ b/apps/mobile/src/lib/dev-session-inject.ts @@ -0,0 +1,71 @@ +type DevSessionCredentials = { + token: string; + refreshToken: string; + expiresIn: number; +}; + +const TOKEN_PARAM = 'dev_session_token'; +const REFRESH_PARAM = 'dev_session_refresh'; +const EXPIRES_PARAM = 'dev_session_expires_in'; + +let pending: DevSessionCredentials | null = null; +const listeners = new Set<() => void>(); + +export function parseDevSessionQuery(raw: string): DevSessionCredentials | null { + if (!__DEV__) { + return null; + } + if (raw.length === 0) { + return null; + } + const queryStart = raw.indexOf('?'); + if (queryStart === -1) { + return null; + } + let queryEnd = raw.length; + const hash = raw.indexOf('#', queryStart); + if (hash !== -1) { + queryEnd = hash; + } + const params = new URLSearchParams(raw.slice(queryStart + 1, queryEnd)); + const token = params.get(TOKEN_PARAM); + const refreshToken = params.get(REFRESH_PARAM); + const expiresInRaw = params.get(EXPIRES_PARAM); + if (!token || !refreshToken || !expiresInRaw) { + return null; + } + const expiresIn = Number(expiresInRaw); + if (!Number.isFinite(expiresIn) || expiresIn <= 0) { + return null; + } + return { token, refreshToken, expiresIn }; +} + +export function takeDevSessionFromUrl(raw: string): void { + const credentials = parseDevSessionQuery(raw); + if (!credentials) { + return; + } + pending = credentials; + for (const listener of listeners) { + listener(); + } +} + +export function consumePendingDevSession(): DevSessionCredentials | null { + const credentials = pending; + pending = null; + return credentials; +} + +export function subscribePendingDevSession(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +export function _resetDevSessionInjectForTests(): void { + pending = null; + listeners.clear(); +} diff --git a/packages/worker-utils/src/kilo-token.ts b/packages/worker-utils/src/kilo-token.ts index de83d64c77..c54d3e5973 100644 --- a/packages/worker-utils/src/kilo-token.ts +++ b/packages/worker-utils/src/kilo-token.ts @@ -25,6 +25,7 @@ export const kiloTokenPayload = z.object({ createdOnPlatform: z.string().optional(), tokenSource: z.string().optional(), deviceAuthRequestCode: z.string().optional(), + deviceSessionId: z.string().optional(), // Org memberships (baked into gastown tokens to avoid DB lookups) orgMemberships: z .array(z.object({ orgId: z.string(), role: z.enum(['owner', 'member', 'billing_manager']) })) @@ -52,6 +53,7 @@ export type SignKiloTokenExtra = Pick< | 'createdOnPlatform' | 'tokenSource' | 'deviceAuthRequestCode' + | 'deviceSessionId' | 'orgMemberships' >; From 20095b76bf1e9c0f189fce38973a6bebf736c27e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 20 Aug 2026 00:31:59 +0200 Subject: [PATCH 3/3] feat(dev): dev:mobile:open deep-link launcher and app:github-account seed open: mints a device session for a seeded user and opens the app on a named route, signed in. seed: reuses a live donor GitHub authorization and verifies connected via the product query; --json output. --- dev/local/mobile-open-routes.ts | 187 ++++++++++++++++++++++++++++ dev/local/mobile-open.test.ts | 55 ++++++++ dev/local/mobile-open.ts | 176 ++++++++++++++++++++++++++ dev/seed/app/github-account.ts | 97 +++++++++++++++ dev/seed/lib/github-account.test.ts | 22 ++++ dev/seed/lib/github-account.ts | 130 +++++++++++++++++++ package.json | 3 +- 7 files changed, 669 insertions(+), 1 deletion(-) create mode 100644 dev/local/mobile-open-routes.ts create mode 100644 dev/local/mobile-open.test.ts create mode 100644 dev/local/mobile-open.ts create mode 100644 dev/seed/app/github-account.ts create mode 100644 dev/seed/lib/github-account.test.ts create mode 100644 dev/seed/lib/github-account.ts diff --git a/dev/local/mobile-open-routes.ts b/dev/local/mobile-open-routes.ts new file mode 100644 index 0000000000..57bca93fe6 --- /dev/null +++ b/dev/local/mobile-open-routes.ts @@ -0,0 +1,187 @@ +export const MOBILE_OPEN_ROUTES = [ + { name: 'home', path: '/home', description: 'Home tab' }, + { name: 'sessions', path: '/cloud/sessions', description: 'Session list (Agents tab)' }, + { name: 'session-list', path: '/cloud/sessions', description: 'Alias of sessions' }, + { + name: 'session', + path: '/cloud/sessions/', + description: 'One session. Pass --session-id=.', + }, + { name: 'settings', path: '/profile/preferences', description: 'Settings / preferences' }, + { name: 'profile', path: '/profile', description: 'Profile tab' }, +] as const; + +const NAMED_PATHS: Record = { + home: '/home', + sessions: '/cloud/sessions', + 'session-list': '/cloud/sessions', + settings: '/profile/preferences', + profile: '/profile', +}; + +export type MobileOpenPlatform = 'ios' | 'android'; + +export type MobileOpenOptions = { + email: string; + route: string; + sessionId: string | null; + platform: MobileOpenPlatform | null; + udid: string | null; + serial: string | null; +}; + +export function printMobileOpenUsage(): void { + console.log('Usage: pnpm dev:mobile:open --email [options]'); + console.log(''); + console.log('Issues a device session for a seeded user and opens the mobile dev build'); + console.log('on that route. Dev-build only: the app reads session tokens from the URL'); + console.log('when __DEV__ is true.'); + console.log(''); + console.log('Routes:'); + for (const route of MOBILE_OPEN_ROUTES) { + console.log(` ${route.name.padEnd(14)} ${route.path.padEnd(32)} ${route.description}`); + } + console.log(' / raw web path already in the universal-link table'); + console.log(''); + console.log('Options:'); + console.log(' --email= Seeded user email (required)'); + console.log(' --session-id= Required when is session'); + console.log(' --ios Open on the booted iOS simulator'); + console.log(' --android Open on a connected Android device/emulator'); + console.log(' --udid= iOS simulator UDID (default: booted)'); + console.log(' --serial= Android serial (default: first adb device)'); + console.log(''); + console.log('Examples:'); + console.log(' pnpm dev:mobile:open'); + console.log(' pnpm dev:mobile:open --email ada@example.com home'); + console.log(' pnpm dev:mobile:open --email ada@example.com session --session-id ses_1 --ios'); +} + +function isValidEmail(email: string): boolean { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); +} + +function takeFlagValue( + args: string[], + index: number, + flag: string +): { value: string; consumed: number } { + const arg = args[index]; + if (arg.length > flag.length && arg[flag.length] === '=') { + const inline = arg.slice(flag.length + 1).trim(); + if (!inline) { + throw new Error(`${flag} requires a value`); + } + return { value: inline, consumed: 1 }; + } + const next = args[index + 1]; + if (next === undefined || next.startsWith('--')) { + throw new Error(`${flag} requires a value`); + } + return { value: next.trim(), consumed: 2 }; +} + +export function parseMobileOpenArgs(args: string[]): MobileOpenOptions | null { + if (args.length === 0 || args.includes('--help') || args.includes('-h')) { + return null; + } + + let email: string | null = null; + let route: string | null = null; + let sessionId: string | null = null; + let platform: MobileOpenPlatform | null = null; + let udid: string | null = null; + let serial: string | null = null; + + for (let index = 0; index < args.length; index++) { + const arg = args[index]; + if (arg === '--ios') { + platform = 'ios'; + continue; + } + if (arg === '--android') { + platform = 'android'; + continue; + } + if (arg === '--email' || arg.startsWith('--email=')) { + const taken = takeFlagValue(args, index, '--email'); + email = taken.value; + index += taken.consumed - 1; + continue; + } + if (arg === '--session-id' || arg.startsWith('--session-id=')) { + const taken = takeFlagValue(args, index, '--session-id'); + sessionId = taken.value; + index += taken.consumed - 1; + continue; + } + if (arg === '--udid' || arg.startsWith('--udid=')) { + const taken = takeFlagValue(args, index, '--udid'); + udid = taken.value; + index += taken.consumed - 1; + continue; + } + if (arg === '--serial' || arg.startsWith('--serial=')) { + const taken = takeFlagValue(args, index, '--serial'); + serial = taken.value; + index += taken.consumed - 1; + continue; + } + if (arg.startsWith('--')) { + throw new Error(`Unknown argument: ${arg}`); + } + if (route !== null) { + throw new Error(`Unexpected positional argument: ${arg}`); + } + route = arg.trim(); + } + + if (!email) { + throw new Error('--email is required'); + } + if (!isValidEmail(email)) { + throw new Error(`email is not a valid address: ${email}`); + } + if (!route) { + throw new Error('route is required'); + } + + return { email, route, sessionId, platform, udid, serial }; +} + +export function resolveMobileOpenRoute(route: string, sessionId: string | null): string { + if (route === 'session') { + if (!sessionId) { + throw new Error('session requires --session-id='); + } + if (sessionId.includes('/') || sessionId.includes('?')) { + throw new Error('--session-id must be a single path segment'); + } + return `/cloud/sessions/${sessionId}`; + } + if (route.startsWith('/')) { + return route; + } + const named = NAMED_PATHS[route]; + if (!named) { + const names = MOBILE_OPEN_ROUTES.map(entry => entry.name).join(', '); + throw new Error(`Unknown route: ${route}. Known routes: ${names}`); + } + return named; +} + +export function buildDevSessionUrl( + pathName: string, + credentials: { + token: string; + refreshToken: string; + expiresIn: number; + } +): string { + const params = new URLSearchParams({ + dev_session_token: credentials.token, + dev_session_refresh: credentials.refreshToken, + dev_session_expires_in: String(credentials.expiresIn), + }); + return `kiloapp://${pathName}?${params.toString()}`; +} diff --git a/dev/local/mobile-open.test.ts b/dev/local/mobile-open.test.ts new file mode 100644 index 0000000000..95fb314f1d --- /dev/null +++ b/dev/local/mobile-open.test.ts @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + buildDevSessionUrl, + MOBILE_OPEN_ROUTES, + parseMobileOpenArgs, + resolveMobileOpenRoute, +} from './mobile-open-routes'; + +test('parseMobileOpenArgs lists usage when called without arguments', () => { + assert.equal(parseMobileOpenArgs([]), null); + assert.equal(parseMobileOpenArgs(['--help']), null); +}); + +test('parseMobileOpenArgs reads email and a named route', () => { + assert.deepEqual(parseMobileOpenArgs(['--email', 'ada@example.com', 'home']), { + email: 'ada@example.com', + route: 'home', + sessionId: null, + platform: null, + udid: null, + serial: null, + }); +}); + +test('resolveMobileOpenRoute maps names and raw paths', () => { + assert.equal(resolveMobileOpenRoute('home', null), '/home'); + assert.equal(resolveMobileOpenRoute('sessions', null), '/cloud/sessions'); + assert.equal(resolveMobileOpenRoute('settings', null), '/profile/preferences'); + assert.equal(resolveMobileOpenRoute('/profile', null), '/profile'); + assert.equal(resolveMobileOpenRoute('session', 'ses_1'), '/cloud/sessions/ses_1'); +}); + +test('resolveMobileOpenRoute rejects an unknown name and a missing session id', () => { + assert.throws(() => resolveMobileOpenRoute('unknown', null), /Unknown route/); + assert.throws(() => resolveMobileOpenRoute('session', null), /session requires --session-id/); +}); + +test('buildDevSessionUrl puts credentials on the kiloapp URL', () => { + const url = buildDevSessionUrl('/home', { + token: 'tok', + refreshToken: 'ref', + expiresIn: 3600, + }); + assert.equal( + url, + 'kiloapp:///home?dev_session_token=tok&dev_session_refresh=ref&dev_session_expires_in=3600' + ); +}); + +test('route list includes the E2E screens', () => { + const names = MOBILE_OPEN_ROUTES.map(route => route.name); + assert.deepEqual(names, ['home', 'sessions', 'session-list', 'session', 'settings', 'profile']); +}); diff --git a/dev/local/mobile-open.ts b/dev/local/mobile-open.ts new file mode 100644 index 0000000000..fb188b9446 --- /dev/null +++ b/dev/local/mobile-open.ts @@ -0,0 +1,176 @@ +import { execFileSync } from 'node:child_process'; +import { createHash, randomBytes } from 'node:crypto'; +import path from 'node:path'; + +import { device_refresh_tokens, device_sessions, kilocode_users } from '@kilocode/db/schema'; +import { signKiloToken } from '@kilocode/worker-utils'; +import { eq } from 'drizzle-orm'; + +import { resolveAndroidEnvironment } from './mobile-android'; +import { + buildDevSessionUrl, + parseMobileOpenArgs, + printMobileOpenUsage, + resolveMobileOpenRoute, +} from './mobile-open-routes'; +import { getSeedDb } from '../seed/lib/db'; +import { resolveSeedUserId } from '../seed/lib/users'; + +const ACCESS_TOKEN_SECONDS = 60 * 60; +const REFRESH_TOKEN_SECONDS = 30 * 24 * 60 * 60; +const DEV_USER_AGENT = 'kilo-dev-mobile-open'; + +function hashToken(token: string): string { + return createHash('sha256').update(token).digest('hex'); +} + +async function issueDevMobileSession(userId: string): Promise<{ + token: string; + refreshToken: string; + expiresIn: number; +}> { + const secret = process.env.NEXTAUTH_SECRET; + if (!secret) { + throw new Error( + 'NEXTAUTH_SECRET is not set for this worktree. Run pnpm dev:worktree:prepare first.' + ); + } + + const db = getSeedDb(); + const [user] = await db + .select({ + id: kilocode_users.id, + apiTokenPepper: kilocode_users.api_token_pepper, + }) + .from(kilocode_users) + .where(eq(kilocode_users.id, userId)) + .limit(1); + if (!user) { + throw new Error(`User ${userId} was not found`); + } + + const [session] = await db + .insert(device_sessions) + .values({ + kilo_user_id: user.id, + user_agent: DEV_USER_AGENT, + }) + .returning({ id: device_sessions.id }); + if (!session) { + throw new Error('Failed to create device session'); + } + + const { token } = await signKiloToken({ + userId: user.id, + pepper: user.apiTokenPepper, + secret, + expiresInSeconds: ACCESS_TOKEN_SECONDS, + env: process.env.NODE_ENV ?? 'development', + extra: { deviceSessionId: session.id }, + }); + const refreshToken = randomBytes(32).toString('base64url'); + const expiresAt = new Date(Date.now() + REFRESH_TOKEN_SECONDS * 1000).toISOString(); + await db.insert(device_refresh_tokens).values({ + token_hash: hashToken(refreshToken), + device_session_id: session.id, + expires_at: expiresAt, + }); + + return { + token, + refreshToken, + expiresIn: ACCESS_TOKEN_SECONDS, + }; +} + +function detectIosBooted(): boolean { + try { + const output = execFileSync('xcrun', ['simctl', 'list', 'devices', 'booted'], { + encoding: 'utf8', + }); + return output.includes('(Booted)'); + } catch { + return false; + } +} + +function firstAndroidSerial(): string | null { + try { + const env = resolveAndroidEnvironment({ + home: process.env.HOME ?? '', + path: process.env.PATH ?? '', + }); + const output = execFileSync(env.adb, ['devices'], { encoding: 'utf8' }); + const lines = output.split('\n').slice(1); + for (const line of lines) { + const [serial, state] = line.trim().split(/\s+/); + if (serial && state === 'device') { + return serial; + } + } + return null; + } catch { + return null; + } +} + +function openOnIos(url: string, udid: string | null): void { + const target = udid ?? 'booted'; + execFileSync('xcrun', ['simctl', 'openurl', target, url], { stdio: 'inherit' }); +} + +function openOnAndroid(url: string, serial: string | null): void { + const env = resolveAndroidEnvironment({ + home: process.env.HOME ?? '', + path: process.env.PATH ?? '', + }); + const args = ['shell', 'am', 'start', '-a', 'android.intent.action.VIEW', '-d', url]; + if (serial) { + execFileSync(env.adb, ['-s', serial, ...args], { stdio: 'inherit' }); + return; + } + execFileSync(env.adb, args, { stdio: 'inherit' }); +} + +export async function runMobileOpen(args: string[]): Promise { + const options = parseMobileOpenArgs(args); + if (!options) { + printMobileOpenUsage(); + return; + } + + const webPath = resolveMobileOpenRoute(options.route, options.sessionId); + const userId = await resolveSeedUserId(options.email); + const credentials = await issueDevMobileSession(userId); + const url = buildDevSessionUrl(webPath, credentials); + + let platform = options.platform; + if (!platform) { + if (detectIosBooted()) { + platform = 'ios'; + } else if (firstAndroidSerial()) { + platform = 'android'; + } else { + throw new Error( + 'No booted iOS simulator or connected Android device. Boot one, or pass --ios / --android.' + ); + } + } + + if (platform === 'ios') { + openOnIos(url, options.udid); + } else { + openOnAndroid(url, options.serial); + } + + console.log(`Opened ${webPath} as ${options.email} (${userId}) on ${platform}`); +} + +const isMain = + process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.filename); +if (isMain) { + runMobileOpen(process.argv.slice(2)).catch(error => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); + }); +} diff --git a/dev/seed/app/github-account.ts b/dev/seed/app/github-account.ts new file mode 100644 index 0000000000..782c853380 --- /dev/null +++ b/dev/seed/app/github-account.ts @@ -0,0 +1,97 @@ +import { isValidEmail, resolveSeedUserId } from '../lib/users'; +import { + donorRevokedError, + findLiveGitHubDonor, + findRevokedGitHubDonor, + noDonorAvailableError, + verifyLiveGitHubAuthorization, +} from '../lib/github-account'; +import type { SeedResult } from '../index'; + +export const usage = '[email]'; + +function printUsage(): void { + console.log(`Usage: pnpm dev:seed app:github-account ${usage}`); + console.log(''); + console.log('Reuses a local user that already has a live GitHub user authorization'); + console.log('(user_github_app_tokens.revoked_at IS NULL). Copying a donor onto a second'); + console.log('user is not possible: (github_user_id, github_app_type) is unique.'); + console.log(''); + console.log('Verification uses the same query as githubApps.getUserAuthorization.'); + console.log(''); + console.log('Examples:'); + console.log(' pnpm dev:seed app:github-account'); + console.log(' pnpm -s dev:seed app:github-account ada@example.com --json'); +} + +export async function run(...args: string[]): Promise { + if (args.includes('--help') || args.includes('-h')) { + printUsage(); + return; + } + + const [emailArg, ...rest] = args; + if (rest.length > 0) { + printUsage(); + throw new Error(`Unexpected extra arguments: ${rest.join(' ')}`); + } + + let preferredUserId: string | undefined; + if (emailArg) { + if (!isValidEmail(emailArg)) { + throw new Error(`email is not a valid address: ${emailArg}`); + } + preferredUserId = await resolveSeedUserId(emailArg); + const preferred = await findLiveGitHubDonor(preferredUserId); + if (preferred) { + const status = await verifyLiveGitHubAuthorization(preferred.userId); + console.log(''); + console.log( + 'This fixture represents: a reused user-owned GitHub user authorization that reports connected: true.' + ); + return { + userId: preferred.userId, + email: preferred.email, + authorizationId: preferred.authorizationId, + githubLogin: preferred.githubLogin, + connected: status.connected, + revoked: status.revoked, + reused: true, + }; + } + const revokedPreferred = await findRevokedGitHubDonor(preferredUserId); + if (revokedPreferred) { + throw donorRevokedError(revokedPreferred.githubLogin, revokedPreferred.reason); + } + } + + const live = await findLiveGitHubDonor(); + if (live) { + const status = await verifyLiveGitHubAuthorization(live.userId); + console.log(''); + console.log( + 'This fixture represents: a reused user-owned GitHub user authorization that reports connected: true.' + ); + if (preferredUserId && preferredUserId !== live.userId) { + console.log( + 'Note: the requested user has no GitHub authorization. Reused the live donor instead.' + ); + } + return { + userId: live.userId, + email: live.email, + authorizationId: live.authorizationId, + githubLogin: live.githubLogin, + connected: status.connected, + revoked: status.revoked, + reused: true, + }; + } + + const revoked = await findRevokedGitHubDonor(); + if (revoked) { + throw donorRevokedError(revoked.githubLogin, revoked.reason); + } + + throw noDonorAvailableError(); +} diff --git a/dev/seed/lib/github-account.test.ts b/dev/seed/lib/github-account.test.ts new file mode 100644 index 0000000000..603c3cf790 --- /dev/null +++ b/dev/seed/lib/github-account.test.ts @@ -0,0 +1,22 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + donorRevokedError, + noDonorAvailableError, + verificationFailedError, +} from './github-account'; + +test('donor errors are one line and distinguishable', () => { + assert.match(noDonorAvailableError().message, /^no donor available:/); + assert.match(donorRevokedError('octocat', 'github_token_rejected').message, /^donor revoked:/); + assert.match(donorRevokedError('octocat', 'github_token_rejected').message, /octocat/); + assert.match( + verificationFailedError({ connected: false, revoked: true }).message, + /^verification failed:/ + ); + assert.match( + verificationFailedError({ connected: false, revoked: true }).message, + /connected=false revoked=true/ + ); +}); diff --git a/dev/seed/lib/github-account.ts b/dev/seed/lib/github-account.ts new file mode 100644 index 0000000000..3c9f454c4a --- /dev/null +++ b/dev/seed/lib/github-account.ts @@ -0,0 +1,130 @@ +import { kilocode_users, user_github_app_tokens } from '@kilocode/db/schema'; +import { and, eq, isNotNull, isNull } from 'drizzle-orm'; + +import { getSeedDb } from './db'; + +export type GitHubAccountStatus = { + userId: string; + email: string; + authorizationId: string; + githubLogin: string; + connected: boolean; + revoked: boolean; +}; + +const STANDARD_APP = 'standard' as const; + +export function noDonorAvailableError(): Error { + return new Error( + 'no donor available: no live GitHub user authorization in this database. Connect GitHub once in the local web app, then re-run.' + ); +} + +export function donorRevokedError(githubLogin: string, reason: string | null): Error { + const suffix = reason ? ` reason=${reason}` : ''; + return new Error( + `donor revoked: githubLogin=${githubLogin}${suffix}. Reconnect that GitHub account in the local web app.` + ); +} + +export function verificationFailedError(status: { connected: boolean; revoked: boolean }): Error { + return new Error( + `verification failed: expected connected=true revoked=false, got connected=${status.connected} revoked=${status.revoked}.` + ); +} + +export async function readGitHubUserAuthorizationStatus( + kiloUserId: string +): Promise<{ connected: boolean; githubLogin: string | null; revoked: boolean }> { + const db = getSeedDb(); + const [authorization] = await db + .select({ + githubLogin: user_github_app_tokens.github_login, + revokedAt: user_github_app_tokens.revoked_at, + }) + .from(user_github_app_tokens) + .where( + and( + eq(user_github_app_tokens.kilo_user_id, kiloUserId), + eq(user_github_app_tokens.github_app_type, STANDARD_APP) + ) + ) + .limit(1); + + return authorization + ? { + connected: authorization.revokedAt === null, + githubLogin: authorization.githubLogin, + revoked: authorization.revokedAt !== null, + } + : { connected: false, githubLogin: null, revoked: false }; +} + +export async function findLiveGitHubDonor(userId?: string): Promise { + const db = getSeedDb(); + const filters = [ + eq(user_github_app_tokens.github_app_type, STANDARD_APP), + isNull(user_github_app_tokens.revoked_at), + ]; + if (userId) { + filters.push(eq(user_github_app_tokens.kilo_user_id, userId)); + } + const [row] = await db + .select({ + userId: kilocode_users.id, + email: kilocode_users.google_user_email, + authorizationId: user_github_app_tokens.id, + githubLogin: user_github_app_tokens.github_login, + }) + .from(user_github_app_tokens) + .innerJoin(kilocode_users, eq(kilocode_users.id, user_github_app_tokens.kilo_user_id)) + .where(and(...filters)) + .limit(1); + + if (!row) { + return null; + } + return { + userId: row.userId, + email: row.email, + authorizationId: row.authorizationId, + githubLogin: row.githubLogin, + connected: true, + revoked: false, + }; +} + +export async function findRevokedGitHubDonor(userId?: string): Promise<{ + githubLogin: string; + reason: string | null; +} | null> { + const db = getSeedDb(); + const filters = [ + eq(user_github_app_tokens.github_app_type, STANDARD_APP), + isNotNull(user_github_app_tokens.revoked_at), + ]; + if (userId) { + filters.push(eq(user_github_app_tokens.kilo_user_id, userId)); + } + const [row] = await db + .select({ + githubLogin: user_github_app_tokens.github_login, + reason: user_github_app_tokens.revocation_reason, + }) + .from(user_github_app_tokens) + .where(and(...filters)) + .limit(1); + return row ?? null; +} + +export async function verifyLiveGitHubAuthorization(userId: string): Promise<{ + connected: boolean; + githubLogin: string | null; + revoked: boolean; +}> { + const status = await readGitHubUserAuthorizationStatus(userId); + if (!status.connected || status.revoked) { + throw verificationFailedError(status); + } + return status; +} diff --git a/package.json b/package.json index 91cd9b2be1..37d87d340b 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,8 @@ "dev:env:mobile": "tsx dev/local/mobile-env.ts", "dev:mobile:android": "tsx dev/local/mobile-android.ts", "dev:mobile:ios": "tsx dev/local/mobile-ios-build.ts", - "test:dev-local": "tsx --test dev/local/*.test.ts dev/local/env-sync/*.test.ts dev/local/scripts/*.test.ts", + "dev:mobile:open": "tsx dev/local/mobile-open.ts", + "test:dev-local": "tsx --test dev/local/*.test.ts dev/local/env-sync/*.test.ts dev/local/scripts/*.test.ts dev/seed/lib/*.test.ts", "test:mobile-workflow": "tsx --test dev/local/mobile-native-build.test.ts dev/local/mobile-ios-build.test.ts dev/local/mobile-android-build.test.ts dev/local/mobile-android.test.ts dev/local/mobile-workflow.test.ts", "dev:setup-env": "tsx dev/local/setup-env.ts", "dev:seed": "tsx dev/seed/index.ts",