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
31 changes: 27 additions & 4 deletions app/components/chat/Chat.client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,27 @@ const STEP_EVENT_FLUSH_MS = 250;
const TELEMETRY_OUTPUT_MAX_CHARS = 1600;
const TELEMETRY_MERGE_WINDOW_MS = 20000;
const LOCAL_PROVIDER_SET = new Set<string>(LOCAL_PROVIDERS);
const MODEL_PREFLIGHT_CACHE_TTL_MS = 45_000;
let cachedModelCatalog: { expiresAt: number; models: ModelInfo[] } | null = null;

async function fetchCachedModelCatalog(): Promise<ModelInfo[]> {
const now = Date.now();

if (cachedModelCatalog && cachedModelCatalog.expiresAt > now) {
return cachedModelCatalog.models;
}

const response = await fetch('/api/models');
const data = (await response.json()) as { modelList?: ModelInfo[] };
const models = data.modelList || [];

cachedModelCatalog = {
models,
expiresAt: now + MODEL_PREFLIGHT_CACHE_TTL_MS,
};

return models;
}
const ANSI_ESCAPE_RE = /\u001b\[[0-?]*[ -/]*[@-~]/g;
const CARRIAGE_RETURN_RE = /\r+/g;
const loadSessionManager = () => import('~/lib/services/sessionManager');
Expand Down Expand Up @@ -673,7 +694,10 @@ export const ChatImpl = memo(
providerSettings: getProviderSettingsFromCookiesSafe(),
selectedProvider: provider.name,
selectedModel: model,
files,
files:
hostedRuntimeEnabled && typeof workbenchStore.hostedRuntimeSessionId === 'string'
? undefined
: files,
hostedRuntimeSessionId: hostedRuntimeEnabled ? workbenchStore.hostedRuntimeSessionId : undefined,
projectContextId,
promptId,
Expand Down Expand Up @@ -2290,9 +2314,7 @@ Requirements:
const resolveModelSelection = useCallback(
async (prompt: string, currentModel: string, currentProvider: ProviderInfo) => {
try {
const response = await fetch('/api/models');
const data = (await response.json()) as { modelList: ModelInfo[] };
const availableModels = data.modelList || [];
const availableModels = await fetchCachedModelCatalog();
const decision = selectModelForPrompt({
prompt,
currentModel,
Expand Down Expand Up @@ -3354,6 +3376,7 @@ CONTINUE IMMEDIATELY:
setApiKeysCookie(updatedApiKeys, CHAT_SELECTION_COOKIE_EXPIRY_DAYS);

const normalizedKey = apiKey.trim();
cachedModelCatalog = null;

if (!normalizedKey) {
return;
Expand Down
43 changes: 27 additions & 16 deletions app/components/header/Shoutbox.client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export function Shoutbox() {
const [content, setContent] = useState('');
const [loading, setLoading] = useState(false);
const [enabled, setEnabled] = useState(isShoutboxEnabled);
const [canSend, setCanSend] = useState(false);
const [lastReadAt, setLastReadAt] = useState<string | null>(() => localStorage.getItem(SHOUTBOX_LAST_READ_AT_KEY));
const panelRef = useRef<HTMLDivElement | null>(null);

Expand Down Expand Up @@ -67,14 +68,15 @@ export function Shoutbox() {
const loadMessages = async () => {
try {
const response = await fetch('/api/shout/messages');
const payload = (await response.json()) as { messages?: unknown; error?: string };
const payload = (await response.json()) as { messages?: unknown; error?: string; canSend?: boolean };

if (!response.ok) {
throw new Error(payload.error || 'Failed to load shout-out messages.');
}

if (active) {
setMessages(normalizeShoutMessages(payload.messages));
setCanSend(Boolean(payload.canSend));
}
} catch (error) {
console.error('Failed to load shout-out messages:', error);
Expand Down Expand Up @@ -225,21 +227,30 @@ export function Shoutbox() {
</div>

<div className="mt-3 space-y-2">
<textarea
value={content}
onChange={(event) => setContent(event.target.value)}
rows={3}
placeholder="Send a short update to other bolt.gives users on this deployment."
className="w-full rounded-xl border border-bolt-elements-borderColor bg-bolt-elements-background-depth-1 px-3 py-2 text-sm text-bolt-elements-textPrimary"
/>
<button
type="button"
onClick={() => void sendMessage()}
disabled={loading || content.trim().length === 0}
className="rounded-lg bg-bolt-elements-button-primary-background px-3 py-2 text-xs font-medium text-bolt-elements-button-primary-text disabled:cursor-not-allowed disabled:opacity-50"
>
{loading ? 'Sending...' : 'Send shout-out'}
</button>
{canSend ? (
<>
<textarea
value={content}
onChange={(event) => setContent(event.target.value)}
rows={3}
placeholder="Send a short update to other bolt.gives users on this deployment."
className="w-full rounded-xl border border-bolt-elements-borderColor bg-bolt-elements-background-depth-1 px-3 py-2 text-sm text-bolt-elements-textPrimary"
/>
<button
type="button"
onClick={() => void sendMessage()}
disabled={loading || content.trim().length === 0}
className="rounded-lg bg-bolt-elements-button-primary-background px-3 py-2 text-xs font-medium text-bolt-elements-button-primary-text disabled:cursor-not-allowed disabled:opacity-50"
>
{loading ? 'Sending...' : 'Send shout-out'}
</button>
</>
) : (
<div className="rounded-xl border border-dashed border-bolt-elements-borderColor p-3 text-xs text-bolt-elements-textSecondary">
Shout-out broadcasts are operator-managed. You can read updates here, but only signed-in tenant admins can
post new messages.
</div>
)}
</div>
</div>
) : null}
Expand Down
64 changes: 64 additions & 0 deletions app/lib/.server/admin-auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { createCookie } from '@remix-run/cloudflare';
import { fetchRuntimeControlJson } from '~/lib/.server/runtime-control';

export type TenantAdminSession = {
username: string;
issuedAt: string;
};

type TenantAdminStatusPayload = {
admin?: {
username?: string;
};
};

function getTenantAdminCookieSecret() {
if (typeof process !== 'undefined' && process.env?.BOLT_TENANT_ADMIN_COOKIE_SECRET?.trim()) {
return process.env.BOLT_TENANT_ADMIN_COOKIE_SECRET.trim();
}

return 'bolt-tenant-admin-dev-secret-change-me';
}

function createTenantAdminSessionCookie() {
return createCookie('bolt_tenant_admin', {
httpOnly: true,
path: '/',
sameSite: 'lax',
secure: typeof process !== 'undefined' ? process.env.NODE_ENV === 'production' : true,
maxAge: 60 * 60 * 12,
secrets: [getTenantAdminCookieSecret()],
});
}

export async function readTenantAdminSession(request: Request): Promise<TenantAdminSession | null> {
try {
const cookie = createTenantAdminSessionCookie();
const parsed = (await cookie.parse(request.headers.get('Cookie'))) as TenantAdminSession | undefined;

if (!parsed?.username) {
return null;
}

return parsed;
} catch {
return null;
}
}

export async function isTenantAdminAuthorized(request: Request): Promise<boolean> {
const session = await readTenantAdminSession(request);

if (!session?.username) {
return false;
}

try {
const status = await fetchRuntimeControlJson<TenantAdminStatusPayload>('/tenant-admin/status');
const adminUsername = status.admin?.username;

return Boolean(adminUsername && adminUsername === session.username);
} catch {
return false;
}
}
22 changes: 22 additions & 0 deletions app/lib/api/cookies.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { getApiKeysFromCookie, getProviderSettingsFromCookie, parseCookies } from './cookies';

describe('cookie parsing helpers', () => {
it('skips malformed encoded cookie pairs without throwing', () => {
expect(() => parseCookies('ok=value; bad=%E0%A4%A; another=1')).not.toThrow();
expect(parseCookies('ok=value; bad=%E0%A4%A; another=1')).toEqual({
ok: 'value',
another: '1',
});
});

it('returns empty provider settings for invalid JSON cookie values', () => {
expect(getProviderSettingsFromCookie('providers=%7Binvalid-json')).toEqual({});
});

it('returns sanitized api key map from cookie payload', () => {
const cookie = `apiKeys=${encodeURIComponent(JSON.stringify({ OpenAI: ' sk-test ', Empty: ' ' }))}`;

expect(getApiKeysFromCookie(cookie)).toEqual({ OpenAI: 'sk-test' });
});
});
37 changes: 30 additions & 7 deletions app/lib/api/cookies.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,37 @@
import { normalizeCredential } from '~/lib/runtime/credentials';

function safeDecodeURIComponent(value: string): string | null {
try {
return decodeURIComponent(value);
} catch {
return null;
}
}

export function parseCookies(cookieHeader: string | null) {
const cookies: Record<string, string> = {};

if (!cookieHeader) {
return cookies;
}

// Split the cookie string by semicolons and spaces
const items = cookieHeader.split(';').map((cookie) => cookie.trim());

items.forEach((item) => {
const [name, ...rest] = item.split('=');

if (name && rest.length > 0) {
// Decode the name and value, and join value parts in case it contains '='
const decodedName = decodeURIComponent(name.trim());
const decodedValue = decodeURIComponent(rest.join('=').trim());
cookies[decodedName] = decodedValue;
if (!name || rest.length === 0) {
return;
}

const decodedName = safeDecodeURIComponent(name.trim());
const decodedValue = safeDecodeURIComponent(rest.join('=').trim());

if (!decodedName || decodedValue === null) {
return;
}

cookies[decodedName] = decodedValue;
});

return cookies;
Expand Down Expand Up @@ -53,5 +66,15 @@ export function getApiKeysFromCookie(cookieHeader: string | null): Record<string

export function getProviderSettingsFromCookie(cookieHeader: string | null): Record<string, any> {
const cookies = parseCookies(cookieHeader);
return cookies.providers ? JSON.parse(cookies.providers) : {};

if (!cookies.providers) {
return {};
}

try {
const parsed = JSON.parse(cookies.providers) as Record<string, any>;
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
} catch {
return {};
}
}
39 changes: 39 additions & 0 deletions app/lib/runtime/action-runner.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,45 @@ describe('ActionRunner start actions', () => {
expect(runner.actions.get()['file-outside-workdir-traversal-1']?.status).toBe('failed');
});

it('marks file actions as failed when workspace writes throw errors', async () => {
const writeFile = vi.fn().mockRejectedValue(new Error('disk full'));
const runner = new ActionRunner(
Promise.resolve({
workdir: '/home/project',
fs: {
readFile: vi.fn().mockResolvedValue('{}'),
readdir: vi.fn().mockResolvedValue([]),
mkdir: vi.fn().mockResolvedValue(undefined),
writeFile,
},
}) as any,
() =>
({
ready: vi.fn().mockResolvedValue(undefined),
terminal: {},
process: {},
executeCommand: vi.fn().mockResolvedValue({ exitCode: 0, output: 'ok' }),
}) as any,
);

const actionData: ActionCallbackData = {
artifactId: 'artifact-1',
messageId: 'message-1',
actionId: 'file-write-error-1',
action: {
type: 'file',
filePath: '/home/project/src/App.jsx',
content: 'export default function App() { return null; }',
} as any,
};

runner.addAction(actionData);
await expect(runner.runAction(actionData)).rejects.toBeInstanceOf(Error);

expect(writeFile).toHaveBeenCalled();
expect(runner.actions.get()['file-write-error-1']?.status).toBe('failed');
});

it('writes file actions using canonical workdir-relative paths', async () => {
const executeCommand = vi.fn().mockResolvedValue({ exitCode: 0, output: 'ok' });
const writeFile = vi.fn().mockResolvedValue(undefined);
Expand Down
Loading
Loading