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
89 changes: 70 additions & 19 deletions src/lib/ai-providers/store.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -193,15 +193,69 @@ export type CoderConfig = {
apiKey: string;
};

/**
* Per-provider default model for the bridge-default (company-free) path,
* used when `MIND_AGENT_MODEL` is unset. Gemini's free tier is the house
* default; OpenRouter stays on the free coder-tuned Qwen.
*/
const ENV_FALLBACK_DEFAULT_MODEL: Record<ProviderName, string> = {
google: "gemini-2.0-flash",
openrouter: "qwen/qwen3-coder:free",
anthropic: "claude-haiku-4-5",
openai: "gpt-4o-mini",
};

/** Provider preference order for the env-fallback when none is pinned. */
const ENV_FALLBACK_ORDER: ProviderName[] = ["google", "openrouter", "anthropic", "openai"];

/** First non-empty container env var holding `provider`'s key, or null. */
function envKeyForProvider(provider: ProviderName): string | null {
const spec = getProvider(provider);
if (!spec) return null;
for (const name of spec.containerEnvNames) {
const v = process.env[name]?.trim();
if (v) return v;
}
return null;
}

/**
* The bridge-default ("env-fallback") provider — the company-free path
* shared by everyone who hasn't brought their own key. Prefers Gemini
* (its key configured as a Secret) so Builder's free runs use the same
* metered Gemini path as Slides; falls back to whichever provider key is
* present. Pin explicitly with `MIND_DEFAULT_PROVIDER`. The single
* `MIND_AGENT_MODEL` override should match the chosen provider; unset =
* the per-provider default above.
*/
function resolveEnvFallback(): {
provider: ProviderName;
model: string;
apiKey: string;
} | null {
const pinned = process.env.MIND_DEFAULT_PROVIDER?.trim();
const order = isProviderName(pinned) ? [pinned] : ENV_FALLBACK_ORDER;
for (const provider of order) {
const apiKey = envKeyForProvider(provider);
if (apiKey) {
const model = process.env.MIND_AGENT_MODEL?.trim() || ENV_FALLBACK_DEFAULT_MODEL[provider];
return { provider, model, apiKey };
}
}
return null;
}

/**
* Resolve which (provider, model, apiKey) the coder should use when
* acting on behalf of `webId`.
*
* Priority:
* 1. The user's pref + their stored key for that provider.
* 2. The bridge-wide OPENROUTER_API_KEY + MIND_AGENT_MODEL fallback
* (preserves the demo behavior — anyone can push a repo and have
* the agents work without configuring anything personally).
* 1. The user's pref + their stored key for that provider (BYOK — the
* optional in-app custom provider; runs unmetered).
* 2. The bridge-default provider (`resolveEnvFallback` — Gemini by
* default), so anyone can push a repo and have the agents work
* without configuring anything; metered against the free MIND
* allotment.
* 3. null — caller surfaces the configuration error to the user.
*/
export function resolveCoderConfig(webId: string): CoderConfig | null {
Expand All@@ -218,18 +272,13 @@ export function resolveCoderConfig(webId: string): CoderConfig | null {
}
}

// Fallback: env-configured OpenRouter setup. The MIND_AGENT_MODEL var
// includes a provider slash (e.g. "qwen/qwen3-coder:free") and is
// consumed as the bare model id by OpenRouter, so we forward it
// as-is. Default is a free model so a deployment with no per-user
// BYOK keys still completes a run end-to-end.
const envKey = process.env.OPENROUTER_API_KEY;
if (envKey) {
const fb = resolveEnvFallback();
if (fb) {
return {
source: "env-fallback",
provider: "openrouter",
model: process.env.MIND_AGENT_MODEL ?? "qwen/qwen3-coder:free",
apiKey: envKey,
provider: fb.provider,
model: fb.model,
apiKey: fb.apiKey,
};
}
return null;
Expand All@@ -246,7 +295,7 @@ export type CoderConfigSummary =
}
| {
source: "env-fallback";
provider: "openrouter";
provider: ProviderName;
providerLabel: string;
model: string;
}
Expand All@@ -266,12 +315,14 @@ export function resolveCoderConfigSummary(webId: string): CoderConfigSummary {
};
}
}
if (process.env.OPENROUTER_API_KEY) {
const fb = resolveEnvFallback();
if (fb) {
const spec = getProvider(fb.provider);
return {
source: "env-fallback",
provider: "openrouter",
providerLabel: "OpenRouter (bridge-default)",
model: process.env.MIND_AGENT_MODEL ?? "qwen/qwen3-coder:free",
provider: fb.provider,
providerLabel: `${spec?.label ?? fb.provider} (bridge-default)`,
model: fb.model,
};
}
return { source: "none" };
Expand Down
2 changes: 1 addition & 1 deletion src/lib/env.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -164,7 +164,7 @@ export function getEnv(): BridgeEnv {
const allowSeededFallback = process.env.ALLOW_SEEDED_FALLBACK === "1";

const openrouterApiKey = process.env.OPENROUTER_API_KEY?.trim() || null;
const agentModel = process.env.MIND_AGENT_MODEL?.trim() || "anthropic/claude-3.5-sonnet";
const agentModel = process.env.MIND_AGENT_MODEL?.trim() || "qwen/qwen3-coder:free";
const coderImage = process.env.MIND_CODER_IMAGE?.trim() || "mind-codespaces/coder:latest";
const coderTimeoutMs = Number(process.env.MIND_CODER_TIMEOUT ?? 600) * 1000;
const coderWorkroot = process.env.MIND_CODER_WORKROOT ?? null;
Expand Down
24 changes: 17 additions & 7 deletions src/lib/pages/publisher.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -231,14 +231,24 @@ async function pruneStale(
relPrefix: string,
kept: Set<string>,
): Promise<number> {
// CSS emits the `ldp:contains` objects as relative URIs (e.g. `<index.html>`),
// not absolute URLs. Resolve them against the container URL before doing
// anything URL-based.
const childSlugs = await listContainerChildren(fetcher, containerUrl);
const childRefs = await listContainerChildren(fetcher, containerUrl);
const containerBase = containerUrl.endsWith("/") ? containerUrl : containerUrl + "/";
let pruned = 0;
for (const slug of childSlugs) {
if (!slug || slug === "./" || slug === ".") continue;
const childAbsUrl = new URL(slug, containerUrl).toString();
for (const ref of childRefs) {
if (!ref || ref === "./" || ref === ".") continue;
// `ldp:contains` objects come back as relative slugs (`<index.html>`) on
// some Solid servers and as absolute URLs (`<https://pod/…/index.html>`)
// on others. Resolve to absolute, then re-derive the immediate child
// segment RELATIVE to this container so `childRel` lines up with the
// relative keys in `kept`. (This previously used the raw object as the
// slug; against an absolute-URL server `childRel` became a full URL that
// never matched `kept`, so the prune deleted every file it had just
// uploaded and the published site came back empty.)
const childAbsUrl = new URL(ref, containerBase).toString();
if (childAbsUrl === containerBase) continue; // self-reference
if (!childAbsUrl.startsWith(containerBase)) continue; // outside the tree
const slug = childAbsUrl.slice(containerBase.length);
if (!slug) continue;
const childRel = relPrefix + slug;

if (slug.endsWith("/")) {
Expand Down
179 changes: 102 additions & 77 deletions src/lib/solid/oidc-server.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,6 +48,17 @@ type CachedSession = {
};
const sessionCache = new Map<string, CachedSession>();

// In-flight refresh coalescing, keyed by WebID. CSS issues *single-use*
// refresh tokens: if two callers (post_receive + reconciler + tracker +
// publisher all fire near-simultaneously) hit a cold cache at once, each
// spends the SAME stored refresh token against the IdP. The first rotates it;
// the rest present an already-consumed token and get `invalid_grant
// (unknown refresh_token)` — and the clobbered rotation can leave the stored
// token permanently dead, forcing a /connect re-auth. Collapsing concurrent
// refreshes for a WebID onto one promise means the token is spent exactly
// once and every caller shares the resulting live session.
const inFlightRefresh = new Map<string, Promise<typeof fetch | null>>();

// Re-derive the access token this long before it actually expires, so an
// in-flight request never rides a token that lapses mid-publish.
const TOKEN_EXPIRY_SKEW_MS = 60_000;
Expand DownExpand Up@@ -194,89 +205,103 @@ export async function loadAuthedFetchForWebId(webId: string): Promise<typeof fet
// Stale or session changed (re-/connect) — drop it and re-derive below.
if (cached) sessionCache.delete(webId);

const storage = makeIdentityStorage(identity.sessionId);
// If a refresh for this WebID is already running, await it rather than
// spending the single-use refresh token a second time (see inFlightRefresh).
const pending = inFlightRefresh.get(webId);
if (pending) return pending;

// `rotations` records every `newRefreshToken` event the SDK emits.
// The SDK's persistence path is to immediately call setForUser on
// our storage adapter — but we capture the event independently so
// a structured log line exists even if the storage write was
// somehow skipped or clobbered. On refresh failure the count is
// logged so we can tell "refresh ran and rotated tokens N times
// before giving up" from "refresh refused immediately".
const rotations: string[] = [];
// A dead dynamic-client registration (e.g. the issuer's `.css-data` was
// wiped in dev) makes the SDK *throw* during refresh — `invalid_client`,
// a network error, etc. — rather than return a non-logged-in session. We
// must funnel that throw into the same `OidcRefreshFailedError` as the
// returns-not-logged-in case; otherwise the raw error escapes past
// `getOwnerFetch`'s `OidcRefreshFailedError` catch and every caller turns a
// stale identity into an opaque 500 instead of a clear "re-connect via
// /connect" 503. (MC-173.)
let session: Awaited<ReturnType<typeof getSessionFromStorage>> | undefined;
try {
session = await getSessionFromStorage(identity.sessionId, {
storage,
refreshSession: true,
onNewRefreshToken: (newToken: string) => {
// First+last 4 chars only — proves a new value was observed
// without exposing the secret in logs.
const fp = `${newToken.slice(0, 4)}…${newToken.slice(-4)}`;
rotations.push(fp);
log.info("oidc.refresh.rotated", {
webId: scrubWebId(webId),
sessionId: identity.sessionId.slice(0, 8),
tokenLen: newToken.length,
tokenFingerprint: fp,
});
},
});
} catch (e) {
log.warn("oidc.refresh.failed", {
webId: scrubWebId(webId),
sessionId: identity.sessionId.slice(0, 8),
threw: true,
error: (e as Error).message ?? String(e),
rotationsDuringCall: rotations.length,
const refreshPromise = (async (): Promise<typeof fetch | null> => {
const storage = makeIdentityStorage(identity.sessionId);

// `rotations` records every `newRefreshToken` event the SDK emits.
// The SDK's persistence path is to immediately call setForUser on
// our storage adapter — but we capture the event independently so
// a structured log line exists even if the storage write was
// somehow skipped or clobbered. On refresh failure the count is
// logged so we can tell "refresh ran and rotated tokens N times
// before giving up" from "refresh refused immediately".
const rotations: string[] = [];
// A dead dynamic-client registration (e.g. the issuer's `.css-data` was
// wiped in dev) makes the SDK *throw* during refresh — `invalid_client`,
// a network error, etc. — rather than return a non-logged-in session. We
// must funnel that throw into the same `OidcRefreshFailedError` as the
// returns-not-logged-in case; otherwise the raw error escapes past
// `getOwnerFetch`'s `OidcRefreshFailedError` catch and every caller turns a
// stale identity into an opaque 500 instead of a clear "re-connect via
// /connect" 503. (MC-173.)
let session: Awaited<ReturnType<typeof getSessionFromStorage>> | undefined;
try {
session = await getSessionFromStorage(identity.sessionId, {
storage,
refreshSession: true,
onNewRefreshToken: (newToken: string) => {
// First+last 4 chars only — proves a new value was observed
// without exposing the secret in logs.
const fp = `${newToken.slice(0, 4)}…${newToken.slice(-4)}`;
rotations.push(fp);
log.info("oidc.refresh.rotated", {
webId: scrubWebId(webId),
sessionId: identity.sessionId.slice(0, 8),
tokenLen: newToken.length,
tokenFingerprint: fp,
});
},
});
} catch (e) {
log.warn("oidc.refresh.failed", {
webId: scrubWebId(webId),
sessionId: identity.sessionId.slice(0, 8),
threw: true,
error: (e as Error).message ?? String(e),
rotationsDuringCall: rotations.length,
});
throw new OidcRefreshFailedError(webId);
}

if (!session || !session.info.isLoggedIn) {
log.warn("oidc.refresh.failed", {
webId: scrubWebId(webId),
sessionId: identity.sessionId.slice(0, 8),
hasSession: !!session,
isLoggedIn: session?.info.isLoggedIn ?? false,
rotationsDuringCall: rotations.length,
});
throw new OidcRefreshFailedError(webId);
}
const authedFetch = session.fetch.bind(session) as typeof fetch;

// Cache the live session's fetch until just before the access token expires.
// `expirationDate` is ms-epoch when present; if the SDK didn't surface it (or
// it's not a sane future timestamp) we cache for one skew window only — still
// enough to collapse a burst of publishes into a single refresh without
// riding an unknown-lifetime token for long.
const exp = (session.info as { expirationDate?: number }).expirationDate;
const expiresAt =
typeof exp === "number" && exp > Date.now()
? exp
: // No expiry surfaced — cache for one skew window so a burst of publishes
// still collapses to a single refresh, without riding an unknown-lifetime
// token for long. (CSS access tokens live for minutes, so 60s is safe.)
Date.now() + 2 * TOKEN_EXPIRY_SKEW_MS;
sessionCache.set(webId, {
sessionId: identity.sessionId,
fetch: authedFetch,
expiresAt,
});
throw new OidcRefreshFailedError(webId);
}

if (!session || !session.info.isLoggedIn) {
log.warn("oidc.refresh.failed", {
log.info("oidc.refresh.ok", {
webId: scrubWebId(webId),
sessionId: identity.sessionId.slice(0, 8),
hasSession: !!session,
isLoggedIn: session?.info.isLoggedIn ?? false,
rotationsDuringCall: rotations.length,
cachedUntil: expiresAt,
});
throw new OidcRefreshFailedError(webId);
}
const authedFetch = session.fetch.bind(session) as typeof fetch;
return authedFetch;
})();

// Cache the live session's fetch until just before the access token expires.
// `expirationDate` is ms-epoch when present; if the SDK didn't surface it (or
// it's not a sane future timestamp) we cache for one skew window only — still
// enough to collapse a burst of publishes into a single refresh without
// riding an unknown-lifetime token for long.
const exp = (session.info as { expirationDate?: number }).expirationDate;
const expiresAt =
typeof exp === "number" && exp > Date.now()
? exp
: // No expiry surfaced — cache for one skew window so a burst of publishes
// still collapses to a single refresh, without riding an unknown-lifetime
// token for long. (CSS access tokens live for minutes, so 60s is safe.)
Date.now() + 2 * TOKEN_EXPIRY_SKEW_MS;
sessionCache.set(webId, {
sessionId: identity.sessionId,
fetch: authedFetch,
expiresAt,
});

log.info("oidc.refresh.ok", {
webId: scrubWebId(webId),
sessionId: identity.sessionId.slice(0, 8),
rotationsDuringCall: rotations.length,
cachedUntil: expiresAt,
});
return authedFetch;
inFlightRefresh.set(webId, refreshPromise);
try {
return await refreshPromise;
} finally {
inFlightRefresh.delete(webId);
}
}
8 changes: 7 additions & 1 deletion src/lib/workflows/docker.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,7 +22,13 @@ import { randomBytes } from "node:crypto";
* MIND_RUNNER env var.
*/

const DEFAULT_IMAGE = "node:22-alpine";
// Runner image for `npm install`/`vite build`. Defaults to a glibc image
// (Debian bookworm), NOT alpine/musl: native build deps like lightningcss
// (pulled in by Vite/Tailwind v4) only reliably resolve their prebuilt
// binary on glibc — on musl, npm's optional-deps-with-lockfile bug skips
// `lightningcss.linux-x64-musl.node` and the build dies. Override with
// MIND_WORKFLOW_IMAGE if a project needs a different toolchain.
const DEFAULT_IMAGE = process.env.MIND_WORKFLOW_IMAGE?.trim() || "node:22-bookworm-slim";
const DOCKER_PROBE_TIMEOUT_MS = 3000;

// Network isolation for the workflow container (§3.4):
Expand Down
Loading
Loading