From db03912d7d5b50792d9c5aeb79d59635af5df44e Mon Sep 17 00:00:00 2001 From: lucas77778 <3098274296@qq.com> Date: Mon, 24 Aug 2026 22:32:27 +0800 Subject: [PATCH 1/9] feat(schema): accept optional agents/services on the config build bundle 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. --- .../config/__tests__/brand-identity.test.ts | 73 ++++++++++++++++ .../common/src/config/brand-identity.ts | 51 ++++++++++-- .../src/__tests__/remote-config.test.ts | 83 +++++++++++++++++++ .../foundation/schema/src/remote-config.ts | 20 +++++ 4 files changed, 221 insertions(+), 6 deletions(-) create mode 100644 packages/foundation/schema/src/__tests__/remote-config.test.ts diff --git a/packages/foundation/common/src/config/__tests__/brand-identity.test.ts b/packages/foundation/common/src/config/__tests__/brand-identity.test.ts index c471fa57b..32dd0aefb 100644 --- a/packages/foundation/common/src/config/__tests__/brand-identity.test.ts +++ b/packages/foundation/common/src/config/__tests__/brand-identity.test.ts @@ -216,6 +216,79 @@ describe('parseBrandIdentityArtifact', () => { }); }); +describe('agents/services (CODE-618)', () => { + it('accepts an artifact without either field', () => { + const identity = parseBrandIdentityArtifact(structuredClone(fixture)); + expect(identity.agents).toBeUndefined(); + expect(identity.services).toBeUndefined(); + }); + + it('accepts a restricted artifact declaring both fields', () => { + const identity = parseBrandIdentityArtifact( + mutate((artifact) => { + artifact.agents = ['pi']; + artifact.services = ['linkcode-gateway']; + }), + ); + expect(identity.agents).toEqual(['pi']); + expect(identity.services).toEqual(['linkcode-gateway']); + }); + + it('rejects an empty agents or services array', () => { + expect(() => + parseBrandIdentityArtifact( + mutate((artifact) => { + artifact.agents = []; + }), + ), + ).toThrow('non-empty array'); + expect(() => + parseBrandIdentityArtifact( + mutate((artifact) => { + artifact.services = []; + }), + ), + ).toThrow('non-empty array'); + }); + + it('rejects an unknown agent kind', () => { + expect(() => + parseBrandIdentityArtifact( + mutate((artifact) => { + artifact.agents = ['not-a-kind']; + }), + ), + ).toThrow('unknown agent'); + }); + + it('rejects a malformed service id', () => { + expect(() => + parseBrandIdentityArtifact( + mutate((artifact) => { + artifact.services = ['Not_Valid']; + }), + ), + ).toThrow('invalid service id'); + }); + + it('rejects duplicates in either array', () => { + expect(() => + parseBrandIdentityArtifact( + mutate((artifact) => { + artifact.agents = ['pi', 'pi']; + }), + ), + ).toThrow('duplicates'); + expect(() => + parseBrandIdentityArtifact( + mutate((artifact) => { + artifact.services = ['linkcode-gateway', 'linkcode-gateway']; + }), + ), + ).toThrow('duplicates'); + }); +}); + describe('assertBrandIdentityMatchesBundle', () => { const bundle = parseConfigBuildBundle(structuredClone(bundleFixture)); diff --git a/packages/foundation/common/src/config/brand-identity.ts b/packages/foundation/common/src/config/brand-identity.ts index c73ecc596..5f3724f85 100644 --- a/packages/foundation/common/src/config/brand-identity.ts +++ b/packages/foundation/common/src/config/brand-identity.ts @@ -1,5 +1,7 @@ // Client half of the frozen brand identity artifact v1 (publisher CONTRACT.md "Brand identity // artifact v1"). Validation only — derivation stays in the publisher; never reimplement it here. +import type { AgentKind } from '@linkcode/schema'; +import { AgentKindSchema } from '@linkcode/schema'; import type { ConfigBuildBundle } from './build-bundle'; import { isRecord } from './contract'; import type { ConfigChannel, ConfigPlatform } from './types'; @@ -13,8 +15,10 @@ export interface BrandIdentityProvenance { } /** Resolved build identity for exactly one brand/platform/channel target. Every field is final: - * build tooling consumes it verbatim and never re-derives identity from the brand manifest. */ + * build tooling consumes it verbatim and never re-derives identity from the brand manifest. + * `agents`/`services` are absent unless the brand restricts them — absent means unrestricted. */ export interface BrandIdentityArtifact { + readonly agents?: readonly AgentKind[]; readonly applicationId: string; readonly assetsPath: string; readonly brandId: string; @@ -23,11 +27,12 @@ export interface BrandIdentityArtifact { readonly displayName: string; readonly platform: ConfigPlatform; readonly provenance: BrandIdentityProvenance; + readonly services?: readonly string[]; readonly storageNamespace: string; readonly urlScheme: string; } -const ARTIFACT_KEYS = new Set([ +const ARTIFACT_REQUIRED_KEYS = new Set([ 'applicationId', 'assetsPath', 'brandId', @@ -39,9 +44,13 @@ const ARTIFACT_KEYS = new Set([ 'storageNamespace', 'urlScheme', ]); +const ARTIFACT_OPTIONAL_KEYS = new Set(['agents', 'services']); const PROVENANCE_KEYS = new Set(['manifestSchemaVersion', 'sourceGitSha']); +const KNOWN_AGENT_KINDS = new Set(AgentKindSchema.options); + const RE_BRAND_ID = /^[a-z][a-z0-9-]{0,62}$/; +const RE_SERVICE_ID = /^[a-z][a-z0-9-]{0,62}$/; const RE_SOURCE_GIT_SHA = /^[0-9a-f]{40}$/; const RE_URL_SCHEME = /^[a-z][a-z0-9+.-]*$/; // Android application ids reject dashes and uppercase; Apple/desktop ids allow dashes. @@ -63,17 +72,31 @@ function fail(message: string): never { function requireExactKeys( value: Record, - allowed: ReadonlySet, + required: ReadonlySet, label: string, + optional: ReadonlySet = new Set(), ): void { for (const key of Object.keys(value)) { - if (!allowed.has(key)) fail(`${label} contains unsupported field ${key}`); + if (!required.has(key) && !optional.has(key)) { + fail(`${label} contains unsupported field ${key}`); + } } - for (const key of allowed) { + for (const key of required) { if (!(key in value)) fail(`${label} is missing field ${key}`); } } +function assertNonEmptyUniqueArray( + value: unknown, + label: string, + assertItem: (item: unknown, index: number) => T, +): T[] { + if (!Array.isArray(value) || value.length === 0) fail(`${label} must be a non-empty array`); + const items = value.map((item, index) => assertItem(item, index)); + if (new Set(items).size !== items.length) fail(`${label} must not contain duplicates`); + return items; +} + function assertApplicationId(value: string, platform: ConfigPlatform, label: string): void { if (value.length > MAX_APPLICATION_ID_LENGTH) fail(`${label} is too long`); const segments = value.split('.'); @@ -119,7 +142,7 @@ export function assertBrandIdentityArtifact( value: unknown, ): asserts value is BrandIdentityArtifact { if (!isRecord(value)) fail('artifact must be an object'); - requireExactKeys(value, ARTIFACT_KEYS, 'artifact'); + requireExactKeys(value, ARTIFACT_REQUIRED_KEYS, 'artifact', ARTIFACT_OPTIONAL_KEYS); if (value.brandIdentityVersion !== BRAND_IDENTITY_VERSION) { fail('artifact.brandIdentityVersion is unsupported'); } @@ -158,6 +181,22 @@ export function assertBrandIdentityArtifact( if (typeof sourceGitSha !== 'string' || !RE_SOURCE_GIT_SHA.test(sourceGitSha)) { fail('artifact.provenance.sourceGitSha must be a lowercase 40-hex commit'); } + if (value.agents !== undefined) { + assertNonEmptyUniqueArray(value.agents, 'artifact.agents', (item) => { + if (typeof item !== 'string' || !KNOWN_AGENT_KINDS.has(item)) { + fail(`artifact.agents contains unknown agent ${String(item)}`); + } + return item; + }); + } + if (value.services !== undefined) { + assertNonEmptyUniqueArray(value.services, 'artifact.services', (item) => { + if (typeof item !== 'string' || !RE_SERVICE_ID.test(item)) { + fail(`artifact.services contains an invalid service id ${String(item)}`); + } + return item; + }); + } } export function parseBrandIdentityArtifact(value: unknown): BrandIdentityArtifact { diff --git a/packages/foundation/schema/src/__tests__/remote-config.test.ts b/packages/foundation/schema/src/__tests__/remote-config.test.ts new file mode 100644 index 000000000..ee2d59b28 --- /dev/null +++ b/packages/foundation/schema/src/__tests__/remote-config.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest'; +import { ConfigBuildBundleSchema } from '../remote-config'; + +// Schema-level fixture only: this file validates shape/consistency of ConfigBuildBundleSchema in +// isolation, not the snapshot-hash cross-checks that live in @linkcode/common's assertConfigBuildBundle. +function validBundle(): Record { + return { + brandId: 'linkcode', + buildBundleVersion: 1, + channel: 'stable', + endpoints: { + emergency: null, + normal: null, + telemetry: 'https://telemetry.example.invalid/linkcode', + }, + keyrings: { emergency: {}, normal: {} }, + maximumSchemaVersion: 1, + platform: 'desktop', + provenance: { + configRevisionId: 'rev-1', + configVersion: '1', + generatedAt: '2026-08-24T00:00:00Z', + schemaVersion: 1, + sourceGitSha: 'a'.repeat(40), + }, + snapshot: { + base64Url: 'AAAA', + sha256: '0'.repeat(64), + sizeBytes: 4, + }, + }; +} + +describe('ConfigBuildBundleSchema agents/services', () => { + it('accepts a bundle with neither field (absent = unrestricted)', () => { + const result = ConfigBuildBundleSchema.parse(validBundle()); + expect(result.agents).toBeUndefined(); + expect(result.services).toBeUndefined(); + }); + + it('accepts declared agents and services', () => { + const result = ConfigBuildBundleSchema.parse({ + ...validBundle(), + agents: ['pi'], + services: ['linkcode-gateway'], + }); + expect(result.agents).toEqual(['pi']); + expect(result.services).toEqual(['linkcode-gateway']); + }); + + it('rejects an empty agents or services array', () => { + expect(() => ConfigBuildBundleSchema.parse({ ...validBundle(), agents: [] })).toThrow(); + expect(() => ConfigBuildBundleSchema.parse({ ...validBundle(), services: [] })).toThrow(); + }); + + it('rejects an unknown agent kind', () => { + expect(() => + ConfigBuildBundleSchema.parse({ ...validBundle(), agents: ['not-a-kind'] }), + ).toThrow(); + }); + + it('rejects a malformed service id', () => { + expect(() => + ConfigBuildBundleSchema.parse({ ...validBundle(), services: ['Not_Valid'] }), + ).toThrow(); + }); + + it('rejects duplicates in either array', () => { + expect(() => ConfigBuildBundleSchema.parse({ ...validBundle(), agents: ['pi', 'pi'] })).toThrow( + 'duplicates', + ); + expect(() => + ConfigBuildBundleSchema.parse({ + ...validBundle(), + services: ['linkcode-gateway', 'linkcode-gateway'], + }), + ).toThrow('duplicates'); + }); + + it('still fails closed on unknown top-level fields (regression)', () => { + expect(() => ConfigBuildBundleSchema.parse({ ...validBundle(), extra: 1 })).toThrow(); + }); +}); diff --git a/packages/foundation/schema/src/remote-config.ts b/packages/foundation/schema/src/remote-config.ts index d496fc747..528460b50 100644 --- a/packages/foundation/schema/src/remote-config.ts +++ b/packages/foundation/schema/src/remote-config.ts @@ -1,5 +1,6 @@ import canonicalize from 'canonicalize'; import { z } from 'zod'; +import { AgentKindSchema } from './model/primitives'; export const CONFIG_CONTRACT_VERSION = 1; export const CONFIG_BUILD_BUNDLE_VERSION = 1; @@ -12,6 +13,7 @@ export const APPLY_MODES = ['hot', 'cold'] as const; export const OPERATING_SYSTEMS = ['windows', 'macos', 'linux', 'ios', 'android'] as const; const RE_BRAND_ID = /^[a-z][a-z0-9-]{0,62}$/; +const RE_SERVICE_ID = /^[a-z][a-z0-9-]{0,62}$/; const RE_CONFIG_KEY = /^(?:app|content|feature|modules|params|ui)(?:\.[a-z][A-Za-z0-9]*)+$/; const RE_CONFIG_VERSION = /^[\dA-Z][\w.-]{0,127}$/i; const RE_DECIMAL = /^(?:0|[1-9]\d*)$/; @@ -260,6 +262,8 @@ export type ConfigBuildBundleSnapshotEnvelope = z.infer< export const ConfigBuildBundleSchema = z .strictObject({ + // Absent = unrestricted (every agent/service allowed); a brand only ever narrows this set. + agents: z.array(AgentKindSchema).min(1).optional(), brandId: BrandIdSchema, buildBundleVersion: z.literal(CONFIG_BUILD_BUNDLE_VERSION), channel: ConfigChannelSchema, @@ -268,6 +272,8 @@ export const ConfigBuildBundleSchema = z maximumSchemaVersion: z.number().int(), platform: ConfigPlatformSchema, provenance: ConfigBuildBundleProvenanceSchema, + // Free-form ids (this package must not depend on the providers catalog); shape-checked only. + services: z.array(z.string().regex(RE_SERVICE_ID)).min(1).optional(), snapshot: ConfigBuildBundleSnapshotEnvelopeSchema, }) .superRefine((bundle, context) => { @@ -283,6 +289,20 @@ export const ConfigBuildBundleSchema = z }); } } + if (bundle.agents !== undefined && new Set(bundle.agents).size !== bundle.agents.length) { + context.addIssue({ + code: 'custom', + message: 'agents must not contain duplicates', + path: ['agents'], + }); + } + if (bundle.services !== undefined && new Set(bundle.services).size !== bundle.services.length) { + context.addIssue({ + code: 'custom', + message: 'services must not contain duplicates', + path: ['services'], + }); + } if (bundle.maximumSchemaVersion < bundle.provenance.schemaVersion) { context.addIssue({ code: 'custom', From dfd090db5c97e3532007c6d90977369fe5aa120b Mon Sep 17 00:00:00 2001 From: lucas77778 <3098274296@qq.com> Date: Mon, 24 Aug 2026 22:45:08 +0800 Subject: [PATCH 2/9] feat(desktop): render and surface the build-time agent/service allowlist 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. --- apps/desktop/scripts/config-bundle.mts | 18 +++++ .../src/__tests__/config-bundle.test.ts | 23 +++++++ apps/desktop/src/env.d.ts | 4 ++ .../main/__tests__/agent-restrictions.test.ts | 69 +++++++++++++++++++ .../main/__tests__/daemon-supervisor.test.ts | 27 ++++++++ apps/desktop/src/main/agent-restrictions.ts | 61 ++++++++++++++++ apps/desktop/src/main/constants.ts | 10 +++ apps/desktop/src/main/daemon-supervisor.ts | 6 +- apps/desktop/src/main/system-context.ts | 4 ++ apps/desktop/src/renderer/src/ipc.ts | 4 ++ apps/desktop/vite.main.config.mts | 5 ++ packages/system-plane/ipc/src/bridge.ts | 6 ++ packages/system-plane/ipc/src/context.ts | 11 +++ .../system-plane/ipc/src/electron-main.ts | 12 ++++ .../system-plane/ipc/src/electron-renderer.ts | 19 ++++- packages/system-plane/ipc/src/events.ts | 3 + 16 files changed, 280 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/agent-restrictions.test.ts create mode 100644 apps/desktop/src/main/agent-restrictions.ts diff --git a/apps/desktop/scripts/config-bundle.mts b/apps/desktop/scripts/config-bundle.mts index 76d46fbed..628e932cf 100644 --- a/apps/desktop/scripts/config-bundle.mts +++ b/apps/desktop/scripts/config-bundle.mts @@ -19,6 +19,8 @@ import { } from '../src/build/electron-builder-brand'; interface GeneratedConfigBundleBase { + /** Agent/service allowlist snapshot (CODE-618), undefined when the brand declares neither. */ + readonly agentRestrictionsJson?: string; readonly bootstrapJson: string; readonly bundleText: string; } @@ -103,6 +105,12 @@ export function loadGeneratedConfigBundle( 'the generated bootstrap is immutable', ); } + if (env.MAIN_VITE_AGENT_RESTRICTIONS) { + throw new Error( + 'MAIN_VITE_AGENT_RESTRICTIONS must not be set when a generated config bundle exists; ' + + 'the generated restriction snapshot is immutable', + ); + } const bundleText = readFileSync(bundlePath, 'utf8'); const bundle = parseConfigBuildBundle(JSON.parse(bundleText)); if (bundle.platform !== 'desktop') { @@ -176,7 +184,17 @@ export function loadGeneratedConfigBundle( publicKeys: bundle.keyrings.normal, telemetryEndpoint: bundle.endpoints.telemetry, }; + // Absent on the bundle (the common case) omits the field entirely, so an unrestricted build's + // vite.main.config.mts define step never inlines MAIN_VITE_AGENT_RESTRICTIONS. + const agentRestrictionsJson = + bundle.agents === undefined && bundle.services === undefined + ? undefined + : JSON.stringify({ + ...(bundle.agents !== undefined && { agents: bundle.agents }), + ...(bundle.services !== undefined && { services: bundle.services }), + }); const generatedBase = { + ...(agentRestrictionsJson !== undefined && { agentRestrictionsJson }), bootstrapJson: JSON.stringify(bootstrap), bundleText, }; diff --git a/apps/desktop/src/__tests__/config-bundle.test.ts b/apps/desktop/src/__tests__/config-bundle.test.ts index 3dc194fa1..cada05a3d 100644 --- a/apps/desktop/src/__tests__/config-bundle.test.ts +++ b/apps/desktop/src/__tests__/config-bundle.test.ts @@ -134,6 +134,29 @@ describe('loadGeneratedConfigBundle', () => { ).toThrow(RE_IMMUTABLE); }); + it('rejects an ambient MAIN_VITE_AGENT_RESTRICTIONS when a bundle exists', async () => { + const dir = await makeDesktopDir(desktopFixture); + expect(() => + loadGeneratedConfigBundle(dir, { MAIN_VITE_AGENT_RESTRICTIONS: '{"agents":["pi"]}' }), + ).toThrow(RE_IMMUTABLE); + }); + + it('omits agentRestrictionsJson when the bundle declares neither agents nor services', async () => { + const dir = await makeDesktopDir(validDesktopFixture); + const generated = loadGeneratedConfigBundle(dir, {}); + expect(generated?.agentRestrictionsJson).toBeUndefined(); + }); + + it('derives agentRestrictionsJson from the bundle agents/services fields', async () => { + const dir = await makeDesktopDir( + desktopBundle({ agents: ['pi'], services: ['linkcode-gateway'] }), + ); + const generated = loadGeneratedConfigBundle(dir, {}); + expect(generated?.agentRestrictionsJson).toBe( + JSON.stringify({ agents: ['pi'], services: ['linkcode-gateway'] }), + ); + }); + it('fails closed on malformed JSON', async () => { const dir = await makeDesktopDir('{not json'); expect(() => loadGeneratedConfigBundle(dir, {})).toThrow(); diff --git a/apps/desktop/src/env.d.ts b/apps/desktop/src/env.d.ts index 9469585f9..76d1e094e 100644 --- a/apps/desktop/src/env.d.ts +++ b/apps/desktop/src/env.d.ts @@ -11,6 +11,10 @@ interface ImportMetaEnv { * the default LinkCode identity. Inlined only from generated output — never from ambient env, * which vite.main.config.ts rejects outright. */ readonly MAIN_VITE_BRAND_IDENTITY?: string; + /** Build-time agent/service restriction snapshot (config:render, CODE-618); unset builds are + * unrestricted. Inlined only from generated output — never from ambient env, which + * vite.main.config.ts rejects outright. */ + readonly MAIN_VITE_AGENT_RESTRICTIONS?: string; /** Public PostHog project configuration; both values are required or analytics no-ops. */ readonly RENDERER_VITE_POSTHOG_PROJECT_TOKEN?: string; readonly RENDERER_VITE_POSTHOG_HOST?: string; diff --git a/apps/desktop/src/main/__tests__/agent-restrictions.test.ts b/apps/desktop/src/main/__tests__/agent-restrictions.test.ts new file mode 100644 index 000000000..a7e956813 --- /dev/null +++ b/apps/desktop/src/main/__tests__/agent-restrictions.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; +import { parseDesktopAgentRestrictions } from '../agent-restrictions'; + +describe('parseDesktopAgentRestrictions', () => { + it('returns unrestricted when nothing is inlined', () => { + expect(parseDesktopAgentRestrictions(undefined)).toEqual({ + allowedAgents: null, + allowedServices: null, + }); + expect(parseDesktopAgentRestrictions('')).toEqual({ + allowedAgents: null, + allowedServices: null, + }); + }); + + it('parses a restricted snapshot', () => { + const restrictions = parseDesktopAgentRestrictions( + JSON.stringify({ agents: ['pi'], services: ['linkcode-gateway'] }), + ); + expect(restrictions).toEqual({ + allowedAgents: ['pi'], + allowedServices: ['linkcode-gateway'], + }); + }); + + it('leaves the other axis unrestricted when only one field is declared', () => { + expect(parseDesktopAgentRestrictions(JSON.stringify({ agents: ['pi'] }))).toEqual({ + allowedAgents: ['pi'], + allowedServices: null, + }); + expect( + parseDesktopAgentRestrictions(JSON.stringify({ services: ['linkcode-gateway'] })), + ).toEqual({ + allowedAgents: null, + allowedServices: ['linkcode-gateway'], + }); + }); + + it('fails closed on malformed JSON instead of falling back to unrestricted', () => { + expect(() => parseDesktopAgentRestrictions('{not json')).toThrow(); + }); + + it('fails closed on an unsupported field', () => { + expect(() => parseDesktopAgentRestrictions(JSON.stringify({ extra: true }))).toThrow( + 'unsupported field extra', + ); + }); + + it('fails closed on an empty or duplicated agents array', () => { + expect(() => parseDesktopAgentRestrictions(JSON.stringify({ agents: [] }))).toThrow( + 'non-empty array', + ); + expect(() => parseDesktopAgentRestrictions(JSON.stringify({ agents: ['pi', 'pi'] }))).toThrow( + 'duplicates', + ); + }); + + it('fails closed on an unknown agent kind', () => { + expect(() => + parseDesktopAgentRestrictions(JSON.stringify({ agents: ['not-a-kind'] })), + ).toThrow(); + }); + + it('fails closed on a malformed service id', () => { + expect(() => + parseDesktopAgentRestrictions(JSON.stringify({ services: ['Not_Valid'] })), + ).toThrow('invalid service id'); + }); +}); diff --git a/apps/desktop/src/main/__tests__/daemon-supervisor.test.ts b/apps/desktop/src/main/__tests__/daemon-supervisor.test.ts index 279475973..f155ec6b6 100644 --- a/apps/desktop/src/main/__tests__/daemon-supervisor.test.ts +++ b/apps/desktop/src/main/__tests__/daemon-supervisor.test.ts @@ -231,4 +231,31 @@ describe('daemon supervisor recovery', () => { ]; expect(forkArgs[2].env?.LINKCODE_CHANNEL).toBe('development'); }); + + it('leaves LINKCODE_ALLOWED_AGENTS unset on an unrestricted (default) build', async () => { + await startSupervisor(); + + const forkArgs = mocks.fork.mock.calls[0] as [ + string, + string[], + { env?: Record }, + ]; + expect(forkArgs[2].env?.LINKCODE_ALLOWED_AGENTS).toBeUndefined(); + }); + + it('forwards the restricted agent allowlist to the daemon it spawns', async () => { + vi.stubEnv('MAIN_VITE_AGENT_RESTRICTIONS', JSON.stringify({ agents: ['pi'] })); + try { + await startSupervisor(); + } finally { + vi.unstubAllEnvs(); + } + + const forkArgs = mocks.fork.mock.calls[0] as [ + string, + string[], + { env?: Record }, + ]; + expect(forkArgs[2].env?.LINKCODE_ALLOWED_AGENTS).toBe('pi'); + }); }); diff --git a/apps/desktop/src/main/agent-restrictions.ts b/apps/desktop/src/main/agent-restrictions.ts new file mode 100644 index 000000000..20040c9f8 --- /dev/null +++ b/apps/desktop/src/main/agent-restrictions.ts @@ -0,0 +1,61 @@ +import type { AgentKind } from '@linkcode/schema'; +import { AgentKindSchema } from '@linkcode/schema'; + +const RE_SERVICE_ID = /^[a-z][a-z0-9-]{0,62}$/; + +/** `null` means unrestricted — every agent/service is allowed. A brand only ever narrows this. */ +export interface DesktopAgentRestrictions { + readonly allowedAgents: readonly AgentKind[] | null; + readonly allowedServices: readonly string[] | null; +} + +const UNRESTRICTED: DesktopAgentRestrictions = { allowedAgents: null, allowedServices: null }; + +/** + * The build-time agent/service restriction snapshot (CODE-618): rendered by the pinned config + * publisher onto the config build bundle, inlined by vite.main.config.mts as + * MAIN_VITE_AGENT_RESTRICTIONS next to MAIN_VITE_BRAND_IDENTITY (see brand.ts). Absent means the + * default unrestricted build; a present-but-invalid snapshot aborts boot instead of silently + * falling back to unrestricted, so a tampered or stale artifact can never widen access. + */ +export function parseDesktopAgentRestrictions(raw: string | undefined): DesktopAgentRestrictions { + if (raw === undefined || raw === '') return UNRESTRICTED; + const parsed: unknown = JSON.parse(raw); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new TypeError('agent restrictions must be an object'); + } + const { agents, services, ...rest } = parsed as Record; + const unsupported = Object.keys(rest).at(0); + if (unsupported !== undefined) { + throw new TypeError(`agent restrictions contains unsupported field ${unsupported}`); + } + return { + allowedAgents: agents === undefined ? null : assertAgents(agents), + allowedServices: services === undefined ? null : assertServices(services), + }; +} + +function assertNonEmptyUniqueArray(value: unknown, label: string): T[] { + if (!Array.isArray(value) || value.length === 0) { + throw new TypeError(`${label} must be a non-empty array`); + } + if (new Set(value).size !== value.length) { + throw new TypeError(`${label} must not contain duplicates`); + } + return value as T[]; +} + +function assertAgents(value: unknown): AgentKind[] { + const agents = assertNonEmptyUniqueArray(value, 'agents'); + return agents.map((agent) => AgentKindSchema.parse(agent)); +} + +function assertServices(value: unknown): string[] { + const services = assertNonEmptyUniqueArray(value, 'services'); + return services.map((service) => { + if (typeof service !== 'string' || !RE_SERVICE_ID.test(service)) { + throw new TypeError(`services contains an invalid service id ${String(service)}`); + } + return service; + }); +} diff --git a/apps/desktop/src/main/constants.ts b/apps/desktop/src/main/constants.ts index ecee91929..59e5d0e7d 100644 --- a/apps/desktop/src/main/constants.ts +++ b/apps/desktop/src/main/constants.ts @@ -3,6 +3,8 @@ import { parseProfileName } from '@linkcode/schema/daemon-runtime'; import { workspacesDirName } from '@linkcode/schema/product'; import { app, dialog } from 'electron'; import { extractErrorMessage } from 'foxts/extract-error-message'; +import type { DesktopAgentRestrictions } from './agent-restrictions'; +import { parseDesktopAgentRestrictions } from './agent-restrictions'; import { deriveDesktopBrandBase, parseDesktopBrandIdentity } from './brand'; /** @@ -45,6 +47,14 @@ export const PROFILE = resolveProfile(); const BRAND = parseDesktopBrandIdentity(import.meta.env.MAIN_VITE_BRAND_IDENTITY); const BRAND_BASE = BRAND === null ? null : deriveDesktopBrandBase(BRAND, CHANNEL); +/** Build-time agent/service allowlist (CODE-618): unrestricted on default LinkCode builds. A + * malformed inlined snapshot throws here and aborts boot, same fail-closed contract as BRAND. Its + * own define (not folded into brand.ts) so a brand can restrict without declaring an identity, + * and so this module never needs to import config.ts (which would cycle back into constants.ts). */ +export const AGENT_RESTRICTIONS: DesktopAgentRestrictions = parseDesktopAgentRestrictions( + import.meta.env.MAIN_VITE_AGENT_RESTRICTIONS, +); + const BASE_NAME = BRAND_BASE?.appName ?? (CHANNEL === 'development' ? 'LinkCode Development' : 'LinkCode'); diff --git a/apps/desktop/src/main/daemon-supervisor.ts b/apps/desktop/src/main/daemon-supervisor.ts index 775eda170..640129905 100644 --- a/apps/desktop/src/main/daemon-supervisor.ts +++ b/apps/desktop/src/main/daemon-supervisor.ts @@ -4,7 +4,7 @@ import { DAEMON_EXIT_ALREADY_RUNNING } from '@linkcode/schema'; import type { UtilityProcess } from 'electron'; import { app, utilityProcess } from 'electron'; import log from 'electron-log'; -import { CHANNEL, PROFILE } from './constants'; +import { AGENT_RESTRICTIONS, CHANNEL, PROFILE } from './constants'; import { watchDaemonRuntime } from './daemon-discovery'; import { getSettings } from './settings'; @@ -90,6 +90,10 @@ function spawnDaemon(): void { // The channel cannot be inferred by the child: the devshell pack bundles a daemon stamped // `release` at build time, and only this shell knows it is a development build (CODE-460). env.LINKCODE_CHANNEL = CHANNEL; + // Restricted-brand agent gate (CODE-618): unset on unrestricted builds, an exact no-op for the + // daemon (see apps/daemon/src/config.ts#daemonAllowedAgents). + if (AGENT_RESTRICTIONS.allowedAgents === null) delete env.LINKCODE_ALLOWED_AGENTS; + else env.LINKCODE_ALLOWED_AGENTS = AGENT_RESTRICTIONS.allowedAgents.join(','); const sidecar = sidecarPath(); if (existsSync(sidecar)) env.LINKCODE_PTY_SIDECAR_PATH = sidecar; else log.warn(`[linkcode/desktop] pty sidecar missing at ${sidecar}; terminals unavailable`); diff --git a/apps/desktop/src/main/system-context.ts b/apps/desktop/src/main/system-context.ts index db489ec7d..50eaf969d 100644 --- a/apps/desktop/src/main/system-context.ts +++ b/apps/desktop/src/main/system-context.ts @@ -3,6 +3,7 @@ import { NOTIFICATION_CLICKED_CHANNEL } from '@linkcode/ipc'; import type { BrowserWindow } from 'electron'; import { app, dialog, Notification, shell } from 'electron'; import { applyThemePreference } from './appearance'; +import { AGENT_RESTRICTIONS } from './constants'; import { resolveDaemonUrl } from './daemon-discovery'; import { isDaemonManaged, retryDaemonSupervisor } from './daemon-supervisor'; import { ensureDefaultPickerDirectory } from './default-picker-directory'; @@ -63,6 +64,9 @@ export function systemContextFor(win: BrowserWindow): SystemContext { isManaged: () => isDaemonManaged(), retry: () => retryDaemonSupervisor(), }, + identity: { + restrictions: () => AGENT_RESTRICTIONS, + }, notifications: { notify({ title, body, clickToken }) { // Unsupported (e.g. Windows without a shortcut/AppUserModelID) degrades to a silent no-op. diff --git a/apps/desktop/src/renderer/src/ipc.ts b/apps/desktop/src/renderer/src/ipc.ts index ba697beee..1bd78c4f6 100644 --- a/apps/desktop/src/renderer/src/ipc.ts +++ b/apps/desktop/src/renderer/src/ipc.ts @@ -68,6 +68,10 @@ export const systemBridge: SystemBridge = { onClick: (callback) => traceRendererIpc('notifications.on-click', () => source.notifications.onClick(callback)), }, + identity: { + restrictions: () => + traceRendererIpc('identity.restrictions', () => source.identity.restrictions()), + }, browser: { onOpenTab: (callback) => traceRendererIpc('browser.on-open-tab', () => source.browser.onOpenTab(callback)), diff --git a/apps/desktop/vite.main.config.mts b/apps/desktop/vite.main.config.mts index fd4d930c5..0d6529aa6 100644 --- a/apps/desktop/vite.main.config.mts +++ b/apps/desktop/vite.main.config.mts @@ -18,6 +18,11 @@ export default defineConfig({ ...(generatedConfig?.brandIdentityJson !== undefined && { 'import.meta.env.MAIN_VITE_BRAND_IDENTITY': JSON.stringify(generatedConfig.brandIdentityJson), }), + ...(generatedConfig?.agentRestrictionsJson !== undefined && { + 'import.meta.env.MAIN_VITE_AGENT_RESTRICTIONS': JSON.stringify( + generatedConfig.agentRestrictionsJson, + ), + }), }, envPrefix: ['MAIN_VITE_', 'VITE_'], resolve: { diff --git a/packages/system-plane/ipc/src/bridge.ts b/packages/system-plane/ipc/src/bridge.ts index e4a1cd849..accfbd8ae 100644 --- a/packages/system-plane/ipc/src/bridge.ts +++ b/packages/system-plane/ipc/src/bridge.ts @@ -1,4 +1,5 @@ import type { + AgentRestrictionsSnapshot, BrowserDownloadDone, BrowserShortcutAction, DesktopSettings, @@ -82,4 +83,9 @@ export interface SystemBridge { /** Subscribe to app-owned shortcuts captured while a guest webview owns keyboard focus. */ onShortcut(cb: (action: BrowserShortcutAction) => void): () => void; }; + identity: { + /** Synchronous boot snapshot of the build's agent/service allowlist — same rationale as + * `settings.snapshot`: the composer's harness picker needs it before first paint. */ + restrictions(): AgentRestrictionsSnapshot; + }; } diff --git a/packages/system-plane/ipc/src/context.ts b/packages/system-plane/ipc/src/context.ts index efdb1789e..0f4f26384 100644 --- a/packages/system-plane/ipc/src/context.ts +++ b/packages/system-plane/ipc/src/context.ts @@ -1,3 +1,4 @@ +import type { AgentKind } from '@linkcode/schema'; import { z } from 'zod'; /** @@ -48,6 +49,16 @@ export interface SystemContext { /** Show an OS notification; a click focuses the window and pushes `clickToken` back. */ notify(notification: SystemNotification): void; }; + identity: { + /** Build-time agent/service allowlist (CODE-618); `null` fields mean unrestricted. */ + restrictions(): AgentRestrictionsSnapshot; + }; +} + +/** `null` means unrestricted — every agent/service is allowed. A brand only ever narrows this. */ +export interface AgentRestrictionsSnapshot { + readonly allowedAgents: readonly AgentKind[] | null; + readonly allowedServices: readonly string[] | null; } export const FileFilterSchema = z.object({ diff --git a/packages/system-plane/ipc/src/electron-main.ts b/packages/system-plane/ipc/src/electron-main.ts index 85ee654cc..d333b478f 100644 --- a/packages/system-plane/ipc/src/electron-main.ts +++ b/packages/system-plane/ipc/src/electron-main.ts @@ -12,6 +12,7 @@ import { } from './context'; import { DAEMON_URL_SNAPSHOT_CHANNEL, + IDENTITY_RESTRICTIONS_SNAPSHOT_CHANNEL, SETTINGS_SNAPSHOT_CHANNEL, systemIpcEvents, WINDOW_MAXIMIZED_CHANGED_CHANNEL, @@ -74,6 +75,13 @@ export function bindElectronSystemIpc({ }; ipcMain.on(DAEMON_URL_SNAPSHOT_CHANNEL, handleDaemonUrlSnapshot); + // Same sendSync rationale: the composer's harness picker must not flash the full agent set + // before the first restricted-build render. + const handleIdentityRestrictionsSnapshot = (event: IpcMainEvent): void => { + event.returnValue = ctx.identity.restrictions(); + }; + ipcMain.on(IDENTITY_RESTRICTIONS_SNAPSHOT_CHANNEL, handleIdentityRestrictionsSnapshot); + const emitMaximizedState = (): void => { if (!window.isDestroyed()) { window.webContents.send(WINDOW_MAXIMIZED_CHANGED_CHANNEL, ctx.window.isMaximized()); @@ -91,6 +99,10 @@ export function bindElectronSystemIpc({ for (const removeHandler of Object.values(removeHandlers)) removeHandler(); ipcMain.removeListener(SETTINGS_SNAPSHOT_CHANNEL, handleSnapshot); ipcMain.removeListener(DAEMON_URL_SNAPSHOT_CHANNEL, handleDaemonUrlSnapshot); + ipcMain.removeListener( + IDENTITY_RESTRICTIONS_SNAPSHOT_CHANNEL, + handleIdentityRestrictionsSnapshot, + ); window.off('maximize', emitMaximizedState); window.off('unmaximize', emitMaximizedState); window.off('enter-full-screen', emitMaximizedState); diff --git a/packages/system-plane/ipc/src/electron-renderer.ts b/packages/system-plane/ipc/src/electron-renderer.ts index d68f0fe69..be1fc9fc3 100644 --- a/packages/system-plane/ipc/src/electron-renderer.ts +++ b/packages/system-plane/ipc/src/electron-renderer.ts @@ -3,13 +3,19 @@ import { defineInvokes } from '@moeru/eventa'; import { createContext as createRendererContext } from '@moeru/eventa/adapters/electron/renderer'; import type { IpcRenderer } from 'electron'; import type { SystemBridge } from './bridge'; -import type { BrowserDownloadDone, DesktopSettings, UpdaterState } from './context'; +import type { + AgentRestrictionsSnapshot, + BrowserDownloadDone, + DesktopSettings, + UpdaterState, +} from './context'; import { BROWSER_DOWNLOAD_DONE_CHANNEL, BROWSER_OPEN_TAB_CHANNEL, BROWSER_SHORTCUT_CHANNEL, DAEMON_RUNTIME_CHANGED_CHANNEL, DAEMON_URL_SNAPSHOT_CHANNEL, + IDENTITY_RESTRICTIONS_SNAPSHOT_CHANNEL, NOTIFICATION_CLICKED_CHANNEL, SETTINGS_OPEN_CHANNEL, SETTINGS_SNAPSHOT_CHANNEL, @@ -30,6 +36,11 @@ const FALLBACK_SETTINGS: DesktopSettings = { historyImportOnboardingHandled: true, }; +const FALLBACK_RESTRICTIONS: AgentRestrictionsSnapshot = { + allowedAgents: null, + allowedServices: null, +}; + export function createElectronSystemBridge( ipcRenderer: IpcRenderer, platform: NodeJS.Platform, @@ -115,6 +126,12 @@ export function createElectronSystemBridge( return () => ipcRenderer.removeListener(NOTIFICATION_CLICKED_CHANNEL, handler); }, }, + identity: { + restrictions: () => + (ipcRenderer.sendSync(IDENTITY_RESTRICTIONS_SNAPSHOT_CHANNEL) as + | AgentRestrictionsSnapshot + | undefined) ?? FALLBACK_RESTRICTIONS, + }, browser: { onOpenTab(cb) { const handler: IpcRendererListener = (_event, value: unknown) => { diff --git a/packages/system-plane/ipc/src/events.ts b/packages/system-plane/ipc/src/events.ts index 321f735c7..31fb28096 100644 --- a/packages/system-plane/ipc/src/events.ts +++ b/packages/system-plane/ipc/src/events.ts @@ -14,6 +14,9 @@ export const WINDOW_MAXIMIZED_CHANGED_CHANNEL = 'linkcode.system.window.maximize export const SETTINGS_SNAPSHOT_CHANNEL = 'linkcode.system.settings.snapshot'; /** Synchronous effective daemon endpoint (read via `ipcRenderer.sendSync`, needed before first paint). */ export const DAEMON_URL_SNAPSHOT_CHANNEL = 'linkcode.system.daemon.urlSnapshot'; +/** Synchronous boot snapshot of the agent/service allowlist (read via `ipcRenderer.sendSync`). */ +export const IDENTITY_RESTRICTIONS_SNAPSHOT_CHANNEL = + 'linkcode.system.identity.restrictionsSnapshot'; /** Main → renderer push: the menubar/Cmd+, asked to open Settings. */ export const SETTINGS_OPEN_CHANNEL = 'linkcode.system.settings.open'; /** Main → renderer push: auto-update lifecycle state. */ From 28eb599466b712f5239e2f4a07e018d4ff278b1b Mon Sep 17 00:00:00 2001 From: lucas77778 <3098274296@qq.com> Date: Mon, 24 Aug 2026 22:46:24 +0800 Subject: [PATCH 3/9] feat(daemon): gate new agent adapters by the restricted-brand allowlist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/__tests__/agent-factory.test.ts | 19 ++++++++++++++ apps/daemon/src/__tests__/config.test.ts | 26 +++++++++++++++++++ apps/daemon/src/agent-factory.ts | 22 ++++++++++++++++ apps/daemon/src/config.ts | 14 ++++++++++ apps/daemon/src/index.ts | 8 +++++- 5 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 apps/daemon/src/__tests__/agent-factory.test.ts create mode 100644 apps/daemon/src/agent-factory.ts diff --git a/apps/daemon/src/__tests__/agent-factory.test.ts b/apps/daemon/src/__tests__/agent-factory.test.ts new file mode 100644 index 000000000..58ce95526 --- /dev/null +++ b/apps/daemon/src/__tests__/agent-factory.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; +import { restrictedAdapterFactory } from '../agent-factory'; + +describe('restrictedAdapterFactory', () => { + it('returns undefined when unrestricted, so the engine falls back to the bare createAdapter', () => { + expect(restrictedAdapterFactory(null)).toBeUndefined(); + }); + + it('constructs an allowed kind', () => { + const factory = restrictedAdapterFactory(['pi']); + expect(factory).toBeDefined(); + expect(factory?.('pi').kind).toBe('pi'); + }); + + it('rejects a kind outside the allowlist', () => { + const factory = restrictedAdapterFactory(['pi']); + expect(() => factory?.('codex')).toThrow('codex'); + }); +}); diff --git a/apps/daemon/src/__tests__/config.test.ts b/apps/daemon/src/__tests__/config.test.ts index 25f906fe9..a150d9f98 100644 --- a/apps/daemon/src/__tests__/config.test.ts +++ b/apps/daemon/src/__tests__/config.test.ts @@ -8,6 +8,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { cloudCredentialsPath, + daemonAllowedAgents, daemonProfile, databasePath, loadConfig, @@ -39,6 +40,7 @@ afterEach(() => { process.env.HOME = savedHome; delete process.env.LINKCODE_PROFILE; delete process.env.LINKCODE_CHANNEL; + delete process.env.LINKCODE_ALLOWED_AGENTS; vi.restoreAllMocks(); }); @@ -634,3 +636,27 @@ describe('credential storage', () => { expect(loadConfig(vault).accounts).toEqual([oauth]); }); }); + +describe('daemonAllowedAgents', () => { + it('is unrestricted when the env var is unset or empty', () => { + delete process.env.LINKCODE_ALLOWED_AGENTS; + expect(daemonAllowedAgents()).toBeNull(); + process.env.LINKCODE_ALLOWED_AGENTS = ''; + expect(daemonAllowedAgents()).toBeNull(); + }); + + it('parses a single allowed agent', () => { + process.env.LINKCODE_ALLOWED_AGENTS = 'pi'; + expect(daemonAllowedAgents()).toEqual(['pi']); + }); + + it('parses and trims a comma-separated list', () => { + process.env.LINKCODE_ALLOWED_AGENTS = 'pi, claude-code'; + expect(daemonAllowedAgents()).toEqual(['pi', 'claude-code']); + }); + + it('fails closed on an unknown agent kind', () => { + process.env.LINKCODE_ALLOWED_AGENTS = 'not-a-kind'; + expect(() => daemonAllowedAgents()).toThrow(); + }); +}); diff --git a/apps/daemon/src/agent-factory.ts b/apps/daemon/src/agent-factory.ts new file mode 100644 index 000000000..643ea25a3 --- /dev/null +++ b/apps/daemon/src/agent-factory.ts @@ -0,0 +1,22 @@ +import type { AdapterFactory } from '@linkcode/agent-adapter'; +import { createAdapter } from '@linkcode/agent-adapter'; +import type { AgentKind } from '@linkcode/schema'; + +/** + * Restricted-brand adapter gate (CODE-618): wraps `createAdapter` to reject any kind outside the + * allowlist. Only guards new adapter construction — a session started before a restriction landed + * keeps running on its existing adapter instance, and history reads never call this at all. + * `null` (unrestricted, the default build) returns `undefined` so the engine falls back to the + * bare `createAdapter`, an exact no-op. + */ +export function restrictedAdapterFactory( + allowedAgents: readonly AgentKind[] | null, +): AdapterFactory | undefined { + if (allowedAgents === null) return undefined; + return (kind) => { + if (!allowedAgents.includes(kind)) { + throw new Error(`agent kind ${kind} is not available in this build`); + } + return createAdapter(kind); + }; +} diff --git a/apps/daemon/src/config.ts b/apps/daemon/src/config.ts index 56ad8aa0d..266e012be 100644 --- a/apps/daemon/src/config.ts +++ b/apps/daemon/src/config.ts @@ -15,6 +15,7 @@ import { dirname, join } from 'node:path'; import { daemonRuntimeFilePath } from '@linkcode/common/node'; import type { Accounts, + AgentKind, CustomMcpServer, ProvidersConfig, SimulatorConsentState, @@ -88,6 +89,19 @@ export function worktreeRoot(): string { return join(daemonStateDir(), 'worktrees'); } +/** + * Restricted-brand agent allowlist (CODE-618): `LINKCODE_ALLOWED_AGENTS` — injected by the desktop + * supervisor from the build's identity, comma-separated — gates which adapter kinds this daemon + * will spawn. Absent (the default, unbranded build) means unrestricted: `null`, never an empty + * array, so every downstream check can treat "no restriction" as "skip the check". + */ +export function daemonAllowedAgents(): readonly AgentKind[] | null { + const raw = process.env.LINKCODE_ALLOWED_AGENTS; + if (raw === undefined || raw === '') return null; + const kinds = raw.split(',').map((entry) => AgentKindSchema.parse(entry.trim())); + return kinds.length > 0 ? kinds : null; +} + /** Runtime discovery file advertising the running daemon's bound endpoints, next to config.json. */ export function runtimeFilePath(): string { return daemonRuntimeFilePath(daemonChannel(), daemonProfile()); diff --git a/apps/daemon/src/index.ts b/apps/daemon/src/index.ts index d65c4eb67..0dd09bb68 100644 --- a/apps/daemon/src/index.ts +++ b/apps/daemon/src/index.ts @@ -26,6 +26,7 @@ import * as Sentry from '@sentry/node'; import type { Runtime } from 'effect'; import { Cause, Context, Effect, Exit, Layer, Option } from 'effect'; import { extractErrorMessage } from 'foxts/extract-error-message'; +import { restrictedAdapterFactory } from './agent-factory'; import { createAiGatewaySidecar } from './ai-gateway'; import { installAsarSpawnFix } from './asar-spawn'; import { adoptLegacyDeviceKeyFile } from './cloud/device-key'; @@ -34,6 +35,7 @@ import { startCloudUplink } from './cloud/uplink'; import type { DaemonConfig } from './config'; import { chatWorkspaceRoot, + daemonAllowedAgents, daemonChannel, daemonProfile, databasePath, @@ -186,7 +188,10 @@ async function main(): Promise { config.customMcpServers ?? [], ); const assets = new AssetManager(); - const consentedAgents = consentedManagedAgents(assets); + const allowedAgents = daemonAllowedAgents(); + const consentedAgents = consentedManagedAgents(assets).filter( + (kind) => allowedAgents === null || allowedAgents.includes(kind), + ); const gc = assets.gcAtBoot(); if (gc.removed.length > 0) { yield* Effect.logInfo('Removed superseded managed assets', { @@ -249,6 +254,7 @@ async function main(): Promise { yield* Effect.addFinalizer(() => finalize(() => simulatorMcp.close())); } const EngineInfrastructureLive = makeEngineInfrastructureLayer(hub, { + factory: restrictedAdapterFactory(allowedAgents), providerStore: store, ptyBackend: new SidecarPtyBackend(resolveSidecarPath()), simulators, From 46648dd44081be6dfefbce0cd1d3d288741ddf86 Mon Sep 17 00:00:00 2001 From: lucas77778 <3098274296@qq.com> Date: Mon, 24 Aug 2026 23:00:39 +0800 Subject: [PATCH 4/9] feat(desktop): exclude restricted agents' SDKs from packaged builds 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. --- apps/desktop/scripts/package-app.mts | 29 +++++++++++- apps/desktop/scripts/verify-artifacts.mts | 44 ++++++++++++++++++- .../__tests__/agent-package-excludes.test.ts | 28 ++++++++++++ .../src/build/agent-package-excludes.ts | 29 ++++++++++++ 4 files changed, 127 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/src/build/__tests__/agent-package-excludes.test.ts create mode 100644 apps/desktop/src/build/agent-package-excludes.ts diff --git a/apps/desktop/scripts/package-app.mts b/apps/desktop/scripts/package-app.mts index 23658340b..ded2ed456 100644 --- a/apps/desktop/scripts/package-app.mts +++ b/apps/desktop/scripts/package-app.mts @@ -27,7 +27,9 @@ import { import { tmpdir } from 'node:os'; import { join } from 'node:path'; import process from 'node:process'; +import type { AgentKind } from '@linkcode/schema'; import crossSpawn from 'cross-spawn'; +import { agentFilesExcludes } from '../src/build/agent-package-excludes'; import { assertStagedConfigMatchesGenerated } from './package-config.mts'; import { mergeUpdateFeeds } from './update-feed.mts'; @@ -164,6 +166,27 @@ function updateFeedName(arch: BuilderArch): string { * silently re-brand the artifact, so they are refused outright. */ const IDENTITY_OVERRIDE_RE = /^-c\.(?:appId|productName|protocols)\b/; +/** The rendered build bundle's declared agents, or `null` if absent/unrendered (CODE-618). */ +function stagedAllowedAgents(): readonly AgentKind[] | null { + const bundlePath = join(desktopDir, 'out', 'config', 'build-bundle.json'); + if (!existsSync(bundlePath)) return null; + const bundle = JSON.parse(readFileSync(bundlePath, 'utf8')) as { agents?: unknown }; + return Array.isArray(bundle.agents) ? (bundle.agents as AgentKind[]) : null; +} + +/** + * Wraps `configPath` in a temporary `extends` overlay adding `excludes` to `files` — electron- + * builder concatenates an extended config's `files` array rather than replacing it. `configPath` + * is always absolute here, which `extends` also resolves as-is (only relative `extends` targets + * resolve against the project dir — see electron-builder-brand.ts). + */ +function withFilesOverlay(configPath: string, excludes: readonly string[]): string { + if (excludes.length === 0) return configPath; + const overlayPath = join(tmpdir(), 'linkcode-desktop-agent-excludes.json'); + writeFileSync(overlayPath, JSON.stringify({ extends: configPath, files: excludes })); + return overlayPath; +} + function build(): void { // Both extend the shared electron-builder.yml base; each adds its own deep-link scheme (release // `linkcode://`, dev shell `linkcode-dev://`). The base is never passed directly — it has none. @@ -188,6 +211,10 @@ function build(): void { : branded ? brandConfig : 'electron-builder.release.yml'; + const configPath = branded ? config : join(desktopDir, config); + // Restricted-brand SDK exclusion (CODE-618): absent bundle agents is a no-op, so an unbranded + // (or unrestricted) build passes `configPath` through unmodified. + const finalConfigPath = withFilesOverlay(configPath, agentFilesExcludes(stagedAllowedAgents())); const brandIcon = join(desktopDir, 'out', 'config', 'brand-assets', 'icon.png'); const feeds = new Map(); for (const arch of stagedArches()) { @@ -206,7 +233,7 @@ function build(): void { '--projectDir', target, '--config', - branded ? config : join(desktopDir, config), + finalConfigPath, // projectDir is the staging dir, so config-relative paths would resolve under it; redirect // output back to where CI/verify-artifacts expect it and icons to the shared repo-root // assets — or, on branded builds, to the staged brand assets only. diff --git a/apps/desktop/scripts/verify-artifacts.mts b/apps/desktop/scripts/verify-artifacts.mts index d95da51f1..4923af0e5 100644 --- a/apps/desktop/scripts/verify-artifacts.mts +++ b/apps/desktop/scripts/verify-artifacts.mts @@ -12,6 +12,7 @@ import { } from 'node:fs'; import { join, sep } from 'node:path'; import process, { argv } from 'node:process'; +import { extractFile, listPackage, statFile } from '@electron/asar'; /** * Post-pack assertions for the desktop release artifacts, run in CI right after electron-builder * (locally: `node scripts/verify-artifacts.mts ` from apps/desktop). Asserts: the @@ -21,8 +22,9 @@ import process, { argv } from 'node:process'; * at an existing file with a matching sha512; and the unpacked apps carry the bundled daemon and * PTY sidecar, so a build never ships a client with no host runtime (CODE-86/87). */ -import { extractFile, listPackage, statFile } from '@electron/asar'; +import type { AgentKind } from '@linkcode/schema'; import { keysLength } from 'foxts/property-count'; +import { AGENT_SDK_PACKAGE_PATHS } from '../src/build/agent-package-excludes'; const RELEASE_DIR = 'release'; const FEED_URL_LINE = /^ {2}- url: (.+)$/; @@ -237,6 +239,42 @@ function verifyConfigBundle(resourceDir: string, asarPath: string, problems: str } } +/** + * A restricted brand's package must not ship the excluded agents' SDKs (CODE-618 acceptance a). + * Reads the rendered bundle's declared `agents` — absent (the standard/unbranded build) skips + * this check entirely, matching today's behavior byte-for-byte. Path segments are matched exactly + * (not by prefix) so e.g. `@openai/codex-darwin-*` never false-positives against `@openai/codex`. + */ +function verifyNoRestrictedAgentPackages( + resourceDir: string, + asarPath: string, + problems: string[], +): void { + const generated = readOrNull(join('generated', 'config-build-bundle.json')); + if (generated === null) return; + const bundle = JSON.parse(generated) as { agents?: unknown }; + if (!Array.isArray(bundle.agents)) return; + const allowed = new Set(bundle.agents as AgentKind[]); + const excludedPaths = ( + Object.entries(AGENT_SDK_PACKAGE_PATHS) as Array<[AgentKind, readonly string[]]> + ) + .filter(([kind]) => !allowed.has(kind)) + .flatMap(([, paths]) => paths); + if (excludedPaths.length === 0) return; + const entries = new Set( + listPackage(asarPath, { isPack: false }).map((raw) => { + const normalized = raw.replaceAll('\\', '/'); + return normalized[0] === '/' ? normalized.slice(1) : normalized; + }), + ); + for (const path of excludedPaths) { + const shipped = [...entries].some((entry) => entry === path || entry.startsWith(`${path}/`)); + if (shipped) { + problems.push(`${resourceDir}/app.asar: restricted-brand package shipped: ${path}`); + } + } +} + /** The packed app must carry the host runtime: bundled daemon in the asar, sidecar beside it. */ function verifyHostRuntime(resourceDir: string, problems: string[]): void { const asarPath = join(RELEASE_DIR, resourceDir, 'app.asar'); @@ -397,9 +435,11 @@ async function main(): Promise { ), ); for (const resourceDir of expected.resourceDirs) { + const asarPath = join(RELEASE_DIR, resourceDir, 'app.asar'); verifyHostRuntime(resourceDir, problems); verifyNativeBindings(platform, resourceDir, problems); - verifyConfigBundle(resourceDir, join(RELEASE_DIR, resourceDir, 'app.asar'), problems); + verifyConfigBundle(resourceDir, asarPath, problems); + verifyNoRestrictedAgentPackages(resourceDir, asarPath, problems); } if (problems.length > 0) { diff --git a/apps/desktop/src/build/__tests__/agent-package-excludes.test.ts b/apps/desktop/src/build/__tests__/agent-package-excludes.test.ts new file mode 100644 index 000000000..3574d4eaa --- /dev/null +++ b/apps/desktop/src/build/__tests__/agent-package-excludes.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { agentFilesExcludes } from '../agent-package-excludes'; + +describe('agentFilesExcludes', () => { + it('is an exact no-op when unrestricted', () => { + expect(agentFilesExcludes(null)).toEqual([]); + }); + + it.each([ + ['pi', ['!node_modules/@anthropic-ai/claude-agent-sdk/**', '!node_modules/@openai/codex/**', '!node_modules/@opencode-ai/sdk/**']], + ['grok-build', ['!node_modules/@anthropic-ai/claude-agent-sdk/**', '!node_modules/@openai/codex/**', '!node_modules/@opencode-ai/sdk/**']], + ['claude-code', ['!node_modules/@openai/codex/**', '!node_modules/@opencode-ai/sdk/**']], + ['codex', ['!node_modules/@anthropic-ai/claude-agent-sdk/**', '!node_modules/@opencode-ai/sdk/**']], + ['opencode', ['!node_modules/@anthropic-ai/claude-agent-sdk/**', '!node_modules/@openai/codex/**']], + ] as const)('excludes every SDK except the ones the sole allowed kind %s needs', (kind, expected) => { + expect(agentFilesExcludes([kind])).toEqual(expected); + }); + + it('excludes nothing when every SDK-carrying kind is allowed', () => { + expect(agentFilesExcludes(['claude-code', 'codex', 'opencode'])).toEqual([]); + }); + + it('never excludes pi or grok-build (neither carries a staged SDK package)', () => { + const excludes = agentFilesExcludes(['pi']); + expect(excludes).toHaveLength(3); + expect(excludes.join(' ')).not.toMatch(/grok|[/@]pi[/@-]/); + }); +}); diff --git a/apps/desktop/src/build/agent-package-excludes.ts b/apps/desktop/src/build/agent-package-excludes.ts new file mode 100644 index 000000000..cceae3f89 --- /dev/null +++ b/apps/desktop/src/build/agent-package-excludes.ts @@ -0,0 +1,29 @@ +import type { AgentKind } from '@linkcode/schema'; + +/** + * Per-kind node_modules package paths for the agent SDKs actually staged in the deploy closure + * (CODE-618). Platform CLI binaries are already excluded for every build by the shared + * electron-builder.yml globs (CODE-114); this table only covers the pure-JS SDK entry packages a + * restricted brand does not declare. `pi`'s SDK is a hosted download (never staged) and + * `grok-build` has no SDK, so neither needs an entry. Single source of truth for both the + * packaging exclusion globs (below) and verify-artifacts.mts's post-pack assertion. + */ +export const AGENT_SDK_PACKAGE_PATHS: Readonly>> = { + 'claude-code': ['node_modules/@anthropic-ai/claude-agent-sdk'], + codex: ['node_modules/@openai/codex'], + opencode: ['node_modules/@opencode-ai/sdk'], +}; + +/** + * Exclusion globs for every agent kind not in `allowedAgents`. `null` (unrestricted, the default + * build) is an exact no-op — an empty array, so packaging stays on its unmodified config. + */ +export function agentFilesExcludes(allowedAgents: readonly AgentKind[] | null): string[] { + if (allowedAgents === null) return []; + const allowed = new Set(allowedAgents); + const excludes: string[] = []; + for (const [kind, paths] of Object.entries(AGENT_SDK_PACKAGE_PATHS)) { + if (!allowed.has(kind as AgentKind)) excludes.push(...paths.map((path) => `!${path}/**`)); + } + return excludes; +} From bd62ef1d68fc8fc13e33fa7626009f1d67f39c8c Mon Sep 17 00:00:00 2001 From: lucas77778 <3098274296@qq.com> Date: Mon, 24 Aug 2026 23:01:42 +0800 Subject: [PATCH 5/9] feat(mobile): thread the build-time agent allowlist into the config bootstrap 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. --- .../src/components/host/new-thread-sheet.tsx | 38 +++++++++++-------- .../runtime/config/__tests__/bundled.test.ts | 14 +++++++ apps/mobile/src/runtime/config/bundled.ts | 9 +++++ 3 files changed, 46 insertions(+), 15 deletions(-) diff --git a/apps/mobile/src/components/host/new-thread-sheet.tsx b/apps/mobile/src/components/host/new-thread-sheet.tsx index 852204e9f..48c5d6129 100644 --- a/apps/mobile/src/components/host/new-thread-sheet.tsx +++ b/apps/mobile/src/components/host/new-thread-sheet.tsx @@ -25,6 +25,11 @@ import { AgentKindSchema } from '@linkcode/schema'; import { AGENT_LABELS, repositoryLabel } from '@linkcode/ui/native'; import { useState } from 'react'; import { useTranslations } from 'use-intl'; +import { BUNDLED_CONFIG_BOOTSTRAP } from '../../runtime/config/bundled'; + +/** Build-time agent allowlist (CODE-618); unrestricted builds see every known kind. */ +const SELECTABLE_KINDS: readonly AgentKind[] = + BUNDLED_CONFIG_BOOTSTRAP.allowedAgents ?? AgentKindSchema.options; const SECONDARY = foregroundStyle({ type: 'hierarchical', style: 'secondary' }); const FOOTNOTE = font({ textStyle: 'footnote' }); @@ -46,7 +51,7 @@ export function NewThreadSheet({ }): React.ReactNode { const t = useTranslations('mobile.sessions'); - const [kind, setKind] = useState(AgentKindSchema.options[0]); + const [kind, setKind] = useState(SELECTABLE_KINDS[0]); const [selectedCwd, setSelectedCwd] = useState(null); const customPath = useNativeState(''); @@ -73,20 +78,23 @@ export function NewThreadSheet({ >
{/* Segmented rather than the old icon chips: the agent brand marks are RN SVG - components, which have no place in a SwiftUI view tree. */} -
- - {AgentKindSchema.options.map((option) => ( - - {AGENT_LABELS[option]} - - ))} - -
+ components, which have no place in a SwiftUI view tree. Hidden entirely (rather than + disabled) once a restricted build leaves only one selectable kind pinned above. */} + {SELECTABLE_KINDS.length > 1 && ( +
+ + {SELECTABLE_KINDS.map((option) => ( + + {AGENT_LABELS[option]} + + ))} + +
+ )} {ordered.length > 0 ? ( // An inline picker draws the selection checkmark itself, replacing the hand-placed one. diff --git a/apps/mobile/src/runtime/config/__tests__/bundled.test.ts b/apps/mobile/src/runtime/config/__tests__/bundled.test.ts index 3f3365c90..7729f2e99 100644 --- a/apps/mobile/src/runtime/config/__tests__/bundled.test.ts +++ b/apps/mobile/src/runtime/config/__tests__/bundled.test.ts @@ -23,6 +23,8 @@ describe('bundledConfigFromModule', () => { expect(bootstrap.platform).toBeNull(); expect(bootstrap.remoteBaseUrl).toBeNull(); expect(bootstrap.telemetryEndpoint).toBeNull(); + expect(bootstrap.allowedAgents).toBeNull(); + expect(bootstrap.allowedServices).toBeNull(); expect(defaults).toEqual({}); expect(definitions).toEqual({}); }); @@ -44,9 +46,21 @@ describe('bundledConfigFromModule', () => { expect(defaults['app.displayName']).toBe('Acme Studio'); expect(defaults['modules.terminal.enabled']).toBe(false); expect(definitions['app.displayName'].defaultValue).toBe('Acme Studio'); + // Neither fixture declares agents/services — absent must stay absent (unrestricted). + expect(bootstrap.allowedAgents).toBeNull(); + expect(bootstrap.allowedServices).toBeNull(); } }); + it('carries a restricted bundle agents/services through as the allowlists', async () => { + const restricted = (await loadFixture('-ios')) as Record; + restricted.agents = ['pi']; + restricted.services = ['linkcode-gateway']; + const { bootstrap } = bundledConfigFromModule({ bundle: restricted }); + expect(bootstrap.allowedAgents).toEqual(['pi']); + expect(bootstrap.allowedServices).toEqual(['linkcode-gateway']); + }); + it('rejects a desktop bundle instead of running it on mobile', async () => { const fixtureDesktop = await loadFixture(''); expect(() => bundledConfigFromModule({ bundle: fixtureDesktop })).toThrow( diff --git a/apps/mobile/src/runtime/config/bundled.ts b/apps/mobile/src/runtime/config/bundled.ts index 4a5d68733..860357940 100644 --- a/apps/mobile/src/runtime/config/bundled.ts +++ b/apps/mobile/src/runtime/config/bundled.ts @@ -9,11 +9,16 @@ import { definitionsFromDefaults, parseConfigBuildBundle, } from '@linkcode/common/config'; +import type { AgentKind } from '@linkcode/schema'; // Metro resolves bundled.generated..ts when scripts/render-config-bundle.mts has run; // the committed base module must stay the { bundle: null } development sentinel. import generatedModule from './bundled.generated'; export interface BundledConfigBootstrap { + /** Build-time agent allowlist (CODE-618); `null` means unrestricted. */ + readonly allowedAgents: readonly AgentKind[] | null; + /** Build-time service allowlist (CODE-618); `null` means unrestricted. */ + readonly allowedServices: readonly string[] | null; readonly brandId: string; readonly channel: ConfigChannel; readonly emergencyKeyring: Readonly>; @@ -34,6 +39,8 @@ export interface BundledConfig { const DEV_FALLBACK: BundledConfig = { bootstrap: { + allowedAgents: null, + allowedServices: null, brandId: 'linkcode', channel: 'stable', emergencyKeyring: {}, @@ -63,6 +70,8 @@ export function bundledConfigFromModule(module: unknown): BundledConfig { const defaults = configBuildBundleDefaults(bundle); return { bootstrap: { + allowedAgents: bundle.agents ?? null, + allowedServices: bundle.services ?? null, brandId: bundle.brandId, channel: bundle.channel, emergencyKeyring: bundle.keyrings.emergency, From ccac9afa10a60f07e06475ce8bc4c638c491c1f3 Mon Sep 17 00:00:00 2001 From: lucas77778 <3098274296@qq.com> Date: Mon, 24 Aug 2026 23:05:17 +0800 Subject: [PATCH 6/9] feat(workbench,ui): filter the harness picker and add-account catalog 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. --- apps/desktop/src/renderer/src/app.tsx | 6 ++- .../src/renderer/src/settings/agents-tab.tsx | 6 +++ .../renderer/src/settings/providers-tab.tsx | 7 ++++ .../src/settings/agents-settings.tsx | 12 +++++- .../providers/__tests__/add-flow.test.tsx | 38 +++++++++++++++++++ .../providers/__tests__/model-options.test.ts | 14 +++++++ .../__tests__/providers-settings.test.tsx | 9 +++++ .../src/settings/providers/add-flow.tsx | 28 +++++++++++++- .../src/settings/providers/model-options.ts | 16 ++++++-- .../settings/providers/providers-settings.tsx | 7 ++++ .../workbench/src/surface/workbench.tsx | 11 +++++- .../__tests__/new-session-surface.test.tsx | 19 ++++++++++ .../ui/src/shell/composer-controls.tsx | 7 +++- 13 files changed, 171 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/renderer/src/app.tsx b/apps/desktop/src/renderer/src/app.tsx index 09c2f6aff..d47a7e8ff 100644 --- a/apps/desktop/src/renderer/src/app.tsx +++ b/apps/desktop/src/renderer/src/app.tsx @@ -28,6 +28,10 @@ const listCloudHosts = (): Promise => cloudDataBridge.listHosts(); // The preload bridge implements CloudImSource verbatim; hand it to the provider as-is. const cloudImSource = cloudDataBridge.im; +// Build-time snapshot (CODE-618): read once, like settings/store.ts's snapshot — it never +// changes for the life of this process, so it need not be a React data source. +const { allowedAgents } = systemBridge.identity.restrictions(); + export function DesktopApp(): React.ReactNode { const localeOverride = useDesktopSettingsStore((state) => state.localeOverride); const settingsOpen = useNavigationHistoryStore((state) => state.overlay === 'settings'); @@ -46,7 +50,7 @@ export function DesktopApp(): React.ReactNode { > - + {/* Gated: Automations lists schedules over the data plane, so it mounts inside the gate. */} {automationsOpen ? : null} diff --git a/apps/desktop/src/renderer/src/settings/agents-tab.tsx b/apps/desktop/src/renderer/src/settings/agents-tab.tsx index ea2f65cc9..c96b52df8 100644 --- a/apps/desktop/src/renderer/src/settings/agents-tab.tsx +++ b/apps/desktop/src/renderer/src/settings/agents-tab.tsx @@ -1,6 +1,11 @@ import { AgentsSettingsPanel, useProvidersSettingsStore } from '@linkcode/workbench'; +import { systemBridge } from '../ipc'; import { useDesktopSettingsStore } from './store'; +// Build-time snapshot (CODE-618): read once, like app.tsx's — it never changes for the life of +// this process. +const { allowedAgents } = systemBridge.identity.restrictions(); + // Runtime concerns only; account/model bindings live on the Providers tab, and the summary row // jumps there with the bound account pre-selected. export function AgentsTab(): React.ReactNode { @@ -8,6 +13,7 @@ export function AgentsTab(): React.ReactNode { const selectAccount = useProvidersSettingsStore((state) => state.select); return ( { if (accountId !== undefined) selectAccount(accountId); setCategory('providers'); diff --git a/apps/desktop/src/renderer/src/settings/providers-tab.tsx b/apps/desktop/src/renderer/src/settings/providers-tab.tsx index dec0b7522..45c39e6ab 100644 --- a/apps/desktop/src/renderer/src/settings/providers-tab.tsx +++ b/apps/desktop/src/renderer/src/settings/providers-tab.tsx @@ -1,6 +1,11 @@ import { ProvidersSettingsPanel } from '@linkcode/workbench'; import { cloudDataBridge } from '../cloud-auth/bridges'; import { useCloudAccount } from '../cloud-auth/use-cloud-account'; +import { systemBridge } from '../ipc'; + +// Build-time snapshot (CODE-618): read once, like app.tsx's — it never changes for the life of +// this process. +const { allowedAgents, allowedServices } = systemBridge.identity.restrictions(); // A transport-backed workbench container: reachable above the connection gate (the `ungated` // slot), degrading to loading/error while the daemon is unreachable — like the history-import tab. @@ -14,6 +19,8 @@ export function ProvidersTab(): React.ReactNode { signIn: cloud.signIn, createKey: cloudDataBridge.createGatewayKey, }} + allowedAgents={allowedAgents} + allowedServices={allowedServices} /> ); } diff --git a/packages/client/workbench/src/settings/agents-settings.tsx b/packages/client/workbench/src/settings/agents-settings.tsx index eb2249acd..d990264dc 100644 --- a/packages/client/workbench/src/settings/agents-settings.tsx +++ b/packages/client/workbench/src/settings/agents-settings.tsx @@ -20,9 +20,14 @@ import { SimulatorAgentAccessCard } from './simulator-access'; */ export function AgentsSettingsPanel({ onOpenProviders, + allowedAgents = null, }: { /** Navigate to the Providers page, selecting the agent's bound account when there is one. */ onOpenProviders: (accountId: string | undefined) => void; + /** Restricted-brand agent allowlist (CODE-618); `null` (the default) means unrestricted. Hides + * the row outright — a disallowed agent isn't bundled, so a switch for it would have nothing to + * enable. Account-binding resolution (`view.ts`'s `AGENT_KINDS` usage) is untouched. */ + allowedAgents?: readonly AgentKind[] | null; }): React.ReactNode { const t = useTranslations('settings.agents'); const tAgent = useTranslations('workbench.agentKind'); @@ -37,12 +42,17 @@ export function AgentsSettingsPanel({ void mutateProviders(); }; + const visibleKinds = + allowedAgents === null + ? AGENT_KINDS + : AGENT_KINDS.filter((kind) => allowedAgents.includes(kind)); + return (
{/* The page title is rendered by the settings shell; this is the lead subtitle. */}

{t('hint')}

- {AGENT_KINDS.map((kind) => { + {visibleKinds.map((kind) => { const runtime = runtimes?.[kind]; // The first enabled account is what a start that names none resolves to, so it is the one // worth naming here; the rest are alternatives its model menu offers. diff --git a/packages/client/workbench/src/settings/providers/__tests__/add-flow.test.tsx b/packages/client/workbench/src/settings/providers/__tests__/add-flow.test.tsx index dca3f8202..4ff2266c1 100644 --- a/packages/client/workbench/src/settings/providers/__tests__/add-flow.test.tsx +++ b/packages/client/workbench/src/settings/providers/__tests__/add-flow.test.tsx @@ -295,6 +295,44 @@ describe('non-subscription account creation', () => { expect(onPick).toHaveBeenCalledWith('linkcode-gateway'); }); + it('is unaffected by allowedAgents/allowedServices when both are null (standard build)', () => { + const onPick = vi.fn(); + render(); + expect(screen.getByText('serviceName.custom')).toBeTruthy(); + expect(screen.getByText('serviceName.claude-sub')).toBeTruthy(); + expect(screen.getByText('serviceName.linkcode-gateway')).toBeTruthy(); + }); + + it('excludes custom (and every other endpoint service) by default under a service allowlist (CODE-618)', () => { + const onPick = vi.fn(); + render( + , + ); + expect(screen.queryByText('serviceName.custom')).toBeNull(); + expect(screen.queryByText('serviceName.anthropic-api')).toBeNull(); + expect(screen.getByText('serviceName.linkcode-gateway')).toBeTruthy(); + }); + + it('includes custom once a brand explicitly declares it as an allowed service', () => { + const onPick = vi.fn(); + render(); + expect(screen.getByText('serviceName.custom')).toBeTruthy(); + expect(screen.queryByText('serviceName.anthropic-api')).toBeNull(); + }); + + it('filters oauth subscription entries by allowedAgents, independent of allowedServices', () => { + const onPick = vi.fn(); + render(); + expect(screen.queryByText('serviceName.claude-sub')).toBeNull(); + expect(screen.queryByText('serviceName.chatgpt-sub')).toBeNull(); + // pi has no oauth catalog entry, but every endpoint/custom service stays (allowedServices null). + expect(screen.getByText('serviceName.custom')).toBeTruthy(); + }); + it('stores template values instead of a resolved endpoint', async () => { const onSubmit = vi.fn(); const { container } = render( diff --git a/packages/client/workbench/src/settings/providers/__tests__/model-options.test.ts b/packages/client/workbench/src/settings/providers/__tests__/model-options.test.ts index 2d7730cd9..b3e5beb80 100644 --- a/packages/client/workbench/src/settings/providers/__tests__/model-options.test.ts +++ b/packages/client/workbench/src/settings/providers/__tests__/model-options.test.ts @@ -141,4 +141,18 @@ describe('selectableHarnessKinds', () => { 'grok-build', ]); }); + + it('is unaffected by allowedAgents when null or absent (standard build, CODE-618)', () => { + const withoutRestriction = selectableHarnessKinds({ opencode: { enabled: false } }); + expect(selectableHarnessKinds({ opencode: { enabled: false } }, null)).toEqual( + withoutRestriction, + ); + expect(selectableHarnessKinds({ opencode: { enabled: false } })).toEqual(withoutRestriction); + }); + + it('intersects the enabled set with a restricted-brand allowlist', () => { + expect(selectableHarnessKinds({}, ['pi'])).toEqual(['pi']); + expect(selectableHarnessKinds({ pi: { enabled: false } }, ['pi'])).toEqual([]); + expect(selectableHarnessKinds({}, ['pi', 'codex'])).toEqual(['codex', 'pi']); + }); }); diff --git a/packages/client/workbench/src/settings/providers/__tests__/providers-settings.test.tsx b/packages/client/workbench/src/settings/providers/__tests__/providers-settings.test.tsx index 88dae40ca..e54d271d1 100644 --- a/packages/client/workbench/src/settings/providers/__tests__/providers-settings.test.tsx +++ b/packages/client/workbench/src/settings/providers/__tests__/providers-settings.test.tsx @@ -176,3 +176,12 @@ describe('provider account ordering', () => { expect(screen.getByTestId('account-order').textContent).toBe('Account A,Account B'); }); }); + +describe('restricted-brand allowlists (CODE-618)', () => { + it('still lists a stored account whose service the current build excludes from the catalog', () => { + // Neither account's service ('anthropic-api', 'deepseek') is in this allowlist — the account + // list/detail must stay unfiltered regardless; only the add-account catalog narrows. + render(); + expect(screen.getByTestId('account-order').textContent).toBe('Account A,Account B'); + }); +}); diff --git a/packages/client/workbench/src/settings/providers/add-flow.tsx b/packages/client/workbench/src/settings/providers/add-flow.tsx index b83f57c8b..25e646eaa 100644 --- a/packages/client/workbench/src/settings/providers/add-flow.tsx +++ b/packages/client/workbench/src/settings/providers/add-flow.tsx @@ -14,6 +14,7 @@ import type { AccountModel, AccountProtocol, AccountSecret, + AgentKind, AgentRuntimes, } from '@linkcode/schema'; import { AccountModelSchema } from '@linkcode/schema'; @@ -47,6 +48,25 @@ const SERVICES_BY_GROUP = new Map( ); for (const service of SERVICE_CATALOG) SERVICES_BY_GROUP.get(service.group)?.push(service); +/** + * Two-axis restricted-brand filter (CODE-618) for the add-account catalog only: oauth entries by + * their bound agent, everything else (endpoint services and `custom`) by service id. `custom` + * gets no special case — it is a plain catalog id like any other, so the id-set intersection + * excludes it on its own whenever a brand declares `services` without naming it. `serviceById`, + * the account list/detail, and account resolution stay unfiltered everywhere else: an account + * created under a service this build no longer offers must keep resolving and rendering. + */ +export function isServiceSelectable( + service: ServiceDescriptor, + allowedAgents: readonly AgentKind[] | null, + allowedServices: readonly string[] | null, +): boolean { + if (service.kind === 'oauth') { + return allowedAgents === null || allowedAgents.includes(service.agent); + } + return allowedServices === null || allowedServices.includes(service.id); +} + /** Account constructors live at module scope: `Date.now` may not run in a component body. */ function newAccountBase(label: string): Pick { return { id: `acc_${crypto.randomUUID()}`, label: label.trim(), createdAt: Date.now() }; @@ -132,9 +152,14 @@ function accountFromCustomDraft(draft: CustomDraft, account?: Account): Account export function ServiceCatalogView({ onPick, linkCodeGatewayAvailable = false, + allowedAgents = null, + allowedServices = null, }: { onPick: (service: string) => void; linkCodeGatewayAvailable?: boolean; + /** Restricted-brand allowlists (CODE-618); `null` (the default) means unrestricted. */ + allowedAgents?: readonly AgentKind[] | null; + allowedServices?: readonly string[] | null; }): React.ReactNode { const t = useTranslations('settings.providers'); const locale = useLocale(); @@ -152,7 +177,8 @@ export function ServiceCatalogView({
{(SERVICES_BY_GROUP.get(group) ?? []).map((service) => - !linkCodeGatewayAvailable && service.id === LINKCODE_GATEWAY_SERVICE_ID ? null : ( + (!linkCodeGatewayAvailable && service.id === LINKCODE_GATEWAY_SERVICE_ID) || + !isServiceSelectable(service, allowedAgents, allowedServices) ? null : (
@@ -199,6 +205,7 @@ interface WorkbenchSessionSurfaceProps { onWorkspacePick: (pick: NewSessionWorkspacePick) => void; onClearError: () => void; onError: (err: unknown) => void; + allowedAgents: readonly AgentKind[] | null; } function WorkbenchSessionSurface({ @@ -210,6 +217,7 @@ function WorkbenchSessionSurface({ onWorkspacePick, onClearError, onError, + allowedAgents, }: WorkbenchSessionSurfaceProps): React.ReactNode { const tk = useTranslations('workbench.agentKind'); const tComposer = useTranslations('workbench.composer'); @@ -247,7 +255,8 @@ function WorkbenchSessionSurface({ const { mentionItems, onMentionQueryChange } = useFileMentionSource(); const accountModels = useAccountModelOptions(); const { data: providers } = useData(getProviderConfig, {}); - const selectableHarnesses = providers === undefined ? null : selectableHarnessKinds(providers); + const selectableHarnesses = + providers === undefined ? null : selectableHarnessKinds(providers, allowedAgents); const sdkClient = useWorkbenchSdkClient(); const activeSessionId = sessions.activeId; // Announce observation of the focused session so the daemon replays buffered per-session state diff --git a/packages/presentation/ui/src/shell/__tests__/new-session-surface.test.tsx b/packages/presentation/ui/src/shell/__tests__/new-session-surface.test.tsx index b9e3b9387..a96b5c5b9 100644 --- a/packages/presentation/ui/src/shell/__tests__/new-session-surface.test.tsx +++ b/packages/presentation/ui/src/shell/__tests__/new-session-surface.test.tsx @@ -326,6 +326,25 @@ describe('NewSessionSurface', () => { ); }); + it('hides the harness picker entirely when only one harness is selectable (CODE-618)', async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click(screen.getByRole('button', { name: RE_HARNESS_CLAUDE_CODE_BUTTON })); + expect(screen.queryByRole('menuitem', { name: RE_HARNESS_CLAUDE_CODE_MENU })).toBeNull(); + }); + it('blocks submission when every harness is disabled', async () => { const onSubmit = vi.fn().mockResolvedValue(undefined); render( diff --git a/packages/presentation/ui/src/shell/composer-controls.tsx b/packages/presentation/ui/src/shell/composer-controls.tsx index 2462bf7e1..59ef59a63 100644 --- a/packages/presentation/ui/src/shell/composer-controls.tsx +++ b/packages/presentation/ui/src/shell/composer-controls.tsx @@ -299,6 +299,9 @@ export function ModelSelectorMenu({ const showsModel = harnesses.length > 0 || hasModels || selectedModelId !== null; if (!hasEfforts && !showsModel && harnesses.length === 0) return null; + // A single remaining harness (restricted build, CODE-618, or otherwise) is nothing to pick + // between — the picker must disappear rather than offer a menu with one inert entry. + const showsHarnessPicker = harnesses.length > 1; const selectorLabels: string[] = []; if (harness) selectorLabels.push(AGENT_LABELS[harness]); if (showsModel) selectorLabels.push(modelLabel); @@ -311,7 +314,7 @@ export function ModelSelectorMenu({ disabled={disabled} render={