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
5 changes: 5 additions & 0 deletions .changeset/agent-toggle-message-dots.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@agent-native/core": patch
---

Use the Tabler message-dots icon for the agent sidebar toggle.
5 changes: 5 additions & 0 deletions .changeset/owner-null-org-db-tools.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@agent-native/core": patch
---

Keep agent DB tools scoped to owner rows when org context is active and rows have no org id.
5 changes: 5 additions & 0 deletions .changeset/quiet-desktop-webviews.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@agent-native/core": patch
---

Suppress automatic stale route-chunk reloads inside the Agent Native desktop app.
5 changes: 5 additions & 0 deletions .changeset/secure-public-viewer-resolver.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@agent-native/core": patch
---

Harden the public-viewer anonymous-owner resolver: validate Referer origin, require the exact Builder callback path, and discard expired status connect URLs in the embedded settings panel.
4 changes: 2 additions & 2 deletions packages/core/src/client/AgentPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ import {
} from "./components/ui/dropdown-menu.js";
import {
IconMessageCircle,
IconMessageChatbot,
IconMessageDots,
IconTerminal2,
IconSettings,
IconLayoutSidebarRightCollapse,
Expand Down Expand Up @@ -1906,7 +1906,7 @@ export function AgentToggleButton({ className }: { className?: string }) {
className,
)}
>
<IconMessageChatbot size={16} />
<IconMessageDots size={16} />
</button>
</TooltipTrigger>
<TooltipContent>Toggle agent</TooltipContent>
Expand Down
78 changes: 77 additions & 1 deletion packages/core/src/client/route-chunk-recovery.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {

function createFakeWindow(
startHref = "https://example.com/dispatch/apps",
opts: { lockReload?: boolean } = {},
opts: { lockReload?: boolean; userAgent?: string } = {},
) {
const documentListeners = new Map<string, EventListener[]>();
const windowListeners = new Map<string, EventListener[]>();
Expand Down Expand Up @@ -63,6 +63,9 @@ function createFakeWindow(
},
location: fakeLocation,
history: fakeHistory,
navigator: {
userAgent: opts.userAgent ?? "Mozilla/5.0",
},
console: {
error: vi.fn(),
},
Expand Down Expand Up @@ -253,6 +256,41 @@ describe("route chunk recovery", () => {
expect(originalReload).not.toHaveBeenCalled();
});

it("suppresses stale route chunk auto-reloads inside Agent Native desktop", () => {
const { fakeWindow, fakeLocation, originalReload, dispatchDocument } =
createFakeWindow("https://example.com/dispatch/apps", {
userAgent: "Mozilla/5.0 Electron/41.2.2 AgentNativeDesktop/0.1.7",
});

installRouteChunkRecovery(fakeWindow);

const anchor = {
tagName: "A",
href: "https://example.com/dispatch/new-app",
hasAttribute: () => false,
getAttribute: () => null,
parentElement: null,
};
dispatchDocument("click", {
defaultPrevented: false,
button: 0,
metaKey: false,
ctrlKey: false,
shiftKey: false,
altKey: false,
target: anchor,
} as unknown as MouseEvent);

fakeWindow.console.error(
"Error loading route module `/dispatch/assets/new-app-stale.js`, reloading page...",
);
fakeLocation.reload();

expect(fakeLocation.assign).not.toHaveBeenCalled();
expect(originalReload).not.toHaveBeenCalled();
expect(fakeLocation.href).toBe("https://example.com/dispatch/apps");
});

it("recovers unhandled dynamic import rejections using the intended target", () => {
const { fakeWindow, fakeLocation, dispatchDocument, dispatchWindow } =
createFakeWindow();
Expand Down Expand Up @@ -290,6 +328,44 @@ describe("route chunk recovery", () => {
expect(preventDefault).toHaveBeenCalled();
});

it("suppresses unhandled dynamic import navigation inside Agent Native desktop", () => {
const { fakeWindow, fakeLocation, dispatchDocument, dispatchWindow } =
createFakeWindow("https://example.com/dispatch/apps", {
userAgent: "Mozilla/5.0 Electron/41.2.2 AgentNativeDesktop/0.1.7",
});

installRouteChunkRecovery(fakeWindow);

const anchor = {
tagName: "A",
href: "https://example.com/dispatch/new-app",
hasAttribute: () => false,
getAttribute: () => null,
parentElement: null,
};
dispatchDocument("click", {
defaultPrevented: false,
button: 0,
metaKey: false,
ctrlKey: false,
shiftKey: false,
altKey: false,
target: anchor,
} as unknown as MouseEvent);

const preventDefault = vi.fn();
dispatchWindow("unhandledrejection", {
reason: new Error(
"Failed to fetch dynamically imported module: https://example.com/dispatch/assets/new-app-stale.js",
),
preventDefault,
} as unknown as PromiseRejectionEvent);

expect(fakeLocation.assign).not.toHaveBeenCalled();
expect(fakeLocation.href).toBe("https://example.com/dispatch/apps");
expect(preventDefault).toHaveBeenCalled();
});

it("moves the current URL before reload when the browser will not let reload be patched", () => {
const { fakeWindow, fakeLocation, originalReload, dispatchDocument } =
createFakeWindow("https://example.com/dispatch/apps", {
Expand Down
13 changes: 13 additions & 0 deletions packages/core/src/client/route-chunk-recovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,10 @@ function hardNavigate(win: Window, href: string): void {
}
}

function isAgentNativeDesktop(win: Window): boolean {
return /AgentNativeDesktop/i.test(win.navigator?.userAgent || "");
}

function recoverToIntendedNavigation(
win: Window,
state: RouteChunkRecoveryState,
Expand All @@ -114,6 +118,9 @@ function recoverToIntendedNavigation(
if (!target) return false;
state.recovering = true;
state.recoveryHref = target;
// Desktop webviews stay open across many deploys; a forced navigation here
// reads as a random tab reload. Leave the current view alive instead.
if (isAgentNativeDesktop(win)) return true;
try {
win.history.replaceState(win.history.state, "", target);
} catch {}
Expand All @@ -139,6 +146,12 @@ function patchHistoryMethod(
function patchReload(win: Window, state: RouteChunkRecoveryState): void {
const originalReload = win.location.reload.bind(win.location);
const patchedReload = function patchedReload() {
if (
isAgentNativeDesktop(win) &&
Date.now() - state.routeModuleFailureAt <= 1_000
) {
return;
}
if (
state.recoveryHref &&
Date.now() - state.routeModuleFailureAt <= 1_000
Expand Down
18 changes: 17 additions & 1 deletion packages/core/src/client/settings/useBuilderStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,11 @@ export function useBuilderConnectFlow(
const [error, setError] = useState<string | null>(null);
const [hasFetchedStatus, setHasFetchedStatus] = useState(false);
const [statusConnectUrl, setStatusConnectUrl] = useState<string | null>(null);
// When statusConnectUrl was last fetched. The server signs the embedded
// _an_connect token with a 10-minute TTL; using an older URL silently
// fails the same-origin check on the popup side. Track freshness so
// start() can fall back to the bare /builder/connect path when stale.
const statusConnectUrlAtRef = useRef<number | null>(null);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const mountedRef = useRef(true);
const notifiedConnectedRef = useRef(false);
Expand Down Expand Up @@ -210,6 +215,7 @@ export function useBuilderConnectFlow(
setEnvManaged(!!s.envManaged);
setBuilderEnabled(!!s.builderEnabled);
setStatusConnectUrl(s.connectUrl ?? null);
statusConnectUrlAtRef.current = s.connectUrl ? Date.now() : null;
const org = s.orgName ?? null;
setOrgName(org);
if (s.configured && !notifiedConnectedRef.current) {
Expand Down Expand Up @@ -254,8 +260,17 @@ export function useBuilderConnectFlow(
// before window.open lets the user-gesture token expire, which causes
// popup blockers to block entirely or fall back to same-tab navigation.
const origin = getCallbackOrigin() || window.location.origin;
// The signed _an_connect token in statusConnectUrl has a 10-minute TTL.
// If the panel has been open longer than that the token is dead and the
// popup will silently 403; drop the cached URL and let the bare /connect
// route do the same-origin Sec-Fetch-Site check instead.
const STATUS_CONNECT_URL_TTL_MS = 9 * 60 * 1000;
const cachedAt = statusConnectUrlAtRef.current;
const cachedFresh =
typeof cachedAt === "number" &&
Date.now() - cachedAt < STATUS_CONNECT_URL_TTL_MS;
const url =
statusConnectUrl ??
(cachedFresh ? statusConnectUrl : null) ??
popupUrl ??
new URL(agentNativePath("/_agent-native/builder/connect"), origin).href;
try {
Expand All @@ -278,6 +293,7 @@ export function useBuilderConnectFlow(
setEnvManaged(!!s.envManaged);
setBuilderEnabled(!!s.builderEnabled);
setStatusConnectUrl(s.connectUrl ?? null);
statusConnectUrlAtRef.current = s.connectUrl ? Date.now() : null;
const org = s.orgName ?? null;
setOrgName(org);
setConnecting(false);
Expand Down
16 changes: 12 additions & 4 deletions packages/core/src/scripts/db/exec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,10 +368,18 @@ function sqliteScopePredicate(
}

const clauses: string[] = [];
if (scoping.userEmail && scoping.ownerEmailTables.has(tableName)) {
clauses.push(`owner_email = '${escapeSqlString(scoping.userEmail)}'`);
}
if (scoping.orgId && scoping.orgIdTables.has(tableName)) {
const hasOwner = scoping.ownerEmailTables.has(tableName);
const hasOrg = scoping.orgIdTables.has(tableName);
if (scoping.userEmail && hasOwner) {
const ownerClause = `owner_email = '${escapeSqlString(scoping.userEmail)}'`;
if (scoping.orgId && hasOrg) {
clauses.push(
`${ownerClause} AND (org_id = '${escapeSqlString(scoping.orgId)}' OR org_id IS NULL)`,
);
} else {
clauses.push(ownerClause);
}
} else if (scoping.orgId && hasOrg) {
clauses.push(`org_id = '${escapeSqlString(scoping.orgId)}'`);
}
return clauses.length > 0 ? clauses.join(" AND ") : null;
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/scripts/db/parameterized.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@ describe("db scripts parameterized SQL", () => {
]);

expect(execute).toHaveBeenCalledWith({
sql: `UPDATE main."notes" SET title = ? WHERE owner_email = 'script+qa-alice@example.com' AND org_id = 'org-qa-1' AND (id = ?)`,
sql: `UPDATE main."notes" SET title = ? WHERE owner_email = 'script+qa-alice@example.com' AND (org_id = 'org-qa-1' OR org_id IS NULL) AND (id = ?)`,
args: ["Scoped title", "note-qa-1"],
});
});
Expand Down
59 changes: 57 additions & 2 deletions packages/core/src/scripts/db/scoping.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,11 +201,12 @@ describe("scoping", () => {
expect(ctx.active).toBe(true);
expect(ctx.orgId).toBe("org-123");

// notes has both owner_email AND org_id — both should appear
// notes has both owner_email AND org_id — the user owns rows in the
// current org plus legacy/personal rows with no org.
const notesView = ctx.setup.find((s) => s.includes('"notes"'));
expect(notesView).toContain('"owner_email" = ');
expect(notesView).toContain('"org_id" = ');
expect(notesView).toContain("AND");
expect(notesView).toContain('OR "org_id" IS NULL');

// org_only_table has only org_id
const orgOnlyView = ctx.setup.find((s) => s.includes('"org_only_table"'));
Expand Down Expand Up @@ -253,6 +254,37 @@ describe("scoping", () => {
expect(notesView).not.toContain("org_id");
});

it("keeps owner legacy rows visible when org scoping is active", async () => {
vi.stubEnv("NODE_ENV", "production");
vi.stubEnv("AGENT_USER_EMAIL", "legacy-owner@test.com");
vi.stubEnv("AGENT_ORG_ID", "org-current");
const { buildScopingSqlite } = await import("./scoping.js");

const mockClient = {
execute: vi.fn().mockImplementation((sql: string) => {
if (sql.includes("sqlite_master")) {
return { rows: [{ name: "decks" }] };
}
return {
rows: [
{ name: "id" },
{ name: "owner_email" },
{ name: "org_id" },
{ name: "title" },
],
};
}),
};

const ctx = await buildScopingSqlite(mockClient);
const decksView = ctx.setup.find((s) => s.includes('"decks"'));

expect(decksView).toContain(`"owner_email" = 'legacy-owner@test.com'`);
expect(decksView).toContain(
`("org_id" = 'org-current' OR "org_id" IS NULL)`,
);
});

it("scopes tool_data to private user rows plus matching org rows", async () => {
vi.stubEnv("NODE_ENV", "production");
vi.stubEnv("AGENT_USER_EMAIL", "tools+qa@test.com");
Expand Down Expand Up @@ -427,5 +459,28 @@ describe("scoping", () => {

expect(ctx.ownerEmailTables.has("tasks")).toBe(true);
});

it("keeps owner legacy rows visible in postgres when org scoping is active", async () => {
vi.stubEnv("NODE_ENV", "production");
vi.stubEnv("AGENT_USER_EMAIL", "legacy-pg@test.com");
vi.stubEnv("AGENT_ORG_ID", "org-pg");
const { buildScopingPostgres } = await import("./scoping.js");

const mockPgSql: any = async function (): Promise<any[]> {
return [
{ table_name: "decks", column_name: "id" },
{ table_name: "decks", column_name: "owner_email" },
{ table_name: "decks", column_name: "org_id" },
{ table_name: "decks", column_name: "title" },
];
};

const ctx = await buildScopingPostgres(mockPgSql);
const decksView = ctx.setup.find((s) => s.includes('"decks"'));

expect(decksView).toContain(`"owner_email" = 'legacy-pg@test.com'`);
expect(decksView).toContain(`("org_id" = 'org-pg' OR "org_id" IS NULL)`);
expect(decksView).toContain("WITH LOCAL CHECK OPTION");
});
});
});
23 changes: 14 additions & 9 deletions packages/core/src/scripts/db/scoping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
* - Template tables use an `owner_email` column for user scoping.
* - Template tables use an `org_id` column for org scoping.
* - Core tables have their own scoping patterns (key prefix, session_id, etc.).
* - When both columns are present, both WHERE clauses are applied (AND).
* - When both columns are present, owner_email is always required; org_id
* narrows to the current org while preserving legacy/personal NULL rows.
*
* Temp views take precedence over real tables in both SQLite and Postgres,
* so the user's SQL runs unmodified against the filtered views.
Expand Down Expand Up @@ -175,23 +176,27 @@ function buildScopedTables(
continue;
}

// Build WHERE clauses for owner_email and org_id
const clauses: string[] = [];
const hasOwner = columns.includes(OWNER_COLUMN);
const hasOrg = columns.includes(ORG_COLUMN);

if (hasOwner) {
clauses.push(`"${OWNER_COLUMN}" = '${safeEmail}'`);
}
if (hasOrg && safeOrgId) {
clauses.push(`"${ORG_COLUMN}" = '${safeOrgId}'`);
const orgClause =
hasOrg && safeOrgId
? ` AND ("${ORG_COLUMN}" = '${safeOrgId}' OR "${ORG_COLUMN}" IS NULL)`
: "";
const realTable = `${qualifiedPrefix}"${table}"`;
scoped.push({
name: table,
viewSql: `${isPostgres ? "CREATE OR REPLACE TEMPORARY" : "CREATE TEMPORARY"} VIEW "${table}" AS SELECT * FROM ${realTable} WHERE "${OWNER_COLUMN}" = '${safeEmail}'${orgClause}${checkOption}`,
});
continue;
}

if (clauses.length > 0) {
if (hasOrg && safeOrgId) {
const realTable = `${qualifiedPrefix}"${table}"`;
scoped.push({
name: table,
viewSql: `${isPostgres ? "CREATE OR REPLACE TEMPORARY" : "CREATE TEMPORARY"} VIEW "${table}" AS SELECT * FROM ${realTable} WHERE ${clauses.join(" AND ")}${checkOption}`,
viewSql: `${isPostgres ? "CREATE OR REPLACE TEMPORARY" : "CREATE TEMPORARY"} VIEW "${table}" AS SELECT * FROM ${realTable} WHERE "${ORG_COLUMN}" = '${safeOrgId}'${checkOption}`,
});
}
}
Expand Down
Loading
Loading