diff --git a/src/lib/ai-providers/store.ts b/src/lib/ai-providers/store.ts index 995049e..a8df373 100644 --- a/src/lib/ai-providers/store.ts +++ b/src/lib/ai-providers/store.ts @@ -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 = { + 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 { @@ -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; @@ -246,7 +295,7 @@ export type CoderConfigSummary = } | { source: "env-fallback"; - provider: "openrouter"; + provider: ProviderName; providerLabel: string; model: string; } @@ -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" }; diff --git a/src/lib/env.ts b/src/lib/env.ts index 42cdb82..6eec0b2 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -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; diff --git a/src/lib/pages/publisher.ts b/src/lib/pages/publisher.ts index 97f4afc..7280747 100644 --- a/src/lib/pages/publisher.ts +++ b/src/lib/pages/publisher.ts @@ -231,14 +231,24 @@ async function pruneStale( relPrefix: string, kept: Set, ): Promise { - // CSS emits the `ldp:contains` objects as relative URIs (e.g. ``), - // 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 (``) on + // some Solid servers and as absolute URLs (``) + // 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("/")) { diff --git a/src/lib/solid/oidc-server.ts b/src/lib/solid/oidc-server.ts index 265e1f7..d93ed68 100644 --- a/src/lib/solid/oidc-server.ts +++ b/src/lib/solid/oidc-server.ts @@ -48,6 +48,17 @@ type CachedSession = { }; const sessionCache = new Map(); +// 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>(); + // 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; @@ -194,89 +205,103 @@ export async function loadAuthedFetchForWebId(webId: string): Promise> | 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 => { + 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> | 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); + } } diff --git a/src/lib/workflows/docker.ts b/src/lib/workflows/docker.ts index efaefb7..f03ad0c 100644 --- a/src/lib/workflows/docker.ts +++ b/src/lib/workflows/docker.ts @@ -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): diff --git a/tests/tracker-pod.test.ts b/tests/tracker-pod.test.ts index 015734e..7ad2934 100644 --- a/tests/tracker-pod.test.ts +++ b/tests/tracker-pod.test.ts @@ -97,7 +97,7 @@ describe("tracker → pod mirror (MC-160)", () => { expect(pod.store.has(`${CONTAINER}state.ttl`)).toBe(true); // Public-read ACL: owner R/W/Control, foaf:Agent Read. - const acl = pod.store.get(`${CONTAINER}.acl`)!; + const acl = pod.store.get(`${CONTAINER}.acl`); expect(acl).toContain("acl:Read, acl:Write, acl:Control"); expect(acl).toContain("foaf:Agent"); @@ -112,7 +112,7 @@ describe("tracker → pod mirror (MC-160)", () => { it("the published tracker.ttl carries the flow:Tracker shape (mind-issues can render it)", async () => { const { publishTrackerToPod } = await import("@/lib/solid/tracker-pod"); await publishTrackerToPod(repo, OUTPUTS); - const trackerDoc = pod.store.get(`${CONTAINER}tracker.ttl`)!; + const trackerDoc = pod.store.get(`${CONTAINER}tracker.ttl`); // C6 regression guard: the same URL must be a conformant flow:Tracker with a // flow:stateStore pointer — what mind-issues / the SolidOS issue-pane read. expect(trackerDoc).toContain("a flow:Tracker"); @@ -125,12 +125,12 @@ describe("tracker → pod mirror (MC-160)", () => { const tracker = await readPodTracker(repo, "alice", "site"); expect(tracker).not.toBeNull(); - expect(tracker!.title.length).toBeGreaterThan(0); - expect(tracker!.issues.length).toBeGreaterThan(0); + expect(tracker?.title.length).toBeGreaterThan(0); + expect(tracker?.issues.length).toBeGreaterThan(0); // Every issue carries a display number (the board groups + links by it). - expect(tracker!.issues.every((i) => i.number != null)).toBe(true); + expect(tracker?.issues.every((i) => i.number != null)).toBe(true); // At least one epic was parsed from epics.ttl. - expect(tracker!.epics.length).toBeGreaterThan(0); + expect(tracker?.epics.length).toBeGreaterThan(0); }); it("returns null when the pod has no state.ttl (caller falls back to git)", async () => {