Files
qwen-code/docs/design/web-shell-brand-customization.md
Shaojin WenandQwen-Coder 13b69b3313 feat(web-shell): make the product name and logo configurable (#11244)
* feat(web-shell): make the product name and logo configurable

White-labelling the Web Shell meant editing six source files and rebuilding: the
product name was a literal in the sidebar, the welcome header, the About panel
and the document title, and the logo existed as two hand-duplicated copies of the
same inline SVG.

Brand is now configuration. A name and a logo path are read from the operator
settings scopes, and a new process-global daemon route resolves the logo into a
data URI for the client. Embedding hosts get the same control through a prop that
replaces the resolved value outright. The terminal banner already had its own
white-label settings, so this follows their sanitization and path-resolution
conventions rather than inventing parallel ones.

Two decisions carry the design. The workspace settings layer is never read, so a
repository's own settings file cannot rename the product or name a file for the
daemon to inline into every connected browser. And a custom logo is only ever
rendered as an image, never injected as markup, which is what makes it safe for
the daemon to skip sanitizing the SVG it read.

An untouched installation renders exactly what it did before: the resolved brand
is empty, every consumer falls back to its own built-in literal, and the served
document keeps its original title and favicon bytes, so the existing visual
baselines stay valid.

* fix(docs): sync the daemon capability tag count with the new brand tag

The daemon index states how many capability tags the registry holds, and a
contract test fails the build when the two disagree. Adding the brand tag made
the registry 158 while the index still said 157.

* chore: re-trigger CI on the current head

The previous push arrived while a re-run of the prior commit was still holding
the workflow's concurrency slot, so GitHub suppressed the new run instead of
queueing it and the event does not replay once the slot frees. This leaves the
branch head with no CI at all. An empty commit re-fires the synchronize event;
the repository squash-merges, so it does not survive into main.

* fix(web-shell): Address brand review feedback across resolver, client and docs

- Expose a brandSettled flag from the workspace provider so a
  settled-with-no-brand outcome (older daemon, withdrawn host prop) is
  reported and invalidates stale cached chrome, while the in-flight
  state still fires nothing
- Fall back to the built-in sidebar mark when a logo data URI fails to
  decode, and when a host logo node is falsy; remount the logo image
  per data URI so a decode failure cannot stick to the next logo
- Report an empty host brand name as unset in onBrandResolved, and type
  the callback payload as the document-actionable subset
- Exclude the favicon from the boot watchdog's fatal-resource
  classification and keep its content-free error out of the panel list
- Require a namespaced <svg> root in the resolver, cover the post-decode
  size cap, and pin the exact stderr prefix the protocol doc publishes
- Assert GET /brand answers JSON on the real app for any Accept header
- Sync the capability index count (159), settings docs and the design
  doc with the above

* fix(web-shell): Address second brand review round — settle semantics, SVG scanner, test fixtures

- Settle the brand fetch only on a definitive outcome: an answer, or a
  404 from a route-less daemon. A retryable failure (503 bootstrap, 429,
  transport, old SDK) is unknown rather than absent — settling it
  cleared the pre-paint cache and reset the tab title mid-session over
  a blip nothing retries (review Critical)
- Rewrite the SVG root scanner quote-aware: a quoted `>`, `[` or `]` in
  DOCTYPE literals or the root tag no longer rejects valid documents,
  and an xmlns-shaped substring inside another attribute's value no
  longer certifies a namespace-less document as SVG
- Warn (not reject) when the root svg carries no viewBox and no
  width/height: the browser loads such an image successfully but may
  paint a blank mark at the sidebar's fixed size, and only the daemon's
  stderr can say so
- Treat a nullish host brand prop as absent in the resolution gate,
  matching the `??` one line above
- Pin previously unwitnessed guards: the fd identity re-check and both
  fs soft-fail branches, the logo key remount, the favicon error-list
  exclusion, the unsettled-to-settled transition, whole-object host
  takeover against a daemon logo, and the superseded-client reset and
  catch-leg disposed guards
- Drive GET /brand on a real app with the SPA fallback mounted and a
  token configured, and pin the System settings layer to empty files so
  a maintainer's machine-wide brand cannot leak into the assertions
- Round-trip the pre-paint cache through the real inline script instead
  of pinning each side against its own copy of the key
- Document the hard-link and decoded-size rules, the namespace
  requirement, the viewBox advisory, and the corrected 503 semantics
  (bootstrap catch-all, retryable; a draining daemon does not reject
  this route); the README's onBrandResolved row now names the narrower
  payload type

* fix(web-shell): Close third brand review round — placeholder exclusion, scanner correctness, retry path

- Read brand keys from the pre-substitution snapshot and refuse
  environment placeholders with a warning: placeholder substitution
  draws from the process-wide environment, which a workspace populates
  first at boot, so resolving brand placeholders would let a repository
  supply the value — the workspace exclusion through a side door
  (review Critical)
- Blank quoted SVG attribute spans per UTF-16 code unit, not per code
  point: an astral character collapsed to one space and shifted every
  later index, refusing valid namespaced documents (review Critical)
- Accept prefix-bound <svg:svg> roots that bind xmlns:svg to the SVG
  namespace (browsers render them), keep refusing <svgfoo> near-misses
  and prefix-only bindings on an unprefixed root, and pin the
  whitespace-prefixed fake-xmlns shape that actually exercises the
  quote-blanking
- Make the scaling-geometry advisory value-aware: an empty viewBox,
  zero dimensions, or percentage width/height now warns (loads
  successfully but cannot scale into the sidebar box)
- Add refreshBrand beside refreshCapabilities — same 404-only settle
  rule, generation-guarded, gated to the genuinely-missing state and
  no in-flight fetch — and call it from the connection recovery path,
  so a retryable brand failure no longer leaves in-app chrome and tab
  chrome disagreeing for the page's lifetime
- Warn on console when the sidebar logo fails to decode (the one logo
  failure the daemon's root-tag validation cannot see)
- Pin the O_NONBLOCK FIFO defence with a real mkfifo fixture, the
  published 429/drain transport states on the real app, and make the
  ordering test's body discriminating with a fixture brand
- Document the placeholder carve-out, prefix-bound roots and the
  value-aware advisory across settings docs, schema, protocol reference
  and the design record

* fix(web-shell): Close fourth brand review round — env-var side door, retry machinery, scanner class fix

- Add QWEN_CODE_SYSTEM_SETTINGS_PATH / QWEN_CODE_SYSTEM_DEFAULTS_PATH to
  the hardcoded project-.env exclusions: they select which file becomes
  the System settings layer, so a repository could otherwise promote
  its own file above the operator's User layer and rebrand the shell
  with literal values (review Critical)
- Refuse brand placeholders on substitution, not syntax: run the real
  resolveEnvVarsInString and refuse only when it would change the
  value, so a literal name containing `$` survives while anything a
  workspace-tainted environment could have supplied is refused
- Emit one stderr line per brand warning instead of a joined line, so
  a log rule keyed on one key's prefix keeps firing when another
  misconfiguration exists
- Make the SVG root matcher prefix-agnostic and entity-aware: any
  prefix bound to the SVG namespace qualifies, character references in
  attribute values are decoded before comparison, a degenerate viewBox
  no longer suppresses the scaling advisory, and a prefix-bound root
  with unprefixed children gets its own advisory
- Retry a retryable brand failure once after a short delay and warn
  only when the retry is also refused (404 stays silent); wire
  refreshBrand into the recovery path with tests; gate it on
  in-flight; attribute retries per client instance; reset the brand in
  the render that observes a client change so no committed frame mixes
  two connections
- Pin the O_NONBLOCK flag with a flags-capture witness (the FIFO case
  alone cannot fail fast: a blocked open wedges the worker's event
  loop), skip the FIFO fixture on Windows where the defence does not
  exist, and mock the not-stattable path instead of relying on POSIX
  ENOTDIR
- Document the startup hold vs terminal 503 semantics correctly and
  retire the design record's pre-retry narrative

* fix(web-shell): Close fifth brand review round — real XML parser, docs alignment, hold test

- Parse the logo with the vendored fast-xml-parser instead of the
  hand-rolled root-tag scanner: well-formedness (duplicate attributes,
  misplaced declarations) is now rejected by a real validator, the
  namespace check reads parsed attributes with entity references
  decoded, any prefix bound to the SVG namespace qualifies, and the
  prefix-children advisory walks the parsed tree — construct-aware by
  construction. The DOCTYPE is stripped for the validator with a
  quote- and subset-aware scan because the validator's own DOCTYPE
  handling is quote-blind (review Suggestion, filed as the class-level
  finding)
- Bound entity decoding at Unicode's ceiling: an out-of-range
  character reference no longer throws a RangeError out of the
  resolver — which the route's catch-all turned into a 200 {} that
  dropped the validly configured brand NAME along with the logo
  (review Critical)
- Refuse brand placeholders by comparing the layer's substituted value
  against its pre-substitution snapshot — the authoritative engine
  WITH the home-.env fallback — and keep an unresolvable placeholder
  verbatim so a typo'd variable shows as literal text; say so in all
  five published artifacts, and hint when a missing logo's path still
  contains a placeholder token (review Critical)
- Gate the prefix-children advisory on the default namespace being
  absent: with both bindings, unprefixed children render fine
- Split the scaling advisory's message by cause (zero-area viewport,
  malformed viewBox, no usable geometry) and document both advisories
- Pin the startup-hold guarantee with a real deferred-runtime test:
  GET /brand issued before the runtime is ready is held and answered
  200 once it settles
- Drive the two retry tests on fake timers (BRAND_RETRY_DELAY_MS is
  exported for them) instead of sleeping through the production
  constant, and pin the route's one-stderr-line-per-cause invariant
  with a two-misconfiguration case

* fix(web-shell): Close sixth brand review round — saxes parser, lockfiles

Swap fast-xml-parser (subset validator plus pre-decoded attributes) for
saxes, the streaming XML parser jsdom uses: malformed documents are now
refused instead of shipped, attribute values are entity-decoded exactly
once so the xmlns gate cannot be faked by a second decode, and the parse
stream is closed after write so an unclosed root fails at EOF. Regenerate
both lockfiles for the dependency swap — the round-5 fast-xml-parser edge
was never recorded, which is what turned the frozen pnpm installs red —
and pin the refused well-formedness classes and the double-decode fixture
in the resolver tests.

Co-authored-by: Qwen-Coder <[email protected]>

* fix(web-shell): Enable namespace processing in the brand logo parser

saxes was constructed with default options, so an undeclared namespace
prefix on a child (the classic legacy-export xlink:href with no
xmlns:xlink) parsed clean here while browsers fatal on it — the daemon
shipped a data URI no browser renders, with nothing on stderr. Enable
xmlns: true, matching jsdom (browsers always have namespace support),
flatten the object-shaped attribute values that mode returns, and pin
both sides: undeclared child prefixes refuse, implicitly-bound xml: and
properly declared bindings still accept. Mutation-checked: reverting the
option and the flattening turns the new refusal test red.

Co-authored-by: Qwen-Coder <[email protected]>

---------

Co-authored-by: Qwen-Coder <[email protected]>
2026-09-10 04:54:45 +00:00

29 KiB

Web Shell brand customization

Goal

A deployment can rename the Web Shell product and replace its logo without editing source or rebuilding. Two channels: settings.json for the standalone shell served by qwen serve, and a React prop for hosts that embed @qwen-code/web-shell. An untouched installation renders exactly what it renders today.

Current state

Every structural brand point is a literal. The product name appears in the sidebar brand row and its version tooltip (client/components/sidebar/WebShellSidebar.tsx), in the welcome header (client/components/WelcomeHeader.tsx), as the About panel's version row label (the about.qwenCode message), and in the document title (client/index.html). The logo exists as two hand-duplicated copies of the same artwork: an inline SVG component in the sidebar, and a percent-encoded data URI favicon in index.html. Nothing reads a name or a logo from configuration, no code path writes document.title, and the standalone entry passes sidebar: true, which resolves to sidebar options with no branding key.

The one existing hook is WebShellSidebarBranding.render, which replaces the sidebar's brand row wholesale. It is an escape hatch for embedded hosts, not configuration: it cannot reach the welcome header, the version tooltip, the About row, the document title, or the favicon, and the standalone shell has no way to supply it. It stays supported and unchanged.

Configuration surface

Brand lives under the existing ui section as two string leaves:

{
  "ui": {
    "brand": {
      "name": "QiuQiu Code",
      "logoPath": "~/.qwen/brand/logo.svg"
    }
  }
}

Both default to the empty string, which means "use the built-in brand". An absent or empty name keeps Qwen Code; an absent or empty logoPath keeps the current Qwen mark in both the sidebar and the favicon. The two leaves are independent — a deployment may rename without replacing the artwork, or the reverse.

Both are declared with showInDialog: false and are not added to the web shell's opt-in exposure list, so they never reach the in-browser Settings page. Brand is deployment configuration edited in settings.json, not a preference a viewer of one workspace should be able to change for everyone else, and the Settings page has no control type for "path to an SVG on the daemon's disk". Nesting under ui rather than adding a top-level section keeps the change out of the schema's asserted top-level key list.

Scope, and why workspace is excluded

Brand resolves from the System Defaults, User and System settings layers only, in the precedence mergeSettings gives them. The Workspace layer is not consulted, and the daemon route passes skipWorkspaceSettings so that layer is never even read off disk — the exclusion is structural rather than a filter applied after merging.

A workspace .qwen/settings.json commonly arrives from a cloned repository that the person opening the shell did not write. Letting that file rename the product would let a repo control what every connected browser displays, and letting it name a file for the daemon to read and inline into every client would turn the shell into a rendering surface for repository-controlled content. general.voice.keytermsFile already excludes the workspace layer for the same reason, and brand follows that precedent.

The same boundary closes the placeholder side door. loadSettings substitutes $VAR/${VAR} placeholders from the process-wide environment, which loadEnvironment populates workspace-first at boot — so an operator-layer ${BRAND_NAME} would resolve to whatever a repository's .qwen/.env or env block supplied. Brand keys therefore compare each layer's substituted value against its pre-substitution snapshot (originalSettings) and refuse with a warning whenever substitution actually fired. A placeholder that resolves to itself (the variable is unset) is kept verbatim, so a typo'd variable surfaces as the literal text rather than silently falling back. Every other string leaf keeps the documented substitution behavior; brand is the exception because the exclusion is its whole point.

This is also why the merged settings value is not used: the merged value is exactly the thing that would reintroduce the excluded layer.

Alignment with the terminal banner settings

The TUI already has white-label settings — ui.customBannerTitle, ui.customBannerSubtitle and ui.customAsciiArt — resolved by ui/utils/customBanner.ts. The Web Shell resolver follows their conventions rather than inventing parallel ones, so one deployment's branding behaves the same way on both surfaces:

  • a single-line branding string is stripped of terminal escape sequences, has its whitespace folded, and is clamped to 80 characters, the same cap the banner title uses;
  • a relative asset path resolves against the directory of the settings file that declared it, and soft-fails with a warning when that layer has no owning file;
  • a non-regular file is refused before it is opened, because opening a FIFO read-only blocks until a writer connects.

The Web Shell resolver is strictest where it differs. It rejects an oversized logo where the banner reader truncates it — a truncated SVG is not a smaller SVG, it is a corrupt one. It also refuses a file with more than one hard link, opens with O_NONBLOCK, and re-verifies the file's stat identity on the descriptor before reading, where the banner reader re-checks only that what it opened is still a regular file.

Symlink handling is not a divergence, and an earlier draft of this document claimed it was: the banner reader stats with lstatSync and refuses anything that is not a regular file, so a symlinked art file is already rejected there before the open. Both resolvers refuse symlinks; they only differ in whether the refusal gets its own message.

Logo resolution

A new consumer service resolves logoPath at request time. Nothing in settings loading dereferences it; resolution happens at the point of use, matching how every other path-valued setting in this repository behaves.

The path expands a leading ~, and a relative path resolves against the declaring settings file's directory as described above. The file must be a regular file, not a symlink, with a single hard link, and must open without following symlinks and re-verify its stat identity before the bytes are read. The content is capped at 32 KiB — a logo is a handful of paths, and the cap keeps the resulting data URI well inside both a JSON payload and a localStorage write. The parsed document's root element must be an <svg> that declares the SVG namespace — the default xmlns="http://www.w3.org/2000/svg", or an xmlns:svg binding on a prefix-bound <svg:svg> root, which is equally renderable — tolerating a BOM, an XML declaration, comments, and a DOCTYPE with or without an internal subset before it. This is a renderability check, not a sanitizer. The document is parsed with saxes — the streaming XML parser jsdom itself uses, with namespace processing enabled as browsers always have it — so a document a browser's XML parser would refuse (duplicate attributes, junk after the root element, undeclared entities, undeclared namespace prefixes, out-of-range character references) is refused here too, attribute values are entity-decoded exactly once before the namespace comparison, and a namespace-shaped substring inside another attribute's value does not satisfy it. A root with no non-empty viewBox and no explicit positive, non-percentage width/height is accepted with an advisory on the daemon's stderr, because the browser cannot scale such artwork into the fixed sidebar box and may render it blank. A rejection yields a warning and no logo, so a bad path degrades to the built-in mark instead of failing the request.

The resolved SVG is percent-encoded into a data:image/svg+xml URI on the server. A data URI rather than a served file because the shell's Content Security Policy already permits img-src 'self' data: blob:, while the daemon's static server exposes only /assets/* and / — the same constraint that made the existing favicon a data URI. No policy change is needed.

Rendering custom logos as images, not markup

The client renders a custom logo as an img element whose src is the data URI. It never injects the SVG as markup.

This is the load-bearing security decision in the design, and it is what makes the absence of a sanitizer correct rather than negligent. SVG loaded as an image — in an img element or as a favicon — cannot run script in any current browser. SVG injected into the document can. Because no code path injects resolver output into the DOM as markup, an SVG containing script or event handlers is inert by construction, and the resolver does not need to parse and rewrite the artwork's internals.

The built-in Qwen mark stays an inline SVG component. It is first-party artwork, and staying inline lets it inherit color from the surrounding CSS. Only custom logos take the image path.

Delivery

A new daemon route returns the resolved brand. Ownership classification: process-global. It takes neither a workspace selector nor a session id, the same classification as the sessionless user-level language route, because the value derives from user-global configuration. The response carries an optional name and an optional logo data URI; either may be absent, and absence means the client uses its built-in default.

The route is not folded into /capabilities. That envelope's documented position is that clients probe by connecting rather than reading ambient settings into it, precisely because doing so would make the envelope depend on the user's home configuration — and brand is the user's home configuration. It is not folded into /workspace/settings either: brand is deliberately not workspace-scoped, that channel reports merged effective values which would reintroduce the excluded layer, and a settings descriptor has no place for a derived data URI that is not the value the user wrote.

The client fetches it on connection setup, inside the workspace provider beside the capabilities fetch, and exposes it as an optional field on the workspace context. It stays out of the connection status machine: a failed brand fetch leaves the built-in brand in place and never marks the connection as errored. The call is deferred into a promise chain rather than made directly, because the SDK is a peer dependency and a host on an older SDK has a client with no brand method at all — a synchronous TypeError thrown out of a mount effect would white-screen the shell over a cosmetic feature.

There is no live-reload subscription. The daemon does not watch settings files at all — the settings watcher is instantiated only by the interactive terminal — so no setting propagates from an external file edit today, and brand matching that behavior is consistency rather than a gap. A page reload picks up a change. A write through any settings route still bumps the client's settings signal, which does not re-fetch brand; brand is re-read on the next connection. One bounded retry exists purely for transient transport failures — a retryable rejection is re-asked once after a short delay, and the connection recovery path re-asks through refreshBrand when the brand is still missing — neither touches the live-reload boundary.

Title and favicon before first paint

The document title and favicon live in index.html, which the browser parses before any React code runs. To keep a renamed deployment from flashing the default name in the tab on every load, brand follows the mechanism theme already uses:

  • when the resolved brand arrives, the standalone entry writes it to a localStorage key, ignoring storage failures the same way the theme and language writers do;
  • the pre-paint inline script in index.html reads that key and applies the title and swaps the icon link's href before first paint.

The first-ever load still shows the default title until the fetch resolves. Every load after that is flash-free. This is the identical trade-off theme makes today.

The document title derives from the name rather than getting its own setting: when a brand name is present the title is that name followed by the existing suffix, and when it is absent the current literal is untouched. One knob, not two.

No server-side HTML rewriting is introduced. The static handler keeps sending index.html as a plain file, so there is no response body mutation and no interaction with its ETag, Content-Length, or security headers. Getting the title from a cached localStorage value is what makes that possible. Verified against a running daemon: the served document still carries the built-in title and the built-in favicon bytes.

Un-setting a logo is the one asymmetric case. The built-in favicon exists only as a literal in index.html, so once the entry has overwritten the icon link's href it cannot be restored from within the same page. The entry therefore clears the cache when the resolved brand has no logo, and the next load — with no cache to apply — gets the built-in favicon back from the document itself. The title needs no such handling, because the entry derives the default from the shared built-in name constant and restores it immediately.

Only the standalone entry writes to document. It learns the resolved brand through a callback prop, the same shape as the existing theme and language change callbacks, which keeps every document write out of the component an embedding host mounts.

The callback must not fire while the brand fetch is in flight: the workspace context reports undefined both then and on a daemon without the route, and firing on the in-flight case would reset the tab title and drop the cache on every load, flashing branded to default to branded. But "no value yet" and "no value, ever" are genuinely different outcomes, and an earlier draft of this design conflated them — pointed at a daemon too old to have the route, a previously cached brand would have stayed in the tab chrome forever because nothing could report the absence. The provider therefore exposes a settled flag beside the value, and the callback fires on a host prop or on that flag. The flag flips only on a definitive outcome — an answer (a brand or an empty object), or a 404 from a daemon that has no route and never will — because a retryable failure (a 503 while the deferred runtime starts, a 429, a transport blip, an old SDK with no brand()) is unknown, not absent: settling there would report an authoritative empty brand, reset the tab title mid-session and delete the cache over a blip. A blip is retried once after a short delay and re-asked through the workspace-error recovery path when the brand is still missing, but a settings-signal bump never re-fetches it. A settled-with-no-brand outcome reports an empty brand, which clears stale cached chrome; an unsettled one reports nothing, which is what keeps the flash away. An older daemon's 404 and a host withdrawing its brand prop both settle, so both invalidate the cache the same way.

The prop channel

The shell component accepts an optional brand carrying a name and a logo node. The prop wins over the fetched value, mirroring the precedence the existing theme and language resolution effects already implement.

The prop's logo is a React node, not a data URI, because an embedding host owns its own document and its own Content Security Policy and may pass inline markup, an image, or a component. Hosts also own their tab title and favicon: the shell writes to document only from the standalone entry, never from inside the component, so an embedded shell cannot hijack its host page's title.

Overall precedence, highest first: the existing sidebar branding render override, then the brand prop, then the fetched value, then the built-in default.

What the name replaces

The brand name drives exactly the structural points where the product names itself: the sidebar brand row, the sidebar footer's version tooltip, the welcome header title, the About panel's version row label, and the document title in the standalone shell. The brand logo drives the sidebar mark and the favicon.

What it deliberately does not replace

The auth provider label reading Qwen OAuth names the identity provider, not the product. A white-labeled shell still authenticates against Qwen's OAuth service, and renaming that string would misdescribe the auth flow to the person about to grant it access.

The roughly forty localized strings that mention the product inside longer prose — settings page descriptions, channel setup, live host installation, workflows, skill descriptions — are out of scope by decision. Covering them requires a message-override mechanism on the localization provider, which does not exist and is a materially larger change. The consequence is stated plainly so it is not discovered later: a renamed shell still says Qwen Code in body copy.

One localized string is removed rather than left behind: the About panel's version-row label existed only to say the product name, and the brand name now supplies it, so keeping the key would leave an orphan in both language tables.

The terms-of-service and documentation links, and the bug report URL, point at real Qwen resources and keep pointing there.

The public CSS custom properties, the data attributes, and the localStorage key prefixes are contracts, and renaming the storage keys would discard every existing user's saved preferences for no visible gain. The theme wire values that contain the product name are persisted into the theme setting; renaming them is a settings compatibility migration, not cosmetics.

The terminal CLI is out of scope and already served: it has its own white-label settings for the banner title, subtitle and ASCII logo. The web shell imports nothing from core or from the CLI package and shares no brand constant with them, so this change neither renames the terminal product nor depends on it. A deployment that wants both surfaces rebranded configures both, which is why the resolver mirrors the banner settings' conventions.

The welcome title's gradient is already a public CSS custom property a host can override without this feature.

Files affected

Layer Change
packages/cli/src/config/settingsSchema.ts two string leaves under ui, both showInDialog: false
packages/vscode-ide-companion/schemas/settings.schema.json regenerated, not hand-edited
packages/cli/src/services/web-shell-brand.ts new resolver: layer selection, name sanitization, hardened SVG read, data URI encoding
packages/cli/src/serve/routes/brand.ts new process-global route
packages/cli/src/serve/server.ts route registration
packages/sdk-typescript/src/daemon/types.ts new response type
packages/sdk-typescript/src/daemon/DaemonClient.ts new client method
packages/sdk-typescript/src/index.ts, src/daemon/index.ts type re-exports
packages/web-shell/client/brandContext.ts new: context, provider, hooks, built-in name, stable empty value
packages/web-shell/client/daemon/workspace/types.ts optional brand and settled-flag fields on the workspace context
packages/web-shell/client/daemon/workspace/DaemonWorkspaceProvider.tsx fetch once per connection with a settled flag, one bounded retry for transient failures, outside the status machine
packages/web-shell/client/App.tsx brand and callback props, precedence resolution, provider mount
packages/web-shell/client/index.tsx export the brand prop type
packages/web-shell/client/components/sidebar/WebShellSidebar.tsx name, version tooltip, logo with a falsy-node fallback and an image-decode fallback
packages/web-shell/client/components/sidebar/WebShellSidebar.module.css size the img logo like the inline svg one
packages/web-shell/client/components/WelcomeHeader.tsx name
packages/web-shell/client/components/messages/StatusMessage.tsx About row label
packages/web-shell/client/i18n.tsx drop the orphaned About label key from both tables
packages/web-shell/client/index.html pre-paint title and favicon from cache; boot watchdog excludes the icon from its fatal-resource classification and its error list
packages/web-shell/client/main.tsx apply title and favicon, write and clear the cache
packages/web-shell/client/e2e/utils/mockDaemon.ts answer the new route
packages/web-shell/README.md, docs/users/configuration/settings.md document both channels

Testing

The resolver gets the bulk of the unit coverage, because it is the part that touches the filesystem: layer precedence in both directions, workspace-layer exclusion for the name and the logo, placeholder rejection from the pre-substitution snapshot (with a literal higher layer still overriding a placeholder below), name sanitization and the length cap, tilde expansion, relative resolution against the declaring file and the soft-fail when there is none, missing file, symlink, directory, a FIFO swapped in past the pre-open guards, oversize content in both the pre-read and post-decode caps, the fd identity re-check and both fs soft-fail branches, non-SVG content, an xmlns-less root, an xmlns-shaped substring hidden in another attribute's value (immediately after the quote, and whitespace-prefixed inside the value — the shape that pins the blanking), a prefix-only namespace binding, a prefix-bound <svg:svg> root accepted with its binding and refused without it, an <svgfoo> near-miss refused, an astral character before the xmlns, whitespace around the attribute =, a quoted > or bracket inside DOCTYPE literals and the root tag, and the scaling-geometry advisory for a root with no usable geometry — absent, empty, zero or percentage. One case asserts that an SVG containing script is accepted — the resolver is meant to pass bytes through. The alarm for a renderer that inlines those bytes lives on the other side of the package boundary, in the sidebar test: it renders a script-bearing logo and asserts the payload reaches the document only as an img src with zero script nodes present. A future change that inlines the logo fails that test, not the resolver's.

The route test asserts the response shape, that skipWorkspaceSettings is passed, that a rejected logo still answers 200 with the name and reports the reason on stderr, and that a settings failure degrades to an empty brand rather than erroring. The schema test asserts both leaves and that neither reaches the settings dialog; the settings route test asserts the keys are neither exposed nor writable through it.

On the client, the provider test asserts the brand and the settled flag reach the context on success, that a definitive 404 settles with no brand while a retryable 503 stays unsettled (unknown, not absent), that a retryable failure retries once on its own and is attributed on the console only once the retry is exhausted, that refreshBrand re-issues the fetch with the same settle rule and is a no-op on an already-resolved, settled, or in-flight brand, that a superseded client can neither write its brand into nor settle the new connection nor be re-asked by a refresh (per-instance call attribution), that a token rotation re-fetches against the new identity, and that no committed frame carries the previous client's brand beside the new connection's baseUrl. The app test asserts prop-wins-over-fetched precedence (including against a daemon brand that carries a logo — the takeover is whole-object, not field-merge), in-flight silence for both an absent and a nullish host prop, the unsettled-to-settled transition that production actually takes, settled-with-no-brand reporting (which is what clears stale chrome), the recovery path re-asking the brand alongside capabilities, host-prop withdrawal reporting the empty brand again, empty-name normalization in the payload, logo-URI pass-through, and no re-firing for a fresh-but-equal inline prop and handler. The sidebar test asserts the built-in rendering is untouched — the inline mark is asserted present, not merely no image asserted absent — the name and tooltip follow the brand, the logo renders as an image with no script node in the document, an undecodable logo falls back to the built-in mark on error with a console.warn (the one logo failure the daemon cannot see), a replacement URI after such a failure gets a fresh mount, a falsy host logo falls back too, a host logo node renders as given and never beside the built-in mark, and the existing branding render override still wins. A server-level test asserts the route answers JSON on the real app for a browser-like Accept with the SPA fallback mounted, that it sits behind bearer authentication when a token is configured, that the read-tier limiter can 429 it, and that a draining daemon does not reject it; the System settings layer is pinned to empty files there so a maintainer's machine-wide brand cannot leak into the assertions, and the ordering test's body carries a fixture brand so the response cannot be confused with the route's error fallback. The boot watchdog test asserts a failing favicon neither proves boot impossible nor enters the panel's error list past the grace timer. The standalone entry test asserts the title, the favicon (untouched for a name-only brand), the cache write, and clearing the cache when the brand goes away, plus that the built-in title is derived to match the document's own static title, and — by reading the entry the entry-point actually wrote, located by enumeration rather than by a second copy of the key — that the real pre-paint script applies both fields back. The pre-paint script joins the existing index.html contract test, which also pins the built-in title and favicon bytes. A parity test runs the same inputs through the TUI banner resolver and asserts identical sanitization.

One property matters more than the others: with no brand configured, the rendered shell is unchanged. That is what makes this safe to ship, and it is what keeps the existing Playwright visual baselines valid instead of requiring a re-capture.

Decisions settled during implementation

A long brand name is limited in both places. The resolver clamps to 80 characters to match the TUI banner title, and the sidebar brand row already carried overflow: hidden with text-overflow: ellipsis, so a name at the cap still degrades gracefully in a narrow sidebar. No new layout rule was needed.

The resolved brand is not cached on the daemon. It is read from settings on each request and the file read is bounded at 32 KiB, so caching would buy nothing and add an invalidation question — the daemon has no settings watcher to invalidate it with.

The brand prop replaces the fetched brand wholesale rather than merging field by field. That matches how the theme and language props already behave, and a host that passes brand is deliberately taking control of branding.