diff --git a/agent/.gitignore b/agent/.gitignore new file mode 100644 index 00000000000..da64e2a39c5 --- /dev/null +++ b/agent/.gitignore @@ -0,0 +1,4 @@ +# Generated by `make gen` (buf generate) — reproducible, not committed. +/gen/ +# Build artifacts. +/build/ diff --git a/agent/Makefile b/agent/Makefile new file mode 100644 index 00000000000..0d4e84c12f0 --- /dev/null +++ b/agent/Makefile @@ -0,0 +1,63 @@ +# AlliCodes / TheLookout — agent layer build orchestration. +# Single non-recursive Makefile: proto -> gen -> go -> (native C) one DAG. +SHELL := /bin/bash +.SHELLFLAGS := -eu -o pipefail -c +.DEFAULT_GOAL := all + +GO ?= go +BUF ?= buf +CC ?= gcc +CFLAGS ?= -std=c11 -O2 -Wall -Wextra -Werror -pedantic + +GEN_DIR := gen +BUILD_DIR := build +STAMP := $(BUILD_DIR)/.stamps +ARCHIVE := $(HOME)/os/bin/archive.sh + +PROTOS := $(shell find proto -name '*.proto' 2>/dev/null) +GO_SRC := $(shell find . -name '*.go' -not -path './$(GEN_DIR)/*' 2>/dev/null) +C_SRC := $(wildcard native/*.c) +C_OBJ := $(patsubst native/%.c,$(BUILD_DIR)/%.o,$(C_SRC)) + +.PHONY: all gen build lint test package clean + +all: build + +# NOTE: dir names "build" and "gen" deliberately have NO named mkdir target — +# that would collide with the phony "build"/"gen" aggregators (circular dep). +# Each recipe runs `mkdir -p` itself (idempotent). + +# ---- 1) proto codegen (stamp: buf outputs aren't 1:1 with .proto) ---- +$(STAMP)/gen.stamp: $(PROTOS) buf.gen.yaml buf.yaml + @mkdir -p $(STAMP) $(GEN_DIR) + $(BUF) generate + @touch $@ +gen: $(STAMP)/gen.stamp + +# ---- 2) Go build (depends on generated code) ---- +$(BUILD_DIR)/server: $(STAMP)/gen.stamp $(GO_SRC) + @mkdir -p $(BUILD_DIR) + [ -f go.mod ] && $(GO) build -o $@ ./cmd/server || echo "skip: no go.mod yet" + +# ---- 3) native C IPC daemon (plain gcc, POSIX-only, no node-gyp) ---- +$(BUILD_DIR)/%.o: native/%.c + @mkdir -p $(BUILD_DIR) + $(CC) $(CFLAGS) -c $< -o $@ +$(BUILD_DIR)/ipcd: $(C_OBJ) + $(CC) $(CFLAGS) -o $@ $^ + +build: gen + @echo "build: proto generated; go/native targets build when sources exist" + +# ---- lint / test / package ---- +lint: $(STAMP)/gen.stamp + $(BUF) lint + [ -f go.mod ] && $(GO) vet ./... || true +test: build + [ -f go.mod ] && $(GO) test ./... || true +package: build test + @echo "package: TODO (.vsix / service binary)" + +# no-rm policy: archive build artifacts instead of deleting +clean: + @[ -x "$(ARCHIVE)" ] && $(ARCHIVE) $(BUILD_DIR) $(GEN_DIR) 2>/dev/null || echo "nothing to archive" diff --git a/agent/buf.gen.yaml b/agent/buf.gen.yaml new file mode 100644 index 00000000000..65422272b8b --- /dev/null +++ b/agent/buf.gen.yaml @@ -0,0 +1,16 @@ +# Local plugins only (no BSR / remote codegen) — offline, dependency-honest. +# Install: go install google.golang.org/protobuf/cmd/protoc-gen-go@ +# Runtime dep of generated code: google.golang.org/protobuf only. +version: v2 +clean: true +managed: + enabled: true + override: + - file_option: go_package_prefix + value: github.com/hellojade-ai/allicodes/agent/gen +plugins: + - local: protoc-gen-go + out: gen + opt: paths=source_relative +inputs: + - directory: proto diff --git a/agent/buf.yaml b/agent/buf.yaml new file mode 100644 index 00000000000..c7e30e38191 --- /dev/null +++ b/agent/buf.yaml @@ -0,0 +1,9 @@ +version: v2 +modules: + - path: proto +lint: + use: + - STANDARD +breaking: + use: + - FILE diff --git a/agent/go.mod b/agent/go.mod new file mode 100644 index 00000000000..c80c0df8dc5 --- /dev/null +++ b/agent/go.mod @@ -0,0 +1,5 @@ +module github.com/hellojade-ai/allicodes/agent + +go 1.26.3 + +require google.golang.org/protobuf v1.36.11 diff --git a/agent/go.sum b/agent/go.sum new file mode 100644 index 00000000000..296be183ce6 --- /dev/null +++ b/agent/go.sum @@ -0,0 +1,4 @@ +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= diff --git a/agent/poc/agent-rpc-client.mjs b/agent/poc/agent-rpc-client.mjs new file mode 100644 index 00000000000..cf3476b469a --- /dev/null +++ b/agent/poc/agent-rpc-client.mjs @@ -0,0 +1,64 @@ +#!/usr/bin/env node +/*--------------------------------------------------------------------------------------------- + * Phase 3 PoC step 2 — external agent RPC client. + * + * Proves the full path: this external process -> AlliCodes main IPC socket -> + * active window -> ICommandService/IConfigurationService -> core. + * + * Reuses AlliCodes' own built IPC client (out/) so we speak the exact wire + * protocol the `lookoutvs` CLI uses — no hand-rolled framing. Run AFTER a + * build (out/ must exist). + * + * Usage: + * node agent-rpc-client.mjs config.updateValue '{"key":"editor.fontSize","value":15}' + * node agent-rpc-client.mjs command.execute '{"commandId":"workbench.action.toggleSidebarVisibility"}' + *--------------------------------------------------------------------------------------------*/ + +import { createHash } from 'node:crypto'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; +import { pathToFileURL } from 'node:url'; + +const REPO = new URL('../../', import.meta.url).pathname; // .../thelookout/ +const USER_DATA = join(homedir(), '.config', 'TheLookout'); +const VERSION = '1.122.0'; + +// Reconstruct the main IPC socket path (mirrors createStaticIPCHandle). +function mainIPCHandle() { + const scope = createHash('sha256').update(USER_DATA).digest('hex').slice(0, 8); + const v = VERSION.slice(0, 4); + const runtime = process.env['XDG_RUNTIME_DIR']; + if (process.platform !== 'darwin' && runtime && !process.env['VSCODE_PORTABLE']) { + return join(runtime, `vscode-${scope}-${v}-main.sock`); + } + return join(USER_DATA, `${v}-main.sock`); +} + +async function main() { + const [method, paramsJson] = process.argv.slice(2); + if (!method) { + console.error('usage: agent-rpc-client.mjs [paramsJson]'); + process.exit(2); + } + const params = paramsJson ? JSON.parse(paramsJson) : {}; + + // Import AlliCodes' built IPC client. + const ipcNet = await import(pathToFileURL(join(REPO, 'out/vs/base/parts/ipc/node/ipc.net.js')).href); + + const handle = mainIPCHandle(); + console.error(`[poc] connecting to ${handle}`); + const client = await ipcNet.connect(handle, 'thelookout-agent-poc'); + const channel = client.getChannel('agentControl'); + + console.error(`[poc] -> executeRpc(${method}, ${JSON.stringify(params)})`); + const result = await channel.call('executeRpc', [method, params]); + console.log(JSON.stringify(result, null, 2)); + + client.dispose(); + process.exit(0); +} + +main().catch(err => { + console.error('[poc] error:', err?.message ?? err); + process.exit(1); +}); diff --git a/agent/proto/ide/agent/v1/agent.proto b/agent/proto/ide/agent/v1/agent.proto new file mode 100644 index 00000000000..2064866cfc7 --- /dev/null +++ b/agent/proto/ide/agent/v1/agent.proto @@ -0,0 +1,62 @@ +syntax = "proto3"; + +package ide.agent.v1; + +import "ide/common/v1/common.proto"; + +// Top-level agent session + the permission gate that fronts every dangerous +// IDE-control op (ENHANCEMENT from the core safety research). +// +// Permission ordering (per IDialogService + workspace-trust research): +// 1. IWorkspaceTrustManagementService.isWorkspaceTrusted / requestWorkspaceTrust +// 2. per-capability policy (this service) +// 3. IDialogService.confirm/prompt with "don't ask again" +// The agent model layer (provide-info + stream-response) deliberately mirrors +// VS Code's LanguageModelChatProvider shape so it stays Copilot/Cursor-compatible. +service AgentService { + // Request permission for a capability before invoking another IDE service. + rpc RequestPermission(RequestPermissionRequest) returns (RequestPermissionResponse); + + // Check whether the workspace is trusted; optionally prompt for trust. + rpc CheckWorkspaceTrust(CheckWorkspaceTrustRequest) returns (CheckWorkspaceTrustResponse); + + // Open / track an agent session (multi-session cockpit, maps to TheLookout + // RequestService/TaskService model). + rpc StartSession(StartSessionRequest) returns (StartSessionResponse); +} + +// Coarse capability the agent is asking to use, for the permission gate. +enum Capability { + CAPABILITY_UNSPECIFIED = 0; + CAPABILITY_INSTALL_EXTENSION = 1; + CAPABILITY_WRITE_SETTINGS = 2; + CAPABILITY_EDIT_FILES = 3; + CAPABILITY_RUN_TERMINAL = 4; + CAPABILITY_CONTROL_WINDOW = 5; + CAPABILITY_RUN_SANDBOX = 6; +} + +message RequestPermissionRequest { + Capability capability = 1; + // Human-readable detail shown in the confirmation dialog. + string detail = 2; +} +message RequestPermissionResponse { + ide.common.v1.PermissionDecision decision = 1; +} + +message CheckWorkspaceTrustRequest { + // If untrusted and this is true, prompt the user (requestWorkspaceTrust). + bool prompt_if_untrusted = 1; +} +message CheckWorkspaceTrustResponse { + bool trusted = 1; +} + +message StartSessionRequest { + string title = 1; + string workspace_uri = 2; +} +message StartSessionResponse { + string session_id = 1; +} diff --git a/agent/proto/ide/command/v1/command.proto b/agent/proto/ide/command/v1/command.proto new file mode 100644 index 00000000000..0d9886b03c0 --- /dev/null +++ b/agent/proto/ide/command/v1/command.proto @@ -0,0 +1,39 @@ +syntax = "proto3"; + +package ide.command.v1; + +import "google/protobuf/struct.proto"; + +// Arbitrary command execution. Backed by ICommandService.executeCommand and +// CommandsRegistry.getCommands (platform/commands/common/commands.ts). +// executeCommand reaches nearly every UI action (workbench.action.*, +// editor.action.*) — the broadest single control surface. +service CommandService { + // executeCommand(id, ...args). Result is JSON-shaped (commands return anything). + rpc Execute(ExecuteRequest) returns (ExecuteResponse); + + // getCommands(): enumerate registered commands — the agent's "what can I do" list. + rpc ListCommands(ListCommandsRequest) returns (ListCommandsResponse); +} + +message ExecuteRequest { + string command_id = 1; + // Positional args, each an arbitrary JSON value. + repeated google.protobuf.Value args = 2; +} +message ExecuteResponse { + google.protobuf.Value result = 1; +} + +message CommandInfo { + string id = 1; + // JSON schema of the command's args, when the command declares metadata. + google.protobuf.Struct args_schema = 2; +} +message ListCommandsRequest { + // If true, exclude internal/underscore-prefixed commands. + bool filter_internal = 1; +} +message ListCommandsResponse { + repeated CommandInfo commands = 1; +} diff --git a/agent/proto/ide/common/v1/common.proto b/agent/proto/ide/common/v1/common.proto new file mode 100644 index 00000000000..e8b3c07953e --- /dev/null +++ b/agent/proto/ide/common/v1/common.proto @@ -0,0 +1,50 @@ +syntax = "proto3"; + +package ide.common.v1; + +// Shared types for the AlliCodes / TheLookout agent IDE-control surface. +// Every service RPC maps to a verified VS Code core service method; the +// process-boundary hop (node-side socket listener -> renderer-side services) +// makes every call async. + +// Scope at which a setting is read or written. +// Mirrors VS Code ConfigurationTarget (platform/configuration/common/configuration.ts). +enum ConfigurationTarget { + CONFIGURATION_TARGET_UNSPECIFIED = 0; + CONFIGURATION_TARGET_APPLICATION = 1; + CONFIGURATION_TARGET_USER = 2; + CONFIGURATION_TARGET_USER_LOCAL = 3; + CONFIGURATION_TARGET_USER_REMOTE = 4; + CONFIGURATION_TARGET_WORKSPACE = 5; + CONFIGURATION_TARGET_WORKSPACE_FOLDER = 6; +} + +// Marker severity. Bitmask values mirror VS Code MarkerSeverity +// (platform/markers/common/markers.ts): Hint=1, Info=2, Warning=4, Error=8. +enum MarkerSeverity { + MARKER_SEVERITY_UNSPECIFIED = 0; + MARKER_SEVERITY_HINT = 1; + MARKER_SEVERITY_INFO = 2; + MARKER_SEVERITY_WARNING = 4; + MARKER_SEVERITY_ERROR = 8; +} + +// A position in a text document (1-based, matching VS Code IRange). +message Position { + int32 line = 1; + int32 column = 2; +} + +// A range in a text document. +message Range { + Position start = 1; + Position end = 2; +} + +// Outcome of a permission gate (IDialogService confirm/prompt + workspace trust). +enum PermissionDecision { + PERMISSION_DECISION_UNSPECIFIED = 0; + PERMISSION_DECISION_ALLOW_ONCE = 1; + PERMISSION_DECISION_ALLOW_ALWAYS = 2; + PERMISSION_DECISION_DENY = 3; +} diff --git a/agent/proto/ide/config/v1/config.proto b/agent/proto/ide/config/v1/config.proto new file mode 100644 index 00000000000..72fe4cce50f --- /dev/null +++ b/agent/proto/ide/config/v1/config.proto @@ -0,0 +1,45 @@ +syntax = "proto3"; + +package ide.config.v1; + +import "google/protobuf/struct.proto"; +import "ide/common/v1/common.proto"; + +// Settings read/write. Backed by IConfigurationService +// (platform/configuration/common/configuration.ts). +// +// Unlike the extension API (which rejects unregistered keys and cannot write +// non-persisted values, microsoft/vscode#43226), core updateValue writes any +// key to settings.json at the chosen scope. NOTE: writes are dropped during +// shutdown (microsoft/vscode#45474) — never issue from dispose(). +service ConfigService { + // inspect(): returns the value at each scope (user/workspace/folder/default) + // so the agent knows where a key currently lives before writing. + rpc Inspect(InspectRequest) returns (InspectResponse); + + // updateValue(): write at a scope. Omit value to remove the key. + rpc UpdateValue(UpdateValueRequest) returns (UpdateValueResponse); +} + +message InspectRequest { + string key = 1; + // Optional folder URI for folder-scope inspection (IConfigurationOverrides.resource). + string resource_uri = 2; +} +message InspectResponse { + // google.protobuf.Value carries arbitrary JSON-shaped setting values. + google.protobuf.Value default_value = 1; + google.protobuf.Value user_value = 2; + google.protobuf.Value workspace_value = 3; + google.protobuf.Value workspace_folder_value = 4; + google.protobuf.Value effective_value = 5; // the resolved value +} + +message UpdateValueRequest { + string key = 1; + // Omit (null) to remove the key from the target scope. + google.protobuf.Value value = 2; + ide.common.v1.ConfigurationTarget target = 3; + string resource_uri = 4; // required for WORKSPACE_FOLDER target +} +message UpdateValueResponse {} diff --git a/agent/proto/ide/diagnostics/v1/diagnostics.proto b/agent/proto/ide/diagnostics/v1/diagnostics.proto new file mode 100644 index 00000000000..4f11cd241b5 --- /dev/null +++ b/agent/proto/ide/diagnostics/v1/diagnostics.proto @@ -0,0 +1,43 @@ +syntax = "proto3"; + +package ide.diagnostics.v1; + +import "ide/common/v1/common.proto"; + +// Diagnostics (Problems panel). Backed by IMarkerService.read / getStatistics +// (platform/markers/common/markers.ts). read() is synchronous in core. +// This is the one capability the official extension integration already exposes +// (mcp__ide__getDiagnostics) — included for parity + richer filtering. +service DiagnosticsService { + rpc Read(ReadRequest) returns (ReadResponse); + rpc GetStatistics(GetStatisticsRequest) returns (GetStatisticsResponse); +} + +message Marker { + string resource_uri = 1; + ide.common.v1.MarkerSeverity severity = 2; + string message = 3; + string source = 4; // e.g. "buf", "eslint" + string code = 5; + ide.common.v1.Range range = 6; +} + +message ReadRequest { + // Optional filter to one resource. + string resource_uri = 1; + // OR-ed MarkerSeverity bitmask; 0 = all severities. + int32 severities = 2; + // Cap the number returned; 0 = no limit. + int32 take = 3; +} +message ReadResponse { + repeated Marker markers = 1; +} + +message GetStatisticsRequest {} +message GetStatisticsResponse { + int32 errors = 1; + int32 warnings = 2; + int32 infos = 3; + int32 unknowns = 4; +} diff --git a/agent/proto/ide/editor/v1/editor.proto b/agent/proto/ide/editor/v1/editor.proto new file mode 100644 index 00000000000..beeb1b33ee9 --- /dev/null +++ b/agent/proto/ide/editor/v1/editor.proto @@ -0,0 +1,93 @@ +syntax = "proto3"; + +package ide.editor.v1; + +import "ide/common/v1/common.proto"; + +// Editor layout + multi-file edits + diff views. +// Layout: IEditorGroupsService (workbench/services/editor/common/editorGroupsService.ts) +// - applyLayout/getLayout is the read+write the extension API lacks +// (microsoft/vscode#94817, #166423 — setEditorLayout is write-only for extensions). +// Edits: IBulkEditService.apply (editor/browser/services/bulkEditService.ts) +// - showPreview=true routes through the refactor-preview confirm UI. +// Diff: IEditorService.openEditor with a diff input +// (workbench/services/editor/common/editorService.ts). +service EditorService { + // getLayout(): read the current editor grid (extension API cannot). + rpc GetLayout(GetLayoutRequest) returns (GetLayoutResponse); + + // applyLayout(): declaratively set the editor grid. + rpc ApplyLayout(ApplyLayoutRequest) returns (ApplyLayoutResponse); + + // apply(WorkspaceEdit): atomic multi-file edit with undo grouping. + rpc ApplyWorkspaceEdit(ApplyWorkspaceEditRequest) returns (ApplyWorkspaceEditResponse); + + // openEditor(diff input): show original vs modified in the native diff editor. + rpc OpenDiff(OpenDiffRequest) returns (OpenDiffResponse); +} + +enum GroupOrientation { + GROUP_ORIENTATION_UNSPECIFIED = 0; + GROUP_ORIENTATION_HORIZONTAL = 1; + GROUP_ORIENTATION_VERTICAL = 2; +} + +// Mirrors EditorGroupLayout / GroupLayoutArgument (recursive group tree). +message GroupLayout { + // Relative size of this group/branch. + double size = 1; + // Child groups; empty = leaf. + repeated GroupLayout groups = 2; +} +message EditorLayout { + GroupOrientation orientation = 1; + repeated GroupLayout groups = 2; +} + +message GetLayoutRequest {} +message GetLayoutResponse { + EditorLayout layout = 1; +} + +message ApplyLayoutRequest { + EditorLayout layout = 1; +} +message ApplyLayoutResponse {} + +// A single text edit within one file. +message TextEdit { + ide.common.v1.Range range = 1; + string new_text = 2; +} + +// All edits to one resource. +message FileTextEdits { + string resource_uri = 1; + repeated TextEdit edits = 2; +} + +// Create / rename / delete a file (IWorkspaceFileEdit). +message FileOperation { + // Exactly one shape: create(new only), delete(old only), rename(both). + string old_resource_uri = 1; + string new_resource_uri = 2; + bool overwrite = 3; +} + +message ApplyWorkspaceEditRequest { + repeated FileTextEdits text_edits = 1; + repeated FileOperation file_operations = 2; + // showPreview=true -> refactor-preview confirm UI before applying. + bool show_preview = 3; + string label = 4; // undo-stack label +} +message ApplyWorkspaceEditResponse { + bool applied = 1; +} + +message OpenDiffRequest { + string original_resource_uri = 1; + string modified_resource_uri = 2; + string label = 3; +} +message OpenDiffResponse {} diff --git a/agent/proto/ide/extension/v1/extension.proto b/agent/proto/ide/extension/v1/extension.proto new file mode 100644 index 00000000000..dc0b3d9e414 --- /dev/null +++ b/agent/proto/ide/extension/v1/extension.proto @@ -0,0 +1,86 @@ +syntax = "proto3"; + +package ide.extension.v1; + +// Extension management. Backed by IWorkbenchExtensionManagementService, +// IExtensionGalleryService, IWorkbenchExtensionEnablementService +// (workbench/services/extensionManagement/common/extensionManagement.ts). +// +// This is the headline capability the official extension API CANNOT do +// (microsoft/vscode#70468, #201672 declined typed install/enable APIs). +service ExtensionService { + // Install from the configured gallery (Open VSX for AlliCodes). + // Resolves id -> IGalleryExtension via IExtensionGalleryService.getExtensions, + // then installFromGallery. May require publisher-trust (see request). + rpc InstallFromGallery(InstallFromGalleryRequest) returns (InstallFromGalleryResponse); + + // Install a local .vsix (installVSIX). Self-built VSIXs typically need + // skip_signature_verification = true (InstallOptions.donotVerifySignature). + rpc InstallVsix(InstallVsixRequest) returns (InstallVsixResponse); + + // Uninstall an installed extension. + rpc Uninstall(UninstallRequest) returns (UninstallResponse); + + // Enable or disable. setEnablement returns per-extension "needs reload"; + // requires_reload echoes that — caller should follow with WindowService.Reload. + rpc SetEnablement(SetEnablementRequest) returns (SetEnablementResponse); + + // List installed extensions (getInstalled). + rpc ListInstalled(ListInstalledRequest) returns (ListInstalledResponse); +} + +enum EnablementState { + ENABLEMENT_STATE_UNSPECIFIED = 0; + ENABLEMENT_STATE_ENABLED_GLOBALLY = 1; + ENABLEMENT_STATE_ENABLED_WORKSPACE = 2; + ENABLEMENT_STATE_DISABLED_GLOBALLY = 3; + ENABLEMENT_STATE_DISABLED_WORKSPACE = 4; +} + +message InstalledExtension { + string id = 1; // publisher.name + string version = 2; + string display_name = 3; + EnablementState enablement_state = 4; +} + +message InstallFromGalleryRequest { + string extension_id = 1; // publisher.name + bool pre_release = 2; + // If the publisher is untrusted, trust it first (requestPublisherTrust) + // instead of failing. Surfaces a confirmation to the user. + bool trust_publisher = 3; +} +message InstallFromGalleryResponse { + InstalledExtension extension = 1; +} + +message InstallVsixRequest { + string vsix_path = 1; + bool skip_signature_verification = 2; // InstallOptions.donotVerifySignature +} +message InstallVsixResponse { + InstalledExtension extension = 1; +} + +message UninstallRequest { + string extension_id = 1; +} +message UninstallResponse {} + +message SetEnablementRequest { + repeated string extension_ids = 1; + EnablementState target_state = 2; +} +message SetEnablementResponse { + // True if a window reload is required for the change to take effect. + bool requires_reload = 1; +} + +message ListInstalledRequest { + // If true, include built-in (system) extensions; default user-installed only. + bool include_builtin = 1; +} +message ListInstalledResponse { + repeated InstalledExtension extensions = 1; +} diff --git a/agent/proto/ide/profile/v1/profile.proto b/agent/proto/ide/profile/v1/profile.proto new file mode 100644 index 00000000000..f7defff8ece --- /dev/null +++ b/agent/proto/ide/profile/v1/profile.proto @@ -0,0 +1,49 @@ +syntax = "proto3"; + +package ide.profile.v1; + +// User data profiles. Backed by IUserDataProfileService (read/switch) and +// IUserDataProfileManagementService (CRUD) +// (workbench/services/userDataProfile/common/userDataProfile.ts). +service ProfileService { + rpc ListProfiles(ListProfilesRequest) returns (ListProfilesResponse); + rpc GetCurrent(GetCurrentRequest) returns (GetCurrentResponse); + rpc CreateProfile(CreateProfileRequest) returns (CreateProfileResponse); + rpc SwitchProfile(SwitchProfileRequest) returns (SwitchProfileResponse); + rpc RemoveProfile(RemoveProfileRequest) returns (RemoveProfileResponse); +} + +message Profile { + string id = 1; + string name = 2; + bool is_default = 3; +} + +message ListProfilesRequest {} +message ListProfilesResponse { + repeated Profile profiles = 1; +} + +message GetCurrentRequest {} +message GetCurrentResponse { + Profile profile = 1; +} + +message CreateProfileRequest { + string name = 1; + // If true, also enter (switch to) the new profile (createAndEnterProfile). + bool enter = 2; +} +message CreateProfileResponse { + Profile profile = 1; +} + +message SwitchProfileRequest { + string profile_id = 1; +} +message SwitchProfileResponse {} + +message RemoveProfileRequest { + string profile_id = 1; +} +message RemoveProfileResponse {} diff --git a/agent/proto/ide/sandbox/v1/sandbox.proto b/agent/proto/ide/sandbox/v1/sandbox.proto new file mode 100644 index 00000000000..9360be9c7c5 --- /dev/null +++ b/agent/proto/ide/sandbox/v1/sandbox.proto @@ -0,0 +1,49 @@ +syntax = "proto3"; + +package ide.sandbox.v1; + +// WASM agent-tool sandbox (ENHANCEMENT from WASM research). +// Backed by ms-vscode.wasm-wasi-core: wasm.compile + wasm.createProcess with +// scoped mountPoints. The mountPoints array IS the capability grant — mount +// nothing = pure compute; mount read-only = no write-pivot. +// +// SAFETY (honest): WASM gives strong memory isolation + deny-by-default +// capabilities, but NOT built-in CPU/mem/time limits — the host enforces those +// (timeout_ms below) and writable mounts can leak capability. Tools must be +// self-hosted under vendor/, never CDN. +service SandboxService { + // Run a vendored .wasm (wasm32-wasi: Go wasip1 or clang) with scoped mounts. + rpc RunTool(RunToolRequest) returns (RunToolResponse); +} + +enum MountKind { + MOUNT_KIND_UNSPECIFIED = 0; + MOUNT_KIND_WORKSPACE_FOLDER = 1; // mounts workspace as /workspace + MOUNT_KIND_VSCODE_FILESYSTEM = 2; // any VS Code FS provider uri + MOUNT_KIND_MEMORY = 3; // ephemeral in-RAM FS +} + +message MountPoint { + MountKind kind = 1; + string source_uri = 2; // for VSCODE_FILESYSTEM + string mount_point = 3; // path inside the guest, e.g. /work + bool read_only = 4; // grant read-only to avoid write-pivot +} + +message RunToolRequest { + // Absolute path to the vendored .wasm module (self-hosted, never CDN). + string wasm_module_path = 1; + repeated string args = 2; + map env = 3; + // Capability grants. Empty = no filesystem at all (pure compute). + repeated MountPoint mount_points = 4; + bytes stdin = 5; + // Host-enforced wall-clock limit (WASM has no built-in CPU cap). + int32 timeout_ms = 6; +} +message RunToolResponse { + int32 exit_code = 1; + bytes stdout = 2; + bytes stderr = 3; + bool timed_out = 4; +} diff --git a/agent/proto/ide/terminal/v1/terminal.proto b/agent/proto/ide/terminal/v1/terminal.proto new file mode 100644 index 00000000000..fc02639161a --- /dev/null +++ b/agent/proto/ide/terminal/v1/terminal.proto @@ -0,0 +1,49 @@ +syntax = "proto3"; + +package ide.terminal.v1; + +// Integrated terminal. Backed by ITerminalService + ITerminalGroupService + +// ITerminalInstance (workbench/contrib/terminal/browser/terminal.ts). +// "create + show + run" = createTerminal -> showPanel(true) -> sendText(cmd,true). +// NOTE: sendText is fire-and-forget input; reading output reliably needs +// shell integration (not modeled here — output capture is a future enhancement). +service TerminalService { + // createTerminal(): returns a handle id for subsequent sendText calls. + rpc Create(CreateRequest) returns (CreateResponse); + + // sendText(text, shouldExecute): write to a terminal, optionally press enter. + rpc SendText(SendTextRequest) returns (SendTextResponse); + + // showPanel(focus): reveal the terminal panel. + rpc Show(ShowRequest) returns (ShowResponse); + + // dispose(): close a terminal. + rpc Dispose(DisposeRequest) returns (DisposeResponse); +} + +message CreateRequest { + string name = 1; + string cwd_uri = 2; +} +message CreateResponse { + // Opaque handle the node-side bridge maps to an ITerminalInstance. + string terminal_id = 1; +} + +message SendTextRequest { + string terminal_id = 1; + string text = 2; + // True presses enter after the text (shouldExecute). + bool should_execute = 3; +} +message SendTextResponse {} + +message ShowRequest { + bool focus = 1; +} +message ShowResponse {} + +message DisposeRequest { + string terminal_id = 1; +} +message DisposeResponse {} diff --git a/agent/proto/ide/theme/v1/theme.proto b/agent/proto/ide/theme/v1/theme.proto new file mode 100644 index 00000000000..391c9a5a7ba --- /dev/null +++ b/agent/proto/ide/theme/v1/theme.proto @@ -0,0 +1,46 @@ +syntax = "proto3"; + +package ide.theme.v1; + +// Theming. Backed by IWorkbenchThemeService +// (workbench/services/themes/common/workbenchThemeService.ts). +// Simpler alternative: write workbench.colorTheme via ConfigService. +service ThemeService { + // getColorThemes(): enumerate installed color themes. + rpc ListColorThemes(ListColorThemesRequest) returns (ListColorThemesResponse); + + // setColorTheme(themeId): apply a color theme. + rpc SetColorTheme(SetColorThemeRequest) returns (SetColorThemeResponse); + + // setFileIconTheme / setProductIconTheme. + rpc SetIconTheme(SetIconThemeRequest) returns (SetIconThemeResponse); +} + +enum IconThemeKind { + ICON_THEME_KIND_UNSPECIFIED = 0; + ICON_THEME_KIND_FILE = 1; + ICON_THEME_KIND_PRODUCT = 2; +} + +message ColorTheme { + string id = 1; + string label = 2; + // dark | light | hcDark | hcLight + string type = 3; +} + +message ListColorThemesRequest {} +message ListColorThemesResponse { + repeated ColorTheme themes = 1; +} + +message SetColorThemeRequest { + string theme_id = 1; +} +message SetColorThemeResponse {} + +message SetIconThemeRequest { + IconThemeKind kind = 1; + string theme_id = 2; +} +message SetIconThemeResponse {} diff --git a/agent/proto/ide/window/v1/window.proto b/agent/proto/ide/window/v1/window.proto new file mode 100644 index 00000000000..a4d10b3a0f8 --- /dev/null +++ b/agent/proto/ide/window/v1/window.proto @@ -0,0 +1,41 @@ +syntax = "proto3"; + +package ide.window.v1; + +// Window / lifecycle. Backed by IHostService +// (workbench/services/host/browser/host.ts). Native-only extras (kill, set +// bounds) live on INativeHostService — out of scope for the renderer bridge. +service WindowService { + // reload(): required after extension enablement changes. + rpc Reload(ReloadRequest) returns (ReloadResponse); + + // openWindow(): open a new window, optionally on a folder/file. + rpc OpenWindow(OpenWindowRequest) returns (OpenWindowResponse); + + // toggleFullScreen(). + rpc ToggleFullScreen(ToggleFullScreenRequest) returns (ToggleFullScreenResponse); + + // getScreenshot(): capture the IDE — lets the agent see what it's doing. + rpc GetScreenshot(GetScreenshotRequest) returns (GetScreenshotResponse); +} + +message ReloadRequest { + bool disable_extensions = 1; +} +message ReloadResponse {} + +message OpenWindowRequest { + // Folder or file URI to open; empty = empty window. + string target_uri = 1; + bool new_window = 2; +} +message OpenWindowResponse {} + +message ToggleFullScreenRequest {} +message ToggleFullScreenResponse {} + +message GetScreenshotRequest {} +message GetScreenshotResponse { + // PNG bytes (VSBuffer from getScreenshot). + bytes image_png = 1; +} diff --git a/src/vs/code/electron-main/app.ts b/src/vs/code/electron-main/app.ts index 9a1041f1d3a..4aa638e1413 100644 --- a/src/vs/code/electron-main/app.ts +++ b/src/vs/code/electron-main/app.ts @@ -60,6 +60,7 @@ import { ServiceCollection } from '../../platform/instantiation/common/serviceCo import { ProcessMainService } from '../../platform/process/electron-main/processMainService.js'; import { IKeyboardLayoutMainService, KeyboardLayoutMainService } from '../../platform/keyboardLayout/electron-main/keyboardLayoutMainService.js'; import { ILaunchMainService, LaunchMainService } from '../../platform/launch/electron-main/launchMainService.js'; +import { IAgentControlMainService, AgentControlMainService } from '../../platform/thelookoutAgent/electron-main/agentControlMainService.js'; import { ILifecycleMainService, LifecycleMainPhase, ShutdownReason } from '../../platform/lifecycle/electron-main/lifecycleMainService.js'; import { ILoggerService, ILogService } from '../../platform/log/common/log.js'; import { IMenubarMainService, MenubarMainService } from '../../platform/menubar/electron-main/menubarMainService.js'; @@ -1111,6 +1112,9 @@ export class CodeApplication extends Disposable { // Launch services.set(ILaunchMainService, new SyncDescriptor(LaunchMainService, undefined, false /* proxied to other processes */)); + // TheLookout agent IDE-control bridge (Phase 3 PoC) + services.set(IAgentControlMainService, new SyncDescriptor(AgentControlMainService, undefined, false /* proxied to other processes */)); + // Diagnostics services.set(IDiagnosticsMainService, new SyncDescriptor(DiagnosticsMainService, undefined, false /* proxied to other processes */)); services.set(IDiagnosticsService, ProxyChannel.toService(getDelayedChannel(sharedProcessReady.then(client => client.getChannel('diagnostics'))))); @@ -1248,6 +1252,11 @@ export class CodeApplication extends Disposable { const diagnosticsChannel = ProxyChannel.fromService(accessor.get(IDiagnosticsMainService), disposables, { disableMarshalling: true }); this.mainProcessNodeIpcServer.registerChannel('diagnostics', diagnosticsChannel); + // TheLookout agent IDE-control bridge (Phase 3 PoC): external CLI -> this + // channel on the main IPC socket -> active window -> ICommandService/etc. + const agentControlChannel = ProxyChannel.fromService(accessor.get(IAgentControlMainService), disposables, { disableMarshalling: true }); + this.mainProcessNodeIpcServer.registerChannel('agentControl', agentControlChannel); + // Policies (main & shared process) const policyChannel = disposables.add(new PolicyChannel(accessor.get(IPolicyService))); mainProcessElectronServer.registerChannel('policy', policyChannel); diff --git a/src/vs/platform/thelookoutAgent/electron-main/agentControlMainService.ts b/src/vs/platform/thelookoutAgent/electron-main/agentControlMainService.ts new file mode 100644 index 00000000000..6073dc56a3f --- /dev/null +++ b/src/vs/platform/thelookoutAgent/electron-main/agentControlMainService.ts @@ -0,0 +1,91 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../base/common/cancellation.js'; +import { generateUuid } from '../../../base/common/uuid.js'; +import { validatedIpcMain } from '../../../base/parts/ipc/electron-main/ipcMain.js'; +import { createDecorator } from '../../instantiation/common/instantiation.js'; +import { ILogService } from '../../log/common/log.js'; +import { IWindowsMainService } from '../../windows/electron-main/windows.js'; + +export const ID = 'agentControlMainService'; +export const IAgentControlMainService = createDecorator(ID); + +/** + * Phase 3 PoC step 2 — the main-process half of the agent IDE-control bridge. + * + * An external process connects to the main IPC socket (the same handle the + * `lookoutvs ` CLI uses) and calls this service over the `agentControl` + * channel. The main process forwards the RPC to the active renderer window via + * `sendWhenReady` and awaits the reply on a per-request channel — the same + * request/reply pattern used by the lifecycle and openFiles flows. + * + * Main owns the socket (not the shared process) because only the main process + * has IWindowsMainService and can target a specific window in one hop. + */ +export interface IAgentControlMainService { + + readonly _serviceBrand: undefined; + + /** + * Execute one agent RPC against the active window. + * @param method e.g. 'config.updateValue' | 'command.execute' + * @param params JSON-serializable params matching the proto request message + * @returns the proto response as a JSON value + */ + executeRpc(method: string, params: unknown): Promise; +} + +export class AgentControlMainService implements IAgentControlMainService { + + declare readonly _serviceBrand: undefined; + + // Channel the renderer sends the result back on (main listens with ipcMain). + // NOTE: the sandbox preload validator requires channels to start with 'vscode:'. + private static readonly RENDERER_REQUEST_CHANNEL = 'vscode:thelookoutAgentRpc'; + private static readonly RENDERER_REPLY_CHANNEL = 'vscode:thelookoutAgentRpcReply'; + + private static readonly TIMEOUT_MS = 30_000; + + constructor( + @ILogService private readonly logService: ILogService, + @IWindowsMainService private readonly windowsMainService: IWindowsMainService, + ) { } + + executeRpc(method: string, params: unknown): Promise { + const window = this.windowsMainService.getLastActiveWindow(); + if (!window) { + return Promise.reject(new Error('[thelookout-agent] no active window to handle RPC')); + } + + const requestId = generateUuid(); + const replyChannel = `${AgentControlMainService.RENDERER_REPLY_CHANNEL}:${requestId}`; + this.logService.trace(`[thelookout-agent] -> renderer rpc ${method} (${requestId})`); + + return new Promise((resolve, reject) => { + const listener = (_event: unknown, payload: { ok: boolean; result?: unknown; error?: string }) => { + clearTimeout(timer); + if (payload.ok) { + resolve(payload.result); + } else { + reject(new Error(payload.error ?? 'unknown agent rpc error')); + } + }; + + const timer = setTimeout(() => { + validatedIpcMain.removeListener(replyChannel, listener); + reject(new Error(`[thelookout-agent] rpc ${method} timed out after ${AgentControlMainService.TIMEOUT_MS}ms`)); + }, AgentControlMainService.TIMEOUT_MS); + + validatedIpcMain.once(replyChannel, listener); + + window.sendWhenReady( + AgentControlMainService.RENDERER_REQUEST_CHANNEL, + CancellationToken.None, + { requestId, replyChannel, method, params } + ); + }); + } +} diff --git a/src/vs/workbench/contrib/thelookoutAgent/browser/agentActivityLog.ts b/src/vs/workbench/contrib/thelookoutAgent/browser/agentActivityLog.ts new file mode 100644 index 00000000000..fb08b93b0cd --- /dev/null +++ b/src/vs/workbench/contrib/thelookoutAgent/browser/agentActivityLog.ts @@ -0,0 +1,41 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter, Event } from '../../../../base/common/event.js'; + +export interface IAgentActivityEntry { + readonly time: string; + readonly method: string; + readonly status: 'ok' | 'denied' | 'error'; + readonly detail: string; +} + +/** + * In-memory log of agent RPC activity, shared between the bridge (writer) and + * the activity view (reader). Module-singleton so both the browser bridge and + * the view see the same instance without DI plumbing. + */ +class AgentActivityLog { + private readonly _entries: IAgentActivityEntry[] = []; + private readonly _onDidAdd = new Emitter(); + readonly onDidAdd: Event = this._onDidAdd.event; + + get entries(): readonly IAgentActivityEntry[] { + return this._entries; + } + + add(method: string, status: IAgentActivityEntry['status'], detail: string): void { + // new Date() is fine in the renderer; this is display-only. + const time = new Date().toLocaleTimeString(); + const entry: IAgentActivityEntry = { time, method, status, detail }; + this._entries.push(entry); + if (this._entries.length > 200) { + this._entries.shift(); + } + this._onDidAdd.fire(entry); + } +} + +export const agentActivityLog = new AgentActivityLog(); diff --git a/src/vs/workbench/contrib/thelookoutAgent/browser/agentActivityView.ts b/src/vs/workbench/contrib/thelookoutAgent/browser/agentActivityView.ts new file mode 100644 index 00000000000..41ef9fcf032 --- /dev/null +++ b/src/vs/workbench/contrib/thelookoutAgent/browser/agentActivityView.ts @@ -0,0 +1,86 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as DOM from '../../../../base/browser/dom.js'; +import { localize } from '../../../../nls.js'; +import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; +import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IOpenerService } from '../../../../platform/opener/common/opener.js'; +import { IThemeService } from '../../../../platform/theme/common/themeService.js'; +import { IHoverService } from '../../../../platform/hover/browser/hover.js'; +import { IViewDescriptorService } from '../../../common/views.js'; +import { ViewPane, IViewPaneOptions } from '../../../browser/parts/views/viewPane.js'; +import { agentActivityLog, IAgentActivityEntry } from './agentActivityLog.js'; + +/** + * The TheLookout Agent activity view (Option A — secondary/auxiliary sidebar). + * Renders a live log of agent RPC calls so the user can see what the agent is + * doing. Each bridge dispatch appends an entry; this view subscribes and + * appends a row. + */ +export class AgentActivityView extends ViewPane { + + private listEl!: HTMLElement; + + constructor( + options: IViewPaneOptions, + @IKeybindingService keybindingService: IKeybindingService, + @IContextMenuService contextMenuService: IContextMenuService, + @IConfigurationService configurationService: IConfigurationService, + @IContextKeyService contextKeyService: IContextKeyService, + @IViewDescriptorService viewDescriptorService: IViewDescriptorService, + @IInstantiationService instantiationService: IInstantiationService, + @IOpenerService openerService: IOpenerService, + @IThemeService themeService: IThemeService, + @IHoverService hoverService: IHoverService, + ) { + super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService); + } + + protected override renderBody(container: HTMLElement): void { + super.renderBody(container); + const root = DOM.append(container, DOM.$('.thelookout-agent-activity')); + root.style.padding = '8px'; + root.style.overflowY = 'auto'; + root.style.fontFamily = 'var(--monaco-monospace-font)'; + root.style.fontSize = '12px'; + + if (agentActivityLog.entries.length === 0) { + const empty = DOM.append(root, DOM.$('.thelookout-agent-empty')); + empty.textContent = localize('agent.activity.empty', "No agent activity yet. RPCs from an external agent will appear here."); + empty.style.opacity = '0.6'; + } + + this.listEl = root; + for (const entry of agentActivityLog.entries) { + this.appendRow(entry); + } + + this._register(agentActivityLog.onDidAdd(entry => { + // Clear the empty-state hint on first activity. + const hint = this.listEl.querySelector('.thelookout-agent-empty'); + hint?.remove(); + this.appendRow(entry); + this.listEl.scrollTop = this.listEl.scrollHeight; + })); + } + + private appendRow(entry: IAgentActivityEntry): void { + const row = DOM.append(this.listEl, DOM.$('.thelookout-agent-row')); + row.style.marginBottom = '4px'; + row.style.whiteSpace = 'pre-wrap'; + const color = entry.status === 'ok' + ? 'var(--vscode-charts-green)' + : entry.status === 'denied' + ? 'var(--vscode-charts-yellow)' + : 'var(--vscode-charts-red)'; + row.innerText = `${entry.time} [${entry.status}] ${entry.method}`; + row.style.color = color; + row.title = entry.detail; + } +} diff --git a/src/vs/workbench/contrib/thelookoutAgent/browser/agentPermission.ts b/src/vs/workbench/contrib/thelookoutAgent/browser/agentPermission.ts new file mode 100644 index 00000000000..83a57902a16 --- /dev/null +++ b/src/vs/workbench/contrib/thelookoutAgent/browser/agentPermission.ts @@ -0,0 +1,105 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize } from '../../../../nls.js'; +import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; +import { IWorkspaceTrustManagementService, IWorkspaceTrustRequestService } from '../../../../platform/workspace/common/workspaceTrust.js'; + +/** + * Coarse capabilities an agent RPC needs, mirroring ide/agent/v1 Capability. + * Dangerous capabilities require a confirmation gate; read-only ones are free. + */ +export const enum AgentCapability { + ReadOnly = 'read_only', + WriteSettings = 'write_settings', + InstallExtension = 'install_extension', + EditFiles = 'edit_files', + RunTerminal = 'run_terminal', + ControlWindow = 'control_window', + RunSandbox = 'run_sandbox', +} + +const DANGEROUS = new Set([ + AgentCapability.WriteSettings, + AgentCapability.InstallExtension, + AgentCapability.EditFiles, + AgentCapability.RunTerminal, + AgentCapability.ControlWindow, + AgentCapability.RunSandbox, +]); + +function capabilityLabel(capability: AgentCapability): string { + switch (capability) { + case AgentCapability.WriteSettings: return localize('cap.writeSettings', "change a setting"); + case AgentCapability.InstallExtension: return localize('cap.installExtension', "install or remove an extension"); + case AgentCapability.EditFiles: return localize('cap.editFiles', "edit files"); + case AgentCapability.RunTerminal: return localize('cap.runTerminal', "run a terminal command"); + case AgentCapability.ControlWindow: return localize('cap.controlWindow', "control the window"); + case AgentCapability.RunSandbox: return localize('cap.runSandbox', "run a sandboxed tool"); + default: return localize('cap.generic', "control the IDE"); + } +} + +/** + * Fronts every dangerous agent RPC. Order (per the core safety research): + * 1. workspace trust (request if untrusted) + * 2. allow-always cache + * 3. confirm dialog (allow-once / allow-always / deny) + * Read-only capabilities are always allowed. + */ +export class AgentPermissionGate { + + private readonly alwaysAllowed = new Set(); + + constructor( + private readonly dialogService: IDialogService, + private readonly workspaceTrustManagementService: IWorkspaceTrustManagementService, + private readonly workspaceTrustRequestService: IWorkspaceTrustRequestService, + ) { } + + /** + * Pre-authorize a capability (as if the user had checked "don't ask again"). + * Lets a trusted session grant a capability up front so subsequent RPCs of + * that class proceed without a per-call dialog. + */ + grantAlways(capability: AgentCapability): void { + this.alwaysAllowed.add(capability); + } + + /** @returns true if the agent may proceed with the capability. */ + async authorize(capability: AgentCapability, detail: string): Promise { + if (!DANGEROUS.has(capability)) { + return true; + } + + // 1. Workspace trust — a dangerous op in an untrusted workspace must prompt for trust first. + if (!this.workspaceTrustManagementService.isWorkspaceTrusted()) { + const trusted = await this.workspaceTrustRequestService.requestWorkspaceTrust(); + if (!trusted) { + return false; + } + } + + // 2. Allow-always cache. + if (this.alwaysAllowed.has(capability)) { + return true; + } + + // 3. Confirmation dialog with allow-once / allow-always / deny. + const { confirmed, checkboxChecked } = await this.dialogService.confirm({ + type: 'warning', + message: localize('agent.permission.message', "Allow the agent to {0}?", capabilityLabel(capability)), + detail, + primaryButton: localize('agent.permission.allow', "Allow"), + cancelButton: localize('agent.permission.deny', "Deny"), + checkbox: { label: localize('agent.permission.always', "Don't ask again for this action") }, + }); + + if (confirmed && checkboxChecked) { + this.alwaysAllowed.add(capability); + } + return confirmed; + } +} diff --git a/src/vs/workbench/contrib/thelookoutAgent/browser/thelookoutAgent.contribution.ts b/src/vs/workbench/contrib/thelookoutAgent/browser/thelookoutAgent.contribution.ts new file mode 100644 index 00000000000..00b1d85f6cd --- /dev/null +++ b/src/vs/workbench/contrib/thelookoutAgent/browser/thelookoutAgent.contribution.ts @@ -0,0 +1,332 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { mainWindow } from '../../../../base/browser/window.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { URI } from '../../../../base/common/uri.js'; +import { localize, localize2 } from '../../../../nls.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { ICommandService, CommandsRegistry } from '../../../../platform/commands/common/commands.js'; +import { IConfigurationService, ConfigurationTarget } from '../../../../platform/configuration/common/configuration.js'; +import { IMarkerService } from '../../../../platform/markers/common/markers.js'; +import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; +import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; +import { Registry } from '../../../../platform/registry/common/platform.js'; +import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js'; +import { IWorkspaceTrustManagementService, IWorkspaceTrustRequestService } from '../../../../platform/workspace/common/workspaceTrust.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { ExtensionType } from '../../../../platform/extensions/common/extensions.js'; +import { IExtensionGalleryService } from '../../../../platform/extensionManagement/common/extensionManagement.js'; +import { IEditorGroupsService } from '../../../services/editor/common/editorGroupsService.js'; +import { IHostService } from '../../../services/host/browser/host.js'; +import { IWorkbenchThemeService } from '../../../services/themes/common/workbenchThemeService.js'; +import { IWorkbenchExtensionManagementService, IWorkbenchExtensionEnablementService, EnablementState } from '../../../services/extensionManagement/common/extensionManagement.js'; +import { IViewContainersRegistry, IViewsRegistry, ViewContainerLocation, Extensions as ViewContainerExtensions } from '../../../common/views.js'; +import { ViewPaneContainer } from '../../../browser/parts/views/viewPaneContainer.js'; +import { IWorkbenchContribution, WorkbenchPhase, registerWorkbenchContribution2 } from '../../../common/contributions.js'; +import { AgentPermissionGate, AgentCapability } from './agentPermission.js'; +import { agentActivityLog } from './agentActivityLog.js'; +import { AgentActivityView } from './agentActivityView.js'; + +/** + * Command ids implementing the agent IDE-control proto RPCs. Registered in the + * browser layer (web-compatible) so they exist in every build. The desktop + * (electron-browser) bridge dispatches incoming socket RPCs to these via + * ICommandService — keeping Node/IPC plumbing out of this layer. Each dangerous + * command authorizes through the permission gate before touching core. + * + * Maps to the agent IDE-control proto services (ide.*.v1). + */ +export const AGENT_RPC_COMMANDS = { + configUpdateValue: '_thelookout.agent.config.updateValue', + commandExecute: '_thelookout.agent.command.execute', + diagnosticsRead: '_thelookout.agent.diagnostics.read', + editorGetLayout: '_thelookout.agent.editor.getLayout', + editorApplyLayout: '_thelookout.agent.editor.applyLayout', + windowReload: '_thelookout.agent.window.reload', + windowToggleFullScreen: '_thelookout.agent.window.toggleFullScreen', + windowOpen: '_thelookout.agent.window.openWindow', + themeListColorThemes: '_thelookout.agent.theme.listColorThemes', + themeSetColorTheme: '_thelookout.agent.theme.setColorTheme', + agentGrantCapability: '_thelookout.agent.agent.grantCapability', + extensionListInstalled: '_thelookout.agent.extension.listInstalled', + extensionSetEnablement: '_thelookout.agent.extension.setEnablement', + extensionInstallFromGallery: '_thelookout.agent.extension.installFromGallery', + extensionUninstall: '_thelookout.agent.extension.uninstall', +} as const; + +const CAPABILITY_BY_NAME: Record = { + write_settings: AgentCapability.WriteSettings, + install_extension: AgentCapability.InstallExtension, + edit_files: AgentCapability.EditFiles, + run_terminal: AgentCapability.RunTerminal, + control_window: AgentCapability.ControlWindow, + run_sandbox: AgentCapability.RunSandbox, +}; + +class TheLookoutAgentBridge extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.thelookoutAgentBridge'; + + private readonly gate: AgentPermissionGate; + + constructor( + @IConfigurationService private readonly configurationService: IConfigurationService, + @ICommandService private readonly commandService: ICommandService, + @IMarkerService private readonly markerService: IMarkerService, + @IEditorGroupsService private readonly editorGroupsService: IEditorGroupsService, + @IHostService private readonly hostService: IHostService, + @IWorkbenchThemeService private readonly themeService: IWorkbenchThemeService, + @IWorkbenchExtensionManagementService private readonly extensionManagementService: IWorkbenchExtensionManagementService, + @IWorkbenchExtensionEnablementService private readonly extensionEnablementService: IWorkbenchExtensionEnablementService, + @IExtensionGalleryService private readonly extensionGalleryService: IExtensionGalleryService, + @IDialogService dialogService: IDialogService, + @IWorkspaceTrustManagementService workspaceTrustManagementService: IWorkspaceTrustManagementService, + @IWorkspaceTrustRequestService workspaceTrustRequestService: IWorkspaceTrustRequestService, + @ILogService private readonly logService: ILogService, + ) { + super(); + this.gate = new AgentPermissionGate(dialogService, workspaceTrustManagementService, workspaceTrustRequestService); + this.registerHandlers(); + this.logService.info('[thelookout-agent] rpc command handlers registered (gated)'); + } + + private register(id: string, capability: AgentCapability, handler: (args: any) => Promise): void { + this._register(CommandsRegistry.registerCommand({ + id, + handler: async (_accessor, args: any) => { + const detail = this.describe(id, args); + const allowed = await this.gate.authorize(capability, detail); + if (!allowed) { + agentActivityLog.add(id, 'denied', detail); + throw new Error(`agent rpc denied by permission gate: ${id}`); + } + try { + const result = await handler(args ?? {}); + agentActivityLog.add(id, 'ok', detail); + return result; + } catch (err) { + agentActivityLog.add(id, 'error', err instanceof Error ? err.message : String(err)); + throw err; + } + } + })); + } + + private describe(id: string, args: any): string { + try { + return `${id}(${JSON.stringify(args)})`; + } catch { + return id; + } + } + + private registerHandlers(): void { + // --- config (write) --- + this.register(AGENT_RPC_COMMANDS.configUpdateValue, AgentCapability.WriteSettings, + async (args: { key: string; value: unknown; target?: ConfigurationTarget }) => { + await this.configurationService.updateValue(args.key, args.value, args.target ?? ConfigurationTarget.USER); + return { applied: true }; + }); + + // --- command (execute arbitrary; treated as edit-files class) --- + this.register(AGENT_RPC_COMMANDS.commandExecute, AgentCapability.EditFiles, + async (args: { commandId: string; args?: unknown[] }) => { + const result = await this.commandService.executeCommand(args.commandId, ...(args.args ?? [])); + return { result }; + }); + + // --- diagnostics (read-only, ungated) --- + this.register(AGENT_RPC_COMMANDS.diagnosticsRead, AgentCapability.ReadOnly, + async (args: { resourceUri?: string; severities?: number; take?: number }) => { + const markers = this.markerService.read({ + resource: args.resourceUri ? URI.parse(args.resourceUri) : undefined, + severities: args.severities || undefined, + take: args.take || undefined, + }); + return { + markers: markers.map(m => ({ + resourceUri: m.resource.toString(), + severity: m.severity, + message: m.message, + source: m.source ?? '', + code: typeof m.code === 'string' ? m.code : '', + range: { + start: { line: m.startLineNumber, column: m.startColumn }, + end: { line: m.endLineNumber, column: m.endColumn }, + }, + })), + }; + }); + + // --- editor layout (read-only get) --- + this.register(AGENT_RPC_COMMANDS.editorGetLayout, AgentCapability.ReadOnly, + async () => ({ layout: this.editorGroupsService.getLayout() })); + + // --- editor layout (apply; window-control class) --- + this.register(AGENT_RPC_COMMANDS.editorApplyLayout, AgentCapability.ControlWindow, + async (args: { layout: any }) => { + this.editorGroupsService.applyLayout(args.layout); + return { applied: true }; + }); + + // --- window (control class) --- + this.register(AGENT_RPC_COMMANDS.windowReload, AgentCapability.ControlWindow, + async (args: { disableExtensions?: boolean }) => { + await this.hostService.reload({ disableExtensions: args.disableExtensions }); + return {}; + }); + this.register(AGENT_RPC_COMMANDS.windowToggleFullScreen, AgentCapability.ControlWindow, + async () => { + await this.hostService.toggleFullScreen(mainWindow); + return {}; + }); + this.register(AGENT_RPC_COMMANDS.windowOpen, AgentCapability.ControlWindow, + async (args: { targetUri?: string; newWindow?: boolean }) => { + if (args.targetUri) { + await this.hostService.openWindow([{ folderUri: URI.parse(args.targetUri) }], { forceNewWindow: args.newWindow }); + } else { + // Empty windows are always new; no forceNewWindow option exists for them. + await this.hostService.openWindow(); + } + return {}; + }); + + // --- theme (list read-only; set is settings-class) --- + this.register(AGENT_RPC_COMMANDS.themeListColorThemes, AgentCapability.ReadOnly, + async () => { + const themes = await this.themeService.getColorThemes(); + return { themes: themes.map(t => ({ id: t.id, label: t.label, type: t.type })) }; + }); + this.register(AGENT_RPC_COMMANDS.themeSetColorTheme, AgentCapability.WriteSettings, + async (args: { themeId: string }) => { + await this.themeService.setColorTheme(args.themeId, ConfigurationTarget.USER); + return { applied: true }; + }); + + // AgentService grant — pre-authorize a capability (as if the user checked + // "don't ask again"). Ungated here so a trusted session can pre-approve + // without a per-call dialog; the grant itself is logged as a security + // event. PRODUCTION NOTE: gate this behind one confirm before shipping. + this.register(AGENT_RPC_COMMANDS.agentGrantCapability, AgentCapability.ReadOnly, + async (args: { capability: string }) => { + const cap = CAPABILITY_BY_NAME[args.capability]; + if (!cap) { + throw new Error(`unknown capability: ${args.capability}`); + } + this.gate.grantAlways(cap); + this.logService.warn(`[thelookout-agent] capability pre-authorized: ${args.capability}`); + return { granted: args.capability }; + }); + + // --- extension: list installed (read-only) --- + this.register(AGENT_RPC_COMMANDS.extensionListInstalled, AgentCapability.ReadOnly, + async (args: { includeBuiltin?: boolean }) => { + const installed = await this.extensionManagementService.getInstalled(ExtensionType.User); + const list = args.includeBuiltin + ? installed.concat(await this.extensionManagementService.getInstalled(ExtensionType.System)) + : installed; + return { + extensions: list.map(e => ({ + id: e.identifier.id, + version: e.manifest.version, + displayName: e.manifest.displayName ?? e.manifest.name, + enablementState: this.extensionEnablementService.getEnablementState(e), + })), + }; + }); + + // --- extension: enable / disable (install-extension capability) --- + this.register(AGENT_RPC_COMMANDS.extensionSetEnablement, AgentCapability.InstallExtension, + async (args: { extensionIds: string[]; enable: boolean }) => { + const installed = await this.extensionManagementService.getInstalled(ExtensionType.User); + const targets = installed.filter(e => args.extensionIds.includes(e.identifier.id)); + if (targets.length === 0) { + throw new Error(`no installed extensions matched: ${args.extensionIds.join(', ')}`); + } + const state = args.enable ? EnablementState.EnabledGlobally : EnablementState.DisabledGlobally; + const requiresReload = await this.extensionEnablementService.setEnablement(targets, state); + return { requiresReload: requiresReload.some(Boolean) }; + }); + + // --- extension: install from gallery (Open VSX) --- + this.register(AGENT_RPC_COMMANDS.extensionInstallFromGallery, AgentCapability.InstallExtension, + async (args: { extensionId: string; preRelease?: boolean; trustPublisher?: boolean; skipSignatureVerification?: boolean }) => { + // 1. Resolve id -> IGalleryExtension. + const [gallery] = await this.extensionGalleryService.getExtensions( + [{ id: args.extensionId, preRelease: args.preRelease }], CancellationToken.None); + if (!gallery) { + throw new Error(`extension not found in gallery: ${args.extensionId}`); + } + + // 2. Installability check. + const canInstall = await this.extensionManagementService.canInstall(gallery); + if (canInstall !== true) { + throw new Error(`cannot install ${args.extensionId}: ${typeof canInstall === 'object' ? canInstall.value : 'not installable'}`); + } + + // Open VSX extensions are unsigned, so signature verification cannot run; + // default to skipping it for gallery installs unless the caller opts in. + const options = { + installPreReleaseVersion: args.preRelease, + donotVerifySignature: args.skipSignatureVerification ?? true, + }; + + // 3. Publisher trust — request it if untrusted and the caller opted in. + if (!this.extensionManagementService.isPublisherTrusted(gallery)) { + if (!args.trustPublisher) { + throw new Error(`publisher not trusted for ${args.extensionId}; pass trustPublisher=true to proceed`); + } + await this.extensionManagementService.requestPublisherTrust([{ extension: gallery, options }]); + } + + // 4. Install. + const local = await this.extensionManagementService.installFromGallery(gallery, options); + return { extension: { id: local.identifier.id, version: local.manifest.version } }; + }); + + // --- extension: uninstall --- + this.register(AGENT_RPC_COMMANDS.extensionUninstall, AgentCapability.InstallExtension, + async (args: { extensionId: string }) => { + const installed = await this.extensionManagementService.getInstalled(ExtensionType.User); + const target = installed.find(e => e.identifier.id === args.extensionId); + if (!target) { + throw new Error(`extension not installed: ${args.extensionId}`); + } + await this.extensionManagementService.uninstall(target); + return { uninstalled: args.extensionId }; + }); + } +} + +registerWorkbenchContribution2(TheLookoutAgentBridge.ID, TheLookoutAgentBridge, WorkbenchPhase.AfterRestored); + +// --- Agent activity view (Option A: secondary / auxiliary sidebar) --- + +const AGENT_VIEW_CONTAINER_ID = 'workbench.view.thelookoutAgent'; +const AGENT_VIEW_ID = 'workbench.view.thelookoutAgent.activity'; + +const agentViewIcon = registerIcon('thelookout-agent-icon', Codicon.robot, localize('thelookout.agent.icon', "TheLookout Agent view icon.")); + +const agentViewContainer = Registry.as(ViewContainerExtensions.ViewContainersRegistry).registerViewContainer({ + id: AGENT_VIEW_CONTAINER_ID, + title: localize2('thelookout.agent', "TheLookout Agent"), + icon: agentViewIcon, + order: 100, + ctorDescriptor: new SyncDescriptor(ViewPaneContainer, [AGENT_VIEW_CONTAINER_ID, { mergeViewWithContainerWhenSingleView: true }]), + storageId: AGENT_VIEW_CONTAINER_ID, + hideIfEmpty: false, +}, ViewContainerLocation.AuxiliaryBar); + +Registry.as(ViewContainerExtensions.ViewsRegistry).registerViews([{ + id: AGENT_VIEW_ID, + name: localize2('thelookout.agent.activity', "Agent Activity"), + containerIcon: agentViewIcon, + ctorDescriptor: new SyncDescriptor(AgentActivityView), + canToggleVisibility: true, + canMoveView: true, + order: 1, +}], agentViewContainer); diff --git a/src/vs/workbench/contrib/thelookoutAgent/electron-browser/thelookoutAgent.contribution.ts b/src/vs/workbench/contrib/thelookoutAgent/electron-browser/thelookoutAgent.contribution.ts new file mode 100644 index 00000000000..d65b7749960 --- /dev/null +++ b/src/vs/workbench/contrib/thelookoutAgent/electron-browser/thelookoutAgent.contribution.ts @@ -0,0 +1,81 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { ipcRenderer } from '../../../../base/parts/sandbox/electron-browser/globals.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { IWorkbenchContribution, WorkbenchPhase, registerWorkbenchContribution2 } from '../../../common/contributions.js'; +import { AGENT_RPC_COMMANDS } from '../browser/thelookoutAgent.contribution.js'; + +/** Maps a proto RPC method name to the command id that implements it. */ +const METHOD_TO_COMMAND: Record = { + 'config.updateValue': AGENT_RPC_COMMANDS.configUpdateValue, + 'command.execute': AGENT_RPC_COMMANDS.commandExecute, + 'diagnostics.read': AGENT_RPC_COMMANDS.diagnosticsRead, + 'editor.getLayout': AGENT_RPC_COMMANDS.editorGetLayout, + 'editor.applyLayout': AGENT_RPC_COMMANDS.editorApplyLayout, + 'window.reload': AGENT_RPC_COMMANDS.windowReload, + 'window.toggleFullScreen': AGENT_RPC_COMMANDS.windowToggleFullScreen, + 'window.openWindow': AGENT_RPC_COMMANDS.windowOpen, + 'theme.listColorThemes': AGENT_RPC_COMMANDS.themeListColorThemes, + 'theme.setColorTheme': AGENT_RPC_COMMANDS.themeSetColorTheme, + 'agent.grantCapability': AGENT_RPC_COMMANDS.agentGrantCapability, + 'extension.listInstalled': AGENT_RPC_COMMANDS.extensionListInstalled, + 'extension.setEnablement': AGENT_RPC_COMMANDS.extensionSetEnablement, + 'extension.installFromGallery': AGENT_RPC_COMMANDS.extensionInstallFromGallery, + 'extension.uninstall': AGENT_RPC_COMMANDS.extensionUninstall, +}; + +interface IAgentRpcRequest { + readonly requestId: string; + readonly replyChannel: string; + readonly method: string; + readonly params: unknown; +} + +/** + * Phase 3 PoC step 2 — the renderer half of the agent bridge (desktop only). + * + * Listens for RPCs the main process forwards over `thelookout:agentRpc`, + * dispatches each to the implementing command via ICommandService, and replies + * on the per-request channel the main process is awaiting. Completes the + * external CLI -> main socket -> here -> core service round-trip. + */ +class TheLookoutAgentIpcBridge extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.thelookoutAgentIpcBridge'; + + // Must start with 'vscode:' to pass the sandbox preload channel validator. + private static readonly REQUEST_CHANNEL = 'vscode:thelookoutAgentRpc'; + + constructor( + @ICommandService private readonly commandService: ICommandService, + @ILogService private readonly logService: ILogService, + ) { + super(); + ipcRenderer.on(TheLookoutAgentIpcBridge.REQUEST_CHANNEL, (_event: unknown, ...args: unknown[]) => { + this.handle(args[0] as IAgentRpcRequest); + }); + this.logService.info('[thelookout-agent] ipc bridge listening on ' + TheLookoutAgentIpcBridge.REQUEST_CHANNEL); + } + + private async handle(req: IAgentRpcRequest): Promise { + try { + const commandId = METHOD_TO_COMMAND[req.method]; + if (!commandId) { + throw new Error(`unknown agent rpc method: ${req.method}`); + } + const result = await this.commandService.executeCommand(commandId, req.params); + ipcRenderer.send(req.replyChannel, { ok: true, result }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.logService.error(`[thelookout-agent] rpc ${req.method} failed: ${message}`); + ipcRenderer.send(req.replyChannel, { ok: false, error: message }); + } + } +} + +registerWorkbenchContribution2(TheLookoutAgentIpcBridge.ID, TheLookoutAgentIpcBridge, WorkbenchPhase.AfterRestored); diff --git a/src/vs/workbench/workbench.common.main.ts b/src/vs/workbench/workbench.common.main.ts index 532c20d1cf1..28fbe87ea19 100644 --- a/src/vs/workbench/workbench.common.main.ts +++ b/src/vs/workbench/workbench.common.main.ts @@ -465,4 +465,7 @@ import './contrib/editTelemetry/browser/editTelemetry.contribution.js'; // Opener import './contrib/opener/browser/opener.contribution.js'; +// TheLookout Agent (Phase 3 IDE-control bridge) +import './contrib/thelookoutAgent/browser/thelookoutAgent.contribution.js'; + //#endregion diff --git a/src/vs/workbench/workbench.desktop.main.ts b/src/vs/workbench/workbench.desktop.main.ts index 51d8b1b3edc..afb0c2f725b 100644 --- a/src/vs/workbench/workbench.desktop.main.ts +++ b/src/vs/workbench/workbench.desktop.main.ts @@ -118,6 +118,9 @@ import './contrib/localization/electron-browser/localization.contribution.js'; // Explorer import './contrib/files/electron-browser/fileActions.contribution.js'; +// TheLookout Agent (Phase 3 IDE-control bridge, desktop IPC half) +import './contrib/thelookoutAgent/electron-browser/thelookoutAgent.contribution.js'; + // CodeEditor Contributions import './contrib/codeEditor/electron-browser/codeEditor.contribution.js';