From 8f8573a384a08981841b1ed81e48f98f512836ca Mon Sep 17 00:00:00 2001 From: fettpl <38704082+fettpl@users.noreply.github.com> Date: Thu, 3 Sep 2026 07:38:34 +0200 Subject: [PATCH 1/6] fix(runtime): deny paired clients host input, skill install, and unbounded dir browse Paired mobile and runtime sockets could drive computer-use, install skills, persist agentDefaultEnv, and readdir any absolute path through the shared RPC dispatcher. Gate computer.* and skill install/remove/commit to local unix-socket callers, strip launch env/args from paired settings.update, and limit files.browseServerDir to the home directory. Fixes #18269 --- .../repository-project-operations.spec.ts | 12 +++- .../runtime/rpc/methods/client-ui.test.ts | 44 ++++++++++++ src/main/runtime/rpc/methods/client-ui.ts | 11 ++- src/main/runtime/rpc/methods/computer.test.ts | 63 ++++++++++++++++ src/main/runtime/rpc/methods/computer.ts | 55 +++++++++----- src/main/runtime/rpc/methods/skills.test.ts | 64 +++++++++++++++++ src/main/runtime/rpc/methods/skills.ts | 37 ++++++---- ...untime-server-environment-commands.test.ts | 71 +++++++++++++++++++ .../runtime-server-environment-commands.ts | 11 ++- 9 files changed, 335 insertions(+), 33 deletions(-) create mode 100644 src/main/runtime/runtime-server-environment-commands.test.ts diff --git a/src/main/runtime/orca-runtime-tests/repository-project-operations.spec.ts b/src/main/runtime/orca-runtime-tests/repository-project-operations.spec.ts index c9879bf3d7c..384b75e8633 100644 --- a/src/main/runtime/orca-runtime-tests/repository-project-operations.spec.ts +++ b/src/main/runtime/orca-runtime-tests/repository-project-operations.spec.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { homedir } from 'node:os' import { DEFAULT_REPO_BADGE_COLOR, EventEmitter, @@ -49,7 +50,7 @@ describe('OrcaRuntimeService', () => { }) it('browses runtime server directories before projects are added', async () => { - const tempRoot = await mkdtemp(join(tmpdir(), 'orca-runtime-browse-')) + const tempRoot = await mkdtemp(join(homedir(), 'orca-runtime-browse-')) try { await mkdir(join(tempRoot, 'zeta')) await mkdir(join(tempRoot, 'alpha')) @@ -70,6 +71,15 @@ describe('OrcaRuntimeService', () => { } }) + it('refuses to browse a path outside the home directory', async () => { + const runtime = new OrcaRuntimeService(store) + const outsideHome = process.platform === 'win32' ? 'C:\\Windows' : '/etc' + + await expect(runtime.browseServerDir(outsideHome)).rejects.toThrow( + 'Directory browsing is limited to the home directory.' + ) + }) + it.runIf(process.platform === 'win32')('lists drive roots for a server-root browse', async () => { const runtime = new OrcaRuntimeService(store) diff --git a/src/main/runtime/rpc/methods/client-ui.test.ts b/src/main/runtime/rpc/methods/client-ui.test.ts index 3a26b1c615d..8979f4c3800 100644 --- a/src/main/runtime/rpc/methods/client-ui.test.ts +++ b/src/main/runtime/rpc/methods/client-ui.test.ts @@ -362,6 +362,50 @@ describe('client UI RPC methods', () => { expect((update as { prBotAuthorOverrides: string[] }).prBotAuthorOverrides).toHaveLength(500) }) + it('lets the local unix socket persist agent launch env and args', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + updateClientSettings: vi.fn(() => ({})) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: CLIENT_UI_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('settings.update', { + agentDefaultEnv: { claude: { PATH: '/evil' } }, + agentDefaultArgs: { claude: '--debug' } + }) + ) + + expect(runtime.updateClientSettings).toHaveBeenCalledWith({ + agentDefaultEnv: { claude: { PATH: '/evil' } }, + agentDefaultArgs: { claude: '--debug' } + }) + expect(response).toMatchObject({ ok: true }) + }) + + it('strips agentDefaultEnv and agentDefaultArgs from paired settings.update', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + updateClientSettings: vi.fn(() => ({})) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: CLIENT_UI_METHODS }) + + for (const clientKind of ['mobile', 'runtime'] as const) { + vi.mocked(runtime.updateClientSettings).mockClear() + const response = await dispatcher.dispatch( + makeRequest('settings.update', { + agentDefaultEnv: { claude: { PATH: '/evil' } }, + agentDefaultArgs: { claude: '--debug' }, + compactWorktreeCards: true + }), + { clientKind } + ) + + expect(runtime.updateClientSettings).toHaveBeenCalledWith({ compactWorktreeCards: true }) + expect(response).toMatchObject({ ok: true }) + } + }) + it('routes bot-author deltas to the runtime-owned atomic update', async () => { const settings = { prBotAuthorOverrides: ['alice', 'bob'] } const runtime = { diff --git a/src/main/runtime/rpc/methods/client-ui.ts b/src/main/runtime/rpc/methods/client-ui.ts index 6ed36a6fe83..4556e81a617 100644 --- a/src/main/runtime/rpc/methods/client-ui.ts +++ b/src/main/runtime/rpc/methods/client-ui.ts @@ -21,9 +21,14 @@ export const CLIENT_UI_METHODS = [ defineMethod({ name: 'settings.update', params: SettingsUpdate, - handler: async (params, { runtime }) => ({ - settings: await runtime.updateClientSettings(params) - }) + handler: async (params, { runtime, clientKind }) => { + const updates = { ...params } + if (clientKind !== undefined) { + delete updates.agentDefaultEnv + delete updates.agentDefaultArgs + } + return { settings: await runtime.updateClientSettings(updates) } + } }), defineMethod({ name: 'settings.getTerminalQuickCommands', diff --git a/src/main/runtime/rpc/methods/computer.test.ts b/src/main/runtime/rpc/methods/computer.test.ts index 073a1c0a363..e4fd639587f 100644 --- a/src/main/runtime/rpc/methods/computer.test.ts +++ b/src/main/runtime/rpc/methods/computer.test.ts @@ -29,6 +29,27 @@ vi.mock('../../../computer/macos-computer-use-permissions', () => ({ import { COMPUTER_METHODS, resetComputerSessionsForTest } from './computer' +const PAIRED_COMPUTER_METHOD_CASES = [ + { name: 'computer.capabilities', params: {} }, + { name: 'computer.listApps', params: {} }, + { name: 'computer.permissions', params: { id: 'accessibility' } }, + { name: 'computer.permissionsStatus', params: {} }, + { name: 'computer.listWindows', params: { app: 'Finder' } }, + { name: 'computer.getAppState', params: { app: 'Finder' } }, + { name: 'computer.click', params: { app: 'Finder', elementIndex: 0 } }, + { + name: 'computer.performSecondaryAction', + params: { app: 'Finder', elementIndex: 0, action: 'Raise' } + }, + { name: 'computer.scroll', params: { app: 'Finder', elementIndex: 0, direction: 'down' } }, + { name: 'computer.drag', params: { app: 'Finder', fromX: 1, fromY: 2, toX: 3, toY: 4 } }, + { name: 'computer.typeText', params: { app: 'Finder', text: 'hello' } }, + { name: 'computer.pressKey', params: { app: 'Finder', key: 'Return' } }, + { name: 'computer.hotkey', params: { app: 'Finder', key: 'CmdOrCtrl+L' } }, + { name: 'computer.pasteText', params: { app: 'Finder', text: 'hello' } }, + { name: 'computer.setValue', params: { app: 'Finder', elementIndex: 0, value: 'x' } } +] as const + describe('computer RPC methods', () => { beforeEach(() => { computerMocks.callComputerSidecarAction.mockReset() @@ -246,6 +267,48 @@ describe('computer RPC methods', () => { findMethod('computer.pasteText').params!.safeParse({ app: 'Finder', text }).success ).toBe(true) }) + + it.each(PAIRED_COMPUTER_METHOD_CASES)( + 'rejects paired-device calls to $name', + async ({ name, params }) => { + const method = findMethod(name) + const parsed = method.params ? method.params.parse(params) : undefined + const runtime = { getRuntimeId: () => 'runtime-1' } as never + + for (const clientKind of ['mobile', 'runtime'] as const) { + await expect(method.handler(parsed, { runtime, clientKind })).rejects.toThrow( + /only available on the Orca host runtime/ + ) + } + + expect(computerMocks.callComputerSidecarAction).not.toHaveBeenCalled() + expect(computerMocks.callComputerSidecarCapabilities).not.toHaveBeenCalled() + expect(computerMocks.callComputerSidecarListApps).not.toHaveBeenCalled() + expect(computerMocks.callComputerSidecarListWindows).not.toHaveBeenCalled() + expect(computerMocks.callComputerSidecarSnapshot).not.toHaveBeenCalled() + expect(computerMocks.openComputerUsePermissions).not.toHaveBeenCalled() + expect(computerMocks.getComputerUsePermissionStatus).not.toHaveBeenCalled() + } + ) + + it('allows local-socket computer.click and computer.typeText', async () => { + computerMocks.callComputerSidecarAction.mockResolvedValue({ ok: true }) + + await expect(call('computer.click', { app: 'Finder', elementIndex: 0 })).resolves.toEqual({ + ok: true + }) + await expect(call('computer.typeText', { app: 'Finder', text: 'hello' })).resolves.toEqual({ + ok: true + }) + expect(computerMocks.callComputerSidecarAction).toHaveBeenNthCalledWith(1, 'click', { + app: 'Finder', + elementIndex: 0 + }) + expect(computerMocks.callComputerSidecarAction).toHaveBeenNthCalledWith(2, 'typeText', { + app: 'Finder', + text: 'hello' + }) + }) }) function findMethod(name: string) { diff --git a/src/main/runtime/rpc/methods/computer.ts b/src/main/runtime/rpc/methods/computer.ts index e2708667633..a45656c7305 100644 --- a/src/main/runtime/rpc/methods/computer.ts +++ b/src/main/runtime/rpc/methods/computer.ts @@ -6,7 +6,7 @@ import { callComputerSidecarSnapshot, resetComputerSidecarForTest } from '../../../computer/sidecar-client' -import { defineMethod } from '../core' +import { defineMethod, type RpcContext } from '../core' import { Click, ComputerObserveTarget, @@ -31,25 +31,36 @@ export function resetComputerSessionsForTest(): void { resetComputerSidecarForTest() } +const COMPUTER_HOST_ONLY_MESSAGE = 'Computer use is only available on the Orca host runtime.' + +function assertHostOnlyClient(clientKind: RpcContext['clientKind'], message: string): void { + if (clientKind !== undefined) { + throw new Error(message) + } +} + export const COMPUTER_METHODS = [ defineMethod({ name: 'computer.capabilities', params: ComputerCapabilitiesParams, - handler: async () => { + handler: async (_params, { clientKind }) => { + assertHostOnlyClient(clientKind, COMPUTER_HOST_ONLY_MESSAGE) return await callComputerSidecarCapabilities() } }), defineMethod({ name: 'computer.listApps', params: ListApps, - handler: async () => { + handler: async (_params, { clientKind }) => { + assertHostOnlyClient(clientKind, COMPUTER_HOST_ONLY_MESSAGE) return await callComputerSidecarListApps() } }), defineMethod({ name: 'computer.permissions', params: ComputerPermissions, - handler: async (params) => { + handler: async (params, { clientKind }) => { + assertHostOnlyClient(clientKind, COMPUTER_HOST_ONLY_MESSAGE) const { openComputerUsePermissions } = await import('../../../computer/macos-computer-use-permissions') return openComputerUsePermissions(params.id) @@ -58,7 +69,8 @@ export const COMPUTER_METHODS = [ defineMethod({ name: 'computer.permissionsStatus', params: ComputerPermissionsStatusParams, - handler: async () => { + handler: async (_params, { clientKind }) => { + assertHostOnlyClient(clientKind, COMPUTER_HOST_ONLY_MESSAGE) const { getComputerUsePermissionStatus } = await import('../../../computer/macos-computer-use-permissions') return getComputerUsePermissionStatus() @@ -67,77 +79,88 @@ export const COMPUTER_METHODS = [ defineMethod({ name: 'computer.listWindows', params: ListWindows, - handler: async (params) => { + handler: async (params, { clientKind }) => { + assertHostOnlyClient(clientKind, COMPUTER_HOST_ONLY_MESSAGE) return await callComputerSidecarListWindows(params) } }), defineMethod({ name: 'computer.getAppState', params: ComputerObserveTarget, - handler: async (params) => { + handler: async (params, { clientKind }) => { + assertHostOnlyClient(clientKind, COMPUTER_HOST_ONLY_MESSAGE) return await callComputerSidecarSnapshot(params) } }), defineMethod({ name: 'computer.click', params: Click, - handler: async (params) => { + handler: async (params, { clientKind }) => { + assertHostOnlyClient(clientKind, COMPUTER_HOST_ONLY_MESSAGE) return await callComputerSidecarAction('click', params) } }), defineMethod({ name: 'computer.performSecondaryAction', params: PerformSecondaryAction, - handler: async (params) => { + handler: async (params, { clientKind }) => { + assertHostOnlyClient(clientKind, COMPUTER_HOST_ONLY_MESSAGE) return await callComputerSidecarAction('performSecondaryAction', params) } }), defineMethod({ name: 'computer.scroll', params: Scroll, - handler: async (params) => { + handler: async (params, { clientKind }) => { + assertHostOnlyClient(clientKind, COMPUTER_HOST_ONLY_MESSAGE) return await callComputerSidecarAction('scroll', params) } }), defineMethod({ name: 'computer.drag', params: Drag, - handler: async (params) => { + handler: async (params, { clientKind }) => { + assertHostOnlyClient(clientKind, COMPUTER_HOST_ONLY_MESSAGE) return await callComputerSidecarAction('drag', params) } }), defineMethod({ name: 'computer.typeText', params: TypeText, - handler: async (params) => { + handler: async (params, { clientKind }) => { + assertHostOnlyClient(clientKind, COMPUTER_HOST_ONLY_MESSAGE) return await callComputerSidecarAction('typeText', params) } }), defineMethod({ name: 'computer.pressKey', params: PressKey, - handler: async (params) => { + handler: async (params, { clientKind }) => { + assertHostOnlyClient(clientKind, COMPUTER_HOST_ONLY_MESSAGE) return await callComputerSidecarAction('pressKey', params) } }), defineMethod({ name: 'computer.hotkey', params: Hotkey, - handler: async (params) => { + handler: async (params, { clientKind }) => { + assertHostOnlyClient(clientKind, COMPUTER_HOST_ONLY_MESSAGE) return await callComputerSidecarAction('hotkey', params) } }), defineMethod({ name: 'computer.pasteText', params: PasteText, - handler: async (params) => { + handler: async (params, { clientKind }) => { + assertHostOnlyClient(clientKind, COMPUTER_HOST_ONLY_MESSAGE) return await callComputerSidecarAction('pasteText', params) } }), defineMethod({ name: 'computer.setValue', params: SetValue, - handler: async (params) => { + handler: async (params, { clientKind }) => { + assertHostOnlyClient(clientKind, COMPUTER_HOST_ONLY_MESSAGE) return await callComputerSidecarAction('setValue', params) } }) diff --git a/src/main/runtime/rpc/methods/skills.test.ts b/src/main/runtime/rpc/methods/skills.test.ts index 097a948febf..d83717497f6 100644 --- a/src/main/runtime/rpc/methods/skills.test.ts +++ b/src/main/runtime/rpc/methods/skills.test.ts @@ -339,6 +339,42 @@ describe('skills.share RPC', () => { }) }) +const SKILL_INSTALL_REQUEST = { + operationId: 'operation_1', + package: { + packageId: 'package_1', + versionId: 'version_1', + packageDigest: 'a'.repeat(64), + archiveSha256: 'b'.repeat(64), + compressedBytes: 100 + }, + ingress: { + kind: 'download-grant' as const, + url: 'https://storage.googleapis.com/package', + expiresAt: '2026-08-11T12:00:00.000Z' + }, + destination: { scope: 'global' as const } +} + +const SKILL_BUNDLE_INSTALL_REQUEST = { + operationId: 'operation_1', + package: { + packageId: 'package_1', + versionId: 'version_1', + bundleDigest: 'a'.repeat(64), + archiveSha256: 'b'.repeat(64), + compressedBytes: 100 + }, + selectedSkillIds: ['alpha'], + ingress: { + kind: 'download-grant' as const, + url: 'https://storage.googleapis.com/package', + expiresAt: '2026-08-11T12:00:00.000Z' + }, + destination: { scope: 'global' as const }, + conflictDecisions: [] +} + describe('skill management RPC', () => { it('delegates preview and removal to the executing runtime', async () => { const previewSharedSkillInstallRequest = vi.fn(async () => ({ currentState: 'missing' })) @@ -365,4 +401,32 @@ describe('skill management RPC', () => { expect(previewSharedSkillInstallRequest).toHaveBeenCalledOnce() expect(removeSharedSkillInstallRequest).toHaveBeenCalledOnce() }) + + it.each([ + ['skills.install', SKILL_INSTALL_REQUEST, 'installSharedSkillRequest'], + ['skills.installBundle', SKILL_BUNDLE_INSTALL_REQUEST, 'installSharedSkillBundleRequest'], + [ + 'skills.removeInstall', + { operationId: 'operation_1', name: 'example', destination: { scope: 'global' as const } }, + 'removeSharedSkillInstallRequest' + ], + ['skills.commitUpload', { uploadId: 'upload_1' }, 'commitSkillUpload'] + ] as const)( + 'rejects paired callers to %s with the unsupported-environment code', + async (methodName, params, runtimeMethod) => { + const runtimeFn = vi.fn() + const runtime = { [runtimeMethod]: runtimeFn } + + for (const clientKind of ['mobile', 'runtime'] as const) { + await expect( + (async () => + method(methodName).handler(params, { + runtime, + clientKind + } as unknown as RpcContext))() + ).rejects.toMatchObject({ code: 'agent_skill_sharing_unsupported_environment' }) + } + expect(runtimeFn).not.toHaveBeenCalled() + } + ) }) diff --git a/src/main/runtime/rpc/methods/skills.ts b/src/main/runtime/rpc/methods/skills.ts index 39a3a73eb7c..7c296c2715b 100644 --- a/src/main/runtime/rpc/methods/skills.ts +++ b/src/main/runtime/rpc/methods/skills.ts @@ -1,4 +1,4 @@ -import { defineMethod } from '../core' +import { defineMethod, type RpcContext } from '../core' import type { z } from 'zod' import { getAppEnvironment } from '../../../../shared/app-environment' import { SkillDeleteRequestSchema } from '../../../../shared/skill-delete-contract' @@ -54,6 +54,15 @@ export function resolveDiscoveryTarget( return resolveSkillDiscoveryTarget(target) } +function rejectPairedSkillMutation(clientKind: RpcContext['clientKind'], action: string): void { + if (clientKind !== undefined) { + throw new AgentSkillSharingError( + AGENT_SKILL_SHARING_UNSUPPORTED_ENVIRONMENT_CODE, + `${action} through a paired client is not supported. Run the command from Orca on the machine that stores the skills.` + ) + } +} + function skillDeleteDependencies( runtime: Pick ): SkillDeleteRequestDependencies { @@ -104,12 +113,7 @@ export const SKILL_METHODS = [ params: AgentSkillShareRequestSchema, handler: async (params, { runtime, signal, clientKind }) => { runtime.assertAgentSkillSharingAllowed() - if (clientKind !== undefined) { - throw new AgentSkillSharingError( - AGENT_SKILL_SHARING_UNSUPPORTED_ENVIRONMENT_CODE, - 'Publishing skills through a paired client is not supported. Run the command from Orca on the machine that stores the skills.' - ) - } + rejectPairedSkillMutation(clientKind, 'Publishing skills') const resolvedTarget = resolveDiscoveryTarget(params.target ?? {}, runtime) if (resolvedTarget.kind !== 'native-host') { throw new AgentSkillSharingError( @@ -127,7 +131,8 @@ export const SKILL_METHODS = [ defineMethod({ name: 'skills.install', params: SkillInstallRequestSchema, - handler: async (params, { runtime, signal, clientCapabilities }) => { + handler: async (params, { runtime, signal, clientCapabilities, clientKind }) => { + rejectPairedSkillMutation(clientKind, 'Installing skills') const result = await runtime.installSharedSkillRequest(params, signal) if ( result.status === 'cancelled' && @@ -146,8 +151,10 @@ export const SKILL_METHODS = [ defineMethod({ name: 'skills.installBundle', params: SkillBundleInstallRequestSchema, - handler: (params, { runtime, signal }) => - runtime.installSharedSkillBundleRequest(params, signal) + handler: (params, { runtime, signal, clientKind }) => { + rejectPairedSkillMutation(clientKind, 'Installing skills') + return runtime.installSharedSkillBundleRequest(params, signal) + } }), defineMethod({ name: 'skills.cancelInstall', @@ -172,7 +179,10 @@ export const SKILL_METHODS = [ defineMethod({ name: 'skills.removeInstall', params: SkillRemoveRequestSchema, - handler: (params, { runtime }) => runtime.removeSharedSkillInstallRequest(params) + handler: (params, { runtime, clientKind }) => { + rejectPairedSkillMutation(clientKind, 'Removing installed skills') + return runtime.removeSharedSkillInstallRequest(params) + } }), defineMethod({ name: 'skills.listManagedInstalls', @@ -192,7 +202,10 @@ export const SKILL_METHODS = [ defineMethod({ name: 'skills.commitUpload', params: SkillUploadCommitRequestSchema, - handler: (params, { runtime }) => runtime.commitSkillUpload(params.uploadId) + handler: (params, { runtime, clientKind }) => { + rejectPairedSkillMutation(clientKind, 'Uploading skills') + return runtime.commitSkillUpload(params.uploadId) + } }), defineMethod({ name: 'skills.cancelUpload', diff --git a/src/main/runtime/runtime-server-environment-commands.test.ts b/src/main/runtime/runtime-server-environment-commands.test.ts new file mode 100644 index 00000000000..8595df410c5 --- /dev/null +++ b/src/main/runtime/runtime-server-environment-commands.test.ts @@ -0,0 +1,71 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { homedir, tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { isPathInsideOrEqual } from '../../shared/cross-platform-path' +import { RuntimeServerEnvironmentCommands } from './runtime-server-environment-commands' + +describe('RuntimeServerEnvironmentCommands.browseDirectory', () => { + const commands = new RuntimeServerEnvironmentCommands() + + it('lists the home directory', async () => { + const result = await commands.browseDirectory('~') + expect(result.resolvedPath).toBe(resolve(homedir())) + expect(result.pathFlavor).toBe(process.platform === 'win32' ? 'win32' : 'posix') + expect(Array.isArray(result.entries)).toBe(true) + }) + + it('lists a descendant of the home directory', async () => { + const tempRoot = await mkdtemp(join(homedir(), 'orca-runtime-browse-')) + try { + await mkdir(join(tempRoot, 'zeta')) + await mkdir(join(tempRoot, 'alpha')) + await writeFile(join(tempRoot, 'readme.md'), '# Readme\n') + + const result = await commands.browseDirectory(tempRoot) + + expect(result.resolvedPath).toBe(resolve(tempRoot)) + expect(result.entries).toEqual([ + { name: 'alpha', isDirectory: true, isSymlink: false }, + { name: 'zeta', isDirectory: true, isSymlink: false }, + { name: 'readme.md', isDirectory: false, isSymlink: false } + ]) + } finally { + await rm(tempRoot, { recursive: true, force: true }) + } + }) + + it('rejects a null byte before resolving', async () => { + await expect(commands.browseDirectory('/tmp\0/etc')).rejects.toThrow( + 'Path cannot contain null bytes' + ) + }) + + it('does not readdir an arbitrary absolute path outside home', async () => { + const outsideHome = process.platform === 'win32' ? 'C:\\Windows' : '/etc' + await expect(commands.browseDirectory(outsideHome)).rejects.toThrow( + 'Directory browsing is limited to the home directory.' + ) + }) + + it('does not readdir a sibling of the home directory', async () => { + const sibling = resolve(homedir(), '..', 'orca-rpc-gate-deny') + await expect(commands.browseDirectory(sibling)).rejects.toThrow( + 'Directory browsing is limited to the home directory.' + ) + }) + + it('does not treat a temp dir outside home as allowed', async () => { + const tempRoot = await mkdtemp(join(tmpdir(), 'orca-runtime-browse-outside-')) + try { + if (isPathInsideOrEqual(homedir(), tempRoot)) { + return + } + await expect(commands.browseDirectory(tempRoot)).rejects.toThrow( + 'Directory browsing is limited to the home directory.' + ) + } finally { + await rm(tempRoot, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/runtime/runtime-server-environment-commands.ts b/src/main/runtime/runtime-server-environment-commands.ts index 7671481b1e5..2091e718e4f 100644 --- a/src/main/runtime/runtime-server-environment-commands.ts +++ b/src/main/runtime/runtime-server-environment-commands.ts @@ -1,6 +1,7 @@ import { readdir, stat } from 'node:fs/promises' import { homedir } from 'node:os' import { isAbsolute, resolve } from 'node:path' +import { isPathInsideOrEqual } from '../../shared/cross-platform-path' import type { DirEntry, FilesystemPathFlavor } from '../../shared/filesystem-entry-types' import { sortDirEntries } from '../../shared/file-name-sort' import { probeGitAvailability } from '../git/git-availability' @@ -13,7 +14,7 @@ function resolveServerBrowsePath(pathValue: string): string { throw new Error('Path cannot contain null bytes') } if (trimmed === '~') { - return homedir() + return resolve(homedir()) } if (/^~[\\/]/.test(trimmed)) { return resolve(homedir(), trimmed.slice(2)) @@ -24,6 +25,13 @@ function resolveServerBrowsePath(pathValue: string): string { return resolve(homedir(), trimmed) } +function assertAllowedServerBrowsePath(dirPath: string): void { + if (isPathInsideOrEqual(resolve(homedir()), dirPath)) { + return + } + throw new Error('Directory browsing is limited to the home directory.') +} + export class RuntimeServerEnvironmentCommands { async browseDirectory(pathValue: string): Promise<{ resolvedPath: string @@ -34,6 +42,7 @@ export class RuntimeServerEnvironmentCommands { return listWindowsDrives() } const dirPath = resolveServerBrowsePath(pathValue) + assertAllowedServerBrowsePath(dirPath) const dirStat = await stat(dirPath) if (!dirStat.isDirectory()) { throw new Error(`${dirPath} is not a directory`) From 6bf19ed1dfbd40179b8992f811e9a5cc679c7770 Mon Sep 17 00:00:00 2001 From: fettpl <38704082+fettpl@users.noreply.github.com> Date: Thu, 3 Sep 2026 08:09:31 +0200 Subject: [PATCH 2/6] fix(runtime): realpath browse home bound and gate skill upload chunks files.browseServerDir used a lexical home check, so a symlink inside $HOME that pointed outside (e.g. ~/link -> /etc) still listed the target. Resolve realpath of the requested path and of homedir before isPathInsideOrEqual. skills.beginUpload and skills.uploadChunk were still reachable from paired clients after commitUpload was gated, so a client could fill disk with staged chunks. Apply the same host-only rejectPairedSkillMutation gate. --- src/main/runtime/rpc/methods/skills.test.ts | 8 +++++- src/main/runtime/rpc/methods/skills.ts | 10 ++++++-- ...untime-server-environment-commands.test.ts | 19 +++++++++++++- .../runtime-server-environment-commands.ts | 25 +++++++++++++------ 4 files changed, 51 insertions(+), 11 deletions(-) diff --git a/src/main/runtime/rpc/methods/skills.test.ts b/src/main/runtime/rpc/methods/skills.test.ts index d83717497f6..320b1bc1042 100644 --- a/src/main/runtime/rpc/methods/skills.test.ts +++ b/src/main/runtime/rpc/methods/skills.test.ts @@ -410,7 +410,13 @@ describe('skill management RPC', () => { { operationId: 'operation_1', name: 'example', destination: { scope: 'global' as const } }, 'removeSharedSkillInstallRequest' ], - ['skills.commitUpload', { uploadId: 'upload_1' }, 'commitSkillUpload'] + ['skills.commitUpload', { uploadId: 'upload_1' }, 'commitSkillUpload'], + ['skills.beginUpload', { package: SKILL_INSTALL_REQUEST.package }, 'beginSkillUpload'], + [ + 'skills.uploadChunk', + { uploadId: 'upload_1', offset: 0, bytesBase64: '' }, + 'appendSkillUploadChunk' + ] ] as const)( 'rejects paired callers to %s with the unsupported-environment code', async (methodName, params, runtimeMethod) => { diff --git a/src/main/runtime/rpc/methods/skills.ts b/src/main/runtime/rpc/methods/skills.ts index 7c296c2715b..000d0823937 100644 --- a/src/main/runtime/rpc/methods/skills.ts +++ b/src/main/runtime/rpc/methods/skills.ts @@ -192,12 +192,18 @@ export const SKILL_METHODS = [ defineMethod({ name: 'skills.beginUpload', params: SkillUploadBeginRequestSchema, - handler: (params, { runtime }) => runtime.beginSkillUpload(params) + handler: (params, { runtime, clientKind }) => { + rejectPairedSkillMutation(clientKind, 'Uploading skills') + return runtime.beginSkillUpload(params) + } }), defineMethod({ name: 'skills.uploadChunk', params: SkillUploadChunkRequestSchema, - handler: (params, { runtime }) => runtime.appendSkillUploadChunk(params) + handler: (params, { runtime, clientKind }) => { + rejectPairedSkillMutation(clientKind, 'Uploading skills') + return runtime.appendSkillUploadChunk(params) + } }), defineMethod({ name: 'skills.commitUpload', diff --git a/src/main/runtime/runtime-server-environment-commands.test.ts b/src/main/runtime/runtime-server-environment-commands.test.ts index 8595df410c5..2729acc65a5 100644 --- a/src/main/runtime/runtime-server-environment-commands.test.ts +++ b/src/main/runtime/runtime-server-environment-commands.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' import { homedir, tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { describe, expect, it } from 'vitest' @@ -68,4 +68,21 @@ describe('RuntimeServerEnvironmentCommands.browseDirectory', () => { await rm(tempRoot, { recursive: true, force: true }) } }) + + it('rejects a symlink from inside home that points outside home', async () => { + const outsideHome = + process.platform === 'win32' ? (process.env.SystemRoot ?? 'C:\\Windows') : '/etc' + expect(isPathInsideOrEqual(homedir(), outsideHome)).toBe(false) + + const tempRoot = await mkdtemp(join(homedir(), 'orca-runtime-browse-link-')) + const linkPath = join(tempRoot, 'outside-link') + try { + await symlink(outsideHome, linkPath, process.platform === 'win32' ? 'junction' : 'dir') + await expect(commands.browseDirectory(linkPath)).rejects.toThrow( + 'Directory browsing is limited to the home directory.' + ) + } finally { + await rm(tempRoot, { recursive: true, force: true }) + } + }) }) diff --git a/src/main/runtime/runtime-server-environment-commands.ts b/src/main/runtime/runtime-server-environment-commands.ts index 2091e718e4f..1409a96ee4e 100644 --- a/src/main/runtime/runtime-server-environment-commands.ts +++ b/src/main/runtime/runtime-server-environment-commands.ts @@ -1,4 +1,4 @@ -import { readdir, stat } from 'node:fs/promises' +import { readdir, realpath, stat } from 'node:fs/promises' import { homedir } from 'node:os' import { isAbsolute, resolve } from 'node:path' import { isPathInsideOrEqual } from '../../shared/cross-platform-path' @@ -8,6 +8,8 @@ import { probeGitAvailability } from '../git/git-availability' import { gitExecFileAsync } from '../git/runner' import { isServerDriveListRequest, listWindowsDrives } from './windows-drive-listing' +const SERVER_BROWSE_HOME_BOUND_ERROR = 'Directory browsing is limited to the home directory.' + function resolveServerBrowsePath(pathValue: string): string { const trimmed = pathValue.trim() || '~' if (trimmed.includes('\0')) { @@ -25,11 +27,21 @@ function resolveServerBrowsePath(pathValue: string): string { return resolve(homedir(), trimmed) } -function assertAllowedServerBrowsePath(dirPath: string): void { - if (isPathInsideOrEqual(resolve(homedir()), dirPath)) { - return +async function resolveAllowedServerBrowsePath(dirPath: string): Promise { + const realHome = await realpath(homedir()) + let realDir: string + try { + realDir = await realpath(dirPath) + } catch (error) { + if (!isPathInsideOrEqual(realHome, dirPath)) { + throw new Error(SERVER_BROWSE_HOME_BOUND_ERROR) + } + throw error + } + if (isPathInsideOrEqual(realHome, realDir)) { + return realDir } - throw new Error('Directory browsing is limited to the home directory.') + throw new Error(SERVER_BROWSE_HOME_BOUND_ERROR) } export class RuntimeServerEnvironmentCommands { @@ -41,8 +53,7 @@ export class RuntimeServerEnvironmentCommands { if (isServerDriveListRequest(pathValue)) { return listWindowsDrives() } - const dirPath = resolveServerBrowsePath(pathValue) - assertAllowedServerBrowsePath(dirPath) + const dirPath = await resolveAllowedServerBrowsePath(resolveServerBrowsePath(pathValue)) const dirStat = await stat(dirPath) if (!dirStat.isDirectory()) { throw new Error(`${dirPath} is not a directory`) From c9afaafa4d5762bf1e67dc28bef8068388a340b0 Mon Sep 17 00:00:00 2001 From: fettpl <38704082+fettpl@users.noreply.github.com> Date: Wed, 23 Sep 2026 09:53:18 +0200 Subject: [PATCH 3/6] fix(runtime): reject paired callers on skill delete and cancel Paired mobile/runtime clients could still delete host skills or cancel host-owned install/upload state. Gate those RPCs the same way as the other skill mutations. --- src/main/runtime/rpc/methods/skills.test.ts | 35 ++++++++++++++++++++- src/main/runtime/rpc/methods/skills.ts | 20 ++++++++---- 2 files changed, 48 insertions(+), 7 deletions(-) diff --git a/src/main/runtime/rpc/methods/skills.test.ts b/src/main/runtime/rpc/methods/skills.test.ts index 320b1bc1042..9f27ea9584d 100644 --- a/src/main/runtime/rpc/methods/skills.test.ts +++ b/src/main/runtime/rpc/methods/skills.test.ts @@ -416,7 +416,9 @@ describe('skill management RPC', () => { 'skills.uploadChunk', { uploadId: 'upload_1', offset: 0, bytesBase64: '' }, 'appendSkillUploadChunk' - ] + ], + ['skills.cancelInstall', { operationId: 'operation_1' }, 'cancelSharedSkillInstall'], + ['skills.cancelUpload', { uploadId: 'upload_1' }, 'cancelSkillUpload'] ] as const)( 'rejects paired callers to %s with the unsupported-environment code', async (methodName, params, runtimeMethod) => { @@ -435,4 +437,35 @@ describe('skill management RPC', () => { expect(runtimeFn).not.toHaveBeenCalled() } ) + + it('rejects paired callers to skills.delete before host filesystem work', async () => { + const listRepos = vi.fn(() => []) + const runtime = { + listRepos, + resolveSkillDiscoveryProviderRoots: vi.fn(async () => ({})), + resolveProjectRuntimeForWorktree: vi.fn() + } + const params = { + operationId: 'operation_1', + skills: [ + { + id: 'skill_1', + directoryPath: '/home/user/.agents/skills/example', + skillFilePath: '/home/user/.agents/skills/example/SKILL.md', + name: 'example', + updatedAt: 1 + } + ] + } + + for (const clientKind of ['mobile', 'runtime'] as const) { + await expect( + method('skills.delete').handler(params, { + runtime, + clientKind + } as unknown as RpcContext) + ).rejects.toMatchObject({ code: 'agent_skill_sharing_unsupported_environment' }) + } + expect(listRepos).not.toHaveBeenCalled() + }) }) diff --git a/src/main/runtime/rpc/methods/skills.ts b/src/main/runtime/rpc/methods/skills.ts index 000d0823937..4d56c268f2e 100644 --- a/src/main/runtime/rpc/methods/skills.ts +++ b/src/main/runtime/rpc/methods/skills.ts @@ -101,12 +101,14 @@ export const SKILL_METHODS = [ defineMethod({ name: 'skills.delete', params: SkillDeleteRequestSchema, - handler: async (params, { runtime }) => - runSkillDeleteRequest( + handler: async (params, { runtime, clientKind }) => { + rejectPairedSkillMutation(clientKind, 'Deleting skills') + return runSkillDeleteRequest( params, resolveDiscoveryTarget(params.target ?? {}, runtime), skillDeleteDependencies(runtime) ) + } }), defineMethod({ name: 'skills.share', @@ -159,9 +161,12 @@ export const SKILL_METHODS = [ defineMethod({ name: 'skills.cancelInstall', params: SkillsCancelInstallParams, - handler: (params, { runtime }) => ({ - cancelled: runtime.cancelSharedSkillInstall(params.operationId) - }) + handler: (params, { runtime, clientKind }) => { + rejectPairedSkillMutation(clientKind, 'Cancelling skill installation') + return { + cancelled: runtime.cancelSharedSkillInstall(params.operationId) + } + } }), defineMethod({ name: 'skills.getInstallProgress', @@ -216,6 +221,9 @@ export const SKILL_METHODS = [ defineMethod({ name: 'skills.cancelUpload', params: SkillUploadCommitRequestSchema, - handler: (params, { runtime }) => runtime.cancelSkillUpload(params.uploadId) + handler: (params, { runtime, clientKind }) => { + rejectPairedSkillMutation(clientKind, 'Cancelling skill upload') + return runtime.cancelSkillUpload(params.uploadId) + } }) ] From aecb7b9902842949f4fb6ebfb974848388f20b98 Mon Sep 17 00:00:00 2001 From: fettpl <38704082+fettpl@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:37:22 +0200 Subject: [PATCH 4/6] fix(runtime): hide skill delete on paired environment targets Runtime-scoped pairing is the same WebSocket as web/phone. Keep delete host-local so the UI does not offer an action the RPC already denies. --- .../src/runtime/runtime-skills-client.ts | 69 +++++-------------- .../runtime-skills-delete-client.test.ts | 32 +++------ 2 files changed, 27 insertions(+), 74 deletions(-) diff --git a/src/renderer/src/runtime/runtime-skills-client.ts b/src/renderer/src/runtime/runtime-skills-client.ts index 33cade9ef55..78b9dc40796 100644 --- a/src/renderer/src/runtime/runtime-skills-client.ts +++ b/src/renderer/src/runtime/runtime-skills-client.ts @@ -3,17 +3,9 @@ import type { SkillDeleteRequest, SkillDeleteResult } from '../../../shared/skill-delete-contract' -import { - SKILL_DELETE_CAPABILITY, - SKILL_DELETE_UPDATE_REQUIRED_MESSAGE -} from '../../../shared/skill-install-capability' +import { SKILL_DELETE_UPDATE_REQUIRED_MESSAGE } from '../../../shared/skill-install-capability' import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../../../shared/skills' -import { - assertRuntimeEnvironmentCapability, - callRuntimeRpc, - runtimeEnvironmentSupportsCapability, - type RuntimeClientTarget -} from './runtime-rpc-client' +import { callRuntimeRpc, type RuntimeClientTarget } from './runtime-rpc-client' const SKILL_DISCOVERY_TIMEOUT_MS = 15_000 @@ -55,48 +47,31 @@ export async function discoverSkillsForRuntimeTarget( ) } -const SKILL_DELETE_PREVIEW_TIMEOUT_MS = 60_000 -const SKILL_DELETE_TIMEOUT_MS = 5 * 60_000 +const PAIRED_SKILL_DELETE_UNSUPPORTED_MESSAGE = + 'Deleting skills through a paired client is not supported. Run the command from Orca on the machine that stores the skills.' /** - * Whether the delete affordance may be offered at all. The gate lives here, - * beside where `callRuntimeRpc` is actually invoked — the main-process - * `callRuntimeEnvironment` path install and remove use is a different, - * non-overlapping transport, so a check there would never run for this. + * Whether the delete affordance may be offered at all. Paired environment + * targets share the runtime-scoped WebSocket with web/phone clients, so delete + * stays host-local (unix-socket / preload) only. */ export async function runtimeTargetSupportsSkillDelete( runtimeTarget: RuntimeClientTarget | null ): Promise { - if (!runtimeTarget) { + if (!runtimeTarget || runtimeTarget.kind !== 'local') { return false } - if (runtimeTarget.kind === 'local') { - // Desktop answers true immediately; on web the "local" host is a remote - // server that updates independently, so the preload probes its capability. - return window.api.skills.deleteSupported() - } - return runtimeEnvironmentSupportsCapability(runtimeTarget.environmentId, SKILL_DELETE_CAPABILITY) + // Desktop answers true immediately; on web the "local" host is a remote + // server that updates independently, so the preload probes its capability. + return window.api.skills.deleteSupported() } async function assertSkillDeleteSupported(runtimeTarget: RuntimeClientTarget): Promise { - if (runtimeTarget.kind === 'local') { - if (!(await window.api.skills.deleteSupported())) { - throw new Error(SKILL_DELETE_UPDATE_REQUIRED_MESSAGE) - } - return + if (runtimeTarget.kind !== 'local') { + throw new Error(PAIRED_SKILL_DELETE_UNSUPPORTED_MESSAGE) } - try { - await assertRuntimeEnvironmentCapability( - runtimeTarget.environmentId, - SKILL_DELETE_CAPABILITY, - SKILL_DELETE_UPDATE_REQUIRED_MESSAGE - ) - } catch (error) { - // A capability change racing the gate has no main-process hook to reuse: - // `recordSkillCapabilityAbsence` imports the main tracer and its capability - // parameter is a closed union over main-side capabilities. - console.warn('[skills] delete capability absent at call time', error) - throw error + if (!(await window.api.skills.deleteSupported())) { + throw new Error(SKILL_DELETE_UPDATE_REQUIRED_MESSAGE) } } @@ -105,12 +80,7 @@ export async function previewSkillDeletionOnRuntimeTarget( request: SkillDeleteRequest ): Promise { await assertSkillDeleteSupported(runtimeTarget) - if (runtimeTarget.kind === 'local') { - return window.api.skills.previewDelete(request) - } - return callRuntimeRpc(runtimeTarget, 'skills.previewDelete', request, { - timeoutMs: SKILL_DELETE_PREVIEW_TIMEOUT_MS - }) + return window.api.skills.previewDelete(request) } export async function deleteSkillsOnRuntimeTarget( @@ -118,10 +88,5 @@ export async function deleteSkillsOnRuntimeTarget( request: SkillDeleteRequest ): Promise { await assertSkillDeleteSupported(runtimeTarget) - if (runtimeTarget.kind === 'local') { - return window.api.skills.delete(request) - } - return callRuntimeRpc(runtimeTarget, 'skills.delete', request, { - timeoutMs: SKILL_DELETE_TIMEOUT_MS - }) + return window.api.skills.delete(request) } diff --git a/src/renderer/src/runtime/runtime-skills-delete-client.test.ts b/src/renderer/src/runtime/runtime-skills-delete-client.test.ts index 9b167e3f64b..4dc2c642aed 100644 --- a/src/renderer/src/runtime/runtime-skills-delete-client.test.ts +++ b/src/renderer/src/runtime/runtime-skills-delete-client.test.ts @@ -62,24 +62,23 @@ describe('runtimeTargetSupportsSkillDelete', () => { expect(runtimeEnvironmentSupportsCapability).not.toHaveBeenCalled() }) - it('asks the environment for the capability', async () => { - runtimeEnvironmentSupportsCapability.mockResolvedValue(false) + it('does not offer delete on a paired environment target', async () => { + runtimeEnvironmentSupportsCapability.mockResolvedValue(true) expect( await runtimeTargetSupportsSkillDelete({ kind: 'environment', environmentId: 'env-1' }) ).toBe(false) - expect(runtimeEnvironmentSupportsCapability).toHaveBeenCalledWith('env-1', 'skills.delete.v1') + expect(runtimeEnvironmentSupportsCapability).not.toHaveBeenCalled() }) }) describe('delete routing', () => { - it('issues no RPC against a host that lacks the capability', async () => { - assertRuntimeEnvironmentCapability.mockRejectedValue( - new Error(SKILL_DELETE_UPDATE_REQUIRED_MESSAGE) - ) + it('refuses a paired environment delete before any RPC', async () => { + assertRuntimeEnvironmentCapability.mockResolvedValue(undefined) await expect( deleteSkillsOnRuntimeTarget({ kind: 'environment', environmentId: 'env-1' }, REQUEST) - ).rejects.toThrow(SKILL_DELETE_UPDATE_REQUIRED_MESSAGE) + ).rejects.toThrow(/paired client/) expect(callRuntimeRpc).not.toHaveBeenCalled() + expect(assertRuntimeEnvironmentCapability).not.toHaveBeenCalled() }) it('routes a local delete through IPC rather than RPC', async () => { @@ -98,23 +97,12 @@ describe('delete routing', () => { expect(localDelete).not.toHaveBeenCalled() }) - it('sends the whole request to a capable remote host', async () => { + it('refuses a paired environment preview before any RPC', async () => { assertRuntimeEnvironmentCapability.mockResolvedValue(undefined) - callRuntimeRpc.mockResolvedValue({ operationId: 'op', skills: [] }) - await deleteSkillsOnRuntimeTarget({ kind: 'environment', environmentId: 'env-1' }, REQUEST) - expect(callRuntimeRpc).toHaveBeenCalledWith( - { kind: 'environment', environmentId: 'env-1' }, - 'skills.delete', - REQUEST, - expect.objectContaining({ timeoutMs: expect.any(Number) }) - ) - }) - - it('gates the preview on the same capability', async () => { - assertRuntimeEnvironmentCapability.mockRejectedValue(new Error('nope')) await expect( previewSkillDeletionOnRuntimeTarget({ kind: 'environment', environmentId: 'env-1' }, REQUEST) - ).rejects.toThrow('nope') + ).rejects.toThrow(/paired client/) expect(callRuntimeRpc).not.toHaveBeenCalled() + expect(assertRuntimeEnvironmentCapability).not.toHaveBeenCalled() }) }) From 75ae5f34d32724ef540f799b4ec1015c6585b2bf Mon Sep 17 00:00:00 2001 From: fettpl <38704082+fettpl@users.noreply.github.com> Date: Thu, 24 Sep 2026 10:09:55 +0200 Subject: [PATCH 5/6] fix(runtime): hide skill delete on paired web clients Web always rides a pairing token, so deleteSupported is false and preview/delete reject locally. Host previewDelete now fail-closes the same way as delete. Browse tests compare realpath. --- src/main/runtime/rpc/methods/skills.test.ts | 59 ++++++++++--------- src/main/runtime/rpc/methods/skills.ts | 6 +- ...untime-server-environment-commands.test.ts | 6 +- .../web-host-capability-api.test.ts | 27 +++++++++ .../preload-api/web-host-capability-api.ts | 16 ++--- 5 files changed, 70 insertions(+), 44 deletions(-) create mode 100644 src/renderer/src/web/preload-api/web-host-capability-api.test.ts diff --git a/src/main/runtime/rpc/methods/skills.test.ts b/src/main/runtime/rpc/methods/skills.test.ts index 9f27ea9584d..1c3659d7379 100644 --- a/src/main/runtime/rpc/methods/skills.test.ts +++ b/src/main/runtime/rpc/methods/skills.test.ts @@ -438,34 +438,37 @@ describe('skill management RPC', () => { } ) - it('rejects paired callers to skills.delete before host filesystem work', async () => { - const listRepos = vi.fn(() => []) - const runtime = { - listRepos, - resolveSkillDiscoveryProviderRoots: vi.fn(async () => ({})), - resolveProjectRuntimeForWorktree: vi.fn() - } - const params = { - operationId: 'operation_1', - skills: [ - { - id: 'skill_1', - directoryPath: '/home/user/.agents/skills/example', - skillFilePath: '/home/user/.agents/skills/example/SKILL.md', - name: 'example', - updatedAt: 1 - } - ] - } + it.each(['skills.previewDelete', 'skills.delete'] as const)( + 'rejects paired callers to %s before host filesystem work', + async (methodName) => { + const listRepos = vi.fn(() => []) + const runtime = { + listRepos, + resolveSkillDiscoveryProviderRoots: vi.fn(async () => ({})), + resolveProjectRuntimeForWorktree: vi.fn() + } + const params = { + operationId: 'operation_1', + skills: [ + { + id: 'skill_1', + directoryPath: '/home/user/.agents/skills/example', + skillFilePath: '/home/user/.agents/skills/example/SKILL.md', + name: 'example', + updatedAt: 1 + } + ] + } - for (const clientKind of ['mobile', 'runtime'] as const) { - await expect( - method('skills.delete').handler(params, { - runtime, - clientKind - } as unknown as RpcContext) - ).rejects.toMatchObject({ code: 'agent_skill_sharing_unsupported_environment' }) + for (const clientKind of ['mobile', 'runtime'] as const) { + await expect( + method(methodName).handler(params, { + runtime, + clientKind + } as unknown as RpcContext) + ).rejects.toMatchObject({ code: 'agent_skill_sharing_unsupported_environment' }) + } + expect(listRepos).not.toHaveBeenCalled() } - expect(listRepos).not.toHaveBeenCalled() - }) + ) }) diff --git a/src/main/runtime/rpc/methods/skills.ts b/src/main/runtime/rpc/methods/skills.ts index 4d56c268f2e..4a442fcfa83 100644 --- a/src/main/runtime/rpc/methods/skills.ts +++ b/src/main/runtime/rpc/methods/skills.ts @@ -91,12 +91,14 @@ export const SKILL_METHODS = [ defineMethod({ name: 'skills.previewDelete', params: SkillDeleteRequestSchema, - handler: async (params, { runtime }) => - previewSkillDeleteRequest( + handler: async (params, { runtime, clientKind }) => { + rejectPairedSkillMutation(clientKind, 'Deleting skills') + return previewSkillDeleteRequest( params, resolveDiscoveryTarget(params.target ?? {}, runtime), skillDeleteDependencies(runtime) ) + } }), defineMethod({ name: 'skills.delete', diff --git a/src/main/runtime/runtime-server-environment-commands.test.ts b/src/main/runtime/runtime-server-environment-commands.test.ts index 2729acc65a5..52abedf3374 100644 --- a/src/main/runtime/runtime-server-environment-commands.test.ts +++ b/src/main/runtime/runtime-server-environment-commands.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises' import { homedir, tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { describe, expect, it } from 'vitest' @@ -10,7 +10,7 @@ describe('RuntimeServerEnvironmentCommands.browseDirectory', () => { it('lists the home directory', async () => { const result = await commands.browseDirectory('~') - expect(result.resolvedPath).toBe(resolve(homedir())) + expect(result.resolvedPath).toBe(await realpath(homedir())) expect(result.pathFlavor).toBe(process.platform === 'win32' ? 'win32' : 'posix') expect(Array.isArray(result.entries)).toBe(true) }) @@ -24,7 +24,7 @@ describe('RuntimeServerEnvironmentCommands.browseDirectory', () => { const result = await commands.browseDirectory(tempRoot) - expect(result.resolvedPath).toBe(resolve(tempRoot)) + expect(result.resolvedPath).toBe(await realpath(tempRoot)) expect(result.entries).toEqual([ { name: 'alpha', isDirectory: true, isSymlink: false }, { name: 'zeta', isDirectory: true, isSymlink: false }, diff --git a/src/renderer/src/web/preload-api/web-host-capability-api.test.ts b/src/renderer/src/web/preload-api/web-host-capability-api.test.ts new file mode 100644 index 00000000000..f9690377620 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-host-capability-api.test.ts @@ -0,0 +1,27 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { SKILL_DELETE_CAPABILITY } from '../../../../shared/skill-install-capability' +import { installBrowserGlobals } from '../web-preload-api-test-harness' + +vi.mock('./web-runtime-calls', () => ({ + callRuntimeResult: vi.fn(), + getRemoteRuntimeStatus: vi.fn(async () => ({ + capabilities: [SKILL_DELETE_CAPABILITY] + })) +})) + +describe('web skills API', () => { + beforeEach(() => { + vi.resetModules() + installBrowserGlobals('Linux') + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('does not offer delete on a paired web host even when the capability is advertised', async () => { + // Why: web-runtime-session reads window at import time. + const { createSkillsApi } = await import('./web-host-capability-api') + expect(await createSkillsApi().deleteSupported()).toBe(false) + }) +}) diff --git a/src/renderer/src/web/preload-api/web-host-capability-api.ts b/src/renderer/src/web/preload-api/web-host-capability-api.ts index 3834d664d87..f3e25a82e84 100644 --- a/src/renderer/src/web/preload-api/web-host-capability-api.ts +++ b/src/renderer/src/web/preload-api/web-host-capability-api.ts @@ -9,9 +9,7 @@ import type { } from '../../../../shared/computer-use-permissions-types' import type { SkillFreshnessInventory } from '../../../../shared/skill-freshness' import type { SkillDiscoveryResult } from '../../../../shared/skills' -import type { SkillDeletePlan, SkillDeleteResult } from '../../../../shared/skill-delete-contract' -import { SKILL_DELETE_CAPABILITY } from '../../../../shared/skill-install-capability' -import { callRuntimeResult, getRemoteRuntimeStatus } from './web-runtime-calls' +import { callRuntimeResult } from './web-runtime-calls' import { requireActiveEnvironmentOrNull } from './web-runtime-session' import { getBrowserPlatform } from './web-storage' @@ -178,14 +176,10 @@ export function createSkillsApi(): NonNullable['skills']> { previewBundleInstall: () => Promise.reject(new Error('Skill installation requires the desktop app.')), removeInstall: () => Promise.reject(new Error('Skill installation requires the desktop app.')), - // Disable deletion when the paired host predates the capability. - deleteSupported: async () => { - const status = await getRemoteRuntimeStatus().catch(() => null) - return status?.capabilities?.includes(SKILL_DELETE_CAPABILITY) === true - }, - previewDelete: (request) => - callRuntimeResult('skills.previewDelete', request, 60_000), - delete: (request) => callRuntimeResult('skills.delete', request, 5 * 60_000), + // Why: web is always a paired client; host skill-delete RPCs reject paired callers. + deleteSupported: async () => false, + previewDelete: () => Promise.reject(new Error('Deleting skills requires the desktop app.')), + delete: () => Promise.reject(new Error('Deleting skills requires the desktop app.')), listManagedInstalls: () => Promise.reject(new Error('Skill installation requires the desktop app.')), getPackage: () => Promise.reject(new Error('Skill installation requires the desktop app.')), From d77e147224b09396322d70fdab3bfe51a458c4b9 Mon Sep 17 00:00:00 2001 From: fettpl <38704082+fettpl@users.noreply.github.com> Date: Thu, 24 Sep 2026 10:21:07 +0200 Subject: [PATCH 6/6] test(runtime): compare browseServerDir paths with realpath The command returns the canonical path; a symlinked $HOME made the unresolved mkdtemp assertion host-dependent. --- .../orca-runtime-tests/repository-project-operations.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/runtime/orca-runtime-tests/repository-project-operations.spec.ts b/src/main/runtime/orca-runtime-tests/repository-project-operations.spec.ts index 384b75e8633..4fe93600b66 100644 --- a/src/main/runtime/orca-runtime-tests/repository-project-operations.spec.ts +++ b/src/main/runtime/orca-runtime-tests/repository-project-operations.spec.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { realpath } from 'node:fs/promises' import { homedir } from 'node:os' import { DEFAULT_REPO_BADGE_COLOR, @@ -59,7 +60,7 @@ describe('OrcaRuntimeService', () => { const result = await runtime.browseServerDir(tempRoot) - expect(result.resolvedPath).toBe(tempRoot) + expect(result.resolvedPath).toBe(await realpath(tempRoot)) expect(result.pathFlavor).toBe(process.platform === 'win32' ? 'win32' : 'posix') expect(result.entries).toEqual([ { name: 'alpha', isDirectory: true, isSymlink: false },