diff --git a/README.md b/README.md index 5878df6..d278aad 100644 --- a/README.md +++ b/README.md @@ -263,6 +263,51 @@ node --inspect-brk -e " | `DEBUG=planeteer:*` | Enable debug logging (when implemented) | | `NODE_OPTIONS=--inspect` | Attach debugger to running process | +### MCP Environment Variables + +Planeteer can pass environment variables to MCP (Model Context Protocol) servers used by Copilot tools during task execution. This is particularly useful for providing API keys, credentials, or configuration values needed by MCP servers. + +#### Plan-level (`globalEnv`) + +Set environment variables for **all tasks** in a plan by adding a `globalEnv` map to the plan JSON: + +```json +{ + "id": "my-plan", + "name": "My Project", + "globalEnv": { + "MCP_SERVER_URL": "https://mcp.example.com", + "NODE_ENV": "production" + }, + "tasks": [] +} +``` + +#### Task-level (`env`) + +Override or extend environment variables for a **specific task** using the `env` field. Task-level values take precedence over `globalEnv`: + +```json +{ + "id": "task-1", + "title": "Deploy service", + "env": { + "DEPLOY_TARGET": "staging", + "API_KEY": "sk-..." + } +} +``` + +#### How it works + +Before each task's Copilot session is created, Planeteer: +1. Merges `globalEnv` (plan-wide) with the task's `env` (task-specific values win) +2. Sets the merged variables in `process.env` +3. Creates the Copilot session (the SDK passes them to MCP servers via `envValueMode: direct`) +4. Restores the original environment after the session completes + +> **Security note**: Environment variable names containing `key`, `token`, `password`, `secret`, `credential`, or `auth` are considered sensitive and will be masked (`***`) in logs and UI displays. + ### Persistence Plans are saved to `.planeteer/` in the current working directory: diff --git a/package.json b/package.json index f1b17cf..43e9428 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "test:watch": "vitest" }, "dependencies": { - "@github/copilot-sdk": "^0.1.24", + "@github/copilot-sdk": "^0.1.25", "ink": "^5.1.0", "ink-select-input": "^6.0.0", "ink-spinner": "^5.0.0", diff --git a/src/models/plan.ts b/src/models/plan.ts index 80a2d4d..223f530 100644 --- a/src/models/plan.ts +++ b/src/models/plan.ts @@ -8,6 +8,7 @@ export interface Task { dependsOn: string[]; status: TaskStatus; agentResult?: string; + env?: Record; } export interface SkillConfig { @@ -23,6 +24,7 @@ export interface Plan { updatedAt: string; tasks: Task[]; skills?: SkillConfig[]; + globalEnv?: Record; } export interface ChatMessage { diff --git a/src/services/executor.test.ts b/src/services/executor.test.ts index d154c82..741699e 100644 --- a/src/services/executor.test.ts +++ b/src/services/executor.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; import type { ExecutionCallbacks, SessionEventWithTask } from './executor.js'; +import { mergeEnv } from './executor.js'; import type { SessionEvent } from './copilot.js'; describe('SessionEventWithTask type', () => { @@ -114,3 +115,38 @@ describe('ExecutionCallbacks with session events', () => { }); }); }); + +describe('mergeEnv', () => { + it('should return empty object when no env provided', () => { + expect(mergeEnv()).toEqual({}); + expect(mergeEnv(undefined, undefined)).toEqual({}); + }); + + it('should use globalEnv when taskEnv is absent', () => { + expect(mergeEnv({ API_URL: 'https://example.com' })).toEqual({ + API_URL: 'https://example.com', + }); + }); + + it('should merge globalEnv and taskEnv with task taking precedence', () => { + const result = mergeEnv( + { API_URL: 'https://global.example.com', SHARED: 'global' }, + { API_URL: 'https://task.example.com', TASK_VAR: 'task' }, + ); + expect(result).toEqual({ + API_URL: 'https://task.example.com', + SHARED: 'global', + TASK_VAR: 'task', + }); + }); + + it('should skip env var names with invalid POSIX characters', () => { + const result = mergeEnv({ 'invalid name': 'value', VALID_VAR: 'ok' }); + expect(result).toEqual({ VALID_VAR: 'ok' }); + expect(result['invalid name']).toBeUndefined(); + }); + + it('should allow leading underscores in env var names', () => { + expect(mergeEnv({ _PRIVATE: 'val' })).toEqual({ _PRIVATE: 'val' }); + }); +}); diff --git a/src/services/executor.ts b/src/services/executor.ts index 09d6b6c..dc3c4f9 100644 --- a/src/services/executor.ts +++ b/src/services/executor.ts @@ -2,6 +2,7 @@ import type { Plan, Task } from '../models/plan.js'; import { sendPromptSync } from './copilot.js'; import type { SessionEvent } from './copilot.js'; import { getReadyTasks } from '../utils/dependency-graph.js'; +import { isValidEnvName } from '../utils/env-validation.js'; export interface SessionEventWithTask { taskId: string; @@ -77,6 +78,45 @@ export interface ExecutionOptions { codebaseContext?: string; } +/** + * Merge plan-level globalEnv with task-level env (task takes precedence). + * Invalid POSIX names are silently skipped. + */ +export function mergeEnv( + globalEnv?: Record, + taskEnv?: Record, +): Record { + const merged: Record = {}; + for (const [k, v] of Object.entries(globalEnv ?? {})) { + if (isValidEnvName(k)) merged[k] = v; + } + for (const [k, v] of Object.entries(taskEnv ?? {})) { + if (isValidEnvName(k)) merged[k] = v; + } + return merged; +} + +/** Apply env vars to process.env, returning the saved originals for restoration. */ +function applyEnv(env: Record): Record { + const saved: Record = {}; + for (const [k, v] of Object.entries(env)) { + saved[k] = process.env[k]; + process.env[k] = v; + } + return saved; +} + +/** Restore process.env to its state before applyEnv was called. */ +function restoreEnv(saved: Record): void { + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) { + delete process.env[k]; + } else { + process.env[k] = v; + } + } +} + /** Handle returned by executePlan to allow retrying individual tasks mid-flight. */ export interface ExecutionHandle { /** Retry a specific failed task. Safe to call while execution is still in progress. */ @@ -111,6 +151,8 @@ export function executePlan( taskInPlan.status = 'in_progress'; callbacks.onTaskStart(task.id); + const mergedEnv = mergeEnv(updatedPlan.globalEnv, task.env); + const savedEnv = applyEnv(mergedEnv); try { const prompt = buildTaskPrompt(task, updatedPlan, codebaseContext); const result = await sendPromptSync(EXECUTOR_SYSTEM_PROMPT, [ @@ -130,6 +172,8 @@ export function executePlan( taskInPlan.status = 'failed'; taskInPlan.agentResult = err instanceof Error ? err.message : String(err); callbacks.onTaskFailed(task.id, taskInPlan.agentResult!); + } finally { + restoreEnv(savedEnv); } } @@ -182,6 +226,7 @@ export function executePlan( // Bootstrap: create README.md and .gitignore if needed (skip on retry) if (!options.skipInit) { callbacks.onTaskStart(INIT_TASK_ID); + const globalEnvSaved = applyEnv(mergeEnv(updatedPlan.globalEnv)); try { const initPrompt = buildInitPrompt(updatedPlan); const initResult = await sendPromptSync(EXECUTOR_SYSTEM_PROMPT, [ @@ -199,6 +244,8 @@ export function executePlan( const errMsg = err instanceof Error ? err.message : String(err); callbacks.onTaskFailed(INIT_TASK_ID, errMsg); // Non-fatal: continue with actual tasks even if init fails + } finally { + restoreEnv(globalEnvSaved); } } diff --git a/src/utils/env-validation.test.ts b/src/utils/env-validation.test.ts new file mode 100644 index 0000000..0154492 --- /dev/null +++ b/src/utils/env-validation.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from 'vitest'; +import { isSensitiveKey, maskEnvVars, isValidEnvName } from '../utils/env-validation.js'; + +describe('isSensitiveKey', () => { + it('should return true for keys containing sensitive patterns', () => { + expect(isSensitiveKey('API_KEY')).toBe(true); + expect(isSensitiveKey('GITHUB_TOKEN')).toBe(true); + expect(isSensitiveKey('DB_PASSWORD')).toBe(true); + expect(isSensitiveKey('MY_SECRET')).toBe(true); + expect(isSensitiveKey('OAUTH_CREDENTIAL')).toBe(true); + expect(isSensitiveKey('AUTH_HEADER')).toBe(true); + }); + + it('should be case-insensitive', () => { + expect(isSensitiveKey('api_key')).toBe(true); + expect(isSensitiveKey('Github_Token')).toBe(true); + }); + + it('should return false for non-sensitive keys', () => { + expect(isSensitiveKey('API_URL')).toBe(false); + expect(isSensitiveKey('NODE_ENV')).toBe(false); + expect(isSensitiveKey('PORT')).toBe(false); + expect(isSensitiveKey('DEBUG')).toBe(false); + }); +}); + +describe('maskEnvVars', () => { + it('should mask sensitive values', () => { + const result = maskEnvVars({ + API_KEY: 'super-secret-key', + NODE_ENV: 'production', + GITHUB_TOKEN: 'ghp_abc123', + }); + expect(result.API_KEY).toBe('***'); + expect(result.GITHUB_TOKEN).toBe('***'); + expect(result.NODE_ENV).toBe('production'); + }); + + it('should return empty object for empty input', () => { + expect(maskEnvVars({})).toEqual({}); + }); +}); + +describe('isValidEnvName', () => { + it('should accept valid POSIX names', () => { + expect(isValidEnvName('FOO')).toBe(true); + expect(isValidEnvName('FOO_BAR')).toBe(true); + expect(isValidEnvName('_PRIVATE')).toBe(true); + expect(isValidEnvName('foo123')).toBe(true); + }); + + it('should reject names starting with a digit', () => { + expect(isValidEnvName('1FOO')).toBe(false); + }); + + it('should reject names containing spaces or special characters', () => { + expect(isValidEnvName('FOO BAR')).toBe(false); + expect(isValidEnvName('FOO-BAR')).toBe(false); + expect(isValidEnvName('FOO.BAR')).toBe(false); + }); + + it('should reject empty string', () => { + expect(isValidEnvName('')).toBe(false); + }); +}); diff --git a/src/utils/env-validation.ts b/src/utils/env-validation.ts new file mode 100644 index 0000000..a40bd83 --- /dev/null +++ b/src/utils/env-validation.ts @@ -0,0 +1,20 @@ +/** Substrings that indicate a sensitive environment variable name. */ +const SENSITIVE_PATTERNS = ['key', 'token', 'password', 'secret', 'credential', 'auth']; + +/** Returns true if the env var name likely holds sensitive data. */ +export function isSensitiveKey(name: string): boolean { + const lower = name.toLowerCase(); + return SENSITIVE_PATTERNS.some((p) => lower.includes(p)); +} + +/** Returns a copy of the env map with sensitive values replaced by '***'. */ +export function maskEnvVars(env: Record): Record { + return Object.fromEntries( + Object.entries(env).map(([k, v]) => [k, isSensitiveKey(k) ? '***' : v]), + ); +} + +/** Returns true if the name is a valid POSIX environment variable name. */ +export function isValidEnvName(name: string): boolean { + return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name); +}