From 2a85edaaf8c790f39fc9f314545ae7e8f997a614 Mon Sep 17 00:00:00 2001 From: wen2zhou Date: Fri, 14 Aug 2026 11:06:08 +0000 Subject: [PATCH] fix(skills): provide isolated agent runtime access Materialize complete Skill packages from protected sources into normalized Agent runtime projections without changing source permissions. Preserve file content and executable modes in compatibility identity, reject symlinks and unsafe paths, and keep generated Connector and Compute documents in the same framework-neutral catalog contract. Give every backend generation and delegated attempt a private, disposable projection with its own temporary and cache directories. Rebuild forks from validated source metadata instead of exposed parent paths, fail closed when package sources change, redirect Python, Node, and R runtime state away from Skill packages, and remove only the owning lease while preserving rollback-era runtime trees. Project the same secret-free runtime view through Claude Code, Codex, and OpenCode while retaining native Skill discovery, ordinary file reads, and ordinary script execution. Rebind every framework-native discovery surface for delegated attempts, including OpenCode config content and writable config homes, and retire generations after dynamic catalog changes. Stop new sessions from rewriting rollback-owned stable Skill catalogs. Restrict Claude to runtime plugin Skills, isolate OpenCode config while preserving its stable auth data, and disable the Codex legacy Skill documents present at process startup without changing CODEX_HOME authentication or session ownership. The accepted V1 boundary leaves only the documented race where a concurrently running rollback release creates a new Codex legacy Skill after startup. Keep persistent data rollback-safe through the separate v2 compatibility cache and legacy projection behavior. Harden the pinned Codex ACP patches, avoid Claude unsupported plugin flags, and cover lifecycle, concurrency, source integrity, cleanup, cache location, framework rebasing, legacy suppression, and real script execution. This first release intentionally keeps Specialist sessions on the Main Agent catalog. Specialist-specific entitlements and Main-versus-Specialist Skill scope isolation are deferred to a separate runtime-ownership change. --- docs/design.md | 7 +- scripts/ci/change-impact.json | 4 + scripts/ci/classify-pr-changes.test.ts | 3 + scripts/ci/module-impact.json | 1 + src/main/acp/agent-connection-adapter.test.ts | 37 +- src/main/acp/agent-connection-adapter.ts | 14 +- src/main/acp/backend-generation-owner.test.ts | 44 + src/main/acp/backend-generation-owner.ts | 10 +- .../acp/connection-resource-owner.test.ts | 40 +- src/main/acp/connection-resource-owner.ts | 66 +- src/main/acp/context-usage-policy.test.ts | 24 + src/main/acp/context-usage-policy.ts | 3 +- src/main/acp/prompt-preparation-owner.test.ts | 45 + src/main/acp/prompt-preparation-owner.ts | 4 + src/main/acp/provider-session-adopter.test.ts | 50 +- src/main/acp/provider-session-adopter.ts | 10 +- src/main/acp/provider-session-creator.test.ts | 22 + src/main/acp/provider-session-creator.ts | 10 +- src/main/acp/provider-session-resumer.test.ts | 25 + src/main/acp/provider-session-resumer.ts | 4 + src/main/acp/reviewer-session-owner.test.ts | 67 + src/main/acp/reviewer-session-owner.ts | 13 +- src/main/acp/runtime-session-composition.ts | 4 +- src/main/acp/runtime.test.ts | 5 +- .../acp/session-presentation-policy.test.ts | 30 +- src/main/acp/session-presentation-policy.ts | 7 +- src/main/acp/turn-skill-owner.test.ts | 115 ++ src/main/acp/turn-skill-owner.ts | 42 +- src/main/agent-framework/claude-code.test.ts | 89 +- src/main/agent-framework/claude-code.ts | 82 +- src/main/agent-framework/codex.test.ts | 57 +- src/main/agent-framework/codex.ts | 9 + src/main/agent-framework/index.ts | 5 + src/main/agent-framework/opencode.test.ts | 57 + src/main/agent-framework/opencode.ts | 101 +- .../resolved-agent-backend-leases.test.ts | 9 +- .../resolved-agent-backend-leases.ts | 1 + .../agent-framework/skill-runtime-binding.ts | 55 + src/main/agent-framework/types.ts | 53 + .../compute-service.architecture.test.ts | 14 + src/main/compute/ipc.test.ts | 157 +- src/main/compute/ipc.ts | 56 +- src/main/compute/skill-doc.ts | 6 +- src/main/connector-reload.ts | 6 +- src/main/connectors/provision.ts | 4 +- .../runtime-settings-projection.test.ts | 65 +- .../skill-source.architecture.test.ts | 73 + src/main/connectors/skill-source.ts | 8 + .../delegation/durable-delegated-work.test.ts | 10 +- src/main/delegation/durable-delegated-work.ts | 7 +- .../execution-backend-lease.test.ts | 263 +++- .../delegation/execution-backend-lease.ts | 81 +- src/main/delegation/execution-port.ts | 21 +- .../production-framework-runtime.test.ts | 82 +- .../production-framework-runtime.ts | 27 +- src/main/ipc.ts | 7 +- .../settings/agent-runtime-manager.test.ts | 501 +++++- src/main/settings/agent-runtime-manager.ts | 253 ++- .../agent-skill-runtime-projection.ts | 91 ++ src/main/settings/backend-resolver.test.ts | 275 +++- src/main/settings/backend-resolver.ts | 160 +- src/main/settings/claude-config-provision.ts | 3 +- src/main/settings/managed-codex.test.ts | 254 ++- src/main/settings/managed-codex.ts | 161 +- src/main/settings/service.test.ts | 336 ++-- .../settings-backend.architecture.test.ts | 1 + src/main/settings/skill-catalog.test.ts | 9 + src/main/settings/skill-catalog.ts | 13 +- .../settings/subagent-model-owner.test.ts | 79 + src/main/settings/subagent-model-owner.ts | 20 +- .../agent-skill-runtime-environment.test.ts | 112 ++ .../skills/agent-skill-runtime-environment.ts | 91 ++ src/main/skills/agent-skill-runtime.test.ts | 1380 +++++++++++++++++ src/main/skills/agent-skill-runtime.ts | 571 +++++++ .../user-skill-catalog-observer.test.ts | 34 +- .../user-skill-compatibility-index.test.ts | 56 +- .../skills/user-skill-compatibility-index.ts | 19 +- src/main/skills/user-skill-repository.test.ts | 2 +- 78 files changed, 6045 insertions(+), 487 deletions(-) create mode 100644 src/main/acp/reviewer-session-owner.test.ts create mode 100644 src/main/agent-framework/skill-runtime-binding.ts create mode 100644 src/main/connectors/skill-source.architecture.test.ts create mode 100644 src/main/connectors/skill-source.ts create mode 100644 src/main/settings/agent-skill-runtime-projection.ts create mode 100644 src/main/settings/subagent-model-owner.test.ts create mode 100644 src/main/skills/agent-skill-runtime-environment.test.ts create mode 100644 src/main/skills/agent-skill-runtime-environment.ts create mode 100644 src/main/skills/agent-skill-runtime.test.ts create mode 100644 src/main/skills/agent-skill-runtime.ts diff --git a/docs/design.md b/docs/design.md index 9720ac231..4bd0400ee 100644 --- a/docs/design.md +++ b/docs/design.md @@ -705,7 +705,7 @@ colors communicate a successful or failed probe/migration result. - Connector, Skill, and Specialist `name` values are stable invocation identities. They are fixed after creation and are used by host APIs, generated Skill documents, package references, and policy routing. Editing a presentation label must never change these references. - `displayName` is presentation-only and may appear in lists, search results, prompts, and approval UI. Connector and Specialist editors may change it freely. A Skill may read an optional `displayName` from external `SKILL.md` frontmatter and falls back to `name`; built-in Skills and app-generated Skill exports omit that non-standard field, and the app does not maintain a separate Skill display-name field outside the manifest. -- Connector context follows a distinct derived path: after live tool discovery the app generates an on-demand `mcp-/SKILL.md`. Its frontmatter identity and every `host.mcp` example use immutable `name`; `displayName` may appear only in generated prose (or through an explicit `listConnectors()` result). Updating a Connector regenerates this document and reloads Skills without creating an invocation alias. Auth-recovery guidance derived from Connector configuration and discovered login tools remains part of the generated document. +- Connector context follows a distinct derived path: after live tool discovery the app generates an on-demand `mcp-/SKILL.md` under the rebuildable, versioned source `/runtime-support/connector-skills-v1`. This source is app-owned but is not a framework catalog; Connector refresh and cleanup never read, write, or delete the rollback directories under `claude/skills`, `opencode/config/opencode/skills`, `codex/skills`, or `codex-subscription/skills`. Agent runtime generations read custom Connector documents only from this derived source, while bundled Connector documents may be rendered directly into the private generation. Frontmatter identity and every `host.mcp` example use immutable `name`; `displayName` may appear only in generated prose (or through an explicit `listConnectors()` result). Updating a Connector regenerates the derived document and reloads Skills without creating an invocation alias. Auth-recovery guidance derived from Connector configuration and discovered login tools remains part of the generated document. - A custom Connector also has an internal UUID `id`. Local Specialist capability references and durable permission grants use that UUID; runtime calls, generated Connector Skills, and portable package references use the immutable lowercase-hyphenated `name`. Package import/export resolves between the two through the live Connector catalog. The UI shows `displayName` and exposes the immutable Connector name separately. Display names and UUIDs are not invocation aliases. - Connector template schema v1 stores both `name` and `displayName` directly. Export never includes secrets, and import does not synthesize compatibility aliases from a display name. - Specialist package schema v1 remains byte-for-byte compatible in field shape: package `name` stays the immutable invocation identity and `displayName` stays editable presentation metadata. `skillIds` and `connectorIds` keep their released JSON keys but contain portable capability names; local Specialist persistence contains installation IDs. Featured Skills are exported as references and are never copied into `skills/`. @@ -717,8 +717,9 @@ colors communicate a successful or failed probe/migration result. - Directly copied Personal and Imported packages at `/skills///SKILL.md` use the same central catalog as Settings and every agent framework, where `` is `personal` or `imported` and `` is 1–64 lowercase letters or numbers separated by single hyphens. The shared agent system prompt supplies both absolute source paths: it may author a user-requested package in Personal, while Imported is informational and GitHub, attachment, search, preview, or confirmation sources remain on the application-owned import flow. - `storageRoot` is the historical code name for this fixed, non-relocatable `configRoot`; it is not the user-selectable `dataRoot`. Personal and Imported source packages, Settings, and the app-owned agent profiles live below `configRoot`. `dataRoot` holds relocatable artifacts, notebooks, and rebuildable compute/runtime assets and does not participate in user-Skill discovery. - Personal and Imported directories are the writable source of truth. The app scans those two sources into one central catalog; agent frameworks do not independently scan them in place. The user-Skill catalog observer watches `/skills`, coalesces bursts to at most one running and one pending reconciliation, and falls back to reconciliation every 30 seconds when recursive watching is unavailable. A catalog fingerprint change refreshes Settings and retires the current agent runtime generation. An active turn finishes against its existing generation, while every later turn resumes through a freshly provisioned generation. -- User-Skill compatibility hashing is incremental. A rebuildable index at `/runtime-support/user-skill-compatibility-v1.json` stores only relative package/file paths, file size and timestamps, and SHA-256 hashes; it never stores file contents or absolute paths. Unchanged files reuse their hashes, changed files are streamed through the hasher, deleted entries are pruned, and a missing or corrupt index is rebuilt from the Personal and Imported sources. The index lives outside `/skills`, so persisting it cannot trigger the catalog observer. -- Before an agent runtime starts, the enabled central catalog is copied into that framework's isolated, rebuildable Skill projection: `/claude/skills/os-`, `/opencode/config/opencode/skills/os-`, `/codex/skills/os-`, or `/codex-subscription/skills/os-`. The internal `os-` directory is a projection identity, not the user package name. These projections are normalized and made read-only; stale app-owned projections are removed on synchronization, while the Personal/Imported source package remains authoritative. +- User-Skill compatibility hashing is incremental. A rebuildable v2 index at `/runtime-support/user-skill-compatibility-v2.json` stores only relative package/file paths, file size and timestamps, executable-bit state, and SHA-256 hashes; it never stores file contents or absolute paths. Content and normalized executable mode both participate in compatibility identity, so a chmod-only script change retires the current Agent runtime generation. Unchanged files reuse their hashes, changed files are streamed through the hasher, deleted entries are pruned, and a missing or corrupt index is rebuilt from the Personal and Imported sources. The separate legacy `user-skill-compatibility-v1.json` remains untouched for rollback applications. Both indexes live outside `/skills`, so persisting them cannot trigger the catalog observer. +- Stable framework catalogs under `/claude/skills`, `/opencode/config/opencode/skills`, `/codex/skills`, and `/codex-subscription/skills` are retained only for rollback applications. Current sessions neither discover nor update these legacy projections; the application does not delete them because an older release may still be using them. +- Agent-facing execution uses the only active current-version projection under `/runtime/agent-skills/v1/leases//projection`. Every backend generation and subagent attempt receives a private, disposable projection plus cache and temporary directories in the same lease tree. Forks are reconstructed from an application-owned blueprint containing package source paths and validated content/mode metadata, not package bytes; only generated and override files are defensively cloned in memory. Package sources are checked against the acquired snapshot both before and after each copy, and the complete normalized projection is checked against the acquired revision before exposure. Releasing a lease deletes that exact tree; later acquisitions opportunistically remove crash leftovers without reading, sharing, or cleaning the legacy `catalogs/` tree. Read-only modes remain defense in depth rather than an immutability guarantee against deliberate same-user shell commands. Because leases share no projection tree, ordinary mutation through one lease's supplied paths cannot alter protected sources, another lease's files, or a later rebuilt generation; deliberate access to a separately enumerated same-user runtime path is outside that guarantee. - Names beginning with `os-` or `mcp-`, names matching a bundled Featured/Internal Skill, Specialist sidecar IDs colliding with a bundled ID, and duplicate user sidecar IDs are logged and excluded so app-owned packages and user identities stay authoritative. Unsafe sidecar IDs are ignored. Existing newest-wins behavior remains for out-of-band Personal/Imported name duplicates; normal create/import flows prevent those collisions. - List toolbar: a single row of `Select` source filter (`w-36`), a flex-1 search `Input` with a leading `Search` icon (`pl-8`, `type="search"`) and platform `Cmd/Ctrl+K` keycaps, then neutral **Manage** and **Add skill** controls. - "Add skill" is a neutral (not primary) `DropdownMenu` trigger: `h-8 rounded-lg border border-border bg-card px-2.5 text-sm font-medium hover:bg-muted`, with a leading `Plus` and a trailing `ChevronDown` (`opacity-70`). Its items — Write from scratch, Upload a skill, Import from GitHub — use `gap-2.5`, a leading icon, and a stacked label + `text-xs text-muted-foreground` hint. diff --git a/scripts/ci/change-impact.json b/scripts/ci/change-impact.json index 731203e2a..9672e9562 100644 --- a/scripts/ci/change-impact.json +++ b/scripts/ci/change-impact.json @@ -122,6 +122,7 @@ "src/main/crash-diagnostics.ts", "src/main/index.ts", "src/main/lifecycle-shutdown.ts", + "src/main/local-fs/service.ts", "src/main/local-rpc-transport.ts", "src/main/notebook/environment-discovery.ts", "src/main/notebook/environment-state-tracker.ts", @@ -158,6 +159,9 @@ "src/main/settings/opencode-install.ts", "src/main/settings/preferences.ts", "src/main/settings/process-tree.ts", + "src/main/skills/agent-skill-runtime.ts", + "src/main/skills/agent-skill-runtime-environment.ts", + "src/main/skills/user-skill-compatibility-index.ts", "src/main/settings/skill-catalog.ts", "src/main/storage-root.ts", "src/main/storage/**", diff --git a/scripts/ci/classify-pr-changes.test.ts b/scripts/ci/classify-pr-changes.test.ts index aad352d82..41fde6f70 100644 --- a/scripts/ci/classify-pr-changes.test.ts +++ b/scripts/ci/classify-pr-changes.test.ts @@ -328,8 +328,11 @@ describe('pull request change classification', () => { ['session persistence', 'src/main/session-persistence/ipc.ts'], ['notebook shell process', 'src/main/notebook/shell-process.ts'], ['file save', 'src/main/file-save.ts'], + ['local filesystem service', 'src/main/local-fs/service.ts'], ['specialist repository', 'src/main/specialist/repository.ts'], ['notebook runtime settings', 'src/main/settings/notebook-runtime-settings.ts'], + ['Agent Skill runtime environment', 'src/main/skills/agent-skill-runtime-environment.ts'], + ['User Skill compatibility index', 'src/main/skills/user-skill-compatibility-index.ts'], ['preferences', 'src/main/settings/preferences.ts'] ])('adds native Windows lanes for %s changes', (_category, path) => { const plan = classifyChanges([{ path, status: 'modified' }]) diff --git a/scripts/ci/module-impact.json b/scripts/ci/module-impact.json index c13c4534a..5ca45637e 100644 --- a/scripts/ci/module-impact.json +++ b/scripts/ci/module-impact.json @@ -373,6 +373,7 @@ }, "settings_backend_resolution": { "ownerPaths": [ + "src/main/settings/agent-skill-runtime-projection.ts", "src/main/settings/backend-resolver.ts", "src/main/settings/backend-selection-owner.ts", "src/main/settings/backend-route-planner.ts", diff --git a/src/main/acp/agent-connection-adapter.test.ts b/src/main/acp/agent-connection-adapter.test.ts index 789dd58df..f9c3af541 100644 --- a/src/main/acp/agent-connection-adapter.test.ts +++ b/src/main/acp/agent-connection-adapter.test.ts @@ -159,6 +159,7 @@ describe('AcpAgentConnectionAdapter', () => { const releaseBridge = vi.fn(async () => undefined) const releaseAnthropic = vi.fn(async () => undefined) const releaseProviderTransport = vi.fn(async () => undefined) + const releaseSkillRuntime = vi.fn(async () => undefined) const backend: ResolvedAgentBackend = { framework: { ...claudeCodeFramework, spawn: () => asAgentProcess(process) }, executablePath: '/bin/agent', @@ -176,7 +177,8 @@ describe('AcpAgentConnectionAdapter', () => { providerTransportLease: { setTarget: vi.fn(() => true), release: releaseProviderTransport - } + }, + skillRuntimeLease: { release: releaseSkillRuntime } } const candidate = await openCandidate(process, backend) @@ -188,6 +190,32 @@ describe('AcpAgentConnectionAdapter', () => { expect(releaseBridge).toHaveBeenCalledOnce() expect(releaseAnthropic).toHaveBeenCalledOnce() expect(releaseProviderTransport).toHaveBeenCalledOnce() + expect(releaseSkillRuntime).toHaveBeenCalledOnce() + }) + + it('reports an untransferred Skill Runtime lease release failure at its cleanup stage', async () => { + const process = new FakeAgentProcess() + const failure = new Error('runtime release failed') + const connectionHooks = hooks() + const candidate = await openCandidate( + process, + { + framework: claudeCodeFramework, + executablePath: '', + env: {}, + skillRuntimeLease: { release: vi.fn(async () => Promise.reject(failure)) } + }, + connectionHooks + ) + + await candidate.dispose() + + expect(connectionHooks.reportCleanupFailure).toHaveBeenCalledWith( + 'skill-runtime-lease', + failure, + 'claude-code', + 1 + ) }) it('rejects a transfer to a different owner epoch without consuming the candidate', async () => { @@ -214,6 +242,7 @@ describe('AcpAgentConnectionAdapter', () => { const releaseBridge = vi.fn(async () => undefined) const releaseAnthropic = vi.fn(async () => undefined) const releaseProviderTransport = vi.fn(async () => undefined) + const releaseSkillRuntime = vi.fn(async () => undefined) const owner = new AcpConnectionResourceOwner() let candidateDispose: (() => Promise) | undefined await owner.connect(async (attempt) => { @@ -234,7 +263,8 @@ describe('AcpAgentConnectionAdapter', () => { providerTransportLease: { setTarget: vi.fn(() => true), release: releaseProviderTransport - } + }, + skillRuntimeLease: { release: releaseSkillRuntime } }) candidateDispose = candidate.dispose const transferred = candidate.transferTo(attempt) @@ -259,6 +289,7 @@ describe('AcpAgentConnectionAdapter', () => { expect(releaseBridge).not.toHaveBeenCalled() expect(releaseAnthropic).not.toHaveBeenCalled() expect(releaseProviderTransport).not.toHaveBeenCalled() + expect(releaseSkillRuntime).not.toHaveBeenCalled() return attempt.publish({ close: false, delete: false, resume: false }) }) @@ -267,12 +298,14 @@ describe('AcpAgentConnectionAdapter', () => { expect(releaseBridge).not.toHaveBeenCalled() expect(releaseAnthropic).not.toHaveBeenCalled() expect(releaseProviderTransport).not.toHaveBeenCalled() + expect(releaseSkillRuntime).not.toHaveBeenCalled() await owner.teardown(owner.epoch) expect(terminateProcessTree).toHaveBeenCalledOnce() expect(releaseBridge).toHaveBeenCalledOnce() expect(releaseAnthropic).toHaveBeenCalledOnce() expect(releaseProviderTransport).toHaveBeenCalledOnce() + expect(releaseSkillRuntime).toHaveBeenCalledOnce() }) it('retains cleanup ownership when the resource owner rejects transfer', async () => { diff --git a/src/main/acp/agent-connection-adapter.ts b/src/main/acp/agent-connection-adapter.ts index 8f022bf78..295474fcc 100644 --- a/src/main/acp/agent-connection-adapter.ts +++ b/src/main/acp/agent-connection-adapter.ts @@ -26,12 +26,14 @@ import { readWorkspaceTextFile, writeWorkspaceTextFile } from './filesystem' type ResponsesBridgeLease = ResolvedAgentBackend['responsesBridgeLease'] type AnthropicBridgeLease = ResolvedAgentBackend['anthropicBridgeLease'] type ProviderTransportLease = ResolvedAgentBackend['providerTransportLease'] +type SkillRuntimeLease = ResolvedAgentBackend['skillRuntimeLease'] type CandidateCleanupStage = | 'connection' | 'agent-process' | 'bridge-lease' | 'anthropic-bridge-lease' | 'provider-transport-lease' + | 'skill-runtime-lease' type AcpProcessEventContext = Readonly<{ process: ChildProcessWithoutNullStreams framework: AgentFramework['id'] @@ -111,6 +113,7 @@ class AcpAgentConnectionAdapter { let bridgeLease: ResponsesBridgeLease let anthropicBridgeLease: AnthropicBridgeLease let providerTransportLease: ProviderTransportLease + let skillRuntimeLease: SkillRuntimeLease let backendAttempt: AcpBackendGenerationAttempt | undefined let framework: AgentFramework['id'] = 'claude-code' @@ -159,6 +162,13 @@ class AcpAgentConnectionAdapter { reportCleanupFailure('provider-transport-lease', error) } } + if (skillRuntimeLease) { + try { + await skillRuntimeLease.release() + } catch (error) { + reportCleanupFailure('skill-runtime-lease', error) + } + } } try { @@ -167,6 +177,7 @@ class AcpAgentConnectionAdapter { bridgeLease = backend.responsesBridgeLease anthropicBridgeLease = backend.anthropicBridgeLease providerTransportLease = backend.providerTransportLease + skillRuntimeLease = backend.skillRuntimeLease backendAttempt = input.prepareBackend(backend) hooks.onBackendResolved(framework) process = input.spawnAgent @@ -238,7 +249,8 @@ class AcpAgentConnectionAdapter { framework, bridgeLease, anthropicBridgeLease, - providerTransportLease + providerTransportLease, + skillRuntimeLease }) state = 'transferred' openedConnection.closed.then(() => { diff --git a/src/main/acp/backend-generation-owner.test.ts b/src/main/acp/backend-generation-owner.test.ts index 57c70bae0..ceaa841fe 100644 --- a/src/main/acp/backend-generation-owner.test.ts +++ b/src/main/acp/backend-generation-owner.test.ts @@ -8,6 +8,14 @@ describe('AcpBackendGenerationOwner', () => { const owner = new AcpBackendGenerationOwner(claudeCodeFramework) const sessionOptions = { settingSources: ['user'] } const systemPromptAppends = ['Use the app tools.'] + const descriptors = [ + { + id: 'literature-review', + name: 'literature-review', + description: 'Review the literature.', + path: '/runtime/projection/skills/os-literature-review/SKILL.md' + } + ] const attempt = owner.prepare( { epoch: 7, assertCurrent: vi.fn() }, { @@ -15,6 +23,12 @@ describe('AcpBackendGenerationOwner', () => { backendId: 'codex:isolated', executablePath: '/private/provider/bin/codex-acp', env: { CODEX_HOME: '/data/codex', PROVIDER_TOKEN: 'spawn-secret' }, + skillRuntime: { + projectionRoot: '/runtime/projection', + discoveryRoot: '/runtime/projection/skills', + descriptors, + environment: { XDG_CACHE_HOME: '/runtime/cache' } + }, args: ['--token=argument-secret'], sessionModel: 'gpt-selected', sessionModelRequired: true, @@ -44,6 +58,12 @@ describe('AcpBackendGenerationOwner', () => { const view = attempt.publish() sessionOptions.settingSources.push('project') systemPromptAppends.push('late mutation') + descriptors.push({ + id: 'late-skill', + name: 'late-skill', + description: 'Late mutation.', + path: '/runtime/late/SKILL.md' + }) expect(owner.current).toBe(view) expect(view).toMatchObject({ @@ -62,19 +82,43 @@ describe('AcpBackendGenerationOwner', () => { context: { window: 1_000_000, model: 'provider-model', supportsImageInput: true }, adapter: { codexHome: '/data/codex', + additionalDirectories: ['/runtime/projection'], nativeMcpEnabled: false, bridgeMcpAliasesEnabled: true + }, + skillRuntime: { + projectionRoot: '/runtime/projection', + discoveryRoot: '/runtime/projection/skills', + descriptors: [ + { + id: 'literature-review', + name: 'literature-review', + description: 'Review the literature.', + path: '/runtime/projection/skills/os-literature-review/SKILL.md' + } + ], + environment: { XDG_CACHE_HOME: '/runtime/cache' } } }) expect(Object.isFrozen(view)).toBe(true) expect(Object.isFrozen(view.session)).toBe(true) expect(Object.isFrozen(view.session.options)).toBe(true) expect(Object.isFrozen(view.prompt.systemPromptAppends)).toBe(true) + expect(Object.isFrozen(view.skillRuntime)).toBe(true) + expect(Object.isFrozen(view.skillRuntime?.descriptors)).toBe(true) + expect(Object.isFrozen(view.skillRuntime?.descriptors[0])).toBe(true) expect(JSON.stringify(view)).not.toMatch( /spawn-secret|argument-secret|provider-secret|usage-secret|provider\.example|\/private\/provider/ ) }) + it('keeps legacy generations free of Skill Runtime roots', () => { + const owner = new AcpBackendGenerationOwner(claudeCodeFramework) + + expect(owner.current.skillRuntime).toBeUndefined() + expect(owner.current.adapter.additionalDirectories).toEqual([]) + }) + it('consumes attempt-bound initialize material exactly once', () => { const owner = new AcpBackendGenerationOwner(claudeCodeFramework) const authentication = { methodId: 'codex-login', _meta: { token: 'auth-secret' } } diff --git a/src/main/acp/backend-generation-owner.ts b/src/main/acp/backend-generation-owner.ts index 2e76463bf..b15f98534 100644 --- a/src/main/acp/backend-generation-owner.ts +++ b/src/main/acp/backend-generation-owner.ts @@ -3,7 +3,8 @@ import type { AgentFramework, AgentModelChangeTarget, AgentModelRoute, - ResolvedAgentBackend + ResolvedAgentBackend, + SkillRuntimeView } from '../agent-framework' type AcpBackendGenerationAttemptIdentity = Readonly<{ @@ -16,6 +17,7 @@ export type AcpBackendGenerationView = Readonly<{ backendId?: string modelRoute?: AgentModelRoute providerContinuityToken?: string + skillRuntime?: SkillRuntimeView session: Readonly<{ model?: string modelRequired: boolean @@ -33,6 +35,7 @@ export type AcpBackendGenerationView = Readonly<{ }> adapter: Readonly<{ codexHome?: string + additionalDirectories?: readonly string[] nativeMcpEnabled: boolean bridgeMcpAliasesEnabled: boolean }> @@ -73,6 +76,9 @@ const generationView = (backend: ResolvedAgentBackend): AcpBackendGenerationView : undefined const bridgeMcpAliasesEnabled = backend.framework.id === 'codex' && backend.providerConfiguration !== undefined + const skillRuntime = backend.skillRuntime + ? deepFreeze(structuredClone(backend.skillRuntime)) + : undefined return Object.freeze({ framework: backend.framework, @@ -81,6 +87,7 @@ const generationView = (backend: ResolvedAgentBackend): AcpBackendGenerationView ...(backend.providerContinuityToken ? { providerContinuityToken: backend.providerContinuityToken } : {}), + ...(skillRuntime ? { skillRuntime } : {}), session: Object.freeze({ ...(backend.sessionModel ? { model: backend.sessionModel } : {}), modelRequired: backend.sessionModelRequired ?? false, @@ -102,6 +109,7 @@ const generationView = (backend: ResolvedAgentBackend): AcpBackendGenerationView }), adapter: Object.freeze({ ...(codexHome ? { codexHome } : {}), + additionalDirectories: Object.freeze(skillRuntime ? [skillRuntime.projectionRoot] : []), nativeMcpEnabled: backend.framework.id !== 'codex' || !bridgeMcpAliasesEnabled, bridgeMcpAliasesEnabled }) diff --git a/src/main/acp/connection-resource-owner.test.ts b/src/main/acp/connection-resource-owner.test.ts index e88abaa30..14724f99f 100644 --- a/src/main/acp/connection-resource-owner.test.ts +++ b/src/main/acp/connection-resource-owner.test.ts @@ -228,6 +228,7 @@ describe('AcpConnectionResourceOwner', () => { const child = process('physical') const close = vi.fn() const release = vi.fn(async () => undefined) + const releaseSkillRuntime = vi.fn(async () => undefined) const handle = await owner.connect(async (attempt) => { attempt.attach({ process: child, @@ -238,7 +239,8 @@ describe('AcpConnectionResourceOwner', () => { registerReviewerSession: vi.fn(), unregisterReviewerSession: vi.fn(() => true), release - } + }, + skillRuntimeLease: { release: releaseSkillRuntime } }) return attempt.publish({ close: true, delete: false, resume: true }) }) @@ -250,12 +252,38 @@ describe('AcpConnectionResourceOwner', () => { expect(close).toHaveBeenCalledOnce() expect(terminateProcessTree).toHaveBeenCalledOnce() expect(release).toHaveBeenCalledOnce() + expect(releaseSkillRuntime).toHaveBeenCalledOnce() expect(() => handle.assertCurrent()).toThrow('ACP connection was superseded.') await owner.teardown(teardownEpoch, vi.fn()) expect(close).toHaveBeenCalledOnce() expect(terminateProcessTree).toHaveBeenCalledOnce() expect(release).toHaveBeenCalledOnce() + expect(releaseSkillRuntime).toHaveBeenCalledOnce() + }) + + it('releases an unattached Skill Runtime lease from a failed startup candidate', async () => { + const owner = new AcpConnectionResourceOwner() + const release = vi.fn(async () => undefined) + const skillRuntimeLease = { release } + + await owner.cleanupUnattached({ skillRuntimeLease }) + await owner.cleanupUnattached({ skillRuntimeLease }) + + expect(release).toHaveBeenCalledOnce() + }) + + it('reports and retries a failed Skill Runtime lease release on the next cleanup', async () => { + const owner = new AcpConnectionResourceOwner() + const failure = new Error('runtime release failed') + const release = vi.fn().mockRejectedValueOnce(failure).mockResolvedValueOnce(undefined) + const onFailure = vi.fn() + + await owner.cleanupUnattached({ skillRuntimeLease: { release } }, onFailure) + expect(onFailure).toHaveBeenCalledWith('skill-runtime-lease', failure) + + await owner.cleanupUnattached({}, onFailure) + expect(release).toHaveBeenCalledTimes(2) }) it('retargets and releases the generation-scoped Anthropic bridge', async () => { @@ -318,6 +346,7 @@ describe('AcpConnectionResourceOwner', () => { throw new Error('kill failed') }) const release = vi.fn(async () => undefined) + const releaseSkillRuntime = vi.fn(async () => undefined) await owner.connect(async (attempt) => { attempt.attach({ process: { killed: false, kill } as unknown as ChildProcessWithoutNullStreams, @@ -328,7 +357,8 @@ describe('AcpConnectionResourceOwner', () => { registerReviewerSession: vi.fn(), unregisterReviewerSession: vi.fn(() => true), release - } + }, + skillRuntimeLease: { release: releaseSkillRuntime } }) return attempt.publish({ close: true, delete: false, resume: true }) }) @@ -339,6 +369,7 @@ describe('AcpConnectionResourceOwner', () => { try { expect(() => owner.shutdownSynchronously(vi.fn())).not.toThrow() await vi.waitFor(() => expect(release).toHaveBeenCalledOnce()) + await vi.waitFor(() => expect(releaseSkillRuntime).toHaveBeenCalledOnce()) expect(close).toHaveBeenCalledOnce() expect(kill).toHaveBeenCalledOnce() expect(owner.isShuttingDown).toBe(true) @@ -418,6 +449,7 @@ describe('AcpConnectionResourceOwner', () => { const closeMcpHost = vi.fn(async () => undefined) const owner = new AcpConnectionResourceOwner({ closeMcpHost }) const release = vi.fn(async () => undefined) + const releaseSkillRuntime = vi.fn(async () => undefined) await owner.connect(async (attempt) => { attempt.attach({ process: process('unexpected'), @@ -428,7 +460,8 @@ describe('AcpConnectionResourceOwner', () => { registerReviewerSession: vi.fn(), unregisterReviewerSession: vi.fn(() => true), release - } + }, + skillRuntimeLease: { release: releaseSkillRuntime } }) return attempt.publish({ close: true, delete: false, resume: true }) }) @@ -438,6 +471,7 @@ describe('AcpConnectionResourceOwner', () => { await owner.closeMcp(owner.epoch) await vi.waitFor(() => expect(release).toHaveBeenCalledOnce()) + await vi.waitFor(() => expect(releaseSkillRuntime).toHaveBeenCalledOnce()) expect(terminateProcessTree).toHaveBeenCalledOnce() expect(closeMcpHost).toHaveBeenCalledOnce() }) diff --git a/src/main/acp/connection-resource-owner.ts b/src/main/acp/connection-resource-owner.ts index 03ebf39f7..b0fe9fe54 100644 --- a/src/main/acp/connection-resource-owner.ts +++ b/src/main/acp/connection-resource-owner.ts @@ -8,7 +8,11 @@ import { terminateProcessTree } from '../process-tree' type ResponsesBridgeLease = ResolvedAgentBackend['responsesBridgeLease'] type AnthropicBridgeLease = ResolvedAgentBackend['anthropicBridgeLease'] type ProviderTransportLease = ResolvedAgentBackend['providerTransportLease'] -type CleanupFailure = (stage: 'connection' | 'agent-process', error: unknown) => void +type SkillRuntimeLease = NonNullable +type CleanupFailure = ( + stage: 'connection' | 'agent-process' | 'skill-runtime-lease', + error: unknown +) => void const log = createLogger('acp') const safeLogCleanupError = (message: string, error: unknown): void => { @@ -32,6 +36,7 @@ export type AcpAttachedConnectionResource = { bridgeLease: ResponsesBridgeLease anthropicBridgeLease?: AnthropicBridgeLease providerTransportLease?: ProviderTransportLease + skillRuntimeLease?: SkillRuntimeLease } export type AcpConnectionResourceReadyHandle = Readonly<{ @@ -56,6 +61,7 @@ export type AcpUnattachedConnectionResource = Readonly<{ bridgeLease?: ResponsesBridgeLease anthropicBridgeLease?: AnthropicBridgeLease providerTransportLease?: ProviderTransportLease + skillRuntimeLease?: SkillRuntimeLease }> export type AcpConnectionShutdownHandle = Readonly<{ @@ -86,6 +92,9 @@ export class AcpConnectionResourceOwner { private connectInFlight: Promise | undefined private readonly expectedProcessExits = new WeakSet() private readonly releasedBridgeLeases = new WeakSet() + private readonly releasedSkillRuntimeLeases = new WeakSet() + private readonly pendingSkillRuntimeLeases = new Set() + private readonly skillRuntimeReleaseAttempts = new WeakMap>() private shuttingDown = false private lastTreeKillReaped = true @@ -168,6 +177,7 @@ export class AcpConnectionResourceOwner { // Ownership transfers synchronously before the first cleanup await, so a successor may attach // without an older process or lease remaining reachable through this owner. const resource = this.detach(expectedEpoch) + await this.retryPendingSkillRuntimeLeases(onFailure) if (!resource) return this.expectedProcessExits.add(resource.process) @@ -185,6 +195,7 @@ export class AcpConnectionResourceOwner { await this.releaseBridgeLease(resource.bridgeLease) await this.releaseAnthropicBridgeLease(resource.anthropicBridgeLease) await this.releaseProviderTransportLease(resource.providerTransportLease) + await this.releaseSkillRuntimeLease(resource.skillRuntimeLease, onFailure) } async cleanupUnattached( @@ -192,6 +203,7 @@ export class AcpConnectionResourceOwner { onFailure: CleanupFailure = (stage, error) => safeLogCleanupError(`unattached ACP ${stage} cleanup failed`, error) ): Promise { + await this.retryPendingSkillRuntimeLeases(onFailure) if (resource.process) this.expectedProcessExits.add(resource.process) try { resource.connection?.close() @@ -209,15 +221,18 @@ export class AcpConnectionResourceOwner { await this.releaseBridgeLease(resource.bridgeLease) await this.releaseAnthropicBridgeLease(resource.anthropicBridgeLease) await this.releaseProviderTransportLease(resource.providerTransportLease) + await this.releaseSkillRuntimeLease(resource.skillRuntimeLease, onFailure) } cleanupUnexpectedClose(expectedEpoch: number): void { const resource = this.detach(expectedEpoch) if (!resource) return this.expectedProcessExits.add(resource.process) - void this.reapProcessTree(resource.process).catch((error) => { - safeLogCleanupError('agent process cleanup after unexpected close failed', error) - }) + void this.reapProcessTree(resource.process) + .catch((error) => { + safeLogCleanupError('agent process cleanup after unexpected close failed', error) + }) + .finally(() => this.releaseSkillRuntimeLease(resource.skillRuntimeLease)) void this.releaseBridgeLease(resource.bridgeLease) void this.releaseAnthropicBridgeLease(resource.anthropicBridgeLease) void this.releaseProviderTransportLease(resource.providerTransportLease) @@ -242,10 +257,20 @@ export class AcpConnectionResourceOwner { } catch (error) { safeLogCleanupError('agent process kill during shutdown failed', error) } + void this.reapProcessTree(resource.process) + .catch((error) => { + safeLogCleanupError('agent process cleanup during shutdown failed', error) + }) + .finally(() => this.releaseSkillRuntimeLease(resource.skillRuntimeLease)) + } else { + void this.releaseSkillRuntimeLease(resource?.skillRuntimeLease) } void this.releaseBridgeLease(resource?.bridgeLease) void this.releaseAnthropicBridgeLease(resource?.anthropicBridgeLease) void this.releaseProviderTransportLease(resource?.providerTransportLease) + for (const lease of this.pendingSkillRuntimeLeases) { + void this.releaseSkillRuntimeLease(lease) + } void this.closeMcp() } } @@ -376,6 +401,39 @@ export class AcpConnectionResourceOwner { } } + private async releaseSkillRuntimeLease( + lease: SkillRuntimeLease | undefined, + onFailure?: CleanupFailure + ): Promise { + if (!lease || this.releasedSkillRuntimeLeases.has(lease)) return + const existing = this.skillRuntimeReleaseAttempts.get(lease) + if (existing) return existing + this.pendingSkillRuntimeLeases.add(lease) + const attempt = Promise.resolve() + .then(() => lease.release()) + .then(() => { + this.pendingSkillRuntimeLeases.delete(lease) + this.releasedSkillRuntimeLeases.add(lease) + }) + .catch((error) => { + if (onFailure) this.reportCleanupFailure(onFailure, 'skill-runtime-lease', error) + else safeLogCleanupError('Skill Runtime lease release failed', error) + }) + .finally(() => { + if (this.skillRuntimeReleaseAttempts.get(lease) === attempt) { + this.skillRuntimeReleaseAttempts.delete(lease) + } + }) + this.skillRuntimeReleaseAttempts.set(lease, attempt) + return attempt + } + + private async retryPendingSkillRuntimeLeases(onFailure: CleanupFailure): Promise { + for (const lease of [...this.pendingSkillRuntimeLeases]) { + await this.releaseSkillRuntimeLease(lease, onFailure) + } + } + private reportCleanupFailure( onFailure: CleanupFailure, stage: Parameters[0], diff --git a/src/main/acp/context-usage-policy.test.ts b/src/main/acp/context-usage-policy.test.ts index 362816e01..a5a432de3 100644 --- a/src/main/acp/context-usage-policy.test.ts +++ b/src/main/acp/context-usage-policy.test.ts @@ -14,6 +14,30 @@ const backend = (overrides: Partial = {}): AcpBackendG }) describe('AcpContextUsagePolicy', () => { + it('keeps Skill Runtime setup aligned with the published generation', () => { + const buildSessionSetup = vi.fn(() => ({})) + const skillRuntime = { + projectionRoot: '/runtime/projection', + discoveryRoot: '/runtime/projection/skills', + descriptors: [], + environment: {} + } + const policy = new AcpContextUsagePolicy({ + backend: () => + backend({ + framework: { ...opencodeFramework, buildSessionSetup }, + skillRuntime + }), + appliedModel: () => 'confirmed/model', + systemPromptAppends: () => [], + tooling: () => ({ artifacts: false, notebook: false, skillImport: false }) + }) + + policy.resolve('session-1') + + expect(buildSessionSetup).toHaveBeenCalledWith(expect.objectContaining({ skillRuntime })) + }) + it('rejects an OpenCode model and window until the Session confirms its applied model', () => { const selection: { appliedModel?: string } = {} const currentBackend = vi.fn(() => backend()) diff --git a/src/main/acp/context-usage-policy.ts b/src/main/acp/context-usage-policy.ts index 73139fac7..d4c4564da 100644 --- a/src/main/acp/context-usage-policy.ts +++ b/src/main/acp/context-usage-policy.ts @@ -35,7 +35,8 @@ class AcpContextUsagePolicy { systemPromptAppends: backend.prompt.persistentSystemPrompt ? [] : [...this.options.systemPromptAppends()], - sessionOptions: backend.session.options + sessionOptions: backend.session.options, + ...(backend.skillRuntime ? { skillRuntime: backend.skillRuntime } : {}) }) const persistentSystemPrompt = backend.prompt.persistentSystemPrompt ?? sessionSetup.persistentSystemPrompt diff --git a/src/main/acp/prompt-preparation-owner.test.ts b/src/main/acp/prompt-preparation-owner.test.ts index 013b3a33a..906de5f59 100644 --- a/src/main/acp/prompt-preparation-owner.test.ts +++ b/src/main/acp/prompt-preparation-owner.test.ts @@ -141,6 +141,51 @@ const setup = (): Fixture => { } describe('AcpPromptPreparationOwner', () => { + it('keeps the generation Skill Runtime on turn framework setup', async () => { + const fixture = setup() + const buildSessionSetup = vi.fn(() => ({})) + const skillRuntime = { + projectionRoot: '/runtime/projection', + discoveryRoot: '/runtime/projection/skills', + descriptors: [ + { + id: 'research', + name: 'Research', + description: 'Research Skill', + path: '/runtime/projection/skills/os-research/SKILL.md' + } + ], + environment: {} + } + fixture.turnSkill.prepareProvider.mockResolvedValueOnce({ + text: 'prepared task', + codexSkillInputs: [] + }) + + await fixture.prepare({ + backend: { + framework: { ...codexFramework, buildSessionSetup }, + skillRuntime, + session: { modelRequired: false }, + prompt: { systemPromptAppends: [], persistentSystemPrompt: 'baked instructions' }, + context: { window: 100_000, supportsImageInput: true }, + adapter: { + nativeMcpEnabled: true, + bridgeMcpAliasesEnabled: false, + codexHome: '/codex', + additionalDirectories: ['/runtime/projection'] + } + } + }) + + expect(buildSessionSetup).toHaveBeenCalledWith(expect.objectContaining({ skillRuntime })) + expect(fixture.turnSkill.prepareProvider).toHaveBeenCalledWith( + expect.objectContaining({ + codex: expect.objectContaining({ runtimeDescriptors: skillRuntime.descriptors }) + }) + ) + }) + it('composes handoff, presentation, Notebook and prompt content and transfers Context once', async () => { const fixture = setup() diff --git a/src/main/acp/prompt-preparation-owner.ts b/src/main/acp/prompt-preparation-owner.ts index 7aec48c9b..84689c12c 100644 --- a/src/main/acp/prompt-preparation-owner.ts +++ b/src/main/acp/prompt-preparation-owner.ts @@ -144,6 +144,9 @@ class AcpPromptPreparationOwner { promptText: requestText, codex: { home: input.backend.adapter.codexHome, + ...(input.backend.skillRuntime + ? { runtimeDescriptors: input.backend.skillRuntime.descriptors } + : {}), bridgeSkillsAvailable: input.bridgeSkillsAvailable, selectSkills: async (text, catalog, signal) => (await this.options.selectBridgeSkills(text, catalog, signal)) ?? [], @@ -158,6 +161,7 @@ class AcpPromptPreparationOwner { backendSystemPromptAppends: input.backend.prompt.systemPromptAppends, persistentSystemPrompt: input.backend.prompt.persistentSystemPrompt, sessionOptions: input.backend.session.options, + ...(input.backend.skillRuntime ? { skillRuntime: input.backend.skillRuntime } : {}), specialistPrefix: input.specialistPrefix, sessionSetupPromptPrefix: input.sessionSetupPromptPrefix, turnPromptReminders: [ diff --git a/src/main/acp/provider-session-adopter.test.ts b/src/main/acp/provider-session-adopter.test.ts index beb639040..f2ef08bb1 100644 --- a/src/main/acp/provider-session-adopter.test.ts +++ b/src/main/acp/provider-session-adopter.test.ts @@ -30,6 +30,7 @@ type ConfigurationFacts = { type AdopterHarness = { adopt: (specialistId?: string) => Promise + buildSession: ReturnType commit: ReturnType commitClaudeReplay: ReturnType configure: ReturnType @@ -67,17 +68,18 @@ const createHarness = ( sessionId: 'fresh-provider-session', dispose: vi.fn() } as unknown as ActiveSession + const buildSession = vi.fn(() => { + order.push('session/new prepared') + return { + start: vi.fn(async () => { + order.push('session/new') + return providerSession + }) + } + }) const connection = { agent: { - buildSession: vi.fn(() => { - order.push('session/new prepared') - return { - start: vi.fn(async () => { - order.push('session/new') - return providerSession - }) - } - }) + buildSession } } as unknown as ClientConnection const baseBackend: AcpBackendGenerationView = options.initialBackend ?? { @@ -174,6 +176,7 @@ const createHarness = ( }) return { adopt, + buildSession, commit, commitClaudeReplay, configure, @@ -192,6 +195,35 @@ const createHarness = ( } describe('AcpProviderSessionAdopter', () => { + it('authorizes the pinned Skill Runtime root on an adopted provider Session', async () => { + const harness = createHarness({ + initialBackend: { + framework: claudeCodeFramework, + backendId: 'claude-code', + session: { modelRequired: false }, + prompt: { systemPromptAppends: [] }, + context: { supportsImageInput: false }, + skillRuntime: { + projectionRoot: '/runtime/projection', + discoveryRoot: '/runtime/projection/skills', + descriptors: [], + environment: {} + }, + adapter: { + additionalDirectories: ['/runtime/projection'], + nativeMcpEnabled: true, + bridgeMcpAliasesEnabled: false + } + } + }) + + await harness.adopt() + + expect(harness.buildSession).toHaveBeenCalledWith( + expect.objectContaining({ additionalDirectories: ['/runtime/projection'] }) + ) + }) + it('preserves the runtime capability policy while adopting a fresh provider Session', async () => { const harness = createHarness({ capabilityPolicy: SIDE_CHAT_SESSION_CAPABILITY_POLICY }) diff --git a/src/main/acp/provider-session-adopter.ts b/src/main/acp/provider-session-adopter.ts index cdd8f7d97..acd73397f 100644 --- a/src/main/acp/provider-session-adopter.ts +++ b/src/main/acp/provider-session-adopter.ts @@ -109,10 +109,18 @@ export class AcpProviderSessionAdopter { ].filter((append): append is string => Boolean(append)), persistentSystemPrompt: startupBackend.prompt.persistentSystemPrompt, sessionOptions: startupBackend.session.options, + skillRuntime: startupBackend.skillRuntime, specialistSkills }) provisionalSession = await request.connection.agent - .buildSession({ cwd: request.cwd, mcpServers: capability.mcpServers, ...setup.metaArg }) + .buildSession({ + cwd: request.cwd, + mcpServers: capability.mcpServers, + ...(startupBackend.adapter.additionalDirectories?.length + ? { additionalDirectories: [...startupBackend.adapter.additionalDirectories] } + : {}), + ...setup.metaArg + }) .start() adoptedProviderSessionId = provisionalSession.sessionId diff --git a/src/main/acp/provider-session-creator.test.ts b/src/main/acp/provider-session-creator.test.ts index 4789853c2..0c1b7a2d2 100644 --- a/src/main/acp/provider-session-creator.test.ts +++ b/src/main/acp/provider-session-creator.test.ts @@ -9,6 +9,7 @@ import { opencodeFramework, type AgentFramework } from '../agent-framework' +import type { SkillRuntimeView } from '../agent-framework/types' import { SKILL_IMPORT_SYSTEM_PROMPT_APPEND } from '../skills/mcp-server' import type { AcpBackendGenerationView } from './backend-generation-owner' import { AcpProviderSessionCreator } from './provider-session-creator' @@ -55,6 +56,7 @@ const createHarness = (options: { backendId?: string projectAgentContext?: string specialistIdentity?: { append: string; prefix: string } + skillRuntime?: SkillRuntimeView }): CreatorHarness => { const order = options.order ?? [] const sessionSetupAppends: string[][] = [] @@ -92,7 +94,11 @@ const createHarness = (options: { session: { modelRequired: false }, prompt: { systemPromptAppends: [] }, context: { supportsImageInput: false }, + ...(options.skillRuntime ? { skillRuntime: options.skillRuntime } : {}), adapter: { + ...(options.skillRuntime + ? { additionalDirectories: [options.skillRuntime.projectionRoot] } + : {}), nativeMcpEnabled: options.nativeMcpEnabled ?? true, bridgeMcpAliasesEnabled: options.bridgeMcpAliasesEnabled ?? false } @@ -181,6 +187,22 @@ const createHarness = (options: { } describe('AcpProviderSessionCreator', () => { + it('authorizes the pinned Skill Runtime root on a new Codex provider Session', async () => { + const skillRuntime = { + projectionRoot: '/runtime/projection', + discoveryRoot: '/runtime/projection/skills', + descriptors: [], + environment: {} + } + const harness = createHarness({ order: [], framework: codexFramework, skillRuntime }) + + await harness.creator.create({ cwd: '/workspace' }) + + expect(harness.buildSession).toHaveBeenCalledWith( + expect.objectContaining({ additionalDirectories: ['/runtime/projection'] }) + ) + }) + it('publishes the provider-returned id as the fresh application Session id', async () => { const harness = createHarness({ order: [] }) diff --git a/src/main/acp/provider-session-creator.ts b/src/main/acp/provider-session-creator.ts index 92d7bbbe5..44d5d3150 100644 --- a/src/main/acp/provider-session-creator.ts +++ b/src/main/acp/provider-session-creator.ts @@ -100,11 +100,19 @@ export class AcpProviderSessionCreator { ), persistentSystemPrompt: startupBackend.prompt.persistentSystemPrompt, sessionOptions: startupBackend.session.options, + skillRuntime: startupBackend.skillRuntime, specialistSkills: specialist.skills }) log.info('createSession: buildSession', this.deps.diagnosticContext()) const session = await connection.agent - .buildSession({ cwd, mcpServers: capability.mcpServers, ...setup.metaArg }) + .buildSession({ + cwd, + mcpServers: capability.mcpServers, + ...(startupBackend.adapter.additionalDirectories?.length + ? { additionalDirectories: [...startupBackend.adapter.additionalDirectories] } + : {}), + ...setup.metaArg + }) .start() provisionalSession = session diff --git a/src/main/acp/provider-session-resumer.test.ts b/src/main/acp/provider-session-resumer.test.ts index dc204e89c..e5c63a817 100644 --- a/src/main/acp/provider-session-resumer.test.ts +++ b/src/main/acp/provider-session-resumer.test.ts @@ -311,6 +311,31 @@ const createHarness = (options: HarnessOptions = {}): ResumerHarness => { } describe('AcpProviderSessionResumer', () => { + it('reauthorizes the pinned Skill Runtime root on provider Session resume', async () => { + const harness = createHarness({ + initialBackend: { + ...backend, + skillRuntime: { + projectionRoot: '/runtime/projection', + discoveryRoot: '/runtime/projection/skills', + descriptors: [], + environment: {} + }, + adapter: { + ...backend.adapter, + additionalDirectories: ['/runtime/projection'] + } + } + }) + + await harness.resume() + + expect(harness.request).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ additionalDirectories: ['/runtime/projection'] }) + ) + }) + it('preserves the runtime capability policy on compatible provider resume', async () => { const harness = createHarness({ capabilityPolicy: SIDE_CHAT_SESSION_CAPABILITY_POLICY }) diff --git a/src/main/acp/provider-session-resumer.ts b/src/main/acp/provider-session-resumer.ts index 56144078a..b1ea6e42e 100644 --- a/src/main/acp/provider-session-resumer.ts +++ b/src/main/acp/provider-session-resumer.ts @@ -253,6 +253,7 @@ export class AcpProviderSessionResumer { backendSystemPromptAppends: backend.prompt.systemPromptAppends, extraSystemPromptAppends: projectContextAppend ? [projectContextAppend] : [], sessionOptions: backend.session.options, + skillRuntime: backend.skillRuntime, specialistSkills: await this.resolveSpecialistSkills(specialistId) }) @@ -262,6 +263,9 @@ export class AcpProviderSessionResumer { sessionId: providerSessionId, cwd, mcpServers: capability.mcpServers, + ...(backend.adapter.additionalDirectories?.length + ? { additionalDirectories: [...backend.adapter.additionalDirectories] } + : {}), ...setup.metaArg }) } catch (error) { diff --git a/src/main/acp/reviewer-session-owner.test.ts b/src/main/acp/reviewer-session-owner.test.ts new file mode 100644 index 000000000..acb69a9f8 --- /dev/null +++ b/src/main/acp/reviewer-session-owner.test.ts @@ -0,0 +1,67 @@ +import type { ActiveSession, ClientConnection, McpServer } from '@agentclientprotocol/sdk' +import { describe, expect, it, vi } from 'vitest' + +import { claudeCodeFramework } from '../agent-framework' +import { ReviewerSessionOwner } from './reviewer-session-owner' + +const reviewerServer: McpServer = { + type: 'http', + name: 'open-science-reviewer', + url: 'http://127.0.0.1:1234/mcp', + headers: [] +} + +describe('ReviewerSessionOwner Skill Runtime setup', () => { + it('passes the frozen runtime root through reviewer native and framework setup', async () => { + const skillRuntime = { + projectionRoot: '/runtime/projection', + discoveryRoot: '/runtime/projection/skills', + descriptors: [], + environment: { XDG_CACHE_HOME: '/runtime/cache' } + } + const session = { + sessionId: 'reviewer-session', + dispose: vi.fn() + } as unknown as ActiveSession + const buildSession = vi.fn(() => ({ start: vi.fn(async () => session) })) + const connection = { + agent: { buildSession, request: vi.fn() } + } as unknown as ClientConnection + const owner = new ReviewerSessionOwner({ + addStartupBlocker: vi.fn(), + assertCurrentConnection: vi.fn(), + clearPermissionCorrelations: vi.fn(), + currentSessionSetup: () => ({ + framework: claudeCodeFramework, + sessionOptions: undefined, + skillRuntime, + additionalDirectories: [skillRuntime.projectionRoot] + }), + currentStartupGeneration: () => 1, + isPrimarySessionIdClaimed: () => false, + onActiveSessionReleased: vi.fn(), + registerBridgeSession: vi.fn(), + removeStartupBlocker: vi.fn(), + unregisterBridgeSession: () => true + }) + + const result = await owner.create( + { cwd: '/workspace', mcpServers: [reviewerServer] }, + { ensureConnected: async () => connection } + ) + + expect(buildSession).toHaveBeenCalledWith( + expect.objectContaining({ + additionalDirectories: ['/runtime/projection'], + _meta: expect.objectContaining({ + claudeCode: expect.objectContaining({ + options: expect.objectContaining({ + additionalDirectories: ['/runtime/projection'] + }) + }) + }) + }) + ) + owner.dispose(result.session) + }) +}) diff --git a/src/main/acp/reviewer-session-owner.ts b/src/main/acp/reviewer-session-owner.ts index 02a0f65cd..d7823de88 100644 --- a/src/main/acp/reviewer-session-owner.ts +++ b/src/main/acp/reviewer-session-owner.ts @@ -13,7 +13,7 @@ import { join } from 'node:path' import { REVIEWER_MCP_SERVER_NAME, REVIEWER_MCP_TOOLS } from '../../shared/reviewer' import type { AgentFrameworkId } from '../../shared/settings' -import type { AgentFramework } from '../agent-framework' +import type { AgentFramework, SkillRuntimeView } from '../agent-framework' import { createLogger, diagnosticErrorFields } from '../logger' import { canonicalAppMcpServerName } from '../agent-framework/app-mcp-names' import { extractProviderToolName } from './runtime-events' @@ -121,6 +121,8 @@ export type ReviewerSessionOwnerDependencies = { currentSessionSetup: () => { framework: AgentFramework sessionOptions: Record | undefined + skillRuntime?: SkillRuntimeView + additionalDirectories?: readonly string[] } currentStartupGeneration: () => number isPrimarySessionIdClaimed: (sessionId: string) => boolean @@ -151,12 +153,14 @@ export class ReviewerSessionOwner { const mcpServerNames = this.validateRequest(request) const connection = await capability.ensureConnected(request.cwd) this.dependencies.assertCurrentConnection(connection) - const { framework, sessionOptions } = this.dependencies.currentSessionSetup() + const { framework, sessionOptions, skillRuntime, additionalDirectories } = + this.dependencies.currentSessionSetup() const startupGeneration = this.dependencies.currentStartupGeneration() const reviewerCwd = await mkdtemp(join(tmpdir(), 'open-science-reviewer-')) const setup = framework.buildSessionSetup({ systemPromptAppends: request.systemPromptAppend ? [request.systemPromptAppend] : [], - sessionOptions + sessionOptions, + ...(skillRuntime ? { skillRuntime } : {}) }) const reviewerMeta: Record = { ...(setup.meta ?? {}), @@ -184,6 +188,9 @@ export class ReviewerSessionOwner { .buildSession({ cwd: reviewerCwd, mcpServers: request.mcpServers, + ...(additionalDirectories?.length + ? { additionalDirectories: [...additionalDirectories] } + : {}), _meta: reviewerMeta }) .start() diff --git a/src/main/acp/runtime-session-composition.ts b/src/main/acp/runtime-session-composition.ts index 551b0af60..1f043c73f 100644 --- a/src/main/acp/runtime-session-composition.ts +++ b/src/main/acp/runtime-session-composition.ts @@ -246,7 +246,9 @@ const composeAcpRuntimeSessionOwners = (options: AcpRuntimeOptions, base: AcpRun permissionContext.clearCorrelationsForSession(sessionId), currentSessionSetup: () => ({ framework: base.backendGeneration.current.framework, - sessionOptions: base.backendGeneration.current.session.options + sessionOptions: base.backendGeneration.current.session.options, + skillRuntime: base.backendGeneration.current.skillRuntime, + additionalDirectories: base.backendGeneration.current.adapter.additionalDirectories }), currentStartupGeneration: () => sessionRegistry.startupGeneration, isPrimarySessionIdClaimed: (sessionId) => sessionRegistry.isIdentityClaimed(sessionId), diff --git a/src/main/acp/runtime.test.ts b/src/main/acp/runtime.test.ts index 684d58064..8e324ee94 100644 --- a/src/main/acp/runtime.test.ts +++ b/src/main/acp/runtime.test.ts @@ -9442,7 +9442,7 @@ describe('ACP runtime session management', () => { const fakeAgent = startFakeAgent(process, ['remote-session-1'], { supportsResume: true }) const sessionOptions = { settings: '/app/claude/settings.json', - plugins: [{ type: 'local', path: '/app/claude', skipMcpDiscovery: true }] + plugins: [{ type: 'local', path: '/app/claude' }] } const runtime = new AcpRuntime({ appVersion: '0.1.0', @@ -15404,7 +15404,8 @@ describe('ACP runtime session management', () => { managedSettings: { disableAgentView: true, disableWorkflows: true, - workflowKeywordTriggerEnabled: false + workflowKeywordTriggerEnabled: false, + strictPluginOnlyCustomization: ['skills'] }, env: { CLAUDE_CODE_DISABLE_AGENT_VIEW: '1', diff --git a/src/main/acp/session-presentation-policy.test.ts b/src/main/acp/session-presentation-policy.test.ts index 949e86b4a..829f2dd4c 100644 --- a/src/main/acp/session-presentation-policy.test.ts +++ b/src/main/acp/session-presentation-policy.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { claudeCodeFramework } from '../agent-framework/claude-code' import { codexFramework } from '../agent-framework/codex' @@ -7,6 +7,13 @@ import { NOTEBOOK_SYSTEM_PROMPT_APPEND } from '../notebook/mcp-server' import { SKILL_IMPORT_SYSTEM_PROMPT_APPEND } from '../skills/mcp-server' import { AcpSessionPresentationPolicy } from './session-presentation-policy' +const skillRuntime = { + projectionRoot: '/runtime/projection', + discoveryRoot: '/runtime/projection/skills', + descriptors: [], + environment: { XDG_CACHE_HOME: '/runtime/cache' } +} as const + const TURN_CONTINUITY_APPEND = [ '', 'Do not describe a tool-backed action as future work and then end the turn. If you say you will download, install, run, edit, analyze, or otherwise perform an action that needs a tool, issue the corresponding tool call in this same turn.', @@ -38,6 +45,18 @@ const ARTIFACT_FILE_APPEND = [ describe('ACP Session presentation policy', () => { const policy = new AcpSessionPresentationPolicy() + it('passes the same Skill Runtime view through Session and turn framework setup', () => { + const buildSessionSetup = vi.fn(() => ({})) + const framework = { id: 'codex' as const, buildSessionSetup } + const tooling = { artifacts: false, notebook: false, skillImport: false } + + policy.buildSessionSetup({ framework, tooling, skillRuntime }) + policy.buildTurnPromptPrefix({ framework, tooling, skillRuntime }) + + expect(buildSessionSetup).toHaveBeenNthCalledWith(1, expect.objectContaining({ skillRuntime })) + expect(buildSessionSetup).toHaveBeenNthCalledWith(2, expect.objectContaining({ skillRuntime })) + }) + it('returns the exact application appends in stable order when every tool is available', () => { const appends = policy.applicationSystemPromptAppends({ artifacts: true, @@ -102,7 +121,8 @@ describe('ACP Session presentation policy', () => { managedSettings: { disableAgentView: true, disableWorkflows: true, - workflowKeywordTriggerEnabled: false + workflowKeywordTriggerEnabled: false, + strictPluginOnlyCustomization: ['skills'] }, env: { CLAUDE_CODE_DISABLE_AGENT_VIEW: '1', @@ -119,6 +139,12 @@ describe('ACP Session presentation policy', () => { expect(Object.isFrozen(presentation)).toBe(true) expect(Object.isFrozen(presentation.metaArg)).toBe(true) expect(Object.isFrozen(presentation.metaArg._meta)).toBe(true) + const claudeOptions = ( + presentation.metaArg._meta?.claudeCode as { options: Record } + ).options + const managedSettings = claudeOptions.managedSettings as Record + expect(Object.isFrozen(managedSettings)).toBe(true) + expect(Object.isFrozen(managedSettings.strictPluginOnlyCustomization)).toBe(true) }) it('excludes stable appends installed persistently but preserves one-off Session appends', () => { diff --git a/src/main/acp/session-presentation-policy.ts b/src/main/acp/session-presentation-policy.ts index 4c8bf2f2f..7f039e643 100644 --- a/src/main/acp/session-presentation-policy.ts +++ b/src/main/acp/session-presentation-policy.ts @@ -1,6 +1,6 @@ import { NOTEBOOK_SYSTEM_PROMPT_APPEND } from '../notebook/mcp-server' import { SKILL_IMPORT_SYSTEM_PROMPT_APPEND } from '../skills/mcp-server' -import type { AgentFramework, SessionSetup } from '../agent-framework/types' +import type { AgentFramework, SessionSetup, SkillRuntimeView } from '../agent-framework/types' import type { EffectiveSpecialistSkills, SpecialistProfileView } from '../../shared/specialist' import type { AcpPromptRequest } from '../../shared/acp' @@ -17,6 +17,7 @@ type AcpSessionSetupPresentationInput = Readonly<{ extraSystemPromptAppends?: readonly string[] persistentSystemPrompt?: string sessionOptions?: Record + skillRuntime?: SkillRuntimeView specialistSkills?: EffectiveSpecialistSkills }> @@ -117,6 +118,7 @@ class AcpSessionPresentationPolicy { const setup = input.framework.buildSessionSetup({ systemPromptAppends: this.systemPromptAppends(input), sessionOptions: input.sessionOptions, + ...(input.skillRuntime ? { skillRuntime: input.skillRuntime } : {}), ...(skillWhitelist !== undefined ? { skillWhitelist } : {}) }) @@ -170,7 +172,8 @@ class AcpSessionPresentationPolicy { ...(specialistSkillGuidance ? [specialistSkillGuidance] : []), ...(input.turnPromptReminders ?? []) ], - sessionOptions: input.sessionOptions + sessionOptions: input.sessionOptions, + ...(input.skillRuntime ? { skillRuntime: input.skillRuntime } : {}) }) const turnPromptPrefix = diff --git a/src/main/acp/turn-skill-owner.test.ts b/src/main/acp/turn-skill-owner.test.ts index b533be4ce..1dab2be36 100644 --- a/src/main/acp/turn-skill-owner.test.ts +++ b/src/main/acp/turn-skill-owner.test.ts @@ -246,6 +246,53 @@ describe('AcpTurnSkillOwner', () => { }) }) + it('uses the current runtime descriptors as the authoritative explicit Codex Skill input', async () => { + const descriptorsForIds = vi.fn(async () => [ + { name: 'Legacy', path: '/codex/skills/os-personal-research/SKILL.md' } + ]) + const owner = new AcpTurnSkillOwner({ + skills: { + needForceLoad: async () => [], + namesForIds: async () => [], + descriptorsForIds + }, + requestSkillsReload: vi.fn() + }) + const handle = await owner.authorize({ + selectedSkillIds: ['personal-research', 'second-skill', 'personal-research'] + }) + const runtimeSkill = { + id: 'personal-research', + name: 'Research', + description: 'Current generation', + path: '/runtime/catalogs/current/skills/os-personal-research/SKILL.md' + } + const secondRuntimeSkill = { + id: 'second-skill', + name: 'Second', + description: 'Second current generation Skill', + path: '/runtime/catalogs/current/skills/os-second-skill/SKILL.md' + } + + const prepared = await handle.prepareProvider({ + frameworkId: codexFramework.id, + selectionText: 'find papers', + promptText: 'find papers', + codex: { + home: '/codex', + runtimeDescriptors: [secondRuntimeSkill, runtimeSkill], + bridgeSkillsAvailable: true, + selectSkills: vi.fn() + } + }) + + expect(descriptorsForIds).not.toHaveBeenCalled() + expect(prepared.codexSkillInputs).toEqual([ + { name: 'Research', path: runtimeSkill.path }, + { name: 'Second', path: secondRuntimeSkill.path } + ]) + }) + it('scopes Codex automatic selection and rejects stale selector results', async () => { const oldSkill = { name: 'mcp-old', @@ -291,6 +338,74 @@ describe('AcpTurnSkillOwner', () => { expect(prepared.codexSkillInputs).toEqual([currentSkill]) }) + it('builds and Specialist-filters the automatic Codex catalog from the current runtime', async () => { + const catalogForCodexHome = vi.fn(async () => [ + { name: 'legacy', description: 'Legacy', path: '/codex/skills/legacy/SKILL.md' } + ]) + const runtimeSkills = [ + { + id: 'allowed-id', + name: 'allowed-name', + description: 'Allowed runtime Skill', + path: '/runtime/current/skills/os-allowed-id/SKILL.md' + }, + { + id: 'blocked-id', + name: 'blocked-name', + description: 'Blocked runtime Skill', + path: '/runtime/current/skills/os-blocked-id/SKILL.md' + } + ] + const selectSkills = vi.fn(async (_text, catalog) => catalog) + const owner = new AcpTurnSkillOwner({ + resolveSpecialistSkills: async () => ({ + kind: 'specialist', + skillIds: ['allowed-id'], + frameworkNames: ['allowed-name'], + missingSkillIds: [] + }), + skills: { + needForceLoad: async () => [], + namesForIds: async () => [], + catalogForCodexHome + }, + requestSkillsReload: vi.fn() + }) + const handle = await owner.authorize({ specialistId: 'specialist-1' }) + + const prepared = await handle.prepareProvider({ + frameworkId: codexFramework.id, + selectionText: 'use a Skill', + promptText: 'use a Skill', + codex: { + home: '/codex', + runtimeDescriptors: runtimeSkills, + bridgeSkillsAvailable: true, + selectSkills + } + }) + + expect(catalogForCodexHome).not.toHaveBeenCalled() + expect(selectSkills).toHaveBeenCalledWith( + 'use a Skill', + [ + { + name: 'allowed-name', + description: 'Allowed runtime Skill', + path: runtimeSkills[0]!.path + } + ], + undefined + ) + expect(prepared.codexSkillInputs).toEqual([ + { + name: 'allowed-name', + description: 'Allowed runtime Skill', + path: runtimeSkills[0]!.path + } + ]) + }) + it('passes cancellation to the Codex selector and fails open when it aborts', async () => { const controller = new AbortController() controller.abort() diff --git a/src/main/acp/turn-skill-owner.ts b/src/main/acp/turn-skill-owner.ts index b9c605f80..b721dc553 100644 --- a/src/main/acp/turn-skill-owner.ts +++ b/src/main/acp/turn-skill-owner.ts @@ -1,6 +1,6 @@ import type { AgentFrameworkId } from '../../shared/settings' import type { EffectiveSpecialistSkills } from '../../shared/specialist' -import type { ResolvedAgentBackend } from '../agent-framework' +import type { ResolvedAgentBackend, SkillRuntimeDescriptor } from '../agent-framework' import { createLogger } from '../logger' import type { ResponsesBridgeSkillCandidate, @@ -25,6 +25,7 @@ type ProviderPreparationInput = Readonly<{ promptText: string codex?: Readonly<{ home?: string + runtimeDescriptors?: readonly SkillRuntimeDescriptor[] bridgeSkillsAvailable: boolean selectSkills: NonNullable['selectSkills'] signal?: AbortSignal @@ -141,23 +142,50 @@ class AcpTurnSkillOwner { input: ProviderPreparationInput ): Promise { if (input.frameworkId !== 'codex') return [] + const runtimeDescriptors = input.codex?.runtimeDescriptors if (state.selectedSkillIds.length > 0) { + if (runtimeDescriptors) { + const descriptorsById = new Map(runtimeDescriptors.map((skill) => [skill.id, skill])) + const seen = new Set() + return state.selectedSkillIds.flatMap((id) => { + if (seen.has(id)) return [] + seen.add(id) + const skill = descriptorsById.get(id) + return skill ? [{ name: skill.name, path: skill.path }] : [] + }) + } return ( this.options.skills?.descriptorsForIds?.([...state.selectedSkillIds], input.codex?.home) ?? [] ) } const codex = input.codex - if (!codex?.bridgeSkillsAvailable || !this.options.skills?.catalogForCodexHome) return [] + if (!codex?.bridgeSkillsAvailable) return [] let catalog: ResponsesBridgeSkillCandidate[] - try { - catalog = await this.options.skills.catalogForCodexHome(codex.home) - } catch { - return this.selectionFailed('catalog-error') + if (runtimeDescriptors) { + catalog = runtimeDescriptors.map(({ name, description, path }) => ({ + name, + description, + path + })) + } else { + if (!this.options.skills?.catalogForCodexHome) return [] + try { + catalog = await this.options.skills.catalogForCodexHome(codex.home) + } catch { + return this.selectionFailed('catalog-error') + } } if (state.scope?.kind === 'specialist') { + const allowedIds = new Set(state.scope.skillIds) const allowed = new Set(state.scope.frameworkNames) - catalog = catalog.filter((skill) => allowed.has(skill.name)) + catalog = catalog.filter((skill) => { + if (allowed.has(skill.name)) return true + const descriptor = runtimeDescriptors?.find( + (candidate) => candidate.name === skill.name && candidate.path === skill.path + ) + return descriptor ? allowedIds.has(descriptor.id) : false + }) } if (catalog.length === 0) return [] try { diff --git a/src/main/agent-framework/claude-code.test.ts b/src/main/agent-framework/claude-code.test.ts index fe87f7887..c73fcdc9f 100644 --- a/src/main/agent-framework/claude-code.test.ts +++ b/src/main/agent-framework/claude-code.test.ts @@ -4,8 +4,92 @@ import { NOTEBOOK_SYSTEM_PROMPT_APPEND } from '../notebook/mcp-server' import { claudeCodeFramework } from './claude-code' import { codexFramework } from './codex' import { opencodeFramework } from './opencode' +import type { SkillRuntimeView } from './types' + +const skillRuntime: SkillRuntimeView = { + projectionRoot: '/runtime/skills-projection', + discoveryRoot: '/runtime/skills-projection/skills', + descriptors: [ + { + id: 'research', + name: 'Research', + description: 'Research primary sources.', + path: '/runtime/skills-projection/skills/os-research/SKILL.md' + } + ], + environment: { + XDG_CACHE_HOME: '/runtime/cache' + } +} describe('claudeCodeFramework', () => { + it('adds the skill runtime through Claude native discovery without replacing legacy options', () => { + const modelConfig = claudeCodeFramework.prepareModelConfig( + { type: 'custom', baseUrl: 'https://gw.example/v1', model: 'm', key: 'k' }, + { + storageRoot: '/data', + executablePath: '/bin/claude', + skillRuntime + } + ) + + expect(modelConfig.skillRuntime).toBe(skillRuntime) + expect(modelConfig.env).toMatchObject({ + ...skillRuntime.environment, + OPEN_SCIENCE_SKILL_RUNTIME_ROOT: skillRuntime.discoveryRoot, + OPEN_SCIENCE_SKILL_DISCOVERY_ROOT: skillRuntime.discoveryRoot, + OPEN_SCIENCE_SKILL_PROJECTION_ROOT: skillRuntime.projectionRoot + }) + + const setup = claudeCodeFramework.buildSessionSetup({ + systemPromptAppends: [], + skillRuntime: modelConfig.skillRuntime, + skillWhitelist: ['legacy-skill'], + sessionOptions: { + settings: '/legacy/settings.json', + plugins: [{ type: 'local', path: '/legacy/plugin' }], + additionalDirectories: ['/legacy/read'], + env: { LEGACY_SESSION_ENV: 'kept', XDG_CACHE_HOME: '/legacy/cache' }, + sandbox: { + enabled: true, + network: { allowedDomains: ['example.com'] }, + filesystem: { + allowRead: ['/legacy/read'], + denyWrite: ['/legacy/write'] + } + } + } + }) + const options = (setup.meta?.claudeCode as { options: Record }).options + + expect(options.settings).toBe('/legacy/settings.json') + expect(options.managedSettings).toMatchObject({ + strictPluginOnlyCustomization: ['skills'] + }) + expect(options.skills).toEqual(['legacy-skill']) + expect(options.plugins).toEqual([ + { type: 'local', path: '/legacy/plugin' }, + { type: 'local', path: skillRuntime.projectionRoot } + ]) + expect(JSON.stringify(options.plugins)).not.toContain('skipMcpDiscovery') + expect(options.additionalDirectories).toEqual(['/legacy/read', skillRuntime.projectionRoot]) + expect(options.env).toMatchObject({ + LEGACY_SESSION_ENV: 'kept', + ...skillRuntime.environment, + OPEN_SCIENCE_SKILL_RUNTIME_ROOT: skillRuntime.discoveryRoot, + OPEN_SCIENCE_SKILL_DISCOVERY_ROOT: skillRuntime.discoveryRoot, + OPEN_SCIENCE_SKILL_PROJECTION_ROOT: skillRuntime.projectionRoot + }) + expect(options.sandbox).toEqual({ + enabled: true, + network: { allowedDomains: ['example.com'] }, + filesystem: { + allowRead: ['/legacy/read', skillRuntime.projectionRoot], + denyWrite: ['/legacy/write', skillRuntime.projectionRoot] + } + }) + }) + it('disables every Claude-native delegation path without removing ordinary built-in tools', () => { const setup = claudeCodeFramework.buildSessionSetup({ systemPromptAppends: [] }) @@ -17,7 +101,8 @@ describe('claudeCodeFramework', () => { managedSettings: { disableAgentView: true, disableWorkflows: true, - workflowKeywordTriggerEnabled: false + workflowKeywordTriggerEnabled: false, + strictPluginOnlyCustomization: ['skills'] }, env: { CLAUDE_CODE_DISABLE_AGENT_VIEW: '1', @@ -75,7 +160,7 @@ describe('claudeCodeFramework', () => { it('injects resolved settings and local plugins into Claude session options', () => { const sessionOptions = { settings: '/app/claude/settings.json', - plugins: [{ type: 'local', path: '/app/claude', skipMcpDiscovery: true }] + plugins: [{ type: 'local', path: '/app/claude' }] } const setup = claudeCodeFramework.buildSessionSetup({ diff --git a/src/main/agent-framework/claude-code.ts b/src/main/agent-framework/claude-code.ts index b1e97071d..20c9a1bb4 100644 --- a/src/main/agent-framework/claude-code.ts +++ b/src/main/agent-framework/claude-code.ts @@ -18,6 +18,7 @@ import type { } from './types' import { isProductionDelegatedWorkFramework } from '../delegation/production-readiness' import { renderAppMcpToolReferences } from './app-mcp-names' +import { rebaseSkillRuntimeEnvironment, skillRuntimeEnvironment } from './skill-runtime-binding' // Select Claude Code's complete built-in tool set explicitly instead of relying on // claude-agent-acp's current fallback. This keeps WebFetch/WebSearch available if the adapter's @@ -44,6 +45,8 @@ const recordValue = (value: unknown): Record => const stringArrayValue = (value: unknown): string[] => Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : [] +const arrayValue = (value: unknown): unknown[] => (Array.isArray(value) ? value : []) + // Claude Code adapter. A faithful extraction of behavior currently inline in AcpRuntime / // agent-process / provider-env — moving the runtime onto AgentFramework must not change it. export const claudeCodeFramework: AgentFramework = { @@ -76,17 +79,24 @@ export const claudeCodeFramework: AgentFramework = { prepareModelConfig(provider: ResolvedProvider, ctx: ModelConfigContext): AgentModelConfig { // Anthropic-shaped env (ANTHROPIC_* + CLAUDE_CONFIG_DIR/CLAUDE_CODE_EXECUTABLE). return { - env: buildProviderEnv(provider, { - storageRoot: ctx.storageRoot, - claudeExecutablePath: ctx.executablePath - }) + env: { + ...skillRuntimeEnvironment(ctx.skillRuntime), + ...buildProviderEnv(provider, { + storageRoot: ctx.storageRoot, + claudeExecutablePath: ctx.executablePath + }) + }, + ...(ctx.skillRuntime ? { skillRuntime: ctx.skillRuntime } : {}) } }, + rebaseSkillRuntime: rebaseSkillRuntimeEnvironment, + buildSessionSetup(ctx: SessionSetupContext): SessionSetup { // settingSources:['user'] excludes workspace settings that could override the active provider. // Shared mode adds app-owned settings/plugins at the SDK flag layer via sessionOptions. const sessionOptions = ctx.sessionOptions ?? {} + const skillRuntime = ctx.skillRuntime const disallowedTools = Object.freeze([ ...new Set([ ...stringArrayValue(sessionOptions.disallowedTools), @@ -97,13 +107,72 @@ export const claudeCodeFramework: AgentFramework = { ...recordValue(sessionOptions.managedSettings), disableAgentView: true, disableWorkflows: true, - workflowKeywordTriggerEnabled: false + workflowKeywordTriggerEnabled: false, + // Claude's managed-settings schema keeps plugin customizations available while closing the + // stable CLAUDE_CONFIG_DIR and project Skill directories. The runtime projection is supplied + // as the only local plugin below, so rollback-owned Skills cannot join native discovery. + strictPluginOnlyCustomization: Object.freeze(['skills'] as const) }) const env = Object.freeze({ ...recordValue(sessionOptions.env), + // Runtime/cache ownership is an application invariant, not a caller preference. Keep these + // values authoritative even when a legacy session option happens to use the same variable. + ...skillRuntimeEnvironment(skillRuntime), CLAUDE_CODE_DISABLE_AGENT_VIEW: '1', CLAUDE_CODE_DISABLE_WORKFLOWS: '1' }) + const plugins = skillRuntime + ? Object.freeze([ + ...arrayValue(sessionOptions.plugins), + ...(arrayValue(sessionOptions.plugins).some( + (plugin) => + recordValue(plugin).type === 'local' && + recordValue(plugin).path === skillRuntime.projectionRoot + ) + ? [] + : [ + { + type: 'local', + // The runtime plugin contains no MCP manifest. Omitting skipMcpDiscovery keeps + // this compatible with Claude Code releases that support --plugin-dir but not + // the newer SDK-generated --plugin-dir-no-mcp flag. + path: skillRuntime.projectionRoot + } + ]) + ]) + : sessionOptions.plugins + const additionalDirectories = skillRuntime + ? Object.freeze([ + ...new Set([ + ...stringArrayValue(sessionOptions.additionalDirectories), + skillRuntime.projectionRoot + ]) + ]) + : sessionOptions.additionalDirectories + const sandbox = skillRuntime + ? (() => { + const baseSandbox = recordValue(sessionOptions.sandbox) + const baseFilesystem = recordValue(baseSandbox.filesystem) + return Object.freeze({ + ...baseSandbox, + filesystem: Object.freeze({ + ...baseFilesystem, + allowRead: Object.freeze([ + ...new Set([ + ...stringArrayValue(baseFilesystem.allowRead), + skillRuntime.projectionRoot + ]) + ]), + denyWrite: Object.freeze([ + ...new Set([ + ...stringArrayValue(baseFilesystem.denyWrite), + skillRuntime.projectionRoot + ]) + ]) + }) + }) + })() + : sessionOptions.sandbox const meta: Record = { claudeCode: { // ACP's usage total omits the latest model-step split and Claude SDK's agentic turn count. @@ -116,6 +185,9 @@ export const claudeCodeFramework: AgentFramework = { disallowedTools, managedSettings, env, + ...(plugins !== undefined ? { plugins } : {}), + ...(additionalDirectories !== undefined ? { additionalDirectories } : {}), + ...(sandbox !== undefined ? { sandbox } : {}), ...(ctx.skillWhitelist !== undefined ? { skills: ctx.skillWhitelist } : {}) } } diff --git a/src/main/agent-framework/codex.test.ts b/src/main/agent-framework/codex.test.ts index 5bf953308..d7510713b 100644 --- a/src/main/agent-framework/codex.test.ts +++ b/src/main/agent-framework/codex.test.ts @@ -15,6 +15,48 @@ import { CODEX_VERSION } from '../settings/managed-codex' const fakeChild = {} as ChildProcessWithoutNullStreams describe('codexFramework', () => { + it.each([ + { + name: 'subscription', + provider: { type: 'codex-isolated', model: 'gpt-5.4' } as const, + legacyHome: join('/data', 'codex-subscription') + }, + { + name: 'custom', + provider: { + type: 'custom', + apiEndpoints: ['responses'] as const, + baseUrl: 'https://gateway.example/v1', + model: 'gpt-coding' + } as const, + legacyHome: join('/data', 'codex') + } + ])( + 'adds the Skill Runtime environment without replacing the $name Codex profile', + ({ provider, legacyHome }) => { + const framework = createCodexFramework() + const config = framework.prepareModelConfig(provider, { + storageRoot: '/data', + executablePath: '/runtime/codex-acp', + skillRuntime: { + projectionRoot: '/runtime/projections/g-1', + discoveryRoot: '/runtime/projections/g-1/skills', + environment: { XDG_CACHE_HOME: '/runtime/cache/b-1' }, + descriptors: [] + } + }) + + expect(config.env).toMatchObject({ + HOME: legacyHome, + CODEX_HOME: legacyHome, + XDG_CACHE_HOME: '/runtime/cache/b-1', + OPEN_SCIENCE_SKILL_RUNTIME_ROOT: '/runtime/projections/g-1/skills', + OPEN_SCIENCE_SKILL_DISCOVERY_ROOT: '/runtime/projections/g-1/skills', + OPEN_SCIENCE_SKILL_PROJECTION_ROOT: '/runtime/projections/g-1' + }) + } + ) + it('disables every Codex native multi-agent implementation in every spawned profile', () => { const framework = createCodexFramework() const configurations = [ @@ -802,7 +844,11 @@ describe('codexFramework', () => { OPENAI_API_KEY: 'inherited-openai-key', CODEX_API_KEY: 'inherited-codex-key', CODEX_PATH: '/untrusted/codex', - CODEX_CONFIG: '{"untrusted":true}' + CODEX_CONFIG: '{"untrusted":true}', + OPEN_SCIENCE_SKILL_RUNTIME_ROOT: '/stale/runtime', + OPEN_SCIENCE_SKILL_DISCOVERY_ROOT: '/stale/discovery', + OPEN_SCIENCE_SKILL_PROJECTION_ROOT: '/stale/projection', + OPEN_SCIENCE_CODEX_DISABLED_SKILL_PATHS: '["/stale/SKILL.md"]' }, spawnProcess }) @@ -812,7 +858,8 @@ describe('codexFramework', () => { env: { CODEX_HOME: '/data/codex', CODEX_API_KEY: 'app-key', - CODEX_CONFIG: '{"app":true}' + CODEX_CONFIG: '{"app":true}', + OPEN_SCIENCE_CODEX_DISABLED_SKILL_PATHS: '["/data/codex/skills/legacy/SKILL.md"]' }, args: [] }) @@ -822,10 +869,14 @@ describe('codexFramework', () => { PATH: expect.stringContaining('/isolated-parent-bin'), CODEX_HOME: '/data/codex', CODEX_API_KEY: 'app-key', - CODEX_CONFIG: '{"app":true}' + CODEX_CONFIG: '{"app":true}', + OPEN_SCIENCE_CODEX_DISABLED_SKILL_PATHS: '["/data/codex/skills/legacy/SKILL.md"]' }) expect(env.OPENAI_API_KEY).toBeUndefined() expect(env.CODEX_PATH).toBeUndefined() + expect(env.OPEN_SCIENCE_SKILL_RUNTIME_ROOT).toBeUndefined() + expect(env.OPEN_SCIENCE_SKILL_DISCOVERY_ROOT).toBeUndefined() + expect(env.OPEN_SCIENCE_SKILL_PROJECTION_ROOT).toBeUndefined() }) it('replaces every inherited proxy shape for a subscription spawn', () => { diff --git a/src/main/agent-framework/codex.ts b/src/main/agent-framework/codex.ts index f40499127..0405c2465 100644 --- a/src/main/agent-framework/codex.ts +++ b/src/main/agent-framework/codex.ts @@ -31,6 +31,7 @@ import { isCodexSubscriptionProvider } from '../../shared/settings' import { CODEX_VERSION } from '../settings/managed-codex' import { clearSystemProxyEnvironment } from '../settings/system-proxy' import codexNativeModelInstructions from './codex-native-model-instructions.md?raw' +import { rebaseSkillRuntimeEnvironment, skillRuntimeEnvironment } from './skill-runtime-binding' const CODEX_PROVIDER_ID = 'open-science' // Catalog model used only for Codex's local metadata; the Responses bridge rewrites it to the selected @@ -75,6 +76,10 @@ const CODEX_DELEGATION_FEATURES = Object.freeze({ const CODEX_ENV_KEYS = [ 'CODEX_API_KEY', 'OPENAI_API_KEY', + 'OPEN_SCIENCE_SKILL_RUNTIME_ROOT', + 'OPEN_SCIENCE_SKILL_DISCOVERY_ROOT', + 'OPEN_SCIENCE_SKILL_PROJECTION_ROOT', + 'OPEN_SCIENCE_CODEX_DISABLED_SKILL_PATHS', 'CODEX_CONFIG', 'CODEX_HOME', 'CODEX_PATH', @@ -433,6 +438,7 @@ export const createCodexFramework = ({ const codexHome = codexSubscriptionStorageDir(ctx.storageRoot) return { env: { + ...skillRuntimeEnvironment(ctx.skillRuntime), ...isolatedCodexHomeEnv(codexHome, platform), ...(codexConfigJson ? { CODEX_CONFIG: codexConfigJson } : {}) }, @@ -502,6 +508,7 @@ export const createCodexFramework = ({ } return { env: { + ...skillRuntimeEnvironment(ctx.skillRuntime), ...isolatedCodexHomeEnv(codexHome, platform), CODEX_CONFIG: JSON.stringify(codexConfig), MODEL_PROVIDER: CODEX_PROVIDER_ID, @@ -540,6 +547,8 @@ export const createCodexFramework = ({ } }, + rebaseSkillRuntime: rebaseSkillRuntimeEnvironment, + buildSessionSetup(ctx: SessionSetupContext): SessionSetup { // Production backends pass no stable appends here because developer_instructions owns them. // Keep the fallback for injected/legacy backends and ephemeral reviewer sessions. diff --git a/src/main/agent-framework/index.ts b/src/main/agent-framework/index.ts index 215f5ef8d..cf7346f20 100644 --- a/src/main/agent-framework/index.ts +++ b/src/main/agent-framework/index.ts @@ -4,3 +4,8 @@ export { codexFramework } from './codex' export { opencodeFramework } from './opencode' export { DEFAULT_AGENT_FRAMEWORK_ID, getAgentFramework, listAgentFrameworks } from './registry' export { releaseResolvedAgentBackendLeases } from './resolved-agent-backend-leases' +export { + rebaseResolvedAgentBackendSkillRuntime, + rebaseSkillRuntimeEnvironment, + skillRuntimeEnvironment +} from './skill-runtime-binding' diff --git a/src/main/agent-framework/opencode.test.ts b/src/main/agent-framework/opencode.test.ts index 645790bdc..ba0a4e66e 100644 --- a/src/main/agent-framework/opencode.test.ts +++ b/src/main/agent-framework/opencode.test.ts @@ -3,8 +3,65 @@ import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { buildOpencodeConfig, opencodeFramework } from './opencode' +import type { SkillRuntimeView } from './types' + +const skillRuntime: SkillRuntimeView = { + projectionRoot: '/runtime/skills-projection', + discoveryRoot: '/runtime/skills-projection/skills', + descriptors: [ + { + id: 'research', + name: 'Research', + description: 'Research primary sources.', + path: '/runtime/skills-projection/skills/os-research/SKILL.md' + } + ], + environment: { + TMPDIR: '/runtime/tmp', + XDG_CACHE_HOME: '/runtime/cache' + } +} describe('opencodeFramework.prepareModelConfig', () => { + it('adds runtime skill paths to both native config layers while preserving isolation', () => { + const config = opencodeFramework.prepareModelConfig( + { type: 'custom', baseUrl: 'https://gw/v1', model: 'm', key: 'k' }, + { + storageRoot: '/data', + executablePath: '/bin/opencode', + skillRuntime + } + ) + + const writtenConfig = JSON.parse( + config.configFiles?.find((file) => file.path.endsWith('opencode.json'))?.content ?? '{}' + ) + const pinnedConfig = JSON.parse(config.env?.OPENCODE_CONFIG_CONTENT ?? '{}') + + expect(config.skillRuntime).toBe(skillRuntime) + expect(writtenConfig.skills).toEqual({ paths: [skillRuntime.discoveryRoot] }) + expect(pinnedConfig.skills).toEqual({ paths: [skillRuntime.discoveryRoot] }) + expect(config.env).toMatchObject({ + ...skillRuntime.environment, + OPEN_SCIENCE_SKILL_RUNTIME_ROOT: skillRuntime.discoveryRoot, + OPEN_SCIENCE_SKILL_DISCOVERY_ROOT: skillRuntime.discoveryRoot, + OPEN_SCIENCE_SKILL_PROJECTION_ROOT: skillRuntime.projectionRoot, + XDG_CONFIG_HOME: join('/runtime', 'tmp', 'opencode-config'), + XDG_DATA_HOME: join('/data', 'opencode', 'data'), + OPENCODE_DISABLE_EXTERNAL_SKILLS: 'true', + OPENCODE_DISABLE_CLAUDE_CODE_SKILLS: 'true', + OPENCODE_DISABLE_PROJECT_CONFIG: 'true' + }) + expect(config.configFiles?.map((file) => file.path)).toEqual( + expect.arrayContaining([ + join('/runtime', 'tmp', 'opencode-config', 'opencode', 'opencode.json') + ]) + ) + expect( + config.configFiles?.every((file) => !file.path.startsWith(join('/data', 'opencode'))) + ).toBe(true) + }) + it('writes connector conventions and wires them into opencode.json instructions', () => { const config = opencodeFramework.prepareModelConfig( { type: 'custom', baseUrl: 'https://gw/v1', model: 'm', key: 'k' }, diff --git a/src/main/agent-framework/opencode.ts b/src/main/agent-framework/opencode.ts index 9de33bb1b..16c0c7733 100644 --- a/src/main/agent-framework/opencode.ts +++ b/src/main/agent-framework/opencode.ts @@ -21,10 +21,13 @@ import type { AgentSpawnInput, ModelConfigContext, SessionSetup, - SessionSetupContext + SessionSetupContext, + SkillRuntimeRebase, + SkillRuntimeView } from './types' import { isProductionDelegatedWorkFramework } from '../delegation/production-readiness' import { renderAppMcpToolReferences } from './app-mcp-names' +import { rebaseSkillRuntimeEnvironment, skillRuntimeEnvironment } from './skill-runtime-binding' // opencode speaks ACP over `opencode acp` (stdio JSON-RPC). Only the shapes that differ from Claude // are implemented here: model config (a generated opencode.json, not ANTHROPIC_* env), system-prompt @@ -32,17 +35,28 @@ import { renderAppMcpToolReferences } from './app-mcp-names' // config dir, which its native skill tool discovers). Everything else reuses the generic runtime. // See docs/internal/pluggable-agent-framework-feasibility.md. -// opencode is isolated the way Claude uses CLAUDE_CONFIG_DIR: it reads config from -// $XDG_CONFIG_HOME/opencode and auth/data from $XDG_DATA_HOME/opencode. Pointing both at app-owned -// dirs means the app fully owns opencode's config + auth (the app provider is the only credential) -// and the user's own ~/.config/opencode + auth.json are never read or written. Verified: with these -// set, the user's global providers/auth disappear and only the app-injected provider remains. +// OpenCode reads config from $XDG_CONFIG_HOME/opencode and auth/data from $XDG_DATA_HOME/opencode. +// Production config is generated inside the disposable Skill runtime while data/auth remains in the +// stable app-owned directory. The user's own ~/.config/opencode + auth.json are never read or written. const opencodeConfigHome = (storageRoot: string): string => join(storageRoot, 'opencode', 'config') const opencodeDataHome = (storageRoot: string): string => join(storageRoot, 'opencode', 'data') -// The root of opencode's app-owned XDG subtree (both config and data live under here): opencode.json, -// materialized skills, connector instructions, and auth.json. The agent's Read tool must never surface -// it, so the runtime adds this to its protected-read roots. +const opencodeSkillRuntimeConfigHome = (skillRuntime: SkillRuntimeView): string => { + const writableRoot = skillRuntime.environment.TMPDIR ?? skillRuntime.environment.XDG_CACHE_HOME + if (!writableRoot) { + throw new Error('OpenCode Skill runtime has no writable config root.') + } + return join(writableRoot, 'opencode-config') +} + +const resolvedOpencodeConfigHome = ( + storageRoot: string, + skillRuntime: SkillRuntimeView | undefined +): string => + skillRuntime ? opencodeSkillRuntimeConfigHome(skillRuntime) : opencodeConfigHome(storageRoot) + +// Stable OpenCode state: auth/data, isolated home, and the rollback release's legacy config catalog. +// The agent's Read tool must never surface it, so the runtime adds this to its protected-read roots. export const opencodeStorageDir = (storageRoot: string): string => join(storageRoot, 'opencode') // An app-owned stand-in for opencode's notion of `$HOME`, passed via OPENCODE_TEST_HOME. It is a stable, @@ -50,9 +64,8 @@ export const opencodeStorageDir = (storageRoot: string): string => join(storageR const opencodeHomeDir = (storageRoot: string): string => join(opencodeStorageDir(storageRoot), 'home') -// The opencode config directory ($XDG_CONFIG_HOME/opencode) where opencode.json and skills/ live. -// opencode discovers skills at /skills//SKILL.md — the same layout Claude uses under -// its config dir — so the app materializes the enabled skill set here for opencode too. +// The rollback-owned OpenCode config directory. Current sessions use an ephemeral config home and a +// private runtime projection; this stable path remains addressable only by rollback maintenance code. export const opencodeConfigDir = (storageRoot: string): string => join(opencodeConfigHome(storageRoot), 'opencode') @@ -329,7 +342,8 @@ const buildOpencodeProviders = ( const buildAppConfigContent = ( provider: ResolvedProvider, reasoningEffort?: ModelReasoningEffort, - catalog: readonly AgentModelCatalogEntry[] = [] + catalog: readonly AgentModelCatalogEntry[] = [], + skillPaths: readonly string[] = [] ): Record => { const { bareModel, providerId } = resolveOpencodeEndpoint(provider) @@ -337,10 +351,43 @@ const buildAppConfigContent = ( ...(bareModel ? { model: `${providerId}/${bareModel}` } : {}), permission: { ...OPENCODE_PERMISSION_RULES }, agent: { ...OPENCODE_DISABLED_NATIVE_AGENTS }, + ...(skillPaths.length > 0 ? { skills: { paths: [...new Set(skillPaths)] } } : {}), provider: buildOpencodeProviders(provider, reasoningEffort, catalog) } } +const rebaseOpenCodeSkillRuntime = (input: SkillRuntimeRebase): Record => { + const environment = { + ...rebaseSkillRuntimeEnvironment(input), + XDG_CONFIG_HOME: opencodeSkillRuntimeConfigHome(input.next) + } + const content = input.environment.OPENCODE_CONFIG_CONTENT + if (content === undefined) return environment + + let parsed: unknown + try { + parsed = JSON.parse(content) + } catch (error) { + throw new Error( + 'Cannot rebase OpenCode Skill runtime from malformed OPENCODE_CONFIG_CONTENT.', + { cause: error } + ) + } + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('Cannot rebase OpenCode Skill runtime from non-object OPENCODE_CONFIG_CONTENT.') + } + + const config = parsed as Record + const skills = asRecord(config.skills) + return { + ...environment, + OPENCODE_CONFIG_CONTENT: JSON.stringify({ + ...config, + skills: { ...skills, paths: [input.next.discoveryRoot] } + }) + } +} + // Builds opencode's config by MERGING the app's active provider/model onto the user's existing config // so their own providers, mcp servers, and auth are preserved. The model is both selected (top-level // `model`) and registered under the provider's `models` map — without the registration opencode does @@ -351,7 +398,8 @@ const buildOpencodeConfig = ( baseConfig: Record = {}, instructionPaths: string[] = [], reasoningEffort?: ModelReasoningEffort, - catalog: readonly AgentModelCatalogEntry[] = [] + catalog: readonly AgentModelCatalogEntry[] = [], + skillPaths: readonly string[] = [] ): string => { const { bareModel, providerId } = resolveOpencodeEndpoint(provider) @@ -362,6 +410,11 @@ const buildOpencodeConfig = ( ? baseConfig.instructions.filter((entry): entry is string => typeof entry === 'string') : [] const instructions = [...new Set([...baseInstructions, ...instructionPaths])] + const baseSkills = asRecord(baseConfig.skills) + const baseSkillPaths = Array.isArray(baseSkills.paths) + ? baseSkills.paths.filter((entry): entry is string => typeof entry === 'string') + : [] + const mergedSkillPaths = [...new Set([...baseSkillPaths, ...skillPaths])] const merged: Record = { $schema: 'https://opencode.ai/config.json', @@ -380,6 +433,7 @@ const buildOpencodeConfig = ( ...asRecord(baseConfig.agent), ...OPENCODE_DISABLED_NATIVE_AGENTS }, + ...(mergedSkillPaths.length > 0 ? { skills: { ...baseSkills, paths: mergedSkillPaths } } : {}), provider: buildOpencodeProviders(provider, reasoningEffort, catalog, baseProviders) } @@ -430,7 +484,10 @@ export const opencodeFramework: AgentFramework = { // Isolate opencode via app-owned XDG dirs (mirror of CLAUDE_CONFIG_DIR): opencode reads its config // from $XDG_CONFIG_HOME/opencode and auth/data from $XDG_DATA_HOME/opencode. We own the whole // config here, so the app provider/model is written clean (no merge with the user's global config). - const configHome = opencodeConfigHome(ctx.storageRoot) + // Config and generated instructions belong to the disposable runtime lease. Keep XDG_DATA_HOME + // stable below because it owns OpenCode auth/state; rollback releases therefore retain their + // existing data while never observing this version's ephemeral config files. + const configHome = resolvedOpencodeConfigHome(ctx.storageRoot, ctx.skillRuntime) const dataHome = opencodeDataHome(ctx.storageRoot) const opencodeDir = join(configHome, 'opencode') const configPath = join(opencodeDir, 'opencode.json') @@ -465,11 +522,13 @@ export const opencodeFramework: AgentFramework = { {}, instructionPaths, ctx.reasoningEffort, - ctx.providerModelCatalog + ctx.providerModelCatalog, + ctx.skillRuntime ? [ctx.skillRuntime.discoveryRoot] : [] ) return { env: { + ...skillRuntimeEnvironment(ctx.skillRuntime), XDG_CONFIG_HOME: configHome, XDG_DATA_HOME: dataHome, // Redirect opencode's Global.Path.home (= `OPENCODE_TEST_HOME ?? os.homedir()`) to an app-owned, @@ -498,7 +557,12 @@ export const opencodeFramework: AgentFramework = { // active provider's baseURL or swap the model to an attacker provider while inheriting the app's // `{env:...}` key ref. The key itself never rides this layer, only its env reference. OPENCODE_CONFIG_CONTENT: JSON.stringify( - buildAppConfigContent(provider, ctx.reasoningEffort, ctx.providerModelCatalog) + buildAppConfigContent( + provider, + ctx.reasoningEffort, + ctx.providerModelCatalog, + ctx.skillRuntime ? [ctx.skillRuntime.discoveryRoot] : [] + ) ), // Pass credentials only through referenced environment values. Generation-local transport // routes use distinct variables so late OpenCode background work cannot inherit a new route. @@ -512,6 +576,7 @@ export const opencodeFramework: AgentFramework = { ) }, configFiles, + ...(ctx.skillRuntime ? { skillRuntime: ctx.skillRuntime } : {}), ...(provider.agentProviderId && provider.model ? { sessionModel: `${provider.agentProviderId}/${provider.model}` } : {}), @@ -521,6 +586,8 @@ export const opencodeFramework: AgentFramework = { } }, + rebaseSkillRuntime: rebaseOpenCodeSkillRuntime, + buildSessionSetup(ctx: SessionSetupContext): SessionSetup { // Production backends pass no stable appends here because they are already installed in native // instructions. Retain the append fallback for injected/legacy backends and ephemeral reviewers. diff --git a/src/main/agent-framework/resolved-agent-backend-leases.test.ts b/src/main/agent-framework/resolved-agent-backend-leases.test.ts index 767e55bef..0b20b3840 100644 --- a/src/main/agent-framework/resolved-agent-backend-leases.test.ts +++ b/src/main/agent-framework/resolved-agent-backend-leases.test.ts @@ -12,10 +12,12 @@ describe('resolved agent backend lease owner', () => { const transportRelease = vi.fn(() => { throw new Error('synchronous close failure') }) + const skillRuntimeRelease = vi.fn(async () => undefined) const backend = { responsesBridgeLease: { release: responsesRelease }, anthropicBridgeLease: { release: anthropicRelease }, - providerTransportLease: { release: transportRelease } + providerTransportLease: { release: transportRelease }, + skillRuntimeLease: { release: skillRuntimeRelease } } as unknown as ResolvedAgentBackend const first = releaseResolvedAgentBackendLeases(backend) @@ -26,8 +28,10 @@ describe('resolved agent backend lease owner', () => { expect(responsesRelease).toHaveBeenCalledOnce() expect(anthropicRelease).toHaveBeenCalledOnce() expect(transportRelease).toHaveBeenCalledOnce() + expect(skillRuntimeRelease).toHaveBeenCalledOnce() await releaseResolvedAgentBackendLeases(backend) expect(responsesRelease).toHaveBeenCalledOnce() + expect(skillRuntimeRelease).toHaveBeenCalledOnce() }) it('releases an aliased lease only once', async () => { @@ -36,7 +40,8 @@ describe('resolved agent backend lease owner', () => { const backend = { responsesBridgeLease: aliasedLease, anthropicBridgeLease: aliasedLease, - providerTransportLease: aliasedLease + providerTransportLease: aliasedLease, + skillRuntimeLease: aliasedLease } as unknown as ResolvedAgentBackend await releaseResolvedAgentBackendLeases(backend) diff --git a/src/main/agent-framework/resolved-agent-backend-leases.ts b/src/main/agent-framework/resolved-agent-backend-leases.ts index 37d6217a5..70919d74a 100644 --- a/src/main/agent-framework/resolved-agent-backend-leases.ts +++ b/src/main/agent-framework/resolved-agent-backend-leases.ts @@ -10,6 +10,7 @@ const releaseResolvedAgentBackendLeases = (backend: ResolvedAgentBackend): Promi if (backend.responsesBridgeLease) owned.push(backend.responsesBridgeLease) if (backend.anthropicBridgeLease) owned.push(backend.anthropicBridgeLease) if (backend.providerTransportLease) owned.push(backend.providerTransportLease) + if (backend.skillRuntimeLease) owned.push(backend.skillRuntimeLease) const release = Promise.allSettled( [...new Set(owned)].map((lease) => Promise.resolve().then(() => lease.release())) ).then(() => undefined) diff --git a/src/main/agent-framework/skill-runtime-binding.ts b/src/main/agent-framework/skill-runtime-binding.ts new file mode 100644 index 000000000..9597f08ff --- /dev/null +++ b/src/main/agent-framework/skill-runtime-binding.ts @@ -0,0 +1,55 @@ +import type { ResolvedAgentBackend, SkillRuntimeRebase, SkillRuntimeView } from './types' + +const SKILL_RUNTIME_ROOT_ENVIRONMENT = Object.freeze([ + 'OPEN_SCIENCE_SKILL_RUNTIME_ROOT', + 'OPEN_SCIENCE_SKILL_DISCOVERY_ROOT', + 'OPEN_SCIENCE_SKILL_PROJECTION_ROOT' +] as const) + +const skillRuntimeEnvironment = ( + skillRuntime: SkillRuntimeView | undefined +): Record => + skillRuntime + ? { + ...skillRuntime.environment, + OPEN_SCIENCE_SKILL_RUNTIME_ROOT: skillRuntime.discoveryRoot, + OPEN_SCIENCE_SKILL_DISCOVERY_ROOT: skillRuntime.discoveryRoot, + OPEN_SCIENCE_SKILL_PROJECTION_ROOT: skillRuntime.projectionRoot + } + : {} + +// A resolved backend may contain unrelated provider and transport environment. Remove only the keys +// owned by its previous Skill runtime, then install the complete next view. This also drops a runtime +// cache variable if a future adapter stops using it instead of leaking the parent Attempt's path. +const rebaseSkillRuntimeEnvironment = (input: SkillRuntimeRebase): Record => { + const environment = { ...input.environment } + for (const name of [ + ...Object.keys(input.previous.environment), + ...SKILL_RUNTIME_ROOT_ENVIRONMENT + ]) { + delete environment[name] + } + return { ...environment, ...skillRuntimeEnvironment(input.next) } +} + +// Delegation crosses this single framework seam so callers never need to know which native config +// surfaces carry Skill paths (for example OpenCode's high-priority JSON environment layer). +const rebaseResolvedAgentBackendSkillRuntime = ( + backend: ResolvedAgentBackend, + skillRuntime: SkillRuntimeView +): ResolvedAgentBackend => + Object.freeze({ + ...backend, + env: backend.framework.rebaseSkillRuntime({ + environment: backend.env, + previous: backend.skillRuntime ?? skillRuntime, + next: skillRuntime + }), + skillRuntime + }) + +export { + rebaseResolvedAgentBackendSkillRuntime, + rebaseSkillRuntimeEnvironment, + skillRuntimeEnvironment +} diff --git a/src/main/agent-framework/types.ts b/src/main/agent-framework/types.ts index 5e05d1b39..19438bd20 100644 --- a/src/main/agent-framework/types.ts +++ b/src/main/agent-framework/types.ts @@ -48,6 +48,43 @@ export type AgentProviderConfiguration = { headers: Record } +// Secret-free view of one prepared skill runtime. Adapters consume only this projection: lifecycle, +// cache ownership, and release authority remain behind the skills runtime module. +export type SkillRuntimeDescriptor = Readonly<{ + id: string + name: string + description: string + path: string +}> + +export type SkillRuntimeView = Readonly<{ + projectionRoot: string + discoveryRoot: string + descriptors: readonly SkillRuntimeDescriptor[] + environment: Readonly> +}> + +export type SkillRuntimeLifecycle = Readonly<{ + sessionId: string + agentFrameId: string + runtimeSegmentId: string +}> + +export type SkillRuntimeFork = Readonly<{ + acquire(lifecycle: SkillRuntimeLifecycle): Promise< + Readonly<{ + view: SkillRuntimeView + lease: Readonly<{ release(): Promise }> + }> + > +}> + +export type SkillRuntimeRebase = Readonly<{ + environment: Readonly> + previous: SkillRuntimeView + next: SkillRuntimeView +}> + // How the app's provider maps onto a framework's native model configuration. Claude reads env // (ANTHROPIC_*); opencode reads a generated config file referenced by OPENCODE_CONFIG. Fields are // merged over the spawn base, so an empty result just spawns with inherited defaults. @@ -64,6 +101,9 @@ export type AgentModelConfig = { // ordinary ACP prompt content. The runtime uses this for context accounting and to avoid copying // the same text into every user message. persistentSystemPrompt?: string + // Carries the same secret-free runtime view across the public adapter seam so session-native + // discovery can be assembled without exposing the runtime lease or its release authority. + skillRuntime?: SkillRuntimeView } export type AgentModelRoute = @@ -133,6 +173,7 @@ export type ModelConfigContext = { // Same-provider models that keep the active backend route. Frameworks may pre-register these in // their native catalog so a later session configOption switch does not require a process respawn. providerModelCatalog?: readonly AgentModelCatalogEntry[] + skillRuntime?: SkillRuntimeView } // System-prompt guidance the runtime wants appended for a session (artifact routing, notebook, skill @@ -148,6 +189,7 @@ export type SessionSetupContext = { // undefined is the Main Agent and must omit the native field; [] is an explicit Specialist // zero-skill whitelist and must be preserved verbatim by supporting frameworks. skillWhitelist?: string[] + skillRuntime?: SkillRuntimeView } // Framework-specific session configuration returned to the runtime. `meta` becomes the ACP `_meta` @@ -211,6 +253,11 @@ export interface AgentFramework { // Translate the app's provider into the framework's native model config (env / config files / args). prepareModelConfig(provider: ResolvedProvider, ctx: ModelConfigContext): AgentModelConfig + // Rebind every framework-native Skill path in an already-resolved backend to a derived runtime. + // Delegation uses this after forking an Attempt projection; implementations must return a new env + // and leave the admitted parent backend untouched. + rebaseSkillRuntime(input: SkillRuntimeRebase): Record + // Build the session `_meta` and decide how system-prompt appends are delivered for this framework. buildSessionSetup(ctx: SessionSetupContext): SessionSetup @@ -257,6 +304,12 @@ export type ResolvedAgentBackend = { env: Record args?: string[] proxyEnvironmentMode?: ProxyEnvironmentMode + skillRuntime?: SkillRuntimeView + skillRuntimeLease?: { release(): Promise } + // Process-local derivation authority. Delegated Attempts use it to receive independent private + // projections plus writable cache/tmp roots. It is never persisted or exposed to the agent process, + // and is deliberately separate from the physical lease ownership seam. + skillRuntimeFork?: SkillRuntimeFork // Framework-native session options retained by the runtime and passed through buildSessionSetup. sessionOptions?: Record // Backend-resolved guidance appended to every session. Connector conventions use this channel for diff --git a/src/main/compute/compute-service.architecture.test.ts b/src/main/compute/compute-service.architecture.test.ts index 281857c2d..6e01fce4f 100644 --- a/src/main/compute/compute-service.architecture.test.ts +++ b/src/main/compute/compute-service.architecture.test.ts @@ -324,6 +324,20 @@ describe('Compute service architecture', () => { expect(backgroundProjectRecovery).toBeGreaterThan(backgroundOrphanRecovery) }) + it('invalidates private Skill runtimes without writing rollback framework catalogs', () => { + const computeIpc = readSource(computePaths.ipc) + const computeImports = importSpecifiersFrom(computePaths.ipc) + const mainIpc = readSource(computePaths.mainIpc) + + expect(computeImports).not.toContain('./skill-doc') + expect(computeImports).not.toContain('../settings/provider-env') + expect(computeImports).not.toContain('../agent-framework/codex') + expect(computeImports).not.toContain('../agent-framework/opencode') + expect(computeIpc).not.toContain('syncCurrentComputeSkillDocuments') + expect(computeIpc.match(/await refreshSkillCatalog\(\)/g)).toHaveLength(3) + expect(mainIpc).toContain('requestSkillCatalogRefresh\n }\n )') + }) + it('treats unreadable Session authority as unknown during Compute Job recovery', () => { const source = readSource(computePaths.mainIpc) const livenessStart = source.indexOf('const isComputeJobOwnerLive') diff --git a/src/main/compute/ipc.test.ts b/src/main/compute/ipc.test.ts index 5c4fafb0f..a94df4028 100644 --- a/src/main/compute/ipc.test.ts +++ b/src/main/compute/ipc.test.ts @@ -8,7 +8,8 @@ import type { ComputeApprovalRequest, ComputeHost, ComputeJob, - CreateComputeHostRequest + CreateComputeHostRequest, + ProbeResult } from '../../shared/compute' import type { DirListing, DownloadDest, LocalFile } from '../../shared/remote-fs' import { decodeRemoteFsError } from '../../shared/remote-fs' @@ -206,12 +207,12 @@ describe('compute handlers', () => { expect(del).toHaveBeenCalledWith('ssh:biowulf') }) - it('refreshes the canonical Compute Skill after host create and delete', async () => { + it('invalidates the Skill catalog after host create and delete', async () => { const create = vi.fn(() => Promise.resolve(sampleHost())) const del = vi.fn(() => Promise.resolve()) - const syncComputeSkill = vi.fn(() => Promise.resolve()) + const requestSkillCatalogRefresh = vi.fn() const handlers = createComputeHandlers( - mockRepository({ create, delete: del }), + mockRepository({ create, delete: del, get: vi.fn(() => Promise.resolve(sampleHost())) }), undefined, undefined, undefined, @@ -222,13 +223,42 @@ describe('compute handlers', () => { undefined, undefined, undefined, - syncComputeSkill + undefined, + { + pruneSessionEnabledHosts: vi.fn(async (_providerId, afterPrune) => afterPrune?.()), + requestSkillCatalogRefresh + } ) await handlers.create({ sshAlias: 'biowulf' }) await handlers.delete('ssh:biowulf') - expect(syncComputeSkill).toHaveBeenCalledTimes(2) + expect(requestSkillCatalogRefresh).toHaveBeenCalledTimes(2) + }) + + it('does not invalidate the Skill catalog when host deletion fails', async () => { + const requestSkillCatalogRefresh = vi.fn() + const handlers = createComputeHandlers( + mockRepository({ delete: vi.fn(() => Promise.reject(new Error('delete failed'))) }), + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + { + pruneSessionEnabledHosts: vi.fn(async (_providerId, afterPrune) => afterPrune?.()), + requestSkillCatalogRefresh + } + ) + + await expect(handlers.delete('ssh:biowulf')).rejects.toThrow('delete failed') + expect(requestSkillCatalogRefresh).not.toHaveBeenCalled() }) it('sshConfigAliases uses the injected alias lister', async () => { @@ -248,14 +278,127 @@ describe('compute handlers', () => { detectedScheduler: 'slurm' as const } const probe = vi.fn(() => Promise.resolve(probeResult)) - const handlers = createComputeHandlers(mockRepository({}), undefined, mockService({ probe })) + const requestSkillCatalogRefresh = vi.fn(() => Promise.resolve()) + const handlers = createComputeHandlers( + mockRepository({}), + undefined, + mockService({ probe }), + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + requestSkillCatalogRefresh + ) const result = await handlers.probe('ssh:biowulf') expect(probe).toHaveBeenCalledWith('ssh:biowulf') + expect(requestSkillCatalogRefresh).toHaveBeenCalledOnce() expect(result.ok).toBe(true) expect(result.cpus).toBe(64) }) + it('invalidates the Skill catalog after a persisted failed probe result', async () => { + const result: ProbeResult = { + ok: false, + probedAt: '2026-01-01T00:00:00Z', + exitCode: 255, + errorTail: 'Connection failed' + } + const requestSkillCatalogRefresh = vi.fn(() => Promise.resolve()) + const handlers = createComputeHandlers( + mockRepository({}), + undefined, + mockService({ probe: vi.fn(() => Promise.resolve(result)) }), + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + requestSkillCatalogRefresh + ) + + await expect(handlers.probe('ssh:biowulf')).resolves.toBe(result) + expect(requestSkillCatalogRefresh).toHaveBeenCalledOnce() + }) + + it('does not invalidate the Skill catalog when probe produces no result', async () => { + const requestSkillCatalogRefresh = vi.fn(() => Promise.resolve()) + const handlers = createComputeHandlers( + mockRepository({}), + undefined, + mockService({ probe: vi.fn(() => Promise.reject(new Error('host not found'))) }), + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + requestSkillCatalogRefresh + ) + + await expect(handlers.probe('ssh:missing')).rejects.toThrow('host not found') + expect(requestSkillCatalogRefresh).not.toHaveBeenCalled() + }) + + it('keeps a probe result when Skill catalog invalidation fails', async () => { + const result: ProbeResult = { + ok: true, + probedAt: '2026-01-01T00:00:00Z', + exitCode: 0, + errorTail: null + } + const handlers = createComputeHandlers( + mockRepository({}), + undefined, + mockService({ probe: vi.fn(() => Promise.resolve(result)) }), + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + vi.fn(() => Promise.reject(new Error('refresh failed'))) + ) + + await expect(handlers.probe('ssh:biowulf')).resolves.toBe(result) + }) + + it('does not invalidate the Skill catalog for list and get reads', async () => { + const requestSkillCatalogRefresh = vi.fn(() => Promise.resolve()) + const handlers = createComputeHandlers( + mockRepository({ + list: vi.fn(() => Promise.resolve([sampleHost()])), + get: vi.fn(() => Promise.resolve(sampleHost())) + }), + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + requestSkillCatalogRefresh + ) + + await handlers.list() + await handlers.get('ssh:biowulf') + expect(requestSkillCatalogRefresh).not.toHaveBeenCalled() + }) + it('listDir delegates to the injected ComputeService', async () => { const listing: DirListing = { entries: [{ name: 'data', isDirectory: true, size: 0, mtimeMs: 1704067200000 }], diff --git a/src/main/compute/ipc.ts b/src/main/compute/ipc.ts index f952cb5bf..44df81d31 100644 --- a/src/main/compute/ipc.ts +++ b/src/main/compute/ipc.ts @@ -19,10 +19,7 @@ import type { DirListing, DownloadDest, LocalFile } from '../../shared/remote-fs import { getProjectDbClient } from '../projects/prisma-client' import { createLogger, errorLogFields } from '../logger' import { resolveDataRoot, resolveStorageRoot } from '../storage-root' -import { getAppClaudeConfigDir } from '../settings/provider-env' import { createSettingsComputeGrantPort } from '../settings/compute-grant-port' -import { codexStorageDir, codexSubscriptionStorageDir } from '../agent-framework/codex' -import { opencodeConfigDir } from '../agent-framework/opencode' import { broadcastToRenderers } from '../renderer-broadcast' import type { TaskNotificationService } from '../notifications/task-notifications' import { buildComputeApprovalBroadcast } from '../notifications/electron-wiring' @@ -44,7 +41,6 @@ import { createComputePermissionGrantAdapter, type LegacyComputeGrantPort } from './permission-grant-adapter' -import { hasCanonicalComputeSkillDoc, syncComputeSkillDoc } from './skill-doc' export const COMPUTE_JOB_UPDATED_CHANNEL = 'compute:job-updated' const log = createLogger('compute') @@ -182,6 +178,7 @@ type ComputeHandlers = { type ComputeHostLifecycle = Readonly<{ pruneSessionEnabledHosts(providerId: string, afterPrune?: () => Promise): Promise + requestSkillCatalogRefresh?(): void }> // Adapts a repository into thin handlers. @@ -200,7 +197,7 @@ const createComputeHandlers = ( 'handleComputeApproval' | 'settleAuthorization' >, permissionGrantRegistry?: PermissionGrantRegistry, - syncComputeSkillDocument?: () => Promise, + notifySkillCatalogChanged?: () => Promise, hostLifecycle?: ComputeHostLifecycle ): ComputeHandlers => { const permissionGrants = permissionGrantRegistry @@ -265,6 +262,16 @@ const createComputeHandlers = ( ) return result } + const refreshSkillCatalog = async (): Promise => { + try { + if (hostLifecycle?.requestSkillCatalogRefresh) hostLifecycle.requestSkillCatalogRefresh() + else await notifySkillCatalogChanged?.() + } catch (error) { + // Host state has already committed. A later catalog reconciliation can retry retirement, so a + // notification failure must not turn a successful Compute operation into a reported failure. + log.warn('Skill catalog refresh after Compute Host change failed', errorLogFields(error)) + } + } // Construct the production service with the full job dependency set so agent submit_job works and // dispatcher status transitions (submitted→running/error) broadcast to the renderer. Positional @@ -323,7 +330,7 @@ const createComputeHandlers = ( } } const host = await repository.create(request) - await syncComputeSkillDocument?.() + await refreshSkillCatalog() return host }), delete: (providerId) => @@ -353,17 +360,18 @@ const createComputeHandlers = ( errorLogFields(error) ) } - try { - await syncComputeSkillDocument?.() - } catch (error) { - log.warn('compute skill sync after host deletion failed', errorLogFields(error)) - } + await refreshSkillCatalog() } finally { broker.completeProviderInvalidation(providerId) } }), sshConfigAliases: () => listSshAliases(), - probe: (providerId) => service.probe(providerId), + probe: async (providerId) => { + const result = await service.probe(providerId) + // Both successful and failed ProbeResult values are persisted host projection state. + await refreshSkillCatalog() + return result + }, detailsGet: (providerId) => service.getDetails(providerId), detailsSave: (providerId, text, oldText, author) => service.replaceDetails(providerId, { text, oldText, author }), @@ -421,28 +429,6 @@ const createDefaultComputeHostRepository = (): ComputeHostRepository => const createDefaultComputeJobRepository = (): ComputeJobRepository => new ComputeJobRepository(() => getProjectDbClient(resolveStorageRoot())) -const syncCurrentComputeSkillDocuments = async ( - storageRoot: string, - repository: ComputeHostRepository -): Promise => { - const skillsDirs = [ - join(getAppClaudeConfigDir(storageRoot), 'skills'), - join(opencodeConfigDir(storageRoot), 'skills'), - join(codexStorageDir(storageRoot), 'skills'), - join(codexSubscriptionStorageDir(storageRoot), 'skills') - ] - const existing = await Promise.all( - skillsDirs.map((skillsDir) => hasCanonicalComputeSkillDoc(skillsDir)) - ) - if (!existing.some(Boolean)) return - const hosts = await repository.list() - await Promise.all( - skillsDirs.map((skillsDir, index) => - existing[index] ? syncComputeSkillDoc(skillsDir, hosts) : undefined - ) - ) -} - // Broadcasts a job summary to all renderer windows. Called by the JobPoller onJobUpdated hook // and by the job dispatcher on status transitions (Phase 3d, design.md §9). export const broadcastJobUpdated = (summary: JobSummary): void => @@ -517,7 +503,7 @@ const createComputeIpcModule = ( dataRoot, taskNotifications, permissionGrantRegistry, - () => syncCurrentComputeSkillDocuments(storageRoot, repository), + undefined, hostLifecycle ) const jobDeletionOwner = createComputeJobDeletionOwner({ diff --git a/src/main/compute/skill-doc.ts b/src/main/compute/skill-doc.ts index 70ea12e71..00e5c73b6 100644 --- a/src/main/compute/skill-doc.ts +++ b/src/main/compute/skill-doc.ts @@ -46,6 +46,9 @@ const withHostProjection = (document: string, projection: string): string => { return document } +const projectComputeSkillDoc = (document: string, hosts: readonly ComputeHost[]): string => + withHostProjection(document, renderHostProjection(hosts)) + const extractHostProjection = (document: string): string | undefined => { const match = projectionPattern.exec(document) if (!match) return undefined @@ -75,7 +78,7 @@ const syncComputeSkillDoc = async ( return } - const updated = withHostProjection(document, renderHostProjection(hosts)) + const updated = projectComputeSkillDoc(document, hosts) if (updated === document) return // Materialized Skills are normally read-only. Temporarily restore only this application-owned @@ -107,6 +110,7 @@ export { COMPUTE_SKILL_DIRECTORY, COMPUTE_SKILL_ID, hasCanonicalComputeSkillDoc, + projectComputeSkillDoc, preserveComputeHostProjection, syncComputeSkillDoc } diff --git a/src/main/connector-reload.ts b/src/main/connector-reload.ts index 579c9adf5..ac847c797 100644 --- a/src/main/connector-reload.ts +++ b/src/main/connector-reload.ts @@ -1,7 +1,7 @@ // The Connector Settings workflow uses this settle rule after starting its derived projection refresh. -// The skills reload MUST run on BOTH settle paths — a non-Claude framework -// (Codex, opencode) materializes connector docs into its own home at spawn, so it has to pick up a -// connector change even if the doc re-sync itself fails. Hence `.finally`, never `.then`. +// The Skills reload MUST run on BOTH settle paths: every framework captures Connector docs in a +// private runtime generation, so a later turn must rebuild that generation even if source re-sync +// fails. Hence `.finally`, never `.then`. export const wireConnectorReload = ( refreshConnectorSkillDocs: () => Promise, requestSkillsReload: () => void diff --git a/src/main/connectors/provision.ts b/src/main/connectors/provision.ts index 13d9d4935..69e4c94e0 100644 --- a/src/main/connectors/provision.ts +++ b/src/main/connectors/provision.ts @@ -150,8 +150,8 @@ export async function syncCustomServerSkillDocs( return { materializedNames, failures } } -// Copies only successfully generated custom Connector docs from the canonical app-owned Claude -// Skill root into an isolated ACP runtime root. The projection-provided names are the authorization +// Copies only successfully generated custom Connector docs from the versioned app-owned derived +// source into an isolated ACP runtime root. The projection-provided names are the authorization // boundary: stale custom dirs are removed, while bundled Connector dirs remain owned by // syncConnectorSkillDocs. Reading and writing the exact SKILL.md avoids copying arbitrary trees. export async function syncMaterializedCustomServerSkillDocs( diff --git a/src/main/connectors/runtime-settings-projection.test.ts b/src/main/connectors/runtime-settings-projection.test.ts index 995572a17..48043072f 100644 --- a/src/main/connectors/runtime-settings-projection.test.ts +++ b/src/main/connectors/runtime-settings-projection.test.ts @@ -1,7 +1,13 @@ +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + import { describe, expect, it, vi } from 'vitest' import type { StoredConnectors } from '../settings/types' +import { ALL_CONNECTOR_IDS } from './registry' import { ConnectorRuntimeSettingsProjection } from './runtime-settings-projection' +import { connectorSkillSourceRoot } from './skill-source' const connectors = (overrides: Partial = {}): StoredConnectors => ({ enabledIds: [], @@ -10,6 +16,58 @@ const connectors = (overrides: Partial = {}): StoredConnectors }) describe('ConnectorRuntimeSettingsProjection', () => { + it('writes derived documents without changing framework rollback catalogs', async () => { + const configRoot = await mkdtemp(join(tmpdir(), 'open-science-connector-source-')) + const rollbackRoots = [ + join(configRoot, 'claude', 'skills'), + join(configRoot, 'opencode', 'config', 'opencode', 'skills'), + join(configRoot, 'codex', 'skills'), + join(configRoot, 'codex-subscription', 'skills') + ] + try { + for (const root of rollbackRoots) { + await mkdir(root, { recursive: true }) + await writeFile(join(root, 'rollback-sentinel.txt'), 'preserve', 'utf8') + } + const customServer = { + id: 'derived-id', + name: 'derived', + displayName: 'Derived', + transport: 'stdio' as const, + command: 'mcp', + enabled: true + } + const projection = new ConnectorRuntimeSettingsProjection({ + readConnectors: vi.fn().mockResolvedValue( + connectors({ + disabledConnectorIds: ALL_CONNECTOR_IDS.filter((id) => id !== 'pubmed'), + customMcpServers: [customServer] + }) + ), + skillsDir: connectorSkillSourceRoot(configRoot), + mcpClientManager: { listTools: vi.fn().mockResolvedValue([]) } + }) + + await projection.refresh() + + await expect( + readFile(join(connectorSkillSourceRoot(configRoot), 'mcp-pubmed', 'SKILL.md'), 'utf8') + ).resolves.toContain('name: mcp-pubmed') + await expect( + readFile(join(connectorSkillSourceRoot(configRoot), 'mcp-derived', 'SKILL.md'), 'utf8') + ).resolves.toContain('name: mcp-derived') + expect(projection.materializedCustomSkillNames()).toEqual(['mcp-derived']) + for (const root of rollbackRoots) { + expect(await readdir(root)).toEqual(['rollback-sentinel.txt']) + await expect(readFile(join(root, 'rollback-sentinel.txt'), 'utf8')).resolves.toBe( + 'preserve' + ) + } + } finally { + await rm(configRoot, { recursive: true, force: true }) + } + }) + it('owns the current snapshot and synchronizes bundled and enabled custom Skill docs', async () => { const stored = connectors({ disabledConnectorIds: ['chemistry'], @@ -38,9 +96,10 @@ describe('ConnectorRuntimeSettingsProjection', () => { await loadTools(servers[0]) return { materializedNames: ['enabled'], failures: [] } }) + const derivedSource = connectorSkillSourceRoot('/config') const projection = new ConnectorRuntimeSettingsProjection({ readConnectors: vi.fn().mockResolvedValue(stored), - skillsDir: '/config/skills', + skillsDir: derivedSource, mcpClientManager: { listTools }, syncBundledSkillDocs, syncCustomSkillDocs @@ -50,11 +109,11 @@ describe('ConnectorRuntimeSettingsProjection', () => { expect(projection.current()).toBe(stored) expect(syncBundledSkillDocs).toHaveBeenCalledWith( - '/config/skills', + derivedSource, expect.not.arrayContaining(['chemistry']) ) expect(syncCustomSkillDocs).toHaveBeenCalledWith( - '/config/skills', + derivedSource, [stored.customMcpServers?.[0]], expect.any(Function) ) diff --git a/src/main/connectors/skill-source.architecture.test.ts b/src/main/connectors/skill-source.architecture.test.ts new file mode 100644 index 000000000..ab1c5e835 --- /dev/null +++ b/src/main/connectors/skill-source.architecture.test.ts @@ -0,0 +1,73 @@ +import { readFileSync, readdirSync } from 'node:fs' +import { extname, join, relative, resolve } from 'node:path' + +import { describe, expect, it } from 'vitest' + +import { connectorSkillSourceRoot } from './skill-source' + +const projectRoot = resolve(__dirname, '../../..') +const sourceRoot = resolve(projectRoot, 'src') +const portablePath = (path: string): string => relative(projectRoot, path).replaceAll('\\', '/') +const readSource = (path: string): string => readFileSync(path, 'utf8') +const productionSources = (): string[] => { + const sources: string[] = [] + const visit = (directory: string): void => { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = resolve(directory, entry.name) + if (entry.isDirectory()) visit(path) + else if ( + ['.ts', '.tsx'].includes(extname(path)) && + !/\.(?:test|spec)\.[cm]?tsx?$/.test(entry.name) + ) { + sources.push(path) + } + } + } + visit(sourceRoot) + return sources.sort() +} + +describe('Connector Skill derived-source ownership', () => { + it('keeps the versioned source outside every framework rollback catalog', () => { + const configRoot = join('/config', 'open-science') + const source = connectorSkillSourceRoot(configRoot) + + expect(source).toBe(join(configRoot, 'runtime-support', 'connector-skills-v1')) + expect(source).not.toContain(`${join(configRoot, 'claude', 'skills')}`) + expect(source).not.toContain(`${join(configRoot, 'opencode', 'config', 'opencode', 'skills')}`) + expect(source).not.toContain(`${join(configRoot, 'codex', 'skills')}`) + expect(source).not.toContain(`${join(configRoot, 'codex-subscription', 'skills')}`) + }) + + it('routes every production source consumer through the one path owner', () => { + const sources = productionSources() + const consumers = sources + .filter((path) => readSource(path).includes('connectorSkillSourceRoot')) + .map(portablePath) + + expect(consumers).toEqual([ + 'src/main/connectors/skill-source.ts', + 'src/main/ipc.ts', + 'src/main/settings/agent-runtime-manager.ts' + ]) + expect( + sources + .filter((path) => readSource(path).includes('new ConnectorRuntimeSettingsProjection')) + .map(portablePath) + ).toEqual(['src/main/ipc.ts']) + + const ipc = readSource(resolve(projectRoot, 'src/main/ipc.ts')) + expect(ipc).toContain('skillsDir: connectorSkillSourceRoot(resolveStorageRoot())') + expect(ipc).not.toContain('skillsDir: join(getAppClaudeConfigDir(resolveStorageRoot())') + + const runtimeManager = readSource( + resolve(projectRoot, 'src/main/settings/agent-runtime-manager.ts') + ) + expect(runtimeManager).toContain( + "join(connectorSkillSourceRoot(storageRoot), skillName, 'SKILL.md')" + ) + expect(runtimeManager).not.toContain( + "join(getAppClaudeConfigDir(storageRoot), 'skills', skillName, 'SKILL.md')" + ) + }) +}) diff --git a/src/main/connectors/skill-source.ts b/src/main/connectors/skill-source.ts new file mode 100644 index 000000000..ceb9095e8 --- /dev/null +++ b/src/main/connectors/skill-source.ts @@ -0,0 +1,8 @@ +import { join } from 'node:path' + +// Live Connector discovery produces rebuildable Skill documents here. This source is deliberately +// separate from every framework profile so refreshing or cleaning it cannot mutate rollback state. +const connectorSkillSourceRoot = (configRoot: string): string => + join(configRoot, 'runtime-support', 'connector-skills-v1') + +export { connectorSkillSourceRoot } diff --git a/src/main/delegation/durable-delegated-work.test.ts b/src/main/delegation/durable-delegated-work.test.ts index 6cf5adf55..113d26346 100644 --- a/src/main/delegation/durable-delegated-work.test.ts +++ b/src/main/delegation/durable-delegated-work.test.ts @@ -450,7 +450,10 @@ describe('durable delegated work', () => { env: { OPENAI_API_KEY: 'admission-memory-secret' } } as never const backendLease = { - claim: vi.fn(() => ({ backend, release: claimReleases[nextClaim++] })), + claim: vi.fn(() => ({ + acquireAttemptBackend: vi.fn(async () => backend), + release: claimReleases[nextClaim++] + })), release: admissionRelease } const resolveExecutionModel = vi.fn(async () => ({ snapshot, backendLease })) @@ -469,10 +472,7 @@ describe('durable delegated work', () => { await expect.poll(() => execution.controls()).toHaveLength(2) expect(backendLease.claim).toHaveBeenCalledTimes(2) expect(admissionRelease).toHaveBeenCalledOnce() - expect(execution.controls().map(({ input }) => input.executionBackend)).toEqual([ - backend, - backend - ]) + expect(execution.controls().every(({ input }) => input.acquireExecutionBackend)).toBe(true) expect(execution.controls().map(({ input }) => input.executionModel)).toEqual([ expect.objectContaining({ providerId: 'provider-b', diff --git a/src/main/delegation/durable-delegated-work.ts b/src/main/delegation/durable-delegated-work.ts index 2263df6c5..ea00b5be4 100644 --- a/src/main/delegation/durable-delegated-work.ts +++ b/src/main/delegation/durable-delegated-work.ts @@ -209,7 +209,12 @@ const createDurableDelegatedWork = ( attemptId: attempt.id, runtimeSegmentId, executionModel: attempt.executionModel!, - ...(executionBackendClaim ? { executionBackend: executionBackendClaim.backend } : {}), + ...(executionBackendClaim + ? { + acquireExecutionBackend: (request) => + executionBackendClaim.acquireAttemptBackend(request) + } + : {}), task, inputs: child.inputs, ...(workspace ? { workspaceCwd: workspace.cwd } : {}), diff --git a/src/main/delegation/execution-backend-lease.test.ts b/src/main/delegation/execution-backend-lease.test.ts index 7edc459e3..e90b9f30b 100644 --- a/src/main/delegation/execution-backend-lease.test.ts +++ b/src/main/delegation/execution-backend-lease.test.ts @@ -1,23 +1,93 @@ import { describe, expect, it, vi } from 'vitest' -import { opencodeFramework, type ResolvedAgentBackend } from '../agent-framework' +import { + claudeCodeFramework, + codexFramework, + opencodeFramework, + skillRuntimeEnvironment, + type ResolvedAgentBackend, + type SkillRuntimeView +} from '../agent-framework' import { createDelegateExecutionBackendLease } from './execution-backend-lease' +const skillRuntimeView = (projectionRoot: string): SkillRuntimeView => ({ + projectionRoot, + discoveryRoot: `${projectionRoot}/skills`, + descriptors: [ + { + id: 'research', + name: 'Research', + description: 'Research primary sources.', + path: `${projectionRoot}/skills/research/SKILL.md` + } + ], + environment: { + TMPDIR: `${projectionRoot}/tmp`, + XDG_CACHE_HOME: `${projectionRoot}/cache` + } +}) + describe('delegated execution backend lease', () => { it('keeps one secret backend owner across batch claims and releases it exactly once', async () => { const release = vi.fn(async () => undefined) + const releaseSkillRuntime = vi.fn(async () => undefined) + const parentRuntime = skillRuntimeView('/runtime/parent/catalog') const backend: ResolvedAgentBackend = { framework: opencodeFramework, executablePath: '/fake-opencode', - env: { OPENAI_API_KEY: 'process-memory-only' }, - providerTransportLease: { setTarget: () => true, release } + env: { + OPENAI_API_KEY: 'process-memory-only', + ...skillRuntimeEnvironment(parentRuntime), + OPENCODE_CONFIG_CONTENT: JSON.stringify({ + permission: { '*': 'ask' }, + skills: { paths: [parentRuntime.discoveryRoot] } + }) + }, + providerTransportLease: { setTarget: () => true, release }, + skillRuntime: parentRuntime, + skillRuntimeLease: { release: releaseSkillRuntime }, + skillRuntimeFork: { + acquire: vi.fn(async (lifecycle) => { + const releaseAttempt = attemptReleases[nextAttempt++]! + return { + view: skillRuntimeView(`/runtime/${lifecycle.agentFrameId}/catalog`), + lease: { release: releaseAttempt } + } + }) + } } + const attemptReleases = [vi.fn(async () => undefined), vi.fn(async () => undefined)] + let nextAttempt = 0 const admission = createDelegateExecutionBackendLease(backend) const first = admission.claim() const second = admission.claim() - expect(first.backend.env.OPENAI_API_KEY).toBe('process-memory-only') - expect(first.backend.providerTransportLease).toBeUndefined() + const firstBackend = await first.acquireAttemptBackend({ + lifecycle: { sessionId: 'session-1', agentFrameId: 'frame-1', runtimeSegmentId: 'runtime-1' } + }) + const firstBackendAgain = await first.acquireAttemptBackend({ + lifecycle: { sessionId: 'ignored', agentFrameId: 'ignored', runtimeSegmentId: 'ignored' } + }) + const secondBackend = await second.acquireAttemptBackend({ + lifecycle: { sessionId: 'session-1', agentFrameId: 'frame-2', runtimeSegmentId: 'runtime-2' } + }) + expect(firstBackend.env.OPENAI_API_KEY).toBe('process-memory-only') + expect(firstBackendAgain).toBe(firstBackend) + expect(backend.skillRuntimeFork?.acquire).toHaveBeenCalledTimes(2) + expect(firstBackend.providerTransportLease).toBeUndefined() + expect(firstBackend.skillRuntime?.projectionRoot).not.toBe( + secondBackend.skillRuntime?.projectionRoot + ) + expect(firstBackend.skillRuntime?.environment.TMPDIR).not.toBe( + secondBackend.skillRuntime?.environment.TMPDIR + ) + expect(JSON.parse(firstBackend.env.OPENCODE_CONFIG_CONTENT ?? '{}').skills.paths).toEqual([ + firstBackend.skillRuntime?.discoveryRoot + ]) + expect(JSON.parse(backend.env.OPENCODE_CONFIG_CONTENT ?? '{}').skills.paths).toEqual([ + parentRuntime.discoveryRoot + ]) + expect(firstBackend.skillRuntimeLease).toBeUndefined() await admission.release() await admission.release() await first.release() @@ -25,5 +95,188 @@ describe('delegated execution backend lease', () => { await second.release() await second.release() expect(release).toHaveBeenCalledOnce() + expect(releaseSkillRuntime).toHaveBeenCalledOnce() + expect(attemptReleases[0]).toHaveBeenCalledOnce() + expect(attemptReleases[1]).toHaveBeenCalledOnce() + }) + + it.each([ + ['Claude Code', claudeCodeFramework], + ['Codex', codexFramework], + ['OpenCode', opencodeFramework] + ] as const)( + 'rebases every %s native Skill surface onto immutable private Attempt views', + async (_name, framework) => { + const parentRuntime = skillRuntimeView('/runtime/parent/catalog') + const parentEnv = { + PROVIDER_SECRET: 'process-memory-only', + ...skillRuntimeEnvironment(parentRuntime), + ...(framework.id === 'opencode' + ? { + OPENCODE_CONFIG_CONTENT: JSON.stringify({ + model: 'provider/model', + permission: { '*': 'ask' }, + skills: { paths: [parentRuntime.discoveryRoot] } + }) + } + : {}) + } + const attemptReleases = [vi.fn(async () => undefined), vi.fn(async () => undefined)] + let nextAttempt = 0 + const backend: ResolvedAgentBackend = { + framework, + executablePath: `/fake-${framework.id}`, + env: parentEnv, + skillRuntime: parentRuntime, + skillRuntimeLease: { release: vi.fn(async () => undefined) }, + skillRuntimeFork: { + acquire: vi.fn(async (lifecycle) => ({ + view: skillRuntimeView( + `/runtime/attempts/${lifecycle.agentFrameId}/${lifecycle.runtimeSegmentId}/catalog` + ), + lease: { release: attemptReleases[nextAttempt++]! } + })) + } + } + const admission = createDelegateExecutionBackendLease(backend) + const first = admission.claim() + const second = admission.claim() + + const firstBackend = await first.acquireAttemptBackend({ + lifecycle: { sessionId: 'session', agentFrameId: 'frame-1', runtimeSegmentId: 'segment-1' } + }) + const secondBackend = await second.acquireAttemptBackend({ + lifecycle: { sessionId: 'session', agentFrameId: 'frame-2', runtimeSegmentId: 'segment-2' } + }) + + for (const attemptBackend of [firstBackend, secondBackend]) { + const view = attemptBackend.skillRuntime! + expect(attemptBackend.env).toMatchObject({ + PROVIDER_SECRET: 'process-memory-only', + ...view.environment, + OPEN_SCIENCE_SKILL_RUNTIME_ROOT: view.discoveryRoot, + OPEN_SCIENCE_SKILL_DISCOVERY_ROOT: view.discoveryRoot, + OPEN_SCIENCE_SKILL_PROJECTION_ROOT: view.projectionRoot + }) + expect(view.descriptors).toEqual([ + expect.objectContaining({ path: `${view.discoveryRoot}/research/SKILL.md` }) + ]) + expect(JSON.stringify(attemptBackend.env)).not.toContain(parentRuntime.projectionRoot) + + if (framework.id === 'opencode') { + expect(attemptBackend.env.XDG_CONFIG_HOME).toBe( + `${view.environment.TMPDIR}/opencode-config` + ) + expect(JSON.parse(attemptBackend.env.OPENCODE_CONFIG_CONTENT ?? '{}')).toMatchObject({ + model: 'provider/model', + permission: { '*': 'ask' }, + skills: { paths: [view.discoveryRoot] } + }) + } + if (framework.id === 'claude-code') { + const setup = framework.buildSessionSetup({ + systemPromptAppends: [], + skillRuntime: view + }) + const options = (setup.meta?.claudeCode as { options: Record }).options + expect(options).toMatchObject({ + plugins: [{ type: 'local', path: view.projectionRoot }], + additionalDirectories: [view.projectionRoot], + sandbox: { + filesystem: { + allowRead: [view.projectionRoot], + denyWrite: [view.projectionRoot] + } + } + }) + expect(JSON.stringify(options)).not.toContain(parentRuntime.projectionRoot) + } + } + expect(firstBackend.skillRuntime?.projectionRoot).not.toBe( + secondBackend.skillRuntime?.projectionRoot + ) + expect(backend.env).toEqual(parentEnv) + expect(backend.skillRuntime).toBe(parentRuntime) + + await admission.release() + await Promise.all([first.release(), second.release()]) + expect(attemptReleases[0]).toHaveBeenCalledOnce() + expect(attemptReleases[1]).toHaveBeenCalledOnce() + } + ) + + it('retries a transient attempt runtime cleanup failure before releasing admission', async () => { + const releaseSkillRuntime = vi.fn(async () => undefined) + const releaseAttempt = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error('transient cleanup failure')) + .mockResolvedValue(undefined) + const backend: ResolvedAgentBackend = { + framework: opencodeFramework, + executablePath: '/fake-opencode', + env: {}, + skillRuntime: { + projectionRoot: '/runtime/catalog', + discoveryRoot: '/runtime/catalog/skills', + descriptors: [], + environment: { TMPDIR: '/runtime/base/tmp' } + }, + skillRuntimeLease: { release: releaseSkillRuntime }, + skillRuntimeFork: { + acquire: vi.fn(async () => ({ + view: { + ...backend.skillRuntime!, + environment: { TMPDIR: '/runtime/attempt/tmp' } + }, + lease: { release: releaseAttempt } + })) + } + } + const admission = createDelegateExecutionBackendLease(backend) + const claim = admission.claim() + + await claim.acquireAttemptBackend({ + lifecycle: { sessionId: 'session-1', agentFrameId: 'frame-1', runtimeSegmentId: 'runtime-1' } + }) + await admission.release() + await expect(claim.release()).resolves.toBeUndefined() + + expect(releaseAttempt).toHaveBeenCalledTimes(2) + expect(releaseSkillRuntime).toHaveBeenCalledOnce() + }) + + it('releases a private Attempt runtime when framework rebasing fails closed', async () => { + const parentRuntime = skillRuntimeView('/runtime/parent/catalog') + const releaseAttempt = vi.fn(async () => undefined) + const releaseSkillRuntime = vi.fn(async () => undefined) + const backend: ResolvedAgentBackend = { + framework: opencodeFramework, + executablePath: '/fake-opencode', + env: { + ...skillRuntimeEnvironment(parentRuntime), + OPENCODE_CONFIG_CONTENT: '{malformed' + }, + skillRuntime: parentRuntime, + skillRuntimeLease: { release: releaseSkillRuntime }, + skillRuntimeFork: { + acquire: vi.fn(async () => ({ + view: skillRuntimeView('/runtime/attempt/catalog'), + lease: { release: releaseAttempt } + })) + } + } + const admission = createDelegateExecutionBackendLease(backend) + const claim = admission.claim() + + await expect( + claim.acquireAttemptBackend({ + lifecycle: { sessionId: 'session', agentFrameId: 'frame', runtimeSegmentId: 'segment' } + }) + ).rejects.toThrow('Cannot rebase OpenCode Skill runtime') + await admission.release() + await claim.release() + + expect(releaseAttempt).toHaveBeenCalledOnce() + expect(releaseSkillRuntime).toHaveBeenCalledOnce() }) }) diff --git a/src/main/delegation/execution-backend-lease.ts b/src/main/delegation/execution-backend-lease.ts index 8f3e3b9b8..b29a7ed00 100644 --- a/src/main/delegation/execution-backend-lease.ts +++ b/src/main/delegation/execution-backend-lease.ts @@ -1,18 +1,30 @@ -import { releaseResolvedAgentBackendLeases, type ResolvedAgentBackend } from '../agent-framework' +import { + rebaseResolvedAgentBackendSkillRuntime, + releaseResolvedAgentBackendLeases, + type ResolvedAgentBackend +} from '../agent-framework' import type { DelegateExecutionBackendClaim, DelegateExecutionBackendLease } from './execution-port' +const releaseAttemptRuntime = async (release: () => Promise): Promise => { + let lastError: unknown + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + await release() + return + } catch (error) { + lastError = error + } + } + throw lastError +} + // The underlying bridge/transport leases have one owner regardless of batch width. Child runtimes -// receive a lease-free backend view and keep the owner alive through explicit in-memory claims. +// receive a lease-free backend rebound to their private runtime through the framework seam and keep +// the owner alive through explicit in-memory claims. // Secrets remain in this process-local object and can never enter a durable Attempt record. const createDelegateExecutionBackendLease = ( backend: ResolvedAgentBackend ): DelegateExecutionBackendLease => { - const runtimeBackend: ResolvedAgentBackend = Object.freeze({ - ...backend, - responsesBridgeLease: undefined, - anthropicBridgeLease: undefined, - providerTransportLease: undefined - }) let references = 1 let underlyingRelease: Promise | undefined let admissionReleased = false @@ -31,13 +43,54 @@ const createDelegateExecutionBackendLease = ( claim(): DelegateExecutionBackendClaim { if (references <= 0) throw new Error('Delegated execution backend admission has closed.') references += 1 - let released = false + let attemptRuntime: + Promise }>> | undefined + let releasePromise: Promise | undefined return Object.freeze({ - backend: runtimeBackend, - async release(): Promise { - if (released) return - released = true - await releaseReference() + acquireAttemptBackend(request): Promise { + if (releasePromise) { + return Promise.reject(new Error('Delegated execution backend claim has closed.')) + } + if (!backend.skillRuntime || !backend.skillRuntimeFork) { + return Promise.reject( + new Error('The admitted delegated backend has no forkable Skill runtime.') + ) + } + attemptRuntime ??= backend.skillRuntimeFork + .acquire(request.lifecycle) + .then(async (runtime) => { + try { + const rebound = rebaseResolvedAgentBackendSkillRuntime(backend, runtime.view) + return Object.freeze({ + backend: Object.freeze({ + ...rebound, + skillRuntimeFork: undefined, + responsesBridgeLease: undefined, + anthropicBridgeLease: undefined, + providerTransportLease: undefined, + skillRuntimeLease: undefined + }), + release: () => runtime.lease.release() + }) + } catch (error) { + await releaseAttemptRuntime(() => runtime.lease.release()) + throw error + } + }) + return attemptRuntime.then((runtime) => runtime.backend) + }, + release(): Promise { + releasePromise ??= (async () => { + try { + if (attemptRuntime) { + const runtime = await attemptRuntime.catch(() => undefined) + if (runtime) await releaseAttemptRuntime(runtime.release) + } + } finally { + await releaseReference() + } + })() + return releasePromise } }) }, diff --git a/src/main/delegation/execution-port.ts b/src/main/delegation/execution-port.ts index e20ae150c..0138c34ef 100644 --- a/src/main/delegation/execution-port.ts +++ b/src/main/delegation/execution-port.ts @@ -73,9 +73,11 @@ type DelegateExecutionInput = Readonly<{ attemptId: string runtimeSegmentId: string executionModel?: ResolvedSubagentModelSnapshot - // Admission-only capability. It is never persisted; the durable owner releases it after this - // Attempt settles, while the production runtime consumes only the lease-free backend view. - executionBackend?: ResolvedAgentBackend + // Admission-only capability. It is never persisted. The durable owner retains release authority, + // while the production runtime can acquire exactly one lease-free backend for this Attempt. + acquireExecutionBackend?: ( + request: DelegateExecutionAttemptBackendRequest + ) => Promise task: string inputs: readonly string[] workspaceCwd?: string @@ -86,8 +88,18 @@ type DelegateExecutionInput = Readonly<{ turn?: DelegateChildTurnIdentity }> +type DelegateExecutionAttemptBackendRequest = Readonly<{ + lifecycle: Readonly<{ + sessionId: string + agentFrameId: string + runtimeSegmentId: string + }> +}> + type DelegateExecutionBackendClaim = Readonly<{ - backend: ResolvedAgentBackend + acquireAttemptBackend( + request: DelegateExecutionAttemptBackendRequest + ): Promise release(): Promise }> @@ -142,6 +154,7 @@ export type { DelegateCapacityReservation, DelegateExecutionBackendClaim, DelegateExecutionBackendLease, + DelegateExecutionAttemptBackendRequest, DelegateExecution, DelegateExecutionErrorCode, DelegateExecutionEvent, diff --git a/src/main/delegation/production-framework-runtime.test.ts b/src/main/delegation/production-framework-runtime.test.ts index 566e175a8..7e8878539 100644 --- a/src/main/delegation/production-framework-runtime.test.ts +++ b/src/main/delegation/production-framework-runtime.test.ts @@ -140,6 +140,86 @@ const delegatedSession = (frameworkId: AgentFrameworkId): PersistedChatSession = }) describe('production delegated framework runtime bridge', () => { + it('keeps the admitted Skill Runtime in delegated branch Session setup', async () => { + const dataRoot = await mkdtemp(join(tmpdir(), 'delegated-framework-skill-runtime-')) + const workspaceCwd = await mkdtemp(join(tmpdir(), 'delegated-framework-workspace-')) + const durable = delegatedSession('claude-code') + const buildSessionSetup = vi.fn((input) => claudeCodeFramework.buildSessionSetup(input)) + const skillRuntime = { + projectionRoot: '/runtime/projection', + discoveryRoot: '/runtime/projection/skills', + descriptors: [], + environment: { XDG_CACHE_HOME: '/runtime/cache' } + } + const admittedBackend: ResolvedAgentBackend = { + ...backend('claude-code'), + framework: { ...claudeCodeFramework, buildSessionSetup }, + skillRuntime + } + const acquireExecutionBackend = vi.fn(async () => admittedBackend) + const frameworks = createProductionDelegatedFrameworkRuntime({ + capacity: 1, + dataRoot, + runtime: { settingsService: {} } as never, + notebookRpcServer: () => + ({ + issueDelegatedNotebookConnection: async () => ({ + endpoint: 'http://127.0.0.1:1', + token: 'attempt-token', + release: () => undefined, + revoke: async () => undefined + }) + }) as never, + readSession: async () => durable + }) + + try { + const selected = await frameworks.forSession(durable) + const reservation = await selected.execution.reserve(1) + const running = selected.execution.run( + { + session: { projectId: durable.projectId, sessionId: durable.id }, + frameId: 'child-frame', + attemptId: 'attempt-1', + runtimeSegmentId: 'runtime-1', + executionModel: { + frameworkId: 'claude-code', + providerId: 'provider-a', + backendId: 'claude-code:provider-a', + modelRoute: 'claude-anthropic', + model: 'model-a', + reasoningEffort: 'default' + }, + acquireExecutionBackend, + task: 'Investigate', + inputs: [], + workspaceCwd, + continuation: true + }, + reservation.slotIds[0] + ) + + await vi.waitFor(() => expect(buildSessionSetup).toHaveBeenCalled()) + expect(acquireExecutionBackend).toHaveBeenCalledWith({ + lifecycle: { + sessionId: durable.id, + agentFrameId: 'child-frame', + runtimeSegmentId: 'runtime-1' + } + }) + expect(buildSessionSetup.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ skillRuntime }) + ) + await running.cancel() + await running.completion.catch(() => undefined) + } finally { + await Promise.all([ + rm(dataRoot, { recursive: true, force: true }), + rm(workspaceCwd, { recursive: true, force: true }) + ]) + } + }) + it('prepares an admitted Attempt from its transient backend after the provider was deleted', async () => { const dataRoot = await mkdtemp(join(tmpdir(), 'delegated-framework-deleted-provider-')) const workspaceCwd = await mkdtemp(join(tmpdir(), 'delegated-framework-workspace-')) @@ -180,7 +260,7 @@ describe('production delegated framework runtime bridge', () => { attemptId: 'attempt-1', runtimeSegmentId: 'runtime-1', executionModel, - executionBackend: admittedBackend, + acquireExecutionBackend: async () => admittedBackend, task: 'Investigate', inputs: [], workspaceCwd, diff --git a/src/main/delegation/production-framework-runtime.ts b/src/main/delegation/production-framework-runtime.ts index 2e942bc00..9a37ef9f8 100644 --- a/src/main/delegation/production-framework-runtime.ts +++ b/src/main/delegation/production-framework-runtime.ts @@ -48,6 +48,7 @@ type ProductionFrameworkRuntimeOptions = Readonly<{ const openCodeModelConfig = (backend: ResolvedAgentBackend): AgentModelConfig => ({ env: { ...backend.env }, + ...(backend.skillRuntime ? { skillRuntime: backend.skillRuntime } : {}), configFiles: [ { path: 'opencode.json', @@ -62,7 +63,8 @@ const sessionSetup = (backend: ResolvedAgentBackend): SessionSetup => ...(backend.systemPromptAppends ?? []), ...(backend.persistentSystemPrompt ? [backend.persistentSystemPrompt] : []) ], - ...(backend.sessionOptions ? { sessionOptions: backend.sessionOptions } : {}) + ...(backend.sessionOptions ? { sessionOptions: backend.sessionOptions } : {}), + ...(backend.skillRuntime ? { skillRuntime: backend.skillRuntime } : {}) }) const createProductionDelegatedFrameworkRuntime = ( @@ -92,13 +94,28 @@ const createProductionDelegatedFrameworkRuntime = ( throw new Error('Delegated Attempt has no admitted model snapshot.') } const resolveAdmitted = options.runtime.settingsService.resolveAdmittedSubagentBackend - if (!input.executionBackend && !resolveAdmitted) { + if (!input.acquireExecutionBackend && !resolveAdmitted) { throw new Error('Admitted delegated backend resolution is unavailable.') } - const releaseResolvedBackend = input.executionBackend === undefined + const releaseResolvedBackend = input.acquireExecutionBackend === undefined const backend = - input.executionBackend ?? - (await resolveAdmitted!.call(options.runtime.settingsService, input.executionModel)) + (await input.acquireExecutionBackend?.({ + lifecycle: { + sessionId: input.session.sessionId, + agentFrameId: input.frameId, + runtimeSegmentId: input.runtimeSegmentId + } + })) ?? + (await resolveAdmitted!.call(options.runtime.settingsService, input.executionModel, { + skillRuntime: { + lifecycle: { + sessionId: input.session.sessionId, + agentFrameId: input.frameId, + runtimeSegmentId: input.runtimeSegmentId + }, + scope: { kind: 'subagent' } + } + })) if (backend.framework.id !== frameworkId) { if (releaseResolvedBackend) await releaseResolvedAgentBackendLeases(backend) throw new Error('Resolved delegated backend changed framework during admission.') diff --git a/src/main/ipc.ts b/src/main/ipc.ts index e92b718b8..133cb533a 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -66,6 +66,7 @@ import { isCustomMcpServerRouteSafe, toCustomMcpConfig } from './connectors/cust import { createMoleculePreviewHandler } from './connectors/molecule-preview' import { ALL_CONNECTOR_IDS } from './connectors/registry' import { ConnectorRuntimeSettingsProjection } from './connectors/runtime-settings-projection' +import { connectorSkillSourceRoot } from './connectors/skill-source' import { ConnectorService } from './connectors/service' import { registerFileSaveHandlers } from './file-save' import { ImmutableInputAuthority } from './immutable-input-authority' @@ -196,7 +197,6 @@ import { SETTINGS_INSTALL_LOG_CHANNEL, registerSettingsIpcHandlers } from './set import { registerLocalFsIpcHandlers } from './local-fs/ipc' import { GrantedLocalRootsRepository } from './local-fs/granted-roots-repository' import { LocalFsService } from './local-fs/service' -import { getAppClaudeConfigDir } from './settings/provider-env' import { SettingsService } from './settings/service' import { SettingsRepository } from './settings/repository' import { NetworkProxyRuntime } from './settings/network-proxy-runtime' @@ -1087,7 +1087,7 @@ const createApplicationModules = async ( }) const connectorRuntimeSettings = new ConnectorRuntimeSettingsProjection({ readConnectors: () => settingsService.getConnectors(), - skillsDir: join(getAppClaudeConfigDir(resolveStorageRoot()), 'skills'), + skillsDir: connectorSkillSourceRoot(resolveStorageRoot()), mcpClientManager, notifyStatusChanged: () => broadcastToRenderers('settings:connector-runtime-changed', undefined) }) @@ -1215,7 +1215,8 @@ const createApplicationModules = async ( // The durable repair and cache projection have committed; lifecycle delivery is best effort. } } - } + }, + requestSkillCatalogRefresh } ) surfaceAdapters = beforeAcpAdapters diff --git a/src/main/settings/agent-runtime-manager.test.ts b/src/main/settings/agent-runtime-manager.test.ts index b659e737f..99fa89d0f 100644 --- a/src/main/settings/agent-runtime-manager.test.ts +++ b/src/main/settings/agent-runtime-manager.test.ts @@ -1,16 +1,19 @@ -import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { chmod, lstat, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { createHash } from 'node:crypto' import { tmpdir } from 'node:os' import { dirname, join, posix } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ClaudeInstallEvent } from '../../shared/settings' +import type { ComputeHost } from '../../shared/compute' import type { ConnectorSettingsModule } from './connector-settings' import type { ClaudeDetectDeps } from './claude-detect' import type { CodexDetectDeps } from './codex-detect' import type { OpencodeDetectDeps } from './opencode-detect' -import type { ProviderPreflightAccess } from './agent-runtime-manager' +import type { ExecuteClaudeProbe, ProviderPreflightAccess } from './agent-runtime-manager' import type { SkillCatalogModule } from './skill-catalog' +import { connectorSkillSourceRoot } from '../connectors/skill-source' vi.mock('electron', () => ({ safeStorage: { @@ -26,6 +29,7 @@ const { SettingsRepository } = await import('./repository') const { getAppClaudeConfigDir } = await import('./provider-env') const { managedClaudeDir } = await import('./managed-claude') const { managedOpencodeDir } = await import('./managed-opencode') +const { parseFrontmatter } = await import('../skills/frontmatter') type Repository = InstanceType type ManagerOptions = ConstructorParameters[0] @@ -44,6 +48,13 @@ const createInventory = (): RuntimeInventory => ({ codexNative: new Map() }) +const makeTreeWritable = async (directory: string): Promise => { + for (const entry of await readdir(directory, { withFileTypes: true }).catch(() => [])) { + if (entry.isDirectory()) await makeTreeWritable(join(directory, entry.name)) + } + await chmod(directory, 0o755).catch(() => undefined) +} + const createClaudeDeps = (inventory: RuntimeInventory): ClaudeDetectDeps => ({ env: {}, homePath: '/home', @@ -94,7 +105,8 @@ describe('AgentRuntimeManager', () => { provisionClaudeConfig = vi.fn().mockResolvedValue(undefined) const skills = { materializeSkills: vi.fn().mockResolvedValue(undefined), - provisionClaudeConfig + provisionClaudeConfig, + runtimeProjectionCatalog: vi.fn().mockResolvedValue([]) } as unknown as SkillCatalogModule const connectors = { getConnectors: vi.fn().mockResolvedValue(undefined), @@ -138,6 +150,7 @@ describe('AgentRuntimeManager', () => { afterEach(async () => { vi.restoreAllMocks() + await makeTreeWritable(storageRoot) await rm(storageRoot, { recursive: true, force: true }) }) @@ -391,12 +404,31 @@ describe('AgentRuntimeManager', () => { expect((await repository.getSettings()).opencodePath).toBeUndefined() }) - it('provisions the runtime and preserves the shared versus isolated Claude probe contracts', async () => { - const executeClaudeProbe = vi.fn().mockResolvedValue(undefined) - manager = createManager({ executeClaudeProbe }) + it('validates shared and isolated Claude auth without materializing or loading legacy Skills', async () => { + let isolatedProbeConfigDir: string | undefined + const executeClaudeProbe = vi.fn(async (_executablePath, env) => { + if (!env.CLAUDE_CODE_OAUTH_TOKEN) return + isolatedProbeConfigDir = env.CLAUDE_CONFIG_DIR + expect(isolatedProbeConfigDir).toBeTruthy() + await expect(lstat(isolatedProbeConfigDir!)).resolves.toMatchObject({}) + await expect(readFile(join(isolatedProbeConfigDir!, 'settings.json'), 'utf8')).resolves.toBe( + '{"disableBundledSkills":true}\n' + ) + await expect(readdir(join(isolatedProbeConfigDir!, 'skills'))).resolves.toEqual([]) + }) + const syncComputeSkillDocument = vi.fn().mockResolvedValue(undefined) + manager = createManager({ executeClaudeProbe, syncComputeSkillDocument }) + provisionClaudeConfig.mockImplementation(async (configDir) => { + await mkdir(join(configDir, 'skills'), { recursive: true }) + await writeFile(join(configDir, 'settings.json'), '{"disableBundledSkills":true}\n') + }) const executablePath = join(storageRoot, 'bin', 'claude') await repository.setClaudeInfo({ resolvedPath: executablePath, version: '2.1.0' }) const settings = await repository.getSettings() + const stableConfigDir = getAppClaudeConfigDir(storageRoot) + const legacySkillDocument = join(stableConfigDir, 'skills', 'legacy', 'SKILL.md') + await mkdir(dirname(legacySkillDocument), { recursive: true }) + await writeFile(legacySkillDocument, 'legacy rollback skill') await expect( manager.runClaudeSubscriptionProbe( @@ -411,22 +443,73 @@ describe('AgentRuntimeManager', () => { ) ).resolves.toEqual({ ok: true, category: 'ok' }) - const configDir = getAppClaudeConfigDir(storageRoot) expect(provisionClaudeConfig).toHaveBeenCalledTimes(2) + expect(provisionClaudeConfig).toHaveBeenNthCalledWith(1, stableConfigDir, [], undefined, false) + expect(provisionClaudeConfig).toHaveBeenNthCalledWith( + 2, + isolatedProbeConfigDir, + [], + undefined, + false + ) + expect( + isolatedProbeConfigDir?.startsWith( + join(storageRoot, 'runtime', 'claude-probes', 'v1', 'probe-') + ) + ).toBe(true) + expect(isolatedProbeConfigDir).not.toBe(stableConfigDir) + expect(syncComputeSkillDocument).not.toHaveBeenCalled() expect(executeClaudeProbe).toHaveBeenNthCalledWith( 1, executablePath, - expect.objectContaining({ CLAUDE_CONFIG_DIR: join(storageRoot, 'user-claude') }), - ['--settings', join(configDir, 'settings.json'), '--plugin-dir', configDir] + expect.objectContaining({ + CLAUDE_CONFIG_DIR: join(storageRoot, 'user-claude'), + ANTHROPIC_MODEL: 'claude-sonnet' + }), + ['--settings', join(stableConfigDir, 'settings.json')] ) expect(executeClaudeProbe).toHaveBeenNthCalledWith( 2, executablePath, expect.objectContaining({ - CLAUDE_CONFIG_DIR: configDir, + CLAUDE_CONFIG_DIR: isolatedProbeConfigDir, + ANTHROPIC_MODEL: 'claude-sonnet', CLAUDE_CODE_OAUTH_TOKEN: 'setup-token' }) ) + expect(executeClaudeProbe.mock.calls.flatMap(([, , args]) => args ?? [])).not.toContain( + '--plugin-dir' + ) + await expect(readFile(legacySkillDocument, 'utf8')).resolves.toBe('legacy rollback skill') + await expect(lstat(isolatedProbeConfigDir!)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('cleans the disposable isolated Claude probe config after validation failure', async () => { + let isolatedProbeConfigDir: string | undefined + const error = Object.assign(new Error('request failed'), { stderr: 'HTTP 401 unauthorized' }) + const executeClaudeProbe = vi.fn(async (_executablePath, env) => { + isolatedProbeConfigDir = env.CLAUDE_CONFIG_DIR + await expect(lstat(isolatedProbeConfigDir!)).resolves.toMatchObject({}) + throw error + }) + manager = createManager({ executeClaudeProbe }) + await repository.setClaudeInfo({ resolvedPath: '/bin/claude', version: '2.1.0' }) + + await expect( + manager.runClaudeSubscriptionProbe( + { type: 'claude-isolated', key: 'setup-token' }, + await repository.getSettings() + ) + ).resolves.toEqual({ + ok: false, + category: 'auth', + message: + 'Claude rejected the setup token. Run `claude setup-token` again and paste a new token.' + }) + + expect(isolatedProbeConfigDir).toBeTruthy() + expect(isolatedProbeConfigDir).not.toBe(getAppClaudeConfigDir(storageRoot)) + await expect(lstat(isolatedProbeConfigDir!)).rejects.toMatchObject({ code: 'ENOENT' }) }) it('synchronizes the Compute host projection after each Skill provisioning path', async () => { @@ -444,9 +527,405 @@ describe('AgentRuntimeManager', () => { ) }) + it('preserves the legacy Claude Skill catalog when provisioning a new runtime session', async () => { + const syncComputeSkillDocument = vi.fn().mockResolvedValue(undefined) + manager = createManager({ syncComputeSkillDocument }) + const settings = await repository.getSettings() + + await manager.provisionClaudeRuntimeConfig(settings, new Set(), null, false) + + expect(provisionClaudeConfig).toHaveBeenCalledWith( + getAppClaudeConfigDir(storageRoot), + [], + null, + false + ) + expect(syncComputeSkillDocument).not.toHaveBeenCalled() + }) + + it('enumerates only current direct-child legacy Skill documents in stable order', async () => { + manager = createManager() + const configRoot = join(storageRoot, 'codex') + await mkdir(join(configRoot, 'skills', 'z-skill'), { recursive: true }) + await mkdir(join(configRoot, 'skills', 'a-skill'), { recursive: true }) + await mkdir(join(configRoot, 'skills', 'missing-document'), { recursive: true }) + await writeFile(join(configRoot, 'skills', 'z-skill', 'SKILL.md'), 'z') + await writeFile(join(configRoot, 'skills', 'a-skill', 'SKILL.md'), 'a') + + await expect(manager.listLegacyAgentSkillDocumentPaths(configRoot)).resolves.toEqual([ + join(configRoot, 'skills', 'a-skill', 'SKILL.md'), + join(configRoot, 'skills', 'z-skill', 'SKILL.md') + ]) + }) + + it('acquires an agent-facing runtime lease without changing the legacy Skill projection', async () => { + const sourceRoot = join(storageRoot, 'skill-source') + const legacyRoot = join(storageRoot, 'claude', 'skills') + await mkdir(sourceRoot, { recursive: true }) + await mkdir(legacyRoot, { recursive: true }) + await writeFile( + join(sourceRoot, 'SKILL.md'), + '---\nname: demo\ndescription: Demo Skill.\n---\n\nUse demo.\n' + ) + const legacySentinel = join(legacyRoot, 'legacy.txt') + await writeFile(legacySentinel, 'legacy') + const skills = { + materializeSkills: vi.fn().mockResolvedValue(undefined), + provisionClaudeConfig: vi.fn().mockResolvedValue(undefined), + runtimeProjectionCatalog: vi.fn().mockResolvedValue([ + { + id: 'demo', + name: 'demo', + displayName: 'Demo', + description: 'Demo Skill.', + source: 'featured', + updatedAt: '2026-08-14T00:00:00.000Z', + compatibility: 'sha256:demo-v1', + sourceDir: sourceRoot + } + ]) + } as unknown as SkillCatalogModule + manager = createManager({ skills }) + + const settings = await repository.getSettings() + settings.disabledSkillIds = ['demo'] + const lease = await manager.acquireAgentSkillRuntime(settings, { + lifecycle: { + sessionId: 'session-1', + agentFrameId: 'frame-1', + runtimeSegmentId: 'segment-1' + }, + scope: { kind: 'main' }, + forcedSkillIds: ['demo'] + }) + + expect(lease.skills.map((skill) => skill.name)).toEqual(['demo']) + expect(lease.projectionRoot).toContain(join(storageRoot, 'runtime', 'agent-skills', 'v1')) + await expect(readFile(legacySentinel, 'utf8')).resolves.toBe('legacy') + const attemptLease = await manager.forkAgentSkillRuntime( + lease, + { + sessionId: 'session-1', + agentFrameId: 'child-frame', + runtimeSegmentId: 'child-segment' + }, + { kind: 'subagent' } + ) + expect(attemptLease.projectionRoot).not.toBe(lease.projectionRoot) + expect(attemptLease.catalogRevision).toBe(lease.catalogRevision) + await expect(readFile(attemptLease.skills[0]!.skillDocumentPath, 'utf8')).resolves.toContain( + 'Use demo.' + ) + expect(attemptLease.env).not.toEqual(lease.env) + await Promise.all([lease.release(), attemptLease.release()]) + }) + + it('takes a fresh catalog snapshot when a later runtime segment discovers a new Skill', async () => { + const firstSource = join(storageRoot, 'dynamic-first-source') + const secondSource = join(storageRoot, 'dynamic-second-source') + await mkdir(firstSource, { recursive: true }) + await mkdir(secondSource, { recursive: true }) + await writeFile( + join(firstSource, 'SKILL.md'), + '---\nname: dynamic-first\ndescription: First dynamic Skill.\n---\n' + ) + await writeFile( + join(secondSource, 'SKILL.md'), + '---\nname: dynamic-second\ndescription: Second dynamic Skill.\n---\n' + ) + const first = { + id: 'dynamic-first', + name: 'dynamic-first', + description: 'First dynamic Skill.', + source: 'personal' as const, + updatedAt: '2026-08-14T00:00:00.000Z', + compatibility: 'sha256:dynamic-first', + sourceDir: firstSource + } + const second = { + id: 'dynamic-second', + name: 'dynamic-second', + description: 'Second dynamic Skill.', + source: 'personal' as const, + updatedAt: '2026-08-14T00:01:00.000Z', + compatibility: 'sha256:dynamic-second', + sourceDir: secondSource + } + const runtimeProjectionCatalog = vi + .fn() + .mockResolvedValueOnce([first]) + .mockResolvedValueOnce([first, second]) + const skills = { + materializeSkills: vi.fn().mockResolvedValue(undefined), + provisionClaudeConfig: vi.fn().mockResolvedValue(undefined), + runtimeProjectionCatalog + } as unknown as SkillCatalogModule + manager = createManager({ skills }) + const settings = await repository.getSettings() + + const earlier = await manager.acquireAgentSkillRuntime(settings, { + lifecycle: { + sessionId: 'session-dynamic', + agentFrameId: 'frame-main', + runtimeSegmentId: 'segment-before-install' + }, + scope: { kind: 'main' } + }) + const later = await manager.acquireAgentSkillRuntime(settings, { + lifecycle: { + sessionId: 'session-dynamic', + agentFrameId: 'frame-main', + runtimeSegmentId: 'segment-after-install' + }, + scope: { kind: 'main' } + }) + + expect(earlier.skills.map(({ id }) => id)).toEqual(['dynamic-first']) + expect(later.skills.map(({ id }) => id)).toEqual(['dynamic-first', 'dynamic-second']) + expect(later.catalogRevision).not.toBe(earlier.catalogRevision) + await expect(readFile(earlier.skills[0]!.skillDocumentPath, 'utf8')).resolves.toContain( + 'dynamic-first' + ) + expect(runtimeProjectionCatalog).toHaveBeenCalledTimes(2) + await earlier.release() + await later.release() + }) + + it('projects a disabled Skill when an exact Specialist scope authorizes it', async () => { + const sourceRoot = join(storageRoot, 'specialist-skill-source') + await mkdir(sourceRoot, { recursive: true }) + await writeFile( + join(sourceRoot, 'SKILL.md'), + '---\nname: specialist-demo\ndescription: Specialist demo.\n---\n' + ) + const skills = { + materializeSkills: vi.fn().mockResolvedValue(undefined), + provisionClaudeConfig: vi.fn().mockResolvedValue(undefined), + runtimeProjectionCatalog: vi.fn().mockResolvedValue([ + { + id: 'specialist-demo', + name: 'specialist-demo', + displayName: 'Specialist Demo', + description: 'Specialist demo.', + source: 'personal', + updatedAt: '2026-08-14T00:00:00.000Z', + compatibility: 'sha256:specialist-demo-v1', + sourceDir: sourceRoot + } + ]) + } as unknown as SkillCatalogModule + manager = createManager({ skills }) + const settings = await repository.getSettings() + settings.disabledSkillIds = ['specialist-demo'] + + const lease = await manager.acquireAgentSkillRuntime(settings, { + lifecycle: { + sessionId: 'session-1', + agentFrameId: 'frame-specialist', + runtimeSegmentId: 'segment-specialist' + }, + scope: { kind: 'specialist' }, + allowedSkillIds: ['specialist-demo'] + }) + + expect(lease.skills.map((skill) => skill.id)).toEqual(['specialist-demo']) + await lease.release() + }) + + it('projects an exactly authorized bundled Connector from its generated Skill document', async () => { + const packageSource = join(storageRoot, 'package-source') + await mkdir(packageSource, { recursive: true }) + await writeFile( + join(packageSource, 'SKILL.md'), + '---\nname: package-demo\ndescription: Package demo.\n---\n' + ) + const skills = { + materializeSkills: vi.fn().mockResolvedValue(undefined), + provisionClaudeConfig: vi.fn().mockResolvedValue(undefined), + runtimeProjectionCatalog: vi.fn().mockResolvedValue([ + { + id: 'package-demo', + name: 'package-demo', + description: 'Package demo.', + source: 'featured', + updatedAt: '2026-08-14T00:00:00.000Z', + compatibility: 'sha256:package-demo-v1', + sourceDir: packageSource + } + ]) + } as unknown as SkillCatalogModule + const connectors = { + getConnectors: vi.fn().mockResolvedValue(undefined), + enabledConnectorIds: vi.fn().mockReturnValue(['pubmed']), + materializedCustomSkillNames: vi.fn().mockReturnValue([]) + } as unknown as ConnectorSettingsModule + manager = createManager({ skills, connectors }) + + const lease = await manager.acquireAgentSkillRuntime(await repository.getSettings(), { + lifecycle: { + sessionId: 'session-connector', + agentFrameId: 'frame-connector', + runtimeSegmentId: 'segment-connector' + }, + scope: { kind: 'specialist' }, + allowedSkillIds: ['mcp-pubmed'] + }) + + expect(lease.skills.map((skill) => skill.id)).toEqual(['mcp-pubmed']) + const [projected] = lease.skills + const document = await readFile(join(projected.packageRoot, 'SKILL.md'), 'utf8') + const { fields } = parseFrontmatter(document) + expect(projected.description).toBe(fields.description) + expect(projected.packageRevision).toBe( + `sha256:${createHash('sha256').update(document).digest('hex')}` + ) + await lease.release() + }) + + it('projects only the canonical custom Connector Skill document', async () => { + const customSkillName = 'mcp-xt' + const sourceDir = join(connectorSkillSourceRoot(storageRoot), customSkillName) + const rollbackFile = join( + getAppClaudeConfigDir(storageRoot), + 'skills', + customSkillName, + 'SKILL.md' + ) + const document = + '---\nname: mcp-xt\ndescription: Use XT records.\nsource: connector\n---\n\n# XT\n' + await mkdir(sourceDir, { recursive: true }) + await writeFile(join(sourceDir, 'SKILL.md'), document) + await writeFile(join(sourceDir, 'private-config.json'), '{"secret":true}') + await mkdir(dirname(rollbackFile), { recursive: true }) + await writeFile( + rollbackFile, + '---\nname: mcp-xt\ndescription: Stale rollback doc.\nsource: connector\n---\n', + 'utf8' + ) + const connectors = { + getConnectors: vi.fn().mockResolvedValue(undefined), + enabledConnectorIds: vi.fn().mockReturnValue([]), + materializedCustomSkillNames: vi.fn().mockReturnValue([customSkillName]) + } as unknown as ConnectorSettingsModule + manager = createManager({ connectors }) + + const lease = await manager.acquireAgentSkillRuntime(await repository.getSettings(), { + lifecycle: { + sessionId: 'session-custom-connector', + agentFrameId: 'frame-custom-connector', + runtimeSegmentId: 'segment-custom-connector' + }, + scope: { kind: 'specialist' }, + allowedSkillIds: [customSkillName] + }) + + expect(lease.skills.map((skill) => skill.id)).toEqual([customSkillName]) + expect(await readdir(lease.skills[0].packageRoot)).toEqual(['SKILL.md']) + await expect(readFile(join(lease.skills[0].packageRoot, 'SKILL.md'), 'utf8')).resolves.toBe( + document + ) + await expect(readFile(rollbackFile, 'utf8')).resolves.toContain('Stale rollback doc.') + await lease.release() + }) + + it('fails closed when a materialized custom Connector has invalid frontmatter', async () => { + const customSkillName = 'mcp-xt' + const sourceDir = join(connectorSkillSourceRoot(storageRoot), customSkillName) + await mkdir(sourceDir, { recursive: true }) + await writeFile( + join(sourceDir, 'SKILL.md'), + '---\nname: mcp-xt\ndescription: Use XT records.\nsource: user\n---\n' + ) + const connectors = { + getConnectors: vi.fn().mockResolvedValue(undefined), + enabledConnectorIds: vi.fn().mockReturnValue([]), + materializedCustomSkillNames: vi.fn().mockReturnValue([customSkillName]) + } as unknown as ConnectorSettingsModule + manager = createManager({ connectors }) + + await expect( + manager.acquireAgentSkillRuntime(await repository.getSettings(), { + lifecycle: { + sessionId: 'session-invalid-custom', + agentFrameId: 'frame-invalid-custom', + runtimeSegmentId: 'segment-invalid-custom' + }, + scope: { kind: 'main' } + }) + ).rejects.toThrow('Connector Skill document has invalid frontmatter: mcp-xt') + }) + + it('projects current Compute hosts without modifying the package source', async () => { + const sourceRoot = join(storageRoot, 'compute-source') + const sourceDocument = [ + '---', + 'name: remote-compute-ssh', + 'description: Discover and use SSH compute hosts.', + '---', + '', + '## Registered hosts', + '', + '', + ' (no hosts registered yet)', + '', + '', + '## API reference' + ].join('\n') + await mkdir(sourceRoot, { recursive: true }) + await writeFile(join(sourceRoot, 'SKILL.md'), sourceDocument) + const skills = { + materializeSkills: vi.fn().mockResolvedValue(undefined), + provisionClaudeConfig: vi.fn().mockResolvedValue(undefined), + runtimeProjectionCatalog: vi.fn().mockResolvedValue([ + { + id: 'remote-compute-ssh', + name: 'remote-compute-ssh', + description: 'Discover and use SSH compute hosts.', + source: 'featured', + updatedAt: '2026-08-14T00:00:00.000Z', + compatibility: 'sha256:compute-v1', + sourceDir: sourceRoot + } + ]) + } as unknown as SkillCatalogModule + const host: ComputeHost = { + id: 'host-1', + providerId: 'ssh:biowulf', + displayName: 'Biowulf', + shape: 'scheduler_cluster', + sshAlias: 'biowulf', + sshOverrides: undefined, + scratchRoot: undefined, + scratchPinned: false, + concurrencyLimit: undefined, + probeResult: undefined, + detailsDoc: '', + detailsUpdatedAt: undefined, + detailsUpdatedBy: undefined, + createdAt: 1, + updatedAt: 1 + } + manager = createManager({ skills, listComputeHosts: vi.fn().mockResolvedValue([host]) }) + + const lease = await manager.acquireAgentSkillRuntime(await repository.getSettings(), { + lifecycle: { + sessionId: 'session-compute', + agentFrameId: 'frame-compute', + runtimeSegmentId: 'segment-compute' + }, + scope: { kind: 'main' } + }) + + const projectedDocument = await readFile(join(lease.skills[0].packageRoot, 'SKILL.md'), 'utf8') + expect(projectedDocument).toContain('Biowulf') + expect(projectedDocument).toContain('ssh:biowulf') + await expect(readFile(join(sourceRoot, 'SKILL.md'), 'utf8')).resolves.toBe(sourceDocument) + await lease.release() + }) + it('synchronizes provisioned custom Connector docs into isolated agent Skill roots', async () => { const customSkillName = 'mcp-xt' - const sourceDir = join(getAppClaudeConfigDir(storageRoot), 'skills', customSkillName) + const sourceDir = join(connectorSkillSourceRoot(storageRoot), customSkillName) await mkdir(sourceDir, { recursive: true }) await writeFile( join(sourceDir, 'SKILL.md'), diff --git a/src/main/settings/agent-runtime-manager.ts b/src/main/settings/agent-runtime-manager.ts index 2561b8dbf..eed3becd9 100644 --- a/src/main/settings/agent-runtime-manager.ts +++ b/src/main/settings/agent-runtime-manager.ts @@ -1,6 +1,7 @@ import { execFile } from 'node:child_process' +import { createHash } from 'node:crypto' import { constants } from 'node:fs' -import { access } from 'node:fs/promises' +import { access, lstat, mkdir, mkdtemp, readFile, readdir, rm } from 'node:fs/promises' import { dirname, join } from 'node:path' import { createServer } from 'node:net' import { promisify } from 'node:util' @@ -16,6 +17,7 @@ import type { Preflight, ValidateProviderResult } from '../../shared/settings' +import type { ComputeHost } from '../../shared/compute' import { isProviderUsableByFramework } from '../../shared/settings' import { isModelBridgeSupported } from '../../shared/provider-registry' import { CLAUDE_EXECUTABLE_MISSING_MESSAGE } from '../../shared/run-error-classification' @@ -30,10 +32,25 @@ import { syncConnectorSkillDocs, syncMaterializedCustomServerSkillDocs } from '../connectors/provision' +import { renderSkillDoc } from '../connectors/skill-doc' +import { connectorSkillSourceRoot } from '../connectors/skill-source' import { ComputeHostRepository } from '../compute/repository' import { createLogger } from '../logger' import { getProjectDbClient } from '../projects/prisma-client' -import { hasCanonicalComputeSkillDoc, syncComputeSkillDoc } from '../compute/skill-doc' +import { + COMPUTE_SKILL_ID, + hasCanonicalComputeSkillDoc, + projectComputeSkillDoc, + syncComputeSkillDoc +} from '../compute/skill-doc' +import { + AgentSkillRuntime, + type AgentSkillRuntimeLease, + type AgentSkillRuntimeLifecycle, + type AgentSkillRuntimeSkill, + type AgentSkillRuntimeScope +} from '../skills/agent-skill-runtime' +import { parseFrontmatter } from '../skills/frontmatter' import { writeAgentConfigFiles } from './agent-config-files' import { createDefaultDetectDeps, detectClaude, type ClaudeDetectDeps } from './claude-detect' import { @@ -99,6 +116,42 @@ const CODEX_INSTALL_TARGET: InstallTarget = { const isManagedCodexPath = (adapterPath: string, storageRoot: string): boolean => adapterPath === managedCodexAdapterEntry(storageRoot) +const generatedConnectorSkill = (skillName: string, document: string): AgentSkillRuntimeSkill => { + const { fields, hasFrontmatter } = parseFrontmatter(document) + if ( + !hasFrontmatter || + fields.name !== skillName || + fields.source !== 'connector' || + !fields.description?.trim() + ) { + throw new Error(`Connector Skill document has invalid frontmatter: ${skillName}`) + } + + return { + kind: 'generated', + id: skillName, + name: skillName, + description: fields.description, + revision: `sha256:${createHash('sha256').update(document).digest('hex')}`, + files: [{ path: 'SKILL.md', content: document }] + } +} + +const readCustomConnectorSkill = async ( + storageRoot: string, + skillName: string +): Promise => { + if (!/^(?=.{5,64}$)mcp-[a-z0-9]+(?:-[a-z0-9]+)*$/.test(skillName)) { + throw new Error(`Custom Connector Skill has an invalid name: ${skillName}`) + } + const file = join(connectorSkillSourceRoot(storageRoot), skillName, 'SKILL.md') + const entry = await lstat(file) + if (!entry.isFile()) { + throw new Error(`Custom Connector Skill document is not a regular file: ${skillName}`) + } + return generatedConnectorSkill(skillName, await readFile(file, 'utf8')) +} + export type ExecuteClaudeProbe = ( executablePath: string, env: NodeJS.ProcessEnv, @@ -228,8 +281,17 @@ export type AgentRuntimeManagerOptions = { ) => Promise resolveCodexProxyEnvironment?: () => Promise syncComputeSkillDocument?: (skillsDir: string) => Promise + listComputeHosts?: () => Promise + agentSkillRuntime?: Pick } +export type AgentSkillRuntimeAcquireRequest = Readonly<{ + lifecycle: AgentSkillRuntimeLifecycle + scope: AgentSkillRuntimeScope + forcedSkillIds?: readonly string[] + allowedSkillIds?: readonly string[] +}> + // Owns host runtime discovery, installation, executable preparation, and runtime-specific filesystem // provisioning. Durable records remain serialized by SettingsRepository; live ACP generations and // reconnect decisions remain outside this module. @@ -256,6 +318,8 @@ export class AgentRuntimeManager { ) => Promise private readonly resolveProxyEnvironment: () => Promise private readonly syncComputeSkillDocument: (skillsDir: string) => Promise + private readonly listComputeHosts: () => Promise + private readonly agentSkillRuntime: Pick constructor(options: AgentRuntimeManagerOptions) { this.repository = options.repository @@ -300,15 +364,16 @@ export class AgentRuntimeManager { this.installManagedCodexImpl = options.installManagedCodexImpl ?? installManagedCodex this.resolveProxyEnvironment = options.resolveCodexProxyEnvironment ?? resolveSystemProxyEnvironment + this.listComputeHosts = + options.listComputeHosts ?? + (() => new ComputeHostRepository(() => getProjectDbClient(this.storageRoot)).list()) this.syncComputeSkillDocument = options.syncComputeSkillDocument ?? (async (skillsDir) => { if (!(await hasCanonicalComputeSkillDoc(skillsDir))) return - const hosts = await new ComputeHostRepository(() => - getProjectDbClient(this.storageRoot) - ).list() - await syncComputeSkillDoc(skillsDir, hosts) + await syncComputeSkillDoc(skillsDir, await this.listComputeHosts()) }) + this.agentSkillRuntime = options.agentSkillRuntime ?? new AgentSkillRuntime() } async getPreflight(providers: ProviderPreflightAccess): Promise { @@ -605,6 +670,79 @@ export class AgentRuntimeManager { return this.resolveProxyEnvironment() } + async acquireAgentSkillRuntime( + settings: StoredSettings, + request: AgentSkillRuntimeAcquireRequest + ): Promise { + const packageCatalog = await this.skills.runtimeProjectionCatalog() + const packageSkills = await Promise.all( + packageCatalog.map(async (skill): Promise => { + const base = { + kind: 'package' as const, + id: skill.id, + name: skill.name, + description: skill.description, + sourceDir: skill.sourceDir, + revision: skill.compatibility || skill.updatedAt + } + if (skill.id !== COMPUTE_SKILL_ID) return base + + const projectedDocument = projectComputeSkillDoc( + await readFile(join(skill.sourceDir, 'SKILL.md'), 'utf8'), + await this.listComputeHosts() + ) + return { + ...base, + revision: `sha256:${createHash('sha256') + .update(JSON.stringify([base.revision, projectedDocument])) + .digest('hex')}`, + overrides: [{ path: 'SKILL.md', content: projectedDocument }] + } + }) + ) + const connectorSkills = this.connectors + .enabledConnectorIds(settings.connectors) + .map((id) => generatedConnectorSkill(`mcp-${id}`, renderSkillDoc(id))) + const customConnectorSkills = await Promise.all( + this.connectors + .materializedCustomSkillNames() + .map((skillName) => readCustomConnectorSkill(this.storageRoot, skillName)) + ) + const catalog = [...packageSkills, ...connectorSkills, ...customConnectorSkills] + const disabled = new Set(settings.disabledSkillIds ?? []) + const forced = new Set(request.forcedSkillIds ?? []) + const allowed = request.allowedSkillIds ? new Set(request.allowedSkillIds) : undefined + const selected = catalog.filter((skill) => { + if (allowed) return allowed.has(skill.id) + const packageEntry = packageCatalog.find((entry) => entry.id === skill.id) + return ( + packageEntry?.exposure === 'internal' || !disabled.has(skill.id) || forced.has(skill.id) + ) + }) + if (allowed) { + const available = new Set(selected.map((skill) => skill.id)) + const unavailable = [...allowed].filter((id) => !available.has(id)) + if (unavailable.length > 0) { + throw new Error(`Authorized Skill is unavailable: ${unavailable.join(', ')}`) + } + } + + return this.agentSkillRuntime.acquire({ + storageRoot: this.storageRoot, + lifecycle: request.lifecycle, + scope: request.scope, + skills: selected + }) + } + + forkAgentSkillRuntime( + catalog: AgentSkillRuntimeLease, + lifecycle: AgentSkillRuntimeLifecycle, + scope: AgentSkillRuntimeScope + ): Promise { + return this.agentSkillRuntime.fork(catalog, { lifecycle, scope }) + } + async materializeAgentSkills( settings: StoredSettings, configRoot: string, @@ -614,7 +752,7 @@ export class AgentRuntimeManager { const bundledIds = this.connectors.enabledConnectorIds(settings.connectors) await syncConnectorSkillDocs(join(configRoot, 'skills'), bundledIds) const customSkillSync = await syncMaterializedCustomServerSkillDocs( - join(getAppClaudeConfigDir(this.storageRoot), 'skills'), + connectorSkillSourceRoot(this.storageRoot), join(configRoot, 'skills'), this.connectors.materializedCustomSkillNames() ) @@ -625,6 +763,32 @@ export class AgentRuntimeManager { return [...bundledIds.map((id) => `mcp-${id}`), ...customSkillSync.materializedSkillNames] } + async listLegacyAgentSkillDocumentPaths(configRoot: string): Promise { + const skillsRoot = join(configRoot, 'skills') + let entries + try { + entries = await readdir(skillsRoot, { withFileTypes: true }) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [] + throw error + } + + const documents = await Promise.all( + entries + .filter((entry) => entry.isDirectory()) + .map(async (entry) => { + const documentPath = join(skillsRoot, entry.name, 'SKILL.md') + try { + return (await lstat(documentPath)).isFile() ? documentPath : undefined + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined + throw error + } + }) + ) + return documents.filter((path): path is string => path !== undefined).sort() + } + async materializeAgentConfigFiles(files: AgentConfigFile[] | undefined): Promise { await writeAgentConfigFiles(files) } @@ -632,19 +796,27 @@ export class AgentRuntimeManager { async provisionClaudeRuntimeConfig( settings: StoredSettings, forcedSkillIds: ReadonlySet = new Set(), - modelConfig?: ClaudeRuntimeModelConfig | null + modelConfig?: ClaudeRuntimeModelConfig | null, + materializeSkills = true ): Promise { const configDir = getAppClaudeConfigDir(this.storageRoot) const disabledSkillIds = (settings.disabledSkillIds ?? []).filter( (id) => !forcedSkillIds.has(id) ) - await this.skills.provisionClaudeConfig(configDir, disabledSkillIds, modelConfig) - const connectors = await this.connectors.getConnectors() - await syncConnectorSkillDocs( - join(configDir, 'skills'), - this.connectors.enabledConnectorIds(connectors) + await this.skills.provisionClaudeConfig( + configDir, + disabledSkillIds, + modelConfig, + materializeSkills ) - await this.syncComputeSkillDocument(join(configDir, 'skills')) + if (materializeSkills) { + const connectors = await this.connectors.getConnectors() + await syncConnectorSkillDocs( + join(configDir, 'skills'), + this.connectors.enabledConnectorIds(connectors) + ) + await this.syncComputeSkillDocument(join(configDir, 'skills')) + } return configDir } @@ -705,21 +877,48 @@ export class AgentRuntimeManager { } } - const appConfigDir = await this.provisionClaudeRuntimeConfig(settings) - const envOverrides = buildProviderEnv(provider, { - storageRoot: this.storageRoot, - claudeExecutablePath: executablePath, - userClaudeConfigDir: this.userClaudeDir - }) - const env = buildAgentSpawnEnv(augmentedPathEnv(process.env), envOverrides, executablePath) + const sharedProfile = provider.type === 'claude-shared' + let probeConfigDir: string + if (sharedProfile) { + probeConfigDir = await this.provisionClaudeRuntimeConfig( + settings, + new Set(), + undefined, + false + ) + } else { + // A token probe needs app policy settings but no durable Claude state. Give every invocation a + // fresh empty config home inside the rebuildable runtime boundary so native discovery cannot + // scan the rollback-owned /claude/skills catalog. The token remains env-only. + const probesRoot = join(this.storageRoot, 'runtime', 'claude-probes', 'v1') + await mkdir(probesRoot, { recursive: true }) + probeConfigDir = await mkdtemp(join(probesRoot, 'probe-')) + try { + await this.skills.provisionClaudeConfig( + probeConfigDir, + settings.disabledSkillIds ?? [], + undefined, + false + ) + } catch (error) { + await rm(probeConfigDir, { recursive: true, force: true }) + throw error + } + } try { - if (provider.type === 'claude-shared') { + const envOverrides = buildProviderEnv(provider, { + storageRoot: this.storageRoot, + claudeExecutablePath: executablePath, + userClaudeConfigDir: this.userClaudeDir + }) + if (!sharedProfile) envOverrides.CLAUDE_CONFIG_DIR = probeConfigDir + const env = buildAgentSpawnEnv(augmentedPathEnv(process.env), envOverrides, executablePath) + + if (sharedProfile) { await this.executeClaudeProbe(executablePath, env, [ '--settings', - join(appConfigDir, 'settings.json'), - '--plugin-dir', - appConfigDir + join(probeConfigDir, 'settings.json') ]) } else { await this.executeClaudeProbe(executablePath, env) @@ -755,6 +954,10 @@ export class AgentRuntimeManager { 'Claude could not run the token validation probe. Re-detect Claude and try again.' } return { ok: false, category, message: messages[category] } + } finally { + if (!sharedProfile) { + await rm(probeConfigDir, { recursive: true, force: true }) + } } } diff --git a/src/main/settings/agent-skill-runtime-projection.ts b/src/main/settings/agent-skill-runtime-projection.ts new file mode 100644 index 000000000..a2e93a5d7 --- /dev/null +++ b/src/main/settings/agent-skill-runtime-projection.ts @@ -0,0 +1,91 @@ +import { randomUUID } from 'node:crypto' + +import type { ResolvedAgentBackend, SkillRuntimeView } from '../agent-framework' +import type { AgentSkillRuntimeLease } from '../skills/agent-skill-runtime' +import type { AgentRuntimeManager, AgentSkillRuntimeAcquireRequest } from './agent-runtime-manager' +import type { StoredSettings } from './types' + +type AgentSkillRuntimeResolutionInput = Readonly<{ + forcedSkillIds?: readonly string[] + skillRuntime?: Readonly<{ + lifecycle?: AgentSkillRuntimeAcquireRequest['lifecycle'] + scope: AgentSkillRuntimeAcquireRequest['scope'] + allowedSkillIds?: AgentSkillRuntimeAcquireRequest['allowedSkillIds'] + }> +}> + +const createAgentSkillRuntimeRequest = ( + input: AgentSkillRuntimeResolutionInput +): AgentSkillRuntimeAcquireRequest => { + const configured = input.skillRuntime + return { + lifecycle: + configured?.lifecycle ?? + Object.freeze({ + sessionId: `backend-${randomUUID()}`, + agentFrameId: 'root', + runtimeSegmentId: randomUUID() + }), + scope: configured?.scope ?? Object.freeze({ kind: 'main' as const }), + ...(input.forcedSkillIds?.length + ? { forcedSkillIds: Object.freeze([...input.forcedSkillIds]) } + : {}), + ...(configured?.allowedSkillIds + ? { allowedSkillIds: Object.freeze([...configured.allowedSkillIds]) } + : {}) + } +} + +const toSkillRuntimeView = (lease: AgentSkillRuntimeLease): SkillRuntimeView => + Object.freeze({ + projectionRoot: lease.projectionRoot, + discoveryRoot: lease.discoveryRoot, + descriptors: Object.freeze( + lease.skills.map((skill) => + Object.freeze({ + id: skill.id, + name: skill.name, + description: skill.description, + path: skill.skillDocumentPath + }) + ) + ), + environment: lease.env + }) + +type AgentBackendSkillRuntimePort = Pick< + AgentRuntimeManager, + 'acquireAgentSkillRuntime' | 'forkAgentSkillRuntime' +> + +type AgentBackendSkillRuntime = Readonly<{ + lease: AgentSkillRuntimeLease + view: SkillRuntimeView + fork: NonNullable +}> + +class AgentBackendSkillRuntimeOwner { + constructor(private readonly runtime: AgentBackendSkillRuntimePort) {} + + async acquire( + settings: StoredSettings, + input: AgentSkillRuntimeResolutionInput + ): Promise { + const request = createAgentSkillRuntimeRequest(input) + const lease = await this.runtime.acquireAgentSkillRuntime(settings, request) + const fork: AgentBackendSkillRuntime['fork'] = Object.freeze({ + acquire: async (lifecycle) => { + const attemptLease = await this.runtime.forkAgentSkillRuntime( + lease, + lifecycle, + request.scope + ) + return Object.freeze({ view: toSkillRuntimeView(attemptLease), lease: attemptLease }) + } + }) + return Object.freeze({ lease, view: toSkillRuntimeView(lease), fork }) + } +} + +export { AgentBackendSkillRuntimeOwner } +export type { AgentSkillRuntimeResolutionInput } diff --git a/src/main/settings/backend-resolver.test.ts b/src/main/settings/backend-resolver.test.ts index 6a2ac99c4..0938f7372 100644 --- a/src/main/settings/backend-resolver.test.ts +++ b/src/main/settings/backend-resolver.test.ts @@ -6,6 +6,7 @@ import { SETTINGS_FILE_VERSION } from '../../shared/settings' import type { AgentConfigFile, AgentFrameworkId } from '../agent-framework' import { opencodeTransportProviderId } from '../agent-framework/opencode' import { SKILL_IMPORT_SYSTEM_PROMPT_APPEND } from '../skills/mcp-server' +import type { AgentSkillRuntimeLease } from '../skills/agent-skill-runtime' import type { ResolvedProvider } from './provider-env' import type { ProviderRuntimeTarget, RuntimeProviderModelSelection } from './provider-accounts' import type { StoredProvider, StoredSettings } from './types' @@ -152,6 +153,7 @@ type HarnessOptions = { connectorIds?: string[] connectorSkillNames?: string[] materializedConnectorSkillNames?: string[] + legacyCodexSkillDocumentPaths?: string[] rejectRequiredModels?: ReadonlySet targetOverride?: ( provider: StoredProvider, @@ -163,6 +165,7 @@ type HarnessOptions = { anthropicProviderBridgeBuilder?: (index: number) => AnthropicProviderBridgeDouble openAiProviderBridgeBuilder?: (index: number) => OpenAiProviderBridgeDouble nextGenerationId?: () => string + skillRuntimeAcquireError?: Error } // The inferred return preserves each Vitest mock's concrete call signature for assertions below. @@ -234,17 +237,75 @@ const makeHarness = (options: HarnessOptions = {}) => { resolveRuntimeReasoningEffortProfile } + const acquiredSkillRuntimeLeases: AgentSkillRuntimeLease[] = [] + const runtime = { resolveClaudeExecutable: vi.fn(async () => '/runtime/claude'), resolveOpencodeExecutable: vi.fn(async () => '/runtime/opencode'), resolveCodexExecutable: vi.fn(async () => '/runtime/codex-acp'), probeCodexNativeVersion: vi.fn(async () => '0.144.6'), provisionClaudeRuntimeConfig: vi.fn(async () => '/storage/claude-config'), - materializeAgentSkills: vi.fn( - async () => + acquireAgentSkillRuntime: vi.fn(async () => { + if (options.skillRuntimeAcquireError) throw options.skillRuntimeAcquireError + const projectionRoot = '/storage/runtime/agent-skills/v1/catalogs/revision' + const discoveryRoot = join(projectionRoot, 'skills') + const projectedConnectorSkillNames = options.materializedConnectorSkillNames ?? options.connectorSkillNames ?? (options.connectorIds ?? []).map((id) => `mcp-${id}`) + const lease: AgentSkillRuntimeLease = { + catalogRevision: 'sha256:revision', + projectionRoot, + discoveryRoot, + skills: [ + { + id: 'research', + name: 'research', + description: 'Research primary sources.', + packageRoot: join(discoveryRoot, 'os-research'), + skillDocumentPath: join(discoveryRoot, 'os-research', 'SKILL.md'), + packageRevision: 'sha256:research' + }, + ...projectedConnectorSkillNames.map((name) => ({ + id: name, + name, + description: `Connector ${name}.`, + packageRoot: join(discoveryRoot, name), + skillDocumentPath: join(discoveryRoot, name, 'SKILL.md'), + packageRevision: `sha256:${name}` + })) + ], + cacheRoot: '/storage/runtime/agent-skills/v1/leases/lease/cache', + tempRoot: '/storage/runtime/agent-skills/v1/leases/lease/tmp', + env: { + XDG_CACHE_HOME: '/storage/runtime/agent-skills/v1/leases/lease/cache', + TMPDIR: '/storage/runtime/agent-skills/v1/leases/lease/tmp' + }, + release: vi.fn(async () => undefined) + } + acquiredSkillRuntimeLeases.push(lease) + return lease + }), + forkAgentSkillRuntime: vi.fn(async (catalog, lifecycle, scope) => { + void lifecycle + void scope + const leaseIndex = acquiredSkillRuntimeLeases.length + 1 + const leaseRoot = `/storage/runtime/agent-skills/v1/leases/fork-${leaseIndex}` + const lease: AgentSkillRuntimeLease = { + ...catalog, + cacheRoot: `${leaseRoot}/cache`, + tempRoot: `${leaseRoot}/tmp`, + env: { + XDG_CACHE_HOME: `${leaseRoot}/cache`, + TMPDIR: `${leaseRoot}/tmp` + }, + release: vi.fn(async () => undefined) + } + acquiredSkillRuntimeLeases.push(lease) + return lease + }), + listLegacyAgentSkillDocumentPaths: vi.fn( + async () => options.legacyCodexSkillDocumentPaths ?? [] ), materializeAgentConfigFiles: vi.fn(async (files?: AgentConfigFile[]) => { void files @@ -325,6 +386,7 @@ const makeHarness = (options: HarnessOptions = {}) => { nativeResponsesProxies, anthropicProviderBridges, openAiProviderBridges, + acquiredSkillRuntimeLeases, getSettings: () => currentSettings, setSettings: (settings: StoredSettings) => { currentSettings = settings @@ -338,7 +400,7 @@ const expectRuntimeNotStarted = (runtime: ReturnType['runtim expect(runtime.resolveCodexExecutable).not.toHaveBeenCalled() expect(runtime.probeCodexNativeVersion).not.toHaveBeenCalled() expect(runtime.provisionClaudeRuntimeConfig).not.toHaveBeenCalled() - expect(runtime.materializeAgentSkills).not.toHaveBeenCalled() + expect(runtime.acquireAgentSkillRuntime).not.toHaveBeenCalled() expect(runtime.materializeAgentConfigFiles).not.toHaveBeenCalled() expect(runtime.reserveOpenCodeUsagePort).not.toHaveBeenCalled() expect(runtime.resolveCodexProxyEnvironment).not.toHaveBeenCalled() @@ -539,7 +601,8 @@ describe('AgentBackendResolver configured and explicit targets', () => { 'third-party/model-a': 'third-party/model-a', 'third-party/model-b': 'third-party/model-b' } - } + }, + false ) expect(JSON.stringify(modelConfig)).not.toContain('plain:key-a') }) @@ -983,7 +1046,19 @@ describe('AgentBackendResolver configured and explicit targets', () => { reasoningEffort: 'high' }) - expect(explicit).toEqual(configured) + const { + skillRuntimeLease: configuredLease, + skillRuntimeFork: configuredFork, + ...configuredStable + } = configured + const { + skillRuntimeLease: explicitLease, + skillRuntimeFork: explicitFork, + ...explicitStable + } = explicit + expect(explicitStable).toEqual(configuredStable) + expect(explicitLease).not.toBe(configuredLease) + expect(explicitFork).not.toBe(configuredFork) expect(harness.resolveRuntimeTarget).toHaveBeenNthCalledWith( 1, settings.providers[0], @@ -996,6 +1071,8 @@ describe('AgentBackendResolver configured and explicit targets', () => { { kind: 'provider-default' }, expect.objectContaining({ id: 'claude-code' }) ) + await configuredLease?.release() + await explicitLease?.release() }) it('late-binds a configured selection but keeps an explicit target fixed', async () => { @@ -1176,18 +1253,16 @@ describe('AgentBackendResolver runtime delegation', () => { expect(harness.runtime.provisionClaudeRuntimeConfig).toHaveBeenCalledWith( harness.getSettings(), new Set(['forced-skill']), - null + null, + false ) - expect(harness.runtime.materializeAgentSkills).not.toHaveBeenCalled() expect(harness.runtime.materializeAgentConfigFiles).not.toHaveBeenCalled() } else { - expect(harness.runtime.materializeAgentSkills).toHaveBeenCalledWith( - harness.getSettings(), - expect.any(String), - new Set(['forced-skill']) - ) expect(harness.runtime.materializeAgentConfigFiles).toHaveBeenCalledTimes(1) } + expect(harness.runtime.listLegacyAgentSkillDocumentPaths).toHaveBeenCalledTimes( + testCase.frameworkId === 'codex' ? 1 : 0 + ) expect(harness.runtime.reserveOpenCodeUsagePort).toHaveBeenCalledTimes( testCase.frameworkId === 'opencode' ? 1 : 0 ) @@ -1195,6 +1270,108 @@ describe('AgentBackendResolver runtime delegation', () => { testCase.frameworkId === 'codex' ? 1 : 0 ) }) + + it.each(['claude-code', 'opencode', 'codex'] as const)( + 'acquires one framework-neutral Skill runtime after the %s legacy projection', + async (frameworkId) => { + const harness = makeHarness() + const lifecycle = { + sessionId: 'session-1', + agentFrameId: 'frame-1', + runtimeSegmentId: 'segment-1' + } + + const backend = await harness.resolver.resolveExplicitTarget( + { + frameworkId, + providerId: 'provider-a', + model: { kind: 'provider-default' }, + reasoningEffort: 'high' + }, + { + forcedSkillIds: ['forced-skill'], + skillRuntime: { lifecycle, scope: { kind: 'main' } } + } + ) + + expect(harness.runtime.acquireAgentSkillRuntime).toHaveBeenCalledWith(harness.getSettings(), { + lifecycle, + scope: { kind: 'main' }, + forcedSkillIds: ['forced-skill'] + }) + const lease = harness.acquiredSkillRuntimeLeases[0]! + expect(backend.skillRuntime).toEqual({ + projectionRoot: lease.projectionRoot, + discoveryRoot: lease.discoveryRoot, + descriptors: [ + { + id: 'research', + name: 'research', + description: 'Research primary sources.', + path: lease.skills[0]!.skillDocumentPath + } + ], + environment: lease.env + }) + expect(backend.skillRuntimeLease).toBe(lease) + expect(backend.env).toMatchObject(lease.env) + + const attemptLifecycle = { + sessionId: 'session-1', + agentFrameId: 'child-frame', + runtimeSegmentId: 'child-segment' + } + const forked = await backend.skillRuntimeFork!.acquire(attemptLifecycle) + expect(harness.runtime.forkAgentSkillRuntime).toHaveBeenCalledWith(lease, attemptLifecycle, { + kind: 'main' + }) + expect(forked.view.projectionRoot).toBe(lease.projectionRoot) + expect(forked.view.environment).not.toEqual(lease.env) + await forked.lease.release() + } + ) + + it('disables the Codex stable Skill documents without modifying the rollback catalog', async () => { + const harness = makeHarness({ + legacyCodexSkillDocumentPaths: [ + '/storage/codex/skills/os-research/SKILL.md', + '/storage/codex/skills/mcp-pubmed/SKILL.md' + ] + }) + + const backend = await harness.resolver.resolveExplicitTarget({ + frameworkId: 'codex', + providerId: 'provider-a', + model: { kind: 'provider-default' }, + reasoningEffort: 'high' + }) + + expect(harness.runtime.listLegacyAgentSkillDocumentPaths).toHaveBeenCalledWith('/storage/codex') + expect(backend.env.OPEN_SCIENCE_CODEX_DISABLED_SKILL_PATHS).toBe( + JSON.stringify([ + '/storage/codex/skills/os-research/SKILL.md', + '/storage/codex/skills/mcp-pubmed/SKILL.md' + ]) + ) + }) + + it('fails closed when the new Skill runtime cannot be acquired after legacy provisioning', async () => { + const acquireError = new Error('Skill runtime unavailable') + const harness = makeHarness({ skillRuntimeAcquireError: acquireError }) + + await expect( + harness.resolver.resolveExplicitTarget({ + frameworkId: 'claude-code', + providerId: 'provider-a', + model: { kind: 'provider-default' }, + reasoningEffort: 'high' + }) + ).rejects.toBe(acquireError) + + expect(harness.runtime.provisionClaudeRuntimeConfig).toHaveBeenCalledTimes(1) + expect(harness.runtime.acquireAgentSkillRuntime).toHaveBeenCalledTimes(1) + expect(harness.createAnthropicProviderBridge).not.toHaveBeenCalled() + }) }) describe('AgentBackendResolver bridge predicates', () => { @@ -1263,49 +1440,43 @@ describe('AgentBackendResolver bridge predicates', () => { provider: { apiEndpoints: ['openai'] as const } } } - ])( - 'rematerializes exact enabled Connector Skill names for each $name generation', - async (testCase) => { - const harness = makeHarness({ - connectorIds: ['pubmed', 'literature'], - connectorSkillNames: ['mcp-pubmed', 'mcp-literature', 'mcp-custom-chemistry'], - targetOverride: () => testCase.target - }) - const target = { - frameworkId: testCase.frameworkId, - providerId: 'provider-a', - model: { kind: 'provider-default' as const }, - reasoningEffort: 'high' as const - } + ])('projects exact enabled Connector Skill names for each $name generation', async (testCase) => { + const harness = makeHarness({ + connectorIds: ['pubmed', 'literature'], + connectorSkillNames: ['mcp-pubmed', 'mcp-literature', 'mcp-custom-chemistry'], + targetOverride: () => testCase.target + }) + const target = { + frameworkId: testCase.frameworkId, + providerId: 'provider-a', + model: { kind: 'provider-default' as const }, + reasoningEffort: 'high' as const + } - const previousBackend = await harness.resolver.resolveExplicitTarget(target) - await previousBackend.anthropicBridgeLease?.release() - await previousBackend.responsesBridgeLease?.release() - await previousBackend.providerTransportLease?.release() + const previousBackend = await harness.resolver.resolveExplicitTarget(target) + await previousBackend.anthropicBridgeLease?.release() + await previousBackend.responsesBridgeLease?.release() + await previousBackend.providerTransportLease?.release() - const backend = await harness.resolver.resolveExplicitTarget(target) - const instructions = - testCase.frameworkId === 'claude-code' - ? backend.systemPromptAppends?.join('\n\n') - : backend.persistentSystemPrompt + const backend = await harness.resolver.resolveExplicitTarget(target) + const instructions = + testCase.frameworkId === 'claude-code' + ? backend.systemPromptAppends?.join('\n\n') + : backend.persistentSystemPrompt - expect(instructions).toContain( - 'Globally Enabled Connector Skills: `mcp-pubmed`, `mcp-literature`, `mcp-custom-chemistry`.' - ) - expect(instructions).toContain('Allowed Specialist Skills for this session') - expect(instructions).not.toContain('host.mcp("custom-chemistry"') - expect(instructions).not.toContain('`mcp-openalex`') - expect(harness.runtime.provisionClaudeRuntimeConfig).toHaveBeenCalledTimes( - testCase.frameworkId === 'claude-code' ? 2 : 0 - ) - expect(harness.runtime.materializeAgentSkills).toHaveBeenCalledTimes( - testCase.frameworkId === 'claude-code' ? 0 : 2 - ) - await backend.anthropicBridgeLease?.release() - await backend.responsesBridgeLease?.release() - await backend.providerTransportLease?.release() - } - ) + expect(instructions).toContain( + 'Globally Enabled Connector Skills: `mcp-pubmed`, `mcp-literature`, `mcp-custom-chemistry`.' + ) + expect(instructions).toContain('Allowed Specialist Skills for this session') + expect(instructions).not.toContain('host.mcp("custom-chemistry"') + expect(instructions).not.toContain('`mcp-openalex`') + expect(harness.runtime.provisionClaudeRuntimeConfig).toHaveBeenCalledTimes( + testCase.frameworkId === 'claude-code' ? 2 : 0 + ) + await backend.anthropicBridgeLease?.release() + await backend.responsesBridgeLease?.release() + await backend.providerTransportLease?.release() + }) it.each([ { name: 'OpenCode', frameworkId: 'opencode' as const, target: {} }, diff --git a/src/main/settings/backend-resolver.ts b/src/main/settings/backend-resolver.ts index bf034e923..ec1d0555f 100644 --- a/src/main/settings/backend-resolver.ts +++ b/src/main/settings/backend-resolver.ts @@ -21,11 +21,14 @@ import { type AgentFrameworkId, type ResolvedAgentBackend } from '../agent-framework' -import { opencodeConfigDir } from '../agent-framework/opencode' import { codexStorageDir, codexSubscriptionStorageDir } from '../agent-framework/codex' import { renderConnectorInstructions } from '../connectors/skill-doc' import { buildProviderEnv } from './provider-env' import type { AgentRuntimeManager } from './agent-runtime-manager' +import { + AgentBackendSkillRuntimeOwner, + type AgentSkillRuntimeResolutionInput +} from './agent-skill-runtime-projection' import type { ConnectorSettingsModule } from './connector-settings' import { CLAUDE_SHARED_DISCONNECTED_MESSAGE, @@ -56,8 +59,7 @@ export type AdmittedAgentBackendTarget = ExplicitAgentBackendTarget & expectedModelRoute: AgentModelRoute }> -export type AgentBackendResolutionContext = { - forcedSkillIds?: string[] +export type AgentBackendResolutionContext = AgentSkillRuntimeResolutionInput & { systemPromptAppends?: string[] forceCodexNativeResponsesCompatibility?: boolean } @@ -87,7 +89,9 @@ export type AgentBackendRuntimePort = Pick< | 'resolveCodexExecutable' | 'probeCodexNativeVersion' | 'provisionClaudeRuntimeConfig' - | 'materializeAgentSkills' + | 'acquireAgentSkillRuntime' + | 'forkAgentSkillRuntime' + | 'listLegacyAgentSkillDocumentPaths' | 'materializeAgentConfigFiles' | 'reserveOpenCodeUsagePort' | 'resolveCodexProxyEnvironment' @@ -123,6 +127,7 @@ export class AgentBackendResolver { private readonly selection: BackendSelectionOwner private readonly planner: BackendRoutePlanner private readonly transports: ProviderTransportOwner + private readonly skillRuntimes: AgentBackendSkillRuntimeOwner private readonly ensureCodexSubscriptionHome: () => Promise constructor(options: AgentBackendResolverOptions) { @@ -141,6 +146,7 @@ export class AgentBackendResolver { }) this.planner = new BackendRoutePlanner({ providers: this.providers }) this.transports = new ProviderTransportOwner(options) + this.skillRuntimes = new AgentBackendSkillRuntimeOwner(this.runtime) this.ensureCodexSubscriptionHome = options.ensureCodexSubscriptionHome ?? (() => ensureCodexAuthHome('isolated', this.storageRoot)) @@ -342,37 +348,66 @@ export class AgentBackendResolver { const sessionEffort = plan.sessionEffort const supportedReasoningEfforts = plan.supportedReasoningEfforts if (framework.id === 'claude-code') { - const { - envOverrides, - executablePath: claudeExecutablePath, - sessionOptions, - contextWindow - } = await this.resolveClaudeSpawnConfig( - settings, - target, - forcedSkillIds, - executablePath, - plan.claudeModelConfig - ) - const transport = await this.transports.acquire({ activeTarget: target, plan }) - return { - framework, - backendId: `${framework.id}:${target.providerId}`, - modelRoute, - executablePath: claudeExecutablePath, - env: { ...envOverrides, ...(transport.environment ?? {}) }, - sessionOptions, - sessionEffort, - contextWindow, - ...(target.provider.supportsImageInput ? { supportsImageInput: true } : {}), - contextUsageModel: target.effectiveModel, - systemPromptAppends: [ - userSkillDirectoryGuidance, - ...(connectorInstructions ? [connectorInstructions] : []) - ], - ...(transport.anthropicBridgeLease - ? { anthropicBridgeLease: transport.anthropicBridgeLease } - : {}) + let skillRuntimeLease: + Awaited> | undefined + let transport: Awaited> | undefined + try { + const { + envOverrides, + executablePath: claudeExecutablePath, + sessionOptions, + contextWindow + } = await this.resolveClaudeSpawnConfig( + settings, + target, + forcedSkillIds, + executablePath, + plan.claudeModelConfig + ) + const ownedSkillRuntime = await this.skillRuntimes.acquire(settings, context) + skillRuntimeLease = ownedSkillRuntime.lease + const { view: skillRuntime, fork: skillRuntimeFork } = ownedSkillRuntime + const modelConfig = framework.prepareModelConfig(target.provider, { + storageRoot: this.storageRoot, + executablePath, + reasoningEffort: sessionEffort, + reasoningEfforts: supportedReasoningEfforts, + skillRuntime + }) + transport = await this.transports.acquire({ activeTarget: target, plan }) + return { + framework, + backendId: `${framework.id}:${target.providerId}`, + modelRoute, + executablePath: claudeExecutablePath, + env: { + ...(modelConfig.env ?? {}), + ...envOverrides, + ...(transport.environment ?? {}) + }, + sessionOptions, + skillRuntime, + skillRuntimeLease, + skillRuntimeFork, + sessionEffort, + contextWindow, + ...(target.provider.supportsImageInput ? { supportsImageInput: true } : {}), + contextUsageModel: target.effectiveModel, + systemPromptAppends: [ + userSkillDirectoryGuidance, + ...(connectorInstructions ? [connectorInstructions] : []) + ], + ...(transport.anthropicBridgeLease + ? { anthropicBridgeLease: transport.anthropicBridgeLease } + : {}) + } + } catch (error) { + try { + await transport?.release() + } finally { + await skillRuntimeLease?.release().catch(() => undefined) + } + throw error } } @@ -380,20 +415,33 @@ export class AgentBackendResolver { await this.ensureCodexSubscriptionHome() } const backendProviderId = plan.backendProviderId - const skillsRoot = + const codexHome = framework.id === 'codex' ? isCodexSubscriptionProvider(target.provider.type) ? codexSubscriptionStorageDir(this.storageRoot) : codexStorageDir(this.storageRoot) - : opencodeConfigDir(this.storageRoot) - const materializedConnectorSkillNames = await this.runtime.materializeAgentSkills( - settings, - skillsRoot, - forcedSkillIds - ) + : undefined + const disabledCodexSkillPaths = codexHome + ? await this.runtime.listLegacyAgentSkillDocumentPaths(codexHome) + : [] + const { + lease: skillRuntimeLease, + view: skillRuntime, + fork: skillRuntimeFork + } = await this.skillRuntimes.acquire(settings, context) + const projectedSkillIds = new Set(skillRuntime.descriptors.map((skill) => skill.id)) + const projectedSkillNames = new Set(skillRuntime.descriptors.map((skill) => skill.name)) + const materializedConnectorSkillNames = this.connectors + .connectorSkillNames(settings.connectors) + .filter((name) => projectedSkillIds.has(name) || projectedSkillNames.has(name)) connectorInstructions = renderConnectorInstructions(materializedConnectorSkillNames) - - const transport = await this.transports.acquire({ activeTarget: target, plan }) + let transport: Awaited> | undefined + try { + transport = await this.transports.acquire({ activeTarget: target, plan }) + } catch (error) { + await skillRuntimeLease.release().catch(() => undefined) + throw error + } const provider = transport.provider ?? target.provider const providerModelCatalog = transport.providerModelCatalog ?? plan.providerModelCatalog const responsesBridge = transport.responsesBridge @@ -412,6 +460,7 @@ export class AgentBackendResolver { reasoningEffort: sessionEffort, reasoningEfforts: supportedReasoningEfforts, providerModelCatalog, + skillRuntime, instructions: connectorInstructions, ...(persistentSystemPromptAppends.length > 0 ? { systemPromptAppends: persistentSystemPromptAppends } @@ -443,8 +492,16 @@ export class AgentBackendResolver { ...(transport.environment ?? {}), ...(framework.id === 'codex' && settings.codex?.nativePath ? { CODEX_PATH: settings.codex.nativePath } + : {}), + ...(framework.id === 'codex' + ? { + OPEN_SCIENCE_CODEX_DISABLED_SKILL_PATHS: JSON.stringify(disabledCodexSkillPaths) + } : {}) }, + skillRuntime, + skillRuntimeLease, + skillRuntimeFork, args: opencodeUsagePort === undefined ? modelConfig.args @@ -481,7 +538,11 @@ export class AgentBackendResolver { providerTransportLease: transport.providerTransportLease } } catch (error) { - await transport.release() + try { + await transport.release() + } finally { + await skillRuntimeLease.release().catch(() => undefined) + } throw error } } @@ -503,7 +564,8 @@ export class AgentBackendResolver { const appConfigDir = await this.runtime.provisionClaudeRuntimeConfig( settings, forcedSkillIds, - modelConfig ?? null + modelConfig ?? null, + false ) const envOverrides = buildProviderEnv(provider, { storageRoot: this.storageRoot, @@ -513,8 +575,10 @@ export class AgentBackendResolver { const sessionOptions = target.providerType === 'claude-shared' ? { - settings: join(appConfigDir, 'settings.json'), - plugins: [{ type: 'local', path: appConfigDir, skipMcpDiscovery: true }] + // Shared auth still needs the app-owned settings layer, while native Skill discovery is + // supplied exclusively by the immutable generation runtime plugin. Do not load this + // persistent legacy directory as a plugin: older contents could include an MCP manifest. + settings: join(appConfigDir, 'settings.json') } : provider.type === 'custom' ? { diff --git a/src/main/settings/claude-config-provision.ts b/src/main/settings/claude-config-provision.ts index 427deb19d..4ec352192 100644 --- a/src/main/settings/claude-config-provision.ts +++ b/src/main/settings/claude-config-provision.ts @@ -108,6 +108,7 @@ type ProvisionOptions = { // `undefined` preserves the existing projection (validation probes must not perturb a live // backend); `null` explicitly clears a catalog owned by a previously active provider. modelConfig?: ClaudeRuntimeModelConfig | null + materializeSkills?: boolean } // Ensures the app config dir + asset subdirs exist, writes the file-tool deny rules, then materializes @@ -136,7 +137,7 @@ const provisionAppClaudeConfigDir = async ( const disabled = new Set(options.disabledSkillIds ?? []) const enabled = skills.filter((skill) => !disabled.has(skill.id)) - await materializer.sync(configDir, enabled) + if (options.materializeSkills !== false) await materializer.sync(configDir, enabled) } export { diff --git a/src/main/settings/managed-codex.test.ts b/src/main/settings/managed-codex.test.ts index f37553080..afd51080f 100644 --- a/src/main/settings/managed-codex.test.ts +++ b/src/main/settings/managed-codex.test.ts @@ -12,7 +12,7 @@ import { writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { dirname, join, sep } from 'node:path' +import { dirname, join, resolve, sep } from 'node:path' import { Readable } from 'node:stream' import { gzipSync } from 'node:zlib' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -50,13 +50,29 @@ const PINNED_MODEL_CATALOG_STARTUP_FIXTURE = [ '}' ].join('\n') +const PINNED_SKILL_EXTRA_ROOTS_FIXTURE = [ + ' async refreshSkills(cwd, additionalRoots) {', + ' if (!cwd) {', + ' return;', + ' }', + ' const skillExtraRoots = additionalRoots.map((root) => path4.join(root, ".agents", "skills"));', + ' if (!arraysEqual(this.skillExtraRoots, skillExtraRoots)) {', + ' await this.codexClient.skillsExtraRootsSet({ extraRoots: skillExtraRoots });', + ' this.skillExtraRoots = skillExtraRoots;', + ' }', + ' await this.codexClient.listSkills({', + ' cwds: [cwd, ...additionalRoots],', + ' forceReload: true', + ' });', + ' }' +].join('\n') const adapterFixture = (marker: string): Buffer => Buffer.from( - `${marker}\n${PINNED_SKILL_MAPPER_FIXTURE}\n${PINNED_MODEL_CATALOG_STARTUP_FIXTURE}\n` + `${marker}\n${PINNED_SKILL_MAPPER_FIXTURE}\n${PINNED_SKILL_EXTRA_ROOTS_FIXTURE}\n${PINNED_MODEL_CATALOG_STARTUP_FIXTURE}\n` ) const withPinnedSkillMapper = (source: string): string => - `${source}\n${PINNED_SKILL_MAPPER_FIXTURE}\n${PINNED_MODEL_CATALOG_STARTUP_FIXTURE}` + `${source}\n${PINNED_SKILL_MAPPER_FIXTURE}\n${PINNED_SKILL_EXTRA_ROOTS_FIXTURE}\n${PINNED_MODEL_CATALOG_STARTUP_FIXTURE}` // Injectable fault flags for the fs/promises mock — each targets one specific rename call: // onStagedMove: throw EPERM when src is the .codex-install- scratch dir (staged→destination) @@ -183,6 +199,7 @@ import { installManagedCodex, patchCodexAcpContextUsageSource, patchCodexAcpModelCatalogStartupSource, + patchCodexAcpSkillExtraRootsSource, patchCodexAcpSkillInputSource, patchCodexAcpTurnUsageSource, resolveManagedCodexPlatform, @@ -1762,6 +1779,68 @@ describe('patchCodexAcpModelCatalogStartupSource', () => { expect(patchCodexAcpModelCatalogStartupSource(patched)).toBe(patched) }) + it('disables every enumerated stable Skill path at native app-server startup', () => { + const patched = patchCodexAcpModelCatalogStartupSource(PINNED_MODEL_CATALOG_STARTUP_FIXTURE) + const spawn = vi.fn(() => ({ pid: 1 })) + const startCodexConnection = Function( + 'spawn', + 'process', + 'createRequire', + 'path4', + 'importMetaUrl', + `${patched.replace('import.meta.url', 'importMetaUrl')}; return startCodexConnection;` + )( + spawn, + { platform: 'darwin', execPath: '/runtime/node', env: {} }, + () => ({ resolve: () => '/runtime/bundled-codex.js' }), + { isAbsolute: (value: string) => value.startsWith('/') }, + 'file:///runtime/adapter.js' + ) as (codexPath: string, env: NodeJS.ProcessEnv) => unknown + + startCodexConnection('/runtime/codex', { + OPEN_SCIENCE_CODEX_DISABLED_SKILL_PATHS: JSON.stringify([ + '/data/codex/skills/os-research/SKILL.md', + '/data/codex/skills/mcp-pubmed/SKILL.md' + ]) + }) + + expect(spawn).toHaveBeenCalledWith( + '/runtime/codex', + [ + 'app-server', + '-c', + 'skills.config=[{ path = "/data/codex/skills/os-research/SKILL.md", enabled = false }, { path = "/data/codex/skills/mcp-pubmed/SKILL.md", enabled = false }]' + ], + expect.objectContaining({ env: expect.any(Object) }) + ) + }) + + it('fails before spawn when stable Skill path configuration is malformed', () => { + const patched = patchCodexAcpModelCatalogStartupSource(PINNED_MODEL_CATALOG_STARTUP_FIXTURE) + const spawn = vi.fn(() => ({ pid: 1 })) + const startCodexConnection = Function( + 'spawn', + 'process', + 'createRequire', + 'path4', + 'importMetaUrl', + `${patched.replace('import.meta.url', 'importMetaUrl')}; return startCodexConnection;` + )( + spawn, + { platform: 'darwin', execPath: '/runtime/node', env: {} }, + () => ({ resolve: () => '/runtime/bundled-codex.js' }), + { isAbsolute: (value: string) => value.startsWith('/') }, + 'file:///runtime/adapter.js' + ) as (codexPath: string, env: NodeJS.ProcessEnv) => unknown + + expect(() => + startCodexConnection('/runtime/codex', { + OPEN_SCIENCE_CODEX_DISABLED_SKILL_PATHS: '["relative/SKILL.md"]' + }) + ).toThrow(/Invalid Open Science Codex disabled Skill paths/) + expect(spawn).not.toHaveBeenCalled() + }) + it('keeps the native app-server command unchanged without a generated catalog', () => { const patched = patchCodexAcpModelCatalogStartupSource(PINNED_MODEL_CATALOG_STARTUP_FIXTURE) const spawn = vi.fn(() => ({ pid: 1 })) @@ -1787,6 +1866,15 @@ describe('patchCodexAcpModelCatalogStartupSource', () => { ) }) + it('keeps the previous startup patch recognizable to a rollback application', () => { + const patched = patchCodexAcpModelCatalogStartupSource(PINNED_MODEL_CATALOG_STARTUP_FIXTURE) + + expect(patched).toContain('open-science:codex-acp-model-catalog-startup-rollback-v1') + expect(patched).toContain( + 'const appServerArgs = modelCatalogPath\n ? ["app-server", "-c", `model_catalog_json=${JSON.stringify(modelCatalogPath)}`]' + ) + }) + it('fails closed when the pinned app-server spawn source drifts', () => { const drifted = PINNED_MODEL_CATALOG_STARTUP_FIXTURE.replace( 'spawn(codexPath, ["app-server"], { env: spawnEnv })', @@ -1803,7 +1891,8 @@ describe('patchCodexAcpSkillInputSource', () => { it('maps a private ACP descriptor to native Skill input before unchanged text', async () => { const root = await mkdtemp(join(tmpdir(), 'managed-codex-skill-input-')) const codexHome = join(root, 'codex-home') - const skillPath = join(codexHome, 'skills', 'mcp-pubmed', 'SKILL.md') + const runtimeSkillsRoot = join(root, 'runtime-skills') + const skillPath = join(runtimeSkillsRoot, 'mcp-pubmed', 'SKILL.md') try { await mkdir(dirname(skillPath), { recursive: true }) await writeFile(skillPath, '# PubMed') @@ -1826,9 +1915,12 @@ describe('patchCodexAcpSkillInputSource', () => { 'fs4', 'process', `${patched}; return buildPromptItems;` - )(await import('node:path'), await import('node:fs'), { env: { CODEX_HOME: codexHome } }) as ( - prompt: unknown[] - ) => unknown[] + )(await import('node:path'), await import('node:fs'), { + env: { + CODEX_HOME: codexHome, + OPEN_SCIENCE_SKILL_RUNTIME_ROOT: runtimeSkillsRoot + } + }) as (prompt: unknown[]) => unknown[] expect( buildPromptItems([ @@ -1899,6 +1991,42 @@ describe('patchCodexAcpSkillInputSource', () => { } ) + it('keeps the legacy CODEX_HOME Skill root when no runtime is configured', async () => { + const root = await mkdtemp(join(tmpdir(), 'managed-codex-skill-legacy-')) + const codexHome = join(root, 'codex-home') + const skillPath = join(codexHome, 'skills', 'legacy-skill', 'SKILL.md') + try { + await mkdir(dirname(skillPath), { recursive: true }) + await writeFile(skillPath, '# Legacy') + const patched = patchCodexAcpSkillInputSource(PINNED_SKILL_MAPPER_FIXTURE) + const buildPromptItems = Function( + 'path4', + 'fs4', + 'process', + `${patched}; return buildPromptItems;` + )(await import('node:path'), await import('node:fs'), { + env: { CODEX_HOME: codexHome } + }) as (prompt: unknown[]) => unknown[] + + expect( + buildPromptItems([ + { + type: 'text', + text: 'Use legacy', + _meta: { + 'open-science/skill-inputs': [{ name: 'legacy-skill', path: skillPath }] + } + } + ]) + ).toEqual([ + { type: 'skill', name: 'legacy-skill', path: skillPath }, + { type: 'text', text: 'Use legacy', text_elements: [] } + ]) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + it('ignores invalid and duplicate descriptors while preserving original text', async () => { const root = await mkdtemp(join(tmpdir(), 'managed-codex-skill-validation-')) const codexHome = join(root, 'codex-home') @@ -1982,6 +2110,118 @@ describe('patchCodexAcpSkillInputSource', () => { /Skill-input patch no longer matches/ ) }) + + it('migrates the installed CODEX_HOME Skill-input patch and remains idempotent', () => { + const current = patchCodexAcpSkillInputSource(PINNED_SKILL_MAPPER_FIXTURE) + const legacy = current.replace( + [ + ' const runtimeSkillRoot = typeof process.env.OPEN_SCIENCE_SKILL_RUNTIME_ROOT === "string"', + ' ? process.env.OPEN_SCIENCE_SKILL_RUNTIME_ROOT.trim()', + ' : "";', + ' const codexHome = typeof process.env.CODEX_HOME === "string" ? process.env.CODEX_HOME : "";', + ' const skillRoot = runtimeSkillRoot || (codexHome ? path4.join(codexHome, "skills") : "");' + ].join('\n'), + [ + ' const codexHome = typeof process.env.CODEX_HOME === "string" ? process.env.CODEX_HOME : "";', + ' const skillRoot = codexHome ? path4.join(codexHome, "skills") : "";' + ].join('\n') + ) + + const migrated = patchCodexAcpSkillInputSource(legacy) + + expect(migrated).toContain('process.env.OPEN_SCIENCE_SKILL_RUNTIME_ROOT') + expect(patchCodexAcpSkillInputSource(migrated)).toBe(migrated) + }) + + it('keeps the exact previous Skill-input patch recognizable to a rollback application', () => { + const patched = patchCodexAcpSkillInputSource(PINNED_SKILL_MAPPER_FIXTURE) + const rollbackSentinel = patched.match( + /\/\* open-science:codex-acp-skill-input-rollback-v1\n([\s\S]*?)\n\*\// + ) + + expect(rollbackSentinel?.[1]).toContain( + 'const skillRoot = codexHome ? path4.join(codexHome, "skills") : "";' + ) + expect(rollbackSentinel?.[1]).not.toContain('OPEN_SCIENCE_SKILL_RUNTIME_ROOT') + }) +}) + +describe('patchCodexAcpSkillExtraRootsSource', () => { + it('registers the runtime discovery root only for a session granted its projection root', async () => { + const patched = patchCodexAcpSkillExtraRootsSource(PINNED_SKILL_EXTRA_ROOTS_FIXTURE) + const Client = Function( + 'path4', + 'arraysEqual', + 'process', + `return class Client { + skillExtraRoots = []; + constructor(codexClient) { this.codexClient = codexClient; } + ${patched} + }` + )( + { join, resolve }, + (left: string[], right: string[]) => JSON.stringify(left) === JSON.stringify(right), + { + env: { + OPEN_SCIENCE_SKILL_DISCOVERY_ROOT: '/runtime/discovery/b-1', + OPEN_SCIENCE_SKILL_PROJECTION_ROOT: '/runtime/projections/g-1' + } + } + ) as new (client: unknown) => { + refreshSkills(cwd: string, roots: string[]): Promise + } + const setRoots = vi.fn(async () => undefined) + const client = new Client({ + skillsExtraRootsSet: setRoots, + listSkills: vi.fn(async () => undefined) + }) + + await client.refreshSkills('/project', ['/runtime/projections/g-1']) + expect(setRoots).toHaveBeenLastCalledWith({ + extraRoots: ['/runtime/discovery/b-1', join('/runtime/projections/g-1', '.agents', 'skills')] + }) + + await client.refreshSkills('/reviewer', []) + expect(setRoots).toHaveBeenLastCalledWith({ extraRoots: [] }) + }) + + it('migrates the previous unscoped runtime-root patch and remains idempotent', () => { + const legacy = PINNED_SKILL_EXTRA_ROOTS_FIXTURE.replace( + ' const skillExtraRoots = additionalRoots.map((root) => path4.join(root, ".agents", "skills"));', + [ + ' const openScienceSkillRoot = typeof process.env.OPEN_SCIENCE_SKILL_RUNTIME_ROOT === "string"', + ' ? process.env.OPEN_SCIENCE_SKILL_RUNTIME_ROOT.trim()', + ' : "";', + ' const skillExtraRoots = Array.from(new Set([', + ' ...(openScienceSkillRoot ? [openScienceSkillRoot] : []),', + ' ...additionalRoots.map((root) => path4.join(root, ".agents", "skills"))', + ' ]));' + ].join('\n') + ) + + const migrated = patchCodexAcpSkillExtraRootsSource(legacy) + + expect(migrated).toContain('OPEN_SCIENCE_SKILL_PROJECTION_ROOT') + expect(migrated).not.toContain('const openScienceSkillRoot =') + expect(patchCodexAcpSkillExtraRootsSource(migrated)).toBe(migrated) + }) + + it('fails closed when the pinned refreshSkills source drifts', () => { + const drifted = PINNED_SKILL_EXTRA_ROOTS_FIXTURE.replace( + 'await this.codexClient.skillsExtraRootsSet', + 'await this.codexClient.setSkillRoots' + ) + + expect(() => patchCodexAcpSkillExtraRootsSource(drifted)).toThrow( + /Skill extra-roots patch no longer matches/ + ) + }) + + it('fails closed when the pinned refreshSkills method is renamed or removed', () => { + expect(() => patchCodexAcpSkillExtraRootsSource('async reloadSkills() {}')).toThrow( + /Skill extra-roots patch no longer matches/ + ) + }) }) describe('sanitizeManagedCodexDiagnostic', () => { diff --git a/src/main/settings/managed-codex.ts b/src/main/settings/managed-codex.ts index e6eec373a..ea8d9e9cd 100644 --- a/src/main/settings/managed-codex.ts +++ b/src/main/settings/managed-codex.ts @@ -392,7 +392,7 @@ const CODEX_ACP_SKILL_INPUT_SOURCE = [ ' return { type: "text", text: block.text, text_elements: [] };' ].join('\n') -const CODEX_ACP_SKILL_INPUT_REPLACEMENT = [ +const CODEX_ACP_SKILL_INPUT_LEGACY_REPLACEMENT = [ 'function buildPromptItems(prompt) {', ' return prompt.flatMap((block) => {', ' switch (block.type) {', @@ -435,6 +435,81 @@ const CODEX_ACP_SKILL_INPUT_REPLACEMENT = [ ' }' ].join('\n') +const CODEX_ACP_SKILL_INPUT_ROLLBACK_SENTINEL = [ + '/* open-science:codex-acp-skill-input-rollback-v1', + CODEX_ACP_SKILL_INPUT_LEGACY_REPLACEMENT, + '*/' +].join('\n') + +const CODEX_ACP_SKILL_INPUT_REPLACEMENT = [ + CODEX_ACP_SKILL_INPUT_LEGACY_REPLACEMENT.replace( + [ + ' const codexHome = typeof process.env.CODEX_HOME === "string" ? process.env.CODEX_HOME : "";', + ' const skillRoot = codexHome ? path4.join(codexHome, "skills") : "";' + ].join('\n'), + [ + ' const runtimeSkillRoot = typeof process.env.OPEN_SCIENCE_SKILL_RUNTIME_ROOT === "string"', + ' ? process.env.OPEN_SCIENCE_SKILL_RUNTIME_ROOT.trim()', + ' : "";', + ' const codexHome = typeof process.env.CODEX_HOME === "string" ? process.env.CODEX_HOME : "";', + ' const skillRoot = runtimeSkillRoot || (codexHome ? path4.join(codexHome, "skills") : "");' + ].join('\n') + ), + // A rollback application recognizes its already-installed Skill-input patch by exact source + // inclusion before launching the adapter. Preserve that previous patch text in a dead comment so + // the additive runtime-root support remains executable by both the new and rollback application. + CODEX_ACP_SKILL_INPUT_ROLLBACK_SENTINEL +].join('\n') + +const CODEX_ACP_SKILL_EXTRA_ROOTS_SOURCE = [ + ' async refreshSkills(cwd, additionalRoots) {', + ' if (!cwd) {', + ' return;', + ' }', + ' const skillExtraRoots = additionalRoots.map((root) => path4.join(root, ".agents", "skills"));', + ' if (!arraysEqual(this.skillExtraRoots, skillExtraRoots)) {', + ' await this.codexClient.skillsExtraRootsSet({ extraRoots: skillExtraRoots });', + ' this.skillExtraRoots = skillExtraRoots;', + ' }', + ' await this.codexClient.listSkills({', + ' cwds: [cwd, ...additionalRoots],', + ' forceReload: true', + ' });', + ' }' +].join('\n') + +const CODEX_ACP_SKILL_EXTRA_ROOTS_LEGACY_REPLACEMENT = CODEX_ACP_SKILL_EXTRA_ROOTS_SOURCE.replace( + ' const skillExtraRoots = additionalRoots.map((root) => path4.join(root, ".agents", "skills"));', + [ + ' const openScienceSkillRoot = typeof process.env.OPEN_SCIENCE_SKILL_RUNTIME_ROOT === "string"', + ' ? process.env.OPEN_SCIENCE_SKILL_RUNTIME_ROOT.trim()', + ' : "";', + ' const skillExtraRoots = Array.from(new Set([', + ' ...(openScienceSkillRoot ? [openScienceSkillRoot] : []),', + ' ...additionalRoots.map((root) => path4.join(root, ".agents", "skills"))', + ' ]));' + ].join('\n') +) + +const CODEX_ACP_SKILL_EXTRA_ROOTS_REPLACEMENT = CODEX_ACP_SKILL_EXTRA_ROOTS_SOURCE.replace( + ' const skillExtraRoots = additionalRoots.map((root) => path4.join(root, ".agents", "skills"));', + [ + ' const openScienceDiscoveryRoot = typeof process.env.OPEN_SCIENCE_SKILL_DISCOVERY_ROOT === "string"', + ' ? process.env.OPEN_SCIENCE_SKILL_DISCOVERY_ROOT.trim()', + ' : "";', + ' const openScienceProjectionRoot = typeof process.env.OPEN_SCIENCE_SKILL_PROJECTION_ROOT === "string"', + ' ? process.env.OPEN_SCIENCE_SKILL_PROJECTION_ROOT.trim()', + ' : "";', + ' const openScienceDiscoveryAuthorized = openScienceDiscoveryRoot && openScienceProjectionRoot', + ' ? additionalRoots.some((root) => path4.resolve(root) === path4.resolve(openScienceProjectionRoot))', + ' : false;', + ' const skillExtraRoots = Array.from(new Set([', + ' ...(openScienceDiscoveryAuthorized ? [openScienceDiscoveryRoot] : []),', + ' ...additionalRoots.map((root) => path4.join(root, ".agents", "skills"))', + ' ]));' + ].join('\n') +) + const CODEX_ACP_MODEL_CATALOG_STARTUP_SOURCE = [ 'function startCodexConnection(codexPath, env) {', ' const spawnEnv = env ?? process.env;', @@ -447,7 +522,7 @@ const CODEX_ACP_MODEL_CATALOG_STARTUP_SOURCE = [ ' }' ].join('\n') -const CODEX_ACP_MODEL_CATALOG_STARTUP_REPLACEMENT = [ +const CODEX_ACP_MODEL_CATALOG_STARTUP_LEGACY_REPLACEMENT = [ 'function startCodexConnection(codexPath, env) {', ' const spawnEnv = env ?? process.env;', ' const startupConfigString = spawnEnv["CODEX_CONFIG"];', @@ -469,6 +544,45 @@ const CODEX_ACP_MODEL_CATALOG_STARTUP_REPLACEMENT = [ ' }' ].join('\n') +const CODEX_ACP_MODEL_CATALOG_STARTUP_ROLLBACK_SENTINEL = [ + '/* open-science:codex-acp-model-catalog-startup-rollback-v1', + CODEX_ACP_MODEL_CATALOG_STARTUP_LEGACY_REPLACEMENT, + '*/' +].join('\n') + +const CODEX_ACP_MODEL_CATALOG_STARTUP_REPLACEMENT = [ + 'function startCodexConnection(codexPath, env) {', + ' const spawnEnv = env ?? process.env;', + ' const startupConfigString = spawnEnv["CODEX_CONFIG"];', + ' const startupConfig = startupConfigString ? JSON.parse(startupConfigString) : void 0;', + ' const modelCatalogPath = typeof startupConfig?.model_catalog_json === "string"', + ' ? startupConfig.model_catalog_json', + ' : void 0;', + ' const disabledSkillPathsString = spawnEnv["OPEN_SCIENCE_CODEX_DISABLED_SKILL_PATHS"];', + ' const disabledSkillPaths = disabledSkillPathsString ? JSON.parse(disabledSkillPathsString) : [];', + ' if (!Array.isArray(disabledSkillPaths) || disabledSkillPaths.some((value) => typeof value !== "string" || !path4.isAbsolute(value))) {', + ' throw new Error("Invalid Open Science Codex disabled Skill paths.");', + ' }', + ' const appServerArgs = ["app-server"];', + ' if (modelCatalogPath) appServerArgs.push("-c", `model_catalog_json=${JSON.stringify(modelCatalogPath)}`);', + ' if (disabledSkillPaths.length > 0) {', + ' const disabledSkillsConfig = disabledSkillPaths', + ' .map((skillPath) => `{ path = ${JSON.stringify(skillPath)}, enabled = false }`)', + ' .join(", ");', + ' appServerArgs.push("-c", `skills.config=[${disabledSkillsConfig}]`);', + ' }', + ' let codex;', + ' if (codexPath) {', + ' codex = process.platform === "win32"', + ' ? spawn(`"${codexPath}" app-server`, appServerArgs.slice(1), { shell: true, env: spawnEnv })', + ' : spawn(codexPath, appServerArgs, { env: spawnEnv });', + ' } else {', + ' const bundledCodexPath = createRequire(import.meta.url).resolve("@openai/codex/bin/codex.js");', + ' codex = spawn(process.execPath, [bundledCodexPath, ...appServerArgs], { env: spawnEnv });', + ' }', + CODEX_ACP_MODEL_CATALOG_STARTUP_ROLLBACK_SENTINEL +].join('\n') + const CODEX_ADAPTER_REPLACE_RETRY_DELAYS_MS = [25, 50, 100, 200, 400] as const const renameWithTransientLockRetry = async (source: string, destination: string): Promise => { @@ -626,6 +740,13 @@ export const patchCodexAcpTurnUsageSource = (source: string): string => { export const patchCodexAcpSkillInputSource = (source: string): string => { if (source.includes(CODEX_ACP_SKILL_INPUT_REPLACEMENT)) return source + if (source.includes(CODEX_ACP_SKILL_INPUT_LEGACY_REPLACEMENT)) { + return source.replace( + CODEX_ACP_SKILL_INPUT_LEGACY_REPLACEMENT, + CODEX_ACP_SKILL_INPUT_REPLACEMENT + ) + } + const matches = source.split(CODEX_ACP_SKILL_INPUT_SOURCE).length - 1 if (matches === 1) { return source.replace(CODEX_ACP_SKILL_INPUT_SOURCE, CODEX_ACP_SKILL_INPUT_REPLACEMENT) @@ -634,6 +755,27 @@ export const patchCodexAcpSkillInputSource = (source: string): string => { throw new Error('Pinned Codex ACP Skill-input patch no longer matches the adapter bundle') } +export const patchCodexAcpSkillExtraRootsSource = (source: string): string => { + if (source.includes(CODEX_ACP_SKILL_EXTRA_ROOTS_REPLACEMENT)) return source + + if (source.includes(CODEX_ACP_SKILL_EXTRA_ROOTS_LEGACY_REPLACEMENT)) { + return source.replace( + CODEX_ACP_SKILL_EXTRA_ROOTS_LEGACY_REPLACEMENT, + CODEX_ACP_SKILL_EXTRA_ROOTS_REPLACEMENT + ) + } + + const matches = source.split(CODEX_ACP_SKILL_EXTRA_ROOTS_SOURCE).length - 1 + if (matches === 1) { + return source.replace( + CODEX_ACP_SKILL_EXTRA_ROOTS_SOURCE, + CODEX_ACP_SKILL_EXTRA_ROOTS_REPLACEMENT + ) + } + + throw new Error('Pinned Codex ACP Skill extra-roots patch no longer matches the adapter bundle') +} + // Codex builds its ModelsManager once when app-server starts. The adapter otherwise forwards // CODEX_CONFIG only in thread/start, which is too late for a generated model catalog to participate // in model lookup. Project just that immutable catalog path into this native process's CLI override; @@ -641,6 +783,13 @@ export const patchCodexAcpSkillInputSource = (source: string): string => { export const patchCodexAcpModelCatalogStartupSource = (source: string): string => { if (source.includes(CODEX_ACP_MODEL_CATALOG_STARTUP_REPLACEMENT)) return source + if (source.includes(CODEX_ACP_MODEL_CATALOG_STARTUP_LEGACY_REPLACEMENT)) { + return source.replace( + CODEX_ACP_MODEL_CATALOG_STARTUP_LEGACY_REPLACEMENT, + CODEX_ACP_MODEL_CATALOG_STARTUP_REPLACEMENT + ) + } + const matches = source.split(CODEX_ACP_MODEL_CATALOG_STARTUP_SOURCE).length - 1 if (matches === 1) { return source.replace( @@ -656,9 +805,11 @@ export const patchCodexAcpModelCatalogStartupSource = (source: string): string = export const ensureManagedCodexContextUsage = async (adapterPath: string): Promise => { const source = await readFile(adapterPath, 'utf8') - const patched = patchCodexAcpModelCatalogStartupSource( - patchCodexAcpSkillInputSource( - patchCodexAcpTurnUsageSource(patchCodexAcpContextUsageSource(source)) + const patched = patchCodexAcpSkillExtraRootsSource( + patchCodexAcpModelCatalogStartupSource( + patchCodexAcpSkillInputSource( + patchCodexAcpTurnUsageSource(patchCodexAcpContextUsageSource(source)) + ) ) ) diff --git a/src/main/settings/service.test.ts b/src/main/settings/service.test.ts index 8ee7ab90a..b754d8db0 100644 --- a/src/main/settings/service.test.ts +++ b/src/main/settings/service.test.ts @@ -1,4 +1,15 @@ -import { chmod, mkdir, mkdtemp, readFile, rename, rm, symlink, writeFile } from 'node:fs/promises' +import { + chmod, + lstat, + mkdir, + mkdtemp, + readFile, + readdir, + rename, + rm, + symlink, + writeFile +} from 'node:fs/promises' import { dirname, join, normalize } from 'node:path' import { tmpdir } from 'node:os' import { execPath } from 'node:process' @@ -52,6 +63,7 @@ const { UserSkillSpecialistPackageAdapter } = await import('../skills/specialist const { net: mockedNet } = (await import('electron')) as unknown as { net: { fetch: ReturnType } } +const { connectorSkillSourceRoot } = await import('../connectors/skill-source') // Production captures the non-secret framework selection at generation construction, then resolves // current credentials and provider configuration at spawn. Integration tests use that same public seam. @@ -85,7 +97,21 @@ const MANAGED_CODEX_ADAPTER_FIXTURE = [ ' return null;', ' }', ' }).filter((block) => block !== null);', - '}' + '}', + ' async refreshSkills(cwd, additionalRoots) {', + ' if (!cwd) {', + ' return;', + ' }', + ' const skillExtraRoots = additionalRoots.map((root) => path4.join(root, ".agents", "skills"));', + ' if (!arraysEqual(this.skillExtraRoots, skillExtraRoots)) {', + ' await this.codexClient.skillsExtraRootsSet({ extraRoots: skillExtraRoots });', + ' this.skillExtraRoots = skillExtraRoots;', + ' }', + ' await this.codexClient.listSkills({', + ' cwds: [cwd, ...additionalRoots],', + ' forceReload: true', + ' });', + ' }' ].join('\n') const validAnthropicResponse = (): Response => @@ -257,9 +283,17 @@ beforeEach(async () => { await writeFile(join(userCodexDir, 'auth.json'), '{"tokens":{"access_token":"test"}}') }) +const makeTreeWritable = async (directory: string): Promise => { + for (const entry of await readdir(directory, { withFileTypes: true }).catch(() => [])) { + if (entry.isDirectory()) await makeTreeWritable(join(directory, entry.name)) + } + await chmod(directory, 0o755).catch(() => undefined) +} + afterEach(async () => { vi.unstubAllGlobals() vi.unstubAllEnvs() + await makeTreeWritable(storageRoot) await rm(storageRoot, { recursive: true, force: true }) }) @@ -1215,7 +1249,7 @@ describe('SettingsService: providers', () => { await service.setActiveProvider(provider.id) const customSkillName = 'mcp-xt' - const customSkillSource = join(getAppClaudeConfigDir(storageRoot), 'skills', customSkillName) + const customSkillSource = join(connectorSkillSourceRoot(storageRoot), customSkillName) await mkdir(customSkillSource, { recursive: true }) await writeFile( join(customSkillSource, 'SKILL.md'), @@ -1233,43 +1267,39 @@ describe('SettingsService: providers', () => { }) expect(backend.persistentSystemPrompt).toContain('Stable Open Science app guidance.') - const appInstructions = await readFile( - join(storageRoot, 'opencode', 'config', 'opencode', 'instructions', 'open-science.md'), - 'utf8' - ) - expect(appInstructions).toContain('Stable Open Science app guidance.') - expect(appInstructions).toContain(join(storageRoot, 'skills', 'personal')) - expect(appInstructions).toContain(join(storageRoot, 'skills', 'imported')) - - const baseline = await readFile( - join(storageRoot, 'opencode', 'config', 'opencode', 'instructions', 'connectors.md'), - 'utf8' - ) + expect(backend.persistentSystemPrompt).toContain(join(storageRoot, 'skills', 'personal')) + expect(backend.persistentSystemPrompt).toContain(join(storageRoot, 'skills', 'imported')) + const baseline = backend.persistentSystemPrompt ?? '' expect(baseline).toContain('host.mcp') expect(baseline).toContain('mcp-*') expect(baseline).toContain('Load the matching `mcp-*` skill before the first `host.mcp` call') expect(baseline).toContain('Never guess a connector server or method name') expect(baseline).toContain('`mcp-xt`') expect(baseline).not.toContain('Use XT records.') + const customDescriptor = backend.skillRuntime?.descriptors.find( + (descriptor) => descriptor.id === customSkillName + ) + expect(customDescriptor?.path).toContain(backend.skillRuntime?.discoveryRoot) + await expect(readFile(customDescriptor!.path, 'utf8')).resolves.toContain('Use XT records.') expect(baseline).not.toContain('host.mcp("xt"') expect(baseline).not.toContain('pubchem_get_compounds') expect(baseline).not.toContain('search_articles') expect(baseline).not.toContain('```json') - expect(baseline.length).toBeLessThan(2_500) - const chemistrySkill = await readFile( - join(storageRoot, 'opencode', 'config', 'opencode', 'skills', 'mcp-chemistry', 'SKILL.md'), - 'utf8' + const chemistryDescriptor = backend.skillRuntime?.descriptors.find( + (descriptor) => descriptor.id === 'mcp-chemistry' ) + const chemistrySkill = await readFile(chemistryDescriptor!.path, 'utf8') expect(chemistrySkill).toContain('pubchem_get_compounds') expect(chemistrySkill).toContain('**Input:**') expect(chemistrySkill).not.toContain('```json') + expect(chemistryDescriptor?.path).toContain(backend.skillRuntime?.discoveryRoot) await expect( readFile( join(storageRoot, 'opencode', 'config', 'opencode', 'skills', customSkillName, 'SKILL.md'), 'utf8' ) - ).resolves.toContain('Use XT records.') + ).rejects.toMatchObject({ code: 'ENOENT' }) }) it('rejects an invalid custom context window when IPC bypasses the form', async () => { @@ -2821,13 +2851,16 @@ describe('SettingsService: preflight & spawn config', () => { expect(upstreamMessages).not.toContain('') expect(upstreamMessages).not.toContain('host.mcp("pubmed", "search_articles"') - // Connector skill docs (host.mcp guidance) must be materialized into Codex's own home, not only - // the Claude config dir, or bridged Codex never learns to reach connectors via the notebook. - const pubmedSkill = await readFile( - join(storageRoot, 'codex', 'skills', 'mcp-pubmed', 'SKILL.md'), - 'utf8' + // Connector Skill docs live in the private generation runtime, never the stable CODEX_HOME. + const pubmedDescriptor = backend.skillRuntime?.descriptors.find( + (descriptor) => descriptor.id === 'mcp-pubmed' ) + expect(pubmedDescriptor?.path).toContain(backend.skillRuntime?.discoveryRoot) + const pubmedSkill = await readFile(pubmedDescriptor!.path, 'utf8') expect(pubmedSkill).toContain('host.mcp') + await expect( + readFile(join(storageRoot, 'codex', 'skills', 'mcp-pubmed', 'SKILL.md'), 'utf8') + ).rejects.toMatchObject({ code: 'ENOENT' }) await backend.responsesBridgeLease?.release() await repository.setConversationSkillImportEnabled(false) @@ -3807,31 +3840,36 @@ describe('SettingsService: skills', () => { await service.setActiveProvider(created.id) await service.setSkillEnabled({ id: 'demo', enabled: false }) - const skillDir = join(getAppClaudeConfigDir(storageRoot), 'skills', 'os-demo') - const exists = async (path: string): Promise => - readFile(join(path, 'SKILL.md'), 'utf8').then( - () => true, - () => false - ) + const stableSkillFile = join( + getAppClaudeConfigDir(storageRoot), + 'skills', + 'os-demo', + 'SKILL.md' + ) + await mkdir(dirname(stableSkillFile), { recursive: true }) + await writeFile(stableSkillFile, '# Legacy demo', 'utf8') - // Disabled: the skill is not materialized on a normal spawn. - await resolveActiveBackend(service) - expect(await exists(skillDir)).toBe(false) + // Disabled: the skill is absent from a normal private runtime. + const normalBackend = await resolveActiveBackend(service) + expect(normalBackend.skillRuntime?.descriptors.some(({ id }) => id === 'demo')).toBe(false) - // Turn-forced: the disabled skill is materialized for this spawn only. - await resolveActiveBackend(service, { forcedSkillIds: ['demo'] }) - expect(await exists(skillDir)).toBe(true) + // Turn-forced: the disabled skill exists only in this private runtime. + const forcedBackend = await resolveActiveBackend(service, { forcedSkillIds: ['demo'] }) + const forcedDescriptor = forcedBackend.skillRuntime?.descriptors.find(({ id }) => id === 'demo') + expect(forcedDescriptor?.path).toContain(forcedBackend.skillRuntime?.discoveryRoot) + await expect(readFile(forcedDescriptor!.path, 'utf8')).resolves.toContain('demo body') // The stored disabled set is untouched, so the skill still lists as disabled. const skills = await service.listSkills() expect(skills.find((skill) => skill.id === 'demo')?.enabled).toBe(false) - // Clearing the force set removes it again on the next spawn. - await resolveActiveBackend(service) - expect(await exists(skillDir)).toBe(false) + // Clearing the force set removes it from the next runtime without rewriting the rollback copy. + const nextBackend = await resolveActiveBackend(service) + expect(nextBackend.skillRuntime?.descriptors.some(({ id }) => id === 'demo')).toBe(false) + await expect(readFile(stableSkillFile, 'utf8')).resolves.toBe('# Legacy demo') }) - it('provisions Open Science assets into the shared Claude runtime directory', async () => { + it('projects Open Science assets privately without rewriting shared Claude catalogs', async () => { const userClaudeDir = join(storageRoot, 'shared-claude') const userSkillDir = join(userClaudeDir, 'skills', 'os-user-owned') const userConnectorDir = join(userClaudeDir, 'skills', 'mcp-pubmed') @@ -3860,46 +3898,42 @@ describe('SettingsService: skills', () => { const managedSkillDir = join(appClaudeDir, 'skills', 'os-demo') const managedSkillFile = join(managedSkillDir, 'SKILL.md') - try { - const config = await resolveActiveBackend(service) - const backend = await resolveActiveBackend(service) + await mkdir(managedSkillDir, { recursive: true }) + await writeFile(managedSkillFile, '# Legacy demo', 'utf8') + const config = await resolveActiveBackend(service) + const backend = await resolveActiveBackend(service) - expect(config.env.CLAUDE_CONFIG_DIR).toBe(userClaudeDir) - expect(config.sessionOptions).toEqual({ - settings: join(appClaudeDir, 'settings.json'), - plugins: [{ type: 'local', path: appClaudeDir, skipMcpDiscovery: true }] - }) - expect(await readFile(managedSkillFile, 'utf8')).toContain('demo body') - expect(await readFile(join(userSkillDir, 'SKILL.md'), 'utf8')).toBe('# User skill') - expect(await readFile(join(userConnectorDir, 'SKILL.md'), 'utf8')).toBe( - '# User connector skill' - ) - expect( - await readFile(join(appClaudeDir, 'skills', 'mcp-pubmed', 'SKILL.md'), 'utf8') - ).toContain('name: mcp-pubmed') - expect(await readFile(join(customConnectorDir, 'SKILL.md'), 'utf8')).toBe( - '# Custom connector doc' - ) - expect(JSON.parse(await readFile(join(userClaudeDir, 'settings.json'), 'utf8'))).toEqual({ - model: 'keep-user-model' - }) - const appSettings = JSON.parse(await readFile(join(appClaudeDir, 'settings.json'), 'utf8')) - expect(appSettings.disableBundledSkills).toBe(true) - expect(appSettings.permissions.deny).toEqual( - expect.arrayContaining([expect.stringMatching(/^Read/)]) - ) - expect(backend.systemPromptAppends).toEqual( - expect.arrayContaining([ - expect.stringContaining(join(storageRoot, 'skills', 'personal')), - expect.stringContaining( - 'Load the matching `mcp-*` skill before the first `host.mcp` call' - ) - ]) - ) - } finally { - await chmod(managedSkillFile, 0o644).catch(() => undefined) - await chmod(managedSkillDir, 0o755).catch(() => undefined) - } + expect(config.env.CLAUDE_CONFIG_DIR).toBe(userClaudeDir) + expect(config.sessionOptions).toEqual({ + settings: join(appClaudeDir, 'settings.json') + }) + const demoDescriptor = config.skillRuntime?.descriptors.find(({ id }) => id === 'demo') + expect(demoDescriptor?.path).toContain(config.skillRuntime?.discoveryRoot) + await expect(readFile(demoDescriptor!.path, 'utf8')).resolves.toContain('demo body') + expect(await readFile(managedSkillFile, 'utf8')).toBe('# Legacy demo') + expect(await readFile(join(userSkillDir, 'SKILL.md'), 'utf8')).toBe('# User skill') + expect(await readFile(join(userConnectorDir, 'SKILL.md'), 'utf8')).toBe( + '# User connector skill' + ) + const pubmedDescriptor = config.skillRuntime?.descriptors.find(({ id }) => id === 'mcp-pubmed') + await expect(readFile(pubmedDescriptor!.path, 'utf8')).resolves.toContain('name: mcp-pubmed') + expect(await readFile(join(customConnectorDir, 'SKILL.md'), 'utf8')).toBe( + '# Custom connector doc' + ) + expect(JSON.parse(await readFile(join(userClaudeDir, 'settings.json'), 'utf8'))).toEqual({ + model: 'keep-user-model' + }) + const appSettings = JSON.parse(await readFile(join(appClaudeDir, 'settings.json'), 'utf8')) + expect(appSettings.disableBundledSkills).toBe(true) + expect(appSettings.permissions.deny).toEqual( + expect.arrayContaining([expect.stringMatching(/^Read/)]) + ) + expect(backend.systemPromptAppends).toEqual( + expect.arrayContaining([ + expect.stringContaining(join(storageRoot, 'skills', 'personal')), + expect.stringContaining('Load the matching `mcp-*` skill before the first `host.mcp` call') + ]) + ) }) it('injects the selected shared Claude model context window into the spawn config', async () => { @@ -3913,7 +3947,7 @@ describe('SettingsService: skills', () => { }) }) - it('materializes enabled skills into the app-owned CODEX_HOME before spawn', async () => { + it('projects enabled skills privately without rewriting the app-owned CODEX_HOME', async () => { const adapterPath = join(storageRoot, 'bin', 'codex-acp') await mkdir(dirname(adapterPath), { recursive: true }) await writeFile(adapterPath, MANAGED_CODEX_ADAPTER_FIXTURE, 'utf8') @@ -3957,7 +3991,7 @@ describe('SettingsService: skills', () => { await service.setActiveProvider(provider.id) const customSkillName = 'mcp-xt' - const customSkillSource = join(getAppClaudeConfigDir(storageRoot), 'skills', customSkillName) + const customSkillSource = join(connectorSkillSourceRoot(storageRoot), customSkillName) await mkdir(customSkillSource, { recursive: true }) await writeFile( join(customSkillSource, 'SKILL.md'), @@ -3970,51 +4004,19 @@ describe('SettingsService: skills', () => { isRefreshing: () => false }) - await resolveActiveBackend(service) - - const materializedDir = join(storageRoot, 'codex', 'skills', 'os-demo') - const materializedFile = join(materializedDir, 'SKILL.md') - try { - expect(await readFile(materializedFile, 'utf8')).toContain('demo body') - await expect( - service.codexSkillDescriptorsForIds(['demo', 'missing'], join(storageRoot, 'codex')) - ).resolves.toEqual([{ name: 'demo', path: materializedFile }]) - await expect( - readFile(join(storageRoot, 'codex', 'skills', customSkillName, 'SKILL.md'), 'utf8') - ).resolves.toContain('Use XT records.') - const selectorCatalog = await service.codexSkillCatalog(join(storageRoot, 'codex')) - expect(selectorCatalog).toEqual( - expect.arrayContaining([ - { name: 'demo', description: 'A demo skill.', path: materializedFile }, - { - name: customSkillName, - description: 'Use XT records.', - path: join(storageRoot, 'codex', 'skills', customSkillName, 'SKILL.md'), - source: 'connector' - }, - expect.objectContaining({ - name: 'mcp-pubmed', - description: expect.stringContaining('biomedical literature'), - path: join(storageRoot, 'codex', 'skills', 'mcp-pubmed', 'SKILL.md'), - source: 'connector' - }) - ]) - ) - - await service.setSkillEnabled({ id: 'demo', enabled: false }) - const catalogWithoutDemo = await service.codexSkillCatalog(join(storageRoot, 'codex')) - expect(catalogWithoutDemo.some(({ name }) => name === 'demo')).toBe(false) - expect(catalogWithoutDemo.some(({ name }) => name === 'mcp-pubmed')).toBe(true) - - await service.setConnectorEnabled({ id: 'pubmed', enabled: false }) - const catalogWithoutPubmed = await service.codexSkillCatalog(join(storageRoot, 'codex')) - expect(catalogWithoutPubmed.some(({ name }) => name === 'mcp-pubmed')).toBe(false) - } finally { - // The materializer intentionally makes agent-visible skills read-only; restore permissions so - // the test temp root can be removed on every platform. - await chmod(materializedFile, 0o644) - await chmod(materializedDir, 0o755) + const stableSkillFile = join(storageRoot, 'codex', 'skills', 'os-demo', 'SKILL.md') + await mkdir(dirname(stableSkillFile), { recursive: true }) + await writeFile(stableSkillFile, '# Legacy demo', 'utf8') + const backend = await resolveActiveBackend(service) + for (const id of ['demo', customSkillName, 'mcp-pubmed']) { + const descriptor = backend.skillRuntime?.descriptors.find((entry) => entry.id === id) + expect(descriptor?.path).toContain(backend.skillRuntime?.discoveryRoot) + await expect(readFile(descriptor!.path, 'utf8')).resolves.toBeTruthy() } + await expect(readFile(stableSkillFile, 'utf8')).resolves.toBe('# Legacy demo') + await expect( + readFile(join(storageRoot, 'codex', 'skills', customSkillName, 'SKILL.md'), 'utf8') + ).rejects.toMatchObject({ code: 'ENOENT' }) }) it('builds the Codex skill catalog from one settings snapshot', async () => { @@ -4054,7 +4056,7 @@ describe('SettingsService: skills', () => { expect(getSettings).toHaveBeenCalledTimes(1) }) - it('materializes ordinary and custom Connector Skills into the subscription home only', async () => { + it('projects ordinary and custom Connector Skills without rewriting either Codex home', async () => { const adapterPath = join(storageRoot, 'bin', 'codex-acp') await mkdir(dirname(adapterPath), { recursive: true }) await writeFile(adapterPath, MANAGED_CODEX_ADAPTER_FIXTURE, 'utf8') @@ -4083,7 +4085,7 @@ describe('SettingsService: skills', () => { }) await repository.setAgentFramework('codex') const customSkillName = 'mcp-xt' - const customSkillSource = join(getAppClaudeConfigDir(storageRoot), 'skills', customSkillName) + const customSkillSource = join(connectorSkillSourceRoot(storageRoot), customSkillName) await mkdir(customSkillSource, { recursive: true }) await writeFile( join(customSkillSource, 'SKILL.md'), @@ -4104,28 +4106,38 @@ describe('SettingsService: skills', () => { }) await service.setActiveProvider(CODEX_SHARED_PROVIDER_ID) - await resolveActiveBackend(service) + const subscriptionLegacyFile = join( + storageRoot, + 'codex-subscription', + 'skills', + 'os-demo', + 'SKILL.md' + ) + await mkdir(dirname(subscriptionLegacyFile), { recursive: true }) + await writeFile(subscriptionLegacyFile, '# Legacy subscription demo', 'utf8') + const backend = await resolveActiveBackend(service) - expect( - await readFile( - join(storageRoot, 'codex-subscription', 'skills', 'os-demo', 'SKILL.md'), - 'utf8' - ) - ).toContain('demo body') + expect(backend.env.CODEX_HOME).toBe(join(storageRoot, 'codex-subscription')) + for (const id of ['demo', customSkillName, 'mcp-pubmed']) { + const descriptor = backend.skillRuntime?.descriptors.find((entry) => entry.id === id) + expect(descriptor?.path).toContain(backend.skillRuntime?.discoveryRoot) + await expect(readFile(descriptor!.path, 'utf8')).resolves.toBeTruthy() + } + await expect(readFile(subscriptionLegacyFile, 'utf8')).resolves.toBe( + '# Legacy subscription demo' + ) await expect( readFile( join(storageRoot, 'codex-subscription', 'skills', customSkillName, 'SKILL.md'), 'utf8' ) - ).resolves.toContain('Use XT records.') + ).rejects.toMatchObject({ code: 'ENOENT' }) await expect( readFile(join(storageRoot, 'workspace', '.agents', 'skills', 'os-demo', 'SKILL.md'), 'utf8') ).rejects.toMatchObject({ code: 'ENOENT' }) await expect( readFile(join(storageRoot, 'codex', 'skills', customSkillName, 'SKILL.md'), 'utf8') ).rejects.toMatchObject({ code: 'ENOENT' }) - await chmod(join(storageRoot, 'codex-subscription', 'skills', 'os-demo', 'SKILL.md'), 0o644) - await chmod(join(storageRoot, 'codex-subscription', 'skills', 'os-demo'), 0o755) }) it('reports disabled picks and resolves agent-readable skill nudge names', async () => { @@ -5187,7 +5199,14 @@ describe('SettingsService: Subagent model', () => { providerId: provider.id, model: 'subagent-model' }) - expect(claim.backend).toMatchObject({ + const claimedBackend = await claim.acquireAttemptBackend({ + lifecycle: { + sessionId: 'session-1', + agentFrameId: 'frame-1', + runtimeSegmentId: 'runtime-1' + } + }) + expect(claimedBackend).toMatchObject({ framework: { id: 'claude-code' }, env: { ANTHROPIC_AUTH_TOKEN: 'secret' } }) @@ -6157,9 +6176,24 @@ describe('SettingsService: claude-isolated login + status coordination', () => { logoutIsolated: vi.fn().mockResolvedValue({ supported: true, authenticated: false }) } - it('verifies a pasted token with Claude under the app-owned config before reporting success', async () => { - const probe = vi.fn<(executablePath: string, env: NodeJS.ProcessEnv) => Promise>() - probe.mockResolvedValue(undefined) + it('verifies a pasted token in a disposable Claude config without touching legacy Skills', async () => { + const stableConfigDir = getAppClaudeConfigDir(storageRoot) + const legacySkillDocument = join(stableConfigDir, 'skills', 'legacy', 'SKILL.md') + await mkdir(dirname(legacySkillDocument), { recursive: true }) + await writeFile(legacySkillDocument, 'legacy rollback skill') + let probeConfigDir: string | undefined + const probe = vi.fn(async (_executablePath: string, env: NodeJS.ProcessEnv) => { + probeConfigDir = env.CLAUDE_CONFIG_DIR + expect( + probeConfigDir?.startsWith(join(storageRoot, 'runtime', 'claude-probes', 'v1', 'probe-')) + ).toBe(true) + expect(probeConfigDir).not.toBe(stableConfigDir) + await expect(lstat(probeConfigDir!)).resolves.toMatchObject({}) + await expect(readdir(join(probeConfigDir!, 'skills'))).resolves.toEqual([]) + await expect(readFile(join(probeConfigDir!, 'settings.json'), 'utf8')).resolves.toContain( + '"disableBundledSkills": true' + ) + }) const service = createService(undefined, { claudeIsolatedAuth: successAuth, executeClaudeProbe: probe @@ -6178,10 +6212,12 @@ describe('SettingsService: claude-isolated login + status coordination', () => { expect(probe).toHaveBeenCalledWith( '/bin/claude', expect.objectContaining({ - CLAUDE_CONFIG_DIR: getAppClaudeConfigDir(storageRoot), + CLAUDE_CONFIG_DIR: probeConfigDir, CLAUDE_CODE_OAUTH_TOKEN: 'sk-ant-valid' }) ) + await expect(readFile(legacySkillDocument, 'utf8')).resolves.toBe('legacy rollback skill') + await expect(lstat(probeConfigDir!)).rejects.toMatchObject({ code: 'ENOENT' }) }) it('keeps a rejected setup token unverified and records an actionable auth failure', async () => { @@ -6897,12 +6933,7 @@ describe('SettingsService: claude-shared login orchestration', () => { expect(probe).toHaveBeenCalledWith( execPath, expect.objectContaining({ ANTHROPIC_MODEL: 'claude-opus-4-6' }), - [ - '--settings', - join(getAppClaudeConfigDir(storageRoot), 'settings.json'), - '--plugin-dir', - getAppClaudeConfigDir(storageRoot) - ] + ['--settings', join(getAppClaudeConfigDir(storageRoot), 'settings.json')] ) }) @@ -7042,12 +7073,7 @@ describe('SettingsService: claude-shared login orchestration', () => { expect(probe).toHaveBeenCalledWith( execPath, expect.objectContaining({ ANTHROPIC_MODEL: 'claude-bad-model' }), - [ - '--settings', - join(getAppClaudeConfigDir(storageRoot), 'settings.json'), - '--plugin-dir', - getAppClaudeConfigDir(storageRoot) - ] + ['--settings', join(getAppClaudeConfigDir(storageRoot), 'settings.json')] ) expect( (await service.getSettingsView()).providers.find( diff --git a/src/main/settings/settings-backend.architecture.test.ts b/src/main/settings/settings-backend.architecture.test.ts index 0d1181e2e..1d40c6bad 100644 --- a/src/main/settings/settings-backend.architecture.test.ts +++ b/src/main/settings/settings-backend.architecture.test.ts @@ -765,6 +765,7 @@ describe('Settings backend ownership architecture', () => { 'src/main/settings/provider-accounts.ts' ]) expect(manifest.modules.settings_backend_resolution.ownerPaths).toEqual([ + 'src/main/settings/agent-skill-runtime-projection.ts', 'src/main/settings/backend-resolver.ts', 'src/main/settings/backend-selection-owner.ts', 'src/main/settings/backend-route-planner.ts', diff --git a/src/main/settings/skill-catalog.test.ts b/src/main/settings/skill-catalog.test.ts index 792488f20..5b013dc35 100644 --- a/src/main/settings/skill-catalog.test.ts +++ b/src/main/settings/skill-catalog.test.ts @@ -79,6 +79,15 @@ const userSkillSourceDir = (catalog: SkillCatalogModule, source: 'personal' | 'i join(catalogStorageRoots.get(catalog)!, 'skills', source) describe('SkillCatalogModule', () => { + it('exposes the complete installed catalog to the agent-facing runtime projection', async () => { + const catalog = await createCatalog(true) + + expect((await catalog.runtimeProjectionCatalog()).map((skill) => skill.id)).toEqual([ + 'demo', + 'skill-creator' + ]) + }) + it('keeps only the newest user Skill when Personal and Imported packages share a name', async () => { const storageRoot = await mkdtemp(join(tmpdir(), 'settings-skill-catalog-')) roots.push(storageRoot) diff --git a/src/main/settings/skill-catalog.ts b/src/main/settings/skill-catalog.ts index 0bc897af7..e9a1620cf 100644 --- a/src/main/settings/skill-catalog.ts +++ b/src/main/settings/skill-catalog.ts @@ -209,6 +209,13 @@ class SkillCatalogModule { return this.catalog() } + // Agent-facing runtime projections start from the complete installed catalog. Enablement and + // role scope are applied when a runtime lease is acquired, so disabled Specialist Skills and + // internal host Skills remain available without exposing their authoritative source directories. + async runtimeProjectionCatalog(): Promise { + return this.catalog() + } + // Main-process observer adapter. Keeping this read on the existing repository owner avoids a // second production transaction facade while excluding immutable bundled packages from each // writable-directory reconciliation. @@ -861,15 +868,17 @@ class SkillCatalogModule { async provisionClaudeConfig( configDir: string, disabledSkillIds: string[], - modelConfig?: ClaudeRuntimeModelConfig | null + modelConfig?: ClaudeRuntimeModelConfig | null, + materializeSkills = true ): Promise { - const skills = await this.catalog() + const skills = materializeSkills ? await this.catalog() : [] const internalIds = new Set( skills.filter((skill) => skill.exposure === 'internal').map((skill) => skill.id) ) await provisionAppClaudeConfigDir(configDir, { skills, disabledSkillIds: disabledSkillIds.filter((id) => !internalIds.has(id)), + materializeSkills, ...(modelConfig === undefined ? {} : { modelConfig }) }) } diff --git a/src/main/settings/subagent-model-owner.test.ts b/src/main/settings/subagent-model-owner.test.ts new file mode 100644 index 000000000..968b671d8 --- /dev/null +++ b/src/main/settings/subagent-model-owner.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it, vi } from 'vitest' + +import { SETTINGS_FILE_VERSION } from '../../shared/settings' +import { claudeCodeFramework, type ResolvedAgentBackend } from '../agent-framework' +import type { AgentBackendResolver } from './backend-resolver' +import type { ProviderAccountsModule } from './provider-accounts' +import type { SettingsRepository } from './repository' +import { SubagentModelOwner } from './subagent-model-owner' +import type { StoredSettings } from './types' + +const backend = (): ResolvedAgentBackend => ({ + framework: claudeCodeFramework, + backendId: 'claude-code:provider-a', + modelRoute: 'claude-anthropic', + executablePath: '/runtime/claude', + env: {}, + contextUsageModel: 'model-a' +}) + +const owner = ( + settings: StoredSettings, + resolver: Pick +): SubagentModelOwner => + new SubagentModelOwner({ + repository: { getSettings: vi.fn(async () => settings) } as unknown as SettingsRepository, + providers: {} as ProviderAccountsModule, + backendResolver: resolver as AgentBackendResolver + }) + +describe('SubagentModelOwner Skill runtime scope', () => { + it('resolves a configured Subagent admission with a Subagent runtime scope', async () => { + const resolveExplicitTarget = vi.fn(async () => backend()) + const settings: StoredSettings = { + version: SETTINGS_FILE_VERSION, + providers: [], + subagentModel: { + mode: 'fixed', + providerId: 'provider-a', + model: 'model-a', + reasoningEffort: 'medium' + } + } + + const admission = await owner(settings, { + resolveExplicitTarget, + resolveAdmittedTarget: vi.fn() + }).admit('claude-code', {}) + + expect(resolveExplicitTarget).toHaveBeenCalledWith( + expect.objectContaining({ frameworkId: 'claude-code', providerId: 'provider-a' }), + { skillRuntime: { scope: { kind: 'subagent' } } } + ) + await admission.backendLease?.release() + }) + + it('resolves an inherited admitted backend with a Subagent runtime scope', async () => { + const resolveAdmittedTarget = vi.fn(async () => backend()) + const settings: StoredSettings = { + version: SETTINGS_FILE_VERSION, + providers: [], + subagentModel: { mode: 'inherit' } + } + + const admission = await owner(settings, { + resolveExplicitTarget: vi.fn(), + resolveAdmittedTarget + }).admit('claude-code', { + backendId: 'claude-code:provider-a', + modelRoute: 'claude-anthropic', + model: 'model-a' + }) + + expect(resolveAdmittedTarget).toHaveBeenCalledWith( + expect.objectContaining({ frameworkId: 'claude-code', providerId: 'provider-a' }), + { skillRuntime: { scope: { kind: 'subagent' } } } + ) + await admission.backendLease?.release() + }) +}) diff --git a/src/main/settings/subagent-model-owner.ts b/src/main/settings/subagent-model-owner.ts index 62e9f0a68..37fa751cc 100644 --- a/src/main/settings/subagent-model-owner.ts +++ b/src/main/settings/subagent-model-owner.ts @@ -105,12 +105,15 @@ class SubagentModelOwner { throw new Error('The configured Subagent model provider validation failed.') } - const backend = await this.options.backendResolver.resolveExplicitTarget({ - frameworkId, - providerId: configuration.providerId, - model: { kind: 'required', id: configuration.model }, - reasoningEffort: configuration.reasoningEffort - }) + const backend = await this.options.backendResolver.resolveExplicitTarget( + { + frameworkId, + providerId: configuration.providerId, + model: { kind: 'required', id: configuration.model }, + reasoningEffort: configuration.reasoningEffort + }, + { skillRuntime: { scope: { kind: 'subagent' } } } + ) try { if (!backend.backendId || !backend.modelRoute) { throw new Error('The configured Subagent model has no stable runtime route.') @@ -146,6 +149,9 @@ class SubagentModelOwner { snapshot: ResolvedSubagentModelSnapshot, context: AgentBackendResolutionContext = {} ): Promise { + const scopedContext: AgentBackendResolutionContext = context.skillRuntime + ? context + : { ...context, skillRuntime: { scope: { kind: 'subagent' } } } return this.options.backendResolver.resolveAdmittedTarget( { frameworkId: snapshot.frameworkId, @@ -156,7 +162,7 @@ class SubagentModelOwner { expectedBackendId: snapshot.backendId, expectedModelRoute: snapshot.modelRoute }, - context + scopedContext ) } } diff --git a/src/main/skills/agent-skill-runtime-environment.test.ts b/src/main/skills/agent-skill-runtime-environment.test.ts new file mode 100644 index 000000000..b97059554 --- /dev/null +++ b/src/main/skills/agent-skill-runtime-environment.test.ts @@ -0,0 +1,112 @@ +import { mkdtemp, realpath, rm, stat } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, relative } from 'node:path' + +import { afterEach, describe, expect, it } from 'vitest' + +import { + prepareSkillRuntimeEnvironment, + type SkillRuntimeEnvironmentContributor +} from './agent-skill-runtime-environment' + +const roots: string[] = [] + +const makeRuntimeRoot = async (): Promise => { + const root = await mkdtemp(join(tmpdir(), 'open-science-skill-runtime-')) + roots.push(root) + return root +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +const expectDirectoryInside = async (root: string, path: string): Promise => { + expect(relative(await realpath(root), await realpath(path))).not.toMatch(/^\.\.(?:[/\\]|$)/) + expect((await stat(path)).isDirectory()).toBe(true) +} + +describe('prepareSkillRuntimeEnvironment', () => { + it('prepares common temporary and cache directories for an agent Skill runtime', async () => { + const runtimeRoot = await makeRuntimeRoot() + + const prepared = await prepareSkillRuntimeEnvironment(runtimeRoot) + + expect(prepared.env).toMatchObject({ + TMPDIR: join(runtimeRoot, 'tmp'), + TMP: join(runtimeRoot, 'tmp'), + TEMP: join(runtimeRoot, 'tmp'), + XDG_CACHE_HOME: join(runtimeRoot, 'cache') + }) + await Promise.all(prepared.directories.map((path) => expectDirectoryInside(runtimeRoot, path))) + }) + + it('isolates Python bytecode, package, and user installation state', async () => { + const runtimeRoot = await makeRuntimeRoot() + + const prepared = await prepareSkillRuntimeEnvironment(runtimeRoot) + + expect(prepared.env).toMatchObject({ + PYTHONPYCACHEPREFIX: join(runtimeRoot, 'python', 'pycache'), + PIP_CACHE_DIR: join(runtimeRoot, 'python', 'pip-cache'), + PYTHONUSERBASE: join(runtimeRoot, 'python', 'user-base') + }) + await Promise.all( + ['PYTHONPYCACHEPREFIX', 'PIP_CACHE_DIR', 'PYTHONUSERBASE'].map((name) => + expectDirectoryInside(runtimeRoot, prepared.env[name]) + ) + ) + }) + + it('isolates Node compilation and npm caches', async () => { + const runtimeRoot = await makeRuntimeRoot() + + const prepared = await prepareSkillRuntimeEnvironment(runtimeRoot) + + expect(prepared.env).toMatchObject({ + NODE_COMPILE_CACHE: join(runtimeRoot, 'node', 'compile-cache'), + npm_config_cache: join(runtimeRoot, 'node', 'npm-cache') + }) + await Promise.all( + ['NODE_COMPILE_CACHE', 'npm_config_cache'].map((name) => + expectDirectoryInside(runtimeRoot, prepared.env[name]) + ) + ) + }) + + it('isolates R cache, configuration, data, and user libraries', async () => { + const runtimeRoot = await makeRuntimeRoot() + + const prepared = await prepareSkillRuntimeEnvironment(runtimeRoot) + + expect(prepared.env).toMatchObject({ + R_USER_CACHE_DIR: join(runtimeRoot, 'r', 'cache'), + R_USER_CONFIG_DIR: join(runtimeRoot, 'r', 'config'), + R_USER_DATA_DIR: join(runtimeRoot, 'r', 'data'), + R_LIBS_USER: join(runtimeRoot, 'r', 'library') + }) + await Promise.all( + ['R_USER_CACHE_DIR', 'R_USER_CONFIG_DIR', 'R_USER_DATA_DIR', 'R_LIBS_USER'].map((name) => + expectDirectoryInside(runtimeRoot, prepared.env[name]) + ) + ) + }) + + it('adds a new language through a contributor without replacing built-in environments', async () => { + const runtimeRoot = await makeRuntimeRoot() + const julia: SkillRuntimeEnvironmentContributor = { + directoryEnvironment: { JULIA_DEPOT_PATH: 'julia/depot' } + } + + const prepared = await prepareSkillRuntimeEnvironment(runtimeRoot, [julia]) + + expect(prepared.env).toMatchObject({ + TMPDIR: join(runtimeRoot, 'tmp'), + PYTHONPYCACHEPREFIX: join(runtimeRoot, 'python', 'pycache'), + NODE_COMPILE_CACHE: join(runtimeRoot, 'node', 'compile-cache'), + R_USER_CACHE_DIR: join(runtimeRoot, 'r', 'cache'), + JULIA_DEPOT_PATH: join(runtimeRoot, 'julia', 'depot') + }) + await expectDirectoryInside(runtimeRoot, prepared.env.JULIA_DEPOT_PATH) + }) +}) diff --git a/src/main/skills/agent-skill-runtime-environment.ts b/src/main/skills/agent-skill-runtime-environment.ts new file mode 100644 index 000000000..8826d1202 --- /dev/null +++ b/src/main/skills/agent-skill-runtime-environment.ts @@ -0,0 +1,91 @@ +import { mkdir } from 'node:fs/promises' +import { isAbsolute, relative, resolve } from 'node:path' + +export type SkillRuntimeEnvironmentContributor = Readonly<{ + directoryEnvironment: Readonly> +}> + +export type PreparedSkillRuntimeEnvironment = Readonly<{ + directories: readonly string[] + env: Readonly> +}> + +const commonRuntimeEnvironment: SkillRuntimeEnvironmentContributor = { + directoryEnvironment: { + TMPDIR: 'tmp', + TMP: 'tmp', + TEMP: 'tmp', + XDG_CACHE_HOME: 'cache' + } +} + +const pythonRuntimeEnvironment: SkillRuntimeEnvironmentContributor = { + directoryEnvironment: { + PYTHONPYCACHEPREFIX: 'python/pycache', + PIP_CACHE_DIR: 'python/pip-cache', + PYTHONUSERBASE: 'python/user-base' + } +} + +const nodeRuntimeEnvironment: SkillRuntimeEnvironmentContributor = { + directoryEnvironment: { + NODE_COMPILE_CACHE: 'node/compile-cache', + npm_config_cache: 'node/npm-cache' + } +} + +const rRuntimeEnvironment: SkillRuntimeEnvironmentContributor = { + directoryEnvironment: { + R_USER_CACHE_DIR: 'r/cache', + R_USER_CONFIG_DIR: 'r/config', + R_USER_DATA_DIR: 'r/data', + R_LIBS_USER: 'r/library' + } +} + +const defaultRuntimeEnvironmentContributors = [ + commonRuntimeEnvironment, + pythonRuntimeEnvironment, + nodeRuntimeEnvironment, + rRuntimeEnvironment +] as const + +const resolveRuntimeDirectory = (runtimeRoot: string, candidate: string): string => { + const root = resolve(runtimeRoot) + const directory = resolve(root, candidate) + const pathFromRoot = relative(root, directory) + if (pathFromRoot.startsWith('..') || isAbsolute(pathFromRoot)) { + throw new Error(`Skill runtime environment path is outside the runtime root: ${candidate}`) + } + return directory +} + +const prepareSkillRuntimeEnvironment = async ( + runtimeRoot: string, + contributors: readonly SkillRuntimeEnvironmentContributor[] = [] +): Promise => { + const env: Record = {} + const directories = new Set() + + for (const contributor of [...defaultRuntimeEnvironmentContributors, ...contributors]) { + for (const [name, candidate] of Object.entries(contributor.directoryEnvironment)) { + const directory = resolveRuntimeDirectory(runtimeRoot, candidate) + env[name] = directory + directories.add(directory) + } + } + + await Promise.all([...directories].map((directory) => mkdir(directory, { recursive: true }))) + return Object.freeze({ + directories: Object.freeze([...directories]), + env: Object.freeze(env) + }) +} + +export { + commonRuntimeEnvironment, + nodeRuntimeEnvironment, + prepareSkillRuntimeEnvironment, + pythonRuntimeEnvironment, + rRuntimeEnvironment +} diff --git a/src/main/skills/agent-skill-runtime.test.ts b/src/main/skills/agent-skill-runtime.test.ts new file mode 100644 index 000000000..9a540329f --- /dev/null +++ b/src/main/skills/agent-skill-runtime.test.ts @@ -0,0 +1,1380 @@ +import { + chmod, + mkdir, + mkdtemp, + readFile, + readdir, + rm, + stat, + symlink, + writeFile +} from 'node:fs/promises' +import { execFile, spawnSync } from 'node:child_process' +import { tmpdir } from 'node:os' +import { dirname, join, sep } from 'node:path' +import { promisify } from 'node:util' + +import { afterEach, describe, expect, it } from 'vitest' + +import { AgentSkillRuntime } from './agent-skill-runtime' + +const temporaryRoots: string[] = [] +const execFileAsync = promisify(execFile) +const hasPython = spawnSync('python3', ['--version']).status === 0 +const hasRscript = spawnSync('Rscript', ['--version']).status === 0 +const supportsNodeCompileCache = Number(process.versions.node.split('.')[0]) >= 22 + +const temporaryRoot = async (prefix: string): Promise => { + const root = await mkdtemp(join(tmpdir(), prefix)) + temporaryRoots.push(root) + return root +} + +const makeTreeWritable = async (directory: string): Promise => { + const entries = await readdir(directory, { withFileTypes: true }).catch(() => []) + for (const entry of entries) { + if (entry.isDirectory()) await makeTreeWritable(join(directory, entry.name)) + } + await chmod(directory, 0o755).catch(() => undefined) +} + +const listTree = async (directory: string): Promise => { + const paths = [directory] + for (const entry of await readdir(directory, { withFileTypes: true })) { + const child = join(directory, entry.name) + paths.push(...(entry.isDirectory() ? await listTree(child) : [child])) + } + return paths +} + +afterEach(async () => { + await Promise.all( + temporaryRoots.splice(0).map(async (root) => { + await makeTreeWritable(root) + await rm(root, { recursive: true, force: true }) + }) + ) +}) + +describe('AgentSkillRuntime', () => { + it('acquires a complete agent-facing package through the runtime lease', async () => { + const storageRoot = await temporaryRoot('agent-skill-storage-') + const sourceRoot = await temporaryRoot('agent-skill-source-') + await mkdir(join(sourceRoot, 'references'), { recursive: true }) + await mkdir(join(sourceRoot, 'assets'), { recursive: true }) + await mkdir(join(sourceRoot, 'scripts'), { recursive: true }) + await writeFile( + join(sourceRoot, 'SKILL.md'), + '---\nname: paper-review\ndescription: Review a paper.\n---\nUse the package resources.\n' + ) + await writeFile(join(sourceRoot, 'references', 'method.md'), 'reference') + await writeFile(join(sourceRoot, 'assets', 'rubric.txt'), 'rubric') + await writeFile(join(sourceRoot, 'scripts', 'score.py'), 'print(1)') + + const lease = await new AgentSkillRuntime().acquire({ + storageRoot, + lifecycle: { + sessionId: 'session-1', + agentFrameId: 'frame-1', + runtimeSegmentId: 'segment-1' + }, + scope: { kind: 'main' }, + skills: [ + { + kind: 'package', + id: 'featured-paper-review', + name: 'paper-review', + description: 'Review a paper.', + sourceDir: sourceRoot, + revision: 'sha256:package-v1' + } + ] + }) + + expect(lease.skills).toHaveLength(1) + expect(lease.skills[0]).toMatchObject({ + id: 'featured-paper-review', + name: 'paper-review', + description: 'Review a paper.', + packageRevision: 'sha256:package-v1' + }) + expect(lease.projectionRoot).toMatch( + /runtime\/agent-skills\/v1\/leases\/[a-f0-9-]+\/projection$/ + ) + expect(lease.discoveryRoot).toBe(join(lease.projectionRoot, 'skills')) + expect(lease.skills[0]!.packageRoot).toBe(join(lease.discoveryRoot, 'os-featured-paper-review')) + expect(await readFile(lease.skills[0]!.skillDocumentPath, 'utf8')).toContain( + 'name: paper-review' + ) + await expect( + readFile(join(lease.projectionRoot, '.claude-plugin', 'plugin.json'), 'utf8').then(JSON.parse) + ).resolves.toEqual({ name: 'open-science-agent-skills' }) + expect(lease.env).toMatchObject({ + TMPDIR: lease.tempRoot, + XDG_CACHE_HOME: lease.cacheRoot + }) + await expect( + readFile(join(lease.skills[0]!.packageRoot, 'references', 'method.md'), 'utf8') + ).resolves.toBe('reference') + await expect( + readFile(join(lease.skills[0]!.packageRoot, 'assets', 'rubric.txt'), 'utf8') + ).resolves.toBe('rubric') + await expect( + readFile(join(lease.skills[0]!.packageRoot, 'scripts', 'score.py'), 'utf8') + ).resolves.toBe('print(1)') + + const runtimeRoot = join(storageRoot, 'runtime', 'agent-skills', 'v1') + for (const path of [ + lease.skills[0]!.packageRoot, + lease.skills[0]!.skillDocumentPath, + lease.cacheRoot, + lease.tempRoot + ]) { + expect(path.startsWith(`${runtimeRoot}/`)).toBe(true) + } + expect(lease.catalogRevision).toMatch(/^sha256:/) + }) + + it('acquires a generated Connector Skill in the native discovery root', async () => { + const storageRoot = await temporaryRoot('agent-skill-storage-') + + const lease = await new AgentSkillRuntime().acquire({ + storageRoot, + lifecycle: { + sessionId: 'session-1', + agentFrameId: 'frame-1', + runtimeSegmentId: 'segment-1' + }, + scope: { kind: 'main' }, + skills: [ + { + kind: 'generated', + id: 'mcp-pubmed', + name: 'mcp-pubmed', + description: 'Search PubMed.', + revision: 'sha256:connector-v1', + files: [ + { + path: 'SKILL.md', + content: + '---\nname: mcp-pubmed\ndescription: Search PubMed.\n---\nUse the PubMed connector.\n' + }, + { path: 'references/tools.md', content: 'Tool reference.' } + ] + } + ] + }) + + expect(lease.skills[0]).toMatchObject({ + id: 'mcp-pubmed', + name: 'mcp-pubmed', + description: 'Search PubMed.', + packageRoot: join(lease.discoveryRoot, 'os-mcp-pubmed') + }) + await expect(readFile(lease.skills[0]!.skillDocumentPath, 'utf8')).resolves.toContain( + 'Use the PubMed connector.' + ) + await expect( + readFile(join(lease.skills[0]!.packageRoot, 'references', 'tools.md'), 'utf8') + ).resolves.toBe('Tool reference.') + }) + + it.skipIf(!hasPython)( + 'runs Python compilation from the read-only package with bytecode under the lease', + async () => { + const storageRoot = await temporaryRoot('agent-skill-storage-') + const lease = await new AgentSkillRuntime().acquire({ + storageRoot, + lifecycle: { + sessionId: 'session-python', + agentFrameId: 'frame-main', + runtimeSegmentId: 'segment-python' + }, + scope: { kind: 'main' }, + skills: [ + { + kind: 'generated', + id: 'python-runtime-test', + name: 'python-runtime-test', + description: 'Exercise Python cache isolation.', + revision: 'sha256:python-runtime-test', + files: [ + { path: 'SKILL.md', content: '# Python runtime test' }, + { path: 'scripts/module.py', content: 'VALUE = 42\n' } + ] + } + ] + }) + const scriptsRoot = join(lease.skills[0]!.packageRoot, 'scripts') + + await execFileAsync('python3', ['-m', 'py_compile', join(scriptsRoot, 'module.py')], { + env: { ...process.env, ...lease.env } + }) + + expect(await readdir(scriptsRoot)).toEqual(['module.py']) + expect((await listTree(lease.env.PYTHONPYCACHEPREFIX)).length).toBeGreaterThan(1) + await lease.release() + } + ) + + it.skipIf(!supportsNodeCompileCache)( + 'runs Node modules from the read-only package with compile cache under the lease', + async () => { + const storageRoot = await temporaryRoot('agent-skill-storage-') + const lease = await new AgentSkillRuntime().acquire({ + storageRoot, + lifecycle: { + sessionId: 'session-node', + agentFrameId: 'frame-main', + runtimeSegmentId: 'segment-node' + }, + scope: { kind: 'main' }, + skills: [ + { + kind: 'generated', + id: 'node-runtime-test', + name: 'node-runtime-test', + description: 'Exercise Node cache isolation.', + revision: 'sha256:node-runtime-test', + files: [ + { path: 'SKILL.md', content: '# Node runtime test' }, + { path: 'scripts/module.js', content: 'module.exports = 42\n' }, + { path: 'scripts/main.js', content: "require('./module.js')\n" } + ] + } + ] + }) + const scriptsRoot = join(lease.skills[0]!.packageRoot, 'scripts') + + await execFileAsync(process.execPath, [join(scriptsRoot, 'main.js')], { + env: { ...process.env, ...lease.env } + }) + + expect((await readdir(scriptsRoot)).sort()).toEqual(['main.js', 'module.js']) + expect((await listTree(lease.env.NODE_COMPILE_CACHE)).length).toBeGreaterThan(1) + await lease.release() + } + ) + + it.skipIf(!hasRscript)( + 'resolves R package cache and user libraries inside the writable lease', + async () => { + const storageRoot = await temporaryRoot('agent-skill-storage-') + const lease = await new AgentSkillRuntime().acquire({ + storageRoot, + lifecycle: { + sessionId: 'session-r', + agentFrameId: 'frame-main', + runtimeSegmentId: 'segment-r' + }, + scope: { kind: 'main' }, + skills: [] + }) + + const { stdout } = await execFileAsync( + 'Rscript', + [ + '-e', + 'cat(tools::R_user_dir("open.science.test", "cache"), "\\n"); cat(.libPaths()[1], "\\n")' + ], + { env: { ...process.env, ...lease.env } } + ) + const [cache, library] = stdout.trim().split(/\r?\n/) + + expect(cache).toContain(lease.env.R_USER_CACHE_DIR) + expect(library).toBe(lease.env.R_LIBS_USER) + await lease.release() + } + ) + + it('gives equivalent acquisitions private disposable projections', async () => { + const storageRoot = await temporaryRoot('agent-skill-storage-') + const runtime = new AgentSkillRuntime() + const input = { + storageRoot, + lifecycle: { + sessionId: 'session-1', + agentFrameId: 'frame-1', + runtimeSegmentId: 'segment-1' + }, + scope: { kind: 'main' as const }, + skills: [ + { + kind: 'generated' as const, + id: 'mcp-pubmed', + name: 'mcp-pubmed', + description: 'Search PubMed.', + revision: 'sha256:connector-v1', + files: [{ path: 'SKILL.md', content: '# PubMed' }] + } + ] + } + + const first = await runtime.acquire(input) + const second = await runtime.acquire({ + ...input, + lifecycle: { ...input.lifecycle, runtimeSegmentId: 'segment-2' } + }) + + expect(second.projectionRoot).not.toBe(first.projectionRoot) + expect(second.catalogRevision).toBe(first.catalogRevision) + expect(second.tempRoot).not.toBe(first.tempRoot) + await first.release() + await expect(stat(first.projectionRoot)).rejects.toMatchObject({ code: 'ENOENT' }) + await expect(readFile(second.skills[0]!.skillDocumentPath, 'utf8')).resolves.toBe('# PubMed') + await second.release() + await expect(stat(second.projectionRoot)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('gives concurrent acquisitions independent projection and writable roots', async () => { + const storageRoot = await temporaryRoot('agent-skill-storage-') + const runtime = new AgentSkillRuntime() + const input = { + storageRoot, + scope: { kind: 'main' as const }, + skills: [ + { + kind: 'generated' as const, + id: 'concurrent-skill', + name: 'concurrent-skill', + description: 'Concurrent Skill.', + revision: 'sha256:concurrent-v1', + files: [{ path: 'SKILL.md', content: '# Concurrent' }] + } + ] + } + + const [first, second] = await Promise.all( + ['segment-1', 'segment-2'].map((runtimeSegmentId) => + runtime.acquire({ + ...input, + lifecycle: { sessionId: 'session-1', agentFrameId: 'frame-1', runtimeSegmentId } + }) + ) + ) + + expect(second.projectionRoot).not.toBe(first.projectionRoot) + expect(second.catalogRevision).toBe(first.catalogRevision) + expect(second.tempRoot).not.toBe(first.tempRoot) + await Promise.all([first.release(), second.release()]) + }) + + it.skipIf(process.platform === 'win32')( + 'creates a new catalog generation for a chmod-only package change', + async () => { + const storageRoot = await temporaryRoot('agent-skill-storage-') + const sourceRoot = await temporaryRoot('agent-skill-source-') + const scriptPath = join(sourceRoot, 'scripts', 'run.sh') + await mkdir(join(sourceRoot, 'scripts'), { recursive: true }) + await writeFile(join(sourceRoot, 'SKILL.md'), '# Mode Skill') + await writeFile(scriptPath, '#!/bin/sh\n', { mode: 0o644 }) + const runtime = new AgentSkillRuntime() + const acquire = (runtimeSegmentId: string): ReturnType => + runtime.acquire({ + storageRoot, + lifecycle: { sessionId: 'session-1', agentFrameId: 'frame-1', runtimeSegmentId }, + scope: { kind: 'main' }, + skills: [ + { + kind: 'package', + id: 'mode-skill', + name: 'mode-skill', + description: 'Mode Skill.', + sourceDir: sourceRoot, + revision: 'same-upstream-revision' + } + ] + }) + + const before = await acquire('before') + await chmod(scriptPath, 0o755) + const after = await acquire('after') + + expect(after.projectionRoot).not.toBe(before.projectionRoot) + expect(after.catalogRevision).not.toBe(before.catalogRevision) + expect( + (await stat(join(after.skills[0]!.packageRoot, 'scripts', 'run.sh'))).mode & 0o111 + ).toBe(0o111) + await expect(readFile(before.skills[0]!.skillDocumentPath, 'utf8')).resolves.toBe( + '# Mode Skill' + ) + await Promise.all([before.release(), after.release()]) + } + ) + + it.skipIf(process.platform === 'win32')( + 'keeps generated catalogs with identical bytes but different normalized modes distinct', + async () => { + const storageRoot = await temporaryRoot('agent-skill-storage-') + const runtime = new AgentSkillRuntime() + const acquire = ( + mode: number, + runtimeSegmentId: string + ): ReturnType => + runtime.acquire({ + storageRoot, + lifecycle: { sessionId: 'session-1', agentFrameId: 'frame-1', runtimeSegmentId }, + scope: { kind: 'main' }, + skills: [ + { + kind: 'generated', + id: 'generated-mode', + name: 'generated-mode', + description: 'Generated mode.', + revision: 'same-upstream-revision', + files: [ + { path: 'SKILL.md', content: '# Generated Mode' }, + { path: 'scripts/run.sh', content: '#!/bin/sh\n', mode } + ] + } + ] + }) + + const regular = await acquire(0o644, 'regular') + const executable = await acquire(0o755, 'executable') + + expect(executable.projectionRoot).not.toBe(regular.projectionRoot) + expect(executable.catalogRevision).not.toBe(regular.catalogRevision) + expect( + (await stat(join(regular.skills[0]!.packageRoot, 'scripts', 'run.sh'))).mode & 0o111 + ).toBe(0) + expect( + (await stat(join(executable.skills[0]!.packageRoot, 'scripts', 'run.sh'))).mode & 0o111 + ).toBe(0o111) + await Promise.all([regular.release(), executable.release()]) + } + ) + + it('removes crash-stale lease trees without touching active leases or legacy catalogs', async () => { + const storageRoot = await temporaryRoot('agent-skill-storage-') + const agentRuntimeRoot = join(storageRoot, 'runtime', 'agent-skills', 'v1') + const staleRoot = join(agentRuntimeRoot, 'leases', 'stale-from-a-previous-process') + const rollbackLeaseRoot = join(agentRuntimeRoot, 'leases', 'rollback-active-lease') + const legacyCatalogRoot = join(agentRuntimeRoot, 'catalogs', 'rollback-catalog') + const exitedProcess = spawnSync(process.execPath, ['-e', '']) + expect(exitedProcess.status).toBe(0) + await mkdir(staleRoot, { recursive: true }) + await writeFile( + join(staleRoot, 'owner.json'), + `${JSON.stringify({ + version: 2, + kind: 'runtime-lease', + processId: exitedProcess.pid + })}\n` + ) + await mkdir(rollbackLeaseRoot, { recursive: true }) + await writeFile(join(rollbackLeaseRoot, 'owner.json'), '{"version":1,"ownerId":"rollback"}\n') + await writeFile(join(rollbackLeaseRoot, 'retained.txt'), 'rollback lease') + await mkdir(legacyCatalogRoot, { recursive: true }) + await writeFile(join(legacyCatalogRoot, 'retained.txt'), 'rollback') + const runtime = new AgentSkillRuntime() + const acquire = (runtimeSegmentId: string): ReturnType => + runtime.acquire({ + storageRoot, + lifecycle: { sessionId: 'session-1', agentFrameId: 'frame-1', runtimeSegmentId }, + scope: { kind: 'main' }, + skills: [] + }) + + const first = await acquire('first') + await expect(stat(staleRoot)).rejects.toMatchObject({ code: 'ENOENT' }) + await expect(readFile(join(rollbackLeaseRoot, 'retained.txt'), 'utf8')).resolves.toBe( + 'rollback lease' + ) + await expect(readFile(join(legacyCatalogRoot, 'retained.txt'), 'utf8')).resolves.toBe( + 'rollback' + ) + const second = await acquire('second') + await expect(stat(first.projectionRoot)).resolves.toMatchObject({}) + await Promise.all([first.release(), second.release()]) + }) + + it('removes a private lease when its authorization snapshot fails', async () => { + const storageRoot = await temporaryRoot('agent-skill-storage-') + let failAuthorization = true + let failedCatalogRoot: string | undefined + const runtime = new AgentSkillRuntime({ + beforeAuthorizeCatalog: async (catalogRoot) => { + if (!failAuthorization) return + failAuthorization = false + failedCatalogRoot = catalogRoot + await makeTreeWritable(catalogRoot) + await rm(catalogRoot, { recursive: true, force: true }) + } + }) + const input = ( + content: string, + runtimeSegmentId: string + ): Parameters[0] => ({ + storageRoot, + lifecycle: { sessionId: 'session-1', agentFrameId: 'frame-1', runtimeSegmentId }, + scope: { kind: 'main' as const }, + skills: [ + { + kind: 'generated' as const, + id: 'authorization-snapshot', + name: 'authorization-snapshot', + description: 'Authorization snapshot.', + revision: `revision-${content}`, + files: [{ path: 'SKILL.md', content }] + } + ] + }) + + await expect(runtime.acquire(input('same', 'failed'))).rejects.toThrow(/before authorization/i) + expect(failedCatalogRoot).toBeTruthy() + await expect(stat(failedCatalogRoot!)).rejects.toMatchObject({ code: 'ENOENT' }) + + const recovered = await runtime.acquire(input('same', 'recovered')) + expect(recovered.projectionRoot).not.toBe(failedCatalogRoot) + await expect(readFile(recovered.skills[0]!.skillDocumentPath, 'utf8')).resolves.toBe('same') + await recovered.release() + const successor = await runtime.acquire(input('successor', 'successor')) + + await expect(readFile(successor.skills[0]!.skillDocumentPath, 'utf8')).resolves.toBe( + 'successor' + ) + await successor.release() + }) + + it('reconstructs a fork in a private projection that survives parent release', async () => { + const storageRoot = await temporaryRoot('agent-skill-storage-') + const runtime = new AgentSkillRuntime() + const primary = await runtime.acquire({ + storageRoot, + lifecycle: { sessionId: 'session-1', agentFrameId: 'frame-1', runtimeSegmentId: 'primary' }, + scope: { kind: 'main' }, + skills: [ + { + kind: 'generated', + id: 'forked-skill', + name: 'forked-skill', + description: 'Forked Skill.', + revision: 'forked-v1', + files: [{ path: 'SKILL.md', content: '# Forked' }] + } + ] + }) + const forked = await runtime.fork(primary, { + lifecycle: { sessionId: 'session-1', agentFrameId: 'frame-2', runtimeSegmentId: 'forked' }, + scope: { kind: 'subagent' } + }) + + expect(forked.projectionRoot).not.toBe(primary.projectionRoot) + expect(forked.catalogRevision).toBe(primary.catalogRevision) + await primary.release() + await expect(readFile(forked.skills[0]!.skillDocumentPath, 'utf8')).resolves.toBe('# Forked') + await forked.release() + await expect(readFile(forked.skills[0]!.skillDocumentPath, 'utf8')).rejects.toMatchObject({ + code: 'ENOENT' + }) + }) + + it('publishes a stable native discovery directory when every optional Skill is disabled', async () => { + const storageRoot = await temporaryRoot('agent-skill-storage-') + const lease = await new AgentSkillRuntime().acquire({ + storageRoot, + lifecycle: { + sessionId: 'session-empty', + agentFrameId: 'frame-main', + runtimeSegmentId: 'segment-empty' + }, + scope: { kind: 'main' }, + skills: [] + }) + + expect(await readdir(lease.discoveryRoot)).toEqual([]) + await lease.release() + }) + + it('applies a package SKILL.md override without modifying the source package', async () => { + const storageRoot = await temporaryRoot('agent-skill-storage-') + const sourceRoot = await temporaryRoot('agent-skill-source-') + const sourceDocument = join(sourceRoot, 'SKILL.md') + await writeFile( + sourceDocument, + '---\nname: remote-compute-ssh\n---\nSource compute instructions.\n' + ) + if (process.platform !== 'win32') await chmod(sourceDocument, 0o640) + + const lease = await new AgentSkillRuntime().acquire({ + storageRoot, + lifecycle: { + sessionId: 'session-1', + agentFrameId: 'frame-1', + runtimeSegmentId: 'segment-1' + }, + scope: { kind: 'main' }, + skills: [ + { + kind: 'package', + id: 'remote-compute-ssh', + name: 'remote-compute-ssh', + description: 'Use registered compute hosts.', + sourceDir: sourceRoot, + revision: 'sha256:compute-hosts-v2', + overrides: [ + { + path: 'SKILL.md', + content: + '---\nname: remote-compute-ssh\n---\nGenerated registered-host instructions.\n' + } + ] + } + ] + }) + + await expect(readFile(lease.skills[0]!.skillDocumentPath, 'utf8')).resolves.toContain( + 'Generated registered-host instructions.' + ) + await expect(readFile(sourceDocument, 'utf8')).resolves.toContain( + 'Source compute instructions.' + ) + if (process.platform !== 'win32') { + expect((await stat(sourceDocument)).mode & 0o777).toBe(0o640) + } + }) + + it.skipIf(process.platform === 'win32')( + 'normalizes an override executable bit without modifying the package source mode', + async () => { + const storageRoot = await temporaryRoot('agent-skill-storage-') + const sourceRoot = await temporaryRoot('agent-skill-source-') + const sourceScript = join(sourceRoot, 'scripts', 'run.sh') + await mkdir(join(sourceRoot, 'scripts'), { recursive: true }) + await writeFile(join(sourceRoot, 'SKILL.md'), '# Override Mode') + await writeFile(sourceScript, '#!/bin/sh\nexit 1\n', { mode: 0o644 }) + + const lease = await new AgentSkillRuntime().acquire({ + storageRoot, + lifecycle: { + sessionId: 'session-override-mode', + agentFrameId: 'frame-main', + runtimeSegmentId: 'segment-override-mode' + }, + scope: { kind: 'main' }, + skills: [ + { + kind: 'package', + id: 'override-mode', + name: 'override-mode', + description: 'Override mode.', + sourceDir: sourceRoot, + revision: 'override-mode-v1', + overrides: [{ path: 'scripts/run.sh', content: '#!/bin/sh\nexit 0\n', mode: 0o755 }] + } + ] + }) + + expect((await stat(sourceScript)).mode & 0o111).toBe(0) + expect( + (await stat(join(lease.skills[0]!.packageRoot, 'scripts', 'run.sh'))).mode & 0o111 + ).toBe(0o111) + await lease.release() + } + ) + + it('rejects an unsafe generated path without publishing a partial catalog', async () => { + const storageRoot = await temporaryRoot('agent-skill-storage-') + + await expect( + new AgentSkillRuntime().acquire({ + storageRoot, + lifecycle: { + sessionId: 'session-1', + agentFrameId: 'frame-1', + runtimeSegmentId: 'segment-1' + }, + scope: { kind: 'main' }, + skills: [ + { + kind: 'generated', + id: 'mcp-safe', + name: 'mcp-safe', + description: 'Safe generated Skill.', + revision: 'sha256:safe', + files: [{ path: 'SKILL.md', content: 'safe' }] + }, + { + kind: 'generated', + id: 'mcp-unsafe', + name: 'mcp-unsafe', + description: 'Unsafe generated Skill.', + revision: 'sha256:unsafe', + files: [ + { path: 'SKILL.md', content: 'unsafe' }, + { path: '../escaped.md', content: 'escaped' } + ] + } + ] + }) + ).rejects.toThrow(/unsafe generated file path/i) + + const catalogsRoot = join(storageRoot, 'runtime', 'agent-skills', 'v1', 'catalogs') + await expect(readdir(catalogsRoot).catch(() => [])).resolves.toEqual([]) + }) + + it('rejects a generated package without a regular SKILL.md before publication', async () => { + const storageRoot = await temporaryRoot('agent-skill-storage-') + + await expect( + new AgentSkillRuntime().acquire({ + storageRoot, + lifecycle: { + sessionId: 'session-1', + agentFrameId: 'frame-1', + runtimeSegmentId: 'segment-1' + }, + scope: { kind: 'main' }, + skills: [ + { + kind: 'generated', + id: 'mcp-incomplete', + name: 'mcp-incomplete', + description: 'Incomplete generated Skill.', + revision: 'sha256:incomplete', + files: [{ path: 'references/tools.md', content: 'Tools only.' }] + } + ] + }) + ).rejects.toThrow(/regular SKILL\.md/i) + + const catalogsRoot = join(storageRoot, 'runtime', 'agent-skills', 'v1', 'catalogs') + await expect(readdir(catalogsRoot).catch(() => [])).resolves.toEqual([]) + }) + + it('rejects a package containing a symbolic link', async () => { + const storageRoot = await temporaryRoot('agent-skill-storage-') + const sourceRoot = await temporaryRoot('agent-skill-source-') + const externalRoot = await temporaryRoot('agent-skill-external-') + await writeFile(join(sourceRoot, 'SKILL.md'), '---\nname: unsafe-skill\n---\n') + await writeFile(join(externalRoot, 'secret.txt'), 'secret') + await mkdir(join(sourceRoot, 'references')) + await symlink(join(externalRoot, 'secret.txt'), join(sourceRoot, 'references', 'escaped.txt')) + + await expect( + new AgentSkillRuntime().acquire({ + storageRoot, + lifecycle: { + sessionId: 'session-1', + agentFrameId: 'frame-1', + runtimeSegmentId: 'segment-1' + }, + scope: { kind: 'main' }, + skills: [ + { + kind: 'package', + id: 'unsafe-skill', + name: 'unsafe-skill', + description: 'Unsafe Skill.', + sourceDir: sourceRoot, + revision: 'sha256:unsafe' + } + ] + }) + ).rejects.toThrow(/symbolic link/i) + }) + + it('rejects a Skill id that could escape the package directory', async () => { + const storageRoot = await temporaryRoot('agent-skill-storage-') + const sourceRoot = await temporaryRoot('agent-skill-source-') + await writeFile(join(sourceRoot, 'SKILL.md'), 'unsafe') + + await expect( + new AgentSkillRuntime().acquire({ + storageRoot, + lifecycle: { + sessionId: 'session-1', + agentFrameId: 'frame-1', + runtimeSegmentId: 'segment-1' + }, + scope: { kind: 'main' }, + skills: [ + { + kind: 'package', + id: '../escaped', + name: 'unsafe-skill', + description: 'Unsafe Skill.', + sourceDir: sourceRoot, + revision: 'sha256:unsafe' + } + ] + }) + ).rejects.toThrow(/unsafe Skill id/i) + }) + + it('rejects duplicate native Skill names', async () => { + const storageRoot = await temporaryRoot('agent-skill-storage-') + const firstSource = await temporaryRoot('agent-skill-source-') + const secondSource = await temporaryRoot('agent-skill-source-') + await writeFile(join(firstSource, 'SKILL.md'), 'first') + await writeFile(join(secondSource, 'SKILL.md'), 'second') + + await expect( + new AgentSkillRuntime().acquire({ + storageRoot, + lifecycle: { + sessionId: 'session-1', + agentFrameId: 'frame-1', + runtimeSegmentId: 'segment-1' + }, + scope: { kind: 'main' }, + skills: [ + { + kind: 'package', + id: 'first-skill', + name: 'shared-name', + description: 'First Skill.', + sourceDir: firstSource, + revision: 'sha256:first' + }, + { + kind: 'package', + id: 'second-skill', + name: 'shared-name', + description: 'Second Skill.', + sourceDir: secondSource, + revision: 'sha256:second' + } + ] + }) + ).rejects.toThrow(/duplicate Skill name/i) + }) + + it('does not publish a partial catalog when a package copy fails', async () => { + const storageRoot = await temporaryRoot('agent-skill-storage-') + const validSource = await temporaryRoot('agent-skill-source-') + const invalidSource = await temporaryRoot('agent-skill-source-') + await writeFile(join(validSource, 'SKILL.md'), '---\nname: valid-skill\n---\n') + await writeFile(join(invalidSource, 'SKILL.md'), '---\nname: invalid-skill\n---\n') + await symlink(join(validSource, 'SKILL.md'), join(invalidSource, 'escaped.md')) + + await expect( + new AgentSkillRuntime().acquire({ + storageRoot, + lifecycle: { + sessionId: 'session-1', + agentFrameId: 'frame-1', + runtimeSegmentId: 'segment-1' + }, + scope: { kind: 'main' }, + skills: [ + { + kind: 'package', + id: 'valid-skill', + name: 'valid-skill', + description: 'Valid Skill.', + sourceDir: validSource, + revision: 'sha256:valid' + }, + { + kind: 'package', + id: 'invalid-skill', + name: 'invalid-skill', + description: 'Invalid Skill.', + sourceDir: invalidSource, + revision: 'sha256:invalid' + } + ] + }) + ).rejects.toThrow(/symbolic link/i) + + const catalogsRoot = join(storageRoot, 'runtime', 'agent-skills', 'v1', 'catalogs') + await expect(readdir(catalogsRoot).catch(() => [])).resolves.toEqual([]) + }) + + it('releases its exact lease root even when its owner manifest is modified', async () => { + const storageRoot = await temporaryRoot('agent-skill-storage-') + const lease = await new AgentSkillRuntime().acquire({ + storageRoot, + lifecycle: { + sessionId: 'session-1', + agentFrameId: 'frame-1', + runtimeSegmentId: 'segment-1' + }, + scope: { kind: 'main' }, + skills: [] + }) + const sentinel = join(lease.cacheRoot, 'remove.txt') + await writeFile(sentinel, 'remove') + await writeFile( + join(dirname(lease.cacheRoot), 'owner.json'), + `${JSON.stringify({ version: 1, ownerId: 'different-owner' })}\n` + ) + + await lease.release() + + await expect(readFile(sentinel, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) + await expect(lease.release()).resolves.toBeUndefined() + }) + + it('isolates mutation, deletion, chmod, and symlink tampering to one projection', async () => { + const storageRoot = await temporaryRoot('agent-skill-storage-') + const protectedRoot = join(storageRoot, 'config') + const protectedFile = join(protectedRoot, 'credentials.json') + await mkdir(protectedRoot) + await writeFile(protectedFile, 'protected') + const runtime = new AgentSkillRuntime() + const input = { + storageRoot, + lifecycle: { + sessionId: 'session-1', + agentFrameId: 'frame-1', + runtimeSegmentId: 'segment-1' + }, + scope: { kind: 'main' as const }, + skills: [ + { + kind: 'generated' as const, + id: 'safe-skill', + name: 'safe-skill', + description: 'Safe Skill.', + revision: 'sha256:safe-skill', + files: [ + { path: 'SKILL.md', content: '# Safe' }, + { path: 'references/method.md', content: 'trusted method' }, + { path: 'scripts/run.py', content: 'print(1)' } + ] + } + ] + } + const first = await runtime.acquire(input) + const second = await runtime.acquire({ + ...input, + lifecycle: { ...input.lifecycle, runtimeSegmentId: 'segment-2' } + }) + const firstPackage = first.skills[0]!.packageRoot + const firstDocument = first.skills[0]!.skillDocumentPath + const firstReference = join(firstPackage, 'references', 'method.md') + const firstScript = join(firstPackage, 'scripts', 'run.py') + await chmod(first.skills[0]!.packageRoot, 0o755) + await chmod(firstDocument, 0o644) + await writeFile(firstDocument, '# Modified') + await chmod(join(firstPackage, 'references'), 0o755) + await rm(firstReference) + await symlink(protectedFile, firstReference) + await chmod(firstScript, 0o755) + + await expect(readFile(second.skills[0]!.skillDocumentPath, 'utf8')).resolves.toBe('# Safe') + await expect( + readFile(join(second.skills[0]!.packageRoot, 'references', 'method.md'), 'utf8') + ).resolves.toBe('trusted method') + if (process.platform !== 'win32') { + expect( + (await stat(join(second.skills[0]!.packageRoot, 'scripts', 'run.py'))).mode & 0o111 + ).toBe(0) + } + + const forked = await runtime.fork(second, { + lifecycle: { sessionId: 'session-1', agentFrameId: 'frame-2', runtimeSegmentId: 'forked' }, + scope: { kind: 'subagent' } + }) + expect(forked.projectionRoot).not.toBe(first.projectionRoot) + expect(forked.projectionRoot).not.toBe(second.projectionRoot) + await expect(readFile(forked.skills[0]!.skillDocumentPath, 'utf8')).resolves.toBe('# Safe') + await expect( + readFile(join(forked.skills[0]!.packageRoot, 'references', 'method.md'), 'utf8') + ).resolves.toBe('trusted method') + + await expect(readFile(protectedFile, 'utf8')).resolves.toBe('protected') + await Promise.all([first.release(), second.release(), forked.release()]) + }) + + it('fails closed when a package source changes before a fork', async () => { + const storageRoot = await temporaryRoot('agent-skill-storage-') + const sourceRoot = await temporaryRoot('agent-skill-source-') + const sourceDocument = join(sourceRoot, 'SKILL.md') + await writeFile(sourceDocument, '# Source v1') + const runtime = new AgentSkillRuntime() + const primary = await runtime.acquire({ + storageRoot, + lifecycle: { sessionId: 'session-1', agentFrameId: 'frame-1', runtimeSegmentId: 'primary' }, + scope: { kind: 'main' }, + skills: [ + { + kind: 'package', + id: 'source-change', + name: 'source-change', + description: 'Source change.', + sourceDir: sourceRoot, + revision: 'unchanged-declared-revision' + } + ] + }) + await writeFile(sourceDocument, '# Source v2') + + await expect( + runtime.fork(primary, { + lifecycle: { sessionId: 'session-1', agentFrameId: 'frame-2', runtimeSegmentId: 'fork' }, + scope: { kind: 'subagent' } + }) + ).rejects.toThrow(/source package changed/i) + await expect(readFile(primary.skills[0]!.skillDocumentPath, 'utf8')).resolves.toBe( + '# Source v1' + ) + await primary.release() + }) + + it('rebuilds a fork from the validated package source instead of a tampered parent', async () => { + const storageRoot = await temporaryRoot('agent-skill-storage-') + const sourceRoot = await temporaryRoot('agent-skill-source-') + await mkdir(join(sourceRoot, 'references')) + await writeFile(join(sourceRoot, 'SKILL.md'), '# Authoritative') + await writeFile(join(sourceRoot, 'references', 'method.md'), 'authoritative method') + const runtime = new AgentSkillRuntime() + const primary = await runtime.acquire({ + storageRoot, + lifecycle: { sessionId: 'session-1', agentFrameId: 'frame-1', runtimeSegmentId: 'primary' }, + scope: { kind: 'main' }, + skills: [ + { + kind: 'package', + id: 'source-blueprint', + name: 'source-blueprint', + description: 'Source blueprint.', + sourceDir: sourceRoot, + revision: 'source-blueprint-v1' + } + ] + }) + await chmod(primary.skills[0]!.packageRoot, 0o755) + await chmod(primary.skills[0]!.skillDocumentPath, 0o644) + await writeFile(primary.skills[0]!.skillDocumentPath, '# Tampered parent') + + const forked = await runtime.fork(primary, { + lifecycle: { sessionId: 'session-1', agentFrameId: 'frame-2', runtimeSegmentId: 'forked' }, + scope: { kind: 'subagent' } + }) + await expect(readFile(forked.skills[0]!.skillDocumentPath, 'utf8')).resolves.toBe( + '# Authoritative' + ) + await expect( + readFile(join(forked.skills[0]!.packageRoot, 'references', 'method.md'), 'utf8') + ).resolves.toBe('authoritative method') + await Promise.all([primary.release(), forked.release()]) + }) + + it('defensively clones generated bytes before retaining a fork blueprint', async () => { + const storageRoot = await temporaryRoot('agent-skill-storage-') + const documentBytes = new TextEncoder().encode('# Original bytes') + const runtime = new AgentSkillRuntime() + const acquiring = runtime.acquire({ + storageRoot, + lifecycle: { sessionId: 'session-1', agentFrameId: 'frame-1', runtimeSegmentId: 'primary' }, + scope: { kind: 'main' }, + skills: [ + { + kind: 'generated', + id: 'cloned-bytes', + name: 'cloned-bytes', + description: 'Cloned bytes.', + revision: 'cloned-bytes-v1', + files: [{ path: 'SKILL.md', content: documentBytes }] + } + ] + }) + documentBytes.fill('X'.charCodeAt(0)) + const primary = await acquiring + + const forked = await runtime.fork(primary, { + lifecycle: { sessionId: 'session-1', agentFrameId: 'frame-2', runtimeSegmentId: 'forked' }, + scope: { kind: 'subagent' } + }) + await expect(readFile(primary.skills[0]!.skillDocumentPath, 'utf8')).resolves.toBe( + '# Original bytes' + ) + await expect(readFile(forked.skills[0]!.skillDocumentPath, 'utf8')).resolves.toBe( + '# Original bytes' + ) + await Promise.all([primary.release(), forked.release()]) + }) + + it.runIf(process.platform === 'win32')( + 'isolates and disposes projections without relying on Windows mode-bit immutability', + async () => { + const storageRoot = await temporaryRoot('agent-skill-storage-') + const runtime = new AgentSkillRuntime() + const input = { + storageRoot, + lifecycle: { + sessionId: 'session-1', + agentFrameId: 'frame-1', + runtimeSegmentId: 'first' + }, + scope: { kind: 'main' as const }, + skills: [ + { + kind: 'generated' as const, + id: 'windows-isolation', + name: 'windows-isolation', + description: 'Windows isolation.', + revision: 'windows-v1', + files: [{ path: 'SKILL.md', content: '# Original' }] + } + ] + } + const first = await runtime.acquire(input) + const second = await runtime.acquire({ + ...input, + lifecycle: { ...input.lifecycle, runtimeSegmentId: 'second' } + }) + await writeFile(first.skills[0]!.skillDocumentPath, '# Modified') + + await expect(readFile(second.skills[0]!.skillDocumentPath, 'utf8')).resolves.toBe( + '# Original' + ) + await first.release() + await expect(stat(first.projectionRoot)).rejects.toMatchObject({ code: 'ENOENT' }) + await expect(readFile(second.skills[0]!.skillDocumentPath, 'utf8')).resolves.toBe( + '# Original' + ) + await second.release() + } + ) + + it('cleans a partial lease when acquisition fails after environment creation', async () => { + const storageRoot = await temporaryRoot('agent-skill-storage-') + const invalidLifecycle = { + sessionId: 'session-1', + agentFrameId: 'frame-1', + runtimeSegmentId: 1n + } as unknown as { + sessionId: string + agentFrameId: string + runtimeSegmentId: string + } + + await expect( + new AgentSkillRuntime().acquire({ + storageRoot, + lifecycle: invalidLifecycle, + scope: { kind: 'main' }, + skills: [] + }) + ).rejects.toThrow() + + const leasesRoot = join(storageRoot, 'runtime', 'agent-skills', 'v1', 'leases') + await expect(readdir(leasesRoot).catch(() => [])).resolves.toEqual([]) + }) + + it('forks isolated projection and writable roots from an authorized catalog', async () => { + const storageRoot = await temporaryRoot('agent-skill-storage-') + const runtime = new AgentSkillRuntime() + const first = await runtime.acquire({ + storageRoot, + lifecycle: { + sessionId: 'session-1', + agentFrameId: 'frame-1', + runtimeSegmentId: 'segment-1' + }, + scope: { kind: 'main' }, + skills: [] + }) + const second = await runtime.fork(first, { + lifecycle: { + sessionId: 'session-1', + agentFrameId: 'frame-2', + runtimeSegmentId: 'segment-2' + }, + scope: { kind: 'subagent' } + }) + const directoryVariables = [ + 'TMPDIR', + 'TMP', + 'TEMP', + 'XDG_CACHE_HOME', + 'PYTHONPYCACHEPREFIX', + 'PIP_CACHE_DIR', + 'PYTHONUSERBASE', + 'NODE_COMPILE_CACHE', + 'npm_config_cache', + 'R_USER_CACHE_DIR', + 'R_USER_CONFIG_DIR', + 'R_USER_DATA_DIR', + 'R_LIBS_USER' + ] as const + const firstRoot = dirname(first.cacheRoot) + const secondRoot = dirname(second.cacheRoot) + + expect(second.projectionRoot).not.toBe(first.projectionRoot) + expect(secondRoot).not.toBe(firstRoot) + for (const name of directoryVariables) { + expect(first.env[name]?.startsWith(`${firstRoot}${sep}`), name).toBe(true) + expect(second.env[name]?.startsWith(`${secondRoot}${sep}`), name).toBe(true) + expect(second.env[name], name).not.toBe(first.env[name]) + await expect(stat(first.env[name]!)).resolves.toMatchObject({}) + await expect(stat(second.env[name]!)).resolves.toMatchObject({}) + } + + await first.release() + await expect(stat(second.env.PYTHONPYCACHEPREFIX!)).resolves.toMatchObject({}) + await second.release() + }) + + it.runIf(process.platform !== 'win32')( + 'retries release after a transient removal failure', + async () => { + const storageRoot = await temporaryRoot('agent-skill-storage-') + const lease = await new AgentSkillRuntime().acquire({ + storageRoot, + lifecycle: { + sessionId: 'session-1', + agentFrameId: 'frame-1', + runtimeSegmentId: 'segment-1' + }, + scope: { kind: 'main' }, + skills: [] + }) + const leaseRoot = dirname(lease.cacheRoot) + const leasesRoot = dirname(leaseRoot) + await chmod(leasesRoot, 0o555) + + await expect(lease.release()).rejects.toMatchObject({ code: expect.any(String) }) + await expect(stat(leaseRoot)).resolves.toMatchObject({}) + + await chmod(leasesRoot, 0o755) + await expect(lease.release()).resolves.toBeUndefined() + await expect(stat(leaseRoot)).rejects.toMatchObject({ code: 'ENOENT' }) + } + ) + + it('rejects a fork from a catalog not authorized by this runtime instance', async () => { + const runtime = new AgentSkillRuntime() + await expect( + runtime.fork( + { + catalogRevision: 'sha256:untrusted', + projectionRoot: '/tmp/untrusted', + discoveryRoot: '/tmp/untrusted/skills', + skills: [] + }, + { + lifecycle: { + sessionId: 'session-1', + agentFrameId: 'frame-1', + runtimeSegmentId: 'segment-1' + }, + scope: { kind: 'subagent' } + } + ) + ).rejects.toThrow(/unauthorized catalog/i) + }) + + it('releases the exact lease-owned projection, cache, and temporary files', async () => { + const storageRoot = await temporaryRoot('agent-skill-storage-') + const sourceRoot = await temporaryRoot('agent-skill-source-') + await writeFile(join(sourceRoot, 'SKILL.md'), '---\nname: retained-skill\n---\n') + const lease = await new AgentSkillRuntime().acquire({ + storageRoot, + lifecycle: { + sessionId: 'session-1', + agentFrameId: 'frame-1', + runtimeSegmentId: 'segment-1' + }, + scope: { kind: 'main' }, + skills: [ + { + kind: 'package', + id: 'retained-skill', + name: 'retained-skill', + description: 'Retained Skill.', + sourceDir: sourceRoot, + revision: 'sha256:retained' + } + ] + }) + const cacheFile = join(lease.cacheRoot, 'cache.txt') + await writeFile(cacheFile, 'cache') + + await lease.release() + + await expect(readFile(cacheFile, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) + await expect(readFile(lease.skills[0]!.skillDocumentPath, 'utf8')).rejects.toMatchObject({ + code: 'ENOENT' + }) + await expect(readFile(join(sourceRoot, 'SKILL.md'), 'utf8')).resolves.toContain( + 'name: retained-skill' + ) + }) + + it.runIf(process.platform !== 'win32')( + 'copies package files without linking or modifying the source package', + async () => { + const storageRoot = await temporaryRoot('agent-skill-storage-') + const sourceRoot = await temporaryRoot('agent-skill-source-') + await mkdir(join(sourceRoot, 'scripts')) + await writeFile(join(sourceRoot, 'SKILL.md'), '---\nname: copied-skill\n---\n') + const sourceScript = join(sourceRoot, 'scripts', 'run.py') + await writeFile(sourceScript, 'original') + const lease = await new AgentSkillRuntime().acquire({ + storageRoot, + lifecycle: { + sessionId: 'session-1', + agentFrameId: 'frame-1', + runtimeSegmentId: 'segment-1' + }, + scope: { kind: 'main' }, + skills: [ + { + kind: 'package', + id: 'copied-skill', + name: 'copied-skill', + description: 'Copied Skill.', + sourceDir: sourceRoot, + revision: 'sha256:copied' + } + ] + }) + + const projectedScript = join(lease.skills[0]!.packageRoot, 'scripts', 'run.py') + + expect((await stat(projectedScript)).ino).not.toBe((await stat(sourceScript)).ino) + await expect(readFile(sourceScript, 'utf8')).resolves.toBe('original') + } + ) + + it.runIf(process.platform !== 'win32')( + 'publishes a read-only projection without changing source permissions or content', + async () => { + const storageRoot = await temporaryRoot('agent-skill-storage-') + const sourceRoot = await temporaryRoot('agent-skill-source-') + await mkdir(join(sourceRoot, 'scripts')) + const sourceDocument = join(sourceRoot, 'SKILL.md') + const sourceScript = join(sourceRoot, 'scripts', 'run.py') + await writeFile(sourceDocument, '---\nname: immutable-skill\n---\n') + await writeFile(sourceScript, 'print(1)') + await chmod(sourceDocument, 0o640) + await chmod(sourceScript, 0o600) + + const lease = await new AgentSkillRuntime().acquire({ + storageRoot, + lifecycle: { + sessionId: 'session-1', + agentFrameId: 'frame-1', + runtimeSegmentId: 'segment-1' + }, + scope: { kind: 'main' }, + skills: [ + { + kind: 'package', + id: 'immutable-skill', + name: 'immutable-skill', + description: 'Immutable Skill.', + sourceDir: sourceRoot, + revision: 'sha256:immutable' + } + ] + }) + + for (const projectedPath of await listTree(lease.projectionRoot)) { + expect((await stat(projectedPath)).mode & 0o222, projectedPath).toBe(0) + } + expect((await stat(sourceDocument)).mode & 0o777).toBe(0o640) + expect((await stat(sourceScript)).mode & 0o777).toBe(0o600) + await expect(readFile(sourceDocument, 'utf8')).resolves.toBe( + '---\nname: immutable-skill\n---\n' + ) + await expect(readFile(sourceScript, 'utf8')).resolves.toBe('print(1)') + } + ) +}) diff --git a/src/main/skills/agent-skill-runtime.ts b/src/main/skills/agent-skill-runtime.ts new file mode 100644 index 000000000..08eae72e7 --- /dev/null +++ b/src/main/skills/agent-skill-runtime.ts @@ -0,0 +1,571 @@ +import { createHash, randomUUID } from 'node:crypto' +import { chmod, cp, lstat, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { dirname, isAbsolute, join, win32 } from 'node:path' + +import { prepareSkillRuntimeEnvironment } from './agent-skill-runtime-environment' + +type AgentSkillRuntimeLifecycle = Readonly<{ + sessionId: string + agentFrameId: string + runtimeSegmentId: string +}> + +type AgentSkillRuntimeScope = Readonly<{ + kind: 'main' | 'specialist' | 'subagent' +}> + +type AgentSkillRuntimeFile = Readonly<{ + path: string + content: string | Uint8Array + mode?: number +}> + +type AgentSkillRuntimeSkillBase = Readonly<{ + id: string + name: string + description: string + revision: string +}> + +type AgentSkillRuntimePackageSkill = AgentSkillRuntimeSkillBase & + Readonly<{ + kind: 'package' + sourceDir: string + overrides?: readonly AgentSkillRuntimeFile[] + }> + +type AgentSkillRuntimeGeneratedSkill = AgentSkillRuntimeSkillBase & + Readonly<{ + kind: 'generated' + files: readonly AgentSkillRuntimeFile[] + }> + +type AgentSkillRuntimeSkill = AgentSkillRuntimePackageSkill | AgentSkillRuntimeGeneratedSkill + +type AgentSkillRuntimeInput = Readonly<{ + storageRoot: string + lifecycle: AgentSkillRuntimeLifecycle + scope: AgentSkillRuntimeScope + skills: readonly AgentSkillRuntimeSkill[] +}> + +type AgentSkillRuntimeLeaseSkill = Readonly<{ + id: string + name: string + description: string + packageRoot: string + skillDocumentPath: string + packageRevision: string +}> + +type AgentSkillRuntimeCatalog = Readonly<{ + catalogRevision: string + projectionRoot: string + discoveryRoot: string + skills: readonly AgentSkillRuntimeLeaseSkill[] +}> + +type AgentSkillRuntimeLease = AgentSkillRuntimeCatalog & + Readonly<{ + cacheRoot: string + tempRoot: string + env: Readonly> + release(): Promise + }> + +type AgentSkillRuntimeForkInput = Readonly<{ + lifecycle: AgentSkillRuntimeLifecycle + scope: AgentSkillRuntimeScope +}> + +type AgentSkillRuntimeOptions = Readonly<{ + beforeAuthorizeCatalog?: (catalogRoot: string) => Promise +}> + +// Keep rebuildable Agent runtime state under the application's established runtime boundary. Data +// migration and rollback releases already treat storageRoot/runtime as non-authoritative cache/state. +const runtimeRoot = (storageRoot: string): string => + join(storageRoot, 'runtime', 'agent-skills', 'v1') + +// These registries protect live trees owned by any AgentSkillRuntime in this Electron process while +// opportunistic cleanup removes crash leftovers. The on-disk owner is only a cleanup hint, never an +// authorization source. +const activeLeaseRoots = new Set() +const activeBuildRoots = new Set() + +// Existing bundled Connector identities use underscores (for example mcp-clinical_trials), while +// user-authored Skills use hyphens. Both separators are filesystem-safe; path separators, dots, +// empty segments, and other punctuation remain rejected. +const SAFE_SKILL_NAME = /^(?=.{1,64}$)[a-z0-9]+(?:[-_][a-z0-9]+)*$/ + +const assertSafeRuntimeFilePath = (path: string, source: 'generated' | 'override'): void => { + const segments = path.split(/[\\/]/) + if ( + path.includes('\0') || + isAbsolute(path) || + win32.isAbsolute(path) || + segments.some((segment) => segment === '' || segment === '.' || segment === '..') + ) { + throw new Error(`Refusing to project an unsafe ${source} file path: ${path}`) + } +} + +const chmodProjectionTree = async (directory: string): Promise => { + const applyMode = async (path: string, targetMode: number): Promise => { + try { + await chmod(path, targetMode) + } catch (error) { + if (process.platform !== 'win32') throw error + } + } + + const entries = await readdir(directory, { withFileTypes: true }) + for (const entry of entries) { + const child = join(directory, entry.name) + if (entry.isDirectory()) await chmodProjectionTree(child) + else { + const metadata = await lstat(child) + if (!metadata.isFile() || metadata.isSymbolicLink()) { + throw new Error('Refusing to project a Skill package containing a non-regular file.') + } + await applyMode(child, (metadata.mode & 0o111) !== 0 ? 0o555 : 0o444) + } + } + await applyMode(directory, 0o555) +} + +const removeProjectionTree = async (directory: string): Promise => { + const makeDirectoriesWritable = async (path: string): Promise => { + const metadata = await lstat(path).catch(() => undefined) + if (!metadata || !metadata.isDirectory() || metadata.isSymbolicLink()) return + await chmod(path, 0o700).catch((error) => { + if (process.platform !== 'win32') throw error + }) + for (const entry of await readdir(path)) await makeDirectoriesWritable(join(path, entry)) + } + await makeDirectoriesWritable(directory) + await rm(directory, { recursive: true, force: true }) +} + +type CatalogTreeEntry = Readonly<{ + path: string + kind: 'directory' | 'file' + mode?: number + digest?: string +}> + +type RuntimeBlueprintPackageSkill = AgentSkillRuntimeSkillBase & + Readonly<{ + kind: 'package' + sourceDir: string + sourceSnapshot: readonly CatalogTreeEntry[] + overrides: readonly AgentSkillRuntimeFile[] + }> + +type RuntimeBlueprintGeneratedSkill = AgentSkillRuntimeSkillBase & + Readonly<{ + kind: 'generated' + files: readonly AgentSkillRuntimeFile[] + }> + +type RuntimeBlueprintSkill = RuntimeBlueprintPackageSkill | RuntimeBlueprintGeneratedSkill +type RuntimeBlueprintPackageSeed = Omit +type RuntimeBlueprintSkillSeed = RuntimeBlueprintPackageSeed | RuntimeBlueprintGeneratedSkill + +type RuntimeBlueprint = Readonly<{ + runtimeRoot: string + catalogRevision: string + tree: readonly CatalogTreeEntry[] + skills: readonly RuntimeBlueprintSkill[] +}> + +const catalogTreeSnapshot = async (root: string): Promise => { + const entries: CatalogTreeEntry[] = [] + const visit = async (path: string, relativePath: string): Promise => { + const metadata = await lstat(path) + if (metadata.isSymbolicLink()) { + throw new Error('Refusing to project a Skill package containing a symbolic link.') + } + const mode = process.platform === 'win32' ? undefined : metadata.mode & 0o7777 + if (metadata.isDirectory()) { + entries.push({ path: relativePath, kind: 'directory', mode }) + for (const name of (await readdir(path)).sort()) { + await visit(join(path, name), relativePath === '.' ? name : `${relativePath}/${name}`) + } + return + } + if (!metadata.isFile()) { + throw new Error('Refusing to project a Skill package containing a non-regular file.') + } + const bytes = await readFile(path) + entries.push({ + path: relativePath, + kind: 'file', + mode, + digest: createHash('sha256').update(bytes).digest('hex') + }) + } + await visit(root, '.') + return entries +} + +const sameSnapshot = ( + left: readonly CatalogTreeEntry[], + right: readonly CatalogTreeEntry[] +): boolean => JSON.stringify(left) === JSON.stringify(right) + +const writeRuntimeFiles = async ( + packageRoot: string, + files: readonly AgentSkillRuntimeFile[] +): Promise => { + for (const file of files) { + const target = join(packageRoot, file.path) + await mkdir(dirname(target), { recursive: true }) + await writeFile(target, file.content, file.mode === undefined ? {} : { mode: file.mode }) + if (file.mode !== undefined) { + await chmod(target, (file.mode & 0o111) !== 0 ? 0o755 : 0o644).catch((error) => { + if (process.platform !== 'win32') throw error + }) + } + } +} + +const catalogRevision = ( + skills: readonly (AgentSkillRuntimeSkill | RuntimeBlueprintSkill)[], + tree: readonly CatalogTreeEntry[] +): string => { + const entries = skills + .map(({ kind, id, name, description, revision }) => ({ + kind, + id, + name, + description, + revision + })) + .sort((left, right) => left.name.localeCompare(right.name) || left.id.localeCompare(right.id)) + return `sha256:${createHash('sha256').update(JSON.stringify({ entries, tree })).digest('hex')}` +} + +const writeOwner = async ( + root: string, + owner: Readonly> +): Promise => { + await writeFile( + join(root, 'owner.json'), + `${JSON.stringify({ version: 2, processId: process.pid, ...owner })}\n`, + 'utf8' + ) +} + +const staleOwnerState = async ( + root: string, + expectedKind: 'blueprint-build' | 'runtime-lease' +): Promise<'alive' | 'dead' | 'unknown'> => { + try { + const owner = JSON.parse(await readFile(join(root, 'owner.json'), 'utf8')) as { + version?: unknown + kind?: unknown + processId?: unknown + } + // Older rollback releases use a different owner shape. Preserve anything we cannot identify as + // one of our rebuildable v2 trees rather than disrupting a concurrently running older app. + if ( + owner.version !== 2 || + owner.kind !== expectedKind || + !Number.isSafeInteger(owner.processId) || + (owner.processId as number) <= 0 + ) { + return 'unknown' + } + try { + process.kill(owner.processId as number, 0) + return 'alive' + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'ESRCH' ? 'dead' : 'alive' + } + } catch { + return 'unknown' + } +} + +const cleanupCrashLeftovers = async ( + parent: string, + activeRoots: ReadonlySet, + expectedKind: 'blueprint-build' | 'runtime-lease' +): Promise => { + const entries = await readdir(parent, { withFileTypes: true }).catch(() => []) + for (const entry of entries) { + const root = join(parent, entry.name) + if (activeRoots.has(root)) continue + if (!entry.isDirectory() || (await staleOwnerState(root, expectedKind)) !== 'dead') continue + await removeProjectionTree(root).catch(() => undefined) + } +} + +const cloneRuntimeFile = (file: AgentSkillRuntimeFile): AgentSkillRuntimeFile => + Object.freeze({ + path: file.path, + content: typeof file.content === 'string' ? file.content : new Uint8Array(file.content), + mode: file.mode + }) + +const materializeBlueprint = async ( + projectionRoot: string, + skills: readonly RuntimeBlueprintSkill[] +): Promise => { + await mkdir(join(projectionRoot, '.claude-plugin'), { recursive: true }) + await mkdir(join(projectionRoot, 'skills'), { recursive: true }) + await writeFile( + join(projectionRoot, '.claude-plugin', 'plugin.json'), + `${JSON.stringify({ name: 'open-science-agent-skills' })}\n`, + 'utf8' + ) + + for (const skill of skills) { + const packageRoot = join(projectionRoot, 'skills', `os-${skill.id}`) + await mkdir(packageRoot, { recursive: true }) + if (skill.kind === 'generated') { + await writeRuntimeFiles(packageRoot, skill.files) + } else { + const sourceBeforeCopy = await catalogTreeSnapshot(skill.sourceDir) + if (!sameSnapshot(sourceBeforeCopy, skill.sourceSnapshot)) { + throw new Error( + 'Refusing to project an Agent Skill runtime after its source package changed.' + ) + } + await cp(skill.sourceDir, packageRoot, { + recursive: true, + force: true, + filter: async (path) => { + if ((await lstat(path)).isSymbolicLink()) { + throw new Error('Refusing to project a Skill package containing a symbolic link.') + } + return true + } + }) + const sourceAfterCopy = await catalogTreeSnapshot(skill.sourceDir) + if (!sameSnapshot(sourceAfterCopy, skill.sourceSnapshot)) { + throw new Error( + 'Refusing to project an Agent Skill runtime after its source package changed.' + ) + } + await writeRuntimeFiles(packageRoot, skill.overrides) + } + const skillDocument = await lstat(join(packageRoot, 'SKILL.md')).catch(() => undefined) + if (!skillDocument?.isFile() || skillDocument.isSymbolicLink()) { + throw new Error(`Refusing to project Skill "${skill.name}" without a regular SKILL.md.`) + } + } + await chmodProjectionTree(projectionRoot) +} + +class AgentSkillRuntime { + private readonly authorizedCatalogs = new WeakMap() + + constructor(private readonly options: AgentSkillRuntimeOptions = {}) {} + + async acquire(input: AgentSkillRuntimeInput): Promise { + this.validateInput(input) + const blueprint = await this.buildBlueprint(input) + return this.createLease(blueprint, { lifecycle: input.lifecycle, scope: input.scope }) + } + + async fork( + catalog: AgentSkillRuntimeCatalog, + input: AgentSkillRuntimeForkInput + ): Promise { + const blueprint = this.authorizedCatalogs.get(catalog) + if (!blueprint) { + throw new Error('Refusing to fork an Agent Skill runtime from an unauthorized catalog.') + } + return this.createLease(blueprint, input) + } + + private validateInput(input: AgentSkillRuntimeInput): void { + const ids = new Set() + const names = new Set() + for (const skill of input.skills) { + if (!SAFE_SKILL_NAME.test(skill.id)) { + throw new Error(`Refusing to project a Skill with an unsafe Skill id: ${skill.id}`) + } + if (ids.has(skill.id)) { + throw new Error(`Refusing to project a duplicate Skill id: ${skill.id}`) + } + if (!SAFE_SKILL_NAME.test(skill.name)) { + throw new Error(`Refusing to project a Skill with an unsafe Skill name: ${skill.name}`) + } + if (names.has(skill.name)) { + throw new Error(`Refusing to project a duplicate Skill name: ${skill.name}`) + } + const runtimeFiles = skill.kind === 'generated' ? skill.files : (skill.overrides ?? []) + for (const file of runtimeFiles) { + assertSafeRuntimeFilePath(file.path, skill.kind === 'generated' ? 'generated' : 'override') + } + ids.add(skill.id) + names.add(skill.name) + } + } + + private async buildBlueprint(input: AgentSkillRuntimeInput): Promise { + // Clone every caller-owned byte array before the first await. Package hashing can take long + // enough for a caller to otherwise mutate a generated file or override while acquisition is + // still in flight. + const skillSeeds: readonly RuntimeBlueprintSkillSeed[] = input.skills.map((skill) => + Object.freeze( + skill.kind === 'generated' + ? { + kind: 'generated' as const, + id: skill.id, + name: skill.name, + description: skill.description, + revision: skill.revision, + files: Object.freeze(skill.files.map(cloneRuntimeFile)) + } + : { + kind: 'package' as const, + id: skill.id, + name: skill.name, + description: skill.description, + revision: skill.revision, + sourceDir: skill.sourceDir, + overrides: Object.freeze((skill.overrides ?? []).map(cloneRuntimeFile)) + } + ) + ) + const root = runtimeRoot(input.storageRoot) + const buildsRoot = join(root, 'staging') + const buildRoot = join(buildsRoot, randomUUID()) + const projectionRoot = join(buildRoot, 'projection') + activeBuildRoots.add(buildRoot) + try { + await mkdir(buildRoot, { recursive: true }) + await writeOwner(buildRoot, { kind: 'blueprint-build' }) + await cleanupCrashLeftovers(buildsRoot, activeBuildRoots, 'blueprint-build') + const skills = await Promise.all( + skillSeeds.map(async (skill): Promise => { + if (skill.kind === 'generated') return skill + return Object.freeze({ + kind: 'package', + id: skill.id, + name: skill.name, + description: skill.description, + revision: skill.revision, + sourceDir: skill.sourceDir, + sourceSnapshot: Object.freeze(await catalogTreeSnapshot(skill.sourceDir)), + overrides: skill.overrides + }) + }) + ) + await materializeBlueprint(projectionRoot, skills) + const tree = await catalogTreeSnapshot(projectionRoot) + return Object.freeze({ + runtimeRoot: root, + catalogRevision: catalogRevision(skills, tree), + tree: Object.freeze(tree), + skills: Object.freeze(skills) + }) + } finally { + activeBuildRoots.delete(buildRoot) + await removeProjectionTree(buildRoot).catch(() => undefined) + } + } + + private async createLease( + blueprint: RuntimeBlueprint, + input: AgentSkillRuntimeForkInput + ): Promise { + const ownerId = randomUUID() + const leasesRoot = join(blueprint.runtimeRoot, 'leases') + const leaseRoot = join(leasesRoot, ownerId) + const projectionRoot = join(leaseRoot, 'projection') + activeLeaseRoots.add(leaseRoot) + try { + await mkdir(leaseRoot, { recursive: true }) + await writeOwner(leaseRoot, { + kind: 'runtime-lease', + ownerId, + lifecycle: input.lifecycle, + scope: input.scope, + catalogRevision: blueprint.catalogRevision + }) + await cleanupCrashLeftovers(leasesRoot, activeLeaseRoots, 'runtime-lease') + await materializeBlueprint(projectionRoot, blueprint.skills) + const projectedSnapshot = await catalogTreeSnapshot(projectionRoot) + if (!sameSnapshot(projectedSnapshot, blueprint.tree)) { + throw new Error('Agent Skill runtime projection differs from its authorized blueprint.') + } + + const discoveryRoot = join(projectionRoot, 'skills') + const projectedSkills = blueprint.skills.map((skill) => { + const packageRoot = join(discoveryRoot, `os-${skill.id}`) + return Object.freeze({ + id: skill.id, + name: skill.name, + description: skill.description, + packageRoot, + skillDocumentPath: join(packageRoot, 'SKILL.md'), + packageRevision: skill.revision + }) + }) + const environment = await prepareSkillRuntimeEnvironment(leaseRoot) + const catalog = Object.freeze({ + catalogRevision: blueprint.catalogRevision, + projectionRoot, + discoveryRoot, + skills: Object.freeze(projectedSkills) + }) + await this.options.beforeAuthorizeCatalog?.(projectionRoot) + const authorizedSnapshot = await catalogTreeSnapshot(projectionRoot).catch(() => { + throw new Error('Agent Skill runtime projection changed before authorization.') + }) + if (!sameSnapshot(authorizedSnapshot, blueprint.tree)) { + throw new Error('Agent Skill runtime projection changed before authorization.') + } + + let released = false + let releaseInFlight: Promise | undefined + const lease = Object.freeze({ + ...catalog, + cacheRoot: environment.env.XDG_CACHE_HOME!, + tempRoot: environment.env.TMPDIR!, + env: environment.env, + release: (): Promise => { + if (released) return Promise.resolve() + if (releaseInFlight) return releaseInFlight + releaseInFlight = removeProjectionTree(leaseRoot) + .then(() => { + activeLeaseRoots.delete(leaseRoot) + this.authorizedCatalogs.delete(lease) + released = true + }) + .finally(() => { + releaseInFlight = undefined + }) + return releaseInFlight + } + }) + this.authorizedCatalogs.set(catalog, blueprint) + this.authorizedCatalogs.set(lease, blueprint) + return lease + } catch (error) { + activeLeaseRoots.delete(leaseRoot) + await removeProjectionTree(leaseRoot).catch(() => undefined) + throw error + } + } +} + +export { AgentSkillRuntime } +export type { + AgentSkillRuntimeInput, + AgentSkillRuntimeCatalog, + AgentSkillRuntimeForkInput, + AgentSkillRuntimeLease, + AgentSkillRuntimeLeaseSkill, + AgentSkillRuntimeLifecycle, + AgentSkillRuntimeFile, + AgentSkillRuntimeGeneratedSkill, + AgentSkillRuntimePackageSkill, + AgentSkillRuntimeSkill, + AgentSkillRuntimeScope +} diff --git a/src/main/skills/user-skill-catalog-observer.test.ts b/src/main/skills/user-skill-catalog-observer.test.ts index 1e3b9bb7f..53e49848a 100644 --- a/src/main/skills/user-skill-catalog-observer.test.ts +++ b/src/main/skills/user-skill-catalog-observer.test.ts @@ -1,5 +1,5 @@ import type { FSWatcher, watch } from 'node:fs' -import { mkdir, mkdtemp, writeFile } from 'node:fs/promises' +import { chmod, mkdir, mkdtemp, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { EventEmitter } from 'node:events' @@ -101,6 +101,38 @@ describe('UserSkillCatalogObserver', () => { observer.dispose() }) + it.skipIf(process.platform === 'win32')( + 'publishes a chmod-only executable-bit change through the catalog fingerprint', + async () => { + const storageRoot = await makeStorage() + const skillDirectory = join(storageRoot, 'skills', 'personal', 'executable') + const scriptPath = join(skillDirectory, 'scripts', 'run.sh') + await mkdir(join(skillDirectory, 'scripts'), { recursive: true }) + await writeFile( + join(skillDirectory, 'SKILL.md'), + '---\nname: executable\ndescription: Executable.\n---\nRun the script.\n' + ) + await writeFile(scriptPath, '#!/bin/sh\n', { mode: 0o644 }) + const watcher = fakeWatcher() + const onCatalogChanged = vi.fn() + const observer = new UserSkillCatalogObserver({ + storageRoot, + catalog: new UserSkillRepository(storageRoot), + onCatalogChanged, + watchDirectory: watcher.watchDirectory, + debounceMs: 1, + reconcileIntervalMs: 60_000 + }) + await observer.start() + + await chmod(scriptPath, 0o755) + watcher.emitChange() + await waitForCalls(onCatalogChanged, 1) + + observer.dispose() + } + ) + it('forces one shared notification for explicit catalog mutations', async () => { const watcher = fakeWatcher() const onCatalogChanged = vi.fn() diff --git a/src/main/skills/user-skill-compatibility-index.test.ts b/src/main/skills/user-skill-compatibility-index.test.ts index d63a0e577..8d39256ad 100644 --- a/src/main/skills/user-skill-compatibility-index.test.ts +++ b/src/main/skills/user-skill-compatibility-index.test.ts @@ -1,5 +1,5 @@ import { createHash } from 'node:crypto' -import { mkdir, mkdtemp, readFile, rm, stat, utimes, writeFile } from 'node:fs/promises' +import { chmod, mkdir, mkdtemp, readFile, rm, stat, utimes, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -13,6 +13,26 @@ const hashFileContents = async (path: string): Promise => .digest('hex') describe('UserSkillCompatibilityIndex', () => { + it('writes the v2 cache without reading or modifying the legacy v1 rollback cache', async () => { + const storageRoot = await mkdtemp(join(tmpdir(), 'user-skill-index-rollback-')) + const sourceDir = join(storageRoot, 'skills', 'personal', 'rollback-safe') + const runtimeSupport = join(storageRoot, 'runtime-support') + const legacyPath = join(runtimeSupport, 'user-skill-compatibility-v1.json') + const currentPath = join(runtimeSupport, 'user-skill-compatibility-v2.json') + const legacyContents = '{"version":1,"rollback":"preserve-exactly"}' + await mkdir(sourceDir, { recursive: true }) + await mkdir(runtimeSupport, { recursive: true }) + await writeFile(join(sourceDir, 'SKILL.md'), '---\nname: rollback-safe\n---\nBody\n') + await writeFile(legacyPath, legacyContents) + + await new UserSkillCompatibilityIndex(storageRoot).scan([sourceDir]) + + await expect(readFile(legacyPath, 'utf8')).resolves.toBe(legacyContents) + await expect(readFile(currentPath, 'utf8').then(JSON.parse)).resolves.toMatchObject({ + version: 2 + }) + }) + it('reuses persisted file hashes when an unchanged package is scanned after restart', async () => { const storageRoot = await mkdtemp(join(tmpdir(), 'user-skill-index-')) const sourceDir = join(storageRoot, 'skills', 'personal', 'large-skill') @@ -32,7 +52,7 @@ describe('UserSkillCompatibilityIndex', () => { expect('compatibility' in first).toBe(true) expect(firstHash).toHaveBeenCalledTimes(2) const persisted = await readFile( - join(storageRoot, 'runtime-support', 'user-skill-compatibility-v1.json'), + join(storageRoot, 'runtime-support', 'user-skill-compatibility-v2.json'), 'utf8' ) expect(persisted).toContain('personal/large-skill') @@ -52,7 +72,7 @@ describe('UserSkillCompatibilityIndex', () => { const sourceDir = join(storageRoot, 'skills', 'imported', 'recoverable') await mkdir(sourceDir, { recursive: true }) await writeFile(join(sourceDir, 'SKILL.md'), '---\nname: recoverable\n---\nBody\n') - const cachePath = join(storageRoot, 'runtime-support', 'user-skill-compatibility-v1.json') + const cachePath = join(storageRoot, 'runtime-support', 'user-skill-compatibility-v2.json') await mkdir(join(storageRoot, 'runtime-support'), { recursive: true }) await writeFile(cachePath, '{"version":1,"packages":null}') @@ -69,7 +89,7 @@ describe('UserSkillCompatibilityIndex', () => { it('rehashes a cached file whose persisted SHA-256 is malformed', async () => { const storageRoot = await mkdtemp(join(tmpdir(), 'user-skill-index-hash-corrupt-')) const sourceDir = join(storageRoot, 'skills', 'personal', 'corrupt-hash') - const cachePath = join(storageRoot, 'runtime-support', 'user-skill-compatibility-v1.json') + const cachePath = join(storageRoot, 'runtime-support', 'user-skill-compatibility-v2.json') await mkdir(sourceDir, { recursive: true }) await writeFile(join(sourceDir, 'SKILL.md'), '---\nname: corrupt-hash\n---\nBody\n') await new UserSkillCompatibilityIndex(storageRoot, { hashFile: hashFileContents }).scan([ @@ -113,6 +133,30 @@ describe('UserSkillCompatibilityIndex', () => { expect(after).not.toEqual(before) }) + it.skipIf(process.platform === 'win32')( + 'changes compatibility for a chmod-only executable-bit update without rehashing content', + async () => { + const storageRoot = await mkdtemp(join(tmpdir(), 'user-skill-index-mode-')) + const sourceDir = join(storageRoot, 'skills', 'personal', 'mode-skill') + const scriptPath = join(sourceDir, 'scripts', 'run.sh') + await mkdir(join(sourceDir, 'scripts'), { recursive: true }) + await writeFile(join(sourceDir, 'SKILL.md'), '---\nname: mode-skill\n---\nBody\n') + await writeFile(scriptPath, '#!/bin/sh\n', { mode: 0o644 }) + const index = new UserSkillCompatibilityIndex(storageRoot, { hashFile: hashFileContents }) + const [before] = await index.scan([sourceDir]) + + await chmod(scriptPath, 0o755) + const hashFile = vi.fn(hashFileContents) + const [after] = await new UserSkillCompatibilityIndex(storageRoot, { hashFile }).scan([ + sourceDir + ]) + + expect(after).not.toEqual(before) + expect(hashFile).toHaveBeenCalledOnce() + expect(hashFile).toHaveBeenCalledWith(scriptPath) + } + ) + it('keeps the loaded compatibility cache in memory across repeated catalog reads', async () => { const storageRoot = await mkdtemp(join(tmpdir(), 'user-skill-index-memory-')) const sourceDir = join(storageRoot, 'skills', 'personal', 'repeated') @@ -123,7 +167,7 @@ describe('UserSkillCompatibilityIndex', () => { const index = new UserSkillCompatibilityIndex(storageRoot, { hashFile }) await index.scan([sourceDir]) await writeFile( - join(storageRoot, 'runtime-support', 'user-skill-compatibility-v1.json'), + join(storageRoot, 'runtime-support', 'user-skill-compatibility-v2.json'), 'corrupt after load' ) @@ -181,7 +225,7 @@ describe('UserSkillCompatibilityIndex', () => { expect(result).toMatchObject({ sourceDir, - compatibility: expect.stringMatching(/^sha256-tree-v2:[a-f0-9]{64}$/) + compatibility: expect.stringMatching(/^sha256-tree-v3:[a-f0-9]{64}$/) }) }) diff --git a/src/main/skills/user-skill-compatibility-index.ts b/src/main/skills/user-skill-compatibility-index.ts index 19f5a38d8..32ecd697d 100644 --- a/src/main/skills/user-skill-compatibility-index.ts +++ b/src/main/skills/user-skill-compatibility-index.ts @@ -6,8 +6,8 @@ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' import { createLogger, diagnosticErrorFields } from '../logger' const log = createLogger('skills') -const CACHE_VERSION = 1 -const COMPATIBILITY_VERSION = 'sha256-tree-v2' +const CACHE_VERSION = 2 +const COMPATIBILITY_VERSION = 'sha256-tree-v3' const SHA256_HEX = /^[a-f0-9]{64}$/ const NON_NEGATIVE_INTEGER = /^\d+$/ const INTEGER = /^-?\d+$/ @@ -16,6 +16,7 @@ type FileMetadata = { size: string mtimeNs: string ctimeNs: string + executable: boolean } type CachedFile = FileMetadata & { @@ -55,7 +56,10 @@ const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value) const sameMetadata = (left: FileMetadata | undefined, right: FileMetadata): boolean => - left?.size === right.size && left.mtimeNs === right.mtimeNs && left.ctimeNs === right.ctimeNs + left?.size === right.size && + left.mtimeNs === right.mtimeNs && + left.ctimeNs === right.ctimeNs && + left.executable === right.executable const streamFileHash: HashFile = async (path) => { const hash = createHash('sha256') @@ -74,7 +78,7 @@ class UserSkillCompatibilityIndex { constructor(storageRoot: string, options: UserSkillCompatibilityIndexOptions = {}) { this.skillsRoot = resolve(storageRoot, 'skills') - this.cachePath = join(storageRoot, 'runtime-support', 'user-skill-compatibility-v1.json') + this.cachePath = join(storageRoot, 'runtime-support', 'user-skill-compatibility-v2.json') this.hashFile = options.hashFile ?? streamFileHash } @@ -173,6 +177,8 @@ class UserSkillCompatibilityIndex { compatibilityHash.update('\0') compatibilityHash.update(file.sha256) compatibilityHash.update('\0') + compatibilityHash.update(file.executable ? 'executable' : 'regular') + compatibilityHash.update('\0') } return { package: { files }, @@ -187,7 +193,8 @@ class UserSkillCompatibilityIndex { ? { size: metadata.size.toString(), mtimeNs: metadata.mtimeNs.toString(), - ctimeNs: metadata.ctimeNs.toString() + ctimeNs: metadata.ctimeNs.toString(), + executable: process.platform !== 'win32' && (metadata.mode & 0o111n) !== 0n } : undefined } @@ -211,6 +218,7 @@ class UserSkillCompatibilityIndex { INTEGER.test(fileValue.mtimeNs) && typeof fileValue.ctimeNs === 'string' && INTEGER.test(fileValue.ctimeNs) && + typeof fileValue.executable === 'boolean' && typeof fileValue.sha256 === 'string' && SHA256_HEX.test(fileValue.sha256) ) { @@ -218,6 +226,7 @@ class UserSkillCompatibilityIndex { size: fileValue.size, mtimeNs: fileValue.mtimeNs, ctimeNs: fileValue.ctimeNs, + executable: fileValue.executable, sha256: fileValue.sha256 } } diff --git a/src/main/skills/user-skill-repository.test.ts b/src/main/skills/user-skill-repository.test.ts index 49b396264..ccfff1d58 100644 --- a/src/main/skills/user-skill-repository.test.ts +++ b/src/main/skills/user-skill-repository.test.ts @@ -421,7 +421,7 @@ describe('UserSkillRepository', () => { name: 'foo', source: 'imported', license: 'MIT', - compatibility: expect.stringMatching(/^sha256-tree-v2:/) + compatibility: expect.stringMatching(/^sha256-tree-v3:/) }) })