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:
parent
c06557e957
commit
7f70779f1c
5 changed files with 105 additions and 33 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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})` : ""}`);
|
||||
}
|
||||
|
|
|
|||
73
home.html
73
home.html
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue