Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions apps/daemon/src/__tests__/agent-restrictions.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn>;
statuses: ReturnType<typeof vi.fn>;
subscribe: ReturnType<typeof vi.fn>;
} {
const ensure = vi.fn(
(id: ManagedAssetId): Promise<InstalledAsset> =>
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' },
]);
});
});
26 changes: 26 additions & 0 deletions apps/daemon/src/__tests__/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import {
cloudCredentialsPath,
daemonAllowedAgents,
daemonProfile,
databasePath,
loadConfig,
Expand Down Expand Up @@ -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();
});

Expand Down Expand Up @@ -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();
});
});
46 changes: 46 additions & 0 deletions apps/daemon/src/agent-restrictions.ts
Original file line number Diff line number Diff line change
@@ -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)),
};
}
13 changes: 13 additions & 0 deletions apps/daemon/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { dirname, join } from 'node:path';
import { daemonRuntimeFilePath } from '@linkcode/common/node';
import type {
Accounts,
AgentKind,
CustomMcpServer,
ProvidersConfig,
SimulatorConsentState,
Expand Down Expand Up @@ -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());
Expand Down
25 changes: 20 additions & 5 deletions apps/daemon/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -34,6 +35,7 @@ import { startCloudUplink } from './cloud/uplink';
import type { DaemonConfig } from './config';
import {
chatWorkspaceRoot,
daemonAllowedAgents,
daemonChannel,
daemonProfile,
databasePath,
Expand Down Expand Up @@ -186,7 +188,10 @@ async function main(): Promise<void> {
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', {
Expand All @@ -210,8 +215,12 @@ async function main(): Promise<void> {
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))
Expand Down Expand Up @@ -249,6 +258,7 @@ async function main(): Promise<void> {
yield* Effect.addFinalizer(() => finalize(() => simulatorMcp.close()));
}
const EngineInfrastructureLive = makeEngineInfrastructureLayer(hub, {
allowedAgents: allowedAgents ?? undefined,
providerStore: store,
ptyBackend: new SidecarPtyBackend(resolveSidecarPath()),
simulators,
Expand All @@ -266,9 +276,14 @@ async function main(): Promise<void> {
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'
Expand Down
18 changes: 18 additions & 0 deletions apps/desktop/scripts/config-bundle.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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') {
Expand Down Expand Up @@ -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,
};
Expand Down
Loading
Loading