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
5 changes: 4 additions & 1 deletion builds/typescript/adapters/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,13 @@ export function createModelAdapter(
preferences.active_provider_profile?.trim() ||
adapterConfig.default_provider_profile?.trim() ||
"";
// Model resolution: per-provider default takes priority. If no per-provider
// default exists, use the adapter profile's built-in model — never fall back
// to the global default_model, since model IDs are provider-specific.
const providerModel = activeProfile
? preferences.provider_default_models?.[activeProfile]?.trim()
: undefined;
const preferenceModel = (providerModel ?? preferences.default_model).trim();
const preferenceModel = providerModel ?? "";
const useAdapterModel =
preferenceModel.length === 0 ||
(preferenceModel === legacyBootstrapModel && selectedAdapterConfig.model !== legacyBootstrapModel);
Expand Down
4 changes: 3 additions & 1 deletion builds/typescript/client_web/src/api/useGatewayChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,9 @@ export function useGatewayChat(options: UseGatewayChatOptions = {}): {
conversationIdRef.current = restored.conversationId;
backgroundStates.delete(cacheKey);
} else {
setMessages(externalMessages);
// For draft conversations (no external ID), always start empty — externalMessages
// may be stale from the previous project's history that hasn't cleared yet.
setMessages(externalConversationId ? externalMessages : EMPTY_MESSAGES);
setIsLoading(false);
setError(null);
setToolStatus(null);
Expand Down
164 changes: 92 additions & 72 deletions builds/typescript/gateway/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,13 @@ import {
savePreferences,
} from "../config.js";
import type { AdapterConfig, ClientMessageRequest, Preferences, RuntimeConfig } from "../contracts.js";
import { runAgentLoop } from "../engine/loop.js";
import { classifyProviderError } from "../engine/errors.js";
import { formatSseEvent } from "../engine/stream.js";
import { ToolExecutor } from "../engine/tool-executor.js";
import { ensureGitReady } from "../git.js";
import { auditLog } from "../logger.js";
import { ensureAuthState, saveAuthState } from "../memory/auth-state.js";
import { runAgentLoop } from "../engine/loop.js";
import { classifyProviderError } from "../engine/errors.js";
import { formatSseEvent } from "../engine/stream.js";
import { ToolExecutor } from "../engine/tool-executor.js";
import { ensureGitReady } from "../git.js";
import { auditLog } from "../logger.js";
import { ensureAuthState, saveAuthState } from "../memory/auth-state.js";
import type { ConversationRepository } from "../memory/conversation-repository.js";
import { MarkdownConversationStore } from "../memory/conversation-store-markdown.js";
import { exportMemory } from "../memory/export.js";
Expand Down Expand Up @@ -209,15 +209,15 @@ export async function buildServer(rootDir = process.cwd()) {

const app = Fastify({ logger: false, trustProxy: true });
const approvalStore = new ApprovalStore();
const toolExecutor = new ToolExecutor(tools);
const conversations = new GatewayConversationService(createConversationRepository(runtimeConfig));
const projects = new GatewayProjectService(runtimeConfig.memory_root, { rootDir });
const skills = new GatewaySkillService(runtimeConfig.memory_root);
const signupRateLimiter = new FixedWindowRateLimiter(5, 5 * 60 * 1000);
const loginRateLimiter = new FixedWindowRateLimiter(10, 5 * 60 * 1000);
const refreshRateLimiter = new FixedWindowRateLimiter(30, 5 * 60 * 1000);
const signupBootstrapToken = process.env.PAA_AUTH_BOOTSTRAP_TOKEN?.trim();
const allowFirstSignupFromAnyIp = readBooleanEnv(process.env.PAA_AUTH_ALLOW_FIRST_SIGNUP_ANY_IP, false);
const toolExecutor = new ToolExecutor(tools);
const conversations = new GatewayConversationService(createConversationRepository(runtimeConfig));
const projects = new GatewayProjectService(runtimeConfig.memory_root, { rootDir });
const skills = new GatewaySkillService(runtimeConfig.memory_root);
const signupRateLimiter = new FixedWindowRateLimiter(5, 5 * 60 * 1000);
const loginRateLimiter = new FixedWindowRateLimiter(10, 5 * 60 * 1000);
const refreshRateLimiter = new FixedWindowRateLimiter(30, 5 * 60 * 1000);
const signupBootstrapToken = process.env.PAA_AUTH_BOOTSTRAP_TOKEN?.trim();
const allowFirstSignupFromAnyIp = readBooleanEnv(process.env.PAA_AUTH_ALLOW_FIRST_SIGNUP_ANY_IP, false);
const persistAuthState = async (nextState: typeof authState): Promise<void> => {
authState = await saveAuthState(runtimeConfig.memory_root, nextState);
};
Expand Down Expand Up @@ -245,28 +245,28 @@ export async function buildServer(rootDir = process.cwd()) {
return;
}

if (!authState.account_initialized && !allowFirstSignupFromAnyIp) {
const signupAccess = evaluateSignupBootstrapAccess(
{
ip: request.ip,
headers: request.headers as Record<string, unknown>,
},
if (!authState.account_initialized && !allowFirstSignupFromAnyIp) {
const signupAccess = evaluateSignupBootstrapAccess(
{
ip: request.ip,
headers: request.headers as Record<string, unknown>,
},
signupBootstrapToken
);
if (!signupAccess.allowed) {
auditLog("auth.signup.denied", {
reason: signupAccess.reason,
ip: request.ip,
});
reply.code(403).send({ error: signupAccess.reason });
return;
}
} else if (!authState.account_initialized && allowFirstSignupFromAnyIp) {
auditLog("auth.signup.bootstrap_override", {
reason: "allow_first_signup_any_ip",
ip: request.ip,
});
}
reply.code(403).send({ error: signupAccess.reason });
return;
}
} else if (!authState.account_initialized && allowFirstSignupFromAnyIp) {
auditLog("auth.signup.bootstrap_override", {
reason: "allow_first_signup_any_ip",
ip: request.ip,
});
}

const parsed = authCredentialsSchema.safeParse(request.body);
if (!parsed.success) {
Expand Down Expand Up @@ -438,25 +438,33 @@ export async function buildServer(rootDir = process.cwd()) {
const projectId = isProjectMetadata(body.metadata) ? body.metadata.project.trim() : null;
if (isProjectMetadata(body.metadata)) {
await projects.attachConversation(body.metadata.project.trim(), conversationId);
}
const conversationSkillIds = conversations.getConversationSkills(conversationId) ?? [];
const projectSkillIds = projectId ? (await projects.getProjectSkills(projectId)) ?? [] : [];
const promptWithSkills = await skills.composePromptWithSkills(systemPrompt, [...projectSkillIds, ...conversationSkillIds]);

auditLog("skills.apply", {
conversation_id: conversationId,
project_id: projectId,
applied_skill_ids: promptWithSkills.applied,
missing_skill_ids: promptWithSkills.missing,
truncated: promptWithSkills.truncated,
});

const engineRequest = gatewayAdapter.buildEngineRequest({
conversationId,
correlationId: crypto.randomUUID(),
messages: conversations.buildConversationMessages(conversationId, promptWithSkills.prompt),
...(body.metadata ? { clientMetadata: body.metadata } : {}),
});
}
const conversationSkillIds = conversations.getConversationSkills(conversationId) ?? [];
const projectSkillIds = projectId ? (await projects.getProjectSkills(projectId)) ?? [] : [];
const promptWithSkills = await skills.composePromptWithSkills(systemPrompt, [...projectSkillIds, ...conversationSkillIds]);

auditLog("skills.apply", {
conversation_id: conversationId,
project_id: projectId,
applied_skill_ids: promptWithSkills.applied,
missing_skill_ids: promptWithSkills.missing,
truncated: promptWithSkills.truncated,
});

// Inject project context so the AI knows which project it's operating in.
// Without this, the AI sees the base prompt but doesn't know which project
// files to read — it would read all projects and behave like BD+1.
const projectContext = projectId
? `\n\n## Active Project\n\nYou are currently in the **${projectId}** project. Read this project's AGENT.md, spec.md, and plan.md from the documents/${projectId}/ folder. Stay focused on this domain — do not read or reference other projects unless the conversation specifically calls for cross-domain connections.`
: "";
const finalPrompt = promptWithSkills.prompt + projectContext;

const engineRequest = gatewayAdapter.buildEngineRequest({
conversationId,
correlationId: crypto.randomUUID(),
messages: conversations.buildConversationMessages(conversationId, finalPrompt),
...(body.metadata ? { clientMetadata: body.metadata } : {}),
});

reply.raw.writeHead(200, {
"content-type": "text/event-stream",
Expand Down Expand Up @@ -990,6 +998,18 @@ export async function buildServer(rootDir = process.cwd()) {
return;
} else {
nextPreferences.active_provider_profile = body.active_provider_profile;
// When switching providers, sync default_model to the new provider's
// per-provider default so display stays consistent. Model IDs are
// provider-specific — the global default_model should reflect the
// active provider's selection.
if (body.default_model === undefined) {
const newProviderModel = nextPreferences.provider_default_models?.[body.active_provider_profile];
const profileConfig = adapterConfig.provider_profiles?.[body.active_provider_profile];
const effectiveModel = newProviderModel ?? profileConfig?.model;
if (effectiveModel) {
nextPreferences.default_model = effectiveModel;
}
}
}
}

Expand Down Expand Up @@ -1414,34 +1434,34 @@ function serializeRefreshCookie(refreshToken: string, maxAgeSeconds: number, sec
.join("; ");
}

function serializeRefreshCookieClear(secure: boolean): string {
return [
`${REFRESH_COOKIE_NAME}=`,
function serializeRefreshCookieClear(secure: boolean): string {
return [
`${REFRESH_COOKIE_NAME}=`,
"HttpOnly",
"SameSite=Strict",
"Path=/",
"Max-Age=0",
"Expires=Thu, 01 Jan 1970 00:00:00 GMT",
secure ? "Secure" : "",
]
.filter((segment) => segment.length > 0)
.join("; ");
}

function readBooleanEnv(value: string | undefined, defaultValue = false): boolean {
const normalized = value?.trim().toLowerCase();
if (!normalized) {
return defaultValue;
}

return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
}

function isSecureRequest(request: { headers: Record<string, unknown> }): boolean {
const forwardedProto = request.headers["x-forwarded-proto"];
if (typeof forwardedProto === "string" && forwardedProto.toLowerCase().includes("https")) {
return true;
}
.filter((segment) => segment.length > 0)
.join("; ");
}
function readBooleanEnv(value: string | undefined, defaultValue = false): boolean {
const normalized = value?.trim().toLowerCase();
if (!normalized) {
return defaultValue;
}
return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
}
function isSecureRequest(request: { headers: Record<string, unknown> }): boolean {
const forwardedProto = request.headers["x-forwarded-proto"];
if (typeof forwardedProto === "string" && forwardedProto.toLowerCase().includes("https")) {
return true;
}

return process.env.NODE_ENV === "production";
}
Expand Down Expand Up @@ -1487,7 +1507,7 @@ class FixedWindowRateLimiter {
}
}

function createConversationRepository(runtimeConfig: RuntimeConfig): ConversationRepository {
function createConversationRepository(runtimeConfig: RuntimeConfig): ConversationRepository {
switch (runtimeConfig.conversation_store) {
case "markdown":
return new MarkdownConversationStore(runtimeConfig.memory_root);
Expand Down
Loading