Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
19 changes: 19 additions & 0 deletions apps/daemon/src/__tests__/agent-factory.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest';
import { restrictedAdapterFactory } from '../agent-factory';

describe('restrictedAdapterFactory', () => {
it('returns undefined when unrestricted, so the engine falls back to the bare createAdapter', () => {
expect(restrictedAdapterFactory(null)).toBeUndefined();
});

it('constructs an allowed kind', () => {
const factory = restrictedAdapterFactory(['pi']);
expect(factory).toBeDefined();
expect(factory?.('pi').kind).toBe('pi');
});

it('rejects a kind outside the allowlist', () => {
const factory = restrictedAdapterFactory(['pi']);
expect(() => factory?.('codex')).toThrow('codex');
});
});
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();
});
});
22 changes: 22 additions & 0 deletions apps/daemon/src/agent-factory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { AdapterFactory } from '@linkcode/agent-adapter';
import { createAdapter } from '@linkcode/agent-adapter';
import type { AgentKind } from '@linkcode/schema';

/**
* Restricted-brand adapter gate (CODE-618): wraps `createAdapter` to reject any kind outside the
* allowlist. Only guards new adapter construction — a session started before a restriction landed
* keeps running on its existing adapter instance, and history reads never call this at all.
* `null` (unrestricted, the default build) returns `undefined` so the engine falls back to the
* bare `createAdapter`, an exact no-op.
*/
export function restrictedAdapterFactory(
allowedAgents: readonly AgentKind[] | null,
): AdapterFactory | undefined {
if (allowedAgents === null) return undefined;
return (kind) => {
if (!allowedAgents.includes(kind)) {
throw new Error(`agent kind ${kind} is not available in this build`);
Comment thread
pullfrog[bot] marked this conversation as resolved.
Outdated
}
return createAdapter(kind);
};
}
14 changes: 14 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,19 @@ export function worktreeRoot(): string {
return join(daemonStateDir(), 'worktrees');
}

/**
* Restricted-brand agent allowlist (CODE-618): `LINKCODE_ALLOWED_AGENTS` — injected by the desktop
* supervisor from the build's identity, comma-separated — gates which adapter kinds this daemon
* will spawn. Absent (the default, unbranded build) means unrestricted: `null`, never an empty
* array, so every downstream check can treat "no restriction" as "skip the check".
*/
export function daemonAllowedAgents(): readonly AgentKind[] | null {
const raw = process.env.LINKCODE_ALLOWED_AGENTS;
if (raw === undefined || raw === '') return null;
const kinds = raw.split(',').map((entry) => AgentKindSchema.parse(entry.trim()));
return kinds.length > 0 ? kinds : null;
}

/** Runtime discovery file advertising the running daemon's bound endpoints, next to config.json. */
export function runtimeFilePath(): string {
return daemonRuntimeFilePath(daemonChannel(), daemonProfile());
Expand Down
8 changes: 7 additions & 1 deletion 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 { restrictedAdapterFactory } from './agent-factory';
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 Down Expand Up @@ -249,6 +254,7 @@ async function main(): Promise<void> {
yield* Effect.addFinalizer(() => finalize(() => simulatorMcp.close()));
}
const EngineInfrastructureLive = makeEngineInfrastructureLayer(hub, {
factory: restrictedAdapterFactory(allowedAgents),
providerStore: store,
ptyBackend: new SidecarPtyBackend(resolveSidecarPath()),
simulators,
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) {
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
29 changes: 28 additions & 1 deletion apps/desktop/scripts/package-app.mts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ import {
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import process from 'node:process';
import type { AgentKind } from '@linkcode/schema';
import crossSpawn from 'cross-spawn';
import { agentFilesExcludes } from '../src/build/agent-package-excludes';
import { assertStagedConfigMatchesGenerated } from './package-config.mts';
import { mergeUpdateFeeds } from './update-feed.mts';

Expand Down Expand Up @@ -164,6 +166,27 @@ function updateFeedName(arch: BuilderArch): string {
* silently re-brand the artifact, so they are refused outright. */
const IDENTITY_OVERRIDE_RE = /^-c\.(?:appId|productName|protocols)\b/;

/** The rendered build bundle's declared agents, or `null` if absent/unrendered (CODE-618). */
function stagedAllowedAgents(): readonly AgentKind[] | null {
const bundlePath = join(desktopDir, 'out', 'config', 'build-bundle.json');
if (!existsSync(bundlePath)) return null;
const bundle = JSON.parse(readFileSync(bundlePath, 'utf8')) as { agents?: unknown };
return Array.isArray(bundle.agents) ? (bundle.agents as AgentKind[]) : null;
}

/**
* Wraps `configPath` in a temporary `extends` overlay adding `excludes` to `files` — electron-
* builder concatenates an extended config's `files` array rather than replacing it. `configPath`
* is always absolute here, which `extends` also resolves as-is (only relative `extends` targets
* resolve against the project dir — see electron-builder-brand.ts).
*/
function withFilesOverlay(configPath: string, excludes: readonly string[]): string {
if (excludes.length === 0) return configPath;
const overlayPath = join(tmpdir(), 'linkcode-desktop-agent-excludes.json');
writeFileSync(overlayPath, JSON.stringify({ extends: configPath, files: excludes }));
return overlayPath;
}

function build(): void {
// Both extend the shared electron-builder.yml base; each adds its own deep-link scheme (release
// `linkcode://`, dev shell `linkcode-dev://`). The base is never passed directly — it has none.
Expand All @@ -188,6 +211,10 @@ function build(): void {
: branded
? brandConfig
: 'electron-builder.release.yml';
const configPath = branded ? config : join(desktopDir, config);
// Restricted-brand SDK exclusion (CODE-618): absent bundle agents is a no-op, so an unbranded
// (or unrestricted) build passes `configPath` through unmodified.
const finalConfigPath = withFilesOverlay(configPath, agentFilesExcludes(stagedAllowedAgents()));
const brandIcon = join(desktopDir, 'out', 'config', 'brand-assets', 'icon.png');
const feeds = new Map<string, string>();
for (const arch of stagedArches()) {
Expand All @@ -206,7 +233,7 @@ function build(): void {
'--projectDir',
target,
'--config',
branded ? config : join(desktopDir, config),
finalConfigPath,
// projectDir is the staging dir, so config-relative paths would resolve under it; redirect
// output back to where CI/verify-artifacts expect it and icons to the shared repo-root
// assets — or, on branded builds, to the staged brand assets only.
Expand Down
44 changes: 42 additions & 2 deletions apps/desktop/scripts/verify-artifacts.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <mac|win|linux>` from apps/desktop). Asserts: the
Expand All @@ -21,8 +22,9 @@ import process, { argv } from 'node:process';
* at an existing file with a matching sha512; and the unpacked apps carry the bundled daemon and
* PTY sidecar, so a build never ships a client with no host runtime (CODE-86/87).
*/
import { extractFile, listPackage, statFile } from '@electron/asar';
import type { AgentKind } from '@linkcode/schema';
import { keysLength } from 'foxts/property-count';
import { AGENT_SDK_PACKAGE_PATHS } from '../src/build/agent-package-excludes';

const RELEASE_DIR = 'release';
const FEED_URL_LINE = /^ {2}- url: (.+)$/;
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -397,9 +435,11 @@ async function main(): Promise<number> {
),
);
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) {
Expand Down
23 changes: 23 additions & 0 deletions apps/desktop/src/__tests__/config-bundle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
28 changes: 28 additions & 0 deletions apps/desktop/src/build/__tests__/agent-package-excludes.test.ts
Original file line number Diff line number Diff line change
@@ -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[/@-]/);
});
});
Loading
Loading