diff --git a/.changeset/ai-chat-public-share-base-4482.md b/.changeset/ai-chat-public-share-base-4482.md
new file mode 100644
index 0000000000..3ad06d0251
--- /dev/null
+++ b/.changeset/ai-chat-public-share-base-4482.md
@@ -0,0 +1,28 @@
+---
+'@object-ui/app-shell': patch
+---
+
+`AiChatPage`'s public share-link base now resolves through the one console-mount
+resolver instead of a private copy of it (objectui#4482).
+
+The page built `publicShareBase` itself — read the injected ``, take its
+pathname, trim trailing slashes, concatenate `${origin}${base}/s` — which was the third
+independent implementation of the mount resolution `resolveConsoleUrl` centralizes.
+objectui#4472 had just deleted the other two on that rule; this was the surviving
+sibling. Its output was correct, so nothing a user hits was broken and nothing a user
+hits changes: measured over the base-href matrix, the deleted builder and
+`resolveConsoleUrl('s')` return identical URLs for every shape the console is served in
+— `/_console/` (the only href the framework CLI injects), `/` root mounts, `./` portable
+builds, nested mounts, and no `` at all.
+
+The `/s` resolution now lives beside its three siblings as `resolvePublicShareBase()`,
+which keeps the one thing a bare `resolveConsoleUrl('s')` call would drop: with no DOM
+it returns `undefined` rather than a URL built from an origin that does not exist, so
+`ShareDialog` applies its own fallback. It deliberately takes no `baseURI` argument —
+the mount is only ever carried by the injected ``, and a resolver with no
+other input cannot be pinned by a test that steers something production never reads.
+
+`resolvePublicShareBase.browser.test.tsx` pins the resolved base against a real injected
+`` element for each deployment shape, plus a structural case asserting no other
+app-shell file reads the `` tag — so a fourth copy fails a test rather than
+waiting for mount semantics to change under it.
diff --git a/packages/app-shell/src/console/ai/AiChatPage.tsx b/packages/app-shell/src/console/ai/AiChatPage.tsx
index 58084f8dcb..4763669391 100644
--- a/packages/app-shell/src/console/ai/AiChatPage.tsx
+++ b/packages/app-shell/src/console/ai/AiChatPage.tsx
@@ -23,6 +23,7 @@ import { useAdapter } from '../../providers/AdapterProvider.js';
import { useMetadata } from '../../providers/MetadataProvider.js';
import { formatPublishFailures, type PublishFailure } from '../../views/studio-design/metadataError.js';
import { resolveKeyedI18nLabel } from '../../utils/index.js';
+import { resolvePublicShareBase } from '../organizations/resolveHomeUrl.js';
import { ExcelImportBar } from './ExcelImportBar.js';
import {
Select,
@@ -980,20 +981,10 @@ export function AiChatPage({ apiBase: apiBaseProp, defaultAgent: defaultAgentPro
// Public share-link landing base. SharedRecordPage lives UNDER the console
// SPA basename (e.g. `/_console/s/:token`), so the ShareDialog default of
- // `${origin}/s/:token` 404s for recipients. Derive the base from the SPA's
- // BASE_URL so the copyable link points at the actually-served route.
- const publicShareBase = useMemo(() => {
- if (typeof window === 'undefined' || typeof document === 'undefined') return undefined;
- // Mirror the console's own basename resolution (App.tsx resolveBasename):
- // the published SPA uses a relative Vite base, so the mount path is carried
- // by the injected `` tag, NOT import.meta.env.BASE_URL.
- let base = '';
- try {
- const href = document.querySelector('base')?.getAttribute('href');
- if (href) base = new URL(href, window.location.origin).pathname.replace(/\/+$/, '');
- } catch { /* no → root-mounted SPA */ }
- return `${window.location.origin}${base}/s`;
- }, []);
+ // `${origin}/s/:token` 404s for recipients. The mount comes from the one
+ // console-mount resolver, which reads the injected `` — this used
+ // to be a third hand-rolled copy of that resolution (objectui#4482).
+ const publicShareBase = useMemo(() => resolvePublicShareBase(), []);
// New-conversation race guard. On an IN-SPA `/ai?new=1` navigation the
// URL-mirroring effect below fires in the SAME commit as the hook's effect,
diff --git a/packages/app-shell/src/console/organizations/__tests__/resolvePublicShareBase.browser.test.tsx b/packages/app-shell/src/console/organizations/__tests__/resolvePublicShareBase.browser.test.tsx
new file mode 100644
index 0000000000..1f41e684d5
--- /dev/null
+++ b/packages/app-shell/src/console/organizations/__tests__/resolvePublicShareBase.browser.test.tsx
@@ -0,0 +1,180 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+// @vitest-environment happy-dom
+
+/**
+ * The public share-link base resolves through the ONE console-mount resolver
+ * (objectui#4482).
+ *
+ * ## What was wrong, given nothing was broken
+ *
+ * `AiChatPage` built `publicShareBase` itself: read ``, take its
+ * pathname, trim trailing slashes, concatenate `${origin}${base}/s`. Measured
+ * output was correct for every deployment shape the console is actually served
+ * in — this card is drift risk, not a defect. It was the third independent copy
+ * of a resolution `resolveConsoleUrl` already centralizes; objectui#4472 had
+ * just deleted the other two. A correct-today duplicate is precisely the shape
+ * that rots: the next change to mount semantics updates the helper and misses
+ * the copy.
+ *
+ * So a green suite proves nothing by itself here, and these cases are written
+ * to be discriminating rather than merely passing — see the reverse
+ * verification at the bottom of this header.
+ *
+ * ## Why the pins are driven by an injected `` element
+ *
+ * PR #4480 recorded the trap: `vi.stubEnv('BASE_URL', …)` does nothing against
+ * the `import.meta.env.BASE_URL` spelling, because Vite inlines that at
+ * transform time. A test steering it is permanently green while testing
+ * nothing the production path reads. The mount is carried by the injected
+ * `` tag and by nothing else, so every case below sets a real
+ * `` element in the document — the same mechanism the framework CLI
+ * writes into the served HTML (``) and the same
+ * one the router's own basename resolution reads (`apps/console/src/App.tsx`).
+ *
+ * ## Equivalence measured at fix time
+ *
+ * Old builder vs `resolveConsoleUrl('s')` over the base-href matrix: identical
+ * output for every reachable input — `/_console/` (the CLI's only injection,
+ * always trailing-slashed), `/` (root mount), no `` (dev/standalone),
+ * `./` (portable build), and nested mounts. They differ only for inputs
+ * nothing emits: a base href with NO trailing slash, and a cross-origin
+ * absolute base href. In both the shared resolver follows the HTML base-URL
+ * semantics the router and the SPA's relative asset URLs already live by,
+ * while the deleted copy treated the href as a directory prefix and forced the
+ * document origin. Recorded on the issue rather than fixed here — changing
+ * `consoleRoot()` would move `/home` and org-switch navigation too.
+ *
+ * ## Reverse verification (performed when written)
+ *
+ * - Making `consoleRoot()` ignore the `` tag (always the origin root)
+ * reddens the two mount cases — `/_console/` and the port/scheme one — while
+ * the root-mount and no-`` cases stay green, because they resolve to
+ * the same URL either way. That split is the point: it shows the mount cases
+ * are measuring the mechanism and not a constant. It also reddens the scan
+ * self-check below, which was NOT predicted and is worth writing down: that
+ * case asserts the resolver still contains a `` read, and this ablation
+ * deletes precisely that. The coupling is correct — "the resolver is the one
+ * place that reads the tag" is false once the resolver stops reading it — but
+ * it means the self-check is not independent of `consoleRoot()`'s body.
+ * - Deleting the no-DOM guard from `resolvePublicShareBase` reddens the SSR
+ * case by THROWING (`resolveConsoleUrl` reaches `window.location.origin`
+ * through an undefined `window`), not by returning a wrong string.
+ * - Re-introducing a hand-rolled `document.querySelector('base')` into
+ * `AiChatPage` reddens the one-resolver case with that path named.
+ * - Pointing `consoleRoot()`'s no-`` fallback at `document.baseURI`
+ * reddens only the deep-route case — the regression `resolveHomeUrl`'s
+ * header already records, here for the share base.
+ */
+
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { readFileSync, readdirSync, statSync } from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { resolvePublicShareBase } from '../resolveHomeUrl';
+
+function setBaseHref(href: string | null): void {
+ document.head.querySelectorAll('base').forEach((el) => el.remove());
+ if (href != null) {
+ const base = document.createElement('base');
+ base.setAttribute('href', href);
+ document.head.appendChild(base);
+ }
+}
+
+describe('resolvePublicShareBase (injected mechanism)', () => {
+ afterEach(() => {
+ setBaseHref(null);
+ vi.unstubAllGlobals();
+ history.pushState({}, '', '/');
+ });
+
+ it('lands under the console mount the framework CLI injects', () => {
+ // `` — packages/cli/src/utils/console.ts writes
+ // exactly this (CONSOLE_PATH + '/'). A recipient opening the copied link
+ // must reach SharedRecordPage at /_console/s/:token, not /s/:token.
+ setBaseHref('/_console/');
+ expect(resolvePublicShareBase()).toBe(`${window.location.origin}/_console/s`);
+ });
+
+ it('is the origin root on a root-mounted deployment', () => {
+ setBaseHref('/');
+ expect(resolvePublicShareBase()).toBe(`${window.location.origin}/s`);
+ });
+
+ it('is the origin root when the host injected no at all', () => {
+ // Standalone / `os dev` runs ship no ; the SPA is root-mounted.
+ setBaseHref(null);
+ expect(resolvePublicShareBase()).toBe(`${window.location.origin}/s`);
+ });
+
+ it('ignores the current SPA route when no is present', () => {
+ // The share dialog opens from deep inside the chat surface. Resolving
+ // against the current document URL would produce /ai/agent/s — the same
+ // family of bug as resolveHomeUrl's /home/home/home regression.
+ setBaseHref(null);
+ history.pushState({}, '', '/ai/support_agent/conv_123');
+ expect(resolvePublicShareBase()).toBe(`${window.location.origin}/s`);
+ });
+
+ it('carries a non-default port and scheme through unchanged', () => {
+ setBaseHref('/_console/');
+ const url = new URL(resolvePublicShareBase()!);
+ expect(url.origin).toBe(window.location.origin);
+ // Regression the resolver family exists for: `${origin}${BASE_URL}` with a
+ // relative Vite base produced a trailing-dot host in production.
+ expect(url.hostname.endsWith('.')).toBe(false);
+ expect(url.pathname).toBe('/_console/s');
+ });
+
+ it('returns undefined with no DOM instead of guessing an origin', () => {
+ // ShareDialog then applies its own fallback. A string built here without a
+ // window would be a link to a host that does not exist.
+ setBaseHref('/_console/');
+ vi.stubGlobal('window', undefined);
+ expect(resolvePublicShareBase()).toBeUndefined();
+ });
+});
+
+/**
+ * The structural half. The behavioural cases above cannot tell "routes through
+ * the shared resolver" from "hand-rolls the same answer correctly" — which is
+ * the entire subject of this card, and what the next copy will look like.
+ */
+describe('objectui#4482 — app-shell reads in exactly one place', () => {
+ const here = path.dirname(fileURLToPath(import.meta.url));
+ const srcRoot = path.resolve(here, '../../..');
+ const RESOLVER = path.join(srcRoot, 'console/organizations/resolveHomeUrl.ts');
+ const BASE_TAG_READ = /(querySelector|querySelectorAll|getElementsByTagName)\(\s*['"`]base['"`]\s*\)/;
+
+ function walk(dir: string, out: string[] = []): string[] {
+ for (const entry of readdirSync(dir)) {
+ const full = path.join(dir, entry);
+ if (statSync(full).isDirectory()) walk(full, out);
+ // Tests legitimately create and clear elements — including this
+ // file, which must not fence itself out of existence.
+ else if (/\.tsx?$/.test(entry) && !/\.test\.tsx?$/.test(entry)) out.push(full);
+ }
+ return out;
+ }
+
+ const files = walk(srcRoot);
+
+ it('is reading the real tree, not an empty one', () => {
+ expect(files.length).toBeGreaterThan(200);
+ expect(files).toContain(RESOLVER);
+ });
+
+ it('no app-shell file outside the resolver reads the tag', () => {
+ const offenders = files.filter(
+ (f) => f !== RESOLVER && BASE_TAG_READ.test(readFileSync(f, 'utf8')),
+ );
+ expect(offenders.map((f) => path.relative(srcRoot, f))).toEqual([]);
+ });
+
+ it('the scan can see a hand-rolled read (it is not a regex that never matches)', () => {
+ // Without this the case above would pass just as well spelled wrong. This
+ // is the exact line deleted from AiChatPage.
+ expect(BASE_TAG_READ.test("document.querySelector('base')?.getAttribute('href')")).toBe(true);
+ expect(BASE_TAG_READ.test(readFileSync(RESOLVER, 'utf8'))).toBe(true);
+ });
+});
diff --git a/packages/app-shell/src/console/organizations/resolveHomeUrl.ts b/packages/app-shell/src/console/organizations/resolveHomeUrl.ts
index 48af126635..c04403cbd4 100644
--- a/packages/app-shell/src/console/organizations/resolveHomeUrl.ts
+++ b/packages/app-shell/src/console/organizations/resolveHomeUrl.ts
@@ -58,3 +58,35 @@ export function resolveRootUrl(baseURI?: string): string {
export function resolveConsoleUrl(path: string, baseURI?: string): string {
return new URL(path.replace(/^\//, ''), resolveRootUrl(baseURI)).toString();
}
+
+/**
+ * Resolve the public share-link landing base — the console-mounted `/s` route
+ * that `SharedRecordPage` serves (`/_console/s/:token` on a CLI-served
+ * deployment). `ShareDialog` appends `/:token` to it for the link a user
+ * copies and hands to a recipient.
+ *
+ * `ShareDialog`'s own default is `${origin}/s/:token`, which 404s wherever the
+ * console is not root-mounted. The link is a full-page destination pasted into
+ * another browser, so it needs exactly the deployment-mount resolution the
+ * navigations above already do — and it takes it from {@link resolveConsoleUrl}
+ * rather than a private copy. This helper replaced the third hand-rolled
+ * base-href reader in the console (objectui#4482); objectui#4472 removed the
+ * other two. One resolver, so the next change to mount semantics cannot reach
+ * some call sites and miss others.
+ *
+ * Returns `undefined` — not a guessed URL — with no DOM, so `ShareDialog`
+ * applies its own fallback instead of rendering a link built from an origin
+ * that does not exist. That guard is the one thing this adds over a bare
+ * `resolveConsoleUrl('s')` call.
+ *
+ * Deliberately takes no `baseURI` argument, unlike its three siblings: the
+ * mount is only ever carried by the injected ``, and a resolver
+ * with no other input cannot be pinned by a test that steers something the
+ * production path never reads (the `vi.stubEnv('BASE_URL', …)` trap recorded
+ * in objectui#4482 — Vite inlines `import.meta.env` at transform time, so such
+ * a test is permanently green).
+ */
+export function resolvePublicShareBase(): string | undefined {
+ if (typeof window === 'undefined' || typeof document === 'undefined') return undefined;
+ return resolveConsoleUrl('s');
+}