Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions extension/background/routes/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down
7 changes: 4 additions & 3 deletions extension/background/routes/vault.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
},
};
Expand Down
174 changes: 163 additions & 11 deletions extension/background/service-worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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<string, ReturnType<typeof landingStopCard>>}
*/
const landingStopCards = new Map();

/**
* A monotonic token per actor TURN, and the reason it has to exist.
*
Expand Down Expand Up @@ -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:
//
Expand Down Expand Up @@ -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<string, Array<{ id: string, at: number, answer: string, cause: string, via: string|null }>>} */
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;
Expand All @@ -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) => {
Expand Down Expand Up @@ -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';
Expand All @@ -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<string>} */ (sessionConfirmGrants.get(sid))).add(grantKey);
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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')) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -6694,6 +6769,8 @@ const actorMessaging = makeActorMessaging({
const actorSurface = contributorDecision?.resolved;
/** @type {string | null} */
let landingStopSnapshot = null;
/** @type {ReturnType<typeof landingStopCard> | 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.
Expand All @@ -6702,21 +6779,31 @@ 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
* answer — the report is. Overriding UNCONDITIONALLY is the point: a stopped
* 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.
Expand Down Expand Up @@ -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<Array<{ url: string }>> } }} */ (
/** @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.
Expand Down
8 changes: 8 additions & 0 deletions extension/background/settings-patch.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
* knownProviderNames: string[],
* reasoningEffortLevels: readonly string[],
* dwebEnabled: boolean,
* autoUpdateAvailable: boolean,
* normalizeVariant: (v: string) => string,
* normalizeEngine: (v: string) => string,
* }} deps
Expand All @@ -36,6 +37,7 @@ export const normalizeSettingsPatch = (patch, {
knownProviderNames,
reasoningEffortLevels,
dwebEnabled,
autoUpdateAvailable,
normalizeVariant,
normalizeEngine,
}) => {
Expand Down Expand Up @@ -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
Expand Down
Loading