Skip to content
Merged
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
2 changes: 1 addition & 1 deletion application/single_app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@
EXECUTOR_TYPE = 'thread'
EXECUTOR_MAX_WORKERS = 30
SESSION_TYPE = 'filesystem'
VERSION = "0.261.338"
VERSION = "0.261.340"
IS_DEVELOPMENT = is_development_env_enabled()

# Opt-out for deployments where App Service Easy Auth is active but the platform
Expand Down
11 changes: 9 additions & 2 deletions application/v2_ui/src/components/chat/PendingActionSlots.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
import { useChatStore } from '../../stores/chatStore';
import { usePendingActions, usePendingActionsStoreApi } from '../../stores/m365PendingActionsStore';
import { useUiStore } from '../../stores/uiStore';
import { SESSION_REQUIRED_MESSAGE } from '../../stores/appSessionStore';
import { Notice } from '../approvals/ApprovalParts';
import { PendingActionCard } from '../approvals/PendingActionCard';
import { GlassButton, GlassPanel } from '../ui/primitives';
Expand Down Expand Up @@ -282,6 +283,10 @@
const continuationToken = usePendingActions((state) => state.continuationToken);
const loadingMore = usePendingActions((state) => state.loadingMore);
const listLoading = usePendingActions((state) => state.listStatus === 'loading');
const hasActionContext = usePendingActions((state) =>
Object.keys(state.entries).length > 0 || state.referenceLoading || Boolean(state.referenceError) ||
state.focusRequest?.conversationId === state.conversationId);

Check warning on line 288 in application/v2_ui/src/components/chat/PendingActionSlots.tsx

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains AI, plugin, agent, or workspace boundary marker. Recommendation%3A Check whether prompts, chat history, uploaded documents, embeddings, citations, settings, or identity can cross a new boundary.
const contextualListError = hasActionContext && listError !== SESSION_REQUIRED_MESSAGE ? listError : '';

usePendingActionsLifecycle(activeConversationId);
const { unavailable, dismissUnavailable } = usePendingActionFocus(scrollRef, pauseFollowing);
Expand All @@ -290,7 +295,7 @@
// under its message.
const showSection =
!messagesLoading &&
(orphanIds.length > 0 || Boolean(listError) || Boolean(referenceError) || Boolean(continuationToken));
(orphanIds.length > 0 || Boolean(contextualListError) || Boolean(referenceError) || Boolean(continuationToken));

Check warning on line 298 in application/v2_ui/src/components/chat/PendingActionSlots.tsx

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains secret or sensitive data source marker. Recommendation%3A Pair this source with any nearby network, logging, serialization, or process execution sink before approving.
if (!unavailable && !showSection) {
return null;
}
Expand Down Expand Up @@ -328,7 +333,9 @@
</p>
</div>

{listError && <Notice tone="danger" testId="v2-pending-actions-error">{listError}</Notice>}
{contextualListError && (
<Notice tone="danger" testId="v2-pending-actions-error">{contextualListError}</Notice>
)}
{referenceError && (
<Notice tone="danger" testId="v2-pending-actions-reference-error">{referenceError}</Notice>
)}
Expand Down
2 changes: 2 additions & 0 deletions application/v2_ui/src/components/layout/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { WorkflowAlertLiveRegion } from '../notifications/WorkflowAlertLiveRegio
import { useBootstrapStore } from '../../stores/bootstrapStore';
import { useUiStore } from '../../stores/uiStore';
import { useCollaborationStore } from '../../stores/collaborationStore';
import { SessionNotice } from './SessionNotice';

function ClassificationBanner() {
const banner = useBootstrapStore((state) => state.data?.branding?.classification_banner);
Expand Down Expand Up @@ -69,6 +70,7 @@ export function AppShell({ children }: { children: ReactNode }) {
<MobileNavigationContext.Provider value={{ mobile, register }}>
<div className="app-shell flex h-full min-h-0 flex-col">
<ClassificationBanner />
<SessionNotice />
<div className="relative flex min-h-0 min-w-0 flex-1">
{mobile && mobileNavOpen && (
<button
Expand Down
74 changes: 74 additions & 0 deletions application/v2_ui/src/components/layout/SessionNotice.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// SessionNotice.tsx

import { useCallback, useEffect, useState } from 'react';
import { apiUrl, verifyAppSession } from '../../lib/apiClient';
import { SESSION_REQUIRED_MESSAGE, useAppSessionStore } from '../../stores/appSessionStore';
import { GlassButton } from '../ui/primitives';

export function SessionNotice() {
const status = useAppSessionStore((state) => state.status);
const [checking, setChecking] = useState(false);
const [checkFailed, setCheckFailed] = useState(false);

const checkAgain = useCallback(async (force = false) => {
setChecking(true);
setCheckFailed(false);
try {
const confirmed = await verifyAppSession(force);
setCheckFailed(!confirmed);
} catch (error) {
setCheckFailed(true);
reportError(error);
} finally {
setChecking(false);
}
}, []);

useEffect(() => {
setCheckFailed(false);
if (status !== 'required') return undefined;
const check = () => {
if (document.visibilityState === 'visible') void checkAgain();
};
window.addEventListener('focus', check);
window.addEventListener('online', check);
document.addEventListener('visibilitychange', check);
return () => {
window.removeEventListener('focus', check);
window.removeEventListener('online', check);
document.removeEventListener('visibilitychange', check);
};
}, [status, checkAgain]);

if (status === 'ready') return null;

return (
<div role="alert" data-testid="v2-session-notice"

Check warning on line 46 in application/v2_ui/src/components/layout/SessionNotice.tsx

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains security control, sanitization, or audit marker. Recommendation%3A Confirm the change does not weaken auth, CSRF, CSP, XSS defenses, settings sanitization, redaction, audit logging, or tests.
className="shrink-0 space-y-2 border-b border-edge bg-warn-soft px-4 py-3 text-sm text-text-1">
<p className="font-medium">
{status === 'changed'
? 'Your signed-in account changed. Copy any unsaved text, then reload SimpleChat before continuing.'
: SESSION_REQUIRED_MESSAGE}
</p>
<p>Your current page and unsaved draft have been kept. No failed action will be repeated automatically.</p>
<div className="flex flex-wrap items-center gap-2">
{status === 'changed' ? (
<GlassButton size="sm" onClick={() => window.location.reload()}>Reload SimpleChat</GlassButton>
) : (
<>
<a href={apiUrl('/login')} target="_blank" rel="noopener noreferrer"
className="rounded-lg px-3 py-2 font-medium text-accent underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent">
Sign in again (opens a new tab)
</a>
<GlassButton size="sm" variant="ghost" disabled={checking} onClick={() => void checkAgain(true)}>

Check warning on line 63 in application/v2_ui/src/components/layout/SessionNotice.tsx

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains security control, sanitization, or audit marker. Recommendation%3A Confirm the change does not weaken auth, CSRF, CSP, XSS defenses, settings sanitization, redaction, audit logging, or tests.
{checking ? 'Checking session…' : 'Check sign-in'}
</GlassButton>
</>
)}
</div>
{checkFailed && status === 'required' ? (
<p role="status">Your sign-in could not be confirmed. Finish signing in, then check again.</p>

Check warning on line 70 in application/v2_ui/src/components/layout/SessionNotice.tsx

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains security control, sanitization, or audit marker. Recommendation%3A Confirm the change does not weaken auth, CSRF, CSP, XSS defenses, settings sanitization, redaction, audit logging, or tests.
) : null}
</div>
);
}
120 changes: 118 additions & 2 deletions application/v2_ui/src/lib/apiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
// Service later" decision cheap: switching origins is a change to API_BASE alone, not a
// change to every call site.

import { SESSION_REQUIRED_MESSAGE, useAppSessionStore } from '../stores/appSessionStore';

/**
* Base URL for API requests.
*
Expand Down Expand Up @@ -43,6 +45,99 @@
}
}

export function isAppSessionRequired(status: number, payload: unknown): boolean {
if ((status !== 401 && status !== 403) || !payload || typeof payload !== 'object') return false;
const body = payload as Record<string, unknown>;
return body.requires_reauth === true ||
(status === 401 && (body.error === 'not_logged_in' ||
(body.error === 'Unauthorized' && body.message === 'Authentication required')));
}

export function isAppSessionError(error: unknown): boolean {
return error instanceof ApiError && isAppSessionRequired(error.status, error.payload);
}

function sessionError(): ApiError {
return new ApiError(SESSION_REQUIRED_MESSAGE, 401, { requires_reauth: true });
}

function isSignInRedirect(response: Response): boolean {
if (!response.redirected || !response.url) return false;
const url = new URL(response.url);
return url.pathname === '/login' || url.pathname.startsWith('/.auth/login/') ||
/^(login\.microsoftonline\.(com|us|de)|login\.partner\.microsoftonline\.cn)$/.test(url.hostname);
}

let sessionVerification: Promise<boolean> | null = null;
let lastSessionVerification = 0;

function checkWriteSession(method: string): void {
if (method === 'GET' || method === 'HEAD') return;
const { status } = useAppSessionStore.getState();
if (status === 'required') throw sessionError();
if (status === 'changed') {
throw new ApiError('Your signed-in account changed. Reload SimpleChat before continuing.',
409, { error: 'app_account_changed' });
}
}

async function fetchWithSessionCheck(path: string, init: RequestInit): Promise<Response> {
try {
return await fetch(apiUrl(path), init);

Check warning on line 86 in application/v2_ui/src/lib/apiClient.ts

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains external connection or remote asset marker. Recommendation%3A Review whether changed code can send prompts, files, credentials, cookies, settings, logs, or user data to a new sink.
} catch (error) {
if (error instanceof TypeError && !init.signal?.aborted &&
useAppSessionStore.getState().viewerId) {
await verifyAppSession();
if (useAppSessionStore.getState().status === 'required') throw sessionError();
}
throw error;
}
}

/** A read-only check, never a retry of the request that failed. */
export function verifyAppSession(force = false): Promise<boolean> {
if (sessionVerification) return sessionVerification;
if (!force && Date.now() - lastSessionVerification < 15_000) return Promise.resolve(false);
lastSessionVerification = Date.now();
const { revision } = useAppSessionStore.getState();
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);
sessionVerification = (async () => {
try {
const response = await fetch(apiUrl('/api/v2/bootstrap'), {

Check warning on line 107 in application/v2_ui/src/lib/apiClient.ts

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains external connection or remote asset marker. Recommendation%3A Review whether changed code can send prompts, files, credentials, cookies, settings, logs, or user data to a new sink.
credentials: CREDENTIALS_MODE,

Check warning on line 108 in application/v2_ui/src/lib/apiClient.ts

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains secret or sensitive data source marker. Recommendation%3A Pair this source with any nearby network, logging, serialization, or process execution sink before approving.
cache: 'no-store',
headers: { Accept: 'application/json' },
signal: controller.signal,
});
if (isSignInRedirect(response)) {
useAppSessionStore.getState().requireSignIn(revision);
return false;
}
if (!response.headers.get('content-type')?.includes('application/json')) return false;
const data: unknown = await response.json();
if (!response.ok) {
if (isAppSessionRequired(response.status, data)) {
useAppSessionStore.getState().requireSignIn(revision);
}
followServerGate(response.status, data);
return false;
}
const user = (data as { user?: { id?: unknown } } | null)?.user;
return typeof user?.id === 'string' &&
useAppSessionStore.getState().confirmUser(user.id, revision);
} catch (error) {
if (error instanceof TypeError || error instanceof SyntaxError ||
(error instanceof Error && error.name === 'AbortError')) return false;
throw error;
} finally {
clearTimeout(timer);
sessionVerification = null;
}
})();
return sessionVerification;
}

export function apiUrl(path: string): string {
if (/^https?:\/\//i.test(path)) {
return path;
Expand Down Expand Up @@ -193,6 +288,8 @@

export async function requestWithStatus<T>(path: string, options: RequestOptions = {}): Promise<ApiResponse<T>> {
const { method = 'GET', body, signal, headers = {}, cache } = options;
checkWriteSession(method);
const sessionRevision = useAppSessionStore.getState().revision;

const init: RequestInit = {
method,
Expand All @@ -213,11 +310,20 @@
init.body = JSON.stringify(body);
}

const response = await fetch(apiUrl(path), init);
const response = await fetchWithSessionCheck(path, init);

if (isSignInRedirect(response)) {
useAppSessionStore.getState().requireSignIn(sessionRevision);
throw sessionError();
}

if (!response.ok) {
const { message, payload } = await readErrorMessage(response);
followServerGate(response.status, payload);
if (isAppSessionRequired(response.status, payload)) {
useAppSessionStore.getState().requireSignIn(sessionRevision);
throw sessionError();
}
throw new ApiError(message, response.status, payload);
}

Expand Down Expand Up @@ -260,16 +366,26 @@
formData: FormData,
signal?: AbortSignal,
): Promise<ApiResponse<T>> {
const response = await fetch(apiUrl(path), {
checkWriteSession('POST');
const sessionRevision = useAppSessionStore.getState().revision;
const response = await fetchWithSessionCheck(path, {
method: 'POST',
credentials: CREDENTIALS_MODE,
body: formData,
signal,
});

if (isSignInRedirect(response)) {
useAppSessionStore.getState().requireSignIn(sessionRevision);
throw sessionError();
}
if (!response.ok) {
const { message, payload } = await readErrorMessage(response);
followServerGate(response.status, payload);
if (isAppSessionRequired(response.status, payload)) {
useAppSessionStore.getState().requireSignIn(sessionRevision);
throw sessionError();
}
throw new ApiError(message, response.status, payload);
}

Expand Down
6 changes: 5 additions & 1 deletion application/v2_ui/src/lib/workflowPlanReplay.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
// workflowPlanReplay.ts
// Client contract for saving a completed chat orchestration plan as a read-only replay workflow.

import { ApiError, api, requestWithStatus } from './apiClient';
import { ApiError, api, isAppSessionError, requestWithStatus } from './apiClient';
import { SESSION_REQUIRED_MESSAGE } from '../stores/appSessionStore';
import type { WorkflowDefinition } from './workflowEditor';

export const PLAN_REPLAY_INVALID_RESPONSE = 'The saved chat plan returned an invalid response.';
Expand Down Expand Up @@ -266,6 +267,9 @@ function errorTextFor(code: string, status: number, serverText: string): string

export function planReplayError(cause: unknown): WorkflowPlanReplayError {
if (cause instanceof WorkflowPlanReplayError) return cause;
if (isAppSessionError(cause)) {
return new WorkflowPlanReplayError('app_session_required', 401, SESSION_REQUIRED_MESSAGE, []);
}
if (cause instanceof ApiError) {
const payload = isRecord(cause.payload) ? cause.payload : {};
const code = typeof payload.code === 'string' ? payload.code : '';
Expand Down
37 changes: 37 additions & 0 deletions application/v2_ui/src/stores/appSessionStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// appSessionStore.ts
// Runtime sign-in state stays separate from bootstrap so expiry never discards an open draft.

import { create } from 'zustand';

export const SESSION_REQUIRED_MESSAGE = 'Your SimpleChat session needs to be renewed. Sign in again to continue.';

interface AppSessionState {
status: 'ready' | 'required' | 'changed';
viewerId: string;
revision: number;
requireSignIn(revision: number): void;
confirmUser(viewerId: string, revision: number): boolean;
}

export const useAppSessionStore = create<AppSessionState>((set, get) => ({
status: 'ready',
viewerId: '',
revision: 0,
requireSignIn: (revision) => {
const current = get();
if (current.revision !== revision || current.status === 'changed') return;
set({ status: 'required', revision: revision + 1 });
},
confirmUser: (viewerId, revision) => {
const current = get();
if (!viewerId || current.revision !== revision || current.status === 'changed') return false;
if (current.viewerId && current.viewerId !== viewerId) {
set({ status: 'changed', revision: revision + 1 });
return false;
}
if (current.status !== 'ready' || current.viewerId !== viewerId) {
set({ status: 'ready', viewerId, revision: revision + 1 });
}
return true;
},
}));
Loading
Loading