diff --git a/extension/background/routes/settings.js b/extension/background/routes/settings.js index c410e698..f396a87e 100644 --- a/extension/background/routes/settings.js +++ b/extension/background/routes/settings.js @@ -34,6 +34,9 @@ export const makeSettingsRoutes = (deps) => { knownProviderNames: listProviders().map((/** @type {{ name: string }} */ p) => p.name), reasoningEffortLevels: REASONING_EFFORT_LEVELS, dwebEnabled: DWEB_ENABLED, + // Preview-only key: its presence in this package's defaults IS the + // channel gate (no separate build flag, unlike dweb). + autoUpdateAvailable: Object.hasOwn(DEFAULT_SETTINGS, 'autoUpdateEnabled'), normalizeVariant, normalizeEngine, }); diff --git a/extension/background/routes/vault.js b/extension/background/routes/vault.js index 3cd55179..b6e0219d 100644 --- a/extension/background/routes/vault.js +++ b/extension/background/routes/vault.js @@ -236,10 +236,11 @@ export const makeVaultRoutes = (deps) => { // --- confirmation --- // The side panel posts the user's answer to a pending confirm prompt; // we resolve the waiting Promise so the dispatcher proceeds (or blocks). - 'confirm/answer': async ({ id, answer }) => { + 'confirm/answer': async ({ id, answer, surface }) => { // resolve → settle → onSettled broadcasts confirm/resolved to every surface - // (DESIGN-12), so no explicit broadcast is needed here. - confirmCoordinator.resolve(id, answer); + // (DESIGN-12), so no explicit broadcast is needed here. `surface` rides the + // outcome so the surface that did NOT answer can say who decided (§4e). + confirmCoordinator.resolve(id, answer, typeof surface === 'string' ? surface : null); return { ok: true }; }, }; diff --git a/extension/background/service-worker.js b/extension/background/service-worker.js index f3191881..16592c90 100644 --- a/extension/background/service-worker.js +++ b/extension/background/service-worker.js @@ -346,7 +346,7 @@ import { hasDurableSiteClientState, decideNumericTabAuthority, numericTabAuthorityRefusal, IDENTITY_PROVIDER_TRANSIT_ONLY_CODE, - isKnownIdp, isKnownIdpHost, knownIdpDomains, describeLandingStop, originPhrase, isUgcHost, + isKnownIdp, isKnownIdpHost, knownIdpDomains, describeLandingStop, landingStopCard, originPhrase, isUgcHost, isAddressableBrowserTab, finalActorTurnReply, finalAssistantText, // The debug surface: the bundle assembler + the delegation-tree walk the @@ -439,6 +439,7 @@ import { makeModelCatalog } from './model-catalog.js'; import { makeTabAffordances } from './tab-affordances.js'; import { makeMintOnce } from './mint-once.js'; import { makeDwebInboundRateCap } from './dweb-inbound-rate-cap.js'; +import { makeUpdateCheck } from './update-check.js'; import { makeDwebTransfer, IdentityTransferError } from './dweb-transfer.js'; import { makeDwebShare } from './dweb-share.js'; import { makeReseedSharedApps } from './dweb-reseed.js'; @@ -1767,6 +1768,14 @@ const sensitivitySignals = () => ({ */ const landingStopReports = new Map(); +/** + * The same stop, shaped for the transcript CARD (§4c) - landingStopCard's + * output, held beside the prose report and consumed at the same moment. Same + * authorship rule: every field is ours, none is the actor's. + * @type {Map>} + */ +const landingStopCards = new Map(); + /** * A monotonic token per actor TURN, and the reason it has to exist. * @@ -1809,7 +1818,10 @@ const originLockFor = (/** @type {string | null | undefined} */ actorSessionId) // anything. It still records the landing in the audit trail below — the // observation was real even when the turn it belonged to is gone. const current = isCurrentTurn(); - if (current) landingStopReports.set(actorSessionId, describeLandingStop(/** @type {any} */ (event))); + if (current) { + landingStopReports.set(actorSessionId, describeLandingStop(/** @type {any} */ (event))); + landingStopCards.set(actorSessionId, landingStopCard(/** @type {any} */ (event))); + } // RELEASE THE TAB. Without this the stop is self-sealing and the actor is // dead for good, which adversarial review demonstrated end to end: // @@ -3921,6 +3933,22 @@ const { // panel and resolves when the panel posts back 'confirm/answer'. // Exercised whenever the Plan/Act decideAction policy marks an action as // needing confirmation. +// A confirm that settles ITSELF (timeout, abort/Stop, closed panel) is +// invisible today - the modal just vanishes, or never existed. Keep the last +// few self-settles per session so the transcript can say what happened, even +// to a panel that opens later (UI redesign §4e). SW memory only: the prompts +// themselves have the same lifetime, so this is the right blast radius. +/** @type {Map>} */ +const confirmSettleNotes = new Map(); +const CONFIRM_SETTLE_NOTES_CAP = 20; +/** @param {string|null} sessionId @param {{ id: string, answer: string, cause: string, via: string|null }} note */ +const recordConfirmSettle = (sessionId, note) => { + if (!sessionId) return; + const list = confirmSettleNotes.get(sessionId) ?? []; + list.push({ ...note, at: Date.now() }); + confirmSettleNotes.set(sessionId, list.slice(-CONFIRM_SETTLE_NOTES_CAP)); +}; + const confirmCoordinator = makeConfirmCoordinator({ notifySidePanel: (prompt) => { if (!uiConnected()) return; @@ -3933,8 +3961,16 @@ const confirmCoordinator = makeConfirmCoordinator({ // Dismiss the modal on EVERY open surface when a prompt settles for ANY // reason — answer, 120s timeout, or session reset (DESIGN-12). Without this a // timed-out/reset prompt lingers, and a later click "approves" an action that - // was already auto-denied. - onSettled: (id) => { try { uiPorts.broadcast({ type: 'confirm/resolved', id }); } catch { /* port closing */ } }, + // was already auto-denied. The outcome rides along so surfaces can render the + // settle as a transcript line; self-settles are also recorded for late joiners. + onSettled: (id, outcome) => { + if (outcome.cause !== 'answer') { + recordConfirmSettle(outcome.sessionId, { + id, answer: outcome.answer, cause: outcome.cause, via: outcome.via, + }); + } + try { uiPorts.broadcast({ type: 'confirm/resolved', id, outcome }); } catch { /* port closing */ } + }, // Raise an action badge while a confirm is pending so a waiting agent is // visible even if the panel is hidden; cleared at zero. onPendingChange: (count) => { @@ -4017,6 +4053,22 @@ const confirmAction = async (prompt, signal) => { if (sid) { try { ephemeral = (await sessions.get(sid))?.kind === 'actor'; } catch { ephemeral = false; } } + // The CHAT a settle line must land in (§4e): an actor-raised confirm carries + // the ACTOR session id (the ephemeral check above depends on that), but the + // transcript that renders settle notes is the ROOT chat's - walk up exactly + // like recovery notices do (resolveNoticeSession), bounded against a corrupt + // chain. Without this every helper confirm's outcome is recorded under a + // session no surface ever views, and all of §4e silently no-ops for the most + // frequent prompt class. + let chatSessionId = sid; + if (ephemeral && chatSessionId) { + for (let hops = 0; hops < 8; hops += 1) { + const record = /** @type {{ parentSessionId?: string } | null | undefined} */ ( + await sessions.get(/** @type {string} */ (chatSessionId)).catch(() => null)); + if (!record?.parentSessionId) break; + chatSessionId = record.parentSessionId; + } + } if (signal?.aborted) return 'no'; if (!ephemeral && sid && sessionConfirmGrants.get(sid)?.has(grantKey)) { return 'yes_session'; @@ -4028,9 +4080,20 @@ const confirmAction = async (prompt, signal) => { // they see on a GitHub issue, twice per comment — a control that looks like // the way to stop the prompting and silently isn't. The downgrade itself is // correct and stays; what was wrong was offering the choice. - const answer = await confirmCoordinator.confirm(/** @type {any} */ ( - downgradesActorConfirm(prompt.tool, ephemeral, 'yes_session') ? { ...prompt, ephemeral: true } : prompt - ), signal); + // No surface to ask → the coordinator will fail-closed WITHOUT minting an id + // or broadcasting anything, so nothing else can ever tell the user this + // happened. Record it here - the badge they didn't see is not a record (§4e). + if (!uiConnected()) { + recordConfirmSettle(chatSessionId, { + id: crypto.randomUUID(), answer: 'no', cause: 'unreachable', via: null, + }); + } + const outboundPrompt = { + ...prompt, + ...(chatSessionId ? { chatSessionId } : {}), + ...(downgradesActorConfirm(prompt.tool, ephemeral, 'yes_session') ? { ephemeral: true } : {}), + }; + const answer = await confirmCoordinator.confirm(/** @type {any} */ (outboundPrompt), signal); if (answer === 'yes_session' && sid && !ephemeral) { if (!sessionConfirmGrants.has(sid)) sessionConfirmGrants.set(sid, new Set()); (/** @type {Set} */ (sessionConfirmGrants.get(sid))).add(grantKey); @@ -4174,6 +4237,10 @@ const buildStateSnapshot = async () => { }, settings: { ...settingsStore.get() }, pendingConfirm: null, + // Self-settled confirms for THIS chat (timeout / stop / closed panel) - the + // panel folds these into its transcript notes so a settle that happened + // while no surface was open is still tellable (§4e). + confirmSettleNotes: sessionId ? (confirmSettleNotes.get(/** @type {string} */ (sessionId)) ?? []) : [], // Live actor projections are part of the fresh snapshot, not a lucky stream // of events seen only by panels that were already open. Every row is scoped // to this viewed root before it crosses the UI boundary. @@ -4364,9 +4431,16 @@ browser.runtime.onConnect.addListener((port) => { for (const ev of (goalRunner?.activeStates?.() ?? [])) { try { port.postMessage(ev); } catch { /* port closing */ } } + // Replay an undelivered "update available" notice to this fresh surface + // and re-check for a newer preview build (throttled inside; declared at + // the boot tail - connect events only ever fire after module eval). + updateCheck.onUiConnect(); port.onDisconnect.addListener(() => { uiPorts.remove(port); broadcastSurfaces(); + // A parked downloaded update may now be able to apply (the module + // re-checks that everything is quiet before reloading). + updateCheck.onQuiet(); // Sidebar just closed → if the user is sitting on a peerd-opened web tab, // surface the reminder (and start its 15s timer) right then. if (port.name === 'sidepanel' && !uiPorts.hasNamed('sidepanel')) { @@ -6625,6 +6699,7 @@ const actorMessaging = makeActorMessaging({ // Invalidate old judges synchronously, then wait for their serialized // transitions to drain before this turn builds a tool context. landingStopReports.delete(actorSessionId); + landingStopCards.delete(actorSessionId); beginLandingTurn(actorSessionId); await originStates.serialize(actorSessionId, () => undefined); // DESIGN-19 mint-time injection: if this web/API actor's origin has a stored @@ -6694,6 +6769,8 @@ const actorMessaging = makeActorMessaging({ const actorSurface = contributorDecision?.resolved; /** @type {string | null} */ let landingStopSnapshot = null; + /** @type {ReturnType | null} */ + let landingStopCardSnapshot = null; const captureLandingStop = () => { // The next queued turn clears this report at its own start. Consume it // while this turn still owns the actor slot. @@ -6702,6 +6779,11 @@ const actorMessaging = makeActorMessaging({ landingStopReports.delete(actorSessionId); landingStopSnapshot = report; } + const card = landingStopCards.get(actorSessionId); + if (card) { + landingStopCards.delete(actorSessionId); + landingStopCardSnapshot = card; + } }; /** * If the origin lock stopped this actor mid-turn, its own reply is not the @@ -6709,14 +6791,19 @@ const actorMessaging = makeActorMessaging({ * actor may still have emitted text, and text written after the moment we * decided it was somewhere it shouldn't be is exactly what must not reach the * orchestrator. `stopped:true` marks the delivery failed, so the reply arrives - * as "this did not work, here is why" rather than as a result. - * @param {{ result: string, stopped?: boolean }} reply - * @returns {{ result: string, stopped?: boolean }} + * as "this did not work, here is why" rather than as a result. The card + * rides beside the prose so the transcript renders the slotted version (§4c). + * @param {{ result: string, stopped?: boolean, landingStop?: object }} reply + * @returns {{ result: string, stopped?: boolean, landingStop?: object }} */ const withLandingStop = (reply) => { const report = landingStopSnapshot; if (!report) return reply; - return { result: report, stopped: true }; + return { + result: report, + stopped: true, + ...(landingStopCardSnapshot ? { landingStop: landingStopCardSnapshot } : {}), + }; }; /** * Record only fixed enums/counters after the actor session has settled. @@ -7415,6 +7502,71 @@ void dwebSettingsGate.stopWhenDisabled(stopBaseNetwork).catch((error) => { console.warn('[sw] dweb OFF reconciliation failed; next boot will retry', error); }); +// Self-update (preview channel; background/update-check.js). Chrome: force the +// update_url poll at boot and reload when a downloaded update can apply +// without destroying live work. Firefox: read the gecko feed and offer the +// XPI in a notice. Dev/store manifests carry no self-hosted update_url, so +// start() registers nothing there (load-bearing on Firefox - a mere +// onUpdateAvailable listener defers AMO's automatic updates). start() runs +// synchronously at boot - a downloaded update can be the event that wakes +// this worker. +const updateCheck = makeUpdateCheck({ + runtime: browser.runtime, + // why a bare fetch: a chassis-internal DATA fetch of the manifest's own + // update feed - same class as the voice model download; no secret, no + // agent influence over the URL (see update-check.js's header). + fetchFn: (url, init) => fetch(url, init), + ready: settingsReady, + isEnabled: () => settingsStore.get().autoUpdateEnabled === true, + // "peerd is doing work": live turn slots AND goal runs - a goal run holds + // its slot only while an individual turn is in flight, so between + // iterations busySessionIds() alone reads idle mid-run. + busy: () => turnSlots.busySessionIds().length > 0 + || (goalRunner?.activeStates?.().length ?? 0) > 0, + // "a user-facing extension page exists": UI ports, the deliberately + // PORTLESS engine tabs (a running WebVM / notebook / app holds real + // in-memory state a reload would destroy), and - on Chrome, where the SW + // can enumerate its window clients - any other extension page (options, + // permission pages). The offscreen doc is excluded: the keepalive keeps + // it open always, and counting it would block the reload forever. + surfacesOpen: async () => { + if (uiConnected()) return true; + if (vmTabTracker.listLive().length > 0 + || jsTabTracker.listLive().length > 0 + || appTabTracker.listLive().length > 0) return true; + try { + // why the cast: tsconfig lib is DOM (one program checks SW, pages and + // tests alike), so the ServiceWorkerGlobalScope clients API isn't on + // `self`'s type; it exists at runtime only in the Chrome SW. + const swScope = /** @type {{ clients?: { matchAll?: (q: { type: string }) => Promise> } }} */ ( + /** @type {unknown} */ (globalThis)); + const windowClients = await swScope.clients?.matchAll?.({ type: 'window' }); + if (windowClients?.some((c) => !c.url.includes('/offscreen/'))) return true; + } catch { /* not a SW context (Firefox event page) - covered above */ } + return false; + }, + notify: (text, action) => { + if (!uiConnected()) return false; + postChatNote(text, action ?? null); + return true; + }, + // storage.session, not storage.local: the throttle + pending-notice state + // must survive SW/event-page respawns but reset with the browser session. + sessionKv: { + get: async (key) => { + try { return (await browser.storage?.session?.get(key))?.[key]; } + catch { return undefined; } + }, + set: async (key, value) => { + try { await browser.storage?.session?.set({ [key]: value }); } + catch { /* best-effort - a lost throttle just means an extra check */ } + }, + }, + log: (...args) => console.log('[sw]', ...args), +}); +updateCheck.start(); +void updateCheck.checkNow('boot').catch(() => {}); + // SW boot logging — we want a clear timeline of when the SW comes up // (cold start, extension reload, idle respawn). The console clears // when the SW dies, so each fresh boot starts a new transcript. diff --git a/extension/background/settings-patch.js b/extension/background/settings-patch.js index 4448d968..de75f13e 100644 --- a/extension/background/settings-patch.js +++ b/extension/background/settings-patch.js @@ -27,6 +27,7 @@ * knownProviderNames: string[], * reasoningEffortLevels: readonly string[], * dwebEnabled: boolean, + * autoUpdateAvailable: boolean, * normalizeVariant: (v: string) => string, * normalizeEngine: (v: string) => string, * }} deps @@ -36,6 +37,7 @@ export const normalizeSettingsPatch = (patch, { knownProviderNames, reasoningEffortLevels, dwebEnabled, + autoUpdateAvailable, normalizeVariant, normalizeEngine, }) => { @@ -232,6 +234,12 @@ export const normalizeSettingsPatch = (patch, { if (dwebEnabled && typeof patch.dwebAgentEnabled === 'boolean') { next.dwebAgentEnabled = patch.dwebAgentEnabled; } + // Self-update check (preview packages only). Same two-layer posture as the + // dweb keys: the key is absent from other channels' CHANNEL_DEFAULTS AND + // the patch is refused where the package doesn't carry it. + if (autoUpdateAvailable && typeof patch.autoUpdateEnabled === 'boolean') { + next.autoUpdateEnabled = patch.autoUpdateEnabled; + } // Ollama host (issue #104). Accept ONLY a well-formed http(s) ORIGIN, stored // normalized (origin-only — scheme + host + port, no path/query). why strict: // this value is added to the egress allowlist and fetched with no key, so a diff --git a/extension/background/update-check.js b/extension/background/update-check.js new file mode 100644 index 00000000..323b4632 --- /dev/null +++ b/extension/background/update-check.js @@ -0,0 +1,351 @@ +// @ts-check +// background/update-check.js - self-update for the self-hosted preview channel. +// +// why this exists: preview installs update via `update_url` feeds +// (peerd.ai/updates → GitHub release artifacts), but the browsers' own polls +// are slow AND peerd's offscreen keepalive holds the MV3 service worker +// alive - which is exactly the state where Chrome parks a downloaded +// extension update waiting for an "idle" that never comes. So on startup we +// ask for the update ourselves and apply it while nothing is live. +// +// Per browser: +// Chrome - runtime.requestUpdateCheck() forces the update_url poll; a found +// update downloads in the background and fires runtime.onUpdateAvailable, +// where we runtime.reload() IF nothing is live (no turn or goal run in +// flight, no UI port, no engine tab, no other extension page); otherwise a +// note is posted and the parked update is re-attempted when a UI surface +// disconnects (onQuiet), or the browser applies it at its next restart. +// Firefox - has no requestUpdateCheck API at all, so we read the gecko +// update feed directly (it is served with open CORS) and surface an +// "update available" notice whose action opens the XPI; Firefox's own +// daily add-on poll still auto-applies it regardless. +// Dev (load-unpacked) and store packages carry no self-hosted update_url: +// start() registers NOTHING there. That gate is load-bearing on Firefox, +// where the mere PRESENCE of an onUpdateAvailable listener defers every +// downloaded add-on update until reload()/browser restart (presence-based, +// unlike Chrome's unload-based deferral) - a listener on the store package +// would break AMO's automatic updates. The store artifact also omits the +// autoUpdateEnabled key entirely (store updates belong to the store). +// +// why fetchFn is injected, not a bare fetch: the feed read is the same class +// of chassis-internal DATA fetch as the voice model download (a hardcoded, +// manifest-derived URL, no secret attached - see voice/model-store.js's +// rationale for why the egress allowlist doesn't apply), but this module +// stays IO-free so the decision logic is Bun-testable. + +/** Session-storage key for per-browser-session state (throttle + notice). */ +export const UPDATE_CHECK_SESSION_KEY = 'updateCheck.v1'; + +// Re-checks within this window are skipped - startup and panel-open events +// can cluster (SW respawns, panel toggling), and the browsers poll on their +// own cadence anyway; this is a floor, not a schedule. Only a COMPLETED +// check starts the window (an offline boot must not burn it). +export const MIN_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; + +// The only hosts an update_link may point at. The browsers' own update +// pipelines are protected by package signing (CRX key, AMO signature); this +// link instead becomes a trusted-UI "Install update" button, so a compromised +// feed must not be able to aim it at an arbitrary https URL. +export const ALLOWED_UPDATE_LINK_HOSTS = Object.freeze(['github.com', 'peerd.ai']); + +// Release versions are plain dotted numerics; anything else in a feed is +// junk and must not reach compareVersions or the notice copy. +const VERSION_SHAPE = /^\d+(\.\d+)*$/; +const VERSION_MAX_LENGTH = 32; + +/** + * Numeric dotted-version compare ("0.6.0" style). Missing parts count as 0, + * non-numeric parts as 0 - release versions here are plain x.y.z triples. + * @param {string} a + * @param {string} b + * @returns {number} negative if a < b, 0 if equal, positive if a > b + */ +export const compareVersions = (a, b) => { + const pa = String(a).split('.'); + const pb = String(b).split('.'); + const len = Math.max(pa.length, pb.length); + for (let i = 0; i < len; i++) { + const na = Number.parseInt(pa[i] ?? '0', 10) || 0; + const nb = Number.parseInt(pb[i] ?? '0', 10) || 0; + if (na !== nb) return na - nb; + } + return 0; +}; + +/** + * Pick the newest update entry for our gecko id out of an AMO-style update + * feed. Defensive against a malformed or hostile feed: entries must carry a + * plain dotted-numeric version and an https update_link on an allowed host, + * or they are skipped. + * @param {unknown} feedJson + * @param {string} geckoId + * @returns {{ version: string, updateLink: string } | null} + */ +export const latestGeckoUpdate = (feedJson, geckoId) => { + if (!feedJson || typeof feedJson !== 'object') return null; + const addons = /** @type {{ addons?: Record }} */ (feedJson).addons; + const updates = addons?.[geckoId]?.updates; + if (!Array.isArray(updates)) return null; + /** @type {{ version: string, updateLink: string } | null} */ + let best = null; + for (const entry of updates) { + if (!entry || typeof entry !== 'object') continue; + const { version, update_link: updateLink } = /** @type {Record} */ (entry); + if (typeof version !== 'string' || typeof updateLink !== 'string') continue; + if (version.length > VERSION_MAX_LENGTH || !VERSION_SHAPE.test(version)) continue; + let url; + try { url = new URL(updateLink); } catch { continue; } + if (url.protocol !== 'https:') continue; + if (!ALLOWED_UPDATE_LINK_HOSTS.includes(url.hostname)) continue; + if (!best || compareVersions(version, best.version) > 0) { + best = { version, updateLink }; + } + } + return best; +}; + +/** + * @typedef {{ + * version: string, + * update_url?: string, + * browser_specific_settings?: { gecko?: { id?: string, update_url?: string } }, + * }} UpdateManifest + */ + +/** + * @typedef {{ + * lastCheckAt?: number, + * notifiedVersion?: string, + * pendingNotice?: { version: string, text: string, url: string }, + * }} UpdateSessionState + */ + +/** + * @param {{ + * runtime: { + * getManifest: () => UpdateManifest, + * requestUpdateCheck?: () => Promise, + * onUpdateAvailable?: { addListener: (fn: (details: { version: string }) => void) => void }, + * reload: () => void, + * }, + * fetchFn: (url: string, init?: RequestInit) => Promise, + * ready: Promise, + * isEnabled: () => boolean, + * busy: () => boolean, + * surfacesOpen: () => boolean | Promise, + * notify: (text: string, action?: { kind: string, label: string, url: string }) => boolean, + * sessionKv: { + * get: (key: string) => Promise, + * set: (key: string, value: unknown) => Promise, + * }, + * now?: () => number, + * log?: (...args: unknown[]) => void, + * }} deps + * busy is "peerd is doing work" (live turns AND goal runs between turns); + * surfacesOpen is "a user-facing extension page exists" (UI ports, portless + * engine tabs holding VM/notebook state, any other extension page). Both + * must be false before runtime.reload() may fire. + * notify returns whether the note was actually DELIVERED to a live UI + * surface (false when none is connected) - delivery gates every + * notify-once marker so a note nobody saw can post again later. + * sessionKv is per-BROWSER-session storage (storage.session): the throttle + * and the pending notice must survive event-page/SW respawns but reset + * with the browser, matching "check when you start the browser". + */ +export const makeUpdateCheck = ({ + runtime, fetchFn, ready, isEnabled, busy, surfacesOpen, notify, sessionKv, + now = () => Date.now(), + log = () => {}, +}) => { + /** @type {string | null} a downloaded update waiting for a quiet moment */ + let pendingDownloadedVersion = null; + /** @type {string | null} downloaded-update note DELIVERED (per SW lifetime) */ + let downloadNotedVersion = null; + /** @type {Promise | null} coalesces concurrent checkNow calls */ + let inFlightCheck = null; + + /** @returns {Promise} */ + const sessionState = async () => { + const state = await sessionKv.get(UPDATE_CHECK_SESSION_KEY); + return (state && typeof state === 'object') ? state : {}; + }; + + // Serialize every read-modify-write of the session record - checkNow and + // the notice replay can run concurrently (boot vs port connect), and an + // unserialized read-then-write would double-post the notice. + /** @type {Promise} */ + let sessionChain = Promise.resolve(); + /** @param {(state: UpdateSessionState) => Promise | UpdateSessionState | null} mutate */ + const withSession = (mutate) => { + const run = sessionChain.then(async () => { + const state = await sessionState(); + const next = await mutate(state); + if (next) await sessionKv.set(UPDATE_CHECK_SESSION_KEY, next); + }); + sessionChain = run.catch(() => {}); + return run; + }; + + // Post the persisted "update available" notice (the Firefox feed path), at + // most once per version per browser session - counting only deliveries a + // UI surface received. Persisted, not module state: the Firefox event page + // suspends when idle, and a notice held in memory would die with it while + // the throttle survived in storage.session. + const postPendingNotice = () => withSession((state) => { + const notice = state.pendingNotice; + if (!notice || state.notifiedVersion === notice.version) return null; + if (!notify(notice.text, { kind: 'open-url', label: 'Install update', url: notice.url })) return null; + return { ...state, notifiedVersion: notice.version }; + }); + + // A downloaded update is parked; apply it only when nothing is live. + // Reloading restarts the whole extension - open panels close, engine tabs + // (a running WebVM, a notebook mid-compute) are destroyed, the vault + // re-locks - so every surface and every unit of work blocks it. + const maybeApplyPendingDownload = async () => { + const version = pendingDownloadedVersion; + if (!version || !isEnabled()) return; + if (busy() || await surfacesOpen()) { + if (downloadNotedVersion !== version + && notify(`peerd v${version} is downloaded - it installs when peerd goes quiet or the browser restarts.`)) { + downloadNotedVersion = version; + } + return; + } + log('[update] applying downloaded update', version); + runtime.reload(); + }; + + // The browser downloaded an update (our request or its own poll). + const onUpdateDownloaded = async (/** @type {string} */ version) => { + await ready; // stored settings may say OFF; never act on the channel default + if (!isEnabled()) { + // Firefox defers a downloaded update whenever ANY onUpdateAvailable + // listener exists, so "disabled" must restore the no-listener default + // there: apply immediately, live work and all - exactly what Firefox + // does on its own. Chrome's no-listener default (install at the next + // SW unload / browser restart) is preserved by doing nothing. + if (typeof runtime.requestUpdateCheck !== 'function') runtime.reload(); + return; + } + pendingDownloadedVersion = version; + await maybeApplyPendingDownload(); + }; + + /** @returns {Promise} completed (throttle may start) or failed */ + const checkChrome = async () => { + if (typeof runtime.requestUpdateCheck !== 'function') return false; + let result; + try { result = await runtime.requestUpdateCheck(); } + catch (error) { log('[update] requestUpdateCheck failed', error); return false; } + // The polyfill resolves Chrome's two-arg callback as [status, details]; + // a native promise resolves { status, version }. Normalize for the log - + // behavior needs nothing more: a found update downloads in the background + // and fires onUpdateAvailable, which onUpdateDownloaded handles. + const status = typeof result === 'string' ? result + : Array.isArray(result) ? result[0] + : (result && typeof result === 'object' && 'status' in result + ? /** @type {{ status?: unknown }} */ (result).status : undefined); + log('[update] requestUpdateCheck:', status); + return true; + }; + + /** @returns {Promise} completed (throttle may start) or failed */ + const checkGeckoFeed = async ( + /** @type {UpdateManifest} */ manifest, + /** @type {{ id?: string, update_url?: string }} */ gecko, + ) => { + if (!gecko.update_url || !gecko.id) return false; + let feed; + try { + const response = await fetchFn(gecko.update_url, { cache: 'no-store', credentials: 'omit' }); + if (!response.ok) { log('[update] feed fetch failed', response.status); return false; } + feed = await response.json(); + } catch (error) { log('[update] feed fetch failed', error); return false; } + const latest = latestGeckoUpdate(feed, gecko.id); + if (!latest || compareVersions(latest.version, manifest.version) <= 0) return true; + await withSession((state) => ({ + ...state, + pendingNotice: { + version: latest.version, + text: `peerd v${latest.version} is available (you have v${manifest.version}). ` + + 'Firefox installs preview updates on its own daily check, or install it now.', + url: latest.updateLink, + }, + })); + await postPendingNotice(); + return true; + }; + + const runCheck = async (/** @type {string} */ reason) => { + await ready; + if (!isEnabled()) return; + const manifest = runtime.getManifest(); + const gecko = manifest.browser_specific_settings?.gecko; + const chromePath = Boolean(manifest.update_url) && typeof runtime.requestUpdateCheck === 'function'; + if (!chromePath && !gecko?.update_url) return; + const state = await sessionState(); + if (typeof state.lastCheckAt === 'number' && now() - state.lastCheckAt < MIN_CHECK_INTERVAL_MS) return; + log('[update] checking for a newer build -', reason); + const completed = chromePath + ? await checkChrome() + : await checkGeckoFeed(manifest, /** @type {{ id?: string, update_url?: string }} */ (gecko)); + if (completed) await withSession((latest) => ({ ...latest, lastCheckAt: now() })); + }; + + /** + * Ask for a newer build now. Coalesced (boot and a panel connect race on a + * cold start) and throttled. Chrome preview manifests carry a top-level + * update_url + the requestUpdateCheck API; Firefox preview carries only + * the gecko feed URL; dev/store manifests carry neither. + * @param {string} reason + */ + const checkNow = (reason) => { + if (!inFlightCheck) { + inFlightCheck = runCheck(reason) + .catch((error) => { log('[update] check failed', error); }) + .finally(() => { inFlightCheck = null; }); + } + return inFlightCheck; + }; + + return { + /** + * Register the update-downloaded listener - ONLY on a self-hosted + * (preview) manifest, and synchronously at SW boot (a downloaded update + * can be the very event that wakes the worker). Store/dev packages must + * not register: on Firefox a listener's mere presence defers every + * add-on update until reload()/browser restart. + */ + start() { + const manifest = runtime.getManifest(); + const selfHosted = (Boolean(manifest.update_url) && typeof runtime.requestUpdateCheck === 'function') + || Boolean(manifest.browser_specific_settings?.gecko?.update_url); + if (!selfHosted) return; + runtime.onUpdateAvailable?.addListener((details) => { + void onUpdateDownloaded(details?.version ?? '').catch(() => {}); + }); + }, + + checkNow, + + /** + * A UI surface connected: replay an undelivered "update available" + * notice to it, then re-check (the throttle keeps this cheap). + */ + onUiConnect() { + void postPendingNotice() + .then(() => checkNow('ui-connect')) + .catch(() => {}); + }, + + /** + * A UI surface disconnected: a parked downloaded update may now be able + * to apply (the offscreen keepalive means Chrome's own "install when + * idle" moment never comes on its own). + */ + onQuiet() { + void ready.then(() => maybeApplyPendingDownload()).catch(() => {}); + }, + }; +}; diff --git a/extension/home/home.js b/extension/home/home.js index bdde3e8b..a98a7cdc 100644 --- a/extension/home/home.js +++ b/extension/home/home.js @@ -150,7 +150,10 @@ const handlePortMessage = (msg) => { // panel opens, hand Chat + Chats to it and drop to a tool view; when it closes, // take chat back to where we were. if (msg.type === 'surfaces') { applySidePanelOpen(!!msg.sidePanelOpen); return; } - const next = reduceChat(currentState, msg); + // §4e: tag which surface this fold is FOR, so the answering surface doesn't + // transcript-line its own click (mirrors the side panel). + const folded = msg.type === 'confirm/resolved' ? { ...msg, confirmSurface: 'home' } : msg; + const next = reduceChat(currentState, folded); if (next === currentState) return; currentState = next; if (msg.type === 'state') { booted = true; seedDwebApps(); } @@ -194,7 +197,7 @@ const loadActor = (sessionId) => { * @param {string} answer */ const confirmAnswer = (id, answer) => { - send({ type: 'confirm/answer', id, answer }); + send({ type: 'confirm/answer', id, answer, surface: 'home' }); currentState = { ...currentState, pendingConfirm: null }; m.redraw(); }; @@ -241,7 +244,17 @@ const openAgentTab = (tabId, windowId) => { // why no dismiss: the agent-tab card PERSISTS after click — it tracks the live // agent tab so you can jump back any time; it clears itself when the tab closes. }; -const uiActions = { loadActor, confirmAnswer, dismissNotice, requestDebugger, openAgentTab }; +// Same §4c prefill contract as the side panel: a card action types the user's +// likely next message into the composer and never sends it. +let prefillNonce = 0; +/** @param {string} text */ +const prefillComposer = (text) => { + if (typeof text !== 'string' || !text.trim()) return; + prefillNonce += 1; + currentState = { ...currentState, composerPrefill: { text, nonce: prefillNonce } }; + m.redraw(); +}; +const uiActions = { loadActor, confirmAnswer, dismissNotice, requestDebugger, openAgentTab, prefillComposer }; // Cache this tab's own window id at boot so "Pop to side" can pass a REAL // windowId synchronously inside the click gesture — sidePanel.open() rejects diff --git a/extension/options/components/options-app.js b/extension/options/components/options-app.js index 07b108af..5ef01910 100644 --- a/extension/options/components/options-app.js +++ b/extension/options/components/options-app.js @@ -208,6 +208,9 @@ export const OptionsApp = { const navItem = ([id, label]) => m('a.options-nav-item', { href: `#!/${id}`, class: section === id ? 'is-active' : '', + // why aria-current: the class alone marks the active entry visually; + // a screen reader needs the programmatic current-page state. + 'aria-current': section === id ? 'page' : undefined, }, [ label, // Memory carries the pending-suggestions badge so proposals are diff --git a/extension/options/components/settings-row.js b/extension/options/components/settings-row.js index b8250f95..632fb38d 100644 --- a/extension/options/components/settings-row.js +++ b/extension/options/components/settings-row.js @@ -47,6 +47,7 @@ export const toggleSwitch = ({ on, busy = false, disabled = false, label, onclic * One settings row. * * @param {{ + * id?: string | null, * label: string, * summary: string, * pill?: string | null, @@ -57,6 +58,9 @@ export const toggleSwitch = ({ on, busy = false, disabled = false, label, onclic * onToggleWhy?: () => void, * children?: any, * }} p + * id - wires the disclosure: the "Why this matters" button declares + * aria-controls on the rationale region it expands. Absent = the button + * still carries aria-expanded, but controls nothing nameable. * summary — present tense, describing the CURRENT state. Not a description of * the setting: "peerd clicks and types without asking" tells you where you * stand; "controls whether peerd asks" does not. @@ -66,7 +70,7 @@ export const toggleSwitch = ({ on, busy = false, disabled = false, label, onclic * and MUST NOT discard their values. */ export const settingsRow = ({ - label, summary, pill = null, badge = null, control = null, + id = null, label, summary, pill = null, badge = null, control = null, why = null, open = false, onToggleWhy, children = null, }) => m('.set-row', [ m('.set-row-main', [ @@ -81,6 +85,7 @@ export const settingsRow = ({ ? m('button.set-why', { type: 'button', 'aria-expanded': open ? 'true' : 'false', + 'aria-controls': id ? `${id}-why` : undefined, onclick: onToggleWhy, }, [ m('span.set-why-chev', { class: open ? 'is-open' : '', 'aria-hidden': 'true' }, '›'), @@ -90,7 +95,7 @@ export const settingsRow = ({ ]), control ? m('.set-row-control', control) : null, ]), - why && open ? m('.set-why-body', why) : null, + why && open ? m('.set-why-body', { id: id ? `${id}-why` : undefined }, why) : null, children ? m('.set-row-children', children) : null, ]); diff --git a/extension/options/sections/behavior.js b/extension/options/sections/behavior.js index 77e2762f..f7ad0896 100644 --- a/extension/options/sections/behavior.js +++ b/extension/options/sections/behavior.js @@ -47,6 +47,7 @@ export const BehaviorSection = { const toggleRow = ({ id, label, on, busyKey, summary, why, apply, badge = null, children = null }) => { const busy = !!ui[busyKey]; return settingsRow({ + id, label, pill: busy ? '…' : on ? 'ON' : 'OFF', badge, @@ -95,6 +96,7 @@ export const BehaviorSection = { }), settingsRow({ + id: 'web-writes', label: 'Confirm before sending data out', pill: ui.webWriteBusy ? '…' : webWritesOn ? 'ON' : 'OFF', summary: ui.webWriteBusy ? 'Saving…' : webWritesOn @@ -163,6 +165,7 @@ export const BehaviorSection = { apply: () => send({ type: 'settings/update', patch: { advancedAutomationEnabled: !aaOn } }), }) : settingsRow({ + id: 'advanced-automation', label: 'Advanced automation', pill: 'N/A', summary: 'Not available in this browser — Firefox has no Chrome debugger API.', @@ -188,8 +191,16 @@ export const BehaviorSection = { const fallbacks = Array.isArray(s.providerFallbacks) ? s.providerFallbacks : []; const otherProviders = listProviders().map((p) => p.name).filter((n) => n !== activeProvider); + // Preview-only key: absent from store packages' CHANNEL_DEFAULTS, so the + // row simply doesn't render there. Presence, not typeof: a crafted + // transfer import can store a non-boolean verbatim, and that must not + // hide the row on a preview build (the toggle itself heals the value). + const autoUpdateAvailable = Object.hasOwn(s, 'autoUpdateEnabled'); + const auOn = s.autoUpdateEnabled === true; + const behaviorRows = [ settingsRow({ + id: 'front-door', label: 'Toolbar button', summary: frontDoor === 'panel' ? 'Opens the chat in the side panel, next to the page you’re on.' @@ -262,6 +273,21 @@ export const BehaviorSection = { apply: () => send({ type: 'settings/update', patch: { autoResumeInterruptedTurns: !arOn } }), }), + ...(autoUpdateAvailable ? [toggleRow({ + id: 'auto-update', + label: 'Auto-update', + on: auOn, + busyKey: 'autoUpdateBusy', + badge: 'PREVIEW', + summary: auOn + ? 'Checks for a new preview build at startup and installs it when nothing is running.' + : 'Preview updates wait for the browser’s own periodic check.', + why: auOn + ? 'On. At startup peerd asks the browser for a newer preview build from the release feed. In Chrome the update installs immediately when no chat is mid-turn and no peerd surface is open; otherwise it applies on the next browser restart. In Firefox, which gives extensions no way to trigger their own update, peerd shows an “update available” notice with an install link, and Firefox’s own daily add-on check still installs it on its own.' + : 'Off. peerd stays on the installed build until the browser’s own periodic update check picks up a new preview release. Turn this on to check at startup and install right away.', + apply: () => send({ type: 'settings/update', patch: { autoUpdateEnabled: !auOn } }), + })] : []), + toggleRow({ id: 'failover', label: 'Provider failover', @@ -369,6 +395,7 @@ export const BehaviorSection = { }), settingsRow({ + id: 'surface', label: 'Web actor: action surface', badge: 'MODE', summary: surface === 'code' @@ -419,7 +446,8 @@ export const BehaviorSection = { 'frontDoorView', 'reasoningEnabled', 'reasoningEffort', 'confirmWebWrites', 'schemaValidatedReplies', 'advancedAutomationEnabled', 'devMode', - 'autoResumeInterruptedTurns', 'providerFailoverEnabled', 'providerFallbacks', + 'autoResumeInterruptedTurns', 'autoUpdateEnabled', + 'providerFailoverEnabled', 'providerFallbacks', 'prewalkEnabled', 'enginePrewalkEnabled', 'prewalkExecutorModel', ]), ]); diff --git a/extension/peerd-egress/confirm/protocol.js b/extension/peerd-egress/confirm/protocol.js index 0e4e00f5..201ad42e 100644 --- a/extension/peerd-egress/confirm/protocol.js +++ b/extension/peerd-egress/confirm/protocol.js @@ -20,11 +20,12 @@ // user knows the agent is waiting on them (onPendingChange below). // // Session-scoped grants live in SW MEMORY (service-worker.js -// sessionConfirmGrants): sessionId → the set of tool NAMES the user +// sessionConfirmGrants): sessionId → the set of GRANT KEYS the user // blanket-approved for that chat. They die with the SW (same blast -// radius as the vault DK), and they are origin-blind — "yes for this -// session" on `click` approves `click` everywhere for that chat. A -// persistent, origin-scoped `tool_grants` store is a documented +// radius as the vault DK). Since R5 the key is origin-bound when the +// prompt carries an origin - `tool|origin` - and the bare tool name +// only for tools with no origin surface (confirm-grant-key.js is the +// authority). A persistent `tool_grants` store is a documented // follow-up (TODO.md). import { uuidv7 } from '/shared/util.js'; @@ -32,6 +33,22 @@ import { uuidv7 } from '/shared/util.js'; /** @typedef {import('/shared/tool-types.js').ConfirmPrompt} ConfirmPrompt */ /** @typedef {import('/shared/tool-types.js').ConfirmAnswer} ConfirmAnswer */ +/** + * How and why a prompt settled. + * @typedef {Object} ConfirmOutcome + * @property {ConfirmAnswer} answer + * @property {'answer'|'timeout'|'abort'|'stop'} cause + * 'answer' - a surface posted confirm/answer; 'timeout' - the open-but- + * unanswered deadline hit; 'abort' - the requesting operation's signal fired + * (Stop / steer-live); 'stop' - declineSession()/reset() settled it. + * @property {string|null} via the surface that answered ('sidepanel'|'home'), + * null for every self-settle. + * @property {string|null} sessionId the CHAT whose transcript renders the + * settle line - prompt.chatSessionId when the SW mapped an actor-raised + * prompt to its root chat, else prompt.sessionId. (declineSession keeps + * matching on prompt.sessionId - the actor's own turn is what aborts.) + */ + /** * Build a confirm coordinator. The SW creates exactly one of these and * passes its `confirm` function into every ToolContext. @@ -50,10 +67,13 @@ import { uuidv7 } from '/shared/util.js'; * @param {(pendingCount: number) => void} [deps.onPendingChange] * Called whenever the pending-prompt count changes, so the SW can raise/clear * an action badge ("the agent is waiting on you"). Best-effort. - * @param {(id: string) => void} [deps.onSettled] - * Called with a prompt's id whenever it settles for ANY reason — user answer, - * timeout auto-deny, or reset(). The SW broadcasts 'confirm/resolved' so every - * open surface dismisses the modal, not just the one that answered (DESIGN-12). + * @param {(id: string, outcome: ConfirmOutcome) => void} [deps.onSettled] + * Called whenever a prompt settles for ANY reason - user answer, timeout + * auto-deny, abort, or a session decline. The SW broadcasts 'confirm/resolved' + * so every open surface dismisses the modal, not just the one that answered + * (DESIGN-12). why the outcome rides along: three of the four self-settles are + * invisible today - the transcript must be able to say WHY a confirm ended + * without anyone clicking (UI redesign §4e). */ export const makeConfirmCoordinator = ({ notifySidePanel, @@ -62,7 +82,7 @@ export const makeConfirmCoordinator = ({ onPendingChange = () => {}, onSettled = () => {}, }) => { - /** @type {Map void, prompt: ConfirmPrompt }>} */ + /** @type {Map void, prompt: ConfirmPrompt }>} */ const pending = new Map(); /** @type {Map>} */ const timers = new Map(); @@ -75,11 +95,13 @@ export const makeConfirmCoordinator = ({ * * @param {string} id * @param {ConfirmAnswer} answer + * @param {string|null} [via] which surface answered ('sidepanel'|'home') - + * rides the settle outcome so the OTHER surface can say who decided. */ - const resolve = (id, answer) => { + const resolve = (id, answer, via = null) => { const entry = pending.get(id); if (!entry) return; // stale answer (e.g. duplicate / already timed out) — drop silently - entry.settle(answer); + entry.settle(answer, 'answer', via); }; /** @@ -99,25 +121,40 @@ export const makeConfirmCoordinator = ({ if (!isChannelOpen()) { res('no'); return; } const id = uuidv7(); - const prompt = { ...promptInput, id }; + // raisedAt: lets a surface time UI against the auto-deny deadline (the 90s + // "No answer counts as Reject" hint) even when it joined late via replay. + const prompt = { ...promptInput, id, raisedAt: Date.now() }; /** @type {(() => void) | undefined} */ let onAbort; - /** @param {ConfirmAnswer} answer */ - const settle = (answer) => { + /** + * @param {ConfirmAnswer} answer + * @param {ConfirmOutcome['cause']} [cause] + * @param {string|null} [via] + */ + const settle = (answer, cause = 'stop', via = null) => { const t = timers.get(id); if (t) clearTimeout(t); timers.delete(id); if (onAbort) signal?.removeEventListener('abort', onAbort); // onSettled inside the delete-guard → fires EXACTLY once, on the first // settle (answer or timeout), so every surface dismisses the modal. - if (pending.delete(id)) { res(answer); notifyCount(); try { onSettled(id); } catch { /* best-effort */ } } + if (pending.delete(id)) { + res(answer); + notifyCount(); + try { + onSettled(id, { + answer, cause, via, + sessionId: /** @type {{ chatSessionId?: string }} */ (prompt).chatSessionId ?? prompt.sessionId ?? null, + }); + } catch { /* best-effort */ } + } }; pending.set(id, { settle, prompt }); - timers.set(id, setTimeout(() => settle('no'), timeoutMs)); + timers.set(id, setTimeout(() => settle('no', 'timeout'), timeoutMs)); // Install the listener before exposing the prompt. The second aborted check // closes the race between the preflight above and addEventListener(). if (signal) { - onAbort = () => settle('no'); + onAbort = () => settle('no', 'abort'); signal.addEventListener('abort', onAbort, { once: true }); - if (signal.aborted) { settle('no'); return; } + if (signal.aborted) { settle('no', 'abort'); return; } } notifyCount(); notifySidePanel(prompt); @@ -127,7 +164,7 @@ export const makeConfirmCoordinator = ({ * timer, resolves the blocked caller, and dismisses every open modal. */ const reset = () => { // snapshot: settle() mutates both maps while resolving each promise. - for (const { settle } of [...pending.values()]) settle('no'); + for (const { settle } of [...pending.values()]) settle('no', 'stop'); }; /** @@ -147,7 +184,7 @@ export const makeConfirmCoordinator = ({ if (sessionId == null) return; // snapshot: settle() mutates `pending` as it resolves each promise. for (const { settle, prompt } of [...pending.values()]) { - if (prompt.sessionId === sessionId) settle('no'); + if (prompt.sessionId === sessionId) settle('no', 'stop'); } }; diff --git a/extension/peerd-runtime/actor/actor-messaging.js b/extension/peerd-runtime/actor/actor-messaging.js index 82d7bc5a..ddfec830 100644 --- a/extension/peerd-runtime/actor/actor-messaging.js +++ b/extension/peerd-runtime/actor/actor-messaging.js @@ -118,7 +118,7 @@ const SCHEMA_VALIDATED_KINDS = new Set(['web', 'api']); * chat that sent this message — the chat-scoped WEB actor (to:'web') is owned by it, * so it must be threaded (not re-derived from the ambient active chat, which is wrong * on a boot redrain). Engine/per-tab kinds ignore it (globally/tab keyed). - * @param {(opts: { actorSessionId: string, message: string, actorTabId?: number, instanceId: string, kind: string, correlationId: string, parentToolUseId?: string, parentSessionId: string, rootSessionId: string, name?: string, oneShot?: boolean, turnLease?: { controller: AbortController, release: () => void } }) => Promise<{ result: string, stopped?: boolean, executionFailed?: boolean, outcomeKnown?: boolean }>} deps.runActorTurn + * @param {(opts: { actorSessionId: string, message: string, actorTabId?: number, instanceId: string, kind: string, correlationId: string, parentToolUseId?: string, parentSessionId: string, rootSessionId: string, name?: string, oneShot?: boolean, turnLease?: { controller: AbortController, release: () => void } }) => Promise<{ result: string, stopped?: boolean, executionFailed?: boolean, outcomeKnown?: boolean, landingStop?: object|null }>} deps.runActorTurn * Drive ONE actor turn (runAgentTurn against the actor session) and * resolve with its final assistant text. correlationId is the durable mailbox * identity; parentToolUseId keys the actor's live DISPLAY stream to its card. @@ -374,8 +374,8 @@ export const makeActorMessaging = (deps) => { // Build the one envelope used by live delivery and passive restart recovery. // Only the locally composed lead is trusted. The body remains fenced even for // fixed recovery copy, so the model-facing shape never depends on its source. - /** @param {string} instanceId @param {string} kind @param {string|undefined} name @param {string} body @param {boolean} failed @param {string|undefined} via @param {boolean} outcomeUnknown @param {boolean|undefined} performed @param {string|undefined} actorDeliveryId @param {string|undefined} parentToolUseId */ - const deliveryEnvelope = (instanceId, kind, name, body, failed, via, outcomeUnknown, performed, actorDeliveryId = undefined, parentToolUseId = undefined) => { + /** @param {string} instanceId @param {string} kind @param {string|undefined} name @param {string} body @param {boolean} failed @param {string|undefined} via @param {boolean} outcomeUnknown @param {boolean|undefined} performed @param {string|undefined} actorDeliveryId @param {string|undefined} parentToolUseId @param {object|null} [landingStop] */ + const deliveryEnvelope = (instanceId, kind, name, body, failed, via, outcomeUnknown, performed, actorDeliveryId = undefined, parentToolUseId = undefined, landingStop = null) => { const userText = replyText(instanceId, kind, name, body, failed, outcomeUnknown, performed); const safeName = name ? escapeAttr(name.replace(/\s+/g, ' ').trim().slice(0, 80)) : undefined; const safeParentToolUseId = typeof parentToolUseId === 'string' && parentToolUseId.length <= 512 @@ -390,6 +390,10 @@ export const makeActorMessaging = (deps) => { ...(via ? { via } : {}), ...(actorDeliveryId ? { actorDeliveryId } : {}), ...(safeParentToolUseId ? { parentToolUseId: safeParentToolUseId } : {}), + // §4c: the origin-lock stop, shaped for the transcript card. Authored + // entirely by origin-lock-report.js (never the actor) - the same rule + // that lets the trusted lead above sit outside the fence. + ...(landingStop ? { landingStop } : {}), }, }; }; @@ -399,8 +403,8 @@ export const makeActorMessaging = (deps) => { // user's live turn (the focus/work-theft bug, DECISIONS #20). Only the one-line // lead is trusted; the actor's body is fenced (mandatory for App actors, // which render attacker content). - /** @param {string} senderSessionId @param {string} instanceId @param {string} kind @param {string|undefined} name @param {string} body @param {boolean} [failed] @param {string} [via] @param {boolean} [outcomeUnknown] @param {boolean} [performed] @param {string} [actorDeliveryId] @param {string} [parentToolUseId] @param {() => boolean} [shouldSkip] @param {() => Promise} [onSkip] @returns {Promise} */ - const deliver = (senderSessionId, instanceId, kind, name, body, failed = false, via = undefined, outcomeUnknown = false, performed = undefined, actorDeliveryId = undefined, parentToolUseId = undefined, shouldSkip = () => false, onSkip = async () => {}) => { + /** @param {string} senderSessionId @param {string} instanceId @param {string} kind @param {string|undefined} name @param {string} body @param {boolean} [failed] @param {string} [via] @param {boolean} [outcomeUnknown] @param {boolean} [performed] @param {string} [actorDeliveryId] @param {string} [parentToolUseId] @param {() => boolean} [shouldSkip] @param {() => Promise} [onSkip] @param {object|null} [landingStop] @returns {Promise} */ + const deliver = (senderSessionId, instanceId, kind, name, body, failed = false, via = undefined, outcomeUnknown = false, performed = undefined, actorDeliveryId = undefined, parentToolUseId = undefined, shouldSkip = () => false, onSkip = async () => {}, landingStop = null) => { // actorReply rides the wake so the UI can render the reply as its OWN // attributed chat bubble at the bottom (not buried in the tool-call card). // `synthetic` alone can't carry this — it also marks truncation/resume @@ -409,7 +413,7 @@ export const makeActorMessaging = (deps) => { // attributes a mediated delegation if a future async code surface routes // its reply here; without that, a late bubble would be unexplainable. const { userText, actorReply } = deliveryEnvelope( - instanceId, kind, name, body, failed, via, outcomeUnknown, performed, actorDeliveryId, parentToolUseId, + instanceId, kind, name, body, failed, via, outcomeUnknown, performed, actorDeliveryId, parentToolUseId, landingStop, ); return new Promise((resolve) => { try { @@ -524,8 +528,8 @@ export const makeActorMessaging = (deps) => { // a false "did not match format" reject. The schema's own field caps // (reply-schema.js) are the size bound for the validated path; the free-form // and error paths keep the RESULT_CHARS clamp on the way out. - /** @param {string} rawBody @param {boolean} failed @param {boolean} [outcomeUnknown] */ - const settle = (rawBody, failed, outcomeUnknown = false) => { + /** @param {string} rawBody @param {boolean} failed @param {boolean} [outcomeUnknown] @param {object|null} [landingStop] */ + const settle = (rawBody, failed, outcomeUnknown = false, landingStop = null) => { let outBody = rawBody; let outFailed = failed; // #241 — the deterministic schema boundary. An untrusted actor (web/api) @@ -584,6 +588,7 @@ export const makeActorMessaging = (deps) => { outcomeUnknown, undefined, correlationId, parentToolUseId, () => (stopGen.get(rootSessionId) ?? 0) !== genAtQueue, removeMailbox, + landingStop, ).then((committedOrCancelled) => { if (committedOrCancelled) clearPendingReply(rootSessionId); }); @@ -653,6 +658,7 @@ export const makeActorMessaging = (deps) => { res?.stopped === true, res?.outcomeKnown === false || (res?.executionFailed === true && res?.outcomeKnown !== true), + res?.landingStop ?? null, ); }) .catch((e) => settle( diff --git a/extension/peerd-runtime/actor/origin-lock-report.js b/extension/peerd-runtime/actor/origin-lock-report.js index ff9c2336..91fb5fa6 100644 --- a/extension/peerd-runtime/actor/origin-lock-report.js +++ b/extension/peerd-runtime/actor/origin-lock-report.js @@ -236,3 +236,112 @@ export const describeLandingStop = (event) => { : []), ].join('\n'); }; + +// ── The transcript card model (UI redesign §4c) ────────────────────────────── +// +// The SAME event, shaped for the side panel: the prose above is the +// orchestrator's copy and is unchanged; this is the shorter, slotted rendering +// the transcript shows the USER. Kept here - not in the panel - because every +// string that crosses out of a stopped actor's world must be authored in this +// file, under this file's rule: nothing here is written by the actor, the +// page, or the model. + +// The user-register version of `unknownWork` (§3g): it must OCCUPY the slot a +// step list would have wanted, not trail as a caveat. +const CARD_UNKNOWN = 'How far it got. It stopped at a boundary and doesn’t ' + + 'trust its own account of what happened before that - so check before ' + + 'repeating anything that would act twice.'; + +const SIGN_IN_REASONS = new Set([ + ...AUTH_STOP_EXPLANATIONS.keys(), + 'this is a sign-in service that helpers may only visit while signing in to another site', +]); +const NO_ADDRESS_REASONS = new Set([ + 'this page has no address peerd can pin work to', + 'the tab moved to a page peerd cannot verify, so this task was stopped', +]); +const LEFT_SITE_REASON = 'this helper works only on one site, and the tab left it'; +const INTERNAL_REASON = 'this helper is in an unknown state, so it was stopped'; + +/** + * @typedef {object} LandingStopCard + * @property {'HANDOFF'|'SIGN-IN'|'LEFT SITE'|'NO ADDRESS'|'INTERNAL'|null} group + * @property {string} headline composed here; may embed origins, never paths + * @property {string} reason the landing rule's verbatim one-liner + * @property {string} whatIsNotKnown + * @property {'notice'|'error'} tone INTERNAL is a genuine bug and must not + * look routine - it alone takes the error treatment (§4b) + * @property {{ label: string, composerText: string } | null} action fills the + * composer with the user's likely next message; grants nothing, calls no + * tool, and is absent wherever there is no honest next step to offer + */ + +/** + * Shape a landing-stop event into the transcript card. + * + * @param {LandingStopEvent} event + * @returns {LandingStopCard} + */ +export const landingStopCard = (event) => { + const { action, reason, from, to, handoffTo } = event ?? /** @type {any} */ ({}); + const landed = originPhrase(action === 'handoff' ? (handoffTo ?? to) : to); + const owned = from ? originPhrase(from) : null; + + if (action === 'handoff') { + return { + group: 'HANDOFF', + headline: `The web helper was stopped when the tab arrived at ${landed}`, + reason: reason || '', + whatIsNotKnown: CARD_UNKNOWN, + tone: 'notice', + // The cheap route the handoff report already recommends first: a + // sessionless read needs no authority at all. + action: { label: 'Try reading it without signing in', composerText: `Try reading ${landed} without signing in.` }, + }; + } + if (SIGN_IN_REASONS.has(reason)) { + return { + group: 'SIGN-IN', + headline: owned + ? `The web helper was signing in for ${owned} when the tab arrived at ${landed}` + : `The web helper stopped during sign-in when the tab arrived at ${landed}`, + reason: reason || '', + whatIsNotKnown: CARD_UNKNOWN, + tone: 'notice', + // No action on any sign-in stop: the honest next step is the user's own + // hands (finish the sign-in themselves), not a message we can type. + action: null, + }; + } + if (reason === INTERNAL_REASON) { + return { + group: 'INTERNAL', + headline: 'The web helper was stopped.', + reason, + whatIsNotKnown: CARD_UNKNOWN, + tone: 'error', + action: null, + }; + } + const group = NO_ADDRESS_REASONS.has(reason) ? 'NO ADDRESS' + : reason === LEFT_SITE_REASON ? 'LEFT SITE' + : null; + return { + group, + headline: owned + ? `The web helper was working on ${owned} and the tab moved to ${landed}` + : `The web helper stopped: the tab is now on ${landed}`, + reason: reason || '', + whatIsNotKnown: CARD_UNKNOWN, + tone: 'notice', + // NO action on any generic stop - deliberately, against the design mock's + // "Continue on " button. The landing is the one address a hostile + // page fully controls, and a button here turns a page-chosen destination + // into a one-click trusted user instruction - the exact laundering this + // file's report already forbids ("use a different destination only when it + // comes from the user's request"). The report's conditional message_actor + // line survives for the orchestrator; the card offers nothing. Only the + // handoff keeps an action, because a sessionless read spends no authority. + action: null, + }; +}; diff --git a/extension/peerd-runtime/index.js b/extension/peerd-runtime/index.js index 1900f804..8cfa1c05 100644 --- a/extension/peerd-runtime/index.js +++ b/extension/peerd-runtime/index.js @@ -239,7 +239,7 @@ export { makeLearnedOrigins, MAX_LEARNED } from './actor/learned-origins.js'; // made its content attacker-authorable in the first place. export { isUgcHost } from './actor/ugc-registry.js'; export { isKnownIdp, isKnownIdpHost, knownIdpSeeds, knownIdpDomains } from './actor/idp-registry.js'; -export { describeLandingStop, originPhrase } from './actor/origin-lock-report.js'; +export { describeLandingStop, landingStopCard, originPhrase } from './actor/origin-lock-report.js'; // DESIGN-19: site clients — per-origin derived API clients. The pure core // (validation, confirm-gated proposal, staleness header, fenced dossier, URL pin), // the two-tier store, and the capture digester. See site-clients/index.js. diff --git a/extension/shared/channel-config.js b/extension/shared/channel-config.js index 5769efdf..a7071fac 100644 --- a/extension/shared/channel-config.js +++ b/extension/shared/channel-config.js @@ -48,6 +48,7 @@ export const CHANNEL_DEFAULTS = Object.freeze({ providerFallbacks: [], vaultAutoLockMs: 2700000, auditLogMaxEntries: 20000, + autoUpdateEnabled: true, dwebEnabled: true, dwebAgentEnabled: false, }); diff --git a/extension/sidepanel/chat-reducer.js b/extension/sidepanel/chat-reducer.js index 1d12ee10..3f898afc 100644 --- a/extension/sidepanel/chat-reducer.js +++ b/extension/sidepanel/chat-reducer.js @@ -99,6 +99,8 @@ * @property {ReadonlyArray<{ id: number, text?: string, action?: any }>} notices * @property {any} agentTab * @property {ReadonlyArray} agentTabEvents + * @property {ReadonlyArray<{ id: string, sessionId: string|null, text: string, at: number }>} confirmEvents + * @property {{ text: string, nonce: number } | null} [composerPrefill] * @property {Readonly>} vmStreams * @property {{ byToolUse: Record, sessions: Record }} spawned * @property {Readonly>} actors @@ -187,6 +189,14 @@ export const INITIAL_STATE = Object.freeze({ // continues (DECISIONS #26 / the owner's call — replaces the old bright sticky // card). Each: { key, sessionId, tabId, windowId, kind, name, label, anchorId }. agentTabEvents: Object.freeze([]), + // Self-settled / other-surface confirm outcomes as quiet transcript rows + // (§4e) - [{ id, sessionId, text, at }]. Live entries fold in from + // confirm/resolved outcomes; entries that happened while no surface was open + // arrive via the snapshot's confirmSettleNotes. Deduped by prompt id. + confirmEvents: Object.freeze([]), + // A card action's one-shot composer prefill (§4c) - { text, nonce } | null, + // surface-local (set by uiActions.prefillComposer, consumed by InputBar). + composerPrefill: null, // Streaming stdout/stderr per in-flight vm_boot, keyed by toolUseId. vmStreams: Object.freeze({}), // Actor transcripts for inline rendering under actor_create tool @@ -205,6 +215,37 @@ export const INITIAL_STATE = Object.freeze({ goalRuns: Object.freeze({}), }); +// One quiet sentence per confirm settle (§4e - the four undrawn states). The +// wording is fixed, not composed: these are the shipped strings the redesign +// specifies, one per way a prompt can end without this surface's click. +/** @param {{ cause?: string, answer?: string, via?: string|null }} outcome + * @returns {string|null} */ +const confirmSettleText = ({ cause, answer, via }) => { + if (cause === 'timeout') return 'Not approved - no answer in two minutes.'; + if (cause === 'unreachable') return 'Not approved - peerd wasn’t open to ask.'; + if (cause === 'stop' || cause === 'abort') return 'Not approved - you stopped the turn.'; + if (cause === 'answer') { + const verdict = answer === 'no' ? 'Not approved' + : answer === 'yes_session' ? 'Approved for this chat' : 'Approved once'; + return `${verdict}, from the ${via === 'home' ? 'home tab' : 'side panel'}.`; + } + return null; +}; + +// Append one settle line, deduped by prompt id (a live broadcast and a later +// snapshot replay must not double-report the same settle). Kept ordered by +// time - a snapshot can replay an OLD settle after newer live ones, and an +// out-of-order append would render it as the freshest row. The cap is well +// above the SW's per-session note cap so eviction here can't resurrect +// still-snapshotted notes as fresh. +/** + * @param {ReadonlyArray<{ id: string, sessionId: string|null, text: string, at: number }>} events + * @param {{ id: string, sessionId: string|null, text: string, at: number }} event + */ +const appendConfirmEvent = (events, event) => (events.some((e) => e.id === event.id) + ? events + : [...events, event].sort((a, b) => a.at - b.at).slice(-100)); + // The turn a tool_use belongs to: find the assistant message carrying it (by tool_use // id), then walk back to the nearest non-synthetic, non-toolResult-only user message — // that turn's starting message id. null if the tool_use isn't in view yet. Used to anchor @@ -604,7 +645,20 @@ export const reduceChat = (state, msg) => { ? reconcileSpawned(state.spawned, msg.state.spawned) : state.spawned, }; + // Confirm settles that happened while NO surface was open (timeout / + // stop / closed panel) arrive only here, as snapshot notes - fold them + // into the transcript lines, deduped against any live broadcasts seen. + const settleNotes = Array.isArray(msg.state?.confirmSettleNotes) ? msg.state.confirmSettleNotes : []; + const snapshotSid = msg.state?.session?.sessionId ?? null; + // Events keep their sessionId and render filtered by it, so a chat + // switch needs no pruning - the cap bounds growth. + let confirmEvents = state.confirmEvents; + for (const note of settleNotes) { + const text = confirmSettleText(note); + if (text) confirmEvents = appendConfirmEvent(confirmEvents, { id: note.id, sessionId: snapshotSid, text, at: note.at ?? Date.now() }); + } return { ...state, ...msg.state, ...pruneProjections, pendingConfirm: state.pendingConfirm, + confirmEvents, lastError: keepSpendError ? 'spend-limit-reached' : null, rateLimit: null, cost: { ...state.cost, session: msg.state?.session?.cost ?? state.cost.session, limitUsd: msg.state?.settings?.spendLimitUsd ?? state.cost.limitUsd, @@ -658,9 +712,32 @@ export const reduceChat = (state, msg) => { return { ...applyError(state, msg), rateLimit: null }; case 'confirm/request': return { ...state, pendingConfirm: msg.prompt }; - case 'confirm/resolved': - // Answered on another surface (DESIGN-12) — dismiss the same prompt. - return state.pendingConfirm?.id === msg.id ? { ...state, pendingConfirm: null } : state; + case 'confirm/resolved': { + // Dismiss the same prompt when displayed (DESIGN-12), and record the + // outcome line (§4e) whether or not THIS prompt was the one on screen - + // with many pending prompts a settle for an undisplayed one must still + // reach the transcript. The one suppression: the surface that ANSWERED + // (outcome.via === the surface this fold is for) - its own click is its + // own feedback. + const outcome = /** @type {{ cause?: string, answer?: string, via?: string|null, sessionId?: string|null } | undefined} */ (msg.outcome); + const mine = outcome?.cause === 'answer' && outcome?.via != null + && outcome.via === /** @type {{ confirmSurface?: string }} */ (msg).confirmSurface; + const text = outcome && !mine ? confirmSettleText(outcome) : null; + const matches = state.pendingConfirm?.id === msg.id; + if (!matches && !text) return state; + return { + ...state, + pendingConfirm: matches ? null : state.pendingConfirm, + confirmEvents: text + ? appendConfirmEvent(state.confirmEvents, { + id: /** @type {string} */ (msg.id), + sessionId: outcome?.sessionId ?? state.session.sessionId, + text, + at: Date.now(), + }) + : state.confirmEvents, + }; + } case 'turn/system-note': return { ...state, notices: [...state.notices, { id: Date.now() + Math.random(), text: msg.text, action: msg.action ?? null }].slice(-3) }; diff --git a/extension/sidepanel/components/app.js b/extension/sidepanel/components/app.js index 54f0fc93..2251a531 100644 --- a/extension/sidepanel/components/app.js +++ b/extension/sidepanel/components/app.js @@ -230,7 +230,7 @@ const PlaceholderView = { // equality) — /init progress and the grant-debugger nudge must be visible // wherever the user is, not just the side panel. /** - * @typedef {{ id: number, text?: string, action?: { kind?: string, label?: string } | null }} Notice + * @typedef {{ id: number, text?: string, action?: { kind?: string, label?: string, url?: string } | null }} Notice */ export const NoticeBar = { @@ -254,6 +254,17 @@ export const NoticeBar = { onclick: () => uiActions?.requestDebugger?.(n.id), }, n.action.label ?? 'Enable') : null, + // open-url: the SW attaches an https link (e.g. the preview + // update's XPI - background/update-check.js). The click IS the user + // gesture the target flow needs, so no SW round-trip: open the tab + // from here. https only, checked again at render (defense in depth - + // the SW already validates the feed's link). + n.action?.kind === 'open-url' && n.action.url?.startsWith('https://') + ? m('button.notice-action', { + type: 'button', + onclick: () => { window.open(n.action?.url, '_blank', 'noopener'); }, + }, n.action.label ?? 'Open') + : null, m('button.notice-dismiss', { type: 'button', 'aria-label': 'Dismiss notice', @@ -276,6 +287,24 @@ const ACTION_CLASS_LABEL = { external: 'a side-effecting', }; +// The session button says WHAT a standing grant covers (UI redesign §4d): the +// noun comes from the action class the prompt already carries, and the scope +// line mirrors confirm-grant-key.js - origin present → the grant is bound to +// that origin; absent → it really does cover any site this chat touches. A +// user who reads "for session" as "for this site" approves once and stops +// being asked everywhere; the label was the only place to catch that. +/** @type {Readonly>} */ +const ACTION_CLASS_GRANT_NOUN = Object.freeze({ + workspace_write: 'writes', + shell: 'code runs', + external: 'actions', +}); +/** @param {ConfirmPrompt} prompt */ +const sessionGrantNoun = (prompt) => (prompt.actionClass ? ACTION_CLASS_GRANT_NOUN[prompt.actionClass] : undefined) + ?? (prompt.kind === 'web_write' ? 'writes' : 'actions'); +/** @param {string[]} origins */ +const sessionGrantScope = (origins) => `this chat, ${origins.length ? 'this site' : 'any site'}`; + /** @type {Readonly>} */ const SITE_AUTH_LABEL = Object.freeze({ session: 'a signed-in browser session was observed', @@ -368,15 +397,54 @@ const confirmationDialogAttrs = (answer, state) => { * only sso — the card must then NOT vouch for where the button leads. * @property {string | null} [idpOrigin] login only: the exact system-derived IdP * origin authorized by a verified SSO confirmation. + * @property {number} [raisedAt] when the coordinator raised the prompt - the + * 90s hint times against this so replayed prompts share the real deadline. + */ + +/** + * @typedef {Object} ConfirmModalState + * @property {HTMLElement | null} [returnFocus] + * @property {string} [hintPromptId] + * @property {boolean} [showTimeoutHint] + * @property {ReturnType} [timeoutHintTimer] */ // Exported so the full-page home renders the SAME permission prompt (DESIGN-12 // full equality) — a confirm broadcast must be answerable on whichever surface // is open, not just the side panel. +// The 90-second hint (§4e): a quiet line before the 120s auto-deny - NOT a +// countdown, which would turn a security decision into a timed exam. Timed +// from raisedAt so a late-joining surface (replay) hints on the same clock. +// why armed per PROMPT ID, not oninit: the modal is mounted unkeyed and the +// reducer overwrites pendingConfirm in place, so a second prompt can replace +// the first without a remount - it must get its own timer on its own clock, +// never inherit the previous prompt's. +/** @param {ConfirmModalState} state @param {ConfirmPrompt} prompt */ +const armTimeoutHint = (state, prompt) => { + if (state.hintPromptId === prompt.id) return; + state.hintPromptId = prompt.id; + if (state.timeoutHintTimer) clearTimeout(state.timeoutHintTimer); + const raisedAt = typeof prompt.raisedAt === 'number' ? prompt.raisedAt : Date.now(); + const delay = Math.max(0, raisedAt + 90_000 - Date.now()); + state.showTimeoutHint = delay === 0; + state.timeoutHintTimer = setTimeout(() => { + state.showTimeoutHint = true; + m.redraw(); + }, delay); +}; + export const ConfirmModal = { - /** @param {{ attrs: { prompt: ConfirmPrompt, uiActions?: UiActions }, state: { returnFocus?: HTMLElement | null } }} vnode */ + /** @param {{ state: ConfirmModalState }} vnode */ + onremove(vnode) { + if (vnode.state.timeoutHintTimer) clearTimeout(vnode.state.timeoutHintTimer); + }, + /** @param {{ attrs: { prompt: ConfirmPrompt, uiActions?: UiActions }, state: ConfirmModalState }} vnode */ view: (vnode) => { const { prompt, uiActions } = vnode.attrs; - const dialogState = /** @type {{ returnFocus?: HTMLElement | null }} */ (vnode.state); + const dialogState = /** @type {ConfirmModalState} */ (vnode.state); + armTimeoutHint(dialogState, prompt); + const timeoutHint = dialogState.showTimeoutHint + ? m('p.muted.confirm-timeout-hint', { role: 'status' }, 'No answer counts as Reject.') + : null; /** @param {string} a */ const answer = (a) => uiActions?.confirmAnswer?.(prompt.id, a); const origins = Array.isArray(prompt.origins) ? prompt.origins.filter(Boolean) : []; @@ -436,6 +504,7 @@ export const ConfirmModal = { ? `peerd never sees your password and could not confirm this button’s destination — only continue if you trust ${host}. You finish signing in yourself.` : `peerd never sees your password. You finish signing in yourself — with your device${provider ? ` or ${provider}` : ''}.`), ]), + timeoutHint, m('.peerd-modal-actions', [ m('button.secondary', { type: 'button', 'data-confirm-reject': '', onclick: () => answer('no') }, 'Cancel'), m('button', { @@ -535,6 +604,17 @@ export const ConfirmModal = { origins.length ? m('p.muted', { style: 'font-size:12px;' }, `On: ${origins.join(', ')}`) : null, + // The absence is explained, not offered (§4d): when a helper raised the + // prompt the session button is correctly hidden - but hidden SILENTLY, + // the user cannot tell a missing option from a missing feature. + // "content", not the design's "pages": ephemeral covers EVERY actor + // kind - a Notebook or VM helper is steered by instance output, not + // pages - and the sentence must stay true for all of them. + prompt.ephemeral + ? m('p.muted.confirm-ephemeral-note', + 'A helper asked for this, and a helper can be steered by the content it reads - so this one can only be approved a single time.') + : null, + timeoutHint, m('.peerd-modal-actions', [ m('button.secondary', { type: 'button', 'data-confirm-reject': '', onclick: () => answer('no') }, 'Reject'), // why prompt.ephemeral hides it rather than disabling it: an actor's @@ -543,10 +623,17 @@ export const ConfirmModal = { // grant would silence the next prompt). Offering the button anyway // gives the user a control that reads as "stop asking me" and does // nothing — worse than not offering it, because they stop looking for - // another way out. + // another way out. The quiet line above the actions says WHY it is + // missing. a2a keeps the legacy label: its grant is peer-scoped and + // the body copy explains it by that name. isMemory || isSiteClient || prompt.ephemeral ? null - : m('button.secondary', { type: 'button', onclick: () => answer('yes_session') }, 'Allow for session'), + : prompt.tool === 'a2a_contact' || prompt.tool === 'a2a_reply' + ? m('button.secondary', { type: 'button', onclick: () => answer('yes_session') }, 'Allow for session') + : m('button.secondary.confirm-session-grant', { type: 'button', onclick: () => answer('yes_session') }, [ + `Allow all ${sessionGrantNoun(prompt)}`, + m('span.confirm-grant-scope', sessionGrantScope(origins)), + ]), m('button', { type: 'button', onclick: () => answer('yes_once') }, isMemory ? 'Save' : isSiteClient ? (p.op === 'delete' ? 'Delete client' : 'Save client') : 'Allow once'), ]), diff --git a/extension/sidepanel/components/chat-view.js b/extension/sidepanel/components/chat-view.js index 9aa53d60..f332b70e 100644 --- a/extension/sidepanel/components/chat-view.js +++ b/extension/sidepanel/components/chat-view.js @@ -202,6 +202,9 @@ export const ChatView = { // turn they happened (and fade into the backlog as the chat continues) // — not a bright sticky footer. Filtered to this session. tabEvents: (state.agentTabEvents ?? []).filter((e) => e.sessionId === state.session?.sessionId), + // Confirm settles for THIS chat only - the events carry their + // sessionId so a background chat's timeout can't leak into view. + confirmEvents: (state.confirmEvents ?? []).filter((e) => e.sessionId === state.session?.sessionId), uiActions, send, // A model turn may be idle after acknowledging asynchronous actor diff --git a/extension/sidepanel/components/denylist-view.js b/extension/sidepanel/components/denylist-view.js index f53bda86..036004c7 100644 --- a/extension/sidepanel/components/denylist-view.js +++ b/extension/sidepanel/components/denylist-view.js @@ -40,6 +40,9 @@ import { denylistModel, removalCopy, groupDenylist } from './denylist-format.js' * @property {Set} openGroups category keys currently expanded * @property {{ ok: boolean, text: string }|null} note * @property {string|null} confirm pattern with the armed remove/disable confirm + * @property {boolean} confirmNeedsFocus move focus to the verb on the NEXT strip mount only + * @property {string|null} refocus pattern whose arm button should regain focus after a disarm + * @property {HTMLInputElement|null} searchEl * @property {boolean} busy */ @@ -57,6 +60,9 @@ export const DenylistView = { vnode.state.openGroups = new Set(['__user']); // your own patterns open; the seed's categories collapsed vnode.state.note = null; // { ok, text } action banner vnode.state.confirm = null; // pattern with the armed remove/disable confirm + vnode.state.confirmNeedsFocus = false; + vnode.state.refocus = null; + vnode.state.searchEl = null; vnode.state.busy = false; DenylistView.refresh(vnode); }, @@ -161,7 +167,15 @@ export const DenylistView = { placeholder: 'Search patterns…', 'aria-label': 'Search denylist patterns', value: ui.query, - oninput: (/** @type {Event} */ e) => { ui.query = /** @type {HTMLInputElement} */ (e.target).value; }, + oncreate: (/** @type {{ dom: HTMLInputElement }} */ v) => { ui.searchEl = v.dom; }, + onremove: () => { ui.searchEl = null; }, + oninput: (/** @type {Event} */ e) => { + ui.query = /** @type {HTMLInputElement} */ (e.target).value; + // why: a pending refocus is a one-shot claim for the chip that + // replaced a disarmed strip - typing means the user moved on, + // and a match resurfacing later must not yank focus from here. + ui.refocus = null; + }, }), m('span.denylist-count', model.filtered @@ -175,7 +189,16 @@ export const DenylistView = { : null, ]), - ui.note ? m(`p.key-msg${ui.note.ok ? '.ok' : '.err'}`, ui.note.text) : null, + // why role=status: the banner announces the outcome of a mutation + // (added / removed / re-enabled / refused) to assistive tech without + // stealing focus. why ALWAYS mounted: a live region announces content + // CHANGES inside a registered region - a node freshly inserted with its + // text already present is announced unreliably (VoiceOver, some NVDA). + // Empty it takes no space (.key-msg:empty zeroes the margin). + m('p.key-msg', { + role: 'status', + class: ui.note ? (ui.note.ok ? 'ok' : 'err') : '', + }, ui.note ? ui.note.text : null), // A search that matches nothing says so ONCE, at the top. The groups stay // listed underneath (a category that vanishes reads as "peerd does not @@ -233,9 +256,13 @@ const groupBlock = (vnode, g) => { // unfiltered, "38 of 38" is noise where "38" is the fact. ("Your patterns" // has no seed total to be a fraction of, so it is always a plain count.) const count = (g.user || !ui.query.trim()) ? String(g.total) : `${g.shown} of ${g.total}`; + const headId = `denylist-group-head-${g.key}`; + const bodyId = `denylist-group-body-${g.key}`; const header = m('button.denylist-group-head', { type: 'button', + id: headId, 'aria-expanded': open ? 'true' : 'false', + 'aria-controls': bodyId, 'aria-label': `${g.label}, ${count} patterns`, onclick: () => { if (ui.openGroups.has(g.key)) ui.openGroups.delete(g.key); @@ -254,11 +281,17 @@ const groupBlock = (vnode, g) => { }, [ header, open && g.rows.length > 0 - ? m('.denylist-group-body', g.rows.map(({ pattern: p, user }) => + ? m('.denylist-group-body', { + id: bodyId, + role: 'group', + 'aria-labelledby': headId, + }, g.rows.map(({ pattern: p, user }) => ui.confirm === p ? confirmStrip(vnode, p, user) : patternChip(vnode, p, user))) : null, + // why the id here too: an expanded-but-empty group renders this line as + // its whole body, and the header's aria-controls must resolve to it. open && g.rows.length === 0 - ? m('p.muted.denylist-group-empty', ui.query.trim() + ? m('p.muted.denylist-group-empty', { id: bodyId }, ui.query.trim() ? 'No patterns in this group match the search.' : 'Every pattern in this group is currently disabled.') : null, @@ -275,6 +308,12 @@ const groupBlock = (vnode, g) => { */ const patternChip = (vnode, p, user) => { const ui = vnode.state; + // why the refocus dance: a disarm (Escape / ✕) destroys the focused verb + // button, which would drop keyboard focus to . The chip that replaces + // the strip claims focus back onto the arm button it grew from. + const takeFocus = (/** @type {{ dom: HTMLButtonElement }} */ v) => { + if (ui.refocus === p) { ui.refocus = null; v.dom.focus(); } + }; return m('span.denylist-item-row', { key: p }, [ m(`code.denylist-item${user ? '.is-user' : ''}`, { title: user ? 'Added by you' : 'Built-in seed pattern' }, p), @@ -282,7 +321,9 @@ const patternChip = (vnode, p, user) => { 'aria-label': `${user ? 'Remove' : 'Disable'} ${p}`, title: user ? 'Remove this pattern' : 'Disable this built-in pattern (reversible)', disabled: ui.busy, - onclick: () => { ui.confirm = p; }, + oncreate: takeFocus, + onupdate: takeFocus, + onclick: () => { ui.confirm = p; ui.confirmNeedsFocus = true; }, }, '×'), ]); }; @@ -298,7 +339,17 @@ const patternChip = (vnode, p, user) => { const confirmStrip = (vnode, p, user) => { const ui = vnode.state; const { verb, consequence } = removalCopy(p, user); - return m('span.denylist-item-row.is-arming', { key: p }, [ + return m('span.denylist-item-row.is-arming', { + key: p, + // why Escape here (not per button): cancel must work wherever focus + // sits inside the armed strip, and keydown bubbles to the row. The + // event keeps bubbling - nothing above this pane handles Escape, and + // silently eating a key an ancestor may someday want is how document- + // level directives get shadowed by accident. + onkeydown: (/** @type {KeyboardEvent} */ e) => { + if (e.key === 'Escape') { ui.confirm = null; ui.refocus = p; } + }, + }, [ m(`code.denylist-item${user ? '.is-user' : ''}`, p), m('span.denylist-badge', { title: user ? 'A pattern you added' : 'Ships with peerd — can be disabled, not deleted' }, @@ -306,12 +357,28 @@ const confirmStrip = (vnode, p, user) => { m('span.denylist-consequence', consequence), m('button.linkish.danger-text', { disabled: ui.busy, + // why the name carries the pattern: the visible label is just + // "Remove?" - a screen reader landing here must hear WHAT it removes. + 'aria-label': `${verb} ${p}`, + // why focus on arm - and ONLY on arm: the confirm replaces the chip + // the user just clicked, so focus would otherwise be left on a removed + // node. But this strip also re-mounts on unrelated redraws (group + // collapse/expand, a search narrowing past it and back) while + // ui.confirm survives - a re-mount must never steal focus, so the + // one-shot flag is consumed on the first mount after arming. + oncreate: (/** @type {{ dom: HTMLButtonElement }} */ v) => { + if (ui.confirmNeedsFocus) { ui.confirmNeedsFocus = false; v.dom.focus(); } + }, + // why refocus search on success: the strip (and for a removal, the + // chip itself) is gone once the mutation lands - the search box is + // the pane's one stable control for focus to land on. onclick: () => DenylistView.act(vnode, { type: 'denylist/remove', pattern: p }, - user ? `Removed ${p}.` : `Disabled ${p} — re-enable it below.`), + user ? `Removed ${p}.` : `Disabled ${p} - re-enable it below.`) + .then((r) => { if (r?.ok) ui.searchEl?.focus(); }), }, `${verb}?`), m('button.linkish', { 'aria-label': 'Cancel', - onclick: () => { ui.confirm = null; }, + onclick: () => { ui.confirm = null; ui.refocus = p; }, }, '✕'), ]); }; diff --git a/extension/sidepanel/components/input-bar.js b/extension/sidepanel/components/input-bar.js index f81d2a82..38cc1960 100644 --- a/extension/sidepanel/components/input-bar.js +++ b/extension/sidepanel/components/input-bar.js @@ -64,10 +64,18 @@ import { CostChip } from './cost-meter.js'; * @property {string|null} attachError * @property {HTMLInputElement|null} fileInputEl * @property {string|null} [sendAccent] + * @property {(() => void)|null} [resizeListener] */ const CHAT_INPUT_TARGET = 'chat-input'; +// The last composerPrefill nonce adopted (§4c). MODULE-level, not component +// state: the surface never clears composerPrefill, and InputBar unmounts on +// ordinary navigation (chats list, home view switches) - a marker on vnode +// state would die with it and the remounted bar would re-adopt the stale +// prefill over whatever chat's saved draft is now in view. +let consumedPrefillNonce = 0; + // The five brand custom props (sidepanel :root — same palette as // shared/brand.css). The send disc draws ONE of these at random per // draft (picked when the draft starts, stable until cleared — no @@ -125,6 +133,16 @@ const fileToBase64 = (file) => new Promise((resolve, reject) => { r.readAsDataURL(file); }); +// Grow the textarea to fit its content on every redraw. why: a textarea +// never grows on its own - without this the box stays at its two-row +// minimum and any longer draft hides behind an inner scrollbar. The +// CSS min/max-height still bound it; past the max the box scrolls. +/** @param {HTMLTextAreaElement} el */ +const autosize = (el) => { + el.style.height = 'auto'; + el.style.height = `${el.scrollHeight}px`; +}; + // Reset the palette to closed/empty. /** @param {InputBarState} ui */ const closePalette = (ui) => { @@ -238,6 +256,17 @@ export const InputBar = { ui.attachError = null; ui._sid = sid; } + // §4c one-shot prefill: a card action typed the user's likely next message + // into the draft. Nonce-guarded so one click adopts once - after that it is + // an ordinary draft the user edits, sends, or deletes. It never sends. + const prefill = state.composerPrefill; + if (prefill && prefill.nonce !== consumedPrefillNonce) { + consumedPrefillNonce = prefill.nonce; + ui.value = prefill.text; + ui.transcriptBaseline = prefill.text; + saveDraft(sid, prefill.text); + requestAnimationFrame(() => ui.el?.focus()); + } const hasKey = state.providers?.hasKey; // Attachments are Anthropic-only (image/document content blocks). // Same gate expression as chat-view's EffortDial: the session's @@ -520,7 +549,23 @@ export const InputBar = { 'aria-expanded': paletteOpen ? 'true' : 'false', 'aria-controls': paletteOpen ? 'composer-palette' : undefined, 'aria-activedescendant': activeDesc, - oncreate: (/** @type {{ dom: HTMLTextAreaElement }} */ vnode) => { ui.el = vnode.dom; }, + oncreate: (/** @type {{ dom: HTMLTextAreaElement }} */ vnode) => { + ui.el = vnode.dom; + autosize(vnode.dom); + // why a window listener: resizing the panel re-wraps the draft, + // which changes its content height - and Mithril does not + // redraw on resize, so no onupdate would fire. + ui.resizeListener = () => autosize(vnode.dom); + window.addEventListener('resize', ui.resizeListener); + }, + onremove: () => { + if (ui.resizeListener) window.removeEventListener('resize', ui.resizeListener); + ui.resizeListener = null; + }, + // why onupdate: every path that changes the value redraws + // (typing, voice chunks, palette commits, chat switches), so + // resizing here keeps the height in step with all of them. + onupdate: (/** @type {{ dom: HTMLTextAreaElement }} */ vnode) => autosize(vnode.dom), onkeydown: onKeydown, onkeyup: refreshTrigger, onclick: refreshTrigger, diff --git a/extension/sidepanel/components/message-list.js b/extension/sidepanel/components/message-list.js index da1a79fe..292e7468 100644 --- a/extension/sidepanel/components/message-list.js +++ b/extension/sidepanel/components/message-list.js @@ -109,6 +109,7 @@ const actorOutcomeUnknownFailure = (_text) => * @property {string} [peerName] * @property {number} [depth] * @property {TabEvent[]} [tabEvents] + * @property {{ id: string, sessionId: string|null, text: string, at: number }[]} [confirmEvents] * @property {UiActions} [uiActions] * @property {string} [sessionId] * @property {((msg: Record) => Promise)} [send] @@ -146,7 +147,7 @@ const CONTRIBUTOR_FEEDBACK_ENABLED = CHANNEL === 'preview' || CHANNEL === 'dev'; * @param {TranscriptArgs} args * @returns {any[]} */ -const renderTranscript = ({ messages, vmStreams, spawned, actors, scriptOps, loadActor, peerName, depth = 0, tabEvents = [], uiActions, send, sessionId, busy = false }) => { +const renderTranscript = ({ messages, vmStreams, spawned, actors, scriptOps, loadActor, peerName, depth = 0, tabEvents = [], confirmEvents = [], uiActions, send, sessionId, busy = false }) => { const groups = groupMessages(messages ?? []); // Feedback belongs to the completed answer for a human turn, not every // intermediate assistant step before a tool call. The live answer becomes @@ -188,7 +189,7 @@ const renderTranscript = ({ messages, vmStreams, spawned, actors, scriptOps, loa out.push(g.type === 'user' ? m(UserMessage, { key: g.message.id, message: g.message }) : g.type === 'actor-reply' - ? m(ActorReplyMessage, { key: g.message.id, message: g.message }) + ? m(ActorReplyMessage, { key: g.message.id, message: g.message, uiActions }) : m(AssistantMessage, { key: g.message.id, message: g.message, toolResults: g.toolResults, vmStreams, spawned, actors, scriptOps, loadActor, peerName, depth, @@ -200,6 +201,12 @@ const renderTranscript = ({ messages, vmStreams, spawned, actors, scriptOps, loa if (depth === 0) { flush(curTurn, true); for (const ev of tabEvents) flush(ev.turnId, true); + // Confirm settles trail last (§4e): a settle happens at the live turn's + // edge, so the tail is its honest anchor - newest of the capped few wins + // the fresh treatment. + confirmEvents.forEach((ev, i) => out.push(m(ConfirmSettledNotice, { + key: `confirm-${ev.id}`, ev, fresh: i === confirmEvents.length - 1, + }))); } return out; }; @@ -355,11 +362,11 @@ export const MessageList = { }, /** @param {{ attrs: TranscriptArgs, state: any }} vnode */ - view: ({ attrs: { messages, vmStreams, spawned, actors, scriptOps, loadActor, peerName, tabEvents, uiActions, send, sessionId, busy }, state }) => + view: ({ attrs: { messages, vmStreams, spawned, actors, scriptOps, loadActor, peerName, tabEvents, confirmEvents, uiActions, send, sessionId, busy }, state }) => m('.message-list', [ renderTranscript({ messages, vmStreams, spawned, actors, scriptOps, loadActor, peerName, - depth: 0, tabEvents, uiActions, send, sessionId, busy, + depth: 0, tabEvents, confirmEvents, uiActions, send, sessionId, busy, }), state.recoveryAnnouncement ? m('span.sr-only.actor-recovery-announcement.message-list-announcement', { @@ -401,6 +408,20 @@ const AgentTabNotice = { }, }; +// One quiet row per confirm that settled without this surface's click (§4e) - +// timeout, Stop, closed panel, or answered elsewhere. Same visual family as +// the tab notice above: informational, never a control, role=status so a live +// settle is announced without stealing focus. +const ConfirmSettledNotice = { + /** @param {{ attrs: { ev: { id: string, text: string }, fresh: boolean } }} vnode */ + view: ({ attrs: { ev, fresh } }) => m(`.agent-tab-notice.confirm-settled-notice${fresh ? '.agent-tab-notice--fresh' : ''}`, { + role: 'status', + }, [ + m('span.agent-tab-notice-icon', { 'aria-hidden': 'true' }, '▣'), + m('span.agent-tab-notice-copy', m('span.agent-tab-notice-text', ev.text)), + ]), +}; + /** * Walk session.messages and produce a display-friendly grouping: * - user messages with actual text: shown as user bubble @@ -486,6 +507,39 @@ const UserMessage = { }, }; +/** @typedef {{ group: string|null, headline: string, reason: string, whatIsNotKnown: string, tone: string, action: { label: string, composerText: string } | null }} LandingStopCardModel */ + +// The origin-lock stop card (§4c): four slots in 3g's fixed order - where it +// stopped, why (the landing rule's verbatim one-liner), what isn't known, and +// what's next. Monochrome: eight of the nine stops are the boundary doing its +// job on ordinary web behaviour, so red would cry wolf - INTERNAL alone (a +// genuine bug) takes the error treatment. The one action fills the composer +// and grants nothing: it types the user's likely next message, calls no tool, +// and never resumes the stopped helper. +const LandingStopCard = { + /** @param {{ attrs: { card: LandingStopCardModel, uiActions?: UiActions } }} vnode */ + view: ({ attrs: { card, uiActions } }) => m('.message.message-actor-reply.failed', [ + m('.landing-stop-card', { class: card.tone === 'error' ? 'is-error' : '' }, [ + m('.landing-stop-chips', [ + m('span.landing-stop-chip', 'STOPPED'), + card.group ? m('span.landing-stop-group', card.group) : null, + ]), + m('.landing-stop-headline', card.headline), + card.reason ? m('.landing-stop-reason', card.reason) : null, + m('.landing-stop-unknown', [ + m('.landing-stop-unknown-label', 'WHAT PEERD DOESN’T KNOW'), + m('.landing-stop-unknown-text', card.whatIsNotKnown), + ]), + card.action ? m('button.landing-stop-action', { + type: 'button', + // why prefill, not send: the card must never spend authority - the + // user reads the typed message and decides to send it themselves. + onclick: () => uiActions?.prefillComposer?.(card.action?.composerText), + }, card.action.label) : null, + ]), + ]), +}; + // An actor's reply, surfaced as its OWN bubble at its place in the transcript // (the trickle-up: delegated work comes BACK as a visible message, not buried // in the message_actor card above). Attribution mirrors renderActorCard's @@ -493,9 +547,14 @@ const UserMessage = { // still receives the full fenced text). The trusted lead line duplicates the // attribution label, so it's dropped from the bubble. const ActorReplyMessage = { - /** @param {{ attrs: { message: ChatMessage } }} vnode */ - view: ({ attrs: { message } }) => { + /** @param {{ attrs: { message: ChatMessage, uiActions?: UiActions } }} vnode */ + view: ({ attrs: { message, uiActions } }) => { const reply = message.actorReply ?? /** @type {NonNullable} */ ({ kind: 'actor', instanceId: '' }); + // §4c: an origin-lock stop renders as the slotted CARD, not the prose + // paragraphs - the report text stays the orchestrator's copy, byte-identical. + const landingStop = /** @type {LandingStopCardModel | undefined} */ ( + /** @type {any} */ (reply).landingStop); + if (landingStop) return m(LandingStopCard, { card: landingStop, uiActions }); const who = reply.name ?? (reply.instanceId !== reply.kind ? reply.instanceId : ''); const label = (reply.kind === 'web' && /^https?:\/\//.test(String(reply.instanceId))) ? `${reply.instanceId} integration` diff --git a/extension/sidepanel/sidepanel.js b/extension/sidepanel/sidepanel.js index dcb02200..566a7d7a 100644 --- a/extension/sidepanel/sidepanel.js +++ b/extension/sidepanel/sidepanel.js @@ -55,7 +55,11 @@ const handlePortMessage = (raw) => { } // Everything else folds through the shared pure reducer (DESIGN-12) so home // and the side panel stay byte-identical projections of the SW session. - const next = reduceChat(currentState, msg); + // §4e: a confirm settle carries WHICH surface answered; the reducer needs to + // know which surface it is folding FOR, so the answering surface doesn't + // transcript-line its own click. + const folded = msg.type === 'confirm/resolved' ? { ...msg, confirmSurface: 'sidepanel' } : msg; + const next = reduceChat(currentState, folded); if (next === currentState) return; // guarded bail / live complement — nothing changed currentState = next; // Side-panel-only: the voice manager doesn't survive the panel, so re-enable @@ -213,7 +217,7 @@ if (!root) throw new Error('sidepanel: #app missing from HTML'); * @param {string} answer */ const confirmAnswer = (id, answer) => { - send({ type: 'confirm/answer', id, answer }); + send({ type: 'confirm/answer', id, answer, surface: 'sidepanel' }); currentState = { ...currentState, pendingConfirm: null }; m.redraw(); }; @@ -263,7 +267,19 @@ const openAgentTab = async (tabId, windowId) => { const focused = await focusBrowserTab(browser, tabId, windowId); if (!focused) console.warn('[sidepanel] focus tab failed'); }; -const uiActions = { loadActor, confirmAnswer, dismissNotice, requestDebugger, openAgentTab }; + +// A card action types the user's likely next message INTO the composer (§4c) - +// it never sends. The nonce makes each click a fresh one-shot for the InputBar +// to consume; the user edits or discards like any draft. +let prefillNonce = 0; +/** @param {string} text */ +const prefillComposer = (text) => { + if (typeof text !== 'string' || !text.trim()) return; + prefillNonce += 1; + currentState = { ...currentState, composerPrefill: { text, nonce: prefillNonce } }; + m.redraw(); +}; +const uiActions = { loadActor, confirmAnswer, dismissNotice, requestDebugger, openAgentTab, prefillComposer }; // ---- brand hand-off: is the options tab the active one? ------------------- // diff --git a/extension/sidepanel/styles.css b/extension/sidepanel/styles.css index a18a8f25..407fb6ef 100644 --- a/extension/sidepanel/styles.css +++ b/extension/sidepanel/styles.css @@ -1099,6 +1099,73 @@ button.icon.is-active { border-left: 2px solid var(--fg-muted); } .message-actor-reply .role { font-style: italic; } +/* The origin-lock stop card (§4c). Monochrome by design: eight of the nine + stops are the boundary working on ordinary web behaviour, so red would + report a non-event - INTERNAL alone (is-error) takes the semantic red. */ +.landing-stop-card { + border: 1px solid var(--border); + border-radius: var(--r2); + background: var(--bg-elev); + padding: 11px 12px; + display: flex; + flex-direction: column; + gap: 9px; +} +.landing-stop-chips { display: flex; align-items: center; gap: 7px; } +.landing-stop-chip { + font-family: var(--font-mono); + font-size: 9.5px; + font-weight: 600; + letter-spacing: 0.08em; + color: var(--fg-muted); + background: var(--bg); + border: 1px solid var(--border); + padding: 3px 6px; + border-radius: 4px; +} +.landing-stop-group { + font-family: var(--font-mono); + font-size: 9.5px; + font-weight: 600; + letter-spacing: 0.08em; + color: var(--fg-muted); +} +.landing-stop-headline { font-size: 12.5px; font-weight: 600; line-height: 1.45; } +.landing-stop-reason { + border-left: 2px solid var(--border); + padding-left: 9px; + font-size: 11.5px; + line-height: 1.5; + color: var(--fg-muted); +} +.landing-stop-unknown { + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--r1); + padding: 8px 9px; +} +.landing-stop-unknown-label { + font-family: var(--font-mono); + font-size: 9.5px; + font-weight: 600; + letter-spacing: 0.08em; + color: var(--fg-muted); + margin-bottom: 5px; +} +.landing-stop-unknown-text { font-size: 11.5px; line-height: 1.5; color: var(--fg-muted); } +.landing-stop-action { + align-self: flex-start; + font-size: 11.5px; + color: var(--fg); + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--r1); + padding: 7px 10px; + cursor: pointer; +} +.landing-stop-action:hover { background: var(--elev); } +.landing-stop-card.is-error { border-color: var(--danger); } +.landing-stop-card.is-error .landing-stop-headline { color: var(--danger); } .message.streaming .bubble::after { content: '▍'; margin-left: 2px; @@ -2507,17 +2574,21 @@ button.tool-call-header:focus-visible { .actor-fabric-facts > div { grid-template-columns: 58px 1fr; } } -/* Plan/Act selector row above the input bar */ -.chat-mode-row { display: flex; align-items: center; padding: 4px 0 0; } +/* Plan/Act selector row above the input bar. why wrap at every width: + a non-wrapping flex row squeezes the pills at narrow panel widths and + their labels break onto two lines - overflow must become a second row + of intact pills, never a compressed pill. */ +.chat-mode-row { display: flex; flex-wrap: wrap; align-items: center; row-gap: 6px; padding: 4px 0 0; } .chat-mode-row .planact-mode, .chat-mode-row .planact-confirm, .chat-mode-row .effort-dial, .chat-mode-row .goal-toggle { min-height: 28px; box-sizing: border-box; + white-space: nowrap; } @media (max-width: 370px) { - .chat-mode-row { flex-wrap: wrap; column-gap: 6px; row-gap: 6px; } + .chat-mode-row { column-gap: 6px; } .chat-mode-row .planact { flex: 0 0 100%; margin-right: 0; } .chat-mode-row .goal-toggle { margin-left: 0; } .chat-mode-row > .spacer { flex: 1 1 0; min-width: 0; } @@ -2588,6 +2659,24 @@ button.tool-call-header:focus-visible { word-break: break-word; } .confirm-modal .peerd-modal-actions { flex-wrap: wrap; } +/* The session button's second line states the grant's true scope (§4d) - + smaller and muted so the verb stays the label, the scope the qualifier. */ +.confirm-session-grant { display: inline-flex; flex-direction: column; align-items: center; gap: 2px; } +.confirm-grant-scope { + font-family: var(--font-mono); + font-size: 10px; + color: var(--fg-muted); +} +/* Why the session grant is missing (helper-raised prompt) - a quiet line, + not a control; and the 90s "No answer counts as Reject" hint. Both are + informational, so they share the muted small voice. */ +.confirm-ephemeral-note { + border-left: 2px solid var(--border); + padding-left: 9px; + font-size: 12px; + margin: 0 0 8px; +} +.confirm-timeout-hint { font-size: 12px; margin: 0 0 8px; } /* --- login consent card (the `login` tool, kind:'login') -------------- * A sign-in is the highest-stakes confirm, so it gets a distinctive card: @@ -2946,6 +3035,10 @@ button.tool-call-header:focus-visible { } .linkish:hover { text-decoration: underline; } .key-msg { font-size: 12px; margin: 6px 0 0; } +/* why: the denylist pane keeps its role=status banner mounted at all times + (a live region must exist before its first announcement) - empty, it + must cost zero pixels. */ +.key-msg:empty { margin: 0; } .key-msg.ok { color: var(--ok); } .key-msg.err { color: var(--danger); } diff --git a/extension/tests/index.js b/extension/tests/index.js index 664e3517..7f8602c9 100644 --- a/extension/tests/index.js +++ b/extension/tests/index.js @@ -118,6 +118,7 @@ import './unit/sidepanel/failure-chip.test.js'; import './unit/sidepanel/actor-isolation.test.js'; import './unit/sidepanel/actor-fabric.test.js'; import './unit/sidepanel/confirm-note.test.js'; +import './unit/sidepanel/chat-reducer-confirm.test.js'; import './unit/sidepanel/site-client-confirm.test.js'; import './unit/sidepanel/learned-origins-view.test.js'; import './unit/options/activity-origin-events.test.js'; diff --git a/extension/tests/unit/sidepanel/chat-reducer-confirm.test.js b/extension/tests/unit/sidepanel/chat-reducer-confirm.test.js new file mode 100644 index 00000000..5a738e50 --- /dev/null +++ b/extension/tests/unit/sidepanel/chat-reducer-confirm.test.js @@ -0,0 +1,94 @@ +// @ts-check +// §4e - the confirm settle fold. A prompt that ends without this surface's +// click must leave a transcript line; the surface that answered must NOT +// line its own click; and a snapshot replay must never double-report or +// reorder what a live broadcast already recorded. + +import { describe, it, expect } from '../../framework.js'; +import { INITIAL_STATE, reduceChat } from '/sidepanel/chat-reducer.js'; + +/** @param {object} [extra] */ +const withPrompt = (extra = {}) => reduceChat( + /** @type {any} */ ({ ...INITIAL_STATE, session: { ...INITIAL_STATE.session, sessionId: 'chat-1' } }), + /** @type {any} */ ({ type: 'confirm/request', prompt: { id: 'p1', tool: 'click', ...extra } }), +); + +/** @param {any} state @param {any} msg */ +const fold = (state, msg) => reduceChat(state, msg); + +describe('sidepanel.chat-reducer confirm settles (§4e)', () => { + it('a timeout settle dismisses the modal and records the fixed line', () => { + const s = fold(withPrompt(), { + type: 'confirm/resolved', id: 'p1', + outcome: { answer: 'no', cause: 'timeout', via: null, sessionId: 'chat-1' }, + }); + expect(s.pendingConfirm).toBe(null); + expect(s.confirmEvents.length).toBe(1); + expect(s.confirmEvents[0].text).toBe('Not approved - no answer in two minutes.'); + expect(s.confirmEvents[0].sessionId).toBe('chat-1'); + }); + + it('a Stop settle and an abort settle share the you-stopped line', () => { + for (const cause of ['stop', 'abort']) { + const s = fold(withPrompt(), { + type: 'confirm/resolved', id: 'p1', + outcome: { answer: 'no', cause, via: null, sessionId: 'chat-1' }, + }); + expect(s.confirmEvents[0].text).toBe('Not approved - you stopped the turn.'); + } + }); + + it('an answer from the OTHER surface says which way it went and where from', () => { + const s = fold(withPrompt(), { + type: 'confirm/resolved', id: 'p1', confirmSurface: 'sidepanel', + outcome: { answer: 'yes_once', cause: 'answer', via: 'home', sessionId: 'chat-1' }, + }); + expect(s.pendingConfirm).toBe(null); + expect(s.confirmEvents[0].text).toBe('Approved once, from the home tab.'); + }); + + it('the answering surface never lines its own click', () => { + const s = fold(withPrompt(), { + type: 'confirm/resolved', id: 'p1', confirmSurface: 'home', + outcome: { answer: 'yes_once', cause: 'answer', via: 'home', sessionId: 'chat-1' }, + }); + expect(s.pendingConfirm).toBe(null); + expect(s.confirmEvents.length).toBe(0); + }); + + it('a settle for an UNDISPLAYED prompt still records its line', () => { + // Two prompts pending SW-side; this surface displays p2 when p1 times out. + const displayingP2 = fold(withPrompt(), /** @type {any} */ ({ + type: 'confirm/request', prompt: { id: 'p2', tool: 'click' }, + })); + const s = fold(displayingP2, { + type: 'confirm/resolved', id: 'p1', + outcome: { answer: 'no', cause: 'timeout', via: null, sessionId: 'chat-1' }, + }); + expect(s.pendingConfirm?.id).toBe('p2'); // p2 stays up + expect(s.confirmEvents[0].text).toBe('Not approved - no answer in two minutes.'); + }); + + it('snapshot notes fold in, dedupe by id, and keep time order', () => { + const live = fold(withPrompt(), { + type: 'confirm/resolved', id: 'p1', + outcome: { answer: 'no', cause: 'timeout', via: null, sessionId: 'chat-1' }, + }); + const s = fold(live, { + type: 'state', + state: { + session: { sessionId: 'chat-1', messages: [] }, + confirmSettleNotes: [ + // p1 again (already recorded live - must not duplicate) plus an + // OLDER unreachable settle that must sort BEFORE the live line. + { id: 'p1', answer: 'no', cause: 'timeout', via: null, at: Date.now() + 1 }, + { id: 'p0', answer: 'no', cause: 'unreachable', via: null, at: 1 }, + ], + }, + }); + expect(s.confirmEvents.length).toBe(2); + expect(s.confirmEvents[0].id).toBe('p0'); // time-ordered, not append-ordered + expect(s.confirmEvents[0].text).toBe('Not approved - peerd wasn’t open to ask.'); + expect(s.confirmEvents[1].id).toBe('p1'); + }); +}); diff --git a/extension/tests/unit/sidepanel/confirm-note.test.js b/extension/tests/unit/sidepanel/confirm-note.test.js index 918bf15d..4da5f301 100644 --- a/extension/tests/unit/sidepanel/confirm-note.test.js +++ b/extension/tests/unit/sidepanel/confirm-note.test.js @@ -66,7 +66,11 @@ describe('sidepanel.confirm note (issue 242)', () => { const { root, unmount } = mount({ ...base, note: NOTE }); try { const labels = [...root.querySelectorAll('.peerd-modal-actions button')].map((b) => b.textContent); - expect(labels).toEqual(['Reject', 'Allow for session', 'Allow once']); + // §4d: the session button says what it grants - the noun from the action + // class (page_write has no dedicated noun → 'actions') and the true scope + // (no origins on this prompt → the grant really is origin-blind). + // textContent concatenates the verb and the scope line without a space. + expect(labels).toEqual(['Reject', 'Allow all actionsthis chat, any site', 'Allow once']); } finally { unmount(); } }); diff --git a/packaging/check-tscheck.ts b/packaging/check-tscheck.ts index 81431fc3..fcc3b2b3 100644 --- a/packaging/check-tscheck.ts +++ b/packaging/check-tscheck.ts @@ -158,7 +158,7 @@ import { computeCoverage } from './tscheck-coverage.ts'; // route, Options surface, and rendered side-panel coverage. // 673 → 676: the Actor Fabric adds its pure topology model, SW live projection, // and rendered browser contract while replacing the checked async-task bar. -const COVERED_FLOOR = 698; +const COVERED_FLOOR = 705; // The scan (walk + // @ts-check detection + the ES5-injected exemption set) // lives in tscheck-coverage.ts so the badge generator reports the same number. diff --git a/packaging/default-settings.mjs b/packaging/default-settings.mjs index 1750a199..7b0e0754 100644 --- a/packaging/default-settings.mjs +++ b/packaging/default-settings.mjs @@ -253,6 +253,18 @@ export const defaults = { auditLogMaxEntries: { store: 20000, preview: 20000 }, // ── preview-only keys ────────────────────────────────────────────── + // Self-update check at startup (background/update-check.js). Preview + // installs are self-hosted (update_url → the peerd.ai feeds → GitHub + // release artifacts), and peerd's offscreen keepalive holds the MV3 SW + // alive - the exact state where Chrome parks a downloaded extension + // update waiting for an "idle" that never comes. ON by default on + // preview: testers should just be current. Chrome checks + reloads when + // nothing is in flight; Firefox (no requestUpdateCheck API) reads the + // feed and offers the XPI in a notice. Store packages omit the key + // entirely - store updates belong to the store; dev (load-unpacked) has + // no update_url, so the check is a structural no-op there. + autoUpdateEnabled: { preview: true }, + // Dweb participation is ON BY DEFAULT on the dev/preview package (owner // call, 2026-06-13 — supersedes the earlier "opt-in even on preview", // spec §12). Preview ships to contributors and early testers; making diff --git a/scripts/cdp/baselines/linux-x64/goal-running.dark.png b/scripts/cdp/baselines/linux-x64/goal-running.dark.png index 6c30bf98..9c3abd89 100644 Binary files a/scripts/cdp/baselines/linux-x64/goal-running.dark.png and b/scripts/cdp/baselines/linux-x64/goal-running.dark.png differ diff --git a/scripts/cdp/baselines/linux-x64/goal-running.light.png b/scripts/cdp/baselines/linux-x64/goal-running.light.png index b30b3632..915a0230 100644 Binary files a/scripts/cdp/baselines/linux-x64/goal-running.light.png and b/scripts/cdp/baselines/linux-x64/goal-running.light.png differ diff --git a/scripts/cdp/baselines/linux-x64/multi-turn-transcript.dark.png b/scripts/cdp/baselines/linux-x64/multi-turn-transcript.dark.png index b071e60d..c93964e7 100644 Binary files a/scripts/cdp/baselines/linux-x64/multi-turn-transcript.dark.png and b/scripts/cdp/baselines/linux-x64/multi-turn-transcript.dark.png differ diff --git a/scripts/cdp/baselines/linux-x64/options-behavior.dark.png b/scripts/cdp/baselines/linux-x64/options-behavior.dark.png index a7bf95a2..8794a644 100644 Binary files a/scripts/cdp/baselines/linux-x64/options-behavior.dark.png and b/scripts/cdp/baselines/linux-x64/options-behavior.dark.png differ diff --git a/scripts/cdp/baselines/linux-x64/options-behavior.light.png b/scripts/cdp/baselines/linux-x64/options-behavior.light.png index 893021eb..8eb88f82 100644 Binary files a/scripts/cdp/baselines/linux-x64/options-behavior.light.png and b/scripts/cdp/baselines/linux-x64/options-behavior.light.png differ diff --git a/scripts/cdp/states.mjs b/scripts/cdp/states.mjs index 42bc121b..453929f6 100644 --- a/scripts/cdp/states.mjs +++ b/scripts/cdp/states.mjs @@ -2382,6 +2382,128 @@ export const STATES = [ }, }, + // --- visual: the confirm modal, standard shape (§4d) ---------------------- + // Component-render like login-confirm above: the modal's STRUCTURE is the + // subject - the honest session-grant label (verb + true scope) and, second + // pass, the helper-raised variant where the absence is explained, not silent. + { + name: 'sidepanel-confirm', kind: 'visual', phase: 'post-unlock', + responder: () => ({ sse: sseText('noted') }), + async run(ctx, rec) { + try { + await evalIn(ctx.page, `(async () => { + const m = (await import('/vendor/mithril/mithril.js')).default; + const { ConfirmModal } = await import('/sidepanel/components/app.js'); + const host = document.createElement('div'); + host.id = 'e2e-sidepanel-confirm'; + document.body.appendChild(host); + m.render(host, m(ConfirmModal, { prompt: { + id: 'e2e-confirm', tool: 'write_file', actionClass: 'workspace_write', + summary: 'write_file notes/2026-08.md', + origins: ['https://notes.example.com'], + } })); + })()`, true); + const rendered = await waitFor(() => evalIn(ctx.page, `(() => ({ + title: document.querySelector('#e2e-sidepanel-confirm h3')?.textContent, + buttons: [...document.querySelectorAll('#e2e-sidepanel-confirm .peerd-modal-actions button')] + .map((b) => b.textContent.trim().replace(/\\s+/g, ' ')), + scope: document.querySelector('#e2e-sidepanel-confirm .confirm-grant-scope')?.textContent, + }))()`), { budgetMs: 5_000, pollMs: 50 }); + rec.check('the session button names the grant and its true scope (§4d)', + rendered?.title === 'Confirm action' + && rendered?.buttons?.[0] === 'Reject' + && String(rendered?.buttons?.[1] ?? '').startsWith('Allow all writes') + && rendered?.buttons?.[2] === 'Allow once' + && rendered?.scope === 'this chat, this site', + JSON.stringify(rendered)); + await rec.visual('sidepanel-confirm'); + // Second pass - helper-raised (ephemeral): the session button is gone + // AND the quiet line says why; a control that grants nothing must not + // render, and its absence must not be silent. + const ephemeral = await evalIn(ctx.page, `(async () => { + const m = (await import('/vendor/mithril/mithril.js')).default; + const { ConfirmModal } = await import('/sidepanel/components/app.js'); + const host = document.querySelector('#e2e-sidepanel-confirm'); + m.render(host, null); + m.render(host, m(ConfirmModal, { prompt: { + id: 'e2e-confirm-eph', tool: 'click', actionClass: 'external', + summary: 'click "Confirm booking"', ephemeral: true, + origins: ['https://rooms.example.com'], + } })); + return { + buttons: [...document.querySelectorAll('#e2e-sidepanel-confirm .peerd-modal-actions button')] + .map((b) => b.textContent.trim()), + note: document.querySelector('#e2e-sidepanel-confirm .confirm-ephemeral-note')?.textContent ?? null, + }; + })()`, true); + rec.check('a helper-raised confirm hides the session grant and explains the absence', + ephemeral?.buttons?.length === 2 + && ephemeral?.buttons?.[0] === 'Reject' + && ephemeral?.buttons?.[1] === 'Allow once' + && typeof ephemeral?.note === 'string' + && ephemeral.note.includes('approved a single time'), + JSON.stringify(ephemeral)); + } finally { + await evalIn(ctx.page, `document.querySelector('#e2e-sidepanel-confirm')?.remove()`); + } + }, + }, + + // --- visual: the origin-lock stop card (§4c) ------------------------------ + // One state per family: the variants differ by string, and the gate exists to + // catch layout regressions, not to inventory copy. HANDOFF carries the one + // action (composer prefill), so it is the layout-complete member. + { + name: 'sidepanel-stop-card', kind: 'visual', phase: 'post-unlock', + responder: () => ({ sse: sseText('noted') }), + async run(ctx, rec) { + try { + await evalIn(ctx.page, `(async () => { + const m = (await import('/vendor/mithril/mithril.js')).default; + const { MessageList } = await import('/sidepanel/components/message-list.js'); + const { landingStopCard } = await import('/peerd-runtime/index.js'); + const host = document.createElement('div'); + host.id = 'e2e-stop-card'; + // why fixed over the viewport: an appended host lands below the home + // content, off-frame - the visual would photograph nothing. + host.style.cssText = 'position:fixed;inset:0;z-index:999;background:var(--bg);padding:14px;overflow:auto;'; + document.body.appendChild(host); + const card = landingStopCard({ + action: 'handoff', + reason: 'this is a site you have an account on, so its own helper should do the work', + from: null, to: 'https://mail.example.com/inbox?x=1', + handoffTo: 'https://mail.example.com', + }); + m.render(host, m(MessageList, { messages: [{ + id: 'e2e-stop-1', role: 'user', synthetic: true, + content: 'The web actor could not complete your request:\\n\\n(fenced report)', + actorReply: { kind: 'web', instanceId: 'web', failed: true, landingStop: card }, + }] })); + })()`, true); + const rendered = await waitFor(() => evalIn(ctx.page, `(() => ({ + chip: document.querySelector('#e2e-stop-card .landing-stop-chip')?.textContent, + group: document.querySelector('#e2e-stop-card .landing-stop-group')?.textContent, + headline: document.querySelector('#e2e-stop-card .landing-stop-headline')?.textContent, + unknownLabel: document.querySelector('#e2e-stop-card .landing-stop-unknown-label')?.textContent, + action: document.querySelector('#e2e-stop-card .landing-stop-action')?.textContent, + proseHidden: !document.querySelector('#e2e-stop-card .bubble'), + }))()`), { budgetMs: 5_000, pollMs: 50 }); + rec.check('the stop card renders four slots, origin-only, with the one action', + rendered?.chip === 'STOPPED' + && rendered?.group === 'HANDOFF' + && rendered?.headline === 'The web helper was stopped when the tab arrived at https://mail.example.com' + && !String(rendered?.headline).includes('/inbox') + && rendered?.unknownLabel === 'WHAT PEERD DOESN’T KNOW' + && rendered?.action === 'Try reading it without signing in' + && rendered?.proseHidden === true, + JSON.stringify(rendered)); + await rec.visual('sidepanel-stop-card'); + } finally { + await evalIn(ctx.page, `document.querySelector('#e2e-stop-card')?.remove()`); + } + }, + }, + // --- visual: the mode row in Plan mode (segmented Plan/Act + chips) --------- { name: 'mode-plan', kind: 'visual', phase: 'post-unlock', @@ -4917,14 +5039,23 @@ Promise.resolve().then(async () => { return rect.width >= 24 && rect.height >= 24; }), wraps: getComputedStyle(row).flexWrap === 'wrap', + // why: the pill-squeeze bug - a control narrower than its own + // label overflows internally (scrollWidth > clientWidth) or + // grows a second text line. Fitting means neither happens. + unsqueezed: controls.every((el) => el.scrollWidth <= el.clientWidth + && el.getBoundingClientRect().height <= 30), actionsFit: actions.length === 6 && actions.every(inside), actionNames: actions.map((el) => el.getAttribute('aria-label')), }; })()`)); } + // why wraps at EVERY width: the row is flex-wrap:wrap unconditionally + // now - overflow becomes a second row of intact pills. The old + // nowrap-above-370 rule squeezed the pills at 371–460px and their + // labels broke onto two lines inside the pill. rec.check('the authority row fits across both sides of every responsive boundary', widthResults.every((result) => result.pageFits && result.rowFits && result.targets - && result.wraps === (result.width <= 370)), + && result.wraps && result.unsqueezed), JSON.stringify(widthResults)); rec.check('all six named top-bar actions remain reachable across the width matrix', widthResults.every((result) => result.actionsFit diff --git a/tests/background/settings-patch.test.ts b/tests/background/settings-patch.test.ts index c0afdba4..ef0fe7eb 100644 --- a/tests/background/settings-patch.test.ts +++ b/tests/background/settings-patch.test.ts @@ -9,6 +9,7 @@ const deps = { knownProviderNames: ['anthropic', 'openrouter', 'ollama'], reasoningEffortLevels: ['low', 'medium', 'high', 'xhigh', 'max'] as const, dwebEnabled: true, + autoUpdateAvailable: true, normalizeVariant: (_v: string) => 'base', normalizeEngine: (v: string) => (['auto', 'web-speech', 'moonshine'].includes(v) ? v : 'auto'), }; @@ -130,6 +131,16 @@ describe('normalizeSettingsPatch — dweb gate', () => { }); }); +describe('normalizeSettingsPatch - auto-update gate (preview-only key)', () => { + test('autoUpdateEnabled honored only where the package carries the key', () => { + expect(norm({ autoUpdateEnabled: false })).toEqual({ autoUpdateEnabled: false }); + expect(norm({ autoUpdateEnabled: true }, { autoUpdateAvailable: false })).toEqual({}); + }); + test('non-boolean autoUpdateEnabled dropped even when available', () => { + expect(norm({ autoUpdateEnabled: 'yes' })).toEqual({}); + }); +}); + describe('normalizeSettingsPatch — ollamaHost (issue #104)', () => { test('keeps a valid http(s) origin', () => { expect(norm({ ollamaHost: 'http://localhost:11434' })).toEqual({ ollamaHost: 'http://localhost:11434' }); diff --git a/tests/background/update-check.test.ts b/tests/background/update-check.test.ts new file mode 100644 index 00000000..9f506d10 --- /dev/null +++ b/tests/background/update-check.test.ts @@ -0,0 +1,342 @@ +import { describe, test, expect } from 'bun:test'; +import { + compareVersions, latestGeckoUpdate, makeUpdateCheck, + UPDATE_CHECK_SESSION_KEY, MIN_CHECK_INTERVAL_MS, +} from '../../extension/background/update-check.js'; + +// Pins the preview self-update contract: the pure version/feed logic, the +// Chrome requestUpdateCheck → onUpdateAvailable → reload-when-quiet flow +// (with the onQuiet re-arm), the Firefox feed → persisted-notice flow, the +// Firefox listener-presence semantics (register only on self-hosted +// manifests; disabled restores the no-listener default), and the structural +// no-op on manifests without a self-hosted update_url (dev, store). + +describe('compareVersions', () => { + test('orders numeric triples', () => { + expect(compareVersions('0.6.0', '0.7.0')).toBeLessThan(0); + expect(compareVersions('0.7.0', '0.6.9')).toBeGreaterThan(0); + expect(compareVersions('1.0.0', '0.99.99')).toBeGreaterThan(0); + expect(compareVersions('0.6.0', '0.6.0')).toBe(0); + }); + test('missing parts count as zero', () => { + expect(compareVersions('0.6', '0.6.0')).toBe(0); + expect(compareVersions('0.6.1', '0.6')).toBeGreaterThan(0); + }); + test('garbage parts count as zero, never NaN-poison the compare', () => { + expect(compareVersions('x.y', '0.0')).toBe(0); + expect(compareVersions('0.abc.1', '0.0.1')).toBe(0); + }); +}); + +describe('latestGeckoUpdate', () => { + const GECKO_ID = 'peerd-preview@peerd.ai'; + const feed = (updates: unknown[]) => ({ addons: { [GECKO_ID]: { updates } } }); + + test('picks the highest version', () => { + const result = latestGeckoUpdate(feed([ + { version: '0.6.0', update_link: 'https://github.com/a.xpi' }, + { version: '0.7.1', update_link: 'https://github.com/c.xpi' }, + { version: '0.7.0', update_link: 'https://github.com/b.xpi' }, + ]), GECKO_ID); + expect(result).toEqual({ version: '0.7.1', updateLink: 'https://github.com/c.xpi' }); + }); + test('skips non-https links and malformed entries', () => { + const result = latestGeckoUpdate(feed([ + { version: '0.9.0', update_link: 'http://github.com/evil.xpi' }, + { version: '0.9.1', update_link: 'not a url' }, + { version: 42, update_link: 'https://github.com/x.xpi' }, + null, + { version: '0.8.0', update_link: 'https://github.com/ok.xpi' }, + ]), GECKO_ID); + expect(result).toEqual({ version: '0.8.0', updateLink: 'https://github.com/ok.xpi' }); + }); + test('rejects hosts outside the allow-list - a compromised feed cannot aim the Install button elsewhere', () => { + const result = latestGeckoUpdate(feed([ + { version: '0.9.0', update_link: 'https://evil.example/fake-peerd.xpi' }, + { version: '0.8.0', update_link: 'https://peerd.ai/dl/ok.xpi' }, + ]), GECKO_ID); + expect(result).toEqual({ version: '0.8.0', updateLink: 'https://peerd.ai/dl/ok.xpi' }); + }); + test('rejects junk version strings before they reach the compare or the notice copy', () => { + expect(latestGeckoUpdate(feed([ + { version: '0.7.0-beta