diff --git a/apps/daemon/src/__tests__/agent-restrictions.test.ts b/apps/daemon/src/__tests__/agent-restrictions.test.ts new file mode 100644 index 000000000..a0ec4bbc8 --- /dev/null +++ b/apps/daemon/src/__tests__/agent-restrictions.test.ts @@ -0,0 +1,117 @@ +import type { AssetService } from '@linkcode/engine'; +import type { AgentRuntimes, InstalledAsset, ManagedAssetId } from '@linkcode/schema'; +import { noop } from 'foxts/noop'; +import { describe, expect, it, vi } from 'vitest'; +import { filterAgentRuntimes, restrictedAssetService } from '../agent-restrictions'; + +describe('filterAgentRuntimes', () => { + const runtimes: AgentRuntimes = { + 'claude-code': { status: 'available', source: 'detected', path: '/usr/bin/claude' }, + codex: { status: 'available', source: 'sdk' }, + pi: { status: 'missing' }, + }; + + it('returns the runtimes unchanged when unrestricted', () => { + expect(filterAgentRuntimes(runtimes, null)).toBe(runtimes); + }); + + it('reports a disallowed kind as missing regardless of how it was actually probed', () => { + const filtered = filterAgentRuntimes(runtimes, ['pi']); + expect(filtered['claude-code']).toEqual({ status: 'missing' }); + expect(filtered.codex).toEqual({ status: 'missing' }); + expect(filtered.pi).toEqual({ status: 'missing' }); + }); + + it('leaves an allowed kind exactly as probed', () => { + const filtered = filterAgentRuntimes(runtimes, ['claude-code']); + expect(filtered['claude-code']).toBe(runtimes['claude-code']); + }); +}); + +describe('restrictedAssetService', () => { + // Mocks kept as loose locals rather than read back off the typed `AssetService` — asserting via + // `assets.ensure` would reference an interface method (unbound-method lint) for no benefit here. + function fakeAssets(): { + assets: AssetService; + ensure: ReturnType; + statuses: ReturnType; + subscribe: ReturnType; + } { + const ensure = vi.fn( + (id: ManagedAssetId): Promise => + Promise.resolve({ id, version: '1.0.0', path: '/tmp/asset' }), + ); + const statuses = vi.fn(() => []); + const subscribe = vi.fn(() => noop); + return { assets: { statuses, subscribe, ensure }, ensure, statuses, subscribe }; + } + + it('returns the asset service unchanged when unrestricted', () => { + const { assets } = fakeAssets(); + expect(restrictedAssetService(assets, null)).toBe(assets); + }); + + it('refuses to ensure a disallowed agent asset without touching the underlying store', async () => { + const { assets, ensure } = fakeAssets(); + const restricted = restrictedAssetService(assets, ['pi']); + + const installed = await restricted.ensure({ kind: 'agent', name: 'codex' }); + + expect(installed).toBeUndefined(); + expect(ensure).not.toHaveBeenCalled(); + }); + + it('passes an allowed agent asset through to the underlying store', async () => { + const { assets, ensure } = fakeAssets(); + const restricted = restrictedAssetService(assets, ['pi']); + + await restricted.ensure({ kind: 'agent', name: 'pi' }); + + expect(ensure).toHaveBeenCalledWith({ kind: 'agent', name: 'pi' }); + }); + + it('never agent-gates a tool asset', async () => { + const { assets, ensure } = fakeAssets(); + const restricted = restrictedAssetService(assets, ['pi']); + + await restricted.ensure({ kind: 'tool', name: 'aigateway' }); + + expect(ensure).toHaveBeenCalledWith({ kind: 'tool', name: 'aigateway' }); + }); + + it('hides a disallowed agent asset from statuses()', () => { + const { assets, statuses } = fakeAssets(); + statuses.mockReturnValue([ + { id: { kind: 'agent', name: 'codex' } }, + { id: { kind: 'agent', name: 'pi' } }, + { id: { kind: 'tool', name: 'aigateway' } }, + ]); + const restricted = restrictedAssetService(assets, ['pi']); + + expect(restricted.statuses().map(({ id }) => id)).toEqual([ + { kind: 'agent', name: 'pi' }, + { kind: 'tool', name: 'aigateway' }, + ]); + }); + + it('drops a disallowed agent asset from subscribe() events', () => { + const { assets, subscribe } = fakeAssets(); + let emit: ((event: unknown) => void) | undefined; + subscribe.mockImplementation((listener: (event: unknown) => void) => { + emit = listener; + return noop; + }); + const restricted = restrictedAssetService(assets, ['pi']); + const listener = vi.fn(); + restricted.subscribe(listener); + + emit?.({ kind: 'failed', id: { kind: 'agent', name: 'codex' }, error: 'x' }); + emit?.({ kind: 'failed', id: { kind: 'agent', name: 'pi' }, error: 'x' }); + emit?.({ kind: 'failed', id: { kind: 'tool', name: 'aigateway' }, error: 'x' }); + + expect(listener.mock.calls.map(([event]) => (event as { id: ManagedAssetId }).id)).toEqual([ + { kind: 'agent', name: 'pi' }, + { kind: 'tool', name: 'aigateway' }, + ]); + }); +}); 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-restrictions.ts b/apps/daemon/src/agent-restrictions.ts new file mode 100644 index 000000000..5a78a64ab --- /dev/null +++ b/apps/daemon/src/agent-restrictions.ts @@ -0,0 +1,46 @@ +import type { AssetService } from '@linkcode/engine'; +import type { AgentKind, AgentRuntimes, ManagedAssetId } from '@linkcode/schema'; + +/** + * Restricted-brand runtime-probe filter (CODE-618): a disallowed agent must never read as + * `available` on a restricted build, however the boot probe actually found it (detected CLI, + * managed install, or SDK-resolved) — the settings page and onboarding cards read straight off + * this map. `null` (unrestricted, the default build) returns `runtimes` unchanged. + */ +export function filterAgentRuntimes( + runtimes: AgentRuntimes, + allowedAgents: readonly AgentKind[] | null, +): AgentRuntimes { + if (allowedAgents === null) return runtimes; + const filtered: AgentRuntimes = { ...runtimes }; + for (const kind of Object.keys(filtered) as AgentKind[]) { + if (!allowedAgents.includes(kind)) filtered[kind] = { status: 'missing' }; + } + return filtered; +} + +/** + * Restricted-brand managed-download gate (CODE-618): wraps the daemon's `AssetService` so an + * excluded agent's managed asset disappears from every surface — `statuses`/`subscribe` never + * mention it (keeping this wrapper consistent with `filterAgentRuntimes`'s `missing`), and a + * client's `asset.ensure` for it gets the same "cannot be installed here" refusal + * `ManagedAssetService` already gives an unpinnable asset — no new failure path to learn. + * Tool assets (`kind: 'tool'`, e.g. aigateway) are never agent-gated. `null` (unrestricted) returns + * `assets` unchanged. + */ +export function restrictedAssetService( + assets: AssetService, + allowedAgents: readonly AgentKind[] | null, +): AssetService { + if (allowedAgents === null) return assets; + const excluded = (id: ManagedAssetId): boolean => + id.kind === 'agent' && !allowedAgents.includes(id.name); + return { + statuses: () => assets.statuses().filter(({ id }) => !excluded(id)), + subscribe: (listener) => + assets.subscribe((event) => { + if (!excluded(event.id)) listener(event); + }), + ensure: (id: ManagedAssetId) => (excluded(id) ? Promise.resolve(undefined) : assets.ensure(id)), + }; +} diff --git a/apps/daemon/src/config.ts b/apps/daemon/src/config.ts index 56ad8aa0d..0d71058eb 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,18 @@ 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; + return raw.split(',').map((entry) => AgentKindSchema.parse(entry.trim())); +} + /** 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..f513f2759 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 { filterAgentRuntimes, restrictedAssetService } from './agent-restrictions'; 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', { @@ -210,8 +215,12 @@ async function main(): Promise { const version = assets.wantedVersionOf(id); return path && version ? { path, version } : undefined; }); - // Not awaited: CLI probes are slow; listeners must bind without waiting. - const agentRuntimesReady = agentRuntimeProber.collect(); + // Not awaited: CLI probes are slow; listeners must bind without waiting. Filtered so a + // restricted build never reports an excluded agent as available, however the probe actually + // found it (CODE-618). + const agentRuntimesReady = agentRuntimeProber + .collect() + .then((runtimes) => filterAgentRuntimes(runtimes, allowedAgents)); const simSidecarPath = resolveSimSidecarPath(); const simulators = simSidecarPath ? new SimulatorService(new SimSidecarClient(simSidecarPath)) @@ -249,6 +258,7 @@ async function main(): Promise { yield* Effect.addFinalizer(() => finalize(() => simulatorMcp.close())); } const EngineInfrastructureLive = makeEngineInfrastructureLayer(hub, { + allowedAgents: allowedAgents ?? undefined, providerStore: store, ptyBackend: new SidecarPtyBackend(resolveSidecarPath()), simulators, @@ -266,9 +276,14 @@ async function main(): Promise { previewRoutes, browserToolsEnabled: process.env.LINKCODE_BROWSER_TOOLS === '1', agentRuntimesReady, - assets, + // The wire path for a client-initiated `asset.ensure`; the daemon's own boot refresh below + // uses the unwrapped `assets` (it already filters its candidate kinds via `consentedAgents`). + assets: restrictedAssetService(assets, allowedAgents), // Lets the engine refresh (and push) the runtime snapshot after a managed install lands. - collectAgentRuntimes: () => agentRuntimeProber.collect(), + collectAgentRuntimes: () => + agentRuntimeProber + .collect() + .then((runtimes) => filterAgentRuntimes(runtimes, allowedAgents)), // Spawn path for an interactive claude-code/codex login (managed/detected/SDK binary). resolveLoginBinary: (agent) => agent === 'claude-code' || agent === 'codex' diff --git a/apps/desktop/scripts/config-bundle.mts b/apps/desktop/scripts/config-bundle.mts index 76d46fbed..e37bf4591 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 !== undefined) { + 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/scripts/package-app.mts b/apps/desktop/scripts/package-app.mts index 23658340b..1f8ef104a 100644 --- a/apps/desktop/scripts/package-app.mts +++ b/apps/desktop/scripts/package-app.mts @@ -18,6 +18,7 @@ import { cpSync, existsSync, + mkdtempSync, readdirSync, readFileSync, rmSync, @@ -27,7 +28,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.ts'; import { assertStagedConfigMatchesGenerated } from './package-config.mts'; import { mergeUpdateFeeds } from './update-feed.mts'; @@ -164,6 +167,30 @@ 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; + // A fresh directory per call, rather than a fixed filename: two concurrent packaging runs (or a + // stale file from an interrupted one) must never clobber or race each other. + const overlayDir = mkdtempSync(join(tmpdir(), 'linkcode-desktop-agent-excludes-')); + const overlayPath = join(overlayDir, 'electron-builder.overlay.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 +215,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 +237,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..ad752bb4a 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.ts'; 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/__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/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; +} 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/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/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/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/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/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, 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/foundation/common/src/config/__tests__/brand-identity.test.ts b/packages/foundation/common/src/config/__tests__/brand-identity.test.ts index c471fa57b..17e146e50 100644 --- a/packages/foundation/common/src/config/__tests__/brand-identity.test.ts +++ b/packages/foundation/common/src/config/__tests__/brand-identity.test.ts @@ -1,4 +1,5 @@ import { readFile } from 'node:fs/promises'; +import { AgentKindSchema } from '@linkcode/schema'; import { sha256 } from '@noble/hashes/sha2.js'; import { describe, expect, it } from 'vitest'; import fixture from '../__fixtures__/brand-identity-v1.json'; @@ -216,6 +217,90 @@ 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']); + }); + + // brand-identity.ts hand-duplicates the agent-kind list as KNOWN_AGENT_KINDS (its bare-import + // shape keeps it loadable under plain Node ESM); this pins the copy to the real enum. + it('accepts every AgentKindSchema kind, catching hand-duplicated list drift', () => { + const identity = parseBrandIdentityArtifact( + mutate((artifact) => { + artifact.agents = [...AgentKindSchema.options]; + }), + ); + expect(identity.agents).toEqual(AgentKindSchema.options); + }); + + 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..bcf95482b 100644 --- a/packages/foundation/common/src/config/brand-identity.ts +++ b/packages/foundation/common/src/config/brand-identity.ts @@ -1,5 +1,14 @@ // 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. +// +// `AgentKind` is imported type-only, never as a value: this module is reached from +// config-bundle.mts under plain Node (no bundler), and `@linkcode/schema`'s barrel re-exports a +// directory (`./model`), which plain Node's ESM loader cannot resolve +// (ERR_UNSUPPORTED_DIR_IMPORT). KNOWN_AGENT_KINDS below is hand-duplicated from +// AgentKindSchema.options (packages/foundation/schema/src/model/primitives.ts) for the same +// reason `CONFIG_PLATFORMS`/`CONFIG_CHANNELS` stay package-local — see CODE-618 plan notes on the +// accepted drift risk. +import type { AgentKind } from '@linkcode/schema'; import type { ConfigBuildBundle } from './build-bundle'; import { isRecord } from './contract'; import type { ConfigChannel, ConfigPlatform } from './types'; @@ -13,8 +22,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 +34,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 +51,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(['claude-code', 'codex', 'opencode', 'pi', 'grok-build']); + 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 +79,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 +149,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 +188,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..dd72bdaa1 --- /dev/null +++ b/packages/foundation/schema/src/__tests__/remote-config.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; +import { AgentKindSchema } from '../model/primitives'; +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(); + }); + + // remote-config.ts hand-duplicates the agent-kind list (its bare-import-only shape keeps it + // loadable from build scripts under plain Node ESM); this pins the copy to the real enum. + it('accepts every AgentKindSchema kind, catching hand-duplicated list drift', () => { + const result = ConfigBuildBundleSchema.parse({ + ...validBundle(), + agents: AgentKindSchema.options, + }); + expect(result.agents).toEqual(AgentKindSchema.options); + }); +}); diff --git a/packages/foundation/schema/src/remote-config.ts b/packages/foundation/schema/src/remote-config.ts index d496fc747..c8267d09d 100644 --- a/packages/foundation/schema/src/remote-config.ts +++ b/packages/foundation/schema/src/remote-config.ts @@ -10,8 +10,15 @@ export const CONFIG_PLATFORMS = ['desktop', 'ios', 'android'] as const; export const CONFIG_CHANNELS = ['canary', 'stable'] as const; export const APPLY_MODES = ['hot', 'cold'] as const; export const OPERATING_SYSTEMS = ['windows', 'macos', 'linux', 'ios', 'android'] as const; +// Duplicated from AgentKindSchema (./model/primitives.ts) rather than imported, same as +// CONFIG_PLATFORMS/CONFIG_CHANNELS above stay local: this file is also reached via +// `@linkcode/schema/remote-config` from build-time scripts running under plain Node +// (config-bundle.mts), which — unlike a bundler or tsx — cannot resolve an extensionless +// relative import across files. Keep in sync with AgentKindSchema's options by hand. +const CONFIG_BUNDLE_AGENT_KINDS = ['claude-code', 'codex', 'opencode', 'pi', 'grok-build'] 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 +267,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(z.enum(CONFIG_BUNDLE_AGENT_KINDS)).min(1).optional(), brandId: BrandIdSchema, buildBundleVersion: z.literal(CONFIG_BUILD_BUNDLE_VERSION), channel: ConfigChannelSchema, @@ -268,6 +277,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 +294,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', diff --git a/packages/host/engine/src/__tests__/agent-restrictions.test.ts b/packages/host/engine/src/__tests__/agent-restrictions.test.ts new file mode 100644 index 000000000..a1a999d25 --- /dev/null +++ b/packages/host/engine/src/__tests__/agent-restrictions.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest'; +import { InMemorySessionStore } from '../session/session-store'; +import { + createSessionHarness as harness, + listedSessions, + startedSessionId as startedId, +} from './fixtures/session-harness'; + +/** + * CODE-618 boundary: `EngineDeps.allowedAgents` must refuse only a *new* live start of an + * excluded kind. A session persisted before the restriction landed (or created by an unrestricted + * engine sharing the same store) must keep listing and reading — refusing there was the regression + * a restricted-brand build previously hit on every `session.list`. + */ +describe('restricted-brand agent allowlist', () => { + it('keeps listing a persisted session of an excluded kind instead of throwing', async () => { + const store = new InMemorySessionStore(); + const unrestricted = harness(store); + await unrestricted.engine.start(); + await unrestricted.inject({ + kind: 'session.start', + clientReqId: 'r1', + opts: { kind: 'claude-code', cwd: '/repo' }, + }); + const sessionId = startedId(unrestricted.sent, 'r1'); + + // A restricted engine over the same store, excluding the kind of the session just created. + const restricted = harness(store, undefined, undefined, undefined, undefined, undefined, { + allowedAgents: ['pi'], + }); + await restricted.engine.start(); + await restricted.inject({ kind: 'session.list', clientReqId: 'r2' }); + + const sessions = listedSessions(restricted.sent, 'r2'); + expect(sessions).toHaveLength(1); + expect(sessions[0]).toMatchObject({ sessionId, cwd: '/repo' }); + }); + + it('refuses to start a new session of an excluded kind', async () => { + const h = harness( + new InMemorySessionStore(), + undefined, + undefined, + undefined, + undefined, + undefined, + { + allowedAgents: ['pi'], + }, + ); + await h.engine.start(); + + await h.inject({ + kind: 'session.start', + clientReqId: 'r1', + opts: { kind: 'claude-code', cwd: '/repo' }, + }); + + expect(h.sent).toContainEqual({ + kind: 'request.failed', + replyTo: 'r1', + code: 'forbidden', + message: 'claude-code: not available in this build', + }); + expect(h.adapters).toHaveLength(0); + }); + + it('refuses agent.catalog for an excluded kind before any adapter is constructed', async () => { + const h = harness( + new InMemorySessionStore(), + undefined, + undefined, + undefined, + undefined, + undefined, + { + allowedAgents: ['pi'], + }, + ); + await h.engine.start(); + + await h.inject({ kind: 'agent.catalog', clientReqId: 'r1', agentKind: 'codex' }); + + expect(h.sent).toContainEqual({ + kind: 'request.failed', + replyTo: 'r1', + code: 'forbidden', + message: 'codex: not available in this build', + }); + expect(h.adapters).toHaveLength(0); + }); + + it('starts a new session of an allowed kind normally', async () => { + const h = harness( + new InMemorySessionStore(), + undefined, + undefined, + undefined, + undefined, + undefined, + { + allowedAgents: ['pi'], + }, + ); + await h.engine.start(); + + await h.inject({ + kind: 'session.start', + clientReqId: 'r1', + opts: { kind: 'pi', cwd: '/repo' }, + }); + + expect(startedId(h.sent, 'r1')).toBeTruthy(); + expect(h.adapters).toHaveLength(1); + }); +}); diff --git a/packages/host/engine/src/agent/request-handler.ts b/packages/host/engine/src/agent/request-handler.ts index 7f652b739..778735877 100644 --- a/packages/host/engine/src/agent/request-handler.ts +++ b/packages/host/engine/src/agent/request-handler.ts @@ -1,6 +1,6 @@ import type { AdapterFactory } from '@linkcode/agent-adapter'; import { modelListSource } from '@linkcode/providers'; -import type { AccountSecret, WirePayload } from '@linkcode/schema'; +import type { AccountSecret, AgentKind, WirePayload } from '@linkcode/schema'; import type { Transport } from '@linkcode/transport'; import { createWireMessage } from '@linkcode/transport'; import { Effect } from 'effect'; @@ -41,11 +41,25 @@ export class AgentRequestHandler { private readonly responder: WireResponder, private readonly factory: AdapterFactory, private readonly probeModels: ModelProbe = probeEndpointModels, + /** Restricted-brand allowlist (CODE-618); `null` (the default) is unrestricted. Gates + * `agent.catalog`, whose `startCatalog` spawns real agent processes for some kinds. */ + private readonly allowedAgents: readonly AgentKind[] | null = null, ) {} handle(payload: AgentRequest): Effect.Effect { switch (payload.kind) { case 'agent.catalog': + if (this.allowedAgents !== null && !this.allowedAgents.includes(payload.agentKind)) { + return this.responder.reply( + payload.clientReqId, + Effect.fail( + new RequestError({ + code: 'forbidden', + message: `${payload.agentKind}: not available in this build`, + }), + ), + ); + } return this.responder.reply( payload.clientReqId, Effect.tryPromise({ diff --git a/packages/host/engine/src/deps.ts b/packages/host/engine/src/deps.ts index 3fdce4a71..d2fb9cc59 100644 --- a/packages/host/engine/src/deps.ts +++ b/packages/host/engine/src/deps.ts @@ -1,5 +1,5 @@ import type { AdapterFactory, PluginProviderAdapterFactory } from '@linkcode/agent-adapter'; -import type { AgentRuntimes } from '@linkcode/schema'; +import type { AgentKind, AgentRuntimes } from '@linkcode/schema'; import type { LoginBinaryResolver } from './agent/login-service'; import type { ModelProbe } from './agent/model-probe'; import type { ProviderConfigStore } from './agent/provider-config'; @@ -21,6 +21,12 @@ import type { WorktreeStore } from './worktree/worktree-store'; /** Optional collaborators the daemon injects; each defaults to an in-memory/no-op implementation. */ export interface EngineDeps { factory?: AdapterFactory; + /** Restricted-brand agent allowlist (CODE-618); `undefined` (the default) is unrestricted. + * Enforced where an adapter process could actually start — live-session start + * (`SessionOrchestrator.startLive`) and `agent.catalog` (`AgentRequestHandler`) — never at the + * bare factory used for history reads, so a persisted session of an excluded kind stays + * readable; only running it again is refused. */ + allowedAgents?: readonly AgentKind[]; /** Read-only native plugin providers aggregated by the Engine plugin service. */ pluginFactory?: PluginProviderAdapterFactory; sessionStore?: SessionStore; diff --git a/packages/host/engine/src/engine.ts b/packages/host/engine/src/engine.ts index f61ee2fbe..3b022132f 100644 --- a/packages/host/engine/src/engine.ts +++ b/packages/host/engine/src/engine.ts @@ -159,6 +159,7 @@ export const createEngineRuntime = Effect.fn('Engine.create')(function* ( deps.browserToolsEnabled ? () => new BrowserReplHost((op, args) => browserBroker.dispatch(op, args)) : undefined, + deps.allowedAgents ?? null, ); simulators?.setSessionValidator((id) => sessions.has(id)); terminals = deps.ptyBackend @@ -249,6 +250,7 @@ export const createEngineRuntime = Effect.fn('Engine.create')(function* ( responder, factory, deps.modelProbe, + deps.allowedAgents ?? null, ); const browserRequests = new BrowserRequestHandler(transport, browserBroker); const pluginRequests = new PluginRequestHandler(transport, plugins, responder); diff --git a/packages/host/engine/src/session/orchestrator.ts b/packages/host/engine/src/session/orchestrator.ts index 24b093fd6..a62739e6c 100644 --- a/packages/host/engine/src/session/orchestrator.ts +++ b/packages/host/engine/src/session/orchestrator.ts @@ -4,6 +4,7 @@ import type { AgentEvent, AgentHistoryCapabilities, AgentInput, + AgentKind, ContentBlock, McpWarning, MessageId, @@ -41,6 +42,12 @@ export class SessionOrchestrator { private readonly onStopped: (sessionId: SessionId) => void, private readonly resources: ResourceService, private readonly browserTools?: BrowserToolsetFactory, + /** Restricted-brand allowlist (CODE-618); `null` (the default) is unrestricted. Enforced only + * here, at the one place every start/resume/relaunch path constructs a live adapter — never at + * the bare `factory` used for history reads (`list`/`read`/`resolveLiveBranchCursor`) or + * `list()`'s per-record `historyCapabilities`, so a persisted session of an excluded kind stays + * fully readable; only starting a new live run of it is refused. */ + private readonly allowedAgents: readonly AgentKind[] | null = null, ) { this.events = new SessionEventProcessor(transport, records, runtimes, reportFailure, resources); this.inputs = new SessionInputDispatcher(records, this.events, resources); @@ -205,13 +212,21 @@ export class SessionOrchestrator { sessions, transport, } = this; - const { browserTools } = this; + const { browserTools, allowedAgents } = this; const discardFailedStart = (session: LiveSession): Effect.Effect => this.discardFailedStart(record.sessionId, session); const { initialInput, registerRecord = true, rewindMessageId } = options; return observeOperation( Effect.gen(function* () { const sessionId = record.sessionId; + if (allowedAgents !== null && !allowedAgents.includes(record.kind)) { + return yield* Effect.fail( + new RequestError({ + code: 'forbidden', + message: `${record.kind}: not available in this build`, + }), + ); + } const adapter = factory(record.kind); if (browserTools) adapter.attachBrowserTools?.(browserTools); const scope = yield* Scope.fork(parentScope); 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..757630370 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,6 +314,9 @@ export function ModelSelectorMenu({ disabled={disabled} render={