Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions src/models/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export interface Task {
dependsOn: string[];
status: TaskStatus;
agentResult?: string;
env?: Record<string, string>;
}

export interface SkillConfig {
Expand All @@ -23,6 +24,7 @@ export interface Plan {
updatedAt: string;
tasks: Task[];
skills?: SkillConfig[];
globalEnv?: Record<string, string>;
}

export interface ChatMessage {
Expand Down
36 changes: 36 additions & 0 deletions src/services/executor.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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' });
});
});
47 changes: 47 additions & 0 deletions src/services/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, string>,
taskEnv?: Record<string, string>,
): Record<string, string> {
const merged: Record<string, string> = {};
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<string, string>): Record<string, string | undefined> {
const saved: Record<string, string | undefined> = {};
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<string, string | undefined>): 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. */
Expand Down Expand Up @@ -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, [
Expand All @@ -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);
}
}

Expand Down Expand Up @@ -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, [
Expand All @@ -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);
}
}

Expand Down
65 changes: 65 additions & 0 deletions src/utils/env-validation.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
20 changes: 20 additions & 0 deletions src/utils/env-validation.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>): Record<string, string> {
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);
}