Skip to content
Merged
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
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ reconnect/reselect/save, never automatic retry. Metadata grants no autonomy or d
Full Task8 remains open. Combined assembled
migration-stack acceptance remains separate from this source-bound producer fixture.

Local browser (`#/browser-authorization`): `packages/chrome-extension` pairs with a main-owned loopback host by one-time code; the user shares tabs one by one and the agent gets `browser_tabs`/`browser_read` for those only. See `docs/browser-connector.md`.

Shell/settings: the Cortex sidebar adds a Workspace group (Space, Scheduled tasks, Planning, Local browser,
Extensions and skills) and the signed-in account's first Bot; remote and local chats share one Recents list.
Signed-in Profile reads `/v1/me` (`app.me`); the rail shows account initials. Settings links Models,
Expand Down
15 changes: 15 additions & 0 deletions docs/browser-connector.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Chrome connector

`packages/chrome-extension` (Manifest V3, load unpacked from the folder the **Install extension** button opens) talks to the
desktop app over a loopback HTTP endpoint (`127.0.0.1`, ports 47821-47825) owned by `packages/desktop/src/browser-host.ts`.
Chrome native messaging was not used: it needs a per-OS manifest registration and an extension ID that only exists after store publishing.

- **Pairing**: the page asks main for a one-time 8-character code (5 min, burns after 5 wrong guesses). The extension posts it to
`/pair` and receives a random bearer token kept in `chrome.storage.session`. Pairing again invalidates the old token.
- **Origin checks**: requests need a `chrome-extension://` Origin and a literal `127.0.0.1:<port>` Host; web pages are refused.
- **Consent**: the user shares the active tab from the extension popup (`activeTab` only; no cookies, history or host permissions beyond loopback).
Only `http(s)` pages without embedded credentials can be shared. A shared tab that navigates elsewhere loses consent. Either side can revoke.
- **Agent tools**: `browser_tabs` and `browser_read` (`packages/core/src/tool.ts`) are offered only while a tab is shared; reads of any other tab are refused in `BrowserBridge` before reaching the browser and ask for permission each time.
- State is memory-only: restarting the app drops pairing and consents. The renderer sees status only, never page text.

Not done: Chrome Web Store publishing (the extension is loaded unpacked) and Firefox/Safari.
3 changes: 3 additions & 0 deletions electron-builder.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ files:
extraResources:
- from: skills
to: skills
- from: packages/chrome-extension
to: chrome-extension
filter: ["**/*", "!**/*.d.ts"]
- from: packages/i18n/locales
to: locales
filter: ["**/*.json", "!**/*.source.json", "!**/fixtures/**"]
Expand Down
1 change: 1 addition & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export default tseslint.config(
},
},
{ files: ["**/test/**", "tests/**"], rules: { "@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/no-unused-expressions": "off" } },
{ files: ["packages/chrome-extension/**/*.js"], languageOptions: { globals: { chrome: "readonly", document: "readonly", AbortSignal: "readonly" } } },
{ files: ["scripts/dev-smoke.mjs"], languageOptions: { globals: { document: "readonly" } } },
{ files: ["scripts/smoke.mjs"], languageOptions: { globals: { WebSocket: "readonly" } } },
{ files: ["tests/e2e/**"], rules: { "react-hooks/rules-of-hooks": "off", "no-empty-pattern": "off" } },
Expand Down
Binary file added evidence/browser-connector/connected-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added evidence/browser-connector/connected-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added evidence/browser-connector/empty-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added evidence/browser-connector/empty-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added evidence/browser-connector/pairing-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added evidence/browser-connector/pairing-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 12 additions & 0 deletions packages/app/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,18 @@ type Bridge = {
onMenu?: (cb: (cmd: string) => void) => () => void;
update?: { status: () => Promise<UpdateState>; check: () => Promise<UpdateState>; install: () => Promise<boolean>; onState: (cb: (s: UpdateState) => void) => () => void };
call?: CallBridge;
browser?: BrowserBridgeApi;
};
export type BrowserTab = { id: number; title: string; url: string; sharedAt: number };
export type BrowserState = { listening: boolean; port?: number; connected: boolean; pairing?: { code: string; expiresAt: number }; tabs: BrowserTab[] };
export type BrowserBridgeApi = {
status(): Promise<BrowserState>;
pair(): Promise<{ code: string; expiresAt: number }>;
revoke(tabId: number): Promise<void>;
disconnect(): Promise<void>;
extensionDir(): Promise<string>;
revealExtension(): Promise<string>;
onChange(cb: (s: BrowserState) => void): () => void;
};
export type CallSnapshot = { phase: "connecting" | "listening" | "hearing" | "thinking" | "speaking" | "reconnecting" | "ended" | "error"; muted: boolean; heard: readonly string[]; partial: string; streaming: boolean; end?: "ended" | "auth_revoked" | "superseded" | "unavailable" | "busy" | "failed" | "mic_lost" };
export type CallBridge = {
Expand Down
71 changes: 71 additions & 0 deletions packages/app/src/screens/extras/browser.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// Local browser: pair the Cortex Chrome extension and manage the tabs the user shared. Page text never reaches this screen.
import * as React from "react";
import { bridge, type BrowserState } from "../../api";
import { useT } from "../../i18n";
import { Icon } from "../../kit/ui";
import { Page, Empty } from "./extras";

const R = "workspace-extras";
const PAIRING_MINUTES = 5;
const EMPTY: BrowserState = { listening: false, connected: false, tabs: [] };

/** Live bridge state: initial read, pushes from main, and a refresh when a pairing code expires. */
export function useBrowserState() {
const api = bridge()?.browser;
const [state, setState] = React.useState<BrowserState | undefined>(api ? undefined : EMPTY);
React.useEffect(() => {
if (!api) return;
let live = true;
void api.status().then(s => live && setState(s), () => live && setState(EMPTY));
const off = api.onChange(s => live && setState(s));
// The extension's long poll keeps `connected` fresh; this catches silence after it goes away.
const tick = setInterval(() => void api.status().then(s => live && setState(s), () => {}), 15_000);
return () => { live = false; off(); clearInterval(tick); };
}, [api]);
const expiresAt = state?.pairing?.expiresAt;
React.useEffect(() => {
if (!api || !expiresAt) return;
const timer = setTimeout(() => void api.status().then(setState, () => {}), Math.max(0, expiresAt - Date.now()) + 50);
return () => clearTimeout(timer);
}, [api, expiresAt]);
return { state, api };
}

export function Browser() {
const t = useT(), { state, api } = useBrowserState();
const [copied, setCopied] = React.useState(false);
const mode = !state ? "loading" : !state.listening ? "noHost" : state.connected ? "connected" : state.pairing ? "waiting" : "off";
const badge = mode === "connected" ? "ok" : mode === "waiting" ? "wait" : mode === "noHost" ? "err" : "";
const code = state?.pairing?.code;
const copy = async () => { if (!code) return; try { await navigator.clipboard.writeText(code); setCopied(true); setTimeout(() => setCopied(false), 1500); } catch { /* clipboard blocked: the code stays visible */ } };
return <Page title={t("extras.browser.title")}>
<h1>{t("extras.browser.h1")}</h1><p className={`${R}-lead`}>{t("extras.browser.lead")}</p>
{state && <section className={`${R}-conn`} data-testid="browser-status" data-mode={mode}>
<div className={`${R}-conn-head`}>
<span className={`${R}-conn-icon`}><Icon name={mode === "connected" ? "check-circle" : "globe"} /></span>
<div className={`${R}-grow`}><h2>{t(`extras.browser.status.${mode === "waiting" ? "waiting" : mode === "connected" ? "connected" : mode === "noHost" ? "noHost" : "off"}`)}</h2>
<p>{t(mode === "connected" ? "extras.browser.connectedBody" : mode === "noHost" ? "extras.browser.noHostBody" : "extras.browser.offBody")}</p></div>
<span className={`badge ${badge}`}>{t(`extras.browser.status.${mode === "waiting" ? "waiting" : mode === "connected" ? "connected" : mode === "noHost" ? "noHost" : "off"}`)}</span>
</div>
{mode === "connected" && <div className={`${R}-actions`}><button className="btn secondary" data-testid="browser-disconnect" onClick={() => void api?.disconnect()}>{t("extras.browser.disconnect")}</button></div>}
{(mode === "off" || mode === "waiting") && <div className={`${R}-conn-steps`}>
<div className={`${R}-conn-step`}><span className={`${R}-step-n`}>1</span><div className={`${R}-grow`}><h3>{t("extras.browser.install")}</h3><p>{t("extras.browser.installHelp")}</p></div>
<button className="btn secondary" data-testid="browser-install" onClick={() => void api?.revealExtension()}><Icon name="download" />{t("extras.browser.install")}</button></div>
<div className={`${R}-conn-step`}><span className={`${R}-step-n`}>2</span><div className={`${R}-grow`}><h3>{t("extras.browser.pairCode")}</h3>
{code ? <><p className={`${R}-pair-code`} data-testid="browser-code" aria-label={t("extras.browser.pairCode")}>{code.slice(0, 4)}<span aria-hidden="true">-</span>{code.slice(4)}</p><p>{t("extras.browser.pairHelp", { minutes: PAIRING_MINUTES })}</p></> : <p>{t("extras.browser.offBody")}</p>}</div>
<div className={`${R}-conn-buttons`}>
{code && <button className="btn secondary" data-testid="browser-copy" onClick={() => void copy()}><Icon name={copied ? "check" : "copy"} />{t("extras.browser.copy")}</button>}
<button className="btn primary" data-testid="browser-pair" onClick={() => void api?.pair()}>{t(code ? "extras.browser.pairNew" : "extras.browser.pair")}</button></div></div>
</div>}
</section>}
{state && mode !== "noHost" && <section aria-labelledby="browser-tabs-h">
<div className={`${R}-section-head`}><h2 id="browser-tabs-h">{t("extras.browser.tabs")}</h2></div>
{state.tabs.length ? <ul className={`${R}-tabs-list`} data-testid="browser-tabs">{state.tabs.map(tab => <li key={tab.id}>
<span className={`${R}-tab-icon`}><Icon name="globe" /></span>
<div className={`${R}-grow`}><strong>{tab.title || tab.url}</strong><span className={`${R}-muted ${R}-tab-url`}>{tab.url}</span></div>
<button className="btn secondary" data-testid="browser-revoke" aria-label={t("extras.browser.revokeLabel", { title: tab.title || tab.url })} onClick={() => void api?.revoke(tab.id)}>{t("extras.browser.revoke")}</button></li>)}</ul>
: <Empty icon="globe" title={t(mode === "connected" ? "extras.browser.emptyTitle" : "extras.browser.offTitle")}>{t(mode === "connected" ? "extras.browser.emptyBody" : "extras.browser.offBody")}</Empty>}
</section>}
<p className={`${R}-footnote`}><Icon name="shield-check" /> {t("extras.browser.privacy")}</p>
</Page>;
}
24 changes: 24 additions & 0 deletions packages/app/src/screens/extras/extras.css
Original file line number Diff line number Diff line change
Expand Up @@ -162,3 +162,27 @@
.workspace-extras .workspace-extras-matrix .input { min-height: 40px; }
.workspace-extras-people > li { flex-wrap: wrap; }
}

/* Local browser: connection card, pairing steps and shared tabs (extends the design lot-workspace-extras tokens). */
.workspace-extras-conn { border: 1px solid var(--line); border-radius: var(--r-card); background: var(--card); margin: 18px 0 24px; overflow: hidden; }
.workspace-extras-conn-head { display: flex; align-items: center; gap: 14px; padding: 18px; }
.workspace-extras-conn-head h2, .workspace-extras-conn-head p { margin: 0; }
.workspace-extras-conn-head p { color: var(--t2); font-size: 12px; }
.workspace-extras-conn-icon, .workspace-extras-tab-icon { width: 36px; height: 36px; flex-shrink: 0; display: grid; place-items: center; border-radius: var(--r-ctl); background: var(--sidebar-bg); color: var(--i1); }
.workspace-extras-conn[data-mode="connected"] .workspace-extras-conn-icon { color: var(--green); }
.workspace-extras-conn > .workspace-extras-actions { margin: 0; padding: 0 18px 18px; }
.workspace-extras-conn-steps { border-top: 1px solid var(--line); }
.workspace-extras-conn-step { display: flex; align-items: center; gap: 14px; padding: 16px 18px; }
.workspace-extras-conn-step + .workspace-extras-conn-step { border-top: 1px solid var(--line); }
.workspace-extras-conn-step p { margin: 0; color: var(--t2); font-size: 12px; }
.workspace-extras-conn-buttons { display: flex; gap: 8px; flex-shrink: 0; }
.workspace-extras-step-n { width: 22px; height: 22px; flex-shrink: 0; display: grid; place-items: center; border-radius: 50%; border: 1px solid var(--line); color: var(--t2); font-size: 11px; }
.workspace-extras .workspace-extras-pair-code { font-family: var(--mono); font-size: 24px; line-height: 32px; letter-spacing: .14em; color: var(--t1); font-weight: 500; margin: 2px 0; }
.workspace-extras-pair-code span { color: var(--t2); margin-inline: .1em; }
.workspace-extras-tabs-list { list-style: none; margin: 0 0 16px; padding: 0; border-block: 1px solid var(--line); }
.workspace-extras-tabs-list li { display: flex; align-items: center; gap: 12px; padding: 12px 0; }
.workspace-extras-tabs-list li + li { border-top: 1px solid var(--line); }
.workspace-extras-tabs-list strong, .workspace-extras-tab-url { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.workspace-extras-tab-url { font-size: 11px; }
.workspace-extras-footnote > svg { vertical-align: -2px; }
@media (max-width: 720px) { .workspace-extras-conn-step { flex-wrap: wrap; } .workspace-extras-conn-buttons { width: 100%; } }
17 changes: 4 additions & 13 deletions packages/app/src/screens/extras/extras.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Workspace extras (design lot-workspace-extras.tsx) on the real trunk routes through the main-process contract
// transport: /v1/task-plans, /v1/approval-policy and /v1/orgs. Widgets and the local browser operator have no producer
// route, so they show their honest unavailable state (as the web app does).
// transport: /v1/task-plans, /v1/approval-policy and /v1/orgs. Widgets have no producer route, so they show their
// honest unavailable state (as the web app does). The local browser screen lives in browser.tsx.
import * as React from "react";
import { Dialog } from "@base-ui/react/dialog";
import type { ContractCall } from "@cortex/schema";
Expand Down Expand Up @@ -34,7 +34,7 @@ function useOwner() {
: connection.state !== "ready" || owner.state !== "ready" ? { state: "loading" as const } : owner.data ? { state: "ready" as const, epoch: owner.data } : { state: "local" as const };
}

function Page({ title, back, action, children }: { title: string; back?: string; action?: React.ReactNode; children: React.ReactNode }) {
export function Page({ title, back, action, children }: { title: string; back?: string; action?: React.ReactNode; children: React.ReactNode }) {
const t = useT(), { go } = useNav();
return <div className={R}><div className="content-top">{back && <IconBtn icon="arrow-left" label={t("extras.back")} onClick={() => go(back)} />}<span className="title">{title}</span><div className="spacer" />{action}
<Pop align="end" trigger={<button className="ibtn" aria-label={t("extras.more")}><Icon name="more-dots" /></button>}>{LINKS.map(([route, k, icon]) => <MItem key={route} icon={icon} onClick={() => go(route)}>{t(`extras.link.${k}`)}</MItem>)}</Pop></div>
Expand All @@ -43,7 +43,7 @@ function Page({ title, back, action, children }: { title: string; back?: string;
function Notice({ children, error }: { children: React.ReactNode; error?: boolean }) {
return <div className={`${R}-notice`} role={error ? "alert" : "status"} data-error={error || undefined}><Icon name={error ? "alert-triangle" : "info"} /><div>{children}</div></div>;
}
function Empty({ title, icon = "history", action, children }: { title: string; icon?: string; action?: React.ReactNode; children: React.ReactNode }) {
export function Empty({ title, icon = "history", action, children }: { title: string; icon?: string; action?: React.ReactNode; children: React.ReactNode }) {
return <section className={`${R}-empty`}><Icon name={icon} /><h2>{title}</h2><p>{children}</p>{action}</section>;
}
function Skeleton() {
Expand Down Expand Up @@ -90,15 +90,6 @@ export function Widgets() {
</Page>;
}

export function Browser() {
const t = useT();
return <Page title={t("extras.browser.title")}>
<h1>{t("extras.browser.h1")}</h1><p className={`${R}-lead`}>{t("extras.browser.lead")}</p>
<Notice>{t("extras.browser.notice")}</Notice>
<Empty icon="globe" title={t("extras.browser.emptyTitle")} action={<button className="btn secondary" disabled>{t("extras.browser.unavailable")}</button>}>{t("extras.browser.emptyBody")}</Empty>
</Page>;
}

export function Planning() {
const t = useT(), { go } = useNav();
const [creating, setCreating] = React.useState(false), owner = useOwner();
Expand Down
3 changes: 2 additions & 1 deletion packages/app/src/screens/extras/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ScreenDef } from "../../registry";
import { Widgets, Browser, Planning, PlanDetail, Organisation, Approvals, WhatsNew } from "./extras";
import { Browser } from "./browser";
import { Widgets, Planning, PlanDetail, Organisation, Approvals, WhatsNew } from "./extras";

const G = "extras.group";
// Design lot-workspace-extras.tsx. Live screens only: no preview fixtures, so the gallery shows the signed-out state.
Expand Down
Loading
Loading