Files
OpenSpec/test/core/github-copilot-cloud-agent.test.ts
T
NicoAvanzDevandClay Good 7a4a745d80 feat: generate Copilot coding agent files on openspec init (github-copilot) (#1274)
* feat: generate copilot cloud agent files when github-copilot tool is selected

When `openspec init` or `openspec update` is run with the github-copilot
tool selected, two additional files are now generated in the user's project:

1. `.github/workflows/copilot-setup-steps.yml` - A GitHub Actions workflow
   that pre-installs the OpenSpec CLI in the Copilot coding agent's
   ephemeral environment (required for the agent to use `openspec` commands).

2. `.github/agents/openspec.agent.md` - A custom agent definition that
   instructs the GitHub Copilot coding agent how to use the OpenSpec CLI,
   including all agent-compatible commands with `--json` output, workflow
   patterns, and best practices.

These files are only written if they don't already exist (to preserve
user customizations). The generation is non-fatal — if it fails, init/update
still completes successfully.

New module: src/core/github-copilot/cloud-agent.ts
Tests: test/core/github-copilot-cloud-agent.test.ts

* fix: wire up removeCopilotCloudFiles in update flow

When github-copilot is not in the configured tools during update,
remove the cloud agent files (copilot-setup-steps.yml and
openspec.agent.md) if they exist.

* fix: refresh Copilot cloud agent restore

* fix: address Copilot cloud review feedback

* fix: recognize legacy Copilot cloud files

* fix: harden Copilot legacy file matching

* fix(copilot): harden cloud agent file management

* fix(copilot): harden cloud agent file handling

---------

Co-authored-by: Clay Good <hi@claygood.com>
2026-08-05 00:41:30 +00:00

551 lines
25 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import os from 'os';
import path from 'path';
import { promises as fs } from 'fs';
import { parse } from 'yaml';
import {
includesGitHubCopilot,
generateCopilotSetupSteps,
generateCopilotAgentFile,
COPILOT_CLOUD_FILES,
removeCopilotCloudFiles,
writeCopilotCloudFiles,
} from '../../src/core/github-copilot/cloud-agent.js';
const MANAGED_MARKER = 'Generated by OpenSpec for GitHub Copilot coding agent support.';
const MARKERLESS_LEGACY_COPILOT_AGENT_FILE = `---
name: OpenSpec
description: "Manages OpenSpec changes, specs, and workflows using the OpenSpec CLI. Use this agent for proposing changes, exploring ideas, validating artifacts, checking status, and archiving completed work."
tools:
- "terminal"
---
# OpenSpec Agent
You are a specialized agent for managing OpenSpec workflows. You have access to the \`openspec\` CLI which is pre-installed in the development environment via \`copilot-setup-steps.yml\`.
## What is OpenSpec?
OpenSpec is a structured change management system for codebases. It organizes work into **changes** with planning artifacts (proposals, specs, designs, tasks) that guide implementation.
## Available Commands
### Agent-Compatible CLI Commands (prefer \`--json\` for structured output)
| Command | Purpose |
|---------|---------|
| \`openspec list [--json]\` | List all changes and specs |
| \`openspec show <item> [--json]\` | View a specific change or spec |
| \`openspec validate [--all] [--json]\` | Validate changes and specs for issues |
| \`openspec status [--json]\` | Show artifact progress for active changes |
| \`openspec instructions [--json]\` | Get next-step instructions for a change |
| \`openspec templates [--json]\` | List available templates |
| \`openspec schemas [--json]\` | List available workflow schemas |
| \`openspec archive <change>\` | Archive a completed change |
### Interactive CLI Commands (use when prompted by the user)
| Command | Purpose |
|---------|---------|
| \`openspec init\` | Initialize OpenSpec in the project |
| \`openspec update\` | Update OpenSpec configuration and artifacts |
| \`openspec view\` | Interactive dashboard |
| \`openspec config\` | View or modify settings |
## Workflow
When asked to work with OpenSpec, follow this pattern:
1. **Check current state**: Run \`openspec status --json\` to understand what changes exist and their progress.
2. **Follow instructions**: Run \`openspec instructions --json\` to get context-aware next steps.
3. **Validate before completing**: Run \`openspec validate --all --json\` to ensure artifacts are correct.
## Creating New Changes
When the user wants to propose a new change:
1. Create the change directory under \`openspec/changes/<change-name>/\`
2. Generate the required planning artifacts based on the project's configured workflow schema
3. Run \`openspec validate --json\` to verify the artifacts are well-formed
## Key Directories
- \`openspec/\` \u2014 Root OpenSpec directory
- \`openspec/changes/\` \u2014 Active changes with their artifacts
- \`openspec/config.yaml\` \u2014 Project configuration
- \`openspec/explorations/\` \u2014 Exploration documents
## Best Practices
- Always use \`--json\` flag when you need to parse output programmatically
- Run \`openspec validate\` after creating or modifying artifacts
- Check \`openspec status\` before starting work to understand the current state
- When archiving, ensure all tasks are completed and validated first
`;
describe('GitHub Copilot Cloud Agent', () => {
let tempDir: string;
function removeManagedMarker(content: string): string {
const withoutMarker = content
.replace(/^# Generated by OpenSpec for GitHub Copilot coding agent support\.\n\n/, '')
.replace(/\n<!-- Generated by OpenSpec for GitHub Copilot coding agent support\. -->\n/, '');
expect(withoutMarker).not.toBe(content);
expect(withoutMarker).not.toContain(MANAGED_MARKER);
return withoutMarker;
}
function withCrLf(content: string): string {
return content.replace(/\n/g, '\r\n');
}
async function linkDirectoryOutsideProject(outsideDir: string): Promise<void> {
await fs.symlink(
outsideDir,
path.join(tempDir, '.github'),
process.platform === 'win32' ? 'junction' : 'dir'
);
}
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-copilot-cloud-agent-'));
});
afterEach(async () => {
await fs.rm(tempDir, { recursive: true, force: true });
});
describe('includesGitHubCopilot', () => {
it('returns true when github-copilot is in the list', () => {
expect(includesGitHubCopilot(['claude', 'github-copilot', 'cursor'])).toBe(true);
});
it('returns false when github-copilot is not in the list', () => {
expect(includesGitHubCopilot(['claude', 'cursor'])).toBe(false);
});
it('returns false for empty list', () => {
expect(includesGitHubCopilot([])).toBe(false);
});
});
describe('generateCopilotSetupSteps', () => {
it('generates a structurally valid Copilot setup workflow', () => {
const content = generateCopilotSetupSteps();
const workflow = parse(content);
expect(workflow).toMatchObject({
name: 'Copilot Setup Steps',
on: {
workflow_dispatch: null,
push: { paths: ['.github/workflows/copilot-setup-steps.yml'] },
pull_request: { paths: ['.github/workflows/copilot-setup-steps.yml'] },
},
jobs: {
'copilot-setup-steps': {
'runs-on': 'ubuntu-latest',
'timeout-minutes': 10,
permissions: { contents: 'read' },
},
},
});
expect(Object.keys(workflow.jobs)).toEqual(['copilot-setup-steps']);
expect(workflow.jobs['copilot-setup-steps'].steps).toEqual(
expect.arrayContaining([
expect.objectContaining({ run: 'npm install -g @fission-ai/openspec' }),
expect.objectContaining({ run: 'openspec --version' }),
])
);
});
});
describe('generateCopilotAgentFile', () => {
it('generates valid agent frontmatter and non-interactive guidance', () => {
const content = generateCopilotAgentFile();
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n/);
expect(frontmatterMatch).not.toBeNull();
const frontmatter = parse(frontmatterMatch![1]);
expect(frontmatter).toEqual({
name: 'OpenSpec',
description: expect.any(String),
tools: ['execute', 'read', 'search', 'edit'],
});
expect(content).toContain('Generated by OpenSpec for GitHub Copilot coding agent support.');
expect(content).toContain('# OpenSpec Agent');
expect(content).toContain('openspec list');
expect(content).toContain('openspec new change <name>');
expect(content).toContain('openspec status --change <name> --json');
expect(content).toContain('openspec instructions [artifact] --change <name> --json');
expect(content).toContain('openspec archive <change> --json [--yes]');
expect(content).toContain('use `--yes` only after confirming all tasks are complete');
expect(content).toContain('run `openspec --version`');
expect(content).not.toContain('pre-installed in the development environment');
expect(content).not.toContain('Create the change directory under');
expect(content).toContain('openspec validate');
});
});
describe('COPILOT_CLOUD_FILES', () => {
it('has correct file paths', () => {
expect(COPILOT_CLOUD_FILES.setupSteps).toBe(path.join('.github', 'workflows', 'copilot-setup-steps.yml'));
expect(COPILOT_CLOUD_FILES.agent).toBe(path.join('.github', 'agents', 'openspec.agent.md'));
});
});
describe('writeCopilotCloudFiles', () => {
it('writes missing cloud files and creates parent directories', async () => {
const result = await writeCopilotCloudFiles(tempDir);
expect(result).toEqual({ setupStepsWritten: true, agentWritten: true });
await expect(fs.stat(path.join(tempDir, COPILOT_CLOUD_FILES.setupSteps))).resolves.toBeTruthy();
await expect(fs.stat(path.join(tempDir, COPILOT_CLOUD_FILES.agent))).resolves.toBeTruthy();
});
it('preserves customized existing files', async () => {
const setupStepsPath = path.join(tempDir, COPILOT_CLOUD_FILES.setupSteps);
const agentPath = path.join(tempDir, COPILOT_CLOUD_FILES.agent);
await fs.mkdir(path.dirname(setupStepsPath), { recursive: true });
await fs.mkdir(path.dirname(agentPath), { recursive: true });
await fs.writeFile(setupStepsPath, 'custom setup');
await fs.writeFile(agentPath, 'custom agent');
const result = await writeCopilotCloudFiles(tempDir);
expect(result).toEqual({ setupStepsWritten: false, agentWritten: false });
await expect(fs.readFile(setupStepsPath, 'utf8')).resolves.toBe('custom setup');
await expect(fs.readFile(agentPath, 'utf8')).resolves.toBe('custom agent');
});
it('creates robust agent guidance alongside a customized setup workflow', async () => {
const setupStepsPath = path.join(tempDir, COPILOT_CLOUD_FILES.setupSteps);
const agentPath = path.join(tempDir, COPILOT_CLOUD_FILES.agent);
const customSetup = 'name: custom setup\n';
await fs.mkdir(path.dirname(setupStepsPath), { recursive: true });
await fs.writeFile(setupStepsPath, customSetup);
const result = await writeCopilotCloudFiles(tempDir);
expect(result).toEqual({ setupStepsWritten: false, agentWritten: true });
await expect(fs.readFile(setupStepsPath, 'utf8')).resolves.toBe(customSetup);
const agentContent = await fs.readFile(agentPath, 'utf8');
expect(agentContent).toContain('run `openspec --version`');
expect(agentContent).toContain('install it with `npm install -g @fission-ai/openspec`');
expect(agentContent).not.toContain('pre-installed in the development environment');
});
it('preserves an alternate user-owned agent with the same Copilot identifier', async () => {
const alternateAgentPath = path.join(tempDir, '.github', 'agents', 'openspec.md');
const generatedAgentPath = path.join(tempDir, COPILOT_CLOUD_FILES.agent);
const customAgent = 'user-owned OpenSpec agent\n';
await fs.mkdir(path.dirname(alternateAgentPath), { recursive: true });
await fs.writeFile(alternateAgentPath, customAgent);
const result = await writeCopilotCloudFiles(tempDir);
expect(result).toEqual({ setupStepsWritten: true, agentWritten: false });
await expect(fs.readFile(alternateAgentPath, 'utf8')).resolves.toBe(customAgent);
await expect(fs.stat(generatedAgentPath)).rejects.toMatchObject({ code: 'ENOENT' });
});
it('removes a managed agent when an alternate user-owned agent is added later', async () => {
const alternateAgentPath = path.join(tempDir, '.github', 'agents', 'openspec.md');
const generatedAgentPath = path.join(tempDir, COPILOT_CLOUD_FILES.agent);
const customAgent = 'user-owned OpenSpec agent\n';
await writeCopilotCloudFiles(tempDir);
await fs.writeFile(alternateAgentPath, customAgent);
const result = await writeCopilotCloudFiles(tempDir);
expect(result).toEqual({ setupStepsWritten: false, agentWritten: false });
await expect(fs.readFile(alternateAgentPath, 'utf8')).resolves.toBe(customAgent);
await expect(fs.stat(generatedAgentPath)).rejects.toMatchObject({ code: 'ENOENT' });
});
it('reports conflicting user-owned agent profiles without creating setup files', async () => {
const setupStepsPath = path.join(tempDir, COPILOT_CLOUD_FILES.setupSteps);
const alternateAgentPath = path.join(tempDir, '.github', 'agents', 'openspec.md');
const generatedAgentPath = path.join(tempDir, COPILOT_CLOUD_FILES.agent);
await fs.mkdir(path.dirname(alternateAgentPath), { recursive: true });
await fs.writeFile(alternateAgentPath, 'custom alternate agent\n');
await fs.writeFile(generatedAgentPath, 'custom generated-path agent\n');
await expect(writeCopilotCloudFiles(tempDir)).rejects.toThrow(
'Conflicting Copilot agent profiles'
);
await expect(fs.stat(setupStepsPath)).rejects.toMatchObject({ code: 'ENOENT' });
await expect(fs.readFile(alternateAgentPath, 'utf8')).resolves.toBe(
'custom alternate agent\n'
);
await expect(fs.readFile(generatedAgentPath, 'utf8')).resolves.toBe(
'custom generated-path agent\n'
);
});
it('rejects a directory at a managed file path before creating other files', async () => {
const setupStepsPath = path.join(tempDir, COPILOT_CLOUD_FILES.setupSteps);
const agentPath = path.join(tempDir, COPILOT_CLOUD_FILES.agent);
await fs.mkdir(agentPath, { recursive: true });
await expect(writeCopilotCloudFiles(tempDir)).rejects.toThrow(
'Managed Copilot path is not a regular file'
);
await expect(fs.stat(setupStepsPath)).rejects.toMatchObject({ code: 'ENOENT' });
expect((await fs.stat(agentPath)).isDirectory()).toBe(true);
});
it('refreshes exact legacy generated files', async () => {
const setupStepsPath = path.join(tempDir, COPILOT_CLOUD_FILES.setupSteps);
const agentPath = path.join(tempDir, COPILOT_CLOUD_FILES.agent);
await fs.mkdir(path.dirname(setupStepsPath), { recursive: true });
await fs.mkdir(path.dirname(agentPath), { recursive: true });
await fs.writeFile(setupStepsPath, removeManagedMarker(generateCopilotSetupSteps()));
await fs.writeFile(agentPath, MARKERLESS_LEGACY_COPILOT_AGENT_FILE);
const result = await writeCopilotCloudFiles(tempDir);
expect(result).toEqual({ setupStepsWritten: true, agentWritten: true });
await expect(fs.readFile(setupStepsPath, 'utf8')).resolves.toBe(
generateCopilotSetupSteps()
);
await expect(fs.readFile(agentPath, 'utf8')).resolves.toBe(generateCopilotAgentFile());
});
it('refreshes the previous marker-bearing generated agent', async () => {
const agentPath = path.join(tempDir, COPILOT_CLOUD_FILES.agent);
const previousAgent = generateCopilotAgentFile()
.replace(
'You are a specialized agent for managing OpenSpec workflows. Before using the `openspec` CLI, run `openspec --version`. If it is unavailable, install it with `npm install -g @fission-ai/openspec`.',
'You are a specialized agent for managing OpenSpec workflows. You have access to the `openspec` CLI through shell commands, pre-installed in the development environment via `copilot-setup-steps.yml`.'
)
.replace(
'| `openspec archive <change> --json [--yes]` | Archive a completed change; use `--yes` only after confirming all tasks are complete |',
'| `openspec archive <change>` | Archive a completed change |'
);
await fs.mkdir(path.dirname(agentPath), { recursive: true });
await fs.writeFile(agentPath, previousAgent);
const result = await writeCopilotCloudFiles(tempDir);
expect(result.agentWritten).toBe(true);
await expect(fs.readFile(agentPath, 'utf8')).resolves.toBe(generateCopilotAgentFile());
});
it('leaves current generated files unchanged, including CRLF content', async () => {
const setupStepsPath = path.join(tempDir, COPILOT_CLOUD_FILES.setupSteps);
const agentPath = path.join(tempDir, COPILOT_CLOUD_FILES.agent);
const setupStepsContent = generateCopilotSetupSteps();
const agentContent = withCrLf(generateCopilotAgentFile());
await fs.mkdir(path.dirname(setupStepsPath), { recursive: true });
await fs.mkdir(path.dirname(agentPath), { recursive: true });
await fs.writeFile(setupStepsPath, setupStepsContent);
await fs.writeFile(agentPath, agentContent);
const result = await writeCopilotCloudFiles(tempDir);
expect(result).toEqual({ setupStepsWritten: false, agentWritten: false });
await expect(fs.readFile(setupStepsPath, 'utf8')).resolves.toBe(setupStepsContent);
await expect(fs.readFile(agentPath, 'utf8')).resolves.toBe(agentContent);
});
it('refuses to write cloud files through a linked .github directory', async () => {
const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-copilot-outside-'));
const outsideSetupStepsPath = path.join(
outsideDir,
'workflows',
'copilot-setup-steps.yml'
);
const outsideAgentPath = path.join(outsideDir, 'agents', 'openspec.agent.md');
try {
await linkDirectoryOutsideProject(outsideDir);
await expect(writeCopilotCloudFiles(tempDir)).rejects.toThrow(
'Path is outside the allowed directory'
);
await expect(fs.stat(outsideSetupStepsPath)).rejects.toMatchObject({ code: 'ENOENT' });
await expect(fs.stat(outsideAgentPath)).rejects.toMatchObject({ code: 'ENOENT' });
} finally {
await fs.rm(outsideDir, { recursive: true, force: true });
}
});
});
describe('removeCopilotCloudFiles', () => {
it('removes only existing cloud files and returns the removal count', async () => {
const setupStepsPath = path.join(tempDir, COPILOT_CLOUD_FILES.setupSteps);
const agentPath = path.join(tempDir, COPILOT_CLOUD_FILES.agent);
await writeCopilotCloudFiles(tempDir);
await fs.rm(agentPath, { force: true });
const removed = await removeCopilotCloudFiles(tempDir);
expect(removed).toBe(1);
await expect(fs.stat(setupStepsPath)).rejects.toMatchObject({ code: 'ENOENT' });
await expect(fs.stat(agentPath)).rejects.toMatchObject({ code: 'ENOENT' });
});
it('keeps customized cloud files', async () => {
const setupStepsPath = path.join(tempDir, COPILOT_CLOUD_FILES.setupSteps);
const agentPath = path.join(tempDir, COPILOT_CLOUD_FILES.agent);
await fs.mkdir(path.dirname(setupStepsPath), { recursive: true });
await fs.mkdir(path.dirname(agentPath), { recursive: true });
await fs.writeFile(setupStepsPath, 'custom setup');
await fs.writeFile(agentPath, 'custom agent');
const removed = await removeCopilotCloudFiles(tempDir);
expect(removed).toBe(0);
await expect(fs.readFile(setupStepsPath, 'utf8')).resolves.toBe('custom setup');
await expect(fs.readFile(agentPath, 'utf8')).resolves.toBe('custom agent');
});
it('keeps modified marker-bearing cloud files', async () => {
const setupStepsPath = path.join(tempDir, COPILOT_CLOUD_FILES.setupSteps);
const agentPath = path.join(tempDir, COPILOT_CLOUD_FILES.agent);
await fs.mkdir(path.dirname(setupStepsPath), { recursive: true });
await fs.mkdir(path.dirname(agentPath), { recursive: true });
await fs.writeFile(setupStepsPath, `${generateCopilotSetupSteps()}\n# custom change\n`);
await fs.writeFile(agentPath, `${generateCopilotAgentFile()}\ncustom change\n`);
const removed = await removeCopilotCloudFiles(tempDir);
expect(removed).toBe(0);
await expect(fs.readFile(setupStepsPath, 'utf8')).resolves.toContain('custom change');
await expect(fs.readFile(agentPath, 'utf8')).resolves.toContain('custom change');
});
it('removes markerless current generated cloud files', async () => {
const setupStepsPath = path.join(tempDir, COPILOT_CLOUD_FILES.setupSteps);
const agentPath = path.join(tempDir, COPILOT_CLOUD_FILES.agent);
await fs.mkdir(path.dirname(setupStepsPath), { recursive: true });
await fs.mkdir(path.dirname(agentPath), { recursive: true });
await fs.writeFile(setupStepsPath, removeManagedMarker(generateCopilotSetupSteps()));
await fs.writeFile(agentPath, removeManagedMarker(generateCopilotAgentFile()));
const removed = await removeCopilotCloudFiles(tempDir);
expect(removed).toBe(2);
await expect(fs.stat(setupStepsPath)).rejects.toMatchObject({ code: 'ENOENT' });
await expect(fs.stat(agentPath)).rejects.toMatchObject({ code: 'ENOENT' });
});
it('removes markerless legacy generated cloud files', async () => {
const setupStepsPath = path.join(tempDir, COPILOT_CLOUD_FILES.setupSteps);
const agentPath = path.join(tempDir, COPILOT_CLOUD_FILES.agent);
const currentAgentContent = removeManagedMarker(generateCopilotAgentFile());
await fs.mkdir(path.dirname(setupStepsPath), { recursive: true });
await fs.mkdir(path.dirname(agentPath), { recursive: true });
await fs.writeFile(setupStepsPath, removeManagedMarker(generateCopilotSetupSteps()));
await fs.writeFile(agentPath, MARKERLESS_LEGACY_COPILOT_AGENT_FILE);
const removed = await removeCopilotCloudFiles(tempDir);
expect(MARKERLESS_LEGACY_COPILOT_AGENT_FILE).not.toBe(currentAgentContent);
expect(MARKERLESS_LEGACY_COPILOT_AGENT_FILE).toContain(' - "terminal"');
expect(removed).toBe(2);
await expect(fs.stat(setupStepsPath)).rejects.toMatchObject({ code: 'ENOENT' });
await expect(fs.stat(agentPath)).rejects.toMatchObject({ code: 'ENOENT' });
});
it('removes current and legacy generated cloud files with CRLF line endings', async () => {
const setupStepsPath = path.join(tempDir, COPILOT_CLOUD_FILES.setupSteps);
const agentPath = path.join(tempDir, COPILOT_CLOUD_FILES.agent);
await fs.mkdir(path.dirname(setupStepsPath), { recursive: true });
await fs.mkdir(path.dirname(agentPath), { recursive: true });
await fs.writeFile(setupStepsPath, withCrLf(generateCopilotSetupSteps()));
await fs.writeFile(agentPath, withCrLf(MARKERLESS_LEGACY_COPILOT_AGENT_FILE));
const removed = await removeCopilotCloudFiles(tempDir);
expect(removed).toBe(2);
await expect(fs.stat(setupStepsPath)).rejects.toMatchObject({ code: 'ENOENT' });
await expect(fs.stat(agentPath)).rejects.toMatchObject({ code: 'ENOENT' });
});
it('keeps customized cloud files with CRLF line endings', async () => {
const agentPath = path.join(tempDir, COPILOT_CLOUD_FILES.agent);
const customizedContent = withCrLf(`${generateCopilotAgentFile()}\ncustom change\n`);
await fs.mkdir(path.dirname(agentPath), { recursive: true });
await fs.writeFile(agentPath, customizedContent);
const removed = await removeCopilotCloudFiles(tempDir);
expect(removed).toBe(0);
await expect(fs.readFile(agentPath, 'utf8')).resolves.toBe(customizedContent);
});
it('preserves the alternate user-owned agent during cleanup', async () => {
const alternateAgentPath = path.join(tempDir, '.github', 'agents', 'openspec.md');
const customAgent = 'user-owned OpenSpec agent\n';
await fs.mkdir(path.dirname(alternateAgentPath), { recursive: true });
await fs.writeFile(alternateAgentPath, customAgent);
const removed = await removeCopilotCloudFiles(tempDir);
expect(removed).toBe(0);
await expect(fs.readFile(alternateAgentPath, 'utf8')).resolves.toBe(customAgent);
});
it('preflights nested linked paths before removing any managed file', async () => {
const setupStepsPath = path.join(tempDir, COPILOT_CLOUD_FILES.setupSteps);
const agentsDir = path.join(tempDir, '.github', 'agents');
const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-copilot-outside-'));
const outsideAgentPath = path.join(outsideDir, 'openspec.agent.md');
const setupStepsContent = generateCopilotSetupSteps();
const agentContent = generateCopilotAgentFile();
try {
await fs.mkdir(path.dirname(setupStepsPath), { recursive: true });
await fs.writeFile(setupStepsPath, setupStepsContent);
await fs.writeFile(outsideAgentPath, agentContent);
await fs.symlink(
outsideDir,
agentsDir,
process.platform === 'win32' ? 'junction' : 'dir'
);
await expect(removeCopilotCloudFiles(tempDir)).rejects.toThrow(
'Path is outside the allowed directory'
);
await expect(fs.readFile(setupStepsPath, 'utf8')).resolves.toBe(setupStepsContent);
await expect(fs.readFile(outsideAgentPath, 'utf8')).resolves.toBe(agentContent);
} finally {
await fs.rm(outsideDir, { recursive: true, force: true });
}
});
it('refuses to remove managed cloud files through a linked .github directory', async () => {
const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-copilot-outside-'));
const outsideSetupStepsPath = path.join(
outsideDir,
'workflows',
'copilot-setup-steps.yml'
);
const outsideAgentPath = path.join(outsideDir, 'agents', 'openspec.agent.md');
const setupStepsContent = generateCopilotSetupSteps();
const agentContent = generateCopilotAgentFile();
try {
await fs.mkdir(path.dirname(outsideSetupStepsPath), { recursive: true });
await fs.mkdir(path.dirname(outsideAgentPath), { recursive: true });
await fs.writeFile(outsideSetupStepsPath, setupStepsContent);
await fs.writeFile(outsideAgentPath, agentContent);
await linkDirectoryOutsideProject(outsideDir);
await expect(removeCopilotCloudFiles(tempDir)).rejects.toThrow(
'Path is outside the allowed directory'
);
await expect(fs.readFile(outsideSetupStepsPath, 'utf8')).resolves.toBe(
setupStepsContent
);
await expect(fs.readFile(outsideAgentPath, 'utf8')).resolves.toBe(agentContent);
} finally {
await fs.rm(outsideDir, { recursive: true, force: true });
}
});
});
});