From 5ea491300a0d63cc95c34eb3c4e53c55293294d7 Mon Sep 17 00:00:00 2001 From: Hally Maschine Date: Wed, 1 Apr 2026 09:20:18 -0700 Subject: [PATCH] cmd/sgai/skel: gate workbench MCP on session env --- cmd/sgai/main_test.go | 16 ++-- cmd/sgai/service_adhoc_test.go | 8 ++ cmd/sgai/skel/.sgai/plugin/workbench.test.ts | 93 ++++++++++++++++++++ cmd/sgai/skel/.sgai/plugin/workbench.ts | 34 ++++--- 4 files changed, 132 insertions(+), 19 deletions(-) create mode 100644 cmd/sgai/skel/.sgai/plugin/workbench.test.ts diff --git a/cmd/sgai/main_test.go b/cmd/sgai/main_test.go index 927f743..561c1f9 100644 --- a/cmd/sgai/main_test.go +++ b/cmd/sgai/main_test.go @@ -3496,6 +3496,7 @@ func TestExecuteAgentProcessFlushesBufferedTextOnInterrupt(t *testing.T) { ring := newRingWriter() workflow := newTestWorkflow() resultCh := make(chan processResult, 1) + var result processResult go func() { _, _, errState := executeAgentProcess(ctx, &cfg, []string{"run"}, "", "[test]", ring, &workflow) resultCh <- processResult{errState: errState} @@ -3514,12 +3515,15 @@ func TestExecuteAgentProcessFlushesBufferedTextOnInterrupt(t *testing.T) { cancel() - select { - case result := <-resultCh: - require.NotNil(t, result.errState) - case <-time.After(5 * time.Second): - t.Fatal("timed out waiting for interrupted agent process") - } + require.Eventually(t, func() bool { + select { + case result = <-resultCh: + return true + default: + return false + } + }, gracefulShutdownTimeout+time.Second, 10*time.Millisecond) + require.NotNil(t, result.errState) assert.Contains(t, logBuf.String(), "interrupted buffered text") } diff --git a/cmd/sgai/service_adhoc_test.go b/cmd/sgai/service_adhoc_test.go index c031051..5078968 100644 --- a/cmd/sgai/service_adhoc_test.go +++ b/cmd/sgai/service_adhoc_test.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "path/filepath" "sync" "testing" "time" @@ -61,6 +62,13 @@ func TestAdhocStartServiceAlreadyRunningReturnsExisting(t *testing.T) { assert.Contains(t, result.Output, "test output") } +func TestBuildPromptActionCommandSpecUsesOnlyWorkspaceConfigDirEnv(t *testing.T) { + workspacePath := "/tmp/test-workspace" + spec := buildPromptActionCommandSpec(workspacePath, "summarize", "openai/gpt-5.4") + + assert.Equal(t, []string{"OPENCODE_CONFIG_DIR=" + filepath.Join(workspacePath, ".sgai")}, spec.env) +} + func TestGetAdhocStateCreation(t *testing.T) { srv, rootDir := setupTestServer(t) wsDir := setupTestWorkspace(t, srv, rootDir, "adhoc-create") diff --git a/cmd/sgai/skel/.sgai/plugin/workbench.test.ts b/cmd/sgai/skel/.sgai/plugin/workbench.test.ts new file mode 100644 index 0000000..43c9a74 --- /dev/null +++ b/cmd/sgai/skel/.sgai/plugin/workbench.test.ts @@ -0,0 +1,93 @@ +import { afterEach, describe, expect, it } from "bun:test"; + +import { Workbench } from "./workbench"; + +const sessionEnvKeys = ["SGAI_BIN_PATH", "SGAI_MCP_URL", "SGAI_AGENT_IDENTITY"] as const; + +type SessionEnvKey = (typeof sessionEnvKeys)[number]; +type SessionEnv = Record; + +const originalSessionEnv: Partial = Object.fromEntries( + sessionEnvKeys.map((key) => [key, process.env[key]]), +); + +const fullSessionEnv: SessionEnv = { + SGAI_BIN_PATH: "/tmp/sgai", + SGAI_MCP_URL: "http://127.0.0.1:9999/mcp", + SGAI_AGENT_IDENTITY: "test-agent", +}; + +function applySessionEnv(env: Partial) { + for (const key of sessionEnvKeys) { + const value = env[key]; + if (typeof value === "string") { + process.env[key] = value; + continue; + } + delete process.env[key]; + } +} + +async function configureWorkbench(env: Partial, config: Record) { + applySessionEnv(env); + const plugin = await Workbench({ directory: "/tmp/test-workspace" } as never); + const nextConfig = structuredClone(config); + await plugin.config?.(nextConfig); + return nextConfig; +} + +afterEach(() => { + applySessionEnv(originalSessionEnv); +}); + +describe("Workbench config", () => { + for (const missingKey of sessionEnvKeys) { + it(`keeps sgai MCP disabled when ${missingKey} is missing`, async () => { + const env: Partial = { ...fullSessionEnv }; + delete env[missingKey]; + + const config = await configureWorkbench(env, { + mcp: { + existing: { + type: "local", + command: ["existing"], + }, + }, + }); + + expect(config).toMatchObject({ + mcp: { + existing: { + type: "local", + command: ["existing"], + }, + }, + }); + expect((config.mcp as Record).sgai).toBeUndefined(); + }); + } + + it("registers sgai MCP when the session-scoped env is complete", async () => { + const config = await configureWorkbench(fullSessionEnv, { + mcp: { + existing: { + type: "local", + command: ["existing"], + }, + }, + }); + + expect(config).toMatchObject({ + mcp: { + existing: { + type: "local", + command: ["existing"], + }, + sgai: { + type: "local", + command: ["/tmp/sgai", "internal-mcp", "http://127.0.0.1:9999/mcp", "test-agent"], + }, + }, + }); + }); +}); diff --git a/cmd/sgai/skel/.sgai/plugin/workbench.ts b/cmd/sgai/skel/.sgai/plugin/workbench.ts index f394e34..1fae86e 100644 --- a/cmd/sgai/skel/.sgai/plugin/workbench.ts +++ b/cmd/sgai/skel/.sgai/plugin/workbench.ts @@ -2,6 +2,18 @@ import type { Plugin } from "@opencode-ai/plugin" import { readFile, writeFile } from 'fs/promises'; import { join } from 'path'; +function workbenchSGAIMCPCommand() { + const sgaiBinPath = process.env.SGAI_BIN_PATH?.trim(); + const sgaiMCPURL = process.env.SGAI_MCP_URL?.trim(); + const sgaiAgentIdentity = process.env.SGAI_AGENT_IDENTITY?.trim(); + + if (!sgaiBinPath || !sgaiMCPURL || !sgaiAgentIdentity) { + return null; + } + + return [sgaiBinPath, "internal-mcp", sgaiMCPURL, sgaiAgentIdentity]; +} + export const Workbench: Plugin = async ({ directory }) => { const stateFilePath = join(directory, ".sgai", "state.json"); @@ -16,21 +28,17 @@ export const Workbench: Plugin = async ({ directory }) => { config.instructions?.unshift(directory + "/.sgai/AGENTS.md"); config.model = "opencode/big-pickle"; - // Configure MCP server for sgai custom tools via local stdio bridge - if (!config.mcp) { - config.mcp = {}; + const sgaiMCPCommand = workbenchSGAIMCPCommand(); + if (sgaiMCPCommand) { + if (!config.mcp) { + config.mcp = {}; + } + config.mcp.sgai = { + type: "local", + command: sgaiMCPCommand, + }; } - config.mcp.sgai = { - type: "local", - command: [ - process.env.SGAI_BIN_PATH || "", - "internal-mcp", - process.env.SGAI_MCP_URL || "", - process.env.SGAI_AGENT_IDENTITY || "" - ] - }; }, - // Tools are now provided by the MCP server configured above tool: {}, event: async (input: { event: any; client: any }) => { if (input.event.type === "todo.updated") {