Verified Commit 40fc83ba authored by Karl Jamoralin's avatar Karl Jamoralin Committed by GitLab
Browse files

feat(sandboxing): wire LS sandboxing feature into VS Code

parent d9c27c95
Loading
Loading
Loading
Loading
+6 −0
Original line number Diff line number Diff line
@@ -1015,6 +1015,12 @@
            ],
            "default": "foreground",
            "description": "What should happen when Duo Agent Platform edits a file?"
          },
          "gitlab.duoAgentPlatform.sandbox.enabled": {
            "description": "Enable Sandboxing",
            "type": "boolean",
            "order": 8,
            "default": false
          }
        }
      },
+3 −0
Original line number Diff line number Diff line
@@ -266,6 +266,9 @@ describe('LanguageClientWrapper', () => {
              agentPlatform: {
                enabled: true,
              },
              sandbox: {
                enabled: false,
              },
            },
            duoChat: {
              enabled: true,
+3 −0
Original line number Diff line number Diff line
@@ -358,6 +358,9 @@ export class LanguageClientWrapperImpl implements LanguageClientWrapper {
          enabled: extensionConfiguration.duo.agentPlatform.enabled,
          defaultNamespace: extensionConfiguration.duo.agentPlatform.defaultNamespace,
        },
        sandbox: {
          enabled: extensionConfiguration.duo.sandbox.enabled,
        },
      },
      duoChat: {
        enabled: duoChatConfiguration.enabled,
+7 −0
Original line number Diff line number Diff line
@@ -20,6 +20,7 @@ import { diagnosticsCommand } from './diagnostics/diagnostics_command';
import { DiagnosticsService } from './diagnostics/diagnostics_service';
import { DiagnosticsDocumentProvider } from './diagnostics/diagnostics_document_provider';
import { duoTutorial } from './code_suggestions/commands/duo_tutorial';
import { SandboxWarningProvider } from './sandbox/sandbox_warning_provider';

export const activateCommon = async (
  context: vscode.ExtensionContext,
@@ -55,6 +56,12 @@ export const activateCommon = async (
    context.subscriptions.push(vscode.commands.registerCommand(cmdName, cmd));
  });

  if (languageServerFeatureStateProvider) {
    const sandboxWarningProvider = new SandboxWarningProvider(languageServerFeatureStateProvider);
    sandboxWarningProvider.start();
    context.subscriptions.push(sandboxWarningProvider);
  }

  await activateChat(
    context,
    container.gitLabPlatformManager,
+182 −0
Original line number Diff line number Diff line
import * as vscode from 'vscode';
import {
  AGENT_PLATFORM,
  AGENTIC_CHAT,
  AUTHENTICATION,
  CHAT,
  CHAT_TERMINAL_CONTEXT,
  CODE_SUGGESTIONS,
  FLOWS,
  SANDBOX,
  FeatureState,
  FeatureStateCheck,
} from '@gitlab-org/gitlab-lsp';
import { createFakePartial } from '../test_utils/create_fake_partial';
import { createFakeWorkspaceConfiguration } from '../test_utils/vscode_fakes';
import {
  AllFeaturesState,
  LanguageServerFeatureStateProvider,
} from '../language_server/language_server_feature_state_provider';
import { SandboxWarningProvider } from './sandbox_warning_provider';

const SANDBOX_UNSUPPORTED_PLATFORM = 'sandbox-unsupported-platform';
const SANDBOX_MISSING_DEPENDENCIES = 'sandbox-missing-dependencies';

const emptyChecks = (): FeatureState => ({ featureId: SANDBOX, engagedChecks: [], allChecks: [] });

const buildState = (sandbox: FeatureState): AllFeaturesState =>
  createFakePartial<AllFeaturesState>({
    [AUTHENTICATION]: createFakePartial<FeatureState>({}),
    [CODE_SUGGESTIONS]: createFakePartial<FeatureState>({}),
    [CHAT]: createFakePartial<FeatureState>({}),
    [CHAT_TERMINAL_CONTEXT]: createFakePartial<FeatureState>({}),
    [AGENTIC_CHAT]: createFakePartial<FeatureState>({}),
    [AGENT_PLATFORM]: createFakePartial<FeatureState>({}),
    [FLOWS]: createFakePartial<FeatureState>({}),
    [SANDBOX]: sandbox,
  });

describe('SandboxWarningProvider', () => {
  let listener: ((states: AllFeaturesState) => void) | undefined;
  let stateProvider: LanguageServerFeatureStateProvider;
  let provider: SandboxWarningProvider;

  const triggerState = (sandbox: FeatureState) => {
    listener?.(buildState(sandbox));
  };

  const showWarningMessage = () => jest.mocked(vscode.window.showWarningMessage);

  beforeEach(() => {
    listener = undefined;
    stateProvider = createFakePartial<LanguageServerFeatureStateProvider>({
      onChange: jest.fn(cb => {
        listener = cb;
        return new vscode.Disposable(() => {});
      }),
    });

    jest
      .mocked(vscode.workspace.getConfiguration)
      .mockReturnValue(createFakeWorkspaceConfiguration({ enabled: true }));

    jest.mocked(vscode.window.showWarningMessage).mockResolvedValue(undefined as never);

    provider = new SandboxWarningProvider(stateProvider);
    provider.start();
  });

  afterEach(() => {
    provider.dispose();
  });

  it('does not warn when no sandbox checks are engaged', () => {
    triggerState(emptyChecks());
    expect(showWarningMessage()).not.toHaveBeenCalled();
  });

  it('warns once when the platform is unsupported', () => {
    const check = createFakePartial<FeatureStateCheck<typeof SANDBOX_UNSUPPORTED_PLATFORM>>({
      checkId: SANDBOX_UNSUPPORTED_PLATFORM,
      engaged: true,
      context: { platform: 'win32' } as never,
    });
    triggerState({ featureId: SANDBOX, engagedChecks: [check], allChecks: [check] });
    triggerState({ featureId: SANDBOX, engagedChecks: [check], allChecks: [check] });

    expect(showWarningMessage()).toHaveBeenCalledTimes(1);
    expect(showWarningMessage()).toHaveBeenCalledWith(
      expect.stringContaining('win32 is not supported'),
      'Open Settings',
      'Disable Sandboxing',
    );
  });

  const buildMissingCheck = (deps: string[]) =>
    createFakePartial<FeatureStateCheck<typeof SANDBOX_MISSING_DEPENDENCIES>>({
      checkId: SANDBOX_MISSING_DEPENDENCIES,
      engaged: true,
      context: {
        missingDependencies: deps.map(name => ({ name, installHint: `brew install ${name}` })),
      } as never,
    });

  it("formats an 'unsupported' platform value as 'this platform'", () => {
    const check = createFakePartial<FeatureStateCheck<typeof SANDBOX_UNSUPPORTED_PLATFORM>>({
      checkId: SANDBOX_UNSUPPORTED_PLATFORM,
      engaged: true,
      context: { platform: 'unsupported' } as never,
    });
    triggerState({ featureId: SANDBOX, engagedChecks: [check], allChecks: [check] });

    expect(showWarningMessage()).toHaveBeenCalledWith(
      expect.stringContaining('this platform is not supported'),
      'Open Settings',
      'Disable Sandboxing',
    );
    expect(showWarningMessage().mock.calls[0][0]).not.toContain('unsupported is not supported');
  });

  it('warns again when the missing-dependency set changes', () => {
    const firstCheck = buildMissingCheck(['bwrap']);
    triggerState({ featureId: SANDBOX, engagedChecks: [firstCheck], allChecks: [firstCheck] });
    const secondCheck = buildMissingCheck(['bwrap', 'newuidmap']);
    triggerState({ featureId: SANDBOX, engagedChecks: [secondCheck], allChecks: [secondCheck] });

    expect(showWarningMessage()).toHaveBeenCalledTimes(2);
    expect(showWarningMessage().mock.calls[0][0]).toContain('bwrap (brew install bwrap)');
    expect(showWarningMessage().mock.calls[1][0]).toContain('newuidmap');
  });

  it('does not re-warn when the same dependency set arrives in a different order', () => {
    const firstCheck = buildMissingCheck(['bwrap', 'newuidmap']);
    triggerState({ featureId: SANDBOX, engagedChecks: [firstCheck], allChecks: [firstCheck] });
    const reorderedCheck = buildMissingCheck(['newuidmap', 'bwrap']);
    triggerState({
      featureId: SANDBOX,
      engagedChecks: [reorderedCheck],
      allChecks: [reorderedCheck],
    });

    expect(showWarningMessage()).toHaveBeenCalledTimes(1);
  });

  it('does not warn when the user has disabled sandboxing', () => {
    jest
      .mocked(vscode.workspace.getConfiguration)
      .mockReturnValue(createFakeWorkspaceConfiguration({ enabled: false }));
    const check = createFakePartial<FeatureStateCheck<typeof SANDBOX_UNSUPPORTED_PLATFORM>>({
      checkId: SANDBOX_UNSUPPORTED_PLATFORM,
      engaged: true,
      context: { platform: 'win32' } as never,
    });
    triggerState({ featureId: SANDBOX, engagedChecks: [check], allChecks: [check] });

    expect(showWarningMessage()).not.toHaveBeenCalled();
  });

  it('prefers unsupported-platform over missing-dependencies when both are engaged', () => {
    const unsupported = createFakePartial<FeatureStateCheck<typeof SANDBOX_UNSUPPORTED_PLATFORM>>({
      checkId: SANDBOX_UNSUPPORTED_PLATFORM,
      engaged: true,
      context: { platform: 'win32' } as never,
    });
    const missing = createFakePartial<FeatureStateCheck<typeof SANDBOX_MISSING_DEPENDENCIES>>({
      checkId: SANDBOX_MISSING_DEPENDENCIES,
      engaged: true,
      context: { missingDependencies: [{ name: 'bwrap' }] } as never,
    });
    triggerState({
      featureId: SANDBOX,
      engagedChecks: [unsupported, missing],
      allChecks: [unsupported, missing],
    });

    expect(showWarningMessage()).toHaveBeenCalledTimes(1);
    expect(showWarningMessage()).toHaveBeenCalledWith(
      expect.stringContaining('win32 is not supported'),
      'Open Settings',
      'Disable Sandboxing',
    );
  });
});
Loading