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
21 changes: 21 additions & 0 deletions servers/gateway/boot/post-listen.js
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,27 @@ export async function runPostListenSetup(server, app, deps) {
});
}

// Redact local-only columns out of stored conflict snapshots (security audit
// N1b-7, fix round 2). A providers conflict saved before snapshots were
// stripped holds this device's api_key in winning_data (and possibly an old
// peer's in losing_data), and the settings page renders both in full. One-time
// and idempotent: it rewrites only rows that still carry a local-only column.
// Runs on every gateway, --no-auth included: the secret is in the LOCAL db,
// whether or not this instance serves a dashboard. Own db client, closed here,
// never throws, one log line with the count.
setImmediate(async () => {
let db;
try {
const { redactConflictSnapshotsAtBoot } = await import("../../sharing/sync-conflict-redact.js");
db = createDbClient();
await redactConflictSnapshotsAtBoot(db);
} catch (err) {
console.warn("[sync-conflicts] key redaction skipped:", err?.message || err);
} finally {
try { db?.close?.(); } catch {}
}
});

// Start auto-update checker
startAutoUpdate(createDbClient(), { noAuth }).catch((err) => {
console.error("[auto-update] Failed to start:", err.message);
Expand Down
26 changes: 22 additions & 4 deletions servers/gateway/dashboard/settings/sections/llm/providers-tab.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import {
} from "../../../../../shared/cloud-consent.js";
import { readLocalMarks, markLocal, unmarkLocal, isMarkableRow, isMarkCurrent, localMarkTokenFor, ownBoxMarkTokenFor } from "../../../../../shared/provider-local-marks.js";
import { readOwnBoxMarks, markOwnBox, unmarkOwnBox, isOwnBoxMarkableRow, isOwnBoxMarkCurrent } from "../../../../../shared/provider-local-marks.js";
import { providerNeedsKey } from "../../../../../shared/provider-key-need.js";

const BACK = "?section=llm&tab=providers";

Expand Down Expand Up @@ -202,6 +203,23 @@ export function localMarkSwitch(p, { localMarks, salt, ownAddrs, lang = "en" })
</form>`;
}

/**
* "Needs an API key on this device." for a row that arrived from a paired
* device and holds no key here; "" otherwise (operator ruling 2026-11-05).
* Keys are never shared, so a synced row lands keyless — and this is the only
* place the operator is told, beside the row it applies to. Not for a row this
* instance wrote itself (it cannot be keyless by accident), not for a keyless
* provider (a llama.cpp on the LAN, provider-key-need.js), and not while a key
* is set here.
*/
export function needsKeyLine(p, { ownInstanceId, lang = "en" } = {}) {
if (!p || p.disabled || !ownInstanceId) return "";
if (p.instance_id == null || p.instance_id === ownInstanceId) return "";
if (!providerNeedsKey(p)) return "";
if (typeof p.apiKey === "string" && p.apiKey.trim() !== "") return "";
return `<div class="llm-needs-key" role="status">${escapeHtml(t("settings.providers.needsKeyHere", lang))}</div>`;
}

/** markLocal refusals with their own line (?local=<reason>). */
const LOCAL_REFUSALS = new Set(["ip_literal", "public_address", "flagged_cloud", "known_cloud"]);

Expand Down Expand Up @@ -360,7 +378,7 @@ export default {
return `<tr class="${p.disabled ? "llm-row-disabled" : ""}"${dupAttr}>
<td class="llm-cell-status">${statusDot(p, { external, lang })}</td>
<td class="llm-cell-id">${idEsc}${dupBadge}</td>
<td class="llm-cell-host">${hostBadge(p, hostCtx)}${engineBadge(p, lang)}${botsSwitch(p, consentCtx)}${localMarkSwitch(p, localCtx)}${ownBoxSwitch(p, ownBoxCtx)}</td>
<td class="llm-cell-host">${hostBadge(p, hostCtx)}${engineBadge(p, lang)}${botsSwitch(p, consentCtx)}${localMarkSwitch(p, localCtx)}${ownBoxSwitch(p, ownBoxCtx)}${needsKeyLine(p, { ownInstanceId: hostCtx.ownInstanceId, lang })}</td>
<td class="llm-cell-endpoint" data-label="${escapeHtml(t("settings.providers.endpoint", lang))}">${escapeHtml(p.baseUrl || "—")}</td>
<td class="llm-cell-models" data-label="${escapeHtml(t("settings.providers.models", lang))}" title="${escapeHtml(models)}">${models}</td>
${usedByCell(p, usedCtx)}
Expand Down Expand Up @@ -428,6 +446,8 @@ export default {
.llm-cell-id .llm-pill { margin-top:2px; }
}
.llm-bots-switch { margin-top:6px; }
.llm-needs-key { margin-top:6px; font-size:0.75rem; color:var(--crow-warning, #e0a000); }
.llm-key-note { font-size:0.72rem; color:var(--crow-text-muted); margin-top:4px; }
.llm-bots-changed { margin:0 0 0.85rem; padding:0.6rem 0.8rem; border-left:3px solid var(--crow-error); background:var(--crow-bg-elevated); border-radius:4px; font-size:0.85rem; }
.llm-bots-switch button {
display:inline-flex; align-items:center; gap:8px; min-height:44px; padding:0 4px;
Expand Down Expand Up @@ -519,9 +539,6 @@ export default {
<summary class="llm-details-summary">Add cloud provider</summary>
<form method="post" class="llm-add-form">
<input type="hidden" name="action" value="llm_provider_add">
<div style="font-size:0.78rem;color:var(--crow-text-muted);margin-bottom:0.85rem;padding:0.55rem 0.75rem;background:var(--crow-bg-surface);border-left:3px solid var(--crow-brand-gold);border-radius:4px">
<strong style="color:var(--crow-text-secondary)">Note:</strong> this API key will sync to your paired Crow instances (local-lab threat model). A per-row local-only toggle is planned for v2.
</div>
<div class="llm-field">
<label>Provider ID <span style="color:var(--crow-text-muted);font-weight:normal">— lowercase, used in dropdowns</span></label>
<input type="text" name="id" required pattern="[a-z][a-z0-9-]*" placeholder="cloud-openai-main" style="font-family:'JetBrains Mono',monospace">
Expand All @@ -539,6 +556,7 @@ export default {
<div class="llm-field">
<label>API key</label>
<input type="password" name="api_key" autocomplete="off" placeholder="sk-…">
<div class="llm-key-note">${escapeHtml(t("settings.providers.keyStaysLocal", lang))}</div>
</div>
<div style="display:grid;grid-template-columns:1fr 2fr;gap:0.75rem">
<div class="llm-field">
Expand Down
87 changes: 80 additions & 7 deletions servers/gateway/dashboard/settings/sections/sync-conflicts.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,20 @@
*/

import { escapeHtml } from "../../shared/components.js";
import { t } from "../../shared/i18n.js";
import { fill, t } from "../../shared/i18n.js";
import {
providerEndpointChanged,
providerEndpointKey,
providerOrigin,
clearProviderEndpointHeldNotice,
clearAllProviderEndpointHeldNotices,
} from "../../../../shared/provider-endpoint.js";
import {
resolveConflict,
restoreConflict,
NATURAL_KEY_RESTORE_TABLES,
} from "../../../../sharing/sync-conflict-resolve.js";
import { stripConflictSnapshot } from "../../../../sharing/instance-sync.js";
import { getInstanceSyncManager } from "../../../../sharing/server.js";
import {
EXPIRE_DAYS_DEFAULT,
Expand Down Expand Up @@ -68,12 +76,71 @@ function opBadge(op) {
return `<span style="font-size:0.7rem;padding:2px 6px;background:${color}22;color:${color};border-radius:3px;font-family:'JetBrains Mono',monospace">${escapeHtml(op || "update")}</span>`;
}

function prettyJson(raw) {
/**
* A held providers conflict is one this device refused to apply because the
* peer's row MOVES the endpoint while the API key lives here (operator ruling
* 2026-11-06; instance-sync.js _holdProviderEndpointChange). Show the operator
* the one fact that matters before they press Restore. Every part is escaped.
*
* Only unresolved rows render it: a resolved row already had the decision made.
*/
function addressChangeLine(r, lang) {
let win;
let lose;
try {
win = JSON.parse(stripConflictSnapshot(r.table_name, r.winning_data));
lose = JSON.parse(stripConflictSnapshot(r.table_name, r.losing_data));
} catch {
return "";
}
const before = win?.base_url;
const after = lose?.base_url;
if (!providerEndpointChanged(before, after)) return "";
// The origin is what an operator recognises… except for a PATH-only move,
// where both origins read the same; then show the address itself so the line
// never says "https://x → https://x".
const beforeOrigin = providerOrigin(before);
const afterOrigin = providerOrigin(after);
const from = beforeOrigin === afterOrigin
? (providerEndpointKey(before) || String(before ?? "")) : beforeOrigin;
const to = beforeOrigin === afterOrigin
? (providerEndpointKey(after) || String(after ?? "")) : afterOrigin;
return `<div style="margin:0 0 0.4rem;padding:0.4rem 0.6rem;background:var(--crow-bg-deep);border-left:3px solid #ff9800;border-radius:3px;font-size:0.78rem;max-width:32rem">
${escapeHtml(fill(t("syncConflicts.addressChange", lang), { from, to }))}
</div>`;
}

/** Quiet the "another device wants to change its address" bell item once the
* operator has decided this conflict (kept the local address, or accepted it). */
async function clearHoldNotice(db, conflictId) {
try {
const { rows } = await db.execute({
sql: `SELECT row_id FROM sync_conflicts WHERE id = ?`,
args: [String(conflictId)],
});
if (rows[0]) await clearProviderEndpointHeldNotice(db, String(rows[0].row_id));
} catch {
// Best effort: the notice is a bell item, never a reason to fail the action.
}
}

/**
* A stored snapshot as pretty JSON for the page, with `table`'s local-only
* columns stripped FIRST (instance-sync.js LOCAL_ONLY_COLUMNS): a pre-fix
* providers conflict row still holds this device's `api_key`, and this page is
* the only place it would be shown in full. A JSON null snapshot (`'null'`, an
* old row whose side was gone) and a blob we cannot parse render as "(none)" —
* an unreadable snapshot is exactly the one we cannot certify as redacted.
*/
function prettyJson(table, raw) {
if (!raw) return "(none)";
const stripped = stripConflictSnapshot(table, raw);
try {
return JSON.stringify(JSON.parse(raw), null, 2);
const parsed = typeof stripped === "string" ? JSON.parse(stripped) : stripped;
if (!parsed || typeof parsed !== "object") return "(none)";
return JSON.stringify(parsed, null, 2);
} catch {
return String(raw);
return "(none)";
}
}

Expand Down Expand Up @@ -133,8 +200,8 @@ function renderConflictRow(r, lang, csrfToken) {
const isNaturalKey = NATURAL_KEY_RESTORE_TABLES.has(r.table_name);
const resolved = !!r.resolved;

const winJson = prettyJson(r.winning_data);
const loseJson = prettyJson(r.losing_data);
const winJson = prettyJson(r.table_name, r.winning_data);
const loseJson = prettyJson(r.table_name, r.losing_data);

// Restore button is disabled for op='insert' conflicts per spec §6
// (precedence FIRST — matches the backend's refusal order), then for
Expand Down Expand Up @@ -197,6 +264,7 @@ function renderConflictRow(r, lang, csrfToken) {
<td style="padding:8px;font-size:0.78rem;color:var(--crow-text-muted)">${escapeHtml(when)}</td>
<td style="padding:8px">${opBadge(r.op)}</td>
<td style="padding:8px">
${r.table_name === "providers" ? addressChangeLine(r, lang) : ""}
<details class="sc-details" data-conflict-id="${escapeHtml(String(r.id))}">
<summary style="cursor:pointer;font-size:0.78rem;color:var(--crow-accent)">
${escapeHtml(t("syncConflicts.showData", lang))}
Expand Down Expand Up @@ -459,6 +527,7 @@ export default {
}
await resolveConflict(db, String(conflictId));
await markResolvedVia(db, conflictId, "operator");
await clearHoldNotice(db, conflictId);
res.redirectAfterPost(conflictPage);
return true;
}
Expand All @@ -469,7 +538,10 @@ export default {
return true;
}
const outcome = await restoreConflict(db, String(conflictId), { instanceSync });
if (outcome.status === "applied") await markResolvedVia(db, conflictId, "operator");
if (outcome.status === "applied") {
await markResolvedVia(db, conflictId, "operator");
await clearHoldNotice(db, conflictId);
}
// Pass only the status enum; render() maps it to an i18n'd flash.
const status = ["applied", "stale", "refused"].includes(outcome.status)
? outcome.status
Expand All @@ -485,6 +557,7 @@ export default {
sql: `UPDATE sync_conflicts SET resolved = 1, resolved_at = datetime('now')${via} WHERE resolved = 0`,
args: [],
});
await clearAllProviderEndpointHeldNotices(db);
res.redirectAfterPost(conflictPage);
return true;
}
Expand Down
20 changes: 20 additions & 0 deletions servers/gateway/dashboard/shared/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -2598,6 +2598,11 @@ export const translations = {
"settings.providers.introTail": { en: "Bundle providers auto-appear on install. Instance-sync replicates this table across paired Crow instances.", es: "Los proveedores de paquetes aparecen solos al instalar. La sincronización entre instancias replica esta tabla en las instancias Crow emparejadas." },
"settings.providers.noneEnabled": { en: "No enabled providers", es: "Ningún proveedor activado" },
"settings.providers.noneRegistered": { en: "No providers registered.", es: "No hay proveedores registrados." },
// Security audit N1b-7 (operator ruling 2026-11-05): provider rows sync, their
// API keys never do — said where the key is typed, and on a row that arrived
// from a paired device without one.
"settings.providers.keyStaysLocal": { en: "Keys stay on this device; they are not shared with paired devices.", es: "Las claves se quedan en este dispositivo; no se comparten con los dispositivos emparejados." },
"settings.providers.needsKeyHere": { en: "Needs an API key on this device.", es: "Necesita una clave API en este dispositivo." },
"settings.roles.coordinator": { en: "Orchestrator", es: "Orquestador" },
"settings.roles.quick": { en: "Scout", es: "Explorador" },
"settings.roles.deep": { en: "Planner", es: "Planificador" },
Expand Down Expand Up @@ -2736,6 +2741,15 @@ export const translations = {
"providers.ownBoxRefused.known_cloud": { en: "This address belongs to a known cloud provider, so it isn't your other box.", es: "Esta dirección pertenece a un proveedor en la nube conocido, así que no es tu otro equipo." },
"providers.ownBoxRefused.here": { en: "This address is this box, so it can't be your other box.", es: "Esta dirección es este equipo, así que no puede ser tu otro equipo." },
"providers.localNotice": { en: "These providers are no longer treated as local for voice and phone calls until you mark them \"This is local\": {names}", es: "Estos proveedores ya no se consideran locales para la voz y las llamadas hasta que los marques como \"Esto es local\": {names}" },
// Raised ONCE per provider id when a paired device's keyless provider row
// lands here (provider-key-need.js). Keys never sync, so it must be entered here.
"providers.needsKeyTitle": { en: "{name} needs an API key on this device", es: "{name} necesita una clave API en este dispositivo" },
"providers.needsKeyBody": { en: "This provider came from a paired device, and API keys are never shared. Enter a key here to use it.", es: "Este proveedor vino de un dispositivo emparejado y las claves API nunca se comparten. Introduce una clave aquí para usarlo." },
// Raised ONCE per provider id when a paired device moves a provider's ENDPOINT
// while this device holds that provider's key: the change is held for review
// (provider-endpoint.js + instance-sync.js _holdProviderEndpointChange).
"providers.endpointHoldTitle": { en: "{name}: another device wants to change its address", es: "{name}: otro dispositivo quiere cambiar su dirección" },
"providers.endpointHoldBody": { en: "Review it before your key is sent there — accepting sends this device's API key to the new address.", es: "Revísalo antes de que se envíe tu clave allí — aceptar envía la clave API de este dispositivo a la nueva dirección." },
"models.add.botsFailed": { en: "Bots can't use it yet. To allow them, open Models, then Advanced, then Providers, and turn on \"Let bots use this provider\".", es: "Los bots aún no pueden usarlo. Para permitirlo, abre Modelos, luego Avanzado, luego Proveedores, y activa \"Permitir que los bots usen este proveedor\"." },
"models.add.test.ok": { en: "That key works. {n} models available.", es: "Esa clave funciona. {n} modelos disponibles." },
"models.add.test.key_rejected": { en: "{provider} did not accept that key. Check that you copied all of it.", es: "{provider} no aceptó esa clave. Comprueba que la copiaste completa." },
Expand Down Expand Up @@ -3453,6 +3467,12 @@ export const translations = {
"syncConflicts.msgStale": { en: "This item has changed since the conflict was recorded — please review the current version and confirm again.", es: "Este elemento ha cambiado desde que se registró el conflicto — revisa la versión actual y confirma de nuevo." },
"syncConflicts.msgRefused": { en: "This version can't be restored automatically; its data remains visible below.", es: "Esta versión no se puede restaurar automáticamente; sus datos siguen visibles abajo." },
"syncConflicts.msgFailed": { en: "Restore failed — the conflict stays open and its data remains visible below.", es: "La restauración falló — el conflicto sigue abierto y sus datos siguen visibles abajo." },
// A held providers conflict: the peer moved the endpoint and this device holds
// the API key (operator ruling 2026-11-06), so it waited for this decision.
"syncConflicts.addressChange": {
en: "Address change: {from} → {to}. Accepting sends this device's API key to the new address.",
es: "Cambio de dirección: {from} → {to}. Aceptar envía la clave API de este dispositivo a la nueva dirección.",
},

// ─── Nav Groups Settings ───
"navGroups.description": { en: "Organize your sidebar by creating groups and moving panels between them.", es: "Organiza tu barra lateral creando grupos y moviendo paneles entre ellos." },
Expand Down
Loading
Loading