diff --git a/bundled-addons/claude-connector/index.js b/bundled-addons/claude-connector/index.js index 6d8192d1..fa23cddf 100644 --- a/bundled-addons/claude-connector/index.js +++ b/bundled-addons/claude-connector/index.js @@ -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; diff --git a/bundled-addons/claude-connector/lib/tab-driver.cjs b/bundled-addons/claude-connector/lib/tab-driver.cjs index 31008b83..92e33a1b 100644 --- a/bundled-addons/claude-connector/lib/tab-driver.cjs +++ b/bundled-addons/claude-connector/lib/tab-driver.cjs @@ -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; diff --git a/bundled-addons/claude-connector/lib/tools.cjs b/bundled-addons/claude-connector/lib/tools.cjs index 3c72b51b..a1b338aa 100644 --- a/bundled-addons/claude-connector/lib/tools.cjs +++ b/bundled-addons/claude-connector/lib/tools.cjs @@ -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})` : ""}`); } diff --git a/home.html b/home.html index c08a307f..3ff91597 100644 --- a/home.html +++ b/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 @@
-
+
+
Your links
-
+
+
Silent Mode
diff --git a/package.json b/package.json index c356a1b4..8ac2fc14 100644 --- a/package.json +++ b/package.json @@ -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",