Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions src/plugin/pty/tools/spawn.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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,
Expand Down
13 changes: 7 additions & 6 deletions src/v2/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 3 additions & 2 deletions src/v2/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
},
}
Expand Down
40 changes: 38 additions & 2 deletions test/pty-tools.test.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand Down Expand Up @@ -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,
Expand All @@ -68,6 +73,37 @@ describe('PTY Tools', () => {
expect(result).not.toContain('<system_reminder>')
})

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',
Expand Down
22 changes: 21 additions & 1 deletion test/v2.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -7,6 +7,7 @@ import {
getOrCreateServer,
handleShowServerUrlCommand,
ptyTools,
registerV2Tools,
stopActiveServer,
} from '../src/v2/index.ts'
import type {
Expand All @@ -20,6 +21,7 @@ import type {
describe('OpenCode V2 Plugin API', () => {
afterEach(() => {
stopActiveServer()
mock.restore()
})

describe('Plugin Contract Conformance', () => {
Expand Down Expand Up @@ -77,6 +79,24 @@ describe('OpenCode V2 Plugin API', () => {
}
})

it('injects the project directory into a tool context without directory', async () => {
const registeredTools: Record<string, ToolInfoV2> = {}
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)
Expand Down