diff --git a/src/local-agent-acp.ts b/src/local-agent-acp.ts index bbbabb260..88ab8c0ec 100644 --- a/src/local-agent-acp.ts +++ b/src/local-agent-acp.ts @@ -411,9 +411,11 @@ export class AcpRuntime implements LocalAgentRuntime { export class AcpLocalAgentDriver implements LocalAgentDriver { readonly provider: AcpProvider; readonly providerInstanceId: string; - // Keep ACP warm briefly, then let the generic pool close the process so the - // daemon can reach its own idle shutdown state. - readonly idleTimeoutMs = 5 * 60_000; + readonly runtimePolicy = { + scope: "workspace", + authority: "write_mode", + idleTimeoutMs: 5 * 60_000, + } as const; private commandResolved = false; private resolvedCommand?: string; @@ -426,12 +428,6 @@ export class AcpLocalAgentDriver implements LocalAgentDriver { this.providerInstanceId = provider; } - runtimeKey(context: LocalAgentRuntimeContext): string { - const command = this.resolveCommand() ?? ACP_COMMANDS[this.provider][0]; - const writeMode = context.writeMode ?? "allowed"; - return `acp:${this.provider}:${command}:${writeMode}:${resolve(context.workspaceRoot)}`; - } - async createRuntime(context: LocalAgentRuntimeContext) { return captureAgentProviderResult({ provider: this.provider, diff --git a/src/local-agent-adapters.test.ts b/src/local-agent-adapters.test.ts index cbaf2e190..29315ce83 100644 --- a/src/local-agent-adapters.test.ts +++ b/src/local-agent-adapters.test.ts @@ -347,16 +347,5 @@ assert.equal( drivers.map((driver) => [driver.providerInstanceId, driver.provider]), [["claude-work", "claude"], ["claude-personal", "claude"]], ); - const context = { - agentId: "agt_test", - providerInstanceId: "claude-work", - provider: "claude" as const, - workspaceRoot: "/tmp/project", - writeMode: "allowed" as const, - }; - assert.notEqual( - drivers[0]?.runtimeKey(context), - drivers[1]?.runtimeKey({ ...context, providerInstanceId: "claude-personal" }), - "provider instances sharing a driver must never share one runtime pool key", - ); + assert.deepEqual(drivers.map((driver) => driver.runtimePolicy.scope), ["agent", "agent"]); } diff --git a/src/local-agent-adapters.ts b/src/local-agent-adapters.ts index 2059cbc9d..7cdb09037 100644 --- a/src/local-agent-adapters.ts +++ b/src/local-agent-adapters.ts @@ -1,13 +1,9 @@ import { - localAgentProviderEnvironment, - localAgentProviderEnvironmentOverrides, type SubagentProviderConfig, type SubagentsConfig, } from "./local-agent-config.js"; import { LOCAL_AGENT_DRIVER_KINDS, - type LocalAgentDriverKind, - type LocalAgentProviderInstanceId, } from "./local-agent-provider.js"; import { AcpLocalAgentDriver, @@ -33,6 +29,7 @@ import { type PiSessionFactory, } from "./local-agent-pi.js"; import type { LocalAgentDriver } from "./local-agent-runtime.js"; +import { LocalAgentProviderRegistry } from "./local-agent-provider-registry.js"; export type LocalAgentAdapter = LocalAgentDriver; @@ -53,58 +50,21 @@ export function createLocalAgentDrivers( driver, enabled: true, } satisfies SubagentProviderConfig)); - return instances.map((instance) => new ProviderInstanceDriver( - instance.id, - createDriver(instance, options, env), - )); + const registry = createLocalAgentProviderRegistry(options); + return instances.map((instance) => registry.create(instance, options.subagents, env)); } -function createDriver( - instance: SubagentProviderConfig, - options: LocalAgentDriverOptions, - inheritedEnv: NodeJS.ProcessEnv, -): LocalAgentDriver { - const env = options.subagents - ? localAgentProviderEnvironment(options.subagents, instance.id, inheritedEnv) - : inheritedEnv; - const envOverrides = options.subagents - ? localAgentProviderEnvironmentOverrides(options.subagents, instance.id) - : {}; - switch (instance.driver) { - case "codex": - return new CodexLocalAgentDriver(env); - case "claude": - return new ClaudeLocalAgentDriver(options.claudeQueryFactory, env); - case "opencode": - return new OpencodeLocalAgentDriver({ factory: options.opencodeFactory, env }); - case "pi": - return new PiLocalAgentDriver(options.piSessionFactory, envOverrides); - case "cursor": - case "copilot": - case "grok": - return new AcpLocalAgentDriver(instance.driver, env); - } -} - -class ProviderInstanceDriver implements LocalAgentDriver { - readonly provider: LocalAgentDriverKind; - readonly idleTimeoutMs?: number; - - constructor( - readonly providerInstanceId: LocalAgentProviderInstanceId, - private readonly driver: LocalAgentDriver, - ) { - this.provider = driver.provider; - this.idleTimeoutMs = driver.idleTimeoutMs; - } - - runtimeKey(context: Parameters[0]): string { - return JSON.stringify([this.providerInstanceId, this.driver.runtimeKey(context)]); - } - - createRuntime(context: Parameters[0]) { - return this.driver.createRuntime(context); - } +export function createLocalAgentProviderRegistry( + options: LocalAgentDriverOptions = {}, +): LocalAgentProviderRegistry { + return new LocalAgentProviderRegistry() + .register("codex", ({ env }) => new CodexLocalAgentDriver(env)) + .register("claude", ({ env }) => new ClaudeLocalAgentDriver(options.claudeQueryFactory, env)) + .register("opencode", ({ env }) => new OpencodeLocalAgentDriver({ factory: options.opencodeFactory, env })) + .register("pi", ({ envOverrides }) => new PiLocalAgentDriver(options.piSessionFactory, envOverrides)) + .register("cursor", ({ env }) => new AcpLocalAgentDriver("cursor", env)) + .register("copilot", ({ env }) => new AcpLocalAgentDriver("copilot", env)) + .register("grok", ({ env }) => new AcpLocalAgentDriver("grok", env)); } export function extractLocalAgentResponseText(value: unknown): string { diff --git a/src/local-agent-claude.test.ts b/src/local-agent-claude.test.ts index e6c0a2a0e..bd19fbc6d 100644 --- a/src/local-agent-claude.test.ts +++ b/src/local-agent-claude.test.ts @@ -72,17 +72,11 @@ const driver = new ClaudeLocalAgentDriver(({ prompt, options }) => { query = new FakeClaudeQuery(prompt); return query; }, { PATH: "/usr/bin" }); -assert.equal(driver.runtimeKey(context), "claude:agt_claude:restricted"); -assert.equal( - driver.runtimeKey({ ...context, writeMode: "allowed" }), - "claude:agt_claude:restricted", - "restricted Claude modes can share one query because per-turn settings are dynamic", -); -assert.equal( - driver.runtimeKey({ ...context, writeMode: "full_access" }), - "claude:agt_claude:full_access", - "full access uses a query initialized with the explicit dangerous-permission opt-in", -); +assert.deepEqual(driver.runtimePolicy, { + scope: "agent", + authority: "full_access_boundary", + idleTimeoutMs: 3 * 60_000, +}); const runtimeResult = await driver.createRuntime(context); assert.equal(runtimeResult.isOk(), true); diff --git a/src/local-agent-claude.ts b/src/local-agent-claude.ts index 7c8b0d9da..65a85df1d 100644 --- a/src/local-agent-claude.ts +++ b/src/local-agent-claude.ts @@ -214,18 +214,17 @@ export class ClaudeQueryRuntime implements LocalAgentRuntime { export class ClaudeLocalAgentDriver implements LocalAgentDriver { readonly provider = "claude" as const; readonly providerInstanceId = "claude"; - readonly idleTimeoutMs = 3 * 60_000; + readonly runtimePolicy = { + scope: "agent", + authority: "full_access_boundary", + idleTimeoutMs: 3 * 60_000, + } as const; constructor( private readonly factory: ClaudeQueryFactory = defaultClaudeQueryFactory, private readonly env: NodeJS.ProcessEnv = process.env, ) {} - runtimeKey(context: LocalAgentRuntimeContext): string { - const authority = context.writeMode === "full_access" ? "full_access" : "restricted"; - return `claude:${context.agentId}:${authority}`; - } - async createRuntime(context: LocalAgentRuntimeContext) { return captureAgentProviderResult({ provider: this.provider, diff --git a/src/local-agent-codex.ts b/src/local-agent-codex.ts index 46c4c007b..e619a1635 100644 --- a/src/local-agent-codex.ts +++ b/src/local-agent-codex.ts @@ -231,7 +231,7 @@ async function waitForProcessExit( export class CodexLocalAgentDriver implements LocalAgentDriver { readonly provider = "codex" as const; readonly providerInstanceId = "codex"; - readonly idleTimeoutMs = 5 * 60_000; + readonly runtimePolicy = { scope: "instance", idleTimeoutMs: 5 * 60_000 } as const; private commandResolved = false; private resolvedCommand?: ResolvedCodexCommand; @@ -241,13 +241,6 @@ export class CodexLocalAgentDriver implements LocalAgentDriver { private readonly commandResolver: CodexCommandResolver = resolveCodexCommand, ) {} - runtimeKey(_context: LocalAgentRuntimeContext): string { - const command = this.resolveCommand(); - const executable = command?.executable ?? this.env.CODEX_COMMAND ?? "codex"; - const codexHome = resolve(this.env.CODEX_HOME ?? join(homedir(), ".codex")); - return `codex:${executable}:${codexHome}`; - } - async createRuntime(_context: LocalAgentRuntimeContext) { return captureAgentProviderResult({ provider: this.provider, diff --git a/src/local-agent-manager.test.ts b/src/local-agent-manager.test.ts index 3f11deff8..90e15214f 100644 --- a/src/local-agent-manager.test.ts +++ b/src/local-agent-manager.test.ts @@ -99,7 +99,7 @@ const runtimes = new Map(); const driver: LocalAgentDriver = { providerInstanceId: "codex", provider: "codex", - runtimeKey: (context: LocalAgentRuntimeContext) => context.agentId, + runtimePolicy: { scope: "agent" }, createRuntime: async (context) => { const runtime = new FakeRuntime(); runtimes.set(context.agentId, runtime); diff --git a/src/local-agent-opencode.ts b/src/local-agent-opencode.ts index 5f3f5fdd1..cb028fd33 100644 --- a/src/local-agent-opencode.ts +++ b/src/local-agent-opencode.ts @@ -151,7 +151,7 @@ export class OpencodeRuntime implements LocalAgentRuntime { export class OpencodeLocalAgentDriver implements LocalAgentDriver { readonly provider = "opencode" as const; readonly providerInstanceId = "opencode"; - readonly idleTimeoutMs = 5 * 60_000; + readonly runtimePolicy = { scope: "instance", idleTimeoutMs: 5 * 60_000 } as const; private readonly factory: OpencodeFactory; private readonly v2Factory: OpencodeV2Factory; private readonly env: NodeJS.ProcessEnv; @@ -169,10 +169,6 @@ export class OpencodeLocalAgentDriver implements LocalAgentDriver { this.runtimeProbe = options.runtimeProbe ?? createOpenCodeRuntimeProbe(this.env); } - runtimeKey(_context: LocalAgentRuntimeContext): string { - return "opencode:default"; - } - async createRuntime(context: LocalAgentRuntimeContext) { return captureAgentProviderResult({ provider: this.provider, diff --git a/src/local-agent-pi.ts b/src/local-agent-pi.ts index 2b3780455..453168e7d 100644 --- a/src/local-agent-pi.ts +++ b/src/local-agent-pi.ts @@ -165,17 +165,13 @@ export class PiSessionRuntime implements LocalAgentRuntime { export class PiLocalAgentDriver implements LocalAgentDriver { readonly provider = "pi" as const; readonly providerInstanceId = "pi"; - readonly idleTimeoutMs = 3 * 60_000; + readonly runtimePolicy = { scope: "agent", idleTimeoutMs: 3 * 60_000 } as const; constructor( private readonly factory: PiSessionFactory = defaultPiSessionFactory, private readonly env: NodeJS.ProcessEnv = {}, ) {} - runtimeKey(context: LocalAgentRuntimeContext): string { - return `pi:${context.agentId}`; - } - async createRuntime(context: LocalAgentRuntimeContext) { return captureAgentProviderResult({ provider: this.provider, diff --git a/src/local-agent-provider-registry.ts b/src/local-agent-provider-registry.ts new file mode 100644 index 000000000..de944d72f --- /dev/null +++ b/src/local-agent-provider-registry.ts @@ -0,0 +1,66 @@ +import type { + SubagentProviderConfig, + SubagentsConfig, +} from "./local-agent-config.js"; +import { + localAgentProviderEnvironment, + localAgentProviderEnvironmentOverrides, +} from "./local-agent-config.js"; +import type { + LocalAgentDriverKind, + LocalAgentProviderInstanceId, +} from "./local-agent-provider.js"; +import type { LocalAgentDriver } from "./local-agent-runtime.js"; + +export interface LocalAgentDriverFactoryContext { + instance: SubagentProviderConfig; + env: NodeJS.ProcessEnv; + envOverrides: Record; +} + +export type LocalAgentDriverFactory = ( + context: LocalAgentDriverFactoryContext, +) => LocalAgentDriver; + +export class LocalAgentProviderRegistry { + private readonly factories = new Map(); + + register(kind: LocalAgentDriverKind, factory: LocalAgentDriverFactory): this { + if (this.factories.has(kind)) throw new Error(`Local agent driver already registered: ${kind}`); + this.factories.set(kind, factory); + return this; + } + + create( + instance: SubagentProviderConfig, + subagents: SubagentsConfig | undefined, + inheritedEnv: NodeJS.ProcessEnv, + ): LocalAgentDriver { + const factory = this.factories.get(instance.driver); + if (!factory) throw new Error(`No local agent driver registered for: ${instance.driver}`); + const env = subagents + ? localAgentProviderEnvironment(subagents, instance.id, inheritedEnv) + : inheritedEnv; + const envOverrides = subagents + ? localAgentProviderEnvironmentOverrides(subagents, instance.id) + : {}; + return new ProviderInstanceDriver(instance.id, factory({ instance, env, envOverrides })); + } +} + +class ProviderInstanceDriver implements LocalAgentDriver { + readonly provider: LocalAgentDriverKind; + readonly runtimePolicy: LocalAgentDriver["runtimePolicy"]; + + constructor( + readonly providerInstanceId: LocalAgentProviderInstanceId, + private readonly driver: LocalAgentDriver, + ) { + this.provider = driver.provider; + this.runtimePolicy = driver.runtimePolicy; + } + + createRuntime(context: Parameters[0]) { + return this.driver.createRuntime(context); + } +} diff --git a/src/local-agent-runtime-pool.ts b/src/local-agent-runtime-pool.ts index 213223555..399c1833a 100644 --- a/src/local-agent-runtime-pool.ts +++ b/src/local-agent-runtime-pool.ts @@ -255,7 +255,7 @@ export class LocalAgentRuntimePool { driver: LocalAgentDriver, context: LocalAgentRuntimeContext, ): Promise> { - const key = driver.runtimeKey(context); + const key = runtimePoolKey(driver, context); while (true) { const existing = this.entries.get(key); if (existing && !existing.closing) { @@ -311,8 +311,8 @@ export class LocalAgentRuntimePool { entry = { key, driver, - idleTimeoutMs: driver.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS, - sessionIdleTimeoutMs: this.sessionIdleTimeoutMs, + idleTimeoutMs: driver.runtimePolicy.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS, + sessionIdleTimeoutMs: driver.runtimePolicy.sessionIdleTimeoutMs ?? this.sessionIdleTimeoutMs, createPromise, activeRuns: 0, lastUsedAt: this.now(), @@ -496,6 +496,35 @@ export class LocalAgentRuntimePool { } } +function runtimePoolKey(driver: LocalAgentDriver, context: LocalAgentRuntimeContext): string { + const policy = driver.runtimePolicy; + const parts: Array = [ + context.providerInstanceId, + policy.scope, + ]; + switch (policy.scope) { + case "instance": + break; + case "workspace": + parts.push(context.workspaceRoot); + break; + case "agent": + parts.push(context.agentId); + break; + } + switch (policy.authority ?? "none") { + case "none": + break; + case "write_mode": + parts.push(context.writeMode ?? "allowed"); + break; + case "full_access_boundary": + parts.push(context.writeMode === "full_access" ? "full_access" : "restricted"); + break; + } + return JSON.stringify(parts); +} + function poolClosedError( driver: LocalAgentDriver, context: LocalAgentRuntimeContext, diff --git a/src/local-agent-runtime.test.ts b/src/local-agent-runtime.test.ts index a21513b9b..1aaead71f 100644 --- a/src/local-agent-runtime.test.ts +++ b/src/local-agent-runtime.test.ts @@ -95,8 +95,7 @@ let createCount = 0; const driver: LocalAgentDriver = { providerInstanceId: "codex", provider: "codex", - idleTimeoutMs: Number.POSITIVE_INFINITY, - runtimeKey: () => "shared", + runtimePolicy: { scope: "instance", idleTimeoutMs: Number.POSITIVE_INFINITY }, createRuntime: async () => { createCount += 1; await Promise.resolve(); @@ -136,8 +135,7 @@ const sessionPool = new LocalAgentRuntimePool({ const sessionDriver: LocalAgentDriver = { providerInstanceId: "codex", provider: "codex", - idleTimeoutMs: Number.POSITIVE_INFINITY, - runtimeKey: () => "session-runtime", + runtimePolicy: { scope: "instance", idleTimeoutMs: Number.POSITIVE_INFINITY }, createRuntime: async () => Result.ok(sessionRuntime), }; await sessionPool.run(sessionDriver, context, input); @@ -165,8 +163,7 @@ const shutdownReleasePool = new LocalAgentRuntimePool({ const shutdownReleaseDriver: LocalAgentDriver = { providerInstanceId: "codex", provider: "codex", - idleTimeoutMs: Number.POSITIVE_INFINITY, - runtimeKey: () => "shutdown-release-runtime", + runtimePolicy: { scope: "instance", idleTimeoutMs: Number.POSITIVE_INFINITY }, createRuntime: async () => Result.ok(shutdownReleaseRuntime), }; await shutdownReleasePool.run(shutdownReleaseDriver, context, input); @@ -215,7 +212,7 @@ const cleanupRuntime = new CleanupFailureRuntime(); const cleanupDriver: LocalAgentDriver = { providerInstanceId: "codex", provider: "codex", - runtimeKey: () => "cleanup-runtime", + runtimePolicy: { scope: "instance" }, createRuntime: async () => Result.ok(cleanupRuntime), }; const cleanupFailure = await cleanupPool.run(cleanupDriver, context, input); @@ -232,7 +229,7 @@ if (cleanupFailure.isErr()) assert.equal(cleanupFailure.error.message, "provider const recoveryDriver: LocalAgentDriver = { providerInstanceId: "codex", provider: "codex", - runtimeKey: () => "dead-runtime-recovery", + runtimePolicy: { scope: "instance" }, createRuntime: async () => Result.ok(attempts++ === 0 ? deadRuntime : replacementRuntime), }; @@ -257,7 +254,7 @@ if (cleanupFailure.isErr()) assert.equal(cleanupFailure.error.message, "provider const completedTurnDriver: LocalAgentDriver = { providerInstanceId: "codex", provider: "codex", - runtimeKey: () => "completed-turn-during-close", + runtimePolicy: { scope: "instance" }, createRuntime: async () => Result.ok(completedTurnRuntime), }; @@ -274,7 +271,7 @@ const racePool = new LocalAgentRuntimePool(); const raceDriver: LocalAgentDriver = { providerInstanceId: "codex", provider: "codex", - runtimeKey: () => "creation-race", + runtimePolicy: { scope: "instance" }, createRuntime: () => creating, }; const pendingRun = racePool.run(raceDriver, context, input); @@ -306,7 +303,7 @@ if (afterClose.isErr()) { const creationDriver: LocalAgentDriver = { providerInstanceId: "codex", provider: "codex", - runtimeKey: () => "creation-failure", + runtimePolicy: { scope: "instance" }, async createRuntime() { createAttempts += 1; if (createAttempts === 1) { @@ -360,8 +357,7 @@ if (afterClose.isErr()) { const releaseDriver: LocalAgentDriver = { providerInstanceId: "codex", provider: "codex", - idleTimeoutMs: Number.POSITIVE_INFINITY, - runtimeKey: () => "release-failure", + runtimePolicy: { scope: "instance", idleTimeoutMs: Number.POSITIVE_INFINITY }, createRuntime: async () => Result.ok(releaseRuntime), }; @@ -381,6 +377,60 @@ if (afterClose.isErr()) { await releasePool.close(); } +{ + let creates = 0; + const policyPool = new LocalAgentRuntimePool(); + const policyDriver = (providerInstanceId: string, runtimePolicy: LocalAgentDriver["runtimePolicy"]): LocalAgentDriver => ({ + providerInstanceId, + provider: "codex", + runtimePolicy, + createRuntime: async () => { + creates += 1; + return Result.ok(new FakeRuntime()); + }, + }); + + const instanceDriver = policyDriver("codex-work", { scope: "instance" }); + await policyPool.run(instanceDriver, { ...context, providerInstanceId: "codex-work" }, input); + await policyPool.run(instanceDriver, { + ...context, + agentId: "agt_other", + providerInstanceId: "codex-work", + }, input); + assert.equal(creates, 1, "instance-scoped runtimes are shared across agents in one provider instance"); + + const personalDriver = policyDriver("codex-personal", { scope: "instance" }); + await policyPool.run(personalDriver, { + ...context, + providerInstanceId: "codex-personal", + }, input); + assert.equal(creates, 2, "provider instances never share a runtime even when they use the same driver"); + + const claudeDriver = policyDriver("claude-work", { + scope: "agent", + authority: "full_access_boundary", + }); + const claudeContext = { + ...context, + agentId: "agt_claude", + providerInstanceId: "claude-work", + }; + await policyPool.run(claudeDriver, { ...claudeContext, writeMode: "read_only" }, input); + await policyPool.run(claudeDriver, { ...claudeContext, writeMode: "allowed" }, input); + await policyPool.run(claudeDriver, { ...claudeContext, writeMode: "full_access" }, input); + assert.equal(creates, 4, "full-access authority is isolated while restricted modes share one agent runtime"); + + const acpDriver = policyDriver("cursor", { scope: "workspace", authority: "write_mode" }); + const acpContext = { ...context, providerInstanceId: "cursor", workspaceRoot: "/tmp/a" }; + await policyPool.run(acpDriver, { ...acpContext, writeMode: "allowed" }, input); + await policyPool.run(acpDriver, { ...acpContext, agentId: "agt_acp_2", writeMode: "allowed" }, input); + await policyPool.run(acpDriver, { ...acpContext, writeMode: "read_only" }, input); + await policyPool.run(acpDriver, { ...acpContext, workspaceRoot: "/tmp/b", writeMode: "allowed" }, input); + assert.equal(creates, 7, "workspace and write mode are explicit ACP runtime isolation dimensions"); + + await policyPool.close(); +} + function unwrap(result: BetterResult): T { if (result.isErr()) throw result.error; return result.value; diff --git a/src/local-agent-runtime.ts b/src/local-agent-runtime.ts index bd1b0982b..eb3335189 100644 --- a/src/local-agent-runtime.ts +++ b/src/local-agent-runtime.ts @@ -46,6 +46,16 @@ export interface LocalAgentRuntimeContext { agentDir?: string; } +export type LocalAgentRuntimeScope = "instance" | "workspace" | "agent"; +export type LocalAgentRuntimeAuthority = "none" | "write_mode" | "full_access_boundary"; + +export interface LocalAgentRuntimePolicy { + scope: LocalAgentRuntimeScope; + authority?: LocalAgentRuntimeAuthority; + idleTimeoutMs?: number; + sessionIdleTimeoutMs?: number; +} + /** * A runtime is deliberately disposable. Nothing from this interface is * persisted; the provider session ID in LocalAgentStore is the continuation @@ -65,7 +75,6 @@ export interface LocalAgentRuntime { export interface LocalAgentDriver { readonly providerInstanceId: LocalAgentProviderInstanceId; readonly provider: LocalAgentDriverKind; - runtimeKey(context: LocalAgentRuntimeContext): string; + readonly runtimePolicy: LocalAgentRuntimePolicy; createRuntime(context: LocalAgentRuntimeContext): Promise>; - readonly idleTimeoutMs?: number; }