diff --git a/config/copilot.php b/config/copilot.php index 2ba626a..247a5bd 100644 --- a/config/copilot.php +++ b/config/copilot.php @@ -91,4 +91,19 @@ | */ 'model' => env('COPILOT_MODEL'), + + /* + |-------------------------------------------------------------------------- + | Auto-approve Permission Requests + |-------------------------------------------------------------------------- + | + | When true, all permission requests (file write, shell command, etc.) + | are automatically approved when using the Copilot facade (CopilotManager). + | Set to false to require explicit permission handling like the official SDK. + | + | Note: This only applies when using Copilot::run(), Copilot::start(), etc. + | Direct Client usage always requires an explicit onPermissionRequest handler. + | + */ + 'permission_approve' => env('COPILOT_PERMISSION_APPROVE', true), ]; diff --git a/docs/jp/permission-request.md b/docs/jp/permission-request.md index 858c268..7fb95c0 100644 --- a/docs/jp/permission-request.md +++ b/docs/jp/permission-request.md @@ -1,8 +1,31 @@ # Permission Request -## Deny by Default +## Auto-approve (デフォルト) -ツールの操作(ファイル書き込み、シェルコマンド、URL取得、MCP呼び出しなど)はデフォルトで**拒否**される。許可するには `onPermissionRequest` ハンドラを指定する必要がある。 +`config/copilot.php`で`permission_approve`が`true`(デフォルト)の場合、`Copilot::run()`や`Copilot::start()`を使う時は自動的にすべてのPermission Requestが許可される。 + +```php +// config/copilot.php +'permission_approve' => env('COPILOT_PERMISSION_APPROVE', true), +``` + +この設定が有効な場合、`onPermissionRequest`を明示的に指定しなくても`PermissionHandler::approveAll()`が自動的に使われる。 + +```php +use Revolution\Copilot\Facades\Copilot; + +// onPermissionRequestを指定しなくても自動的に全許可 +$response = Copilot::run(prompt: 'Hello'); +``` + +公式SDKと同様にデフォルトで拒否したい場合は`false`に設定する。 + +```php +// config/copilot.php +'permission_approve' => false, +``` + +この場合は`onPermissionRequest`の指定が必須になる。 ## PermissionHandler::approveAll() @@ -20,6 +43,25 @@ $config = new SessionConfig( $response = Copilot::run(prompt: 'Hello', config: $config); ``` +## Clientの直接使用 + +`CopilotClient`を直接使用する場合は、公式SDK同様に`onPermissionRequest`の指定が**必須**。 + +```php +use Revolution\Copilot\Support\PermissionHandler; + +$client = app(CopilotClient::class); +$client->start(); + +// onPermissionRequestが必須 +$session = $client->createSession([ + 'onPermissionRequest' => PermissionHandler::approveAll(), +]); + +// 指定しないとInvalidArgumentExceptionがスローされる +// $session = $client->createSession([]); // Error! +``` + ## カスタムハンドラ リクエストの種類に応じて個別に許可・拒否を制御する場合は、クロージャを指定する。`$request`と`$invocation`は下記のような内容の配列。 @@ -70,7 +112,7 @@ Artisan::command('copilot:chat', function () { `kind`と`toolCallId`以外はkindによって内容が異なる。 ``` -kind: "shell" | "write" | "mcp" | "read" | "url" +kind: "shell" | "write" | "mcp" | "read" | "url" | "custom-tool" ``` ```php diff --git a/src/Client.php b/src/Client.php index 90ddb8a..737fc61 100644 --- a/src/Client.php +++ b/src/Client.php @@ -224,16 +224,23 @@ public function isTcpMode(): bool /** * Create a new conversation session. * - * @param SessionConfig|array{session_id?: string, model?: string, tools?: array, system_message?: array, available_tools?: array, excluded_tools?: array, provider?: array, on_permission_request?: callable, on_user_input_request?: callable, hooks?: array, working_directory?: string, streaming?: bool, mcp_servers?: array, custom_agents?: array, config_dir?: string, skill_directories?: array, disabled_skills?: array} $config + * @param SessionConfig|array{session_id?: string, model?: string, tools?: array, system_message?: array, available_tools?: array, excluded_tools?: array, provider?: array, on_permission_request: callable, on_user_input_request?: callable, hooks?: array, working_directory?: string, streaming?: bool, mcp_servers?: array, custom_agents?: array, config_dir?: string, skill_directories?: array, disabled_skills?: array} $config * * @throws JsonRpcException */ - public function createSession(SessionConfig|array $config = []): CopilotSession + public function createSession(SessionConfig|array $config): CopilotSession { $this->ensureConnected(); $config = is_array($config) ? $config : $config->toArray(); + if (! isset($config['onPermissionRequest']) || ! is_callable($config['onPermissionRequest'])) { + throw new \InvalidArgumentException( + 'An onPermissionRequest handler is required when creating a session. ' + .'For example, to allow all permissions, use new SessionConfig(onPermissionRequest: PermissionHandler::approveAll()).', + ); + } + $tools = $config['tools'] ?? []; $toolsForRequest = array_map(fn ($tool) => [ 'name' => $tool['name'], @@ -278,10 +285,7 @@ public function createSession(SessionConfig|array $config = []): CopilotSession 'workspacePath' => $workspacePath, ]); $session->registerTools($tools); - - if (isset($config['onPermissionRequest']) && is_callable($config['onPermissionRequest'])) { - $session->registerPermissionHandler($config['onPermissionRequest']); - } + $session->registerPermissionHandler($config['onPermissionRequest']); if (isset($config['onUserInputRequest']) && is_callable($config['onUserInputRequest'])) { $session->registerUserInputHandler($config['onUserInputRequest']); @@ -301,16 +305,23 @@ public function createSession(SessionConfig|array $config = []): CopilotSession /** * Resume an existing session. * - * @param ResumeSessionConfig|array{tools?: array, provider?: array, on_permission_request?: callable, on_user_input_request?: callable, hooks?: array, working_directory?: string, streaming?: bool, mcp_servers?: array, custom_agents?: array, skill_directories?: array, disabled_skills?: array} $config + * @param ResumeSessionConfig|array{tools?: array, provider?: array, on_permission_request: callable, on_user_input_request?: callable, hooks?: array, working_directory?: string, streaming?: bool, mcp_servers?: array, custom_agents?: array, skill_directories?: array, disabled_skills?: array} $config * * @throws JsonRpcException */ - public function resumeSession(string $sessionId, ResumeSessionConfig|array $config = []): CopilotSession + public function resumeSession(string $sessionId, ResumeSessionConfig|array $config): CopilotSession { $this->ensureConnected(); $config = is_array($config) ? $config : $config->toArray(); + if (! isset($config['onPermissionRequest']) || ! is_callable($config['onPermissionRequest'])) { + throw new \InvalidArgumentException( + 'An onPermissionRequest handler is required when resuming a session. ' + .'For example, to allow all permissions, use new ResumeSessionConfig(onPermissionRequest: PermissionHandler::approveAll()).', + ); + } + $tools = $config['tools'] ?? []; $toolsForRequest = array_map(fn ($tool) => [ 'name' => $tool['name'], @@ -350,10 +361,7 @@ public function resumeSession(string $sessionId, ResumeSessionConfig|array $conf 'workspacePath' => $workspacePath, ]); $session->registerTools($tools); - - if (isset($config['onPermissionRequest']) && is_callable($config['onPermissionRequest'])) { - $session->registerPermissionHandler($config['onPermissionRequest']); - } + $session->registerPermissionHandler($config['onPermissionRequest']); if (isset($config['onUserInputRequest']) && is_callable($config['onUserInputRequest'])) { $session->registerUserInputHandler($config['onUserInputRequest']); diff --git a/src/Contracts/CopilotClient.php b/src/Contracts/CopilotClient.php index 51f0fad..532726c 100644 --- a/src/Contracts/CopilotClient.php +++ b/src/Contracts/CopilotClient.php @@ -28,16 +28,20 @@ public function start(): static; /** * Create a new conversation session. * + * An onPermissionRequest handler is required in the config. + * * @throws JsonRpcException */ - public function createSession(SessionConfig|array $config = []): CopilotSession; + public function createSession(SessionConfig|array $config): CopilotSession; /** * Resume an existing session. * + * An onPermissionRequest handler is required in the config. + * * @throws JsonRpcException */ - public function resumeSession(string $sessionId, ResumeSessionConfig|array $config = []): CopilotSession; + public function resumeSession(string $sessionId, ResumeSessionConfig|array $config): CopilotSession; /** * Send a ping to verify connectivity. diff --git a/src/CopilotManager.php b/src/CopilotManager.php index 1500c6f..5c3ce7b 100644 --- a/src/CopilotManager.php +++ b/src/CopilotManager.php @@ -10,6 +10,7 @@ use Revolution\Copilot\Contracts\CopilotClient; use Revolution\Copilot\Contracts\CopilotSession; use Revolution\Copilot\Contracts\Factory; +use Revolution\Copilot\Support\PermissionHandler; use Revolution\Copilot\Testing\WithFake; use Revolution\Copilot\Types\ResumeSessionConfig; use Revolution\Copilot\Types\SessionConfig; @@ -117,6 +118,8 @@ protected function prepareSession(SessionConfig|ResumeSessionConfig|array $confi $config = is_array($config) ? $config : $config->toArray(); + $config = $this->ensurePermissionHandler($config); + if (empty($resume)) { if (is_array($config)) { $config = SessionConfig::fromArray(array_merge( @@ -147,6 +150,10 @@ public function createSession(SessionConfig|array $config = []): CopilotSession return $this->fake->createSession($config); } + $config = is_array($config) ? $config : $config->toArray(); + + $config = $this->ensurePermissionHandler($config); + if (is_array($config)) { $config = SessionConfig::fromArray(array_merge( ['model' => $this->config['model'] ?? null], @@ -157,6 +164,23 @@ public function createSession(SessionConfig|array $config = []): CopilotSession return $this->client()->createSession($config); } + /** + * Ensure the config has an onPermissionRequest handler. + * + * If no handler is provided and the `permission_approve` config is true, + * automatically injects PermissionHandler::approveAll(). + */ + protected function ensurePermissionHandler(array $config): array + { + if (! isset($config['onPermissionRequest'])) { + if ($this->config['permission_approve'] ?? config('copilot.permission_approve', true)) { + $config['onPermissionRequest'] = PermissionHandler::approveAll(); + } + } + + return $config; + } + /** * Get or create the CopilotClient instance. * diff --git a/tests/Feature/ClientTest.php b/tests/Feature/ClientTest.php index b2c88d6..9fa203f 100644 --- a/tests/Feature/ClientTest.php +++ b/tests/Feature/ClientTest.php @@ -10,6 +10,7 @@ use Revolution\Copilot\JsonRpc\JsonRpcClient; use Revolution\Copilot\Process\ProcessManager; use Revolution\Copilot\Session; +use Revolution\Copilot\Support\PermissionHandler; use Revolution\Copilot\Transport\StdioTransport; beforeEach(function () { @@ -193,6 +194,7 @@ $mockSession = Mockery::mock(Session::class); $mockSession->shouldReceive('registerTools')->once()->with([]); + $mockSession->shouldReceive('registerPermissionHandler')->once(); $this->app->bind(ProcessManager::class, fn () => $mockProcessManager); $this->app->bind(JsonRpcClient::class, fn () => $mockRpcClient); @@ -200,7 +202,9 @@ $client = new Client; $client->start(); - $session = $client->createSession(); + $session = $client->createSession([ + 'onPermissionRequest' => PermissionHandler::approveAll(), + ]); expect($session)->toBe($mockSession); @@ -216,8 +220,73 @@ $client = new Client; - expect(fn () => $client->createSession()) - ->toThrow(RuntimeException::class, 'Client not connected'); + expect(fn () => $client->createSession([ + 'onPermissionRequest' => PermissionHandler::approveAll(), + ]))->toThrow(RuntimeException::class, 'Client not connected'); + }); + + it('throws when createSession called without onPermissionRequest', function () { + $stdin = fopen('php://memory', 'r+'); + $stdout = fopen('php://memory', 'r+'); + + $mockStdioTransport = Mockery::mock(StdioTransport::class); + + $mockProcessManager = Mockery::mock(ProcessManager::class); + $mockProcessManager->shouldReceive('start')->once(); + $mockProcessManager->shouldReceive('getStdioTransport')->andReturn($mockStdioTransport); + + $mockRpcClient = Mockery::mock(JsonRpcClient::class); + $mockRpcClient->shouldReceive('start')->once(); + $mockRpcClient->shouldReceive('setNotificationHandler')->once(); + $mockRpcClient->shouldReceive('setRequestHandler')->times(4); + $mockRpcClient->shouldReceive('request') + ->with('status.get') + ->once() + ->andReturn(['version' => '', 'protocolVersion' => 2]); + + $this->app->bind(ProcessManager::class, fn () => $mockProcessManager); + $this->app->bind(JsonRpcClient::class, fn () => $mockRpcClient); + + $client = new Client; + $client->start(); + + expect(fn () => $client->createSession([])) + ->toThrow(\InvalidArgumentException::class, 'onPermissionRequest handler is required'); + + fclose($stdin); + fclose($stdout); + }); + + it('throws when resumeSession called without onPermissionRequest', function () { + $stdin = fopen('php://memory', 'r+'); + $stdout = fopen('php://memory', 'r+'); + + $mockStdioTransport = Mockery::mock(StdioTransport::class); + + $mockProcessManager = Mockery::mock(ProcessManager::class); + $mockProcessManager->shouldReceive('start')->once(); + $mockProcessManager->shouldReceive('getStdioTransport')->andReturn($mockStdioTransport); + + $mockRpcClient = Mockery::mock(JsonRpcClient::class); + $mockRpcClient->shouldReceive('start')->once(); + $mockRpcClient->shouldReceive('setNotificationHandler')->once(); + $mockRpcClient->shouldReceive('setRequestHandler')->times(4); + $mockRpcClient->shouldReceive('request') + ->with('status.get') + ->once() + ->andReturn(['version' => '', 'protocolVersion' => 2]); + + $this->app->bind(ProcessManager::class, fn () => $mockProcessManager); + $this->app->bind(JsonRpcClient::class, fn () => $mockRpcClient); + + $client = new Client; + $client->start(); + + expect(fn () => $client->resumeSession('test-session-123', [])) + ->toThrow(\InvalidArgumentException::class, 'onPermissionRequest handler is required'); + + fclose($stdin); + fclose($stdout); }); it('resumeSession resume and returns session', function () { @@ -247,6 +316,7 @@ $mockSession = Mockery::mock(Session::class); $mockSession->shouldReceive('registerTools')->once()->with([]); + $mockSession->shouldReceive('registerPermissionHandler')->once(); $this->app->bind(ProcessManager::class, fn () => $mockProcessManager); $this->app->bind(JsonRpcClient::class, fn () => $mockRpcClient); @@ -254,7 +324,9 @@ $client = new Client; $client->start(); - $session = $client->resumeSession('test-session-123'); + $session = $client->resumeSession('test-session-123', [ + 'onPermissionRequest' => PermissionHandler::approveAll(), + ]); expect($session)->toBe($mockSession);