Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
93 changes: 93 additions & 0 deletions apps/daemon/src/__tests__/agent-restrictions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
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('forwards statuses() and subscribe() untouched', () => {
const { assets, statuses, subscribe } = fakeAssets();
const restricted = restrictedAssetService(assets, ['pi']);
const listener = vi.fn();

restricted.statuses();
restricted.subscribe(listener);

expect(statuses).toHaveBeenCalledTimes(1);
expect(subscribe).toHaveBeenCalledWith(listener);
});
});
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();
});
});
42 changes: 42 additions & 0 deletions apps/daemon/src/agent-restrictions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
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 a
* client's `asset.ensure` for an excluded agent kind 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;
return {
statuses: () => assets.statuses(),
Comment thread
pullfrog[bot] marked this conversation as resolved.
Outdated
subscribe: (listener) => assets.subscribe(listener),
ensure: (id: ManagedAssetId) =>
id.kind === 'agent' && !allowedAgents.includes(id.name)
? 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
33 changes: 32 additions & 1 deletion apps/desktop/scripts/package-app.mts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import {
cpSync,
existsSync,
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
Expand All @@ -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';
import { assertStagedConfigMatchesGenerated } from './package-config.mts';
import { mergeUpdateFeeds } from './update-feed.mts';

Expand Down Expand Up @@ -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.
Expand All @@ -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<string, string>();
for (const arch of stagedArches()) {
Expand All @@ -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.
Expand Down
Loading
Loading