Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
6a76b92
feat(auth): negotiate native API and gateway credentials
pandemicsyn Sep 3, 2026
bc69181
test(auth): isolate native credential route dependencies
pandemicsyn Sep 3, 2026
c612f37
chore(auth): checkpoint unfinished Phase 5.2 migration
pandemicsyn Sep 3, 2026
83d4c55
fix(auth): bound shared token renewal authority
pandemicsyn Sep 3, 2026
aa67346
fix(cloud-agent): harden runtime proxy foundation
pandemicsyn Sep 3, 2026
01652b2
feat(cloud-agent): add fenced runtime credential proxy
pandemicsyn Sep 3, 2026
07fd05a
feat(cloud-agent): deliver renewable runtime credentials
pandemicsyn Sep 3, 2026
32c9014
feat(cloud-agent): fence control runtime credentials
pandemicsyn Sep 3, 2026
6ec5be6
fix(cloud-agent): bind shared worktree authority
pandemicsyn Sep 3, 2026
3386f0a
fix(auth): bind automation tokens to organizations
pandemicsyn Sep 3, 2026
1411178
fix(gastown): validate legacy token renewal authority
pandemicsyn Sep 3, 2026
3e3aba3
fix(auth): scope security workflow gateway tokens
pandemicsyn Sep 3, 2026
b9c5bc3
fix(cloud-agent): contain Vercel proxy credentials
pandemicsyn Sep 3, 2026
c6fc8c9
fix(cloud-agent): normalize CLI inference proxy routes
pandemicsyn Sep 3, 2026
109f813
fix(auth): enforce signed gateway organization scope
pandemicsyn Sep 3, 2026
f2035a0
fix(cloud-agent): share fenced worktree proxy authority
pandemicsyn Sep 4, 2026
2d2883a
test(cloud-agent): provide runtime proxy signing secret
pandemicsyn Sep 4, 2026
d7c7031
fix(auth): pass request headers to delegation issuers
pandemicsyn Sep 4, 2026
7f24f38
fix(worker-utils): isolate database-backed authorization exports
pandemicsyn Sep 4, 2026
dc3c92f
test(auth): mock bounded Cloud Agent delegation
pandemicsyn Sep 4, 2026
3216a97
fix(auth): contain delegated runtime credentials
pandemicsyn Sep 4, 2026
74c20d0
fix(auth): bind automation credential scope
pandemicsyn Sep 4, 2026
5596647
fix(mobile): serialize credential persistence
pandemicsyn Sep 4, 2026
dd3e596
fix(gastown): enforce current runtime authority
pandemicsyn Sep 4, 2026
4b98740
fix(cloud-agent): support immutable CLI proxy routes
pandemicsyn Sep 4, 2026
58cbde8
fix(auth): bind benchmark API and gateway audiences
pandemicsyn Sep 4, 2026
2dbedff
fix(cloud-agent): isolate shared-worktree Kilo runtimes
pandemicsyn Sep 4, 2026
38cf9b3
fix(auth): preserve legacy benchmark rollout
pandemicsyn Sep 4, 2026
789ae7c
fix(auth): preserve staged rollout compatibility
pandemicsyn Sep 4, 2026
d3f105e
test(cloud-agent): cover runtime authorization recovery
pandemicsyn Sep 4, 2026
d1076b8
fix(cloud-agent): recover expired runtime authorization
pandemicsyn Sep 5, 2026
8bf4b41
test(cloud-agent): provide recovery signing secret
pandemicsyn Sep 5, 2026
7a8cdad
fix(auth): close rollout compatibility gaps
pandemicsyn Sep 8, 2026
2416519
fix(auth): reconcile migration with current main
pandemicsyn Sep 8, 2026
1f40800
test(mobile): complete secure store retry config fixture
pandemicsyn Sep 8, 2026
ee1d8ef
fix(auth): isolate token adoption and preserve chat rollback
pandemicsyn Sep 8, 2026
9fcdd38
fix(gastown): migrate legacy town identity during renewal
pandemicsyn Sep 8, 2026
a01a8eb
Merge main and preserve token runtime isolation
pandemicsyn Sep 8, 2026
853dc5e
test(mobile): isolate chat token config from native imports
pandemicsyn Sep 8, 2026
12f0eb5
test(cloud-agent): await complete passive-status event forwarding
pandemicsyn Sep 8, 2026
c4c1ecb
Merge main and preserve readable branches with runtime isolation
pandemicsyn Sep 8, 2026
ad3dbc5
test(cloud-agent): correct smoke errors and cancel owned sessions
pandemicsyn Sep 8, 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
10 changes: 10 additions & 0 deletions ENVIRONMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,16 @@ Manage shared web env var additions and rotations with `pnpm web:env set <VARIAB
- `INTERNAL_API_SECRET` - Shared secret for internal API calls between services; used in `apps/web/src/lib/kiloclaw/cli-runs.test.ts`, `kiloclaw-router.test.ts`, dev seed scripts, and other service routers. `[SECRET]`
- `SUPPORT_API_SECRET` - Shared bearer token for Customer Support Automation (CSA) internal API calls. Cloud uses it to authorize CSA → Cloud `apps/web/src/app/api/internal/support/` and Cloud → CSA `POST /api/internal/cloud/users/gdpr-scrub`. A CSA compromise can also call Cloud deletion and Cloud can scrub CSA-local PII. Leak can look up any email and enqueue deletion for non-admin, non-bot, non-live-subscription customers; access disable is deferred to worker preflight and pending requests can be cancelled. Keep production values off preview deployments; rotate Cloud and CSA together. `[SECRET]`
- `BOUNDED_INTERNAL_SERVICE_TOKENS_ENABLED` - Set to exact `true` to enable modern, purpose-labelled internal assertions at the Phase 5.1 bounded Git broker, export, deletion, and Session Ingest callsites. Unset or any other value retains their existing legacy token formats. Enable only after compatible readers, including the dedicated GitHub disconnect audience, are deployed; generic human/control/runtime signers are not affected. [SERVER]
- `NATIVE_RESOURCE_TOKENS_ENABLED` - Set to exact `true` to permit fresh native adoption of separate one-hour API/gateway access tokens only for clients explicitly requesting `api-gateway-v1`, provided `SHARED_RESOURCE_TOKENS_ENABLED` is also exact `true`. Default-off. Unsupported clients keep legacy responses. Turning this flag off affects subsequent native issuance/refreshes only; active modern device credentials can continue receiving bounded control tokens while their owned device session and current user pepper remain valid. [SERVER]
- `SHARED_RESOURCE_TOKENS_ENABLED` - Master default-off readiness gate. Fresh modern producer issuance requires both this flag and its family flag below to be exact `true`; unset or any other value disables adoption. Native adoption separately requires `NATIVE_RESOURCE_TOKENS_ENABLED` and this master, independent of producer families; old CLI negotiation is unchanged. These flags do not revoke existing credentials. Valid modern device access credentials retain bounded control issuance after rollback, with current owned-session, pepper, and requested organization membership validation and a one-hour/parent-expiry cap. Chat likewise retains bounded three-audience issuance for validated modern devices; modern credentials never fall back to broad legacy tokens. Persisted modern workload renewal does not use adoption gates. Separately deployed readers/producers must be verified before activation. [SERVER]
- `CLOUD_AGENT_RESOURCE_TOKENS_ENABLED` - Default-off family gate for Cloud Agent Next request control and workflow control tokens; requires the master and exact `true`. [SERVER]
- `GASTOWN_RESOURCE_TOKENS_ENABLED` - Default-off family gate for Gastown control tokens; requires the master and exact `true`. This is not a safe-activation declaration: known ingest-audience and live-token-transport blockers remain deferred. [SERVER]
- `WASTELAND_RESOURCE_TOKENS_ENABLED` - Default-off family gate for Wasteland control tokens; requires the master and exact `true`. [SERVER]
- `CHAT_RESOURCE_TOKENS_ENABLED` - Default-off family gate for fresh chat resource issuance; requires the master and exact `true`. Validated modern devices retain chat/event-service/notifications issuance after rollback, capped by one hour and parent expiry. [SERVER]
- `DELEGATED_RESOURCE_TOKENS_ENABLED` - Default-off family gate for explicit API, gateway, attribution, and HTML-deploy delegation, including the organization user-token resource route; requires the master and exact `true`. Disabled explicit delegation remains unavailable. [SERVER]
- `WORKFLOW_GATEWAY_RESOURCE_TOKENS_ENABLED` - Default-off family gate for server workflow gateway tokens; requires the master and exact `true`. [SERVER]
- `BENCHMARK_RESOURCE_TOKENS_ENABLED` - Default-off family gate for benchmark resource tokens; requires the master and exact `true`. [SERVER]
- `RUNTIME_ISOLATION_ENABLED` - Cloud Agent Worker rollout control for new modern control-plane sessions and worktree destinations. Exact `true` permits adoption; default/unset/other values reject it before durable work. Legacy attachments keep directory-shared Kilo runtimes. Persisted modern runtime authorization continues selecting per-session isolation after rollback, and the connected wrapper must advertise the isolation capability. Keep off during the automatic deployment wave and enable only after compatible Worker and wrapper versions are healthy. Foreground expiry recovery retains the same session identity and requires acknowledged idle transport retirement; this flag does not establish complete real-provider smoke coverage. See `docs/token-issuance-policy.md`, Phase 5.2 merge, automatic deployment, and activation. [SERVER]
- `CALLBACK_TOKEN_SECRET` - Secret for signing callback tokens. Required for local development. `[SECRET]`
- `INTERNAL_SECRET` - Alias/fallback for `INTERNAL_API_SECRET`; used in KiloClaw E2E scripts (`services/kiloclaw/e2e/`). `[SECRET]`

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';

const mocks = vi.hoisted(() => ({
getItemAsync: vi.fn<() => Promise<string | null>>(),
getItemAsync: vi.fn<(key: string) => Promise<string | null>>(),
getTokenQuery: vi.fn<() => Promise<{ token: string; userId: string; expiresAt: string }>>(),
}));

Expand All @@ -13,8 +13,11 @@ vi.mock('expo-secure-store', () => ({
getItemAsync: mocks.getItemAsync,
}));

vi.mock('@/lib/config', () => ({ E2E_SECURE_STORE_FAULT_MS: 0 }));

vi.mock('@/lib/storage-keys', () => ({
AUTH_TOKEN_KEY: 'auth-token',
NATIVE_CREDENTIAL_BUNDLE_KEY: 'native-credential-bundle',
}));

vi.mock('@/lib/trpc', () => ({
Expand All @@ -30,6 +33,8 @@ vi.mock('@/lib/trpc', () => ({
describe('useKiloChatTokenResponseGetter', () => {
beforeEach(async () => {
vi.clearAllMocks();
const { clearActiveToken } = await import('@/lib/auth/token-owner');
clearActiveToken();
const { clearKiloChatTokenCache } = await import('./use-kilo-chat-token');
clearKiloChatTokenCache();
});
Expand All @@ -42,7 +47,10 @@ describe('useKiloChatTokenResponseGetter', () => {
};
const seenUserIds: string[] = [];

mocks.getItemAsync.mockResolvedValue('auth-token-1');
mocks.getItemAsync.mockImplementation(async key => {
await Promise.resolve();
return key === 'auth-token' ? 'auth-token-1' : null;
});
mocks.getTokenQuery.mockRejectedValueOnce(new Error('network down'));
mocks.getTokenQuery.mockResolvedValueOnce(response);

Expand All @@ -69,7 +77,10 @@ describe('useKiloChatTokenResponseGetter', () => {
expiresAt: '2099-03-13 14:30:00+00',
};

mocks.getItemAsync.mockResolvedValue('auth-token-2');
mocks.getItemAsync.mockImplementation(async key => {
await Promise.resolve();
return key === 'auth-token' ? 'auth-token-2' : null;
});
mocks.getTokenQuery.mockResolvedValueOnce(response);

const { useKiloChatTokenResponseGetter } = await import('./use-kilo-chat-token');
Expand Down Expand Up @@ -98,7 +109,10 @@ describe('useKiloChatTokenResponseGetter', () => {
expiresAt: '2099-03-13 14:30:00+00',
};

mocks.getItemAsync.mockResolvedValue('auth-token-3');
mocks.getItemAsync.mockImplementation(async key => {
await Promise.resolve();
return key === 'auth-token' ? 'auth-token-3' : null;
});
mocks.getTokenQuery.mockResolvedValueOnce(firstResponse);
mocks.getTokenQuery.mockResolvedValueOnce(secondResponse);

Expand Down
16 changes: 4 additions & 12 deletions apps/mobile/src/components/login-screen.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,8 @@ import { errorMessage } from './login-screen-state';

const deviceAuth = vi.hoisted(() => ({
status: 'idle' as string,
token: undefined as string | undefined,
code: undefined as string | undefined,
refreshToken: undefined as string | undefined,
expiresIn: undefined as number | undefined,
credentials: undefined,
error: undefined as string | undefined,
verificationUrl: undefined as string | undefined,
resumed: false,
Expand Down Expand Up @@ -74,10 +72,8 @@ vi.mock('@/lib/auth/auth-context', () => ({
vi.mock('@/lib/auth/use-device-auth', () => ({
useDeviceAuth: () => ({
status: deviceAuth.status,
token: deviceAuth.token,
code: deviceAuth.code,
refreshToken: deviceAuth.refreshToken,
expiresIn: deviceAuth.expiresIn,
credentials: deviceAuth.credentials,
error: deviceAuth.error,
verificationUrl: deviceAuth.verificationUrl,
resumed: deviceAuth.resumed,
Expand Down Expand Up @@ -270,10 +266,8 @@ describe('login-screen language globe', () => {
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
deviceAuth.status = 'idle';
deviceAuth.token = undefined;
deviceAuth.code = undefined;
deviceAuth.refreshToken = undefined;
deviceAuth.expiresIn = undefined;
deviceAuth.credentials = undefined;
deviceAuth.error = undefined;
deviceAuth.verificationUrl = undefined;
deviceAuth.resumed = false;
Expand Down Expand Up @@ -350,10 +344,8 @@ describe('login-screen idle skeleton', () => {
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
deviceAuth.status = 'idle';
deviceAuth.token = undefined;
deviceAuth.code = undefined;
deviceAuth.refreshToken = undefined;
deviceAuth.expiresIn = undefined;
deviceAuth.credentials = undefined;
deviceAuth.error = undefined;
deviceAuth.verificationUrl = undefined;
deviceAuth.resumed = false;
Expand Down
58 changes: 14 additions & 44 deletions apps/mobile/src/components/login-screen.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
/* eslint-disable max-lines -- The login screen keeps its device-auth branches, keyboard padding, and language picker together. */
import * as Clipboard from 'expo-clipboard';
import { type Href, useRouter } from 'expo-router';
import { ExternalLink, Globe } from '@/components/ui/icons';
import { useCallback, useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
AppState,
I18nManager,
Keyboard,
KeyboardAvoidingView,
type KeyboardEvent,
Platform,
Pressable,
ScrollView,
Expand All @@ -33,60 +31,38 @@ import { Skeleton } from '@/components/ui/skeleton';
import { Text } from '@/components/ui/text';
import { announcingToast } from '@/lib/a11y/announcing-toast';
import { useAuth } from '@/lib/auth/auth-context';
import { useDeviceApprovalPersistence } from '@/lib/auth/use-device-approval-persistence';
import { useDeviceAuth } from '@/lib/auth/use-device-auth';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
import {
clearLoginDrafts,
clearPersistedLoginDrafts,
persistLoginDrafts,
restoreLoginDrafts,
type SsoRecoveryDraft,
} from '@/lib/login-draft';
import { setLanguagePickerBridge } from '@/lib/picker-bridge';

function keyboardHeightFromEvent(event: KeyboardEvent): number {
return event.endCoordinates.height;
}

export function LoginScreen() {
const { sessionEnded, signIn } = useAuth();
const router = useRouter();
const {
status,
token,
code,
refreshToken,
expiresIn,
error,
verificationUrl,
resumed,
start,
cancel,
openBrowser,
} = useDeviceAuth();
const { status, code, credentials, error, verificationUrl, resumed, start, cancel, openBrowser } =
useDeviceAuth();
const colors = useThemeColors();
const insets = useSafeAreaInsets();
const { t } = useTranslation();
const [persistError, setPersistError] = useState<string | undefined>(undefined);
const [androidKeyboardHeight, setAndroidKeyboardHeight] = useState(0);
const [authFormBusy, setAuthFormBusy] = useState(false);
const [draft, setDraft] = useState<{
email: string;
ssoRecovery: SsoRecoveryDraft | null;
} | null>(null);

const persistToken = useCallback(
async (tokenValue: string, refreshTokenValue?: string, expiresInValue?: number) => {
setPersistError(undefined);
try {
await signIn(tokenValue, refreshTokenValue, expiresInValue);
clearLoginDrafts();
} catch {
setPersistError(t('login.couldNotCompleteSignIn'));
}
},
[signIn, t]
);
const { persistError, isPersisting, persistToken } = useDeviceApprovalPersistence({
status,
credentials,
signIn,
couldNotCompleteSignIn: t('login.couldNotCompleteSignIn'),
});

useEffect(() => {
let cancelled = false;
Expand Down Expand Up @@ -116,13 +92,6 @@ export function LoginScreen() {
}
}, [sessionEnded, t]);

useEffect(() => {
if (status === 'approved' && token) {
void persistToken(token, refreshToken, expiresIn);
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- persistToken is stable except for signIn identity; only re-run on a newly approved token
}, [status, token]);

// Android shell keyboard pad: under API 35+ EDGE_TO_EDGE_ENFORCED the window
// never resizes for the IME, so KeyboardAvoidingView is inert. keyboardDidShow
// still fires with real heights; consume them here (r0b: zero layout shift for
Expand All @@ -144,7 +113,7 @@ export function LoginScreen() {
currentPadding: current,
event: {
type: 'keyboard-visible',
keyboardHeight: keyboardHeightFromEvent(event),
keyboardHeight: event.endCoordinates.height,
},
})
);
Expand Down Expand Up @@ -181,11 +150,12 @@ export function LoginScreen() {
<Text className="text-center text-sm text-destructive">{persistError}</Text>
<Button
onPress={() => {
if (token) {
void persistToken(token, refreshToken, expiresIn);
if (credentials) {
void persistToken(credentials);
}
}}
accessibilityLabel={t('login.retrySignIn')}
disabled={isPersisting}
>
<Text>{t('common.retry')}</Text>
</Button>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,9 +116,8 @@ vi.mock('@/lib/utils', () => ({
vi.mock('@/lib/auth/auth-context', () => ({
useAuth: () => ({ authEpoch: authEpoch.value, token: 'token' }),
}));
vi.mock('@/lib/auth/token-owner', () => ({
getAuthTokenForRequest: () => 'token-1',
}));
vi.mock('@/lib/auth/token-owner', () => ({}));
vi.mock('@/lib/auth/credentials', () => ({ getGatewayAuthTokenForRequest: () => 'token-1' }));
vi.mock('@/lib/organization-context', () => ({
useOrganization: () => ({
organizationId: organizationId.value,
Expand Down
4 changes: 2 additions & 2 deletions apps/mobile/src/components/quick-chat/use-quick-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { ulid } from 'ulid';

import { i18n } from '@/i18n';
import { useAuth } from '@/lib/auth/auth-context';
import { getAuthTokenForRequest } from '@/lib/auth/token-owner';
import { getGatewayAuthTokenForRequest } from '@/lib/auth/credentials';
import { useOrganization } from '@/lib/organization-context';
import { trpcClient, useTRPC } from '@/lib/trpc';

Expand Down Expand Up @@ -266,7 +266,7 @@ export function useQuickChat(model: string) {

void (async () => {
try {
const authToken = await getAuthTokenForRequest();
const authToken = await getGatewayAuthTokenForRequest();
if (abortRef.current !== controller) {
return;
}
Expand Down
Loading
Loading