feat(client): consume brand identity agents/services allowlists - #479
feat(client): consume brand identity agents/services allowlists#479lucas77778 wants to merge 9 commits into
Conversation
CODE-618: the client-side schema and brand-identity mirror must accept the publisher's optional per-brand agents/services fields (subset of known agent kinds / kebab-case service ids) before any restricted-brand bundle can be consumed. Absent stays absent so unrestricted builds are byte-identical.
CODE-618: mirrors brand.ts's MAIN_VITE_BRAND_IDENTITY precedent for a new MAIN_VITE_AGENT_RESTRICTIONS define, parsed once at boot into AGENT_RESTRICTIONS (fail-closed on a present-but-invalid snapshot, same as the brand identity). Exposes it to the renderer via a new synchronous systemBridge.identity.restrictions() IPC channel (sendSync, like settings.snapshot) so the composer's harness picker never flashes the full agent set on a restricted build. Absent bundle agents/services is a no-op end to end.
CODE-618: apps/daemon/src/config.ts#daemonAllowedAgents() parses LINKCODE_ALLOWED_AGENTS (set by the desktop supervisor from AGENT_RESTRICTIONS, next commit); restrictedAdapterFactory() wraps createAdapter to reject any kind outside it via the existing EngineDeps.factory injection seam — no change to @linkcode/agent-adapter itself. Only gates new adapter construction: a session started before a restriction landed keeps replaying on its existing adapter, and history reads never call this. Also skips background-refreshing a disallowed agent's managed install at boot. `null` (unrestricted) is an exact no-op.
CODE-618 acceptance (a): agentFilesExcludes() maps each disallowed agent kind to its staged node_modules SDK package (claude-agent-sdk / codex / opencode-ai sdk -- pi's SDK is a hosted download and grok-build has no SDK, so neither needs an entry). package-app.mts wraps the resolved electron-builder config in a temporary `extends` overlay adding these as `files` excludes only when the rendered bundle declares `agents`; verify-artifacts.mts asserts their absence post-pack by exact asar path segment (never a prefix match, so e.g. @openai/codex-darwin-* can't false-positive). Both are no-ops when the bundle carries no `agents`, keeping the standard build's packaging and verification unchanged.
…ootstrap CODE-618: BundledConfigBootstrap gains allowedAgents/allowedServices (bundle.agents/.services ?? null); the dev sentinel and every existing platform fixture stay null (unrestricted), so today's builds are unaffected. new-thread-sheet.tsx's kind picker narrows to BUNDLED_CONFIG_BOOTSTRAP .allowedAgents ?? every known kind, and hides its whole Section outright (never just disables) once at most one kind remains selectable -- mobile is single-process, so this needs no IPC.
… by brand CODE-618 acceptance (c, d): selectableHarnessKinds(providers, allowedAgents?) intersects the enabled set with the brand allowlist; workbench.tsx threads it in from a new Workbench allowedAgents prop (desktop's app.tsx supplies systemBridge.identity.restrictions().allowedAgents, keeping this package IPC-free). composer-controls.tsx's ModelSelectorMenu now hides the harness submenu at <=1 selectable harness instead of 0, so a single remaining harness disappears everywhere the picker is rendered. add-flow.tsx's ServiceCatalogView filters two axes: oauth entries by their bound agent against allowedAgents, everything else (including custom, which needs no special case since it is excluded by the same id-set intersection as any other service) by allowedServices. serviceById, the account list/detail, and account resolution stay unfiltered everywhere, so an account bound to a since-hidden service keeps rendering and resolving. agents-settings.tsx's row list gets the same allowedAgents narrowing (hides the row rather than disabling its switch); view.ts's account-binding resolution is untouched. All of the above default to null (unrestricted) and are no-ops for the standard/unbranded build.
Greptile SummaryThe PR consumes build-time brand agent and service allowlists across desktop, mobile, daemon enforcement, packaging, and shared UI surfaces.
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains.
|
| Filename | Overview |
|---|---|
| packages/host/engine/src/session/orchestrator.ts | Places agent allowlist enforcement at the shared live-adapter startup boundary while preserving history access. |
| apps/daemon/src/index.ts | Threads the daemon allowlist into engine startup, runtime reporting, managed refresh, and client-facing asset operations. |
| apps/desktop/src/main/daemon-supervisor.ts | Passes the immutable desktop agent allowlist to the supervised daemon without changing unrestricted builds. |
| packages/system-plane/ipc/src/electron-renderer.ts | Adds a synchronous restriction snapshot that fails closed when the main-process value is unavailable. |
| apps/desktop/scripts/package-app.mts | Adds per-run builder overlays that exclude disallowed agent SDK packages from restricted artifacts. |
| packages/client/workbench/src/settings/providers/add-flow.tsx | Filters only the add-account catalog by agent and service allowlists while preserving stored-account rendering. |
| packages/client/workbench/src/surface/workbench.tsx | Intersects selectable new-session harnesses with the host-provided agent allowlist. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart LR
B[Config build bundle] --> V[Vite build-time snapshot]
V --> M[Electron main]
M -->|SystemBridge snapshot| R[Desktop renderer]
M -->|LINKCODE_ALLOWED_AGENTS| D[Daemon]
D --> G[Live-start guard]
D --> P[Runtime probe filter]
D --> A[Asset-service gate]
V --> K[Packaging exclusions]
R --> U[Agent and service catalog filtering]
B --> I[Mobile bundled config]
I --> N[Mobile harness filtering]
Reviews (4): Last reviewed commit: "fix(engine,daemon): close review gaps in..." | Re-trigger Greptile
There was a problem hiding this comment.
Important
EngineDeps.factory is not only the session-spawn path — SessionOrchestrator.list() and HistoryService call it for capability lookups, so a throwing factory takes down session.list rather than just refusing a spawn.
Reviewed changes — full read of all 45 files across the 6 commits on chenyu/code-618, tracing the allowlist from the schema contract through desktop main, the daemon gate, packaging, and the four consuming UI surfaces.
- Schema + brand-identity contract —
ConfigBuildBundleSchemaand thepackages/foundation/commonmirror accept optional non-empty, duplicate-freeagents/services; absent stays unrestricted and unknown keys still fail closed. - Desktop main —
MAIN_VITE_AGENT_RESTRICTIONSis inlined only from a generated bundle, parsed byagent-restrictions.ts(present-but-invalid aborts boot), and exposed to the renderer over a new synchronousidentity.restrictions()sendSyncchannel. - Daemon gate —
LINKCODE_ALLOWED_AGENTSis injected by the supervisor, read bydaemonAllowedAgents(), and wrapped intoEngineDeps.factorybyrestrictedAdapterFactory;consentedManagedAgentsis filtered at boot. - Packaging —
AGENT_SDK_PACKAGE_PATHSdrives both a tempextendsoverlay addingfilesnegations and the newverifyNoRestrictedAgentPackagesasar assertion. - UI —
selectableHarnessKindsintersects the allowlist, the composer harness picker hides at ≤1 kind, the add-account catalog filters oauth entries by bound agent and everything else by service id, and mobile's new-thread sheet reads the bundled bootstrap.
The packaging half I verified independently and it is sound: the three SDKs hoist flat into the staging dir's node_modules/ under pnpm --prod deploy, apps/daemon/tsup.config.ts marks all three external so none are inlined into the daemon bundle, app-builder-lib's mergeFileSets concatenates files across extends (base first, overlay negations last) so the CODE-114 globs survive, and adapters load their SDK through dynamic import() so an absent package degrades to sdk-unavailable. brandConfig is absolute, so the extends target resolves as the comment claims.
⚠️ Three enforcement claims in the PR description are not what the code does
The description says "Runtime probing and managed downloads filter the same way" and "persisted-history reads are untouched". Neither holds: AgentRuntimeProber.collect() still reports a blocked kind as available/detected over agent-runtime.list, asset.ensure / asset.list in the engine have no allowlist check at all, and history reads go through the very factory the gate wraps. Separately, allowedServices has exactly one consumer — the add-account grid — so a services allowlist is advisory rather than enforced. None of this is a spawn bypass, but it does mean a restricted build tells the UI it can do things it cannot, and the daemon will happily download a blocked agent's CLI on request.
Technical details
# Restriction enforcement stops at `EngineDeps.factory`
## Affected sites
- `packages/host/agent-adapter/src/probe/prober.ts:97` — `collect()` builds the `AgentRuntimes` map for every probed kind with no `allowedAgents` intersection; served on `agent-runtime.list` via `packages/host/engine/src/agent/request-handler.ts:82`. A restricted build's Agents settings tab shows a blocked kind as installed and available.
- `packages/host/engine/src/asset/service.ts:55` — the `asset.ensure` handler calls `assets.ensure(id)` unconditionally. `apps/daemon/src/index.ts:191-194` filters only `consentedManagedAgents(assets)`, which is the boot auto-refresh, not this wire path.
- `packages/host/engine/src/asset/service.ts:51` — `asset.list` returns `assets.statuses()` unfiltered.
- `packages/host/engine/src/session/history-service.ts:82`, `:133`, `:215` — each calls `this.factory(kind)`, and `packages/host/engine/src/engine.ts:113` hands `HistoryService` the same gated factory. History reads are therefore *not* untouched; see the separate inline comment on `agent-factory.ts`.
- `packages/client/workbench/src/settings/providers/add-flow.tsx:59` — `isServiceSelectable` is applied only in `ServiceCatalogView`'s grid. Nothing daemon-side consults `services`.
## Required outcome
- The PR description and the `restrictedAdapterFactory` doc comment must describe what the code actually enforces, or the code must be extended to match them. Pick one — the mismatch is the problem.
- If probing stays unfiltered, decide deliberately: a restricted build surfacing a blocked agent as "available" is a UX bug even when the session start is correctly refused.
- State whether `services` is intended as enforcement or as catalog curation. If enforcement, it needs a home outside the renderer, since the renderer is not a trust boundary.
## Open questions for the human
- Is `asset.ensure` reachable for a blocked kind from any currently shipped UI path, or only from a hand-built wire client? I traced the onboarding caller in `packages/client/workbench/src/agent-runtime/onboarding.ts` but could not rule out other entry points.
- The Validation section defers packaging verification to post-merge. Is a restricted brand registered in `linkcode-config` yet, or does `verifyNoRestrictedAgentPackages` ship never having run against a real restricted artifact?ℹ️ Nitpicks
apps/daemon/src/config.ts:102—kinds.length > 0 ? kinds : nullis unreachable.String.prototype.splitnever returns an empty array, and theraw === ''case already returned above, sokindsalways has at least one element.apps/desktop/scripts/config-bundle.mts:108—if (env.MAIN_VITE_AGENT_RESTRICTIONS)is a truthiness check where the sibling guards use!== undefined. An explicitly-empty override slips past this one.apps/desktop/scripts/package-app.mts:185—join(tmpdir(), 'linkcode-desktop-agent-excludes.json')is a fixed name in a shared directory, written and never removed. Two concurrent packaging runs clobber each other, and on a multi-user host the path is predictable.mkdtempSynccosts one line.
Claude Opus | 𝕏
…iew findings CODE-618 PR review fixups: - schema/brand-identity: config-bundle.mts loads unbundled under plain Node (CI's Desktop App Entry / e2e:unpackaged), and its import chain reached brand-identity.ts's `import { AgentKindSchema } from '@linkcode/schema'` -- a barrel re-exporting a directory, which the bare Node ESM loader cannot resolve (ERR_UNSUPPORTED_DIR_IMPORT). remote-config.ts's own `./model/primitives` import hit the same loader via the `@linkcode/schema/remote-config` subpath. Both now duplicate the known agent-kind list locally instead of importing it as a value, matching this codebase's CONFIG_PLATFORMS/CONFIG_CHANNELS precedent; `AgentKind` stays a type-only import (erased before it reaches the runtime loader). - engine/daemon gating: the previous `EngineDeps.factory` wrapper threw for any excluded kind, but `SessionOrchestrator.list()` and `HistoryService` call the bare factory for every *persisted* record's `historyCapabilities` -- one history session of an excluded kind broke the whole `session.list`. Moved enforcement to the one place that actually constructs a *live* adapter to start/resume/relaunch a run (`SessionOrchestrator.startLive`), gated by a new `EngineDeps.allowedAgents` threaded in from the daemon; history reads and `list()` are never touched. Renamed apps/daemon/src/agent-factory.ts to agent-restrictions.ts and replaced the removed `restrictedAdapterFactory` with `filterAgentRuntimes` (masks a disallowed agent as `missing` in the boot-probe snapshot regardless of how it was actually detected) and `restrictedAssetService` (refuses `asset.ensure` for an excluded agent's managed download, reusing ManagedAssetService's existing "cannot be installed here" path -- tool assets are never agent-gated). `services` stays catalog-only filtering, as planned; daemon-side enforcement was never in scope for it. - composer-controls.tsx: reverted the harness AgentIcon's visibility back to `harnesses.length > 0` (only the submenu itself hides at <=1) -- a single remaining harness, restricted build or a user manually disabling every other one from Settings, still needs the icon since it is the only harness signal once the picker disappears. - electron-renderer.ts: `identity.restrictions()` now throws when the main process's sendSync snapshot is missing instead of defaulting to unrestricted -- this gates which agents/services render, so a missing snapshot must fail closed like every other security boundary here, not silently widen access. Kept the guard manual (no foxts/guard import): apps/desktop's preload externalizes every non-workspace dependency it doesn't explicitly bundle, the same reason this file already stays zod-free. - small cleanups: dropped an unreachable branch in daemonAllowedAgents() (split always yields >=1 element for a non-empty string); config-bundle.mts's ambient MAIN_VITE_AGENT_RESTRICTIONS guard now matches its neighboring `!== undefined` checks; package-app.mts's files-exclude overlay now writes into a fresh mkdtempSync directory instead of a fixed filename, so two concurrent (or one interrupted) packaging runs can't collide.
There was a problem hiding this comment.
Important
The three findings from the previous review are genuinely fixed (factory-gate regression, composer icon, IPC fail-closed) and I've resolved those threads. Moving the gate into SessionOrchestrator.startLive was the right call — the new session.list-over-a-persisted-excluded-kind test locks in the regression that motivated it. One consequence of that move still needs handling: agent.catalog was covered by the old factory wrapper and is now ungated, and it is reached on every restricted-build render of the new-session surface.
The agent.catalog path (inline on deps.ts) is the only finding I'd call blocking. It isn't a malicious-client hypothetical: useAgentStartCatalogs (packages/client/workbench/src/surface/use-agent-catalogs.ts:22-30) fires getAgentCatalog for claude-code, codex, opencode, and grok-build unconditionally — only pi is gated on runtime availability, and filterAgentRuntimes doesn't reach that call anyway. So on a restricted brand build that excludes codex, simply opening the new-session surface spawns codex app-server if the user has the CLI on PATH. Details and the fix location are in the inline comment.
Design question: history reads of an excluded kind still spawn its server
SessionOrchestrator's new doc comment (orchestrator.ts:45-49) is explicit and deliberate: the bare factory stays in use for list/read/resolveLiveBranchCursor so a persisted session of an excluded kind remains readable. list() is genuinely cheap — I confirmed .historyCapabilities is a plain property read on a constructed-but-inert adapter, no spawn for any of the five kinds. But an actual history read is not: opencode's read path goes through the shared opencode serve history server, and codex's through codex app-server. So a restricted build that excludes codex will still spawn codex app-server when a user opens a pre-restriction codex thread.
That may well be the intended trade — "excluded" plausibly means "can't start new runs", not "the binary must never execute". But it's worth stating the intent explicitly, because a brand excluding an agent for compliance/licensing reasons would likely read it the other way. If the narrower reading is correct, the alternative is to keep list() unfiltered (so threads stay visible) while refusing the history read itself with the same forbidden code, rather than reverting to a gate that breaks listing. No change requested here — I'd rather the answer be recorded than guessed at.
Verified sound
isServiceSelectable's two-axis split (oauth by bound agent, everything else by service id) is coherent and thecustom-has-no-special-case reasoning holds; the account list/detail staying unfiltered is correct and tested.parseDesktopAgentRestrictions→LINKCODE_ALLOWED_AGENTS→daemonAllowedAgents()→EngineDeps.allowedAgentsis fully wired end to end, including thedelete env.LINKCODE_ALLOWED_AGENTSbranch for unrestricted builds.- The
requireExactKeysrequired/optional split is a clean extension, and theagents/servicesvalidation covers empty, unknown, malformed, and duplicate cases in both the artifact and the bundle schema. - The hand-duplicated agent-kind lists' ESM justification checks out —
remote-config.tsnow has only bare imports, which is what makes it loadable fromconfig-bundle.mtsunder plain Node. sendSyncfor the restrictions snapshot matches the existing settings/daemon-url precedent, and failing closed there is the right asymmetry.
Claude Opus | 𝕏
package-app.mts and verify-artifacts.mts run under bare node, whose ESM loader rejects extensionless relative imports; tsx-run scripts were unaffected.
- gate agent.catalog in AgentRequestHandler: startCatalog spawns real agent processes for codex/opencode, and the old factory wrapper's coverage of this path was lost when enforcement moved to startLive - hide excluded agents' managed assets from asset statuses/subscribe so the wrapper agrees with filterAgentRuntimes and the ensure refusal - pin both hand-duplicated agent-kind lists to AgentKindSchema with drift tests
There was a problem hiding this comment.
Reviewed the one new commit since my last review (068c291..5671d2a):
- Gated
agent.cataloginAgentRequestHandlerbehindallowedAgents, replyingforbiddenbefore anystartCatalogspawn. - Hid excluded agents' managed assets from
statuses()andsubscribe()inrestrictedAssetService, so the three restriction surfaces now agree. - Pinned both hand-duplicated agent-kind lists to
AgentKindSchema.optionswith drift tests. - Rewrote the
EngineDeps.allowedAgentsdoc comment to name both enforcement points.
All three of my prior threads are genuinely fixed and I've resolved them. The agent.catalog gate is exactly where I asked for it, and the new test provably fails when I neuter the condition (I verified this by temporarily replacing the gate with if (false as boolean) and re-running — the adapters).toHaveLength(0) assertion fires). The two drift tests are real coverage, and the asset-filtering change has no regression path: tool assets and allowed agents always pass through, aigateway reads the raw assets, and the unrestricted path is a strict identity return.
Important
One finding: agent-login.start is still an ungated spawn path for an excluded agent's CLI — the same class the commit just closed for agent.catalog, sitting two cases below it in the same switch. Details inline.
Design questions from my last review — answered, closing them out
The PR body now states both intents explicitly, so I'm not re-raising either:
- History reads of an excluded kind: persisted sessions stay fully listable and readable. "Excluded" means "can't start new runs", not "the binary must never execute". Recorded — that's the trade I flagged, now deliberate.
servicesenforcement: catalog-display filtering at the UI layer only, with no daemon-side enforcement, as planned.
Not blocking, but worth naming before merge
The packaging half of this feature has never run against a real restricted artifact. verifyNoRestrictedAgentPackages, the electron-builder extends overlay, and the e2e config-canary restricted fixture are all deferred to post-merge verification pending a restricted brand existing in linkcode-config. That's a reasonable sequencing call given nothing in the standard unbranded build changes, but it does mean the asar assertion is untested code guarding the compliance-relevant half of the feature. Worth a tracked follow-up rather than trusting it silently on the first real branded release.
Claude Opus | 𝕏

Summary
Client half of per-brand agent/provider control (CODE-618, part of CODE-612). The pinned render pipeline may now deliver optional
agents/servicesallowlists on the config build bundle (publisher side: arcboxlabs/linkcodehq#49); this PR consumes them at build time. Absent fields = unrestricted — the standard build is byte-identical.ConfigBuildBundleSchemaand the brand-identity mirror accept optionalagents(AgentKind subset) /services(kebab-case ids); non-empty, duplicate-free, still fail-closed on unknown fields. Frozen fixtures untouched. Both files avoid a value import of@linkcode/schema's root barrel (only reachable from build scripts under plain Node, which can't resolve its directory re-export) — the known agent-kind list is duplicated locally instead, matching this codebase'sCONFIG_PLATFORMS/CONFIG_CHANNELSprecedent.MAIN_VITE_AGENT_RESTRICTIONSdefine →agent-restrictions.ts(present-but-invalid aborts boot, mirroring brand identity), surfaced to the renderer via a synchronousidentity.restrictions()SystemBridge channel that fails closed if the main-process snapshot is ever missing (never defaults to unrestricted).LINKCODE_ALLOWED_AGENTSfrom the supervisor is checked wherever an adapter process could actually start:SessionOrchestrator.startLive(the single chokepoint every start/resume/relaunch path constructs a live adapter through) andagent.cataloginAgentRequestHandler(whosestartCatalogspawns real agent processes for codex/opencode). This refuses only a new live run of an excluded kind; it never touchessession.list's per-recordhistoryCapabilitiesorHistoryService's list/read/branch-cursor paths, so a persisted session of an excluded kind stays fully listable and readable. The boot runtime-probe snapshot andcollectAgentRuntimes()are filtered (filterAgentRuntimes) so an excluded agent always readsmissingregardless of how it was actually detected, and the wire-level asset surface is gated (restrictedAssetService):asset.ensurerefuses an excluded agent's managed download andstatuses/subscribeomit its entries entirely — both live inapps/daemon/src/agent-restrictions.ts, composed at the daemon boundary without touching@linkcode/agent-adapter's public API.services.AGENT_SDK_PACKAGE_PATHSis the single source of truth for both brand-conditional electron-builderfilesexcludes (tempextendsoverlay, written into a freshmkdtempSyncdir per run) and a newverifyNoRestrictedAgentPackagespost-pack asar assertion (exact path-segment matching).customentries by service id.serviceById, account list/detail, and resolution stay unfiltered so stored accounts keep rendering even if their service is no longer offered.Fixed after review / CI
Desktop App Entry/e2e:unpackagedfailed withERR_UNSUPPORTED_DIR_IMPORTloadingvite.main.config.mtsunder plain Node — fixed by the contract-layer import change above.SessionOrchestrator's adapter-factory wrapper originally threw for any excluded kind at construction, which brokesession.listfor daemons with even one persisted session of an excluded kind — moved to thestartLive-only boundary described above.harnesses.length > 0for the icon.identity.restrictions()defaulted to unrestricted on a missing IPC snapshot — now throws.daemonAllowedAgents(), an ambient-env guard now consistent with its neighbors, and the packaging files-exclude overlay now uses a unique temp dir instead of a fixed filename.Validation
pnpm typecheck/pnpm lint/pnpm format:checkcleanpnpm test: 3051–3052 passed; 3 failures confirmed pre-existing on the unmodified base (tmpdir/realpath quirk inrelease-artifact.test.ts, unrelated to this PR); one flaky timing failure observed intermittently indev-mock-transport.test.ts(an unrelated pre-existing integration test, polling-based, flaky under the sandbox's concurrent load — passes cleanly in isolation)cd apps/desktop && pnpm e2e:unpackaged: daemon/main/preload/renderer build and the full Electron IPC boundary read (linkcodeSystem/linkcodeConfig) now succeed; the only remaining failure locally is the script's hardcodedassert.equal(boundary.platform, 'linux'), pre-existing and Linux-CI-only (this sandbox is macOS)verify-artifacts.mtsboth ways; e2e config-canary restricted fixtureLinear: CODE-618