Skip to content
81 changes: 81 additions & 0 deletions servers/gateway/capability-ids.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/**
* The ids of the capabilities THIS instance actually has.
*
* N1b-8 review round 1 (B2): on the `/router` mount a peer names the tool
* (`crow_<something>`) and the gate turns that into `crow-<something>`. That
* string then keyed a Fix-it card, its dedupe key and a
* `peer_exposure_card_suppressed` counter entry — so a peer could mint
* unlimited distinct "capabilities" out of junk names (`crow_junk1`,
* `crow_JUNK2`, a 10 KB name …). The id must therefore be canonicalised and
* matched against the instance's OWN catalog before anything is raised or
* counted.
*
* This module is a leaf (static tool manifests + the extension registry, no
* settings, no db) so capability-registry.js, peer-exposure.js and the Fix-it
* detector can all use it without an import cycle. It is the same set the
* public catalog is built from (`coreTools()` + `addonTools()` in
* capability-registry.js), so "in the catalog" and "a capability this instance
* has" mean exactly one thing.
*/
import { TOOL_MANIFESTS } from "./tool-manifests.js";
import { listInstalledExtensions, resolveCrowHome } from "../../scripts/pi-bots/ext_registry.mjs";

/** Canonical server id for a core manifest category (crow-memory, etc.). */
export function canonicalForCategory(category) {
return `crow-${category}`;
}

/**
* A peer-chosen `/router` tool name → canonical capability id, or null.
*
* `crow_memory` → `crow-memory`; the case variant `crow_Memory` → the SAME
* `crow-memory` (so it is one capability, not an unknown one — and it lands on
* the same card and the same dedupe key). Anything whose tail is not a plain
* identifier (`crow_../etc`, `crow_`, a 4 KB name, `crow_store_memory` →
* `crow-store_memory` — the tail keeps its underscore) canonicalises to an id
* that no catalog contains, and is therefore unknown.
*
* Note this is only a PROPOSAL: the caller must still require the returned id
* to be in `getLocalCapabilityIds()`.
*/
const PEER_TOOL_RE = /^crow_([A-Za-z0-9][A-Za-z0-9_-]{0,63})$/;
export function canonicalizePeerCapabilityTool(toolName) {
if (typeof toolName !== "string") return null;
const m = PEER_TOOL_RE.exec(toolName);
if (!m) return null;
return canonicalForCategory(m[1].toLowerCase());
}

// The live aggregation re-reads installed extensions (a few small JSON files +
// readdirs), which is fine per-DENIAL but not per-request: a deny costs one
// scan per TTL window per CROW_HOME. A newly installed addon shows up within
// this window, which is bounded because the window is short.
const CACHE_TTL_MS = 30_000;
const cache = new Map(); // crowHome -> { at, ids }

/**
* Every canonical capability id on this instance: core manifests + installed
* extensions. Never throws; returns a fresh Set the caller may keep.
*
* @param {string} [crowHome]
* @returns {Set<string>}
*/
export function getLocalCapabilityIds(crowHome = resolveCrowHome()) {
const now = Date.now();
const hit = cache.get(crowHome);
if (hit && now - hit.at < CACHE_TTL_MS) return new Set(hit.ids);
const ids = new Set();
for (const category of Object.keys(TOOL_MANIFESTS)) ids.add(canonicalForCategory(category));
try {
for (const ext of listInstalledExtensions(crowHome)) {
if (ext && typeof ext.id === "string" && ext.id) ids.add(ext.id);
}
} catch { /* addon listing unavailable: core ids only */ }
cache.set(crowHome, { at: now, ids });
return new Set(ids);
}

/** Test seam: forget the cached scans. */
export function _clearCapabilityIdCache() {
cache.clear();
}
25 changes: 19 additions & 6 deletions servers/gateway/capability-registry.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,15 @@ import { normalizeSkillName } from "../../scripts/pi-bots/skill_proposals.mjs";
import { readdirSync } from "node:fs";
import { offSkills } from "../shared/skill-state.js";
import { getExposedCapabilities } from "./peer-exposure.js";
import { getExposureForPeer } from "./peer-exposure-store.js";
import { canonicalForCategory } from "./capability-ids.js";
import { getPeerManagedBots } from "./bot-management-exposure.js";
import { CROW_VOICE_BOT_ID } from "../shared/voice-bot.js";

/** Canonical server id for a core manifest category (crow-memory, etc.). */
export function canonicalForCategory(category) {
return `crow-${category}`;
}
// Canonical id shape lives in capability-ids.js (a leaf, so the peer-exposure
// gate can use it without a cycle); re-exported here because callers expect it
// from the catalog module.
export { canonicalForCategory };

// ---- public-safe projectors (security boundary) ----

Expand Down Expand Up @@ -109,9 +111,20 @@ async function localBots(db) {

/**
* The local, vocab-normalized catalog with everything projected public-safe.
*
* N1b-8 review round 1 (S2): when the catalog is served to a VERIFIED peer,
* pass `peerId` — a capability is then marked `exposed` if it is in the
* all-trusted-peers allowlist OR that peer holds an active one-device grant.
* Only THAT peer's grants are merged: another peer's allowance is never
* visible here (the per-peer store is read for one id, never in bulk).
*
* @param {object} db
* @param {{crowHome?: string, instanceId?: string|null, instanceName?: string|null, peerId?: string|null}} [opts]
*/
export async function getLocalCatalog(db, { crowHome = resolveCrowHome(), instanceId = null, instanceName = null } = {}) {
const exposedSet = await getExposedCapabilities(db);
export async function getLocalCatalog(db, { crowHome = resolveCrowHome(), instanceId = null, instanceName = null, peerId = null } = {}) {
const exposedSet = peerId
? await getExposureForPeer(db, peerId)
: await getExposedCapabilities(db);
const managedSet = await getPeerManagedBots(db);
const tools = [...coreTools(), ...addonTools(crowHome)].map((e) => toPublicTool(e, exposedSet));
const skills = localSkills(crowHome).map(toPublicSkill);
Expand Down
2 changes: 1 addition & 1 deletion servers/gateway/dashboard/panels/health.js
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ export default {

// Flash param from post-backup / fix-it redirects
const flash = (typeof req.query?.flash === "string" &&
["backup_ok", "backup_fail", "fixit_fixed", "fixit_dismissed", "fixit_error"].includes(req.query.flash))
["backup_ok", "backup_fail", "fixit_fixed", "fixit_dismissed", "fixit_error", "fixit_not_pending"].includes(req.query.flash))
? req.query.flash
: null;

Expand Down
2 changes: 2 additions & 0 deletions servers/gateway/dashboard/panels/nest/html.js
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@ function buildHealthStrip(health, lang, flash) {
flashHtml = `<div class="nest-flash nest-flash--success">Dismissed for now.</div>`;
} else if (flash === "fixit_error") {
flashHtml = `<div class="nest-flash nest-flash--error">Couldn't apply that fix.</div>`;
} else if (flash === "fixit_not_pending") {
flashHtml = `<div class="nest-flash nest-flash--error">That card isn't open any more — nothing was changed.</div>`;
}

if (!health) return flashHtml;
Expand Down
111 changes: 101 additions & 10 deletions servers/gateway/dashboard/settings/sections/remote-exposure.js
Original file line number Diff line number Diff line change
@@ -1,21 +1,97 @@
/**
* Settings Section: Remote Tool Exposure (Multi-Instance group) — F4a Layer 2a.
*
* Per-instance, LOCAL-ONLY (never synced) allowlist of capabilities this
* instance lets trusted peer instances invoke. Default = nothing exposed
* (deny-all). The authoritative enforcement is server-side in
* peer-exposure.js; this UI only edits the `remote_exposed_tools` setting.
* Per-instance, LOCAL-ONLY (never synced) exposure controls, in two shapes:
* 1. the all-trusted-peers allowlist (`remote_exposed_tools`) — the checkbox
* list below, an explicit "share with all my Crows" statement;
* 2. per-device allowances (`remote_exposed_tools_by_peer`, N1b-8) — what a
* Fix-it "Allow" card grants to the ONE peer that asked, with an expiry.
* Listed here with the peer and the expiry, and renewable or removable.
* Default = nothing exposed (deny-all). The authoritative enforcement is
* server-side in peer-exposure.js; this UI only edits the exposure settings.
*
* `remote_exposed_tools` is deliberately ABSENT from sync-allowlist.js, so
* writeSetting downgrades it to local scope automatically — each instance is
* sovereign over what it exposes.
* Both keys are deliberately ABSENT from sync-allowlist.js, so writeSetting
* downgrades them to local scope automatically — each instance is sovereign
* over what it exposes.
*/
import { writeSetting } from "../registry.js";
import {
getPeerExposures,
grantIsActive,
renewPeerGrant,
removePeerGrant,
} from "../../../peer-exposure-store.js";
import { normalizePeerId } from "../../../../shared/peer-id.js";
import { getLocalCatalog } from "../../../capability-registry.js";
import { getOrCreateLocalInstanceId } from "../../../instance-registry.js";
import { getOrCreateLocalInstanceId, getInstance } from "../../../instance-registry.js";
import { escapeHtml } from "../../shared/components.js";
import { resolveFriendlyName } from "../../../fix-it/friendly-names.js";

const SECTION_URL = "/dashboard/settings?section=remote-exposure";

/** Format an ISO timestamp for display; "" when unparseable. */
function formatWhen(iso) {
const t = Date.parse(iso || "");
if (!Number.isFinite(t)) return "";
return new Date(t).toISOString().slice(0, 10);
}

/** Best-effort display label for a peer: its stored name, else a short id. */
async function peerLabel(db, peerId) {
let name = null;
try { name = (await getInstance(db, peerId))?.name || null; } catch { name = null; }
return name && String(name).trim() ? String(name).trim() : `${peerId.slice(0, 8)}…`;
}

/**
* The "Per-device allowances" block: one row per (peer, capability) grant, with
* its state — active with an expiry, expired (still listed so it can be renewed
* or removed), or "until you remove it".
*/
async function renderPeerGrants(db) {
const grants = await getPeerExposures(db);
const now = Date.now();
const entries = [];
for (const [peerId, caps] of grants) {
for (const [capability, grant] of caps) entries.push({ peerId, capability, grant });
}
entries.sort((a, b) => `${a.peerId}:${a.capability}`.localeCompare(`${b.peerId}:${b.capability}`));

const states = await Promise.all(entries.map(async (e) => {
const label = await peerLabel(db, e.peerId);
let state;
if (e.grant.expiresAt == null) state = "until you remove it";
else if (grantIsActive(e.grant, now)) state = `expires ${escapeHtml(formatWhen(e.grant.expiresAt))}`;
else state = `<strong style="color:var(--crow-error)">expired</strong> ${escapeHtml(formatWhen(e.grant.expiresAt))}`;
return { ...e, label, state };
}));

const rows = states.map((e) => {
const friendly = resolveFriendlyName(e.capability);
const actionForm = (action, buttonText) => `<form method="POST" style="display:inline">
<input type="hidden" name="action" value="${action}">
<input type="hidden" name="peer" value="${escapeHtml(e.peerId)}">
<input type="hidden" name="cap" value="${escapeHtml(e.capability)}">
<button type="submit" class="btn btn-text" style="background:none;border:none;color:var(--crow-text-muted);cursor:pointer;padding:0 0.25rem">${buttonText}</button>
</form>`;
return `<div style="display:flex;align-items:center;gap:0.6rem;flex-wrap:wrap;padding:0.5rem 0;border-bottom:1px solid var(--crow-border,#2222)">
<span style="flex:1 1 16rem">${escapeHtml(e.label)} <span style="color:var(--crow-text-muted);font-size:0.85rem">(${escapeHtml(e.peerId)})</span>
<br><span style="font-size:0.85rem;color:var(--crow-text-muted)">${escapeHtml(friendly)} — ${e.state}</span></span>
${actionForm("renew_peer_exposure", "Renew 30 days")}
${actionForm("remove_peer_exposure", "Remove")}
</div>`;
}).join("");

const body = rows || `<p style="color:var(--crow-text-muted);padding:0.8rem 0;margin:0">No one-device allowances yet. An “Allow” card on the Nest creates one, for the device that asked only.</p>`;
return `<h4 style="font-size:0.95rem;margin:1.75rem 0 0.4rem">Per-device allowances</h4>
<div style="color:var(--crow-text-secondary);font-size:0.9rem;line-height:1.5;margin-bottom:0.5rem">
Capabilities allowed for a single device, usually from an “Allow” card on the Nest. An expired allowance
stops working on its own and can be renewed or removed here. Nothing here is listed above, so it is never
shared with your other devices.
</div>
<div style="border:1px solid var(--crow-border,#2222);border-radius:8px;padding:0 0.8rem">${body}</div>`;
}

export default {
id: "remote-exposure",
group: "otherCrows",
Expand Down Expand Up @@ -69,10 +145,25 @@ export default {
<div style="margin-top:1.5rem">
<button type="submit" class="btn btn-secondary">Save exposure</button>
</div>
</form>`;
</form>
${await renderPeerGrants(db)}`;
},

async handleAction({ req, res, db, action }) {
const body = req.body || {};
// N1b-8: one-device allowances — renew (+30 days from now) or remove.
if (action === "renew_peer_exposure" || action === "remove_peer_exposure") {
const peerId = normalizePeerId(body.peer);
const capability = typeof body.cap === "string" ? body.cap.trim() : "";
if (!peerId || !capability) {
res.redirectAfterPost(SECTION_URL);
return true;
}
if (action === "remove_peer_exposure") await removePeerGrant(db, { peerId, capability });
else await renewPeerGrant(db, { peerId, capability, days: 30 });
res.redirectAfterPost(SECTION_URL);
return true;
}
if (action !== "set_remote_exposure") return false;
// Checkboxes: req.body.cap is a string (one) or array (many) or undefined (none).
let selected = req.body.cap;
Expand All @@ -81,7 +172,7 @@ export default {
const clean = [...new Set(selected.filter((x) => typeof x === "string" && x.length > 0))];
// Local scope (not in sync-allowlist → writeSetting downgrades anyway; be explicit).
await writeSetting(db, "remote_exposed_tools", JSON.stringify(clean), { scope: "local" });
res.redirectAfterPost("/dashboard/settings?section=remote-exposure");
res.redirectAfterPost(SECTION_URL);
return true;
},
};
Loading
Loading