From 28e4dfa88d9002c6f585e6cfd295b7ea5438c1e1 Mon Sep 17 00:00:00 2001 From: dhaern Date: Mon, 5 Oct 2026 06:04:17 +0000 Subject: [PATCH] fix(spawn): resolve a relative workdir against the session project Default spawns to the session project and inject the V2 plugin location into tool contexts. Relative workdirs now resolve against the project; explicit directory permission checks receive the resolved absolute path. --- src/plugin/pty/tools/spawn.ts | 7 ++++-- src/v2/index.ts | 13 ++++++------ src/v2/tools.ts | 5 +++-- test/pty-tools.test.ts | 40 +++++++++++++++++++++++++++++++++-- test/v2.test.ts | 22 ++++++++++++++++++- 5 files changed, 74 insertions(+), 13 deletions(-) diff --git a/src/plugin/pty/tools/spawn.ts b/src/plugin/pty/tools/spawn.ts index f912be4e..ff7bd457 100644 --- a/src/plugin/pty/tools/spawn.ts +++ b/src/plugin/pty/tools/spawn.ts @@ -1,4 +1,5 @@ import { tool } from '@opencode-ai/plugin' +import { resolve } from 'node:path' import { manager } from '../manager.ts' import { checkCommandPermission, checkWorkdirPermission } from '../permissions.ts' import DESCRIPTION from './spawn.txt' @@ -42,15 +43,17 @@ export const ptySpawn = tool({ async execute(args, ctx) { await checkCommandPermission(args.command, args.args ?? []) + const workdir = resolve(ctx.directory ?? '', args.workdir ?? '') + if (args.workdir) { - await checkWorkdirPermission(args.workdir) + await checkWorkdirPermission(workdir) } const sessionId = ctx.sessionID const info = manager.spawn({ command: args.command, args: args.args, - workdir: args.workdir, + workdir, env: args.env, title: args.title, description: args.description, diff --git a/src/v2/index.ts b/src/v2/index.ts index ad044831..05f4f760 100644 --- a/src/v2/index.ts +++ b/src/v2/index.ts @@ -34,18 +34,19 @@ export const Plugin: PluginV2 = define({ const adapter = createV2Adapter({ notifier }) installHostAdapter(adapter) - if (ctx.tool && typeof ctx.tool.transform === 'function') { - await ctx.tool.transform((draft) => { - registerV2Tools(draft) - }) - } - // The project directory lives on `location`. `ctx.worktree` is deliberately // NOT used: it is a domain object on the V2 API, not a path, and passing it // through reached createHash and threw. Absent paths only mean no record is // published — the sidebar then falls back to the instance-scoped URL scrape. const location = (ctx as unknown as { location?: { directory?: unknown } }).location const directory = typeof location?.directory === 'string' ? location.directory : undefined + + if (ctx.tool && typeof ctx.tool.transform === 'function') { + await ctx.tool.transform((draft) => { + registerV2Tools(draft, directory) + }) + } + const serverOptions: ServerOptions = { port: ctx.options?.port, hostname: ctx.options?.hostname, diff --git a/src/v2/tools.ts b/src/v2/tools.ts index b5e41868..24b34f24 100644 --- a/src/v2/tools.ts +++ b/src/v2/tools.ts @@ -31,7 +31,7 @@ type V1ToolDefinition = { * - `args` (Zod raw shape) -> `input`: JSON Schema (Zod v4 `toJSONSchema`) * - string result -> `{ content }` */ -export function registerV2Tools(draft: ToolDraft): void { +export function registerV2Tools(draft: ToolDraft, directory?: string): void { if (typeof draft.add !== 'function') { return } @@ -44,7 +44,8 @@ export function registerV2Tools(draft: ToolDraft): void { description: definition.description, input: tool.schema.toJSONSchema(tool.schema.object(definition.args)), execute: async (input, context) => { - const result = await definition.execute(input as never, context as never) + const ctx = { directory, ...(context as object) } + const result = await definition.execute(input as never, ctx as never) return typeof result === 'string' ? { content: result } : result }, } diff --git a/test/pty-tools.test.ts b/test/pty-tools.test.ts index e15083ac..f3d6c625 100644 --- a/test/pty-tools.test.ts +++ b/test/pty-tools.test.ts @@ -1,15 +1,20 @@ -import { describe, it, expect, beforeEach, mock, spyOn, afterAll } from 'bun:test' +import { describe, it, expect, beforeEach, mock, spyOn, afterAll, afterEach } from 'bun:test' import { ptySpawn } from '../src/plugin/pty/tools/spawn.ts' import { ptyRead } from '../src/plugin/pty/tools/read.ts' import { ptyList } from '../src/plugin/pty/tools/list.ts' import { RingBuffer } from '../src/plugin/pty/buffer.ts' import { manager } from '../src/plugin/pty/manager.ts' +import { setPermissionAuthorizer } from '../src/plugin/pty/permissions.ts' describe('PTY Tools', () => { afterAll(() => { mock.restore() }) describe('ptySpawn', () => { + afterEach(() => { + setPermissionAuthorizer(null) + }) + beforeEach(() => { spyOn(manager, 'spawn').mockImplementation((opts) => ({ id: 'test-session-id', @@ -52,7 +57,7 @@ describe('PTY Tools', () => { description: 'Test session', parentSessionId: 'parent-session-id', parentAgent: 'test-agent', - workdir: undefined, + workdir: '/tmp', env: undefined, title: undefined, notifyOnExit: undefined, @@ -68,6 +73,37 @@ describe('PTY Tools', () => { expect(result).not.toContain('') }) + it('resolves relative workdir against the project before checking permission and spawning', async () => { + const checkWorkdir = mock(async () => {}) + setPermissionAuthorizer({ checkCommand: async () => {}, checkWorkdir }) + const ctx = { + sessionID: 'parent-session-id', + messageID: 'msg-relative', + agent: 'test-agent', + abort: new AbortController().signal, + metadata: () => {}, + ask: async () => {}, + directory: '/tmp/project', + worktree: '/tmp/project', + } + + const result = await ptySpawn.execute( + { + command: 'pwd', + args: [], + description: 'Check project source directory', + workdir: 'src', + }, + ctx + ) + + expect(checkWorkdir).toHaveBeenCalledWith('/tmp/project/src') + expect(manager.spawn).toHaveBeenCalledWith( + expect.objectContaining({ workdir: '/tmp/project/src' }) + ) + expect(result).toContain('Workdir: /tmp/project/src') + }) + it('should spawn with all optional args', async () => { const ctx = { sessionID: 'parent-session-id', diff --git a/test/v2.test.ts b/test/v2.test.ts index 47f979ee..a2e911f9 100644 --- a/test/v2.test.ts +++ b/test/v2.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, mock } from 'bun:test' +import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test' import { PTY_OPEN_CLIENT_COMMAND, PTY_SHOW_SERVER_URL_COMMAND, @@ -7,6 +7,7 @@ import { getOrCreateServer, handleShowServerUrlCommand, ptyTools, + registerV2Tools, stopActiveServer, } from '../src/v2/index.ts' import type { @@ -20,6 +21,7 @@ import type { describe('OpenCode V2 Plugin API', () => { afterEach(() => { stopActiveServer() + mock.restore() }) describe('Plugin Contract Conformance', () => { @@ -77,6 +79,24 @@ describe('OpenCode V2 Plugin API', () => { } }) + it('injects the project directory into a tool context without directory', async () => { + const registeredTools: Record = {} + const draft: ToolDraft = { + add: (tool) => { + registeredTools[tool.name] = tool + }, + } + const execute = spyOn(ptyTools.pty_spawn, 'execute').mockResolvedValue('spawned') + const input = { command: 'pwd', args: [], description: 'Check project directory' } + const context = { sessionID: 'session-1', progress: async () => {} } + + registerV2Tools(draft, '/tmp/project') + const result = await registeredTools.pty_spawn?.execute(input, context) + + expect(execute).toHaveBeenCalledWith(input, { directory: '/tmp/project', ...context }) + expect(result).toEqual({ content: 'spawned' }) + }) + it('does not fail when the tool transform is unavailable', async () => { const ctx: PluginContextV2 = { options: {} } await Plugin.setup(ctx)