Theseus 0.3.107: two-section home + Claude connector hardening

Home page splits into two self-contained panels so the left-column
links no longer scale with the Silent Mode cards. The slot grid is
narrower, the icons default closer to Windows desktop-shortcut size,
and each section carries its own subtle panel background and heading.

Claude connector hardening (the opt-in MCP add-on from 0.3.106):

- Pin the approved origin to the TabDriver on every Controller.use()
  and re-check the tab's current URL before each CDP read / input
  (isolated, snapshot, screenshot, clickAt, insertText, press,
  setFiles, evaluate). A meta-refresh or soft-nav to an unapproved
  origin between approval and the call now throws instead of leaking
  content or steering input to the new site.
- Rotate the bearer token on every stop() so toggling the connector
  off (toolbar chip, panel switch, deactivate) invalidates existing
  credentials. Re-enabling issues a fresh token; the panel's install
  strings update on the next start. Matches the mental model where
  "off" means "the keys change" instead of "the server stops".
- click without a ref now resolves the element at (x, y) through
  DOM.getNodeForLocation and refuses if it's a password / one-time-
  code field. Previously only ref-based click ran isSecretField; raw
  coordinates let focus land on a password field before FOCUS_GUARD
  kicked in to block typing.
This commit is contained in:
Local Dev 2026-10-23 19:12:52 +03:00
parent c06557e957
commit 7f70779f1c
5 changed files with 105 additions and 33 deletions

View file

@ -169,7 +169,15 @@ module.exports = {
await controller.releaseAll();
policy.reset();
try { fs.rmSync(endpointFile, { force: true }); } catch {}
if (s) log.add({ tool: "connector", args: reason || "stopped", ok: true });
// Rotate the bearer token on every stop so that re-enabling the
// connector doesn't resurrect a credential a client (or anyone who
// read its config while it was off) still has — matches the mental
// model where "off" means "the keys change". The panel's installed-
// command strings reflect the new token on the next start.
if (s) {
api.storage.set("token", newToken());
log.add({ tool: "connector", args: `${reason || "stopped"}; token rotated`, ok: true });
}
pushStatus();
})().finally(() => { stopping = null; });
return stopping;

View file

@ -61,6 +61,11 @@ class TabDriver {
this.refs = new Map(); // "e12" -> backendDOMNodeId
this.refSeq = 0;
this.onGone = onGone;
// Approved-origin pin: set once per Controller.use() so each read/input
// method can bail if the page redirected to a different origin between
// approval and the CDP call. See _checkOrigin.
this.expectedOrigin = null;
this._originOf = null;
this._onDetach = () => { this.attachedByUs = false; this.ctx = null; };
this._onDestroyed = () => { if (this.onGone) this.onGone(this.tabId); };
wc.debugger.on("detach", this._onDetach);
@ -69,6 +74,23 @@ class TabDriver {
wc.on("did-navigate", this._onNav);
}
// Called by Controller.use() right after site approval. Each read/input
// method re-checks the current URL's origin against this before firing
// any CDP command, so a meta-refresh or soft-nav to an unapproved origin
// between approval and the call can't leak content or steer input.
setExpectedOrigin(origin, originOf) {
this.expectedOrigin = origin || null;
this._originOf = typeof originOf === "function" ? originOf : null;
}
_checkOrigin() {
if (!this.expectedOrigin || !this._originOf) return;
if (this.wc.isDestroyed()) throw new Error("the tab was closed");
const cur = this._originOf(this.wc.getURL());
if (cur !== this.expectedOrigin) {
throw new Error(`refused: the page navigated from ${this.expectedOrigin} to ${cur || "about:blank"}; call list_tabs/snapshot again — the new site needs its own approval`);
}
}
// Called before every use: the tab's webContents is the same object for
// the tab's life, but the debugger may have been detached (DevTools opened).
async ready() {
@ -114,6 +136,7 @@ class TabDriver {
// Evaluate an expression in the isolated world; the value comes back as JSON.
async isolated(expression) {
this._checkOrigin();
for (let attempt = 0; attempt < 2; attempt++) {
const contextId = await this._context();
let r;
@ -146,6 +169,7 @@ class TabDriver {
}
async snapshot({ interactiveOnly = false, maxLines = 1500 } = {}) {
this._checkOrigin();
await this.send("Accessibility.enable").catch(() => {});
await this.send("DOM.enable").catch(() => {});
await this.send("DOM.getDocument", { depth: 0 }).catch(() => {});
@ -210,6 +234,7 @@ class TabDriver {
}
async clickAt(x, y, { button = "left", clickCount = 1 } = {}) {
this._checkOrigin();
const buttons = { left: 1, right: 2, middle: 4 }[button] || 1;
await this.send("Input.dispatchMouseEvent", { type: "mouseMoved", x, y });
for (let i = 1; i <= clickCount; i++) {
@ -226,6 +251,20 @@ class TabDriver {
if (isSecretField(await this.describe(backendNodeId))) throw new Error("refused: that element is a password field");
}
// Point-level secret check: for click(x, y) without a ref, resolve the
// element at the pointer and refuse if it's a password / one-time-code
// field. Falls back silently if the point lies on nothing we can resolve.
async assertNotSecretAt(x, y) {
this._checkOrigin();
await this.send("DOM.enable").catch(() => {});
let r;
try { r = await this.send("DOM.getNodeForLocation", { x: Math.round(x), y: Math.round(y), includeUserAgentShadowDOM: true }); } catch { return; }
if (!r || !r.backendNodeId) return;
let node;
try { node = await this.describe(r.backendNodeId); } catch { return; }
if (isSecretField(node)) throw new Error("refused: that point is on a password field");
}
// Throws if typing now would land in a password field.
async focusGuard() {
const g = await this.isolated(FOCUS_GUARD);
@ -234,10 +273,13 @@ class TabDriver {
}
async insertText(text) {
if (text) await this.send("Input.insertText", { text });
if (!text) return;
this._checkOrigin();
await this.send("Input.insertText", { text });
}
async press(name, modifiers = []) {
this._checkOrigin();
const k = keyInfo(name);
const mods = (Array.isArray(modifiers) ? modifiers : String(modifiers || "").split("+")).reduce((m, x) => m | (MOD_BITS[String(x).trim().toLowerCase()] || 0), 0);
const chord = (mods & (1 | 2 | 4)) !== 0;
@ -262,6 +304,7 @@ class TabDriver {
}
async setFiles(backendNodeId, files) {
this._checkOrigin();
const node = await this.describe(backendNodeId);
const attrs = {};
const a = node.attributes || [];
@ -273,6 +316,7 @@ class TabDriver {
// ---- screenshots -------------------------------------------------------
// Scaled to CSS pixels, so a point in the image is a point for click(x, y).
async screenshot({ fullPage = false, format } = {}) {
this._checkOrigin();
const dpr = Number(await this.isolated("window.devicePixelRatio")) || 1;
const m = await this.send("Page.getLayoutMetrics");
const vv = m.cssVisualViewport || m.visualViewport;
@ -303,6 +347,7 @@ class TabDriver {
}
async evaluate(expression) {
this._checkOrigin();
const r = await this.send("Runtime.evaluate", { expression: String(expression), returnByValue: true, awaitPromise: true, userGesture: true });
if (r.exceptionDetails) {
const d = r.exceptionDetails;

View file

@ -106,6 +106,11 @@ class Controller {
this.drivers.set(tab.id, driver);
this.onTabsChanged();
}
// Pin the just-approved origin to the driver so every subsequent CDP
// call re-checks the tab's current URL. A soft-nav / meta-refresh to a
// different origin between here and the call throws instead of leaking
// content or landing input on the new site.
driver.setExpectedOrigin(origin, originOf);
await driver.ready();
return { tab, wc, driver, origin };
}
@ -229,6 +234,7 @@ class Controller {
await driver.assertNotSecret(id);
({ x, y } = await driver.centerOf(id));
} else if (!(Number.isFinite(Number(x)) && Number.isFinite(Number(y)))) throw new Error("click needs a ref or x and y");
else await driver.assertNotSecretAt(Number(x), Number(y));
await driver.clickAt(Number(x), Number(y), { button: a.button || "left", clickCount: a.double ? 2 : 1 });
return text(`clicked at ${Math.round(x)},${Math.round(y)}${a.ref ? ` (${a.ref})` : ""}`);
}

View file

@ -28,24 +28,36 @@
.hero form button { padding: 13px 20px; border-radius: 999px; border: none; background: var(--blue); color: #fff; font-size: 15px; cursor: pointer; }
.hero .hint { color: var(--dim); font-size: 12.5px; margin-top: .8rem; }
/* Two-column desktop under the hero: a 3-column grid of icon slots on the
LEFT, a 3-column grid of Silent Mode cards on the RIGHT. Both expand
together on wider screens; the layout stretches to the viewport so the
left column doesn't crowd against the cards. */
.homepage { display: grid; grid-template-columns: minmax(300px, 360px) minmax(380px, 1fr);
gap: 36px; max-width: 1680px; margin: 2.4rem auto 0; padding: 0 40px 48px; align-items: start; }
/* Two distinct sections under the hero: a self-contained "Your links"
panel on the LEFT (quick-launch tiles) and a Silent Mode cards panel
on the RIGHT. Each gets its own panel background so they read as two
separate boxes instead of one shared body. The two are side-by-side
on desktop and stack vertically on narrow viewports. */
.homepage { display: grid; grid-template-columns: minmax(240px, 280px) minmax(380px, 1fr);
gap: 24px; max-width: 1680px; margin: 2.4rem auto 0; padding: 0 32px 48px; align-items: start; }
@media (max-width: 900px) { .homepage { grid-template-columns: 1fr; gap: 20px; padding: 0 14px 32px; } }
/* Section container styling — each column is its own padded panel. The
panel itself is subtle (soft tinted surface, no hard border) so the
separation reads structurally without the sections feeling boxed-in. */
.section { background: rgba(255,255,255,.025); border-radius: 16px;
padding: 16px 16px 20px; box-shadow: inset 0 0 0 1px rgba(255,255,255,.03);
width: 100%; min-width: 0; }
body.light .section { background: rgba(0,0,0,.03); box-shadow: inset 0 0 0 1px rgba(0,0,0,.03); }
.section-title { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .9px;
color: var(--mut); margin: 0 2px 14px; }
/* LEFT AREA — fixed-cell grid. Each icon lives in its own cell; drag
reorders which icon is in which cell. Simpler than free x,y and keeps
the icons tidy against the left edge of the column. */
.left-column { display: flex; flex-direction: column; gap: 14px; }
reorders which icon is in which cell. Icons sized to roughly match a
Windows desktop shortcut (~48 px tile + label) so they stop looming
next to the Silent Mode cards. */
.left-column { align-self: start; }
/* Fixed-width cells (not 1fr) so icons sit in a tight desktop-style grid
instead of drifting inside stretched 3-equal-column cells. The whole
grid left-aligns to the column edge via justify-content: start. */
.slots-list { display: grid; grid-template-columns: repeat(3, 100px); gap: 14px 10px;
.slots-list { display: grid; grid-template-columns: repeat(3, 72px); gap: 10px 8px;
width: 100%; align-items: start; justify-content: start; }
@media (max-width: 900px) { .slots-list { grid-template-columns: repeat(auto-fill, 100px); justify-content: start; } }
@media (max-width: 900px) { .slots-list { grid-template-columns: repeat(auto-fill, 72px); justify-content: start; } }
.cards-grid.drop-target { outline: 2px dashed var(--acid); outline-offset: 4px; border-radius: 14px; }
.slot { position: relative; display: flex; flex-direction: column; align-items: center;
@ -53,20 +65,18 @@
transition: transform .12s ease; }
.slot:hover { transform: translateY(-2px); }
.slot .tile { display: flex; align-items: center; justify-content: center;
border-radius: 16px; background: var(--iconBg, #0b0e14);
border-radius: 12px; background: var(--iconBg, #0b0e14);
color: var(--iconFg, #e7eaf1); font-weight: 700; letter-spacing: -.02em;
box-shadow: 0 6px 18px #0007, inset 0 0 0 1px rgba(255,255,255,.08);
box-shadow: 0 4px 12px #0007, inset 0 0 0 1px rgba(255,255,255,.08);
overflow: hidden; }
.slot .tile svg, .slot .tile img { width: 72%; height: 72%; display: block; }
.slot .label { margin-top: 6px; font-size: 11.5px; line-height: 1.2; color: #fff;
text-shadow: 0 1px 3px #000c, 0 0 10px #000a; max-width: 100px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
padding: 2px 6px; border-radius: 4px; }
body.light .slot .label { color: #0b0e14; text-shadow: 0 1px 2px #fffb, 0 0 10px #fffc; }
.slot.size-small .tile { width: 56px; height: 56px; font-size: 22px; border-radius: 12px; }
.slot.size-medium .tile { width: 72px; height: 72px; font-size: 28px; border-radius: 14px; }
.slot.size-large .tile { width: 88px; height: 88px; font-size: 34px; border-radius: 18px; }
.slot.size-small .label { font-size: 10.5px; max-width: 72px; }
.slot .tile svg, .slot .tile img { width: 76%; height: 76%; display: block; }
.slot .label { margin-top: 5px; font-size: 11px; line-height: 1.2; color: var(--ink);
max-width: 72px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
padding: 1px 4px; border-radius: 4px; text-align: center; }
.slot.size-small .tile { width: 40px; height: 40px; font-size: 17px; border-radius: 10px; }
.slot.size-medium .tile { width: 48px; height: 48px; font-size: 20px; border-radius: 11px; }
.slot.size-large .tile { width: 60px; height: 60px; font-size: 24px; border-radius: 13px; }
.slot.size-small .label { font-size: 10.5px; }
.slot.dragging { opacity: .6; }
.slot.drag-before::before, .slot.drag-after::after {
content: ""; position: absolute; top: 10%; bottom: 10%; width: 3px;
@ -78,16 +88,17 @@
/* Slot-add sits in the grid alongside the slot icons so it's always at
the end of the row, not pinned to a corner. */
.slot-add { display: flex; align-items: center; justify-content: center;
min-height: 72px; border-radius: 12px; border: 2px dashed var(--line2);
background: transparent; color: var(--mut); font-size: 20px; cursor: pointer;
width: 48px; height: 48px; margin: 0 auto;
border-radius: 10px; border: 2px dashed var(--line2);
background: transparent; color: var(--mut); font-size: 18px; cursor: pointer;
transition: color .12s, border-color .12s, background .12s; }
.slot-add:hover { color: var(--acid); border-color: var(--acid); background: rgb(from var(--acid) r g b / .04); }
/* Empty slots in the grid: drop targets for pinning any link to a
specific slot. Order matches the grid's cell index. Subtly visible
so users see where they can drop; brighter on hover of the strip. */
.slot-empty { display: block; min-height: 72px; border-radius: 12px;
border: 2px dashed rgb(from var(--line2) r g b / .55); background: transparent;
transition: border-color .12s, background .12s; }
.slot-empty { display: block; width: 48px; height: 48px; margin: 0 auto;
border-radius: 10px; border: 2px dashed rgb(from var(--line2) r g b / .55);
background: transparent; transition: border-color .12s, background .12s; }
.slots-list:hover .slot-empty { border-color: var(--line2); }
.slot-empty.drop-hot { border-color: var(--acid); background: rgb(from var(--acid) r g b / .06); }
@ -211,10 +222,12 @@
</div>
<div class="homepage">
<div class="left-column">
<div class="section left-column">
<div class="section-title">Your links</div>
<div class="slots-list" id="slotsList"></div>
</div>
<div class="right-column">
<div class="section right-column">
<div class="section-title">Silent Mode</div>
<div class="cards-grid" id="cardsGrid"></div>
</div>
</div>

View file

@ -1,6 +1,6 @@
{
"name": "theseus-navigator",
"version": "0.3.106",
"version": "0.3.107",
"license": "MPL-2.0",
"description": "Theseus Navigator — a browser that follows the thread. By Silent Mode, a Deviant project.",
"author": "Silent Mode",