Skip to content
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
d60ca67
feat(auth): verify current pepper and active account on session and c…
iscekic Aug 17, 2026
6ef3262
feat(session-ingest): mint and consume one-use web tickets for /api/u…
iscekic Aug 17, 2026
21c12cf
feat(active-sessions): mint one-use web tickets via getToken mutation
iscekic Aug 18, 2026
a9a00a1
test(mobile): assert getToken mints the ingest ticket as a mutation
iscekic Aug 18, 2026
6a02188
feat(cloud-agent): bind stream tickets to audience and consume nonce …
iscekic Aug 18, 2026
77d0813
fix(organizations): read primary db for group policy admission
iscekic Aug 18, 2026
af3f65a
fix(mobile): store iOS bearer tokens with WHEN_UNLOCKED_THIS_DEVICE_ONLY
iscekic Aug 18, 2026
e4c971d
feat(cloud-agent): return ticket and expiresAt from every getTicket c…
iscekic Aug 18, 2026
a44f1cd
feat(session-ingest): recheck membership on command and close sockets…
iscekic Aug 18, 2026
1f88e88
feat(user): reauthenticate and perform GDPR removal on account deletion
iscekic Aug 18, 2026
075a45f
test: satisfy typecheck and lint in changed test files
iscekic Aug 18, 2026
6068c10
test(extension): return expiresAt from stream-ticket e2e mock
iscekic Aug 18, 2026
460a397
test(cloud-agent): prove /terminal nonce replay is rejected
iscekic Aug 18, 2026
3261316
test(seed): add w4c-org-pair fixture for member-revocation E2E
iscekic Aug 18, 2026
fd2705a
Merge remote-tracking branch 'origin/main' into audit-w4c-revocation-…
iscekic Aug 18, 2026
32ec70d
refactor(session-ingest): drop unused ticket consume response schema
iscekic Aug 18, 2026
e0b441c
fix(cloud-agent): isolate pg-dependent kilo token validation
iscekic Aug 18, 2026
ac56293
Merge remote-tracking branch 'origin/main' into audit-w4c-revocation-…
iscekic Aug 18, 2026
a747102
fix(user): enforce code expiry and attempt budget on account deletion
iscekic Aug 18, 2026
00fb97a
fix(sdk): refresh one-use ingest ticket on every reconnect
iscekic Aug 18, 2026
412d878
fix(cloud-agent): expire stream-ticket nonce storage after ticket expiry
iscekic Aug 18, 2026
54c4c1f
chore: retrigger review
iscekic Aug 18, 2026
d1c62c4
fix(auth): consume the deletion code only after GDPR removal succeeds
iscekic Aug 18, 2026
151820c
fix(session-ingest): report auth dependency outages as 503, not 401
iscekic Aug 18, 2026
ee56d50
Merge remote-tracking branch 'origin/main' into audit-w4c-revocation-…
iscekic Aug 18, 2026
581fc81
test(mobile): cast api.send fixture to branded SDK types
iscekic Aug 18, 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
2 changes: 1 addition & 1 deletion apps/extension/entrypoints/sidepanel/agents-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export const ExtensionAgentsProvider = ({

const userWebConnection = createUserWebConnection({
getAuthToken: async () => {
const tokenResult = await trpcClient.activeSessions.getToken.query();
const tokenResult = await trpcClient.activeSessions.getToken.mutate();
return tokenResult.token;
},
lifecycleHooks: createBrowserLifecycleHooks(),
Expand Down
27 changes: 21 additions & 6 deletions apps/extension/src/shared/extension-agent-session-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -418,14 +418,14 @@ describe('createExtensionAgentSessionManager', () => {
describe('getTicket', () => {
it('builds correct ticket URL and returns ticket', async () => {
const { mock, restore } = withFakeFetch(
Response.json({ ticket: 'ticket-123' }, { status: 200 })
Response.json({ expiresAt: 1_700_000_000, ticket: 'ticket-123' }, { status: 200 })
);
try {
const opts = makeDefaultOptions();
vi.spyOn(opts, 'getToken').mockReturnValue('bearer-token');
createExtensionAgentSessionManager(opts);
const ticket = await capturedConfig!.getTicket(CLOUD_AGENT_ID as never);
expect(ticket).toBe('ticket-123');
const result = await capturedConfig!.getTicket(CLOUD_AGENT_ID as never);
expect(result).toStrictEqual({ expiresAt: 1_700_000_000, ticket: 'ticket-123' });
expect(mock).toHaveBeenCalledTimes(1);
const [callUrl] = mock.mock.calls[0] as unknown as [string];
expect(callUrl).toBe('https://api.test/api/cloud-agent-next/sessions/stream-ticket');
Expand All @@ -447,7 +447,7 @@ describe('createExtensionAgentSessionManager', () => {

it('includes organizationId in body when org is set', async () => {
const { mock, restore } = withFakeFetch(
Response.json({ ticket: 'ticket-org' }, { status: 200 })
Response.json({ expiresAt: 1_700_000_000, ticket: 'ticket-org' }, { status: 200 })
);
try {
const opts = {
Expand All @@ -469,7 +469,7 @@ describe('createExtensionAgentSessionManager', () => {

it('omits Authorization header when getToken returns undefined', async () => {
const { mock, restore } = withFakeFetch(
Response.json({ ticket: 'ticket-noauth' }, { status: 200 })
Response.json({ expiresAt: 1_700_000_000, ticket: 'ticket-noauth' }, { status: 200 })
);
try {
const opts = makeDefaultOptions();
Expand All @@ -488,7 +488,7 @@ describe('createExtensionAgentSessionManager', () => {

it('omits Authorization header when getToken returns empty string', async () => {
const { mock, restore } = withFakeFetch(
Response.json({ ticket: 'ticket-empty' }, { status: 200 })
Response.json({ expiresAt: 1_700_000_000, ticket: 'ticket-empty' }, { status: 200 })
);
try {
const opts = makeDefaultOptions();
Expand Down Expand Up @@ -527,6 +527,21 @@ describe('createExtensionAgentSessionManager', () => {
restore();
}
});

it('throws when expiresAt is missing from response', async () => {
const { restore } = withFakeFetch(
Response.json({ ticket: 'ticket-noexpiry' }, { status: 200 })
);
try {
const opts = makeDefaultOptions();
createExtensionAgentSessionManager(opts);
await expect(capturedConfig!.getTicket(CLOUD_AGENT_ID as never)).rejects.toThrow(
'Missing expiresAt'
);
} finally {
restore();
}
});
});

// ---- fetchSnapshot ----
Expand Down
15 changes: 12 additions & 3 deletions apps/extension/src/shared/extension-agent-session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -649,7 +649,9 @@ export function createExtensionAgentSessionManager({
}),

// ---- getTicket ----
getTicket: async (sessionId: CloudAgentSessionId): Promise<string> => {
getTicket: async (
sessionId: CloudAgentSessionId
): Promise<{ ticket: string; expiresAt: number }> => {
const token = getToken();
const body: Record<string, string> = { cloudAgentSessionId: sessionId };
if (organizationId !== null) {
Expand All @@ -664,14 +666,21 @@ export function createExtensionAgentSessionManager({
method: 'POST',
});
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- fetch json is untyped
const data = (await response.json()) as { ticket?: string; error?: string };
const data = (await response.json()) as {
ticket?: string;
expiresAt?: number;
error?: string;
};
if (!response.ok) {
throw new Error(data.error ?? 'Failed to get stream ticket');
}
if (data.ticket === undefined) {
throw new Error('Missing ticket in stream-ticket response');
}
return data.ticket;
if (data.expiresAt === undefined) {
throw new Error('Missing expiresAt in stream-ticket response');
}
return { expiresAt: data.expiresAt, ticket: data.ticket };
},

// ---- initiate ----
Expand Down
9 changes: 7 additions & 2 deletions apps/extension/tests/e2e/agents-fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,10 @@ export const mockAgentsApi = async (

// ---- /api/cloud-agent-next/sessions/stream-ticket ----
await context.route('https://app.kilo.ai/api/cloud-agent-next/sessions/stream-ticket', route =>
route.fulfill({ json: { ticket: 'mock-stream-ticket' }, status: 200 })
route.fulfill({
json: { expiresAt: Math.floor(Date.now() / 1000) + 3600, ticket: 'mock-stream-ticket' },
status: 200,
})
);

// ---- tRPC batch dispatcher ----
Expand Down Expand Up @@ -519,7 +522,9 @@ export const mockAgentsApi = async (
}

if (proc === 'activeSessions.getToken') {
return { result: { data: { token: 'mock-ingest-token' } } };
return {
result: { data: { expiresAt: 1_700_000_000, token: 'mock-ingest-token' } },
};
}

if (
Expand Down
105 changes: 101 additions & 4 deletions apps/mobile/src/components/agents/mobile-session-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';

import { type AgentAttachmentSubmissionPayload } from '@/lib/agent-attachments/agent-attachment-types';
import { SPAWNED_NOT_FOUND_MAX_ATTEMPTS } from '@/lib/spawned-not-found-retry';
import { type KiloSessionId } from '@kilocode/cloud-agent-sdk';
import {
type KiloSessionId,
type SessionManager,
type SessionManagerConfig,
} from '@kilocode/cloud-agent-sdk';

// Keep this suite on the pure vitest project: mock every RN / Expo / SDK
// side-effect import that `mobile-session-manager.ts` pulls transitively
Expand All @@ -15,8 +19,21 @@ vi.mock('expo-secure-store', () => ({
vi.mock('sonner-native', () => ({
toast: { error: vi.fn(), success: vi.fn() },
}));
// Capture the config passed to createSessionManager so the getTicket closure
// can be asserted directly, mirroring the extension suite.
const { configHolder, mockCreateSessionManager } = vi.hoisted(() => {
const holder: { current: SessionManagerConfig | null } = { current: null };
const createSessionManagerMock = vi.fn((config: SessionManagerConfig): SessionManager => {
holder.current = config;
return { atoms: {} } as unknown as SessionManager;
});
return { configHolder: holder, mockCreateSessionManager: createSessionManagerMock };
});
vi.mock('@kilocode/cloud-agent-sdk', () => ({
createSessionManager: vi.fn(),
createSessionManager: mockCreateSessionManager,
}));
vi.mock('@/lib/auth/token-owner', () => ({
getAuthTokenForRequest: vi.fn(async () => 'test-token'),
}));
vi.mock('@/components/agents/mobile-session-transport-payload', () => ({
normalizeTransportPayload: vi.fn((x: unknown) => x),
Expand Down Expand Up @@ -60,8 +77,12 @@ vi.mock('@/lib/trpc', () => ({

const { buildRemoteAttachmentParts } =
await import('@/components/agents/mobile-session-manager-helpers');
const { fetchSessionWithNotFoundRetry, isCloudPrepareRetryableError, readFetchSessionErrorCode } =
await import('@/components/agents/mobile-session-manager');
const {
createMobileAgentSessionManager,
fetchSessionWithNotFoundRetry,
isCloudPrepareRetryableError,
readFetchSessionErrorCode,
} = await import('@/components/agents/mobile-session-manager');

const SESSION_ID = 'ses_test_session_id_0000000001' as KiloSessionId;

Expand All @@ -75,6 +96,18 @@ function withCode(code: string, message: string): Error {
return Object.assign(new Error(message), { data: { code } });
}

function withFakeFetch(response: Response) {
const originalFetch = globalThis.fetch;
const mock = vi.fn(async () => response);
globalThis.fetch = mock as never;
return {
mock,
restore: () => {
globalThis.fetch = originalFetch;
},
};
}

describe('buildRemoteAttachmentParts', () => {
beforeEach(() => {
mutate.mockReset();
Expand Down Expand Up @@ -333,3 +366,67 @@ describe('fetchSessionWithNotFoundRetry', () => {
expect(sleep).not.toHaveBeenCalled();
});
});

describe('getTicket', () => {
const CLOUD_AGENT_ID = 'agent_12345678-1234-1234-1234-123456789abc';

beforeEach(() => {
configHolder.current = null;
mockCreateSessionManager.mockClear();
});

function setup(): SessionManagerConfig {
const options = {
store: {},
userWebConnection: {},
};
createMobileAgentSessionManager(options as never);
const config = configHolder.current;
if (config === null) {
throw new Error('createSessionManager did not capture a config');
}
return config;
}

it('returns the ticket and expiresAt from the stream-ticket response', async () => {
const { mock, restore } = withFakeFetch(
Response.json({ ticket: 'ticket-123', expiresAt: 1_700_000_000 }, { status: 200 })
);
try {
const config = setup();
await expect(config.getTicket(CLOUD_AGENT_ID as never)).resolves.toEqual({
ticket: 'ticket-123',
expiresAt: 1_700_000_000,
});
expect(mock).toHaveBeenCalledTimes(1);
} finally {
restore();
}
});

it('throws when expiresAt is missing from the response', async () => {
const { restore } = withFakeFetch(
Response.json({ ticket: 'ticket-noexpiry' }, { status: 200 })
);
try {
const config = setup();
await expect(config.getTicket(CLOUD_AGENT_ID as never)).rejects.toThrow(
'Missing expiresAt in stream-ticket response'
);
} finally {
restore();
}
});

it('throws when ticket is missing from the response', async () => {
const { restore } = withFakeFetch(Response.json({ expiresAt: 1_700_000_000 }, { status: 200 }));
try {
const config = setup();
await expect(config.getTicket(CLOUD_AGENT_ID as never)).rejects.toThrow(
'Missing ticket in stream-ticket response'
);
} finally {
restore();
}
});
});
19 changes: 14 additions & 5 deletions apps/mobile/src/components/agents/mobile-session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,8 +211,10 @@ export function createMobileAgentSessionManager({
...(activeSession.capabilities ? { capabilities: activeSession.capabilities } : {}),
};
},
getTicket: async (sessionId: CloudAgentSessionId): Promise<string> => {
const ticket = await withCloudAgentDiagnostics('getTicket', organizationId, async () => {
getTicket: async (
sessionId: CloudAgentSessionId
): Promise<{ ticket: string; expiresAt: number }> => {
const result = await withCloudAgentDiagnostics('getTicket', organizationId, async () => {
const token = await getAuthTokenForRequest();
const body: Record<string, string> = { cloudAgentSessionId: sessionId };
if (organizationId) {
Expand All @@ -229,16 +231,23 @@ export function createMobileAgentSessionManager({
body: JSON.stringify(body),
}
);
const data = (await response.json()) as { ticket?: string; error?: string };
const data = (await response.json()) as {
ticket?: string;
expiresAt?: number;
error?: string;
};
if (!response.ok) {
throw new Error(data.error ?? 'Failed to get stream ticket');
}
if (!data.ticket) {
throw new Error('Missing ticket in stream-ticket response');
}
return data.ticket;
if (data.expiresAt === undefined) {
throw new Error('Missing expiresAt in stream-ticket response');
}
return { ticket: data.ticket, expiresAt: data.expiresAt };
});
return ticket;
return result;
},
fetchSnapshot: async (id: KiloSessionId) => {
const [sessionData, messagesResult] = await Promise.all([
Expand Down
Loading