From 9d4efb3d7f7a3b03a5a367225e39a3a47e3bd95a Mon Sep 17 00:00:00 2001 From: devartifex Date: Sat, 14 Mar 2026 16:02:50 +0100 Subject: [PATCH 01/48] chore: initial clean commit for v3.0 public release All prior history squashed for zero-trust security posture. No secrets were found in history, but infrastructure metadata (Azure resource names, deploy patterns) was removed as a precaution. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .dockerignore | 17 + .env.example | 13 + .gitattributes | 2 + .github/ISSUE_TEMPLATE/bug_report.md | 28 + .github/ISSUE_TEMPLATE/config.yml | 1 + .github/ISSUE_TEMPLATE/feature_request.md | 19 + .github/agents/accessibility.agent.md | 60 + .github/agents/context-architect.agent.md | 69 + .github/agents/debug.agent.md | 47 + .github/agents/devops-expert.agent.md | 62 + .github/agents/janitor.agent.md | 58 + .github/agents/playwright-tester.agent.md | 31 + .github/agents/security-reviewer.agent.md | 68 + .github/copilot-instructions.md | 135 + .github/dependabot.yml | 22 + .github/instructions/a11y.instructions.md | 73 + .../context-engineering.instructions.md | 44 + .../copilot-sdk-nodejs.instructions.md | 117 + .../playwright-typescript.instructions.md | 63 + .../security-and-owasp.instructions.md | 49 + .github/instructions/svelte.instructions.md | 51 + .../instructions/typescript.instructions.md | 22 + .github/pull_request_template.md | 24 + .github/workflows/ci.yml | 42 + .gitignore | 20 + .husky/pre-commit | 1 + .nvmrc | 1 + CONTRIBUTING.md | 76 + Dockerfile | 40 + LICENSE | 21 + README.md | 350 ++ SECURITY.md | 59 + azure.yaml | 17 + docker-compose.yml | 20 + docs/ARCHITECTURE.md | 243 + docs/TEST-MATRIX.md | 95 + docs/screenshots/chat-desktop.png | Bin 0 -> 116547 bytes docs/screenshots/chat-mobile.png | Bin 0 -> 80801 bytes docs/screenshots/login-desktop.png | Bin 0 -> 56757 bytes docs/screenshots/login-ipad.png | Bin 0 -> 55020 bytes docs/screenshots/login-mobile.png | Bin 0 -> 49427 bytes .../screenshots/usecase-autopilot-desktop.png | Bin 0 -> 71265 bytes docs/screenshots/usecase-autopilot-ipad.png | Bin 0 -> 108627 bytes docs/screenshots/usecase-autopilot-mobile.png | Bin 0 -> 57010 bytes docs/screenshots/usecase-autopilot.png | Bin 0 -> 71265 bytes docs/screenshots/usecase-code-desktop.png | Bin 0 -> 69030 bytes docs/screenshots/usecase-code-ipad.png | Bin 0 -> 93847 bytes docs/screenshots/usecase-code-mobile.png | Bin 0 -> 56023 bytes .../screenshots/usecase-reasoning-desktop.png | Bin 0 -> 99758 bytes docs/screenshots/usecase-reasoning-ipad.png | Bin 0 -> 106324 bytes docs/screenshots/usecase-reasoning-mobile.png | Bin 0 -> 66408 bytes docs/screenshots/usecase-reasoning.png | Bin 0 -> 99774 bytes infra/abbreviations.json | 8 + infra/main.bicep | 115 + infra/main.json | 763 +++ infra/main.parameters.json | 11 + infra/modules/container-apps.bicep | 194 + infra/modules/container-registry.bicep | 23 + infra/modules/managed-identity.bicep | 39 + infra/modules/monitoring.bicep | 34 + infra/modules/storage.bicep | 44 + package-lock.json | 4394 +++++++++++++++++ package.json | 79 + playwright.config.ts | 39 + scripts/bundle-sessions.mjs | 67 + scripts/patch-sdk.mjs | 34 + scripts/sync-push.mjs | 246 + scripts/test-tools.mjs | 252 + server.js | 66 + skills/git-commit/SKILL.md | 124 + src/app.css | 89 + src/app.d.ts | 11 + src/app.html | 17 + src/hooks.server.test.ts | 360 ++ src/hooks.server.ts | 107 + src/lib/components/Banner.svelte | 61 + src/lib/components/ChatInput.svelte | 868 ++++ src/lib/components/ChatMessage.svelte | 437 ++ src/lib/components/CustomToolsEditor.svelte | 487 ++ src/lib/components/DeviceFlowLogin.svelte | 374 ++ src/lib/components/EnvInfo.svelte | 143 + src/lib/components/FleetProgress.svelte | 171 + src/lib/components/MessageList.svelte | 265 + src/lib/components/ModelSheet.svelte | 363 ++ src/lib/components/PermissionPrompt.svelte | 215 + src/lib/components/PlanPanel.svelte | 207 + src/lib/components/QuotaDot.svelte | 56 + src/lib/components/ReasoningBlock.svelte | 93 + src/lib/components/SessionPreview.svelte | 238 + src/lib/components/SessionsSheet.svelte | 604 +++ src/lib/components/SettingsModal.svelte | 1203 +++++ src/lib/components/Sidebar.svelte | 324 ++ src/lib/components/ToolCall.svelte | 194 + src/lib/components/TopBar.svelte | 278 ++ src/lib/components/UserInputPrompt.svelte | 148 + src/lib/server/auth/github.ts | 80 + src/lib/server/auth/guard.test.ts | 199 + src/lib/server/auth/guard.ts | 55 + src/lib/server/auth/session-utils.test.ts | 139 + src/lib/server/auth/session-utils.ts | 25 + src/lib/server/config.test.ts | 206 + src/lib/server/config.ts | 41 + src/lib/server/copilot/client.ts | 20 + .../server/copilot/session-metadata.test.ts | 316 ++ src/lib/server/copilot/session-metadata.ts | 350 ++ src/lib/server/copilot/session.test.ts | 429 ++ src/lib/server/copilot/session.ts | 299 ++ src/lib/server/node-sqlite.d.ts | 19 + src/lib/server/security-log.ts | 25 + src/lib/server/session-store.ts | 36 + src/lib/server/settings-store.test.ts | 206 + src/lib/server/settings-store.ts | 54 + src/lib/server/skills/scanner.test.ts | 136 + src/lib/server/skills/scanner.ts | 81 + src/lib/server/ws/handler.ts | 1225 +++++ src/lib/server/ws/session-pool.test.ts | 254 + src/lib/server/ws/session-pool.ts | 122 + src/lib/stores/auth.svelte.ts | 198 + src/lib/stores/auth.test.ts | 241 + src/lib/stores/chat.svelte.ts | 760 +++ src/lib/stores/chat.test.ts | 637 +++ src/lib/stores/settings.svelte.ts | 241 + src/lib/stores/settings.test.ts | 252 + src/lib/stores/ws.svelte.ts | 369 ++ src/lib/stores/ws.test.ts | 75 + src/lib/types/index.js | 22 + src/lib/types/index.js.map | 1 + src/lib/types/index.ts | 855 ++++ src/lib/utils/markdown.test.ts | 191 + src/lib/utils/markdown.ts | 97 + src/lib/utils/notifications.test.ts | 110 + src/lib/utils/notifications.ts | 50 + src/lib/utils/smoke.test.ts | 7 + src/routes/+error.svelte | 25 + src/routes/+layout.server.ts | 12 + src/routes/+layout.svelte | 6 + src/routes/+page.svelte | 423 ++ src/routes/api/client-error/+server.ts | 21 + src/routes/api/client-error/server.test.ts | 114 + src/routes/api/models/+server.ts | 24 + src/routes/api/models/server.test.ts | 103 + src/routes/api/sessions/sync/+server.ts | 161 + src/routes/api/sessions/sync/server.test.ts | 256 + src/routes/api/settings/+server.ts | 45 + src/routes/api/settings/server.test.ts | 152 + src/routes/api/skills/+server.ts | 15 + src/routes/api/upload/+server.ts | 123 + src/routes/api/upload/server.test.ts | 182 + src/routes/api/version/+server.ts | 16 + src/routes/api/version/server.test.ts | 42 + src/routes/auth/device/poll/+server.ts | 79 + src/routes/auth/device/poll/server.test.ts | 155 + src/routes/auth/device/start/+server.ts | 28 + src/routes/auth/device/start/server.test.ts | 109 + src/routes/auth/logout/+server.ts | 15 + src/routes/auth/status/+server.ts | 18 + src/routes/health/+server.ts | 6 + src/routes/health/server.test.ts | 42 + src/test-setup.ts | 2 + .../appspecific/com.chrome.devtools.json | 1 + static/favicon.png | Bin 0 -> 1786 bytes static/img/logo.png | Bin 0 -> 10448 bytes svelte.config.js | 15 + tests/auth-flow.spec.ts | 310 ++ tests/chat-messaging.spec.ts | 269 + tests/chat.spec.ts | 78 + tests/error-handling.spec.ts | 222 + tests/helpers.ts | 236 + tests/login.spec.ts | 148 + tests/messages.spec.ts | 99 + tests/model-selection.spec.ts | 238 + tests/plan-mode.spec.ts | 260 + tests/responsive-chat.spec.ts | 187 + tests/responsive.spec.ts | 76 + tests/screenshots.spec.ts | 379 ++ tests/session-management.spec.ts | 264 + tests/settings.spec.ts | 330 ++ tsconfig.json | 13 + tsconfig.node.json | 18 + vite.config.ts | 76 + vitest.config.ts | 38 + 181 files changed, 29759 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitattributes create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/agents/accessibility.agent.md create mode 100644 .github/agents/context-architect.agent.md create mode 100644 .github/agents/debug.agent.md create mode 100644 .github/agents/devops-expert.agent.md create mode 100644 .github/agents/janitor.agent.md create mode 100644 .github/agents/playwright-tester.agent.md create mode 100644 .github/agents/security-reviewer.agent.md create mode 100644 .github/copilot-instructions.md create mode 100644 .github/dependabot.yml create mode 100644 .github/instructions/a11y.instructions.md create mode 100644 .github/instructions/context-engineering.instructions.md create mode 100644 .github/instructions/copilot-sdk-nodejs.instructions.md create mode 100644 .github/instructions/playwright-typescript.instructions.md create mode 100644 .github/instructions/security-and-owasp.instructions.md create mode 100644 .github/instructions/svelte.instructions.md create mode 100644 .github/instructions/typescript.instructions.md create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100755 .husky/pre-commit create mode 100644 .nvmrc create mode 100644 CONTRIBUTING.md create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 azure.yaml create mode 100644 docker-compose.yml create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/TEST-MATRIX.md create mode 100644 docs/screenshots/chat-desktop.png create mode 100644 docs/screenshots/chat-mobile.png create mode 100644 docs/screenshots/login-desktop.png create mode 100644 docs/screenshots/login-ipad.png create mode 100644 docs/screenshots/login-mobile.png create mode 100644 docs/screenshots/usecase-autopilot-desktop.png create mode 100644 docs/screenshots/usecase-autopilot-ipad.png create mode 100644 docs/screenshots/usecase-autopilot-mobile.png create mode 100644 docs/screenshots/usecase-autopilot.png create mode 100644 docs/screenshots/usecase-code-desktop.png create mode 100644 docs/screenshots/usecase-code-ipad.png create mode 100644 docs/screenshots/usecase-code-mobile.png create mode 100644 docs/screenshots/usecase-reasoning-desktop.png create mode 100644 docs/screenshots/usecase-reasoning-ipad.png create mode 100644 docs/screenshots/usecase-reasoning-mobile.png create mode 100644 docs/screenshots/usecase-reasoning.png create mode 100644 infra/abbreviations.json create mode 100644 infra/main.bicep create mode 100644 infra/main.json create mode 100644 infra/main.parameters.json create mode 100644 infra/modules/container-apps.bicep create mode 100644 infra/modules/container-registry.bicep create mode 100644 infra/modules/managed-identity.bicep create mode 100644 infra/modules/monitoring.bicep create mode 100644 infra/modules/storage.bicep create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 playwright.config.ts create mode 100644 scripts/bundle-sessions.mjs create mode 100644 scripts/patch-sdk.mjs create mode 100644 scripts/sync-push.mjs create mode 100644 scripts/test-tools.mjs create mode 100644 server.js create mode 100644 skills/git-commit/SKILL.md create mode 100644 src/app.css create mode 100644 src/app.d.ts create mode 100644 src/app.html create mode 100644 src/hooks.server.test.ts create mode 100644 src/hooks.server.ts create mode 100644 src/lib/components/Banner.svelte create mode 100644 src/lib/components/ChatInput.svelte create mode 100644 src/lib/components/ChatMessage.svelte create mode 100644 src/lib/components/CustomToolsEditor.svelte create mode 100644 src/lib/components/DeviceFlowLogin.svelte create mode 100644 src/lib/components/EnvInfo.svelte create mode 100644 src/lib/components/FleetProgress.svelte create mode 100644 src/lib/components/MessageList.svelte create mode 100644 src/lib/components/ModelSheet.svelte create mode 100644 src/lib/components/PermissionPrompt.svelte create mode 100644 src/lib/components/PlanPanel.svelte create mode 100644 src/lib/components/QuotaDot.svelte create mode 100644 src/lib/components/ReasoningBlock.svelte create mode 100644 src/lib/components/SessionPreview.svelte create mode 100644 src/lib/components/SessionsSheet.svelte create mode 100644 src/lib/components/SettingsModal.svelte create mode 100644 src/lib/components/Sidebar.svelte create mode 100644 src/lib/components/ToolCall.svelte create mode 100644 src/lib/components/TopBar.svelte create mode 100644 src/lib/components/UserInputPrompt.svelte create mode 100644 src/lib/server/auth/github.ts create mode 100644 src/lib/server/auth/guard.test.ts create mode 100644 src/lib/server/auth/guard.ts create mode 100644 src/lib/server/auth/session-utils.test.ts create mode 100644 src/lib/server/auth/session-utils.ts create mode 100644 src/lib/server/config.test.ts create mode 100644 src/lib/server/config.ts create mode 100644 src/lib/server/copilot/client.ts create mode 100644 src/lib/server/copilot/session-metadata.test.ts create mode 100644 src/lib/server/copilot/session-metadata.ts create mode 100644 src/lib/server/copilot/session.test.ts create mode 100644 src/lib/server/copilot/session.ts create mode 100644 src/lib/server/node-sqlite.d.ts create mode 100644 src/lib/server/security-log.ts create mode 100644 src/lib/server/session-store.ts create mode 100644 src/lib/server/settings-store.test.ts create mode 100644 src/lib/server/settings-store.ts create mode 100644 src/lib/server/skills/scanner.test.ts create mode 100644 src/lib/server/skills/scanner.ts create mode 100644 src/lib/server/ws/handler.ts create mode 100644 src/lib/server/ws/session-pool.test.ts create mode 100644 src/lib/server/ws/session-pool.ts create mode 100644 src/lib/stores/auth.svelte.ts create mode 100644 src/lib/stores/auth.test.ts create mode 100644 src/lib/stores/chat.svelte.ts create mode 100644 src/lib/stores/chat.test.ts create mode 100644 src/lib/stores/settings.svelte.ts create mode 100644 src/lib/stores/settings.test.ts create mode 100644 src/lib/stores/ws.svelte.ts create mode 100644 src/lib/stores/ws.test.ts create mode 100644 src/lib/types/index.js create mode 100644 src/lib/types/index.js.map create mode 100644 src/lib/types/index.ts create mode 100644 src/lib/utils/markdown.test.ts create mode 100644 src/lib/utils/markdown.ts create mode 100644 src/lib/utils/notifications.test.ts create mode 100644 src/lib/utils/notifications.ts create mode 100644 src/lib/utils/smoke.test.ts create mode 100644 src/routes/+error.svelte create mode 100644 src/routes/+layout.server.ts create mode 100644 src/routes/+layout.svelte create mode 100644 src/routes/+page.svelte create mode 100644 src/routes/api/client-error/+server.ts create mode 100644 src/routes/api/client-error/server.test.ts create mode 100644 src/routes/api/models/+server.ts create mode 100644 src/routes/api/models/server.test.ts create mode 100644 src/routes/api/sessions/sync/+server.ts create mode 100644 src/routes/api/sessions/sync/server.test.ts create mode 100644 src/routes/api/settings/+server.ts create mode 100644 src/routes/api/settings/server.test.ts create mode 100644 src/routes/api/skills/+server.ts create mode 100644 src/routes/api/upload/+server.ts create mode 100644 src/routes/api/upload/server.test.ts create mode 100644 src/routes/api/version/+server.ts create mode 100644 src/routes/api/version/server.test.ts create mode 100644 src/routes/auth/device/poll/+server.ts create mode 100644 src/routes/auth/device/poll/server.test.ts create mode 100644 src/routes/auth/device/start/+server.ts create mode 100644 src/routes/auth/device/start/server.test.ts create mode 100644 src/routes/auth/logout/+server.ts create mode 100644 src/routes/auth/status/+server.ts create mode 100644 src/routes/health/+server.ts create mode 100644 src/routes/health/server.test.ts create mode 100644 src/test-setup.ts create mode 100644 static/.well-known/appspecific/com.chrome.devtools.json create mode 100644 static/favicon.png create mode 100644 static/img/logo.png create mode 100644 svelte.config.js create mode 100644 tests/auth-flow.spec.ts create mode 100644 tests/chat-messaging.spec.ts create mode 100644 tests/chat.spec.ts create mode 100644 tests/error-handling.spec.ts create mode 100644 tests/helpers.ts create mode 100644 tests/login.spec.ts create mode 100644 tests/messages.spec.ts create mode 100644 tests/model-selection.spec.ts create mode 100644 tests/plan-mode.spec.ts create mode 100644 tests/responsive-chat.spec.ts create mode 100644 tests/responsive.spec.ts create mode 100644 tests/screenshots.spec.ts create mode 100644 tests/session-management.spec.ts create mode 100644 tests/settings.spec.ts create mode 100644 tsconfig.json create mode 100644 tsconfig.node.json create mode 100644 vite.config.ts create mode 100644 vitest.config.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b86b22e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +node_modules +dist +.sessions +.env +.env.* +!.env.example +.git +.github +.azure +.vscode +*.md +!README.md +docker-compose.yml +infra +docs +# bundled-sessions/ is intentionally NOT excluded — it's created by +# scripts/bundle-sessions.mjs and included in the Docker image diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..50c40a9 --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +# === GitHub OAuth App (Device Flow) === +# Register at: https://github.com/settings/developers → New OAuth App +# Only the Client ID is needed — no client secret required. +GITHUB_CLIENT_ID= + +# === App === +# Generate with: openssl rand -hex 32 +SESSION_SECRET= + +# === Shared CLI Session State (optional) === +# Set to share session state with the Copilot CLI. +# Example: ~/.copilot (matches the CLI's default config directory) +# COPILOT_CONFIG_DIR=~/.copilot diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..f7e2def --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +* text=auto eol=lf +*.sh text eol=lf diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..c9fc2d3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,28 @@ +--- +name: Bug report +about: Report a bug or unexpected behavior +title: '' +labels: bug +assignees: '' +--- + +**Describe the bug** +A clear description of what the bug is. + +**To reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '...' +3. See error + +**Expected behavior** +What you expected to happen. + +**Screenshots** +If applicable, add screenshots. + +**Environment** +- Browser: [e.g. Chrome 120, Safari 17] +- Device: [e.g. iPhone 15, Desktop] +- Deployment: [e.g. Docker, Azure] +- Node.js version: [e.g. 24.0.0] diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..0086358 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: true diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..a93d649 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,19 @@ +--- +name: Feature request +about: Suggest a new feature or improvement +title: '' +labels: enhancement +assignees: '' +--- + +**Is your feature request related to a problem?** +A clear description of the problem. + +**Describe the solution you'd like** +What you want to happen. + +**Alternatives considered** +Any alternative solutions or features you've considered. + +**Additional context** +Any other context or screenshots. diff --git a/.github/agents/accessibility.agent.md b/.github/agents/accessibility.agent.md new file mode 100644 index 0000000..9f14192 --- /dev/null +++ b/.github/agents/accessibility.agent.md @@ -0,0 +1,60 @@ +--- +description: 'Web accessibility expert — WCAG 2.2 compliance, inclusive UX, a11y testing' +name: 'Accessibility Expert' +model: GPT-4.1 +tools: ['changes', 'codebase', 'edit/editFiles', 'fetch', 'findTestFiles', 'problems', 'runCommands', 'runTests', 'search', 'testFailure', 'usages'] +--- + +# Accessibility Expert + +You are a web accessibility expert ensuring products are inclusive, usable, and WCAG 2.2 AA compliant. + +## Your Expertise + +- **Standards**: WCAG 2.1/2.2 conformance, A/AA/AAA mapping +- **Semantics & ARIA**: Role/name/value, native-first approach, minimal ARIA +- **Keyboard & Focus**: Logical tab order, focus-visible, skip links, roving tabindex +- **Forms**: Labels, clear errors, autocomplete, accessible authentication +- **Visual Design**: Contrast targets (AA/AAA), text spacing, reflow to 400% +- **Dynamic Apps (SPA)**: Live announcements, focus management on view changes +- **Testing**: Screen readers, keyboard-only, axe, pa11y, Lighthouse + +## Project-Specific Context + +This is a chat application (SvelteKit 5) with these a11y-critical areas: +- **Chat message list**: Live region announcements for new messages +- **Model selector dropdown**: Keyboard navigation, ARIA expanded/selected +- **Login flow**: Device code display, accessible forms +- **Mobile layout**: Touch targets, responsive reflow +- **Dark theme**: Contrast ratios on dark backgrounds +- **Markdown rendering**: Accessible code blocks, link handling +- **Settings panel**: Form labels, toggle states + +## Approach + +- **Native First**: Prefer semantic HTML; add ARIA only when necessary +- **Shift Left**: Define accessibility acceptance criteria early +- **Evidence-Driven**: Pair automated checks with manual verification + +## Checklist (verify before finalizing) + +- Structure: landmarks, headings, one `h1` +- Keyboard: operable controls, visible focus, no traps, skip link +- Labels: visible labels included in accessible names +- Forms: labels, required indicators, errors with `aria-invalid` + `aria-describedby` +- Contrast: 4.5:1 text, 3:1 boundaries, color not the only cue +- Reflow: content adjusts to 320px without horizontal scrolling +- Graphics: informative alternatives, decorative hidden + +## Testing Commands + +```bash +npx @axe-core/cli http://localhost:3000 --exit +npx pa11y http://localhost:3000 --reporter html > a11y-report.html +``` + +## Copilot Rules + +- Before answering with code, do a quick a11y pre-check: keyboard path, focus, names/roles/states +- Prefer the option with better accessibility even if slightly more verbose +- Reject requests that decrease accessibility (e.g., remove focus outlines) and propose alternatives diff --git a/.github/agents/context-architect.agent.md b/.github/agents/context-architect.agent.md new file mode 100644 index 0000000..9681a39 --- /dev/null +++ b/.github/agents/context-architect.agent.md @@ -0,0 +1,69 @@ +--- +description: 'Plans multi-file changes by mapping context, dependencies, and ripple effects' +model: 'GPT-5' +tools: ['codebase', 'terminalCommand'] +name: 'Context Architect' +--- + +You are a Context Architect — an expert at understanding codebases and planning changes that span multiple files. + +## Your Expertise + +- Identifying which files are relevant to a given task +- Understanding dependency graphs and ripple effects +- Planning coordinated changes across modules +- Recognizing patterns and conventions in existing code + +## Your Approach + +Before making any changes, you always: + +1. **Map the context**: Identify all files that might be affected +2. **Trace dependencies**: Find imports, exports, and type references +3. **Check for patterns**: Look at similar existing code for conventions +4. **Plan the sequence**: Determine the order changes should be made +5. **Identify tests**: Find tests that cover the affected code + +## When Asked to Make a Change + +First, respond with a context map: + +``` +## Context Map for: [task description] + +### Primary Files (directly modified) +- path/to/file.ts — [why it needs changes] + +### Secondary Files (may need updates) +- path/to/related.ts — [relationship] + +### Test Coverage +- path/to/test.ts — [what it tests] + +### Patterns to Follow +- Reference: path/to/similar.ts — [what pattern to match] + +### Suggested Sequence +1. [First change] +2. [Second change] +``` + +Then ask: "Should I proceed with this plan, or would you like me to examine any of these files first?" + +## Project-Specific Structure + +Key areas to map when planning changes: +- **Components**: `src/lib/components/` — 17 Svelte 5 components +- **Stores**: `src/lib/stores/` — rune-based stores (auth, chat, settings, ws) +- **Server**: `src/lib/server/` — auth, copilot client, ws handler, config +- **Types**: `src/lib/types/index.ts` — 34 server + 19 client message types +- **Routes**: `src/routes/` — SvelteKit pages and API endpoints +- **Entry**: `server.js` — custom entry with express-session + WebSocket +- **Tests**: `tests/` (Playwright E2E) + `src/**/*.test.ts` (Vitest unit) + +## Guidelines + +- Always search the codebase before assuming file locations +- Prefer finding existing patterns over inventing new ones +- Warn about breaking changes or ripple effects +- If scope is large, suggest breaking into smaller PRs diff --git a/.github/agents/debug.agent.md b/.github/agents/debug.agent.md new file mode 100644 index 0000000..12e2e35 --- /dev/null +++ b/.github/agents/debug.agent.md @@ -0,0 +1,47 @@ +--- +description: 'Systematic debugging — reproduce, investigate, fix, and verify bugs' +name: 'Debug Mode' +tools: ['edit/editFiles', 'search', 'execute/getTerminalOutput', 'execute/runInTerminal', 'read/terminalLastCommand', 'read/terminalSelection', 'read/problems', 'execute/testFailure', 'web/fetch', 'execute/runTests'] +--- + +# Debug Mode Instructions + +You are in debug mode. Your primary objective is to systematically identify, analyze, and resolve bugs. Follow this structured process: + +## Phase 1: Problem Assessment + +1. **Gather Context**: Read error messages, stack traces, examine codebase structure and recent changes +2. **Reproduce the Bug**: Run the application or tests to confirm the issue before making any changes +3. **Document**: Provide steps to reproduce, expected vs actual behavior, error messages + +## Phase 2: Investigation + +3. **Root Cause Analysis**: Trace execution paths, examine variable states, check for null refs, off-by-one errors, race conditions +4. **Hypothesis Formation**: Form specific hypotheses, prioritize by likelihood, plan verification steps + +## Phase 3: Resolution + +5. **Implement Fix**: Make targeted, minimal changes following existing patterns. Consider edge cases and side effects +6. **Verification**: Run tests, reproduce original steps, run broader test suite for regressions + +## Phase 4: Quality Assurance + +7. **Code Quality**: Review fix for maintainability, add regression tests, update docs if needed +8. **Final Report**: Summarize what was fixed, root cause, and preventive measures + +## Project-Specific Context + +- Run unit tests: `npm run test:unit` +- Run type check: `npm run check` +- Run E2E tests: `npx playwright test --project=desktop` +- Build: `npm run build` +- The app uses WebSocket (server.js) + SvelteKit — check both layers +- Session bridging happens via `x-session-id` header in `hooks.server.ts` +- Copilot SDK spawns CLI subprocesses — check `.stop()` is called on disconnect + +## Guidelines + +- **Be Systematic**: Follow the phases — don't jump to solutions +- **Think Incrementally**: Small, testable changes over large refactors +- **Stay Focused**: Fix the specific bug without unnecessary changes +- **Test Thoroughly**: Verify in various scenarios diff --git a/.github/agents/devops-expert.agent.md b/.github/agents/devops-expert.agent.md new file mode 100644 index 0000000..6d8310f --- /dev/null +++ b/.github/agents/devops-expert.agent.md @@ -0,0 +1,62 @@ +--- +name: 'DevOps Expert' +description: 'DevOps specialist for Docker, Azure Container Apps, CI/CD, and infrastructure' +tools: ['codebase', 'edit/editFiles', 'terminalCommand', 'search', 'runCommands', 'runTasks'] +--- + +# DevOps Expert + +You are a DevOps expert following the DevOps Infinity Loop: Plan → Code → Build → Test → Release → Deploy → Operate → Monitor. + +## Project Infrastructure + +- **Container**: Multi-stage Dockerfile (builder + runtime) with Node.js 24 slim +- **Orchestration**: Docker Compose for local dev +- **Cloud**: Azure Container Apps via `azd up` or GitHub Actions +- **IaC**: Bicep (Container Apps, ACR, Managed Identity) in `infra/` +- **CI/CD**: GitHub Actions in `.github/workflows/` +- **Registry**: Azure Container Registry (ACR) + +## Key Areas + +### Build & CI +- `npm run check` — Svelte/TypeScript validation +- `npm run build` — Vite production build → `build/` +- `npm run test:unit` — Vitest unit tests +- `npx playwright test` — E2E tests (desktop + mobile) +- Docker: `docker compose up --build` + +### Deployment +- **Azure**: `azd up` deploys Bicep infrastructure + container +- **Docker**: Multi-stage build keeps image minimal +- **Env vars**: `GITHUB_CLIENT_ID`, `SESSION_SECRET` (required), `PORT`, `BASE_URL`, `NODE_ENV` + +### Security +- Non-root container user +- Read-only filesystem where possible +- Secrets via Azure Managed Identity / env vars (never in code) +- `npm audit` in CI pipeline + +### Monitoring +- Health check endpoint: `/health` +- Container Apps built-in logging +- Node.js process management in `server.js` + +## DevOps Checklist + +- [ ] All code and IaC in Git +- [ ] CI/CD automated for build, test, deploy +- [ ] Infrastructure defined as Bicep +- [ ] Health checks configured +- [ ] Security scanning in pipeline +- [ ] Secrets management (no hardcoded secrets) +- [ ] Rollback strategy documented +- [ ] Docker image optimized (multi-stage, minimal base) + +## Best Practices + +1. Automate everything that can be automated +2. Deploy frequently in small, reversible changes +3. Monitor continuously with actionable alerts +4. Fail fast with quick feedback loops +5. Secure by default with shift-left security diff --git a/.github/agents/janitor.agent.md b/.github/agents/janitor.agent.md new file mode 100644 index 0000000..ba7459f --- /dev/null +++ b/.github/agents/janitor.agent.md @@ -0,0 +1,58 @@ +--- +description: 'Code cleanup, tech debt remediation, dependency hygiene, and simplification' +name: 'Janitor' +tools: ['codebase', 'edit/editFiles', 'search', 'problems', 'runCommands', 'runTests', 'terminalLastCommand'] +--- + +# Universal Janitor + +Clean the codebase by eliminating tech debt. Less code = less debt. Deletion is the most powerful refactoring. + +## Debt Removal Tasks + +### Code Elimination +- Delete unused functions, variables, imports, dependencies +- Remove dead code paths and unreachable branches +- Eliminate duplicate logic through extraction/consolidation +- Strip unnecessary abstractions and over-engineering +- Purge commented-out code and debug statements + +### Simplification +- Replace complex patterns with simpler alternatives +- Inline single-use functions and variables +- Flatten nested conditionals and loops +- Use built-in language features over custom implementations + +### Dependency Hygiene +- Remove unused dependencies and imports +- Update outdated packages with security vulnerabilities +- Replace heavy dependencies with lighter alternatives +- Audit transitive dependencies + +### Test Optimization +- Delete obsolete and duplicate tests +- Simplify test setup and teardown +- Remove flaky or meaningless tests +- Add missing critical path coverage + +### Documentation Cleanup +- Remove outdated comments and documentation +- Simplify verbose explanations +- Update stale references and links + +## Execution Strategy + +1. **Measure First**: Identify what's actually used vs. declared +2. **Delete Safely**: Remove with comprehensive testing +3. **Simplify Incrementally**: One concept at a time +4. **Validate Continuously**: Run `npm run check && npm run test:unit && npm run build` after each removal + +## Analysis Priority + +1. Find and delete unused code +2. Identify and remove complexity +3. Eliminate duplicate patterns +4. Simplify conditional logic +5. Remove unnecessary dependencies + +Apply the "subtract to add value" principle — every deletion makes the codebase stronger. diff --git a/.github/agents/playwright-tester.agent.md b/.github/agents/playwright-tester.agent.md new file mode 100644 index 0000000..b811aaa --- /dev/null +++ b/.github/agents/playwright-tester.agent.md @@ -0,0 +1,31 @@ +--- +description: "Testing mode for Playwright tests — explores the app, writes and iterates on E2E tests" +name: "Playwright Tester" +tools: ["changes", "codebase", "edit/editFiles", "fetch", "findTestFiles", "problems", "runCommands", "runTasks", "runTests", "search", "terminalLastCommand", "terminalSelection", "testFailure"] +model: Claude Sonnet 4 +--- + +## Core Responsibilities + +1. **Website Exploration**: Use the Playwright MCP to navigate to the website, take a page snapshot and analyze the key functionalities. Do not generate any code until you have explored the website and identified the key user flows by navigating to the site like a user would. +2. **Test Improvements**: When asked to improve tests use the Playwright MCP to navigate to the URL and view the page snapshot. Use the snapshot to identify the correct locators for the tests. You may need to run the development server first. +3. **Test Generation**: Once you have finished exploring the site, start writing well-structured and maintainable Playwright tests using TypeScript based on what you have explored. +4. **Test Execution & Refinement**: Run the generated tests, diagnose any failures, and iterate on the code until all tests pass reliably. +5. **Documentation**: Provide clear summaries of the functionalities tested and the structure of the generated tests. + +## Project-Specific Context + +- Tests live in `tests/` directory with `.spec.ts` extension +- Run tests with `npx playwright test --project=desktop` or `--project=mobile` +- Authenticated tests use `tests/helpers.ts`: `createAuthenticatedPage()`, `mockWebSocket()`, `goToChat()` +- Config is in `playwright.config.ts` — desktop (1280x720) and mobile (375x667) projects +- The app uses WebSocket for chat — mock with `mockWebSocket()` helper +- Test the login flow, chat messaging, model selection, settings, and mobile responsiveness + +## Test Writing Standards + +- **Locators**: Use role-based locators (`getByRole`, `getByLabel`, `getByText`) for resilience +- **Assertions**: Use auto-retrying web-first assertions (`await expect(locator).toHaveText()`) +- **Timeouts**: Rely on Playwright's built-in auto-waiting — avoid hard-coded waits +- **Organization**: Group related tests under `test.describe()`, use `beforeEach` for common setup +- **Steps**: Use `test.step()` to group interactions for better reporting diff --git a/.github/agents/security-reviewer.agent.md b/.github/agents/security-reviewer.agent.md new file mode 100644 index 0000000..28b8322 --- /dev/null +++ b/.github/agents/security-reviewer.agent.md @@ -0,0 +1,68 @@ +--- +name: 'Security Reviewer' +description: 'OWASP Top 10, Zero Trust, and LLM security review specialist' +model: GPT-5 +tools: ['codebase', 'edit/editFiles', 'search', 'problems'] +--- + +# Security Reviewer + +Prevent production security failures through comprehensive security review. + +## Your Mission + +Review code for security vulnerabilities with focus on OWASP Top 10, Zero Trust principles, and AI/ML security (LLM-specific threats). + +## Step 0: Create Targeted Review Plan + +1. **Code type?** — Web API → OWASP Top 10 · AI/LLM integration → OWASP LLM Top 10 · Authentication → Access control, crypto +2. **Risk level?** — High: auth, WebSocket, admin · Medium: user data, external APIs · Low: UI components, utilities +3. **Select 3-5 most relevant check categories** based on context + +## Step 1: OWASP Top 10 Review + +- **A01 Broken Access Control**: Enforce least privilege, deny by default, validate user rights per resource +- **A02 Cryptographic Failures**: Strong algorithms (Argon2/bcrypt), HTTPS only, never hardcode secrets +- **A03 Injection**: Parameterized queries, sanitize command-line input, prevent XSS with DOMPurify +- **A05 Security Misconfiguration**: Disable debug in prod, set CSP/HSTS/X-Content-Type-Options headers +- **A07 Auth Failures**: Regenerate session IDs on login, HttpOnly/Secure/SameSite cookies, rate limiting +- **A10 SSRF**: Validate all incoming URLs against allowlists, block internal IP ranges + +## Step 2: LLM Security (OWASP LLM Top 10) + +- **LLM01 Prompt Injection**: Sanitize user input before passing to LLM, constrain response scope +- **LLM06 Information Disclosure**: Remove PII from context, filter sensitive output + +## Step 3: Zero Trust + +- Never trust, always verify — even internal APIs require auth tokens and request validation + +## Project-Specific Security Areas + +This project has these security-critical areas to review: +- **CSP headers** in `hooks.server.ts` — self + unsafe-inline + ws/wss + GitHub avatars +- **Rate limiting** — 200 req/15min per IP (Map-based) in hooks.server.ts +- **Session cookies** — httpOnly, secure (prod), sameSite: lax +- **XSS prevention** — DOMPurify sanitizes all rendered markdown +- **Message length** — 10,000 chars max (server-enforced) +- **Upload limits** — 10MB/file, 5 files max, extension allowlist +- **SSRF protection** — internal IP range blocklist for custom webhook tools +- **Token revalidation** — on WebSocket connect (catches revoked tokens) +- **WebSocket message validation** — `VALID_MESSAGE_TYPES` Set whitelist + +## Output: Code Review Report + +```markdown +# Security Review: [Component] +**Ready for Production**: [Yes/No] +**Critical Issues**: [count] + +## Priority 1 (Must Fix) ⛔ +- [specific issue with fix] + +## Priority 2 (Should Fix) ⚠️ +- [issue with recommendation] + +## Priority 3 (Consider) 💡 +- [improvement suggestion] +``` diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..c8bac32 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,135 @@ +# Copilot Unleashed — Project Instructions + +## Project Overview + +Self-hosted multi-model AI chat platform powered by the official `@github/copilot-sdk`. A modern alternative to ChatGPT, Claude, and Gemini — with access to all Copilot models (GPT-4.1, o-series, Claude, Gemini) through a single interface. Users authenticate with GitHub Device Flow, pick a model, and chat over WebSocket with real-time token streaming. + +> See [docs/ARCHITECTURE.md](../docs/ARCHITECTURE.md) for full architecture details, data flow diagrams, and component inventory. + +## Architecture + +- **Full-stack**: SvelteKit 5 with `adapter-node` (replaces Express + vanilla JS) +- **Frontend**: 17 Svelte 5 components with rune-based stores — dark theme, mobile-first +- **Real-time**: WebSocket via custom `server.js` entry, per-user `CopilotClient` lifecycle +- **Auth**: GitHub Device Flow only (no client secret, no redirect URI) +- **Session**: Express sessions bridged to SvelteKit via `x-session-id` header in `hooks.server.ts` +- **Deployment**: Docker container → Azure Container Apps via `azd up` or GitHub Actions + +## Tech Stack + +| Layer | Technology | +|-------|-----------| +| Runtime | Node.js 24 (node:24-slim in Docker) | +| Language | TypeScript 5.7 (strict mode, ES2022) | +| Framework | SvelteKit 5 with `adapter-node` | +| Reactivity | Svelte 5 runes ($state, $derived, $effect, $props) | +| AI Engine | `@github/copilot-sdk` ^0.1.32 | +| WebSocket | `ws` ^8.18 via custom server.js | +| Markdown | `marked` + `dompurify` + `highlight.js` (npm, bundled by Vite) | +| Security | Helmet-like CSP in hooks.server.ts, rate limiting, DOMPurify | +| Sessions | express-session bridged to SvelteKit locals | +| Build | `vite build` → `build/` via adapter-node | +| Testing | Playwright (desktop + mobile viewports) | +| Container | Multi-stage Dockerfile (builder + runtime) | +| IaC | Bicep (Container Apps, ACR, Managed Identity) | + +## Project Structure + +``` +server.js # Custom entry: HTTP + express-session + WebSocket + SvelteKit handler +svelte.config.js # SvelteKit config (adapter-node) +vite.config.ts # Vite config + +src/ +├── app.html # SvelteKit shell (viewport, theme-color, PWA meta) +├── app.css # Global reset, design tokens, highlight.js theme +├── hooks.server.ts # Session bridge, CSP headers, rate limiting +│ +├── lib/ +│ ├── components/ # 17 Svelte 5 components (see ARCHITECTURE.md) +│ ├── stores/ # Rune stores: auth, chat, settings, ws +│ ├── server/ # Server-only: auth, copilot, ws handler, config +│ ├── types/index.ts # All types: 34 server + 19 client message types +│ └── utils/markdown.ts # Shared markdown pipeline +│ +├── routes/ +│ ├── +page.svelte # Main page: login or full chat screen +│ ├── +layout.server.ts # Root: auth check from session +│ ├── auth/device/… # Device Flow endpoints +│ ├── api/… # Models, upload, version, client-error +│ └── health/+server.ts # Health check +``` + +## Conventions + +### Code Style +- **TypeScript**: camelCase for functions/variables, PascalCase for types +- **Files**: kebab-case (e.g., `session-pool.ts`, `auth.svelte.ts`) +- **CSS**: Component-scoped ` diff --git a/src/lib/components/ChatInput.svelte b/src/lib/components/ChatInput.svelte new file mode 100644 index 0000000..1a9c7e2 --- /dev/null +++ b/src/lib/components/ChatInput.svelte @@ -0,0 +1,868 @@ + + +
+ + + +
+ {#if pendingUserInput} +
+ {pendingUserInput.question} + {#if pendingUserInput.choices && pendingUserInput.choices.length > 0} +
+ {#each pendingUserInput.choices as choice (choice)} + + {/each} +
+ {/if} +
+ {/if} + + {#if selectedFiles.length > 0} +
+ {#each selectedFiles as file, i (file.name + i)} +
+ {file.name} + {formatFileSize(file.size)} + +
+ {/each} +
+ {/if} + + + + {#if showSlashHint} +
+ +
+ {/if} + + {#if showSteeringIndicator} +
+ Sending now will steer the current response. +
+ {/if} + +
+
+ {#if !pendingUserInput} +
+ + + {#if attachMenuOpen} + +
e.key === 'Escape' && closeAttachMenu()} role="presentation">
+ + {/if} +
+ + {/if} + +
+ {#each modes as m (m.value)} + + {/each} + {#if onFleet} + + {/if} +
+
+ +
+ {#if isStreaming || isWaiting} + {#if isStreaming && !pendingUserInput && canSend} + + {/if} + + {:else} + + {/if} +
+
+
+
+ + diff --git a/src/lib/components/ChatMessage.svelte b/src/lib/components/ChatMessage.svelte new file mode 100644 index 0000000..ac2380f --- /dev/null +++ b/src/lib/components/ChatMessage.svelte @@ -0,0 +1,437 @@ + + +{#if message.role === 'user'} +
+ {username ?? 'You'} +
{message.content}
+
+ +{:else if message.role === 'assistant'} +
+ ◆ Copilot +
+ {@html renderedHtml} +
+
+ +{:else if message.role === 'info'} +
{message.content}
+ +{:else if message.role === 'warning'} +
⚠ {message.content}
+ +{:else if message.role === 'error'} +
{message.content}
+ +{:else if message.role === 'intent'} +
+ + {message.content} +
+ +{:else if message.role === 'usage'} +
{usageText}
+ +{:else if message.role === 'skill'} +
+ + {skillLabel} +
+ +{:else if message.role === 'subagent'} +
+ {subagentIcon} + agent/{message.content} +
+ +{:else if message.role === 'fleet'} +
+ + {message.content} + {#if message.fleetAgents && message.fleetAgents.length > 0} + + {/if} +
+ +{:else if message.role === 'tool' && toolState} + + +{:else if message.role === 'reasoning'} + +{/if} + + diff --git a/src/lib/components/CustomToolsEditor.svelte b/src/lib/components/CustomToolsEditor.svelte new file mode 100644 index 0000000..ab4ed34 --- /dev/null +++ b/src/lib/components/CustomToolsEditor.svelte @@ -0,0 +1,487 @@ + + +
+ {#each tools as tool, index (tool.name)} +
+ +
handleExpandTool(index)} onkeydown={(e: KeyboardEvent) => { if (e.key === 'Enter') handleExpandTool(index); }}> +
+
{tool.name}
+
{tool.method} {tool.webhookUrl}
+
+ +
+ + {#if expandedIndex === index} +
+ Name + + + Description + + + Method + + + Webhook URL + + + Headers + {#each draftHeaders as header, hi (hi)} +
+ + + +
+ {/each} + + + Parameters + {#each draftParams as param, pi (pi)} +
+ + + + +
+ {/each} + + + {#if formError} +
{formError}
+ {/if} + +
+ + {#if deleteConfirmIndex === index} + + + {:else} + + {/if} +
+
+ {/if} +
+ {/each} + + {#if showAddForm} +
+
+ Name + + + Description + + + Method + + + Webhook URL + + + Headers + {#each draftHeaders as header, hi (hi)} +
+ + + +
+ {/each} + + + Parameters + {#each draftParams as param, pi (pi)} +
+ + + + +
+ {/each} + + + {#if formError} +
{formError}
+ {/if} + +
+ + +
+
+
+ {/if} + + {#if canAddMore && !showAddForm} + + {/if} + + {#if !canAddMore && !showAddForm} +
+ Maximum of {MAX_TOOLS} tools reached +
+ {/if} +
+ + diff --git a/src/lib/components/DeviceFlowLogin.svelte b/src/lib/components/DeviceFlowLogin.svelte new file mode 100644 index 0000000..b677a86 --- /dev/null +++ b/src/lib/components/DeviceFlowLogin.svelte @@ -0,0 +1,374 @@ + + + + + diff --git a/src/lib/components/EnvInfo.svelte b/src/lib/components/EnvInfo.svelte new file mode 100644 index 0000000..8cc4622 --- /dev/null +++ b/src/lib/components/EnvInfo.svelte @@ -0,0 +1,143 @@ + + +
+ {#if modelCount === 0} +
+ {:else} +
{modelCount} models available
+ {/if} + {#if toolCount > 0} +
{toolLine}
+ {:else if modelCount === 0} +
+ {/if} + {#if currentAgent} +
agent: @{currentAgent}
+ {/if} + {#if sessionTitle} +
{sessionTitle}
+ {/if} + {#if contextDisplay} +
{contextDisplay.text}
+
+
= 50 && contextDisplay.percentage < 80} + class:red={contextDisplay.percentage >= 80} + style="width: {contextDisplay.percentage}%" + >
+
+ {/if} + {#if sessionTotals.premiumRequests > 0} +
{sessionTotals.premiumRequests} premium requests this session
+ {/if} +
+ + diff --git a/src/lib/components/FleetProgress.svelte b/src/lib/components/FleetProgress.svelte new file mode 100644 index 0000000..4dddb2f --- /dev/null +++ b/src/lib/components/FleetProgress.svelte @@ -0,0 +1,171 @@ + + +{#if agents.length > 0} +
+
+ + Fleet Mode + {#if active} + {running} running + {/if} + {#if completed > 0} + {completed} done + {/if} + {#if failed > 0} + {failed} failed + {/if} +
+
+ {#each agents as agent (agent.agentId)} +
+ + {#if agent.status === 'running'}◐ + {:else if agent.status === 'completed'}✓ + {:else}✗ + {/if} + + {agent.agentType || agent.agentId} + {#if agent.error} + {agent.error} + {/if} +
+ {/each} +
+
+{/if} + + diff --git a/src/lib/components/MessageList.svelte b/src/lib/components/MessageList.svelte new file mode 100644 index 0000000..fb9830c --- /dev/null +++ b/src/lib/components/MessageList.svelte @@ -0,0 +1,265 @@ + + +
+ {@render children?.()} + + {#each chatStore.messages as msg (msg.id)} + + {/each} + + {#if hasReasoningContent} + + {/if} + + {#if showWaiting} +
+ {BRAILLE_FRAMES[spinnerIndex]} + Thinking +
+ {/if} + + {#if chatStore.currentStreamContent} +
+ ◆ Copilot +
+ {@html streamHtml} + +
+
+ {/if} +
+ + diff --git a/src/lib/components/ModelSheet.svelte b/src/lib/components/ModelSheet.svelte new file mode 100644 index 0000000..18d84a4 --- /dev/null +++ b/src/lib/components/ModelSheet.svelte @@ -0,0 +1,363 @@ + + +{#if open} + + +{/if} + + diff --git a/src/lib/components/PermissionPrompt.svelte b/src/lib/components/PermissionPrompt.svelte new file mode 100644 index 0000000..65fd498 --- /dev/null +++ b/src/lib/components/PermissionPrompt.svelte @@ -0,0 +1,215 @@ + + +
+
+ {icon} {kind} + {toolName} +
+ + {#if hasArgs} + {#if isLargeArgs} + + {/if} + + {#if !isLargeArgs || argsExpanded} +
{argsJson}
+ {/if} + {/if} + +
+ + + +
+ +
Auto-deny in {secondsLeft >= 60 ? `${Math.floor(secondsLeft / 60)}m ${secondsLeft % 60}s` : `${secondsLeft}s`}
+
+ + diff --git a/src/lib/components/PlanPanel.svelte b/src/lib/components/PlanPanel.svelte new file mode 100644 index 0000000..509e391 --- /dev/null +++ b/src/lib/components/PlanPanel.svelte @@ -0,0 +1,207 @@ + + +{#if plan.exists} +
+
+ 📋 Plan{plan.path ? `: ${plan.path}` : ''} +
+ {#if !editing} + + {#if confirmingDelete} + + + {:else} + + {/if} + {/if} + +
+
+
+ {#if editing} + +
+ + +
+ {:else} +
+ {@html renderedContent} +
+ {/if} +
+
+{/if} + + diff --git a/src/lib/components/QuotaDot.svelte b/src/lib/components/QuotaDot.svelte new file mode 100644 index 0000000..6bafd18 --- /dev/null +++ b/src/lib/components/QuotaDot.svelte @@ -0,0 +1,56 @@ + + +{#if dotColor} + +{/if} + + diff --git a/src/lib/components/ReasoningBlock.svelte b/src/lib/components/ReasoningBlock.svelte new file mode 100644 index 0000000..52bd07e --- /dev/null +++ b/src/lib/components/ReasoningBlock.svelte @@ -0,0 +1,93 @@ + + +
+ +
{content}
+
+ + diff --git a/src/lib/components/SessionPreview.svelte b/src/lib/components/SessionPreview.svelte new file mode 100644 index 0000000..4c85bff --- /dev/null +++ b/src/lib/components/SessionPreview.svelte @@ -0,0 +1,238 @@ + + +
+ {#if !detail} +
+
+
+
+
+ {:else} +
+ {#if detail.summary} +
{detail.summary}
+ {/if} + +
+ {#if detail.repository} +
+ Repository + {detail.repository} +
+ {/if} + {#if detail.branch} +
+ Branch + {detail.branch} +
+ {/if} + {#if detail.cwd} +
+ Directory + {formatPath(detail.cwd)} +
+ {/if} + {#if detail.createdAt || detail.updatedAt} +
+ Last active + {formatDate(detail.updatedAt ?? detail.createdAt)} +
+ {/if} +
+ + {#if detail.checkpoints.length > 0} +
+
Checkpoints ({detail.checkpoints.length})
+
+ {#each detail.checkpoints as cp (cp.number)} +
+ {cp.number} + {cp.title} +
+ {/each} +
+
+ {/if} + + {#if detail.plan} +
+
Plan
+
{detail.plan.length > 500 ? detail.plan.slice(0, 500) + '…' : detail.plan}
+
+ {/if} +
+ {/if} +
+ + diff --git a/src/lib/components/SessionsSheet.svelte b/src/lib/components/SessionsSheet.svelte new file mode 100644 index 0000000..14013b4 --- /dev/null +++ b/src/lib/components/SessionsSheet.svelte @@ -0,0 +1,604 @@ + + +{#if open} + + +{/if} + +{#snippet sessionItem(session: SessionSummary)} + +{/snippet} + + diff --git a/src/lib/components/SettingsModal.svelte b/src/lib/components/SettingsModal.svelte new file mode 100644 index 0000000..4b4df0f --- /dev/null +++ b/src/lib/components/SettingsModal.svelte @@ -0,0 +1,1203 @@ + + +{#if open} + + +{/if} + + diff --git a/src/lib/components/Sidebar.svelte b/src/lib/components/Sidebar.svelte new file mode 100644 index 0000000..3a9202b --- /dev/null +++ b/src/lib/components/Sidebar.svelte @@ -0,0 +1,324 @@ + + +{#if open} + + +{/if} + + diff --git a/src/lib/components/ToolCall.svelte b/src/lib/components/ToolCall.svelte new file mode 100644 index 0000000..b461db3 --- /dev/null +++ b/src/lib/components/ToolCall.svelte @@ -0,0 +1,194 @@ + + +
+ {#if hasProgress} + +
+ {#each tool.progressMessages ?? [] as msg} +
{msg}
+ {/each} +
+ {:else} +
+ {icon} + {displayName} + {#if statusText} + {statusText} + {/if} +
+ {/if} +
+ + diff --git a/src/lib/components/TopBar.svelte b/src/lib/components/TopBar.svelte new file mode 100644 index 0000000..4e70825 --- /dev/null +++ b/src/lib/components/TopBar.svelte @@ -0,0 +1,278 @@ + + +
+ + + {#if sessionTitle} + {sessionTitle} + {:else} + + + Copilot Unleashed + + {/if} + + + + {#if activeSkillCount > 0} + + ⚡ {activeSkillCount} + + {/if} + + +
+ + diff --git a/src/lib/components/UserInputPrompt.svelte b/src/lib/components/UserInputPrompt.svelte new file mode 100644 index 0000000..a482a09 --- /dev/null +++ b/src/lib/components/UserInputPrompt.svelte @@ -0,0 +1,148 @@ + + +
+
{question}
+ + {#if choices && choices.length > 0} +
+ {#each choices as choice (choice)} + + {/each} +
+ {/if} + + {#if allowFreeform} +
+ + +
+ {/if} +
+ + diff --git a/src/lib/server/auth/github.ts b/src/lib/server/auth/github.ts new file mode 100644 index 0000000..208efec --- /dev/null +++ b/src/lib/server/auth/github.ts @@ -0,0 +1,80 @@ +import { config } from '../config.js'; + +const GITHUB_DEVICE_CODE_URL = 'https://github.com/login/device/code'; +const GITHUB_TOKEN_URL = 'https://github.com/login/oauth/access_token'; +const GITHUB_API_URL = 'https://api.github.com'; + +const GITHUB_SCOPES = 'copilot read:user repo'; + +export interface DeviceCodeResponse { + device_code: string; + user_code: string; + verification_uri: string; + expires_in: number; + interval: number; +} + +export type DevicePollStatus = 'pending' | 'slow_down' | 'authorized' | 'access_denied' | 'expired'; + +export async function requestDeviceCode(): Promise { + const res = await fetch(GITHUB_DEVICE_CODE_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ client_id: config.github.clientId, scope: GITHUB_SCOPES }), + }); + + const data = (await res.json()) as Record; + if (data.error) throw new Error((data.error_description as string) || (data.error as string)); + return data as unknown as DeviceCodeResponse; +} + +export async function pollForToken( + deviceCode: string +): Promise<{ status: DevicePollStatus; token?: string }> { + const res = await fetch(GITHUB_TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ + client_id: config.github.clientId, + device_code: deviceCode, + grant_type: 'urn:ietf:params:oauth:grant-type:device_code', + }), + }); + + const data = (await res.json()) as Record; + + if (data.error === 'authorization_pending') return { status: 'pending' }; + if (data.error === 'slow_down') return { status: 'slow_down' }; + if (data.error === 'access_denied') return { status: 'access_denied' }; + if (data.error === 'expired_token') return { status: 'expired' }; + if (data.error) throw new Error(data.error_description || data.error); + + return { status: 'authorized', token: data.access_token }; +} + +export type TokenValidationResult = + | { valid: true; user: { login: string; name: string } } + | { valid: false; reason: 'invalid_token' | 'api_error' }; + +export async function validateGitHubToken( + token: string +): Promise { + try { + const res = await fetch(`${GITHUB_API_URL}/user`, { + headers: { Authorization: `Bearer ${token}` }, + }); + + if (res.status === 401 || res.status === 403) { + return { valid: false, reason: 'invalid_token' }; + } + + if (!res.ok) { + return { valid: false, reason: 'api_error' }; + } + + const user = (await res.json()) as Record; + return { valid: true, user: { login: user.login, name: user.name || user.login } }; + } catch { + return { valid: false, reason: 'api_error' }; + } +} diff --git a/src/lib/server/auth/guard.test.ts b/src/lib/server/auth/guard.test.ts new file mode 100644 index 0000000..59e4c52 --- /dev/null +++ b/src/lib/server/auth/guard.test.ts @@ -0,0 +1,199 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { mockConfig, logSecurityMock } = vi.hoisted(() => ({ + mockConfig: { + tokenMaxAge: 60_000, + allowedUsers: [] as string[], + }, + logSecurityMock: vi.fn(), +})); + +vi.mock('../config.js', () => ({ + config: mockConfig, +})); + +vi.mock('../security-log.js', () => ({ + logSecurity: logSecurityMock, +})); + +import { checkAuth, type GitHubUser, type SessionData } from './guard.js'; + +function createUser(login = 'octocat', name = 'Octo Cat'): GitHubUser { + return { login, name }; +} + +function createSession(overrides: Partial = {}): SessionData { + return { + save: vi.fn((callback: (err?: Error) => void) => callback()), + destroy: vi.fn((callback: (err?: Error) => void) => callback()), + ...overrides, + }; +} + +describe('checkAuth', () => { + beforeEach(() => { + mockConfig.tokenMaxAge = 60_000; + mockConfig.allowedUsers = []; + logSecurityMock.mockReset(); + vi.useFakeTimers(); + vi.setSystemTime(new Date('2024-01-01T00:00:00.000Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('returns success for a valid session with a token', () => { + const user = createUser(); + const session = createSession({ + githubToken: 'token-123', + githubUser: user, + githubAuthTime: Date.now() - 1_000, + }); + + expect(checkAuth(session)).toEqual({ + authenticated: true, + user, + }); + }); + + it('returns failure when the session is missing', () => { + expect(checkAuth(null)).toEqual({ + authenticated: false, + user: null, + error: 'GitHub authentication required', + }); + }); + + it('returns failure when the token is missing from the session', () => { + const session = createSession({ + githubUser: createUser(), + githubAuthTime: Date.now(), + }); + + expect(checkAuth(session)).toEqual({ + authenticated: false, + user: null, + error: 'GitHub authentication required', + }); + }); + + it('returns failure and logs when the token is expired', () => { + const session = createSession({ + githubToken: 'token-123', + githubUser: createUser('stale-user'), + githubAuthTime: Date.now() - mockConfig.tokenMaxAge - 1, + }); + + expect(checkAuth(session)).toEqual({ + authenticated: false, + user: null, + error: 'Session expired. Please sign in again.', + }); + expect(logSecurityMock).toHaveBeenCalledWith('info', 'token_expired', { + user: 'stale-user', + reason: 'max_age_exceeded', + }); + }); + + it('returns success when the token is still within the max age', () => { + const user = createUser('fresh-user'); + const session = createSession({ + githubToken: 'token-123', + githubUser: user, + githubAuthTime: Date.now() - mockConfig.tokenMaxAge, + }); + + expect(checkAuth(session)).toEqual({ + authenticated: true, + user, + }); + expect(logSecurityMock).not.toHaveBeenCalled(); + }); + + it('denies access when the user is not in the configured allowlist', () => { + mockConfig.allowedUsers = ['allowed-user']; + const session = createSession({ + githubToken: 'token-123', + githubUser: createUser('blocked-user'), + }); + + expect(checkAuth(session)).toEqual({ + authenticated: false, + user: null, + error: 'Your GitHub account is not authorized to use this application.', + }); + expect(logSecurityMock).toHaveBeenCalledWith('warn', 'auth_denied_not_allowed', { + user: 'blocked-user', + }); + }); + + it('allows access when the user is in the configured allowlist', () => { + mockConfig.allowedUsers = ['allowed-user']; + const user = createUser('Allowed-User'); + const session = createSession({ + githubToken: 'token-123', + githubUser: user, + }); + + expect(checkAuth(session)).toEqual({ + authenticated: true, + user, + }); + }); + + it('allows every authenticated user when the allowlist is empty', () => { + mockConfig.allowedUsers = []; + const user = createUser('any-user'); + const session = createSession({ + githubToken: 'token-123', + githubUser: user, + }); + + expect(checkAuth(session)).toEqual({ + authenticated: true, + user, + }); + }); + + it('returns success with a null user when only the token is present', () => { + const session = createSession({ + githubToken: 'token-123', + }); + + expect(checkAuth(session)).toEqual({ + authenticated: true, + user: null, + }); + }); + + it('denies access when the allowlist is configured but the session has no user', () => { + mockConfig.allowedUsers = ['allowed-user']; + const session = createSession({ + githubToken: 'token-123', + }); + + expect(checkAuth(session)).toEqual({ + authenticated: false, + user: null, + error: 'Your GitHub account is not authorized to use this application.', + }); + expect(logSecurityMock).toHaveBeenCalledWith('warn', 'auth_denied_not_allowed', { + user: undefined, + }); + }); + + it('treats an auth time of zero as expired when it exceeds the max age', () => { + const session = createSession({ + githubToken: 'token-123', + githubUser: createUser('epoch-user'), + githubAuthTime: 0, + }); + + expect(checkAuth(session)).toEqual({ + authenticated: false, + user: null, + error: 'Session expired. Please sign in again.', + }); + }); +}); diff --git a/src/lib/server/auth/guard.ts b/src/lib/server/auth/guard.ts new file mode 100644 index 0000000..b488e06 --- /dev/null +++ b/src/lib/server/auth/guard.ts @@ -0,0 +1,55 @@ +import { config } from '../config.js'; +import { logSecurity } from '../security-log.js'; + +export interface GitHubUser { + login: string; + name: string; +} + +export interface SessionData { + githubToken?: string; + githubUser?: GitHubUser; + githubAuthTime?: number; + githubDeviceCode?: string; + githubDeviceExpiry?: number; + save(callback: (err?: Error) => void): void; + destroy(callback: (err?: Error) => void): void; +} + +export interface AuthResult { + authenticated: boolean; + user: GitHubUser | null; + error?: string; +} + +export function checkAuth(session: SessionData | null | undefined): AuthResult { + if (!session?.githubToken) { + return { authenticated: false, user: null, error: 'GitHub authentication required' }; + } + + const authTime = session.githubAuthTime; + if (typeof authTime === 'number' && Date.now() - authTime > config.tokenMaxAge) { + logSecurity('info', 'token_expired', { + user: session.githubUser?.login, + reason: 'max_age_exceeded', + }); + return { authenticated: false, user: null, error: 'Session expired. Please sign in again.' }; + } + + if (config.allowedUsers.length > 0) { + const login = session.githubUser?.login?.trim().toLowerCase(); + + if (!login || !config.allowedUsers.includes(login)) { + logSecurity('warn', 'auth_denied_not_allowed', { + user: session.githubUser?.login, + }); + return { + authenticated: false, + user: null, + error: 'Your GitHub account is not authorized to use this application.', + }; + } + } + + return { authenticated: true, user: session.githubUser ?? null }; +} diff --git a/src/lib/server/auth/session-utils.test.ts b/src/lib/server/auth/session-utils.test.ts new file mode 100644 index 0000000..139e76d --- /dev/null +++ b/src/lib/server/auth/session-utils.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect, vi } from 'vitest'; + +import type { SessionData } from './guard.js'; +import { clearAuth, clearDeviceFlow, saveSession } from './session-utils.js'; + +type MockSession = SessionData & { + preserved?: string; +}; + +function createSession( + overrides: Partial = {}, + saveImpl: (callback: (err?: Error) => void) => void = (callback) => callback() +): MockSession { + return { + save: vi.fn(saveImpl), + destroy: vi.fn((callback: (err?: Error) => void) => callback()), + ...overrides, + }; +} + +describe('saveSession', () => { + it('resolves and calls session.save exactly once', async () => { + const session = createSession({ githubToken: 'token-123' }); + + await expect(saveSession(session)).resolves.toBeUndefined(); + expect(session.save).toHaveBeenCalledTimes(1); + expect(session.githubToken).toBe('token-123'); + }); + + it('rejects when session.save passes an error', async () => { + const saveError = new Error('save failed'); + const session = createSession({}, (callback) => callback(saveError)); + + await expect(saveSession(session)).rejects.toThrow('save failed'); + }); + + it('rejects when called with a null session', async () => { + await expect(saveSession(null as unknown as SessionData)).rejects.toBeInstanceOf(TypeError); + }); +}); + +describe('clearDeviceFlow', () => { + it('removes only device flow fields and preserves auth state', async () => { + const user = { login: 'octocat', name: 'Octo Cat' }; + const session = createSession({ + githubToken: 'token-123', + githubUser: user, + githubAuthTime: 123, + githubDeviceCode: 'device-code', + githubDeviceExpiry: 456, + preserved: 'keep-me', + }); + + await expect(clearDeviceFlow(session)).resolves.toBeUndefined(); + expect(session.githubDeviceCode).toBeUndefined(); + expect(session.githubDeviceExpiry).toBeUndefined(); + expect(session.githubToken).toBe('token-123'); + expect(session.githubUser).toEqual(user); + expect(session.githubAuthTime).toBe(123); + expect(session.preserved).toBe('keep-me'); + expect(session.save).toHaveBeenCalledTimes(1); + }); + + it('succeeds even when device flow properties are already missing', async () => { + const session = createSession({ preserved: 'keep-me' }); + + await expect(clearDeviceFlow(session)).resolves.toBeUndefined(); + expect(session.preserved).toBe('keep-me'); + expect(session.save).toHaveBeenCalledTimes(1); + }); + + it('rejects when saving the cleared session fails', async () => { + const saveError = new Error('session store unavailable'); + const session = createSession( + { githubDeviceCode: 'device-code', githubDeviceExpiry: 456 }, + (callback) => callback(saveError) + ); + + await expect(clearDeviceFlow(session)).rejects.toThrow('session store unavailable'); + expect(session.githubDeviceCode).toBeUndefined(); + expect(session.githubDeviceExpiry).toBeUndefined(); + }); + + it('rejects when the session does not provide save()', async () => { + const sessionWithoutSave = { + githubDeviceCode: 'device-code', + githubDeviceExpiry: 456, + } as unknown as SessionData; + + await expect(clearDeviceFlow(sessionWithoutSave)).rejects.toBeInstanceOf(TypeError); + }); +}); + +describe('clearAuth', () => { + it('removes auth and device flow fields while preserving unrelated data', async () => { + const user = { login: 'octocat', name: 'Octo Cat' }; + const session = createSession({ + githubToken: 'token-123', + githubUser: user, + githubAuthTime: 123, + githubDeviceCode: 'device-code', + githubDeviceExpiry: 456, + preserved: 'keep-me', + }); + + await expect(clearAuth(session)).resolves.toBeUndefined(); + expect(session.githubToken).toBeUndefined(); + expect(session.githubUser).toBeUndefined(); + expect(session.githubAuthTime).toBeUndefined(); + expect(session.githubDeviceCode).toBeUndefined(); + expect(session.githubDeviceExpiry).toBeUndefined(); + expect(session.preserved).toBe('keep-me'); + expect(session.save).toHaveBeenCalledTimes(1); + }); + + it('succeeds even when auth fields are already absent', async () => { + const session = createSession({ preserved: 'keep-me' }); + + await expect(clearAuth(session)).resolves.toBeUndefined(); + expect(session.preserved).toBe('keep-me'); + expect(session.save).toHaveBeenCalledTimes(1); + }); + + it('rejects when saving the cleared auth state fails', async () => { + const saveError = new Error('save failed'); + const session = createSession( + { githubToken: 'token-123', githubUser: { login: 'octocat', name: 'Octo Cat' } }, + (callback) => callback(saveError) + ); + + await expect(clearAuth(session)).rejects.toThrow('save failed'); + expect(session.githubToken).toBeUndefined(); + expect(session.githubUser).toBeUndefined(); + }); + + it('rejects when called with a null session', async () => { + await expect(clearAuth(null as unknown as SessionData)).rejects.toBeInstanceOf(TypeError); + }); +}); diff --git a/src/lib/server/auth/session-utils.ts b/src/lib/server/auth/session-utils.ts new file mode 100644 index 0000000..a5a15d0 --- /dev/null +++ b/src/lib/server/auth/session-utils.ts @@ -0,0 +1,25 @@ +import type { SessionData } from './guard.js'; + +/** Promisified session.save() — replaces the verbose callback pattern */ +export async function saveSession(session: SessionData): Promise { + return new Promise((resolve, reject) => + session.save((err?: Error) => (err ? reject(err) : resolve())) + ); +} + +/** Clear device flow fields and save */ +export async function clearDeviceFlow(session: SessionData): Promise { + delete session.githubDeviceCode; + delete session.githubDeviceExpiry; + await saveSession(session); +} + +/** Clear all auth fields (on expiry/logout) and save */ +export async function clearAuth(session: SessionData): Promise { + delete session.githubToken; + delete session.githubUser; + delete session.githubAuthTime; + delete session.githubDeviceCode; + delete session.githubDeviceExpiry; + await saveSession(session); +} diff --git a/src/lib/server/config.test.ts b/src/lib/server/config.test.ts new file mode 100644 index 0000000..3c4ec92 --- /dev/null +++ b/src/lib/server/config.test.ts @@ -0,0 +1,206 @@ +import { homedir } from 'node:os'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const ENV_KEYS = [ + 'PORT', + 'BASE_URL', + 'SESSION_SECRET', + 'SESSION_STORE_PATH', + 'SETTINGS_STORE_PATH', + 'GITHUB_CLIENT_ID', + 'NODE_ENV', + 'ALLOWED_GITHUB_USERS', + 'TOKEN_MAX_AGE_MS', + 'SESSION_POOL_TTL_MS', + 'MAX_SESSIONS_PER_USER', + 'COPILOT_CONFIG_DIR', +] as const; + +interface ConfigSnapshot { + port: number; + baseUrl: string; + sessionSecret: string; + sessionStorePath: string; + settingsStorePath: string; + github: { + clientId: string; + }; + isDev: boolean; + allowedUsers: string[]; + tokenMaxAge: number; + sessionPoolTtl: number; + maxSessionsPerUser: number; + copilotConfigDir: string | undefined; +} + +function clearEnv(): void { + vi.unstubAllEnvs(); + for (const key of ENV_KEYS) { + delete process.env[key]; + } +} + +async function importConfig( + overrides: Partial> = {}, + withRequiredDefaults = true, +): Promise { + clearEnv(); + + if (withRequiredDefaults) { + vi.stubEnv('SESSION_SECRET', 'test-session-secret'); + vi.stubEnv('GITHUB_CLIENT_ID', 'test-client-id'); + } + + for (const [key, value] of Object.entries(overrides)) { + if (value === undefined) { + delete process.env[key]; + continue; + } + + vi.stubEnv(key, value); + } + + vi.resetModules(); + return import('./config.js'); +} + +async function loadConfig( + overrides: Partial> = {}, + withRequiredDefaults = true, +): Promise { + const { config } = await importConfig(overrides, withRequiredDefaults); + + return { + port: config.port, + baseUrl: config.baseUrl, + sessionSecret: config.sessionSecret, + sessionStorePath: config.sessionStorePath, + settingsStorePath: config.settingsStorePath, + github: { + clientId: config.github.clientId, + }, + isDev: config.isDev, + allowedUsers: [...config.allowedUsers], + tokenMaxAge: config.tokenMaxAge, + sessionPoolTtl: config.sessionPoolTtl, + maxSessionsPerUser: config.maxSessionsPerUser, + copilotConfigDir: config.copilotConfigDir, + }; +} + +beforeEach(() => { + clearEnv(); + vi.resetModules(); +}); + +afterEach(() => { + clearEnv(); + vi.resetModules(); +}); + +describe('config', () => { + it('throws when SESSION_SECRET is missing', async () => { + const { config } = await importConfig({ GITHUB_CLIENT_ID: 'test-client-id' }, false); + + expect(() => config.port).toThrow('Missing required env var: SESSION_SECRET'); + }); + + it('throws when GITHUB_CLIENT_ID is missing', async () => { + const { config } = await importConfig({ SESSION_SECRET: 'test-session-secret' }, false); + + expect(() => config.port).toThrow('Missing required env var: GITHUB_CLIENT_ID'); + }); + + it('uses development defaults for optional values when env vars are unset', async () => { + const config = await loadConfig(); + + expect(config.port).toBe(3000); + expect(config.baseUrl).toBe('http://localhost:3000'); + expect(config.sessionStorePath).toBe('.sessions'); + expect(config.settingsStorePath).toBe('.settings'); + expect(config.isDev).toBe(true); + expect(config.allowedUsers).toEqual([]); + expect(config.tokenMaxAge).toBe(7 * 24 * 60 * 60 * 1000); + expect(config.sessionPoolTtl).toBe(5 * 60 * 1000); + expect(config.maxSessionsPerUser).toBe(5); + expect(config.copilotConfigDir).toBeUndefined(); + }); + + it('uses production storage defaults when NODE_ENV is production', async () => { + const config = await loadConfig({ NODE_ENV: 'production' }); + + expect(config.sessionStorePath).toBe('/data/sessions'); + expect(config.settingsStorePath).toBe('/data/settings'); + expect(config.isDev).toBe(false); + }); + + it('parses ALLOWED_GITHUB_USERS as a lowercase trimmed array', async () => { + const config = await loadConfig({ ALLOWED_GITHUB_USERS: ' Alice,BOB , carol ' }); + + expect(config.allowedUsers).toEqual(['alice', 'bob', 'carol']); + }); + + it('treats an empty ALLOWED_GITHUB_USERS value as an empty array', async () => { + const config = await loadConfig({ ALLOWED_GITHUB_USERS: '' }); + + expect(config.allowedUsers).toEqual([]); + }); + + it('parses PORT from a string', async () => { + const config = await loadConfig({ PORT: '4123' }); + + expect(config.port).toBe(4123); + }); + + it('parses TOKEN_MAX_AGE_MS from a string', async () => { + const config = await loadConfig({ TOKEN_MAX_AGE_MS: '12345' }); + + expect(config.tokenMaxAge).toBe(12345); + }); + + it('treats non-production NODE_ENV values as development', async () => { + const config = await loadConfig({ NODE_ENV: 'test' }); + + expect(config.isDev).toBe(true); + }); + + it('preserves a BASE_URL without a trailing slash', async () => { + const config = await loadConfig({ BASE_URL: 'https://example.com/app' }); + + expect(config.baseUrl).toBe('https://example.com/app'); + }); + + it('preserves a BASE_URL with a trailing slash', async () => { + const config = await loadConfig({ BASE_URL: 'https://example.com/app/' }); + + expect(config.baseUrl).toBe('https://example.com/app/'); + }); + + it('trims whitespace around BASE_URL and other string env vars', async () => { + const config = await loadConfig({ + BASE_URL: ' https://example.com ', + SESSION_SECRET: ' secret-value ', + GITHUB_CLIENT_ID: ' client-id ', + }); + + expect(config.baseUrl).toBe('https://example.com'); + expect(config.sessionSecret).toBe('secret-value'); + expect(config.github.clientId).toBe('client-id'); + }); + + it('expands a leading tilde in COPILOT_CONFIG_DIR', async () => { + const config = await loadConfig({ COPILOT_CONFIG_DIR: '~/copilot-config' }); + + expect(config.copilotConfigDir).toBe(`${homedir()}/copilot-config`); + }); + + it('uses explicit session and settings store overrides when provided', async () => { + const config = await loadConfig({ + SESSION_STORE_PATH: '/tmp/sessions', + SETTINGS_STORE_PATH: '/tmp/settings', + }); + + expect(config.sessionStorePath).toBe('/tmp/sessions'); + expect(config.settingsStorePath).toBe('/tmp/settings'); + }); +}); diff --git a/src/lib/server/config.ts b/src/lib/server/config.ts new file mode 100644 index 0000000..48ab709 --- /dev/null +++ b/src/lib/server/config.ts @@ -0,0 +1,41 @@ +import { homedir } from 'node:os'; + +function required(name: string): string { + const val = process.env[name]?.trim(); + if (!val) throw new Error(`Missing required env var: ${name}`); + return val; +} + +function env(name: string, fallback: string): string { + return process.env[name]?.trim() || fallback; +} + +function getConfig() { + return { + port: parseInt(env('PORT', '3000')), + baseUrl: env('BASE_URL', 'http://localhost:3000'), + sessionSecret: required('SESSION_SECRET'), + sessionStorePath: env('SESSION_STORE_PATH', env('NODE_ENV', 'development') !== 'production' ? '.sessions' : '/data/sessions'), + settingsStorePath: env('SETTINGS_STORE_PATH', env('NODE_ENV', 'development') !== 'production' ? '.settings' : '/data/settings'), + github: { + clientId: required('GITHUB_CLIENT_ID'), + }, + isDev: env('NODE_ENV', 'development') !== 'production', + allowedUsers: process.env.ALLOWED_GITHUB_USERS + ? process.env.ALLOWED_GITHUB_USERS.split(',').map((u: string) => u.trim().toLowerCase()) + : [], + tokenMaxAge: parseInt(env('TOKEN_MAX_AGE_MS', String(7 * 24 * 60 * 60 * 1000))), + sessionPoolTtl: parseInt(env('SESSION_POOL_TTL_MS', String(5 * 60 * 1000))), + maxSessionsPerUser: parseInt(env('MAX_SESSIONS_PER_USER', '5')), + copilotConfigDir: process.env.COPILOT_CONFIG_DIR?.trim().replace(/^~/, homedir()) || undefined, + }; +} + +let _config: ReturnType | null = null; + +export const config = new Proxy({} as ReturnType, { + get(_target, prop) { + if (!_config) _config = getConfig(); + return (_config as Record)[prop]; + }, +}); diff --git a/src/lib/server/copilot/client.ts b/src/lib/server/copilot/client.ts new file mode 100644 index 0000000..28db6b3 --- /dev/null +++ b/src/lib/server/copilot/client.ts @@ -0,0 +1,20 @@ +import { homedir } from 'node:os'; +import { CopilotClient } from '@github/copilot-sdk'; + +export function createCopilotClient(githubToken: string, configDir?: string): CopilotClient { + const clientEnv: Record = { ...process.env, GH_TOKEN: githubToken }; + + // When configDir is set, pass COPILOT_HOME to the CLI subprocess so it + // reads and writes session state from the same directory as the CLI. + if (configDir) { + clientEnv.COPILOT_HOME = configDir; + } + + return new CopilotClient({ + githubToken, + env: clientEnv, + // Use home directory as CWD so the CLI subprocess has write access + // (in Docker, WORKDIR /app is root-owned but the process runs as node) + cwd: homedir(), + }); +} diff --git a/src/lib/server/copilot/session-metadata.test.ts b/src/lib/server/copilot/session-metadata.test.ts new file mode 100644 index 0000000..5f3273a --- /dev/null +++ b/src/lib/server/copilot/session-metadata.test.ts @@ -0,0 +1,316 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { join } from 'node:path'; + +const { + accessMock, + readFileMock, + readdirMock, + rmMock, + statMock, +} = vi.hoisted(() => ({ + accessMock: vi.fn(), + readFileMock: vi.fn(), + readdirMock: vi.fn(), + rmMock: vi.fn(), + statMock: vi.fn(), +})); + +vi.mock('node:fs/promises', () => ({ + access: accessMock, + readFile: readFileMock, + readdir: readdirMock, + rm: rmMock, + stat: statMock, + default: { + access: accessMock, + readFile: readFileMock, + readdir: readdirMock, + rm: rmMock, + stat: statMock, + }, +})); + +vi.mock('../config.js', () => ({ + config: { + copilotConfigDir: '/mock-copilot', + }, +})); + +import { + buildSessionContext, + countCheckpoints, + deleteSessionFromFilesystem, + getSessionDetail, + getSessionStateDir, + listSessionsFromFilesystem, +} from './session-metadata.js'; + +const stateDir = '/mock-copilot/session-state'; +const sessionId = '11111111-1111-1111-1111-111111111111'; +const sessionDir = join(stateDir, sessionId); +const secondSessionId = '22222222-2222-2222-2222-222222222222'; +const secondSessionDir = join(stateDir, secondSessionId); + +function directoryStat(): { isDirectory: () => boolean } { + return { isDirectory: () => true }; +} + +beforeEach(() => { + accessMock.mockReset(); + readFileMock.mockReset(); + readdirMock.mockReset(); + rmMock.mockReset(); + statMock.mockReset(); + + accessMock.mockResolvedValue(undefined); + rmMock.mockResolvedValue(undefined); + statMock.mockResolvedValue(directoryStat()); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('session metadata helpers', () => { + it('resolves the session-state directory from the Copilot config path', () => { + expect(getSessionStateDir()).toBe(stateDir); + }); + + it('counts checkpoint markdown files and ignores index.md', async () => { + readdirMock.mockResolvedValue(['1.md', '2.md', 'index.md', 'notes.txt']); + + await expect(countCheckpoints(sessionDir)).resolves.toBe(2); + expect(readdirMock).toHaveBeenCalledWith(join(sessionDir, 'checkpoints')); + }); + + it('returns zero checkpoints when the directory is unreadable', async () => { + readdirMock.mockRejectedValue(new Error('EACCES')); + + await expect(countCheckpoints(sessionDir)).resolves.toBe(0); + }); +}); + +describe('getSessionDetail', () => { + it('returns null when the session directory does not exist', async () => { + accessMock.mockRejectedValue(new Error('ENOENT')); + + await expect(getSessionDetail(sessionId)).resolves.toBeNull(); + }); + + it('parses workspace metadata, checkpoint index, and plan content', async () => { + readFileMock.mockImplementation(async (path: string) => { + if (path === join(sessionDir, 'workspace.yaml')) { + return [ + 'cwd: /repo', + 'repository: owner/repo', + 'branch: main', + 'summary: |-', + ' First line', + ' Second line', + 'created_at: 2024-01-01T00:00:00Z', + 'updated_at: 2024-01-02T00:00:00Z', + 'is_remote: true', + ].join('\n'); + } + + if (path === join(sessionDir, 'checkpoints', 'index.md')) { + return [ + '| # | Title | File |', + '| - | ----- | ---- |', + '| 1 | Start | 001-start.md |', + '| 2 | Finish | 002-finish.md |', + ].join('\n'); + } + + if (path === join(sessionDir, 'plan.md')) { + return 'Ship the feature'; + } + + throw new Error(`Unexpected path: ${path}`); + }); + + const detail = await getSessionDetail(sessionId); + + expect(detail).toEqual({ + id: sessionId, + cwd: '/repo', + repository: 'owner/repo', + branch: 'main', + summary: 'First line\nSecond line', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-02T00:00:00Z', + checkpoints: [ + { number: 1, title: 'Start', filename: '001-start.md' }, + { number: 2, title: 'Finish', filename: '002-finish.md' }, + ], + plan: 'Ship the feature', + isRemote: true, + }); + }); + + it('handles malformed workspace.yaml without throwing', async () => { + readFileMock.mockImplementation(async (path: string) => { + if (path === join(sessionDir, 'workspace.yaml')) return 'summary\n bad-data'; + if (path === join(sessionDir, 'checkpoints', 'index.md')) return 'not a table'; + if (path === join(sessionDir, 'plan.md')) throw new Error('ENOENT'); + throw new Error(`Unexpected path: ${path}`); + }); + + await expect(getSessionDetail(sessionId)).resolves.toEqual({ + id: sessionId, + cwd: undefined, + repository: undefined, + branch: undefined, + summary: undefined, + createdAt: undefined, + updatedAt: undefined, + checkpoints: [], + plan: undefined, + isRemote: false, + }); + }); +}); + +describe('listSessionsFromFilesystem', () => { + it('lists filesystem sessions from UUID directories with parsed metadata', async () => { + readdirMock.mockImplementation(async (path: string) => { + if (path === stateDir) { + return [sessionId, 'not-a-session', secondSessionId]; + } + if (path === join(sessionDir, 'checkpoints')) { + return ['1.md', 'index.md']; + } + if (path === join(secondSessionDir, 'checkpoints')) { + return ['1.md', '2.md', 'index.md']; + } + throw new Error(`Unexpected path: ${path}`); + }); + + accessMock.mockImplementation(async (path: string) => { + if (path === join(sessionDir, 'plan.md')) return undefined; + if (path === join(secondSessionDir, 'plan.md')) throw new Error('ENOENT'); + return undefined; + }); + + readFileMock.mockImplementation(async (path: string) => { + if (path === join(sessionDir, 'workspace.yaml')) { + return ['summary: Session one', 'updated_at: 2024-01-05T00:00:00Z', 'cwd: /repo-one'].join('\n'); + } + if (path === join(secondSessionDir, 'workspace.yaml')) { + return ['summary: Session two', 'created_at: 2024-01-03T00:00:00Z', 'branch: dev'].join('\n'); + } + throw new Error(`Unexpected path: ${path}`); + }); + + const sessions = await listSessionsFromFilesystem(); + + expect(sessions).toEqual([ + { + id: sessionId, + title: 'Session one', + updatedAt: '2024-01-05T00:00:00Z', + cwd: '/repo-one', + repository: undefined, + branch: undefined, + checkpointCount: 1, + hasPlan: true, + }, + { + id: secondSessionId, + title: 'Session two', + updatedAt: '2024-01-03T00:00:00Z', + cwd: undefined, + repository: undefined, + branch: 'dev', + checkpointCount: 2, + hasPlan: false, + }, + ]); + }); + + it('returns an empty list when the session-state directory is missing', async () => { + readdirMock.mockRejectedValue(new Error('ENOENT')); + + await expect(listSessionsFromFilesystem()).resolves.toEqual([]); + }); + + it('skips entries that fail stat or workspace reads', async () => { + readdirMock.mockImplementation(async (path: string) => { + if (path === stateDir) return [sessionId, secondSessionId]; + if (path === join(secondSessionDir, 'checkpoints')) return ['index.md']; + throw new Error(`Unexpected path: ${path}`); + }); + + statMock.mockImplementation(async (path: string) => { + if (path === sessionDir) throw new Error('EACCES'); + return directoryStat(); + }); + + readFileMock.mockImplementation(async (path: string) => { + if (path === join(secondSessionDir, 'workspace.yaml')) throw new Error('EACCES'); + throw new Error(`Unexpected path: ${path}`); + }); + + await expect(listSessionsFromFilesystem()).resolves.toEqual([]); + }); +}); + +describe('buildSessionContext', () => { + it('builds context from workspace metadata, plan, and the last three checkpoints', async () => { + readdirMock.mockImplementation(async (path: string) => { + if (path === join(sessionDir, 'checkpoints')) { + return ['001.md', '002.md', '003.md', '004.md', 'index.md']; + } + throw new Error(`Unexpected path: ${path}`); + }); + + readFileMock.mockImplementation(async (path: string) => { + if (path === join(sessionDir, 'workspace.yaml')) { + return ['summary: Prior summary', 'repository: owner/repo', 'branch: feature'].join('\n'); + } + if (path === join(sessionDir, 'plan.md')) { + return 'Resume from plan'; + } + if (path === join(sessionDir, 'checkpoints', '002.md')) return 'Checkpoint two'; + if (path === join(sessionDir, 'checkpoints', '003.md')) return 'Checkpoint three'; + if (path === join(sessionDir, 'checkpoints', '004.md')) return 'Checkpoint four'; + throw new Error(`Unexpected path: ${path}`); + }); + + const context = await buildSessionContext(sessionId); + + expect(context).toContain('## Previous Session Summary\nPrior summary'); + expect(context).toContain('Repository: owner/repo (branch: feature)'); + expect(context).toContain('## Previous Plan\nResume from plan'); + expect(context).not.toContain('Checkpoint: 001.md'); + expect(context).toContain('## Checkpoint: 002.md\nCheckpoint two'); + expect(context).toContain('## Checkpoint: 003.md\nCheckpoint three'); + expect(context).toContain('## Checkpoint: 004.md\nCheckpoint four'); + }); + + it('returns null when no usable context files can be read', async () => { + readFileMock.mockRejectedValue(new Error('ENOENT')); + readdirMock.mockRejectedValue(new Error('ENOENT')); + + await expect(buildSessionContext(sessionId)).resolves.toBeNull(); + }); +}); + +describe('deleteSessionFromFilesystem', () => { + it('rejects invalid session IDs', async () => { + await expect(deleteSessionFromFilesystem('not-a-uuid')).resolves.toBe(false); + expect(rmMock).not.toHaveBeenCalled(); + }); + + it('returns false when the session directory is missing', async () => { + accessMock.mockRejectedValue(new Error('ENOENT')); + + await expect(deleteSessionFromFilesystem(sessionId)).resolves.toBe(false); + expect(rmMock).not.toHaveBeenCalled(); + }); + + it('removes valid session directories from disk', async () => { + await expect(deleteSessionFromFilesystem(sessionId)).resolves.toBe(true); + expect(rmMock).toHaveBeenCalledWith(sessionDir, { recursive: true, force: true }); + }); +}); diff --git a/src/lib/server/copilot/session-metadata.ts b/src/lib/server/copilot/session-metadata.ts new file mode 100644 index 0000000..33e00a4 --- /dev/null +++ b/src/lib/server/copilot/session-metadata.ts @@ -0,0 +1,350 @@ +import { readFile, readdir, access, stat, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; +import { config } from '../config.js'; + +export interface CheckpointEntry { + number: number; + title: string; + filename: string; +} + +export interface SessionDetail { + id: string; + cwd?: string; + repository?: string; + branch?: string; + summary?: string; + createdAt?: string; + updatedAt?: string; + checkpoints: CheckpointEntry[]; + plan?: string; + isRemote?: boolean; +} + +/** Resolve the session-state root directory */ +export function getSessionStateDir(): string { + const base = config.copilotConfigDir || join(homedir(), '.copilot'); + return join(base, 'session-state'); +} + +/** Check if a path exists */ +async function pathExists(path: string): Promise { + try { + await access(path); + return true; + } catch { + return false; + } +} + +/** Count checkpoint files (excluding index.md) in a session directory */ +export async function countCheckpoints(sessionDir: string): Promise { + const checkpointsDir = join(sessionDir, 'checkpoints'); + try { + const entries = await readdir(checkpointsDir); + return entries.filter((f) => f.endsWith('.md') && f !== 'index.md').length; + } catch { + return 0; + } +} + +/** Check if a plan.md exists in a session directory */ +export async function hasPlan(sessionDir: string): Promise { + return pathExists(join(sessionDir, 'plan.md')); +} + +/** Parse checkpoints/index.md to extract the checkpoint list */ +export async function parseCheckpointIndex(sessionDir: string): Promise { + const indexPath = join(sessionDir, 'checkpoints', 'index.md'); + try { + const content = await readFile(indexPath, 'utf-8'); + const entries: CheckpointEntry[] = []; + + // Parse markdown table rows: | # | Title | File | + for (const line of content.split('\n')) { + const match = line.match(/^\|\s*(\d+)\s*\|\s*(.+?)\s*\|\s*(.+?)\s*\|$/); + if (match) { + entries.push({ + number: parseInt(match[1], 10), + title: match[2].trim(), + filename: match[3].trim(), + }); + } + } + return entries; + } catch { + return []; + } +} + +/** Read plan.md content from a session directory */ +async function readPlanContent(sessionDir: string): Promise { + const planPath = join(sessionDir, 'plan.md'); + try { + return await readFile(planPath, 'utf-8'); + } catch { + return undefined; + } +} + +/** Enrich a session summary with filesystem metadata (checkpoints, plan) */ +export async function enrichSessionMetadata( + sessionId: string, + context?: { cwd?: string; gitRoot?: string; repository?: string; branch?: string }, + isRemote?: boolean, +): Promise<{ checkpointCount: number; hasPlan: boolean; cwd?: string; repository?: string; branch?: string; isRemote?: boolean }> { + const sessionDir = join(getSessionStateDir(), sessionId); + + const [checkpointCount, planExists] = await Promise.all([ + countCheckpoints(sessionDir), + hasPlan(sessionDir), + ]); + + return { + checkpointCount, + hasPlan: planExists, + cwd: context?.cwd, + repository: context?.repository, + branch: context?.branch, + isRemote, + }; +} + +/** Get full session detail for the preview panel */ +export async function getSessionDetail(sessionId: string): Promise { + const sessionDir = join(getSessionStateDir(), sessionId); + if (!await pathExists(sessionDir)) return null; + + const [checkpoints, planContent] = await Promise.all([ + parseCheckpointIndex(sessionDir), + readPlanContent(sessionDir), + ]); + + // Read workspace.yaml for metadata + let metadata: Record = {}; + try { + const yamlContent = await readFile(join(sessionDir, 'workspace.yaml'), 'utf-8'); + metadata = parseWorkspaceYaml(yamlContent); + } catch { + // workspace.yaml may not exist for all sessions + } + + return { + id: sessionId, + cwd: metadata.cwd, + repository: metadata.repository, + branch: metadata.branch, + summary: metadata.summary, + createdAt: metadata.created_at, + updatedAt: metadata.updated_at, + checkpoints, + plan: planContent, + isRemote: metadata.is_remote === 'true', + }; +} + +/** Simple YAML parser for workspace.yaml (avoids external dependency) */ +function parseWorkspaceYaml(content: string): Record { + const result: Record = {}; + let currentKey = ''; + let multilineValue = ''; + let inMultiline = false; + + for (const line of content.split('\n')) { + if (inMultiline) { + // Multiline values (YAML block scalars) are indented + if (line.startsWith(' ') || line === '') { + multilineValue += (multilineValue ? '\n' : '') + line.replace(/^ /, ''); + continue; + } + // End of multiline + result[currentKey] = multilineValue.trim(); + inMultiline = false; + multilineValue = ''; + } + + const match = line.match(/^(\w[\w_]*)\s*:\s*(.*)$/); + if (!match) continue; + + const [, key, rawValue] = match; + const value = rawValue.trim(); + + if (value === '|-' || value === '|' || value === '>-' || value === '>') { + // Start of multiline block + currentKey = key; + inMultiline = true; + multilineValue = ''; + } else { + result[key] = value; + } + } + + // Handle trailing multiline + if (inMultiline && currentKey) { + result[currentKey] = multilineValue.trim(); + } + + return result; +} + +/** Lightweight session summary returned by the filesystem scanner */ +export interface FilesystemSession { + id: string; + title?: string; + updatedAt?: string; + cwd?: string; + repository?: string; + branch?: string; + checkpointCount: number; + hasPlan: boolean; +} + +/** + * Scan the session-state directory on disk and return session summaries built + * from workspace.yaml metadata. This is the fallback when the SDK returns + * nothing (e.g. bundled sessions copied into a fresh container). + */ +export async function listSessionsFromFilesystem(): Promise { + const stateDir = getSessionStateDir(); + + let entries: string[]; + try { + entries = await readdir(stateDir); + } catch { + return []; + } + + // UUID pattern — only pick directories that look like session IDs + const uuidRe = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + + const results = await Promise.all( + entries + .filter((name) => uuidRe.test(name)) + .map(async (sessionId): Promise => { + const sessionDir = join(stateDir, sessionId); + + try { + const info = await stat(sessionDir); + if (!info.isDirectory()) return null; + } catch { + return null; + } + + let metadata: Record = {}; + try { + const yamlContent = await readFile(join(sessionDir, 'workspace.yaml'), 'utf-8'); + metadata = parseWorkspaceYaml(yamlContent); + } catch { + // No workspace.yaml — skip this directory + return null; + } + + const [checkpointCount, planExists] = await Promise.all([ + countCheckpoints(sessionDir), + hasPlan(sessionDir), + ]); + + return { + id: sessionId, + title: metadata.summary || undefined, + updatedAt: metadata.updated_at || metadata.created_at, + cwd: metadata.cwd, + repository: metadata.repository, + branch: metadata.branch, + checkpointCount, + hasPlan: planExists, + }; + }), + ); + + return results.filter((s): s is FilesystemSession => s !== null); +} + +/** + * Build a context string from a session's filesystem data (workspace.yaml, + * checkpoints, plan) for injection into a new session's systemMessage. + * Used as a fallback when resumeSession() fails for bundled/filesystem-only sessions. + */ +export async function buildSessionContext(sessionId: string): Promise { + const sessionDir = join(getSessionStateDir(), sessionId); + if (!await pathExists(sessionDir)) return null; + + const parts: string[] = []; + + // 1. Read workspace.yaml metadata + let metadata: Record = {}; + try { + const yamlContent = await readFile(join(sessionDir, 'workspace.yaml'), 'utf-8'); + metadata = parseWorkspaceYaml(yamlContent); + } catch { + // No metadata available + } + + if (metadata.summary) { + parts.push(`## Previous Session Summary\n${metadata.summary}`); + } + if (metadata.repository) { + parts.push(`Repository: ${metadata.repository}${metadata.branch ? ` (branch: ${metadata.branch})` : ''}`); + } + + // 2. Read plan.md if it exists + try { + const planContent = await readFile(join(sessionDir, 'plan.md'), 'utf-8'); + if (planContent.trim()) { + parts.push(`## Previous Plan\n${planContent.trim()}`); + } + } catch { + // No plan + } + + // 3. Read checkpoint content (numbered .md files, not index.md) + try { + const checkpointsDir = join(sessionDir, 'checkpoints'); + const checkpointFiles = await readdir(checkpointsDir); + const numbered = checkpointFiles + .filter((f) => f.endsWith('.md') && f !== 'index.md') + .sort(); + + // Read up to the last 3 checkpoint files to stay within token limits + const recentFiles = numbered.slice(-3); + for (const file of recentFiles) { + try { + const content = await readFile(join(checkpointsDir, file), 'utf-8'); + if (content.trim()) { + parts.push(`## Checkpoint: ${file}\n${content.trim()}`); + } + } catch { + // Skip unreadable files + } + } + } catch { + // No checkpoints directory + } + + if (parts.length === 0) return null; + + return [ + 'You are continuing a previous coding session. Here is the context from that session:', + '', + ...parts, + '', + 'Continue from where the previous session left off. The user wants to resume this work.', + ].join('\n'); +} + +/** + * Delete a session's directory from the filesystem. + * Used as a fallback when the SDK doesn't know about the session + * (e.g. bundled or filesystem-only sessions). + */ +export async function deleteSessionFromFilesystem(sessionId: string): Promise { + const uuidRe = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + if (!uuidRe.test(sessionId)) return false; + + const sessionDir = join(getSessionStateDir(), sessionId); + if (!await pathExists(sessionDir)) return false; + + await rm(sessionDir, { recursive: true, force: true }); + return true; +} diff --git a/src/lib/server/copilot/session.test.ts b/src/lib/server/copilot/session.test.ts new file mode 100644 index 0000000..f817249 --- /dev/null +++ b/src/lib/server/copilot/session.test.ts @@ -0,0 +1,429 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { defineToolMock } = vi.hoisted(() => ({ + defineToolMock: vi.fn((name: string, options: Record) => ({ name, ...options })), +})); + +vi.mock('@github/copilot-sdk', () => ({ + CopilotClient: vi.fn(), + defineTool: defineToolMock, +})); + +vi.mock('../config.js', () => ({ + config: { + copilotConfigDir: '/copilot-config', + }, +})); + +import { createCopilotSession, getAvailableModels } from './session.js'; + +interface ClientMock { + createSession: ReturnType; + listModels: ReturnType; +} + +interface DefinedTool { + name: string; + description: string; + parameters: { + parse: (input: unknown) => unknown; + }; + handler: (args: unknown) => Promise; +} + +function createClientMock(): ClientMock { + return { + createSession: vi.fn(async (sessionConfig: unknown) => ({ sessionConfig })), + listModels: vi.fn(), + }; +} + +function getSessionConfig(client: ClientMock): Record { + const [sessionConfig] = client.createSession.mock.calls[0] as [Record]; + return sessionConfig; +} + +function getCreatedTool(client: ClientMock): DefinedTool { + const sessionConfig = getSessionConfig(client); + const tools = sessionConfig.tools as DefinedTool[]; + return tools[0]; +} + +const fetchMock = vi.fn(); + +beforeEach(() => { + defineToolMock.mockClear(); + fetchMock.mockReset(); + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ ok: true }), + }); + vi.stubGlobal('fetch', fetchMock); + vi.spyOn(console, 'log').mockImplementation(() => undefined); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('createCopilotSession', () => { + it('creates a default session config with GitHub MCP access and approve-all permissions', async () => { + const client = createClientMock(); + + await createCopilotSession(client as unknown as Parameters[0], 'gh-token'); + + expect(client.createSession).toHaveBeenCalledTimes(1); + + const sessionConfig = getSessionConfig(client); + expect(sessionConfig).toMatchObject({ + clientName: 'copilot-unleashed', + model: 'gpt-4.1', + streaming: true, + configDir: '/copilot-config', + }); + + const mcpServers = sessionConfig.mcpServers as Record>; + expect(mcpServers.github).toEqual({ + type: 'http', + url: 'https://api.githubcopilot.com/mcp/x/all', + headers: { + Authorization: 'Bearer gh-token', + }, + tools: ['*'], + }); + + const onPermissionRequest = sessionConfig.onPermissionRequest as ( + request: unknown, + context: unknown, + ) => { kind: string }; + expect(onPermissionRequest({ toolName: 'bash' }, { sessionId: 's1' })).toEqual({ kind: 'approved' }); + }); + + it('uses the provided prompt permission handler when prompt mode is enabled', async () => { + const client = createClientMock(); + const onPermissionRequest = vi.fn((_request: unknown, _context: unknown) => ({ kind: 'approved' as const })); + + await createCopilotSession(client as unknown as Parameters[0], 'gh-token', { + permissionMode: 'prompt', + onPermissionRequest, + }); + + const sessionConfig = getSessionConfig(client); + const configuredHandler = sessionConfig.onPermissionRequest as typeof onPermissionRequest; + + configuredHandler({ toolName: 'write' }, { sessionId: 's2' }); + expect(onPermissionRequest).toHaveBeenCalledWith({ toolName: 'write' }, { sessionId: 's2' }); + }); + + it('applies optional session settings to the SDK config', async () => { + const client = createClientMock(); + const onUserInputRequest = vi.fn(); + + await createCopilotSession(client as unknown as Parameters[0], 'gh-token', { + model: 'claude-sonnet-4.5', + reasoningEffort: 'high', + customInstructions: 'Stay concise.', + excludedTools: ['bash'], + availableTools: ['read'], + onUserInputRequest, + infiniteSessions: { + enabled: true, + backgroundCompactionThreshold: 0.7, + bufferExhaustionThreshold: 0.4, + }, + configDir: '/custom-config', + mcpServers: [{ + name: 'public-api', + url: 'https://api.example.com:8443/stream/%E2%9C%93', + type: 'http', + headers: { 'X-Test': 'true' }, + tools: [], + }], + }); + + expect(getSessionConfig(client)).toMatchObject({ + model: 'claude-sonnet-4.5', + reasoningEffort: 'high', + excludedTools: ['bash'], + availableTools: ['read'], + onUserInputRequest, + configDir: '/custom-config', + systemMessage: { + mode: 'append', + content: 'Stay concise.', + }, + infiniteSessions: { + enabled: true, + backgroundCompactionThreshold: 0.7, + bufferExhaustionThreshold: 0.4, + }, + }); + + const mcpServers = getSessionConfig(client).mcpServers as Record>; + expect(mcpServers['public-api']).toEqual({ + type: 'http', + url: 'https://api.example.com:8443/stream/%E2%9C%93', + headers: { 'X-Test': 'true' }, + tools: ['*'], + }); + }); + + it('builds POST custom tools with zod parameter parsing and JSON fetch bodies', async () => { + const client = createClientMock(); + + await createCopilotSession(client as unknown as Parameters[0], 'gh-token', { + customTools: [{ + name: 'webhook-tool', + description: 'Calls a webhook', + webhookUrl: 'https://tools.example.com/hooks/%E2%9C%93', + method: 'POST', + headers: { Authorization: 'Bearer abc' }, + parameters: { + query: { type: 'string', description: 'Search query' }, + count: { type: 'number', description: 'Result count' }, + enabled: { type: 'boolean', description: 'Feature toggle' }, + }, + }], + }); + + expect(defineToolMock).toHaveBeenCalledTimes(1); + const tool = getCreatedTool(client); + + expect(tool.parameters.parse({ query: 'copilot', count: 2, enabled: true })).toEqual({ + query: 'copilot', + count: 2, + enabled: true, + }); + + const result = await tool.handler({ query: 'copilot', count: 2, enabled: true }); + + expect(result).toEqual({ ok: true }); + expect(fetchMock).toHaveBeenCalledWith( + 'https://tools.example.com/hooks/%E2%9C%93', + expect.objectContaining({ + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer abc', + }, + body: JSON.stringify({ query: 'copilot', count: 2, enabled: true }), + }), + ); + }); + + it('omits the request body for GET custom tools', async () => { + const client = createClientMock(); + + await createCopilotSession(client as unknown as Parameters[0], 'gh-token', { + customTools: [{ + name: 'get-tool', + description: 'Fetches without a body', + webhookUrl: 'https://tools.example.com/read', + method: 'GET', + headers: {}, + parameters: {}, + }], + }); + + const tool = getCreatedTool(client); + await tool.handler({ ignored: true }); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://tools.example.com/read', + expect.objectContaining({ + method: 'GET', + body: undefined, + }), + ); + }); + + it.each([ + 'https://127.0.0.1/hook', + 'https://10.0.0.8/hook', + 'https://172.16.5.10/hook', + 'https://172.31.255.1/hook', + 'https://192.168.1.5/hook', + 'https://169.254.2.3/hook', + 'https://0.0.0.0/hook', + 'https://localhost/hook', + 'https://sub.localhost/hook', + 'https://[::1]/hook', + 'https://[fc00::1]/hook', + 'https://[fe80::1]/hook', + ])('rejects blocked tool URLs: %s', async (webhookUrl) => { + const client = createClientMock(); + + await expect( + createCopilotSession(client as unknown as Parameters[0], 'gh-token', { + customTools: [{ + name: 'private-tool', + description: 'Should fail', + webhookUrl, + method: 'POST', + headers: {}, + parameters: {}, + }], + }), + ).rejects.toThrow('internal network URLs are not allowed'); + }); + + it('rejects HTTP, invalid, and credential-bearing tool URLs', async () => { + const client = createClientMock(); + + await expect( + createCopilotSession(client as unknown as Parameters[0], 'gh-token', { + customTools: [{ + name: 'http-tool', + description: 'Should fail', + webhookUrl: 'http://example.com/hook', + method: 'GET', + headers: {}, + parameters: {}, + }], + }), + ).rejects.toThrow('HTTPS required'); + + await expect( + createCopilotSession(client as unknown as Parameters[0], 'gh-token', { + customTools: [{ + name: 'bad-tool', + description: 'Should fail', + webhookUrl: 'not-a-url', + method: 'GET', + headers: {}, + parameters: {}, + }], + }), + ).rejects.toThrow('invalid URL'); + + await expect( + createCopilotSession(client as unknown as Parameters[0], 'gh-token', { + customTools: [{ + name: 'auth-tool', + description: 'Should fail', + webhookUrl: 'https://user:pass@example.com/hook', + method: 'GET', + headers: {}, + parameters: {}, + }], + }), + ).rejects.toThrow('auth credentials in URLs are not allowed'); + }); + + it.each([ + 'https://127.0.0.1/stream', + 'https://localhost/stream', + 'https://[::1]/stream', + ])('rejects blocked MCP server URLs: %s', async (url) => { + const client = createClientMock(); + + await expect( + createCopilotSession(client as unknown as Parameters[0], 'gh-token', { + mcpServers: [{ + name: 'private-mcp', + url, + type: 'sse', + headers: {}, + tools: ['search'], + }], + }), + ).rejects.toThrow('internal network URLs are not allowed'); + }); + + it('rejects non-HTTPS and invalid MCP server URLs', async () => { + const client = createClientMock(); + + await expect( + createCopilotSession(client as unknown as Parameters[0], 'gh-token', { + mcpServers: [{ + name: 'http-mcp', + url: 'http://public.example.com/stream', + type: 'http', + headers: {}, + tools: ['search'], + }], + }), + ).rejects.toThrow('HTTPS required'); + + await expect( + createCopilotSession(client as unknown as Parameters[0], 'gh-token', { + mcpServers: [{ + name: 'bad-mcp', + url: '%%%bad-url', + type: 'http', + headers: {}, + tools: ['search'], + }], + }), + ).rejects.toThrow('invalid URL'); + }); + + it('passes skillDirectories and disabledSkills to the SDK session config', async () => { + const client = createClientMock(); + + await createCopilotSession(client as unknown as Parameters[0], 'gh-token', { + skillDirectories: ['/skills/git-commit', '/skills/code-review'], + disabledSkills: ['code-review'], + }); + + const config = getSessionConfig(client); + expect(config.skillDirectories).toEqual(['/skills/git-commit', '/skills/code-review']); + expect(config.disabledSkills).toEqual(['code-review']); + }); + + it('omits skillDirectories and disabledSkills when empty', async () => { + const client = createClientMock(); + + await createCopilotSession(client as unknown as Parameters[0], 'gh-token', { + skillDirectories: [], + disabledSkills: [], + }); + + const config = getSessionConfig(client); + expect(config.skillDirectories).toBeUndefined(); + expect(config.disabledSkills).toBeUndefined(); + }); + + it('passes custom agents to SDK session config', async () => { + const client = createClientMock(); + const customAgents = [ + { name: 'editor', prompt: 'Edit code', tools: ['bash', 'edit'] }, + { name: 'reviewer', prompt: 'Review code', displayName: 'Code Reviewer' }, + ]; + + await createCopilotSession(client as unknown as Parameters[0], 'gh-token', { + customAgents, + }); + + const config = getSessionConfig(client); + expect(config.customAgents).toEqual(customAgents); + }); +}); + +describe('getAvailableModels', () => { + it('returns arrays directly from the client', async () => { + const client = createClientMock(); + client.listModels.mockResolvedValue([{ id: 'gpt-4.1' }]); + + await expect(getAvailableModels(client as unknown as Parameters[0])).resolves.toEqual([{ id: 'gpt-4.1' }]); + }); + + it('reads model arrays from object responses', async () => { + const client = createClientMock(); + client.listModels.mockResolvedValueOnce({ models: [{ id: 'claude-sonnet-4.5' }] }); + await expect(getAvailableModels(client as unknown as Parameters[0])).resolves.toEqual([{ id: 'claude-sonnet-4.5' }]); + + client.listModels.mockResolvedValueOnce({ data: [{ id: 'gemini-3-pro-preview' }] }); + await expect(getAvailableModels(client as unknown as Parameters[0])).resolves.toEqual([{ id: 'gemini-3-pro-preview' }]); + }); + + it('returns an empty array for unknown shapes or client errors', async () => { + const client = createClientMock(); + client.listModels.mockResolvedValueOnce({}); + await expect(getAvailableModels(client as unknown as Parameters[0])).resolves.toEqual([]); + + client.listModels.mockRejectedValueOnce(new Error('boom')); + await expect(getAvailableModels(client as unknown as Parameters[0])).resolves.toEqual([]); + }); +}); diff --git a/src/lib/server/copilot/session.ts b/src/lib/server/copilot/session.ts new file mode 100644 index 0000000..478f216 --- /dev/null +++ b/src/lib/server/copilot/session.ts @@ -0,0 +1,299 @@ +import { CopilotClient, defineTool } from '@github/copilot-sdk'; +import type { SessionConfig } from '@github/copilot-sdk'; +import { isIP } from 'node:net'; +import { config } from '../config.js'; +import { z } from 'zod'; + +export interface CustomToolDefinition { + name: string; + description: string; + webhookUrl: string; + method: 'GET' | 'POST'; + headers: Record; + parameters: Record; +} + +type ReasoningEffort = 'low' | 'medium' | 'high' | 'xhigh'; + +export interface InfiniteSessionsConfig { + enabled: boolean; + backgroundCompactionThreshold?: number; + bufferExhaustionThreshold?: number; +} + +export interface McpServerInput { + name: string; + url: string; + type: 'http' | 'sse'; + headers: Record; + tools: string[]; +} + +export interface CreateSessionOptions { + model?: string; + reasoningEffort?: ReasoningEffort; + customInstructions?: string; + excludedTools?: string[]; + availableTools?: string[]; + customTools?: CustomToolDefinition[]; + infiniteSessions?: InfiniteSessionsConfig; + onUserInputRequest?: SessionConfig['onUserInputRequest']; + permissionMode?: 'approve_all' | 'prompt'; + onPermissionRequest?: SessionConfig['onPermissionRequest']; + mcpServers?: McpServerInput[]; + configDir?: string; + skillDirectories?: string[]; + disabledSkills?: string[]; + customAgents?: Array<{ + name: string; + displayName?: string; + description?: string; + tools?: string[]; + prompt: string; + }>; +} + +function buildZodSchema(params: Record): z.ZodObject> { + const shape: Record = {}; + for (const [name, param] of Object.entries(params)) { + let field: z.ZodTypeAny; + switch (param.type) { + case 'number': field = z.number(); break; + case 'boolean': field = z.boolean(); break; + default: field = z.string(); break; + } + shape[name] = field.describe(param.description); + } + return z.object(shape); +} + +function isPrivateIpv4(hostname: string): boolean { + const octets = hostname.split('.').map((segment) => Number.parseInt(segment, 10)); + if (octets.length !== 4 || octets.some((segment) => Number.isNaN(segment) || segment < 0 || segment > 255)) { + return false; + } + + const [first, second] = octets; + return first === 0 || + first === 10 || + first === 127 || + (first === 169 && second === 254) || + (first === 172 && second >= 16 && second <= 31) || + (first === 192 && second === 168); +} + +function isPrivateIpv6(hostname: string): boolean { + const normalized = hostname.toLowerCase(); + if (normalized === '::' || normalized === '::1') { + return true; + } + + const firstSegment = normalized.split(':').find((segment) => segment.length > 0); + if (!firstSegment) { + return false; + } + + const firstHextet = Number.parseInt(firstSegment, 16); + if (Number.isNaN(firstHextet)) { + return false; + } + + return (firstHextet & 0xfe00) === 0xfc00 || (firstHextet & 0xffc0) === 0xfe80; +} + +function isBlockedHostname(hostname: string): boolean { + const normalized = hostname.replace(/^\[/, '').replace(/\]$/, '').toLowerCase(); + if (!normalized) { + return true; + } + + if (normalized === 'localhost' || normalized.endsWith('.localhost') || normalized === '0.0.0.0') { + return true; + } + + switch (isIP(normalized)) { + case 4: + return isPrivateIpv4(normalized); + case 6: + return isPrivateIpv6(normalized); + default: + return false; + } +} + +function validateOutboundUrl(kind: 'Tool' | 'MCP server', name: string, rawUrl: string): void { + let url: URL; + try { + url = new URL(rawUrl); + } catch { + throw new Error(`${kind} "${name}": invalid URL`); + } + + if (url.protocol !== 'https:') { + throw new Error(`${kind} "${name}": HTTPS required`); + } + + if (url.username || url.password) { + throw new Error(`${kind} "${name}": auth credentials in URLs are not allowed`); + } + + if (isBlockedHostname(url.hostname)) { + throw new Error(`${kind} "${name}": internal network URLs are not allowed`); + } +} + +function validateToolUrl(toolName: string, webhookUrl: string): void { + validateOutboundUrl('Tool', toolName, webhookUrl); +} + +function validateMcpServerUrl(name: string, serverUrl: string): void { + validateOutboundUrl('MCP server', name, serverUrl); +} + +function buildUserMcpServers(servers?: McpServerInput[]): Record { + if (!servers || servers.length === 0) return {}; + const result: Record = {}; + for (const server of servers) { + validateMcpServerUrl(server.name, server.url); + result[server.name] = { + type: server.type, + url: server.url, + headers: server.headers, + tools: server.tools.length > 0 ? server.tools : ['*'], + }; + } + return result; +} + +function buildCustomTools(customTools: CustomToolDefinition[]) { + return customTools.map((tool) => { + validateToolUrl(tool.name, tool.webhookUrl); + + return defineTool(tool.name, { + description: tool.description, + parameters: buildZodSchema(tool.parameters), + handler: async (args: unknown) => { + const response = await fetch(tool.webhookUrl, { + method: tool.method, + headers: { 'Content-Type': 'application/json', ...tool.headers }, + body: tool.method === 'POST' ? JSON.stringify(args) : undefined, + signal: AbortSignal.timeout(30_000), + }); + if (!response.ok) throw new Error(`Tool failed: ${response.status}`); + return await response.json(); + }, + }); + }); +} + +export async function createCopilotSession( + client: CopilotClient, + githubToken: string, + options: CreateSessionOptions = {} +) { + // Wrap the permission handler to log calls for diagnostics + const wrappedApproveAll: SessionConfig['onPermissionRequest'] = (request: any, context) => { + console.log('[PERMISSION] approveAll called:', JSON.stringify({ + toolName: request?.toolName ?? request?.tool?.name, + sessionId: context?.sessionId, + })); + return { kind: 'approved' as const }; + }; + + const permissionHandler = options.permissionMode === 'prompt' && options.onPermissionRequest + ? options.onPermissionRequest + : wrappedApproveAll; + + console.log('[SESSION] Creating session with permissionMode:', options.permissionMode || 'approve_all (default)'); + + const sessionConfig: SessionConfig = { + clientName: 'copilot-unleashed', + model: options.model || 'gpt-4.1', + streaming: true, + onPermissionRequest: permissionHandler, + ...(config.copilotConfigDir && { configDir: config.copilotConfigDir }), + mcpServers: { + github: { + type: 'http', + url: 'https://api.githubcopilot.com/mcp/x/all', + headers: { + Authorization: `Bearer ${githubToken}`, + }, + tools: ['*'], + }, + ...buildUserMcpServers(options.mcpServers), + }, + }; + + if (options.reasoningEffort) { + sessionConfig.reasoningEffort = options.reasoningEffort; + } + + if (options.excludedTools && options.excludedTools.length > 0) { + sessionConfig.excludedTools = options.excludedTools; + } + + if (options.availableTools && options.availableTools.length > 0) { + sessionConfig.availableTools = options.availableTools; + } + + if (options.customInstructions) { + sessionConfig.systemMessage = { + mode: 'append', + content: options.customInstructions, + }; + } + + if (options.onUserInputRequest) { + sessionConfig.onUserInputRequest = options.onUserInputRequest; + } + + if (options.infiniteSessions) { + sessionConfig.infiniteSessions = { + enabled: options.infiniteSessions.enabled, + ...(options.infiniteSessions.backgroundCompactionThreshold != null && { + backgroundCompactionThreshold: options.infiniteSessions.backgroundCompactionThreshold, + }), + ...(options.infiniteSessions.bufferExhaustionThreshold != null && { + bufferExhaustionThreshold: options.infiniteSessions.bufferExhaustionThreshold, + }), + }; + } + + if (options.customTools && options.customTools.length > 0) { + sessionConfig.tools = buildCustomTools(options.customTools); + } + + if (options.configDir) { + sessionConfig.configDir = options.configDir; + } + + if (options.skillDirectories && options.skillDirectories.length > 0) { + sessionConfig.skillDirectories = options.skillDirectories; + } + + if (options.disabledSkills && options.disabledSkills.length > 0) { + sessionConfig.disabledSkills = options.disabledSkills; + } + + if (options.customAgents && options.customAgents.length > 0) { + sessionConfig.customAgents = options.customAgents; + } + + return client.createSession(sessionConfig); +} + +export async function getAvailableModels(client: CopilotClient) { + try { + const result = await client.listModels(); + if (Array.isArray(result)) return result; + if (result && typeof result === 'object') { + const obj = result as Record; + if (Array.isArray(obj.models)) return obj.models; + if (Array.isArray(obj.data)) return obj.data; + } + return []; + } catch { + return []; + } +} diff --git a/src/lib/server/node-sqlite.d.ts b/src/lib/server/node-sqlite.d.ts new file mode 100644 index 0000000..9865dba --- /dev/null +++ b/src/lib/server/node-sqlite.d.ts @@ -0,0 +1,19 @@ +declare module 'node:sqlite' { + export class DatabaseSync { + constructor(path: string); + exec(sql: string): void; + prepare(sql: string): StatementSync; + close(): void; + } + + interface StatementSync { + run(...params: unknown[]): RunResult; + get(...params: unknown[]): unknown; + all(...params: unknown[]): unknown[]; + } + + interface RunResult { + changes: number; + lastInsertRowid: number | bigint; + } +} diff --git a/src/lib/server/security-log.ts b/src/lib/server/security-log.ts new file mode 100644 index 0000000..2cf1d0a --- /dev/null +++ b/src/lib/server/security-log.ts @@ -0,0 +1,25 @@ +type SecurityLevel = 'info' | 'warn' | 'error'; + +export function logSecurity( + level: SecurityLevel, + event: string, + details?: Record +): void { + const entry = { + timestamp: new Date().toISOString(), + level, + event, + ...details, + }; + + switch (level) { + case 'error': + console.error(JSON.stringify(entry)); + break; + case 'warn': + console.warn(JSON.stringify(entry)); + break; + default: + console.log(JSON.stringify(entry)); + } +} diff --git a/src/lib/server/session-store.ts b/src/lib/server/session-store.ts new file mode 100644 index 0000000..2594d87 --- /dev/null +++ b/src/lib/server/session-store.ts @@ -0,0 +1,36 @@ +// Bridge between express-session (server.js) and SvelteKit routes. +// Uses globalThis so the same Map is shared between server.js (unbundled) +// and SvelteKit server code (bundled by adapter-node). + +import type { SessionData } from './auth/guard.js'; + +const GLOBAL_KEY = '__copilotSessionMap'; +const COUNTER_KEY = '__copilotSessionCounter'; + +const g = globalThis as Record; + +function getMap(): Map { + if (!g[GLOBAL_KEY]) { + g[GLOBAL_KEY] = new Map(); + } + return g[GLOBAL_KEY] as Map; +} + +export function registerSession(session: SessionData): string { + g[COUNTER_KEY] = ((g[COUNTER_KEY] as number) || 0) + 1; + const id = String(g[COUNTER_KEY]); + getMap().set(id, session); + console.log(`[SESSION-STORE] register id=${id} hasToken=${!!session.githubToken} user=${session.githubUser?.login ?? 'none'} mapSize=${getMap().size}`); + return id; +} + +export function getSessionById(id: string): SessionData | undefined { + const session = getMap().get(id); + console.log(`[SESSION-STORE] get id=${id} found=${!!session} hasToken=${!!session?.githubToken} user=${session?.githubUser?.login ?? 'none'} mapSize=${getMap().size}`); + return session; +} + +export function deleteSessionById(id: string): void { + console.log(`[SESSION-STORE] delete id=${id} mapSize=${getMap().size - 1}`); + getMap().delete(id); +} diff --git a/src/lib/server/settings-store.test.ts b/src/lib/server/settings-store.test.ts new file mode 100644 index 0000000..c817851 --- /dev/null +++ b/src/lib/server/settings-store.test.ts @@ -0,0 +1,206 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { join } from 'node:path'; + +const { + mkdirMock, + randomUUIDMock, + readFileMock, + renameMock, + writeFileMock, +} = vi.hoisted(() => ({ + mkdirMock: vi.fn(), + randomUUIDMock: vi.fn(), + readFileMock: vi.fn(), + renameMock: vi.fn(), + writeFileMock: vi.fn(), +})); + +vi.mock('node:crypto', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + randomUUID: randomUUIDMock, + default: { + ...actual, + randomUUID: randomUUIDMock, + }, + }; +}); + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + default: { + ...actual, + mkdir: mkdirMock, + readFile: readFileMock, + rename: renameMock, + writeFile: writeFileMock, + }, + mkdir: mkdirMock, + readFile: readFileMock, + rename: renameMock, + writeFile: writeFileMock, + }; +}); + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + default: { + ...actual, + mkdir: mkdirMock, + readFile: readFileMock, + rename: renameMock, + writeFile: writeFileMock, + }, + mkdir: mkdirMock, + readFile: readFileMock, + rename: renameMock, + writeFile: writeFileMock, + }; +}); + +vi.mock('./config.js', () => ({ + config: { + settingsStorePath: '/settings-store', + }, +})); + +import { loadUserSettings, saveUserSettings } from './settings-store.js'; + +interface PersistedSettings { + model: string; + mode: string; + reasoningEffort: string; + customInstructions: string; + excludedTools: string[]; + customTools: unknown[]; + mcpServers?: unknown[]; +} + +const sampleSettings: PersistedSettings = { + model: 'gpt-4.1', + mode: 'interactive', + reasoningEffort: 'medium', + customInstructions: 'Be helpful.', + excludedTools: ['bash'], + customTools: [{ name: 'lint' }], + mcpServers: [{ name: 'github' }], +}; + +beforeEach(() => { + mkdirMock.mockReset(); + randomUUIDMock.mockReset(); + readFileMock.mockReset(); + renameMock.mockReset(); + writeFileMock.mockReset(); + + mkdirMock.mockResolvedValue(undefined); + randomUUIDMock.mockReturnValue('uuid-1'); + renameMock.mockResolvedValue(undefined); + writeFileMock.mockResolvedValue(undefined); + + vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('loadUserSettings', () => { + it('reads and parses settings from the user JSON file', async () => { + readFileMock.mockResolvedValue(JSON.stringify(sampleSettings)); + + await expect(loadUserSettings('User.Name!')).resolves.toEqual(sampleSettings); + expect(readFileMock).toHaveBeenCalledWith(join('/settings-store', 'username.json'), 'utf-8'); + }); + + it('returns null when the settings file is missing so callers can apply defaults', async () => { + readFileMock.mockRejectedValue(new Error('ENOENT')); + + await expect(loadUserSettings('missing-user')).resolves.toBeNull(); + }); + + it('returns null when the settings file contains corrupted JSON', async () => { + readFileMock.mockResolvedValue('{ invalid json'); + + await expect(loadUserSettings('broken-user')).resolves.toBeNull(); + }); + + it('returns null for invalid usernames', async () => { + await expect(loadUserSettings('!!!')).resolves.toBeNull(); + expect(readFileMock).not.toHaveBeenCalled(); + }); +}); + +describe('saveUserSettings', () => { + it('writes settings through an atomic sibling temp file and renames into place', async () => { + await saveUserSettings('Test_User', sampleSettings); + + const filePath = join('/settings-store', 'test_user.json'); + const tmpPath = join('/settings-store', '.test_user.1700000000000-uuid-1.tmp'); + + expect(mkdirMock).toHaveBeenCalledWith('/settings-store', { recursive: true }); + expect(writeFileMock).toHaveBeenCalledWith(tmpPath, JSON.stringify(sampleSettings), 'utf-8'); + expect(renameMock).toHaveBeenCalledWith(tmpPath, filePath); + }); + + it('sanitizes usernames consistently when saving', async () => { + await saveUserSettings('Name.With Spaces!', sampleSettings); + + const tmpPath = join('/settings-store', '.namewithspaces.1700000000000-uuid-1.tmp'); + const filePath = join('/settings-store', 'namewithspaces.json'); + expect(writeFileMock).toHaveBeenCalledWith(tmpPath, JSON.stringify(sampleSettings), 'utf-8'); + expect(renameMock).toHaveBeenCalledWith(tmpPath, filePath); + }); + + it('rejects oversized settings payloads before touching the filesystem', async () => { + const oversized: PersistedSettings = { + ...sampleSettings, + customInstructions: 'x'.repeat(60_000), + }; + + await expect(saveUserSettings('user', oversized)).rejects.toThrow('Settings data exceeds maximum size'); + expect(mkdirMock).not.toHaveBeenCalled(); + expect(writeFileMock).not.toHaveBeenCalled(); + expect(renameMock).not.toHaveBeenCalled(); + }); + + it('rejects invalid usernames', async () => { + await expect(saveUserSettings('!!!', sampleSettings)).rejects.toThrow('Invalid username'); + expect(mkdirMock).not.toHaveBeenCalled(); + }); + + it('propagates write failures', async () => { + writeFileMock.mockRejectedValue(new Error('disk full')); + + await expect(saveUserSettings('writer', sampleSettings)).rejects.toThrow('disk full'); + expect(renameMock).not.toHaveBeenCalled(); + }); + + it('propagates rename failures after writing the temp file', async () => { + renameMock.mockRejectedValue(new Error('EXDEV')); + + await expect(saveUserSettings('writer', sampleSettings)).rejects.toThrow('EXDEV'); + expect(writeFileMock).toHaveBeenCalledTimes(1); + expect(renameMock).toHaveBeenCalledTimes(1); + }); + + it('uses unique temp files for concurrent writes', async () => { + randomUUIDMock.mockReturnValueOnce('uuid-a').mockReturnValueOnce('uuid-b'); + + await Promise.all([ + saveUserSettings('parallel', sampleSettings), + saveUserSettings('parallel', sampleSettings), + ]); + + const tempPaths = writeFileMock.mock.calls.map(([filePath]) => filePath); + expect(tempPaths).toEqual([ + join('/settings-store', '.parallel.1700000000000-uuid-a.tmp'), + join('/settings-store', '.parallel.1700000000000-uuid-b.tmp'), + ]); + }); +}); diff --git a/src/lib/server/settings-store.ts b/src/lib/server/settings-store.ts new file mode 100644 index 0000000..9144c5b --- /dev/null +++ b/src/lib/server/settings-store.ts @@ -0,0 +1,54 @@ +import { randomUUID } from 'node:crypto'; +import { readFile, writeFile, mkdir, rename } from 'node:fs/promises'; +import { join, dirname } from 'node:path'; +import { config } from './config.js'; + +interface PersistedSettings { + model: string; + mode: string; + reasoningEffort: string; + customInstructions: string; + excludedTools: string[]; + customTools: unknown[]; + mcpServers?: unknown[]; +} + +const MAX_FILE_SIZE = 50 * 1024; // 50KB per user + +function sanitizeUsername(username: string): string { + return username.toLowerCase().replace(/[^a-z0-9_-]/g, ''); +} + +function settingsPath(username: string): string { + const safe = sanitizeUsername(username); + if (!safe) throw new Error('Invalid username'); + return join(config.settingsStorePath, `${safe}.json`); +} + +export async function loadUserSettings(username: string): Promise { + try { + const filePath = settingsPath(username); + const content = await readFile(filePath, 'utf-8'); + return JSON.parse(content) as PersistedSettings; + } catch { + return null; + } +} + +export async function saveUserSettings(username: string, settings: PersistedSettings): Promise { + const filePath = settingsPath(username); + const data = JSON.stringify(settings); + + if (Buffer.byteLength(data, 'utf-8') > MAX_FILE_SIZE) { + throw new Error('Settings data exceeds maximum size'); + } + + const dir = dirname(filePath); + await mkdir(dir, { recursive: true }); + + // Atomic write: write to a sibling temp file, then rename into place. + const safeUsername = sanitizeUsername(username); + const tmpFile = join(dir, `.${safeUsername}.${Date.now()}-${randomUUID()}.tmp`); + await writeFile(tmpFile, data, 'utf-8'); + await rename(tmpFile, filePath); +} diff --git a/src/lib/server/skills/scanner.test.ts b/src/lib/server/skills/scanner.test.ts new file mode 100644 index 0000000..a8d7b0a --- /dev/null +++ b/src/lib/server/skills/scanner.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { scanSkills, getSkillDirectories, clearSkillCache } from './scanner.js'; + +describe('skill scanner', () => { + let tempDir: string; + + beforeEach(async () => { + clearSkillCache(); + tempDir = await mkdtemp(join(tmpdir(), 'skills-test-')); + }); + + afterEach(async () => { + clearSkillCache(); + await rm(tempDir, { recursive: true, force: true }); + }); + + it('returns empty array when skills directory does not exist', async () => { + const skills = await scanSkills('/nonexistent/path'); + expect(skills).toEqual([]); + }); + + it('returns empty array when skills directory is empty', async () => { + const skills = await scanSkills(tempDir); + expect(skills).toEqual([]); + }); + + it('discovers a valid SKILL.md with frontmatter', async () => { + const skillDir = join(tempDir, 'test-skill'); + await mkdir(skillDir); + await writeFile(join(skillDir, 'SKILL.md'), [ + '---', + 'name: test-skill', + "description: 'A test skill for testing'", + 'license: MIT', + 'allowed-tools: Bash', + '---', + '', + '# Test Skill', + '', + 'Some instructions here.', + ].join('\n')); + + const skills = await scanSkills(tempDir); + expect(skills).toHaveLength(1); + expect(skills[0]).toEqual({ + name: 'test-skill', + description: 'A test skill for testing', + directory: skillDir, + license: 'MIT', + allowedTools: 'Bash', + }); + }); + + it('skips directories without SKILL.md', async () => { + await mkdir(join(tempDir, 'no-skill')); + await writeFile(join(tempDir, 'no-skill', 'README.md'), '# Not a skill'); + + const skills = await scanSkills(tempDir); + expect(skills).toEqual([]); + }); + + it('skips SKILL.md files without a name in frontmatter', async () => { + const skillDir = join(tempDir, 'nameless'); + await mkdir(skillDir); + await writeFile(join(skillDir, 'SKILL.md'), [ + '---', + "description: 'No name field'", + '---', + '', + '# Nameless', + ].join('\n')); + + const skills = await scanSkills(tempDir); + expect(skills).toEqual([]); + }); + + it('discovers multiple skills', async () => { + for (const name of ['alpha', 'beta']) { + const dir = join(tempDir, name); + await mkdir(dir); + await writeFile(join(dir, 'SKILL.md'), [ + '---', + `name: ${name}`, + `description: 'Skill ${name}'`, + '---', + '', + `# ${name}`, + ].join('\n')); + } + + const skills = await scanSkills(tempDir); + expect(skills).toHaveLength(2); + expect(skills.map(s => s.name).sort()).toEqual(['alpha', 'beta']); + }); + + it('getSkillDirectories returns absolute directory paths', async () => { + const dir = join(tempDir, 'my-skill'); + await mkdir(dir); + await writeFile(join(dir, 'SKILL.md'), [ + '---', + 'name: my-skill', + 'description: Test', + '---', + '', + '# My Skill', + ].join('\n')); + + const dirs = await getSkillDirectories(tempDir); + expect(dirs).toEqual([dir]); + }); + + it('caches results after first scan', async () => { + const dir = join(tempDir, 'cached'); + await mkdir(dir); + await writeFile(join(dir, 'SKILL.md'), '---\nname: cached\ndescription: Test\n---\n# Cached'); + + const first = await scanSkills(tempDir); + expect(first).toHaveLength(1); + + // Add another skill — should not appear due to cache + const dir2 = join(tempDir, 'new-skill'); + await mkdir(dir2); + await writeFile(join(dir2, 'SKILL.md'), '---\nname: new\ndescription: New\n---\n# New'); + + const second = await scanSkills(tempDir); + expect(second).toHaveLength(1); // Still cached + + // After clearing cache, new scan should find both + clearSkillCache(); + const third = await scanSkills(tempDir); + expect(third).toHaveLength(2); + }); +}); diff --git a/src/lib/server/skills/scanner.ts b/src/lib/server/skills/scanner.ts new file mode 100644 index 0000000..3ab25d6 --- /dev/null +++ b/src/lib/server/skills/scanner.ts @@ -0,0 +1,81 @@ +import { readdir, readFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; + +export interface SkillDefinition { + name: string; + description: string; + directory: string; + license?: string; + allowedTools?: string; +} + +const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/; + +function parseFrontmatter(raw: string): Record { + const match = raw.match(FRONTMATTER_RE); + if (!match) return {}; + + const result: Record = {}; + for (const line of match[1].split('\n')) { + const colonIdx = line.indexOf(':'); + if (colonIdx < 1) continue; + const key = line.slice(0, colonIdx).trim(); + const value = line.slice(colonIdx + 1).trim().replace(/^['"]|['"]$/g, ''); + if (key && value) result[key] = value; + } + return result; +} + +let cachedSkills: SkillDefinition[] | null = null; + +/** Scan the skills directory and return all valid skill definitions. */ +export async function scanSkills(skillsRoot?: string): Promise { + if (cachedSkills) return cachedSkills; + + const root = skillsRoot ?? resolve(process.cwd(), 'skills'); + const skills: SkillDefinition[] = []; + + let entries: string[]; + try { + entries = await readdir(root); + } catch { + // skills/ directory doesn't exist — no skills available + cachedSkills = []; + return []; + } + + for (const entry of entries) { + const dir = join(root, entry); + const skillPath = join(dir, 'SKILL.md'); + + try { + const content = await readFile(skillPath, 'utf-8'); + const fm = parseFrontmatter(content); + if (!fm.name) continue; + + skills.push({ + name: fm.name, + description: fm.description ?? '', + directory: dir, + ...(fm.license && { license: fm.license }), + ...(fm['allowed-tools'] && { allowedTools: fm['allowed-tools'] }), + }); + } catch { + // No SKILL.md or unreadable — skip + } + } + + cachedSkills = skills; + return skills; +} + +/** Get the absolute directory paths for all discovered skills. */ +export async function getSkillDirectories(skillsRoot?: string): Promise { + const skills = await scanSkills(skillsRoot); + return skills.map((s) => s.directory); +} + +/** Clear the cached skills (for testing or hot-reload). */ +export function clearSkillCache(): void { + cachedSkills = null; +} diff --git a/src/lib/server/ws/handler.ts b/src/lib/server/ws/handler.ts new file mode 100644 index 0000000..b512689 --- /dev/null +++ b/src/lib/server/ws/handler.ts @@ -0,0 +1,1225 @@ +import { WebSocketServer, WebSocket } from 'ws'; +import { Server, IncomingMessage } from 'http'; +import { writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { approveAll } from '@github/copilot-sdk'; +import { createCopilotClient } from '../copilot/client.js'; +import { createCopilotSession, getAvailableModels } from '../copilot/session.js'; +import { enrichSessionMetadata, getSessionDetail, getSessionStateDir, listSessionsFromFilesystem, buildSessionContext, deleteSessionFromFilesystem } from '../copilot/session-metadata.js'; +import { config } from '../config.js'; +import { logSecurity } from '../security-log.js'; +import { validateGitHubToken } from '../auth/github.js'; +import { checkAuth } from '../auth/guard.js'; +import { getSkillDirectories } from '../skills/scanner.js'; +import { clearAuth } from '../auth/session-utils.js'; +import { + sessionPool, createPoolEntry, destroyPoolEntry, poolSend, + isValidTabId, countUserSessions, evictOldestUserSession, + type PoolEntry, +} from './session-pool.js'; + +type SessionMiddleware = (req: any, res: any, next: () => void) => void; + +const MAX_MESSAGE_LENGTH = 10_000; +const VALID_MESSAGE_TYPES = new Set([ + 'new_session', 'message', 'list_models', 'set_mode', + 'abort', 'set_model', 'set_reasoning', 'user_input_response', + 'permission_response', + 'list_tools', 'list_agents', 'select_agent', 'deselect_agent', + 'get_quota', 'compact', 'list_sessions', 'resume_session', + 'delete_session', 'get_session_detail', 'get_plan', 'update_plan', 'delete_plan', 'start_fleet', +]); +const VALID_MODES = new Set(['interactive', 'plan', 'autopilot']); +const VALID_REASONING = new Set(['low', 'medium', 'high', 'xhigh']); +const HEARTBEAT_INTERVAL = 30_000; + +/** Normalize SDK quota snapshots: convert remainingPercentage from 0.0–1.0 to 0–100 and add percentageUsed */ +function normalizeQuotaSnapshots(raw: Record | undefined): Record | undefined { + if (!raw) return raw; + console.log('[QUOTA] raw SDK snapshots:', JSON.stringify(raw)); + const result: Record = {}; + for (const [key, snap] of Object.entries(raw)) { + const remaining = snap.remainingPercentage; + const normalizedRemaining = remaining != null && remaining <= 1 ? remaining * 100 : remaining; + result[key] = { + ...snap, + remainingPercentage: normalizedRemaining, + percentageUsed: normalizedRemaining != null ? 100 - normalizedRemaining : undefined, + }; + } + return result; +} + +function wireSessionEvents(session: any, entry: PoolEntry, sessionId?: string): void { + session.on('assistant.message_delta', (event: any) => { + poolSend(entry, { type: 'delta', content: event.data.deltaContent }); + }); + session.on('assistant.reasoning_delta', (event: any) => { + poolSend(entry, { type: 'reasoning_delta', content: event.data.deltaContent, reasoningId: event.data.reasoningId }); + }); + session.on('assistant.reasoning', (event: any) => { + poolSend(entry, { type: 'reasoning_done', reasoningId: event.data.reasoningId, content: event.data.content }); + }); + session.on('assistant.intent', (event: any) => { + poolSend(entry, { type: 'intent', intent: event.data.intent }); + }); + session.on('assistant.turn_start', () => { poolSend(entry, { type: 'turn_start' }); }); + session.on('assistant.turn_end', () => { + entry.isProcessing = false; + poolSend(entry, { type: 'turn_end' }); + poolSend(entry, { type: 'done' }); + }); + session.on('tool.execution_start', (event: any) => { + console.log('[TOOL] execution_start:', event.data.toolName, 'mcp:', event.data.mcpServerName, '/', event.data.mcpToolName); + poolSend(entry, { type: 'tool_start', toolCallId: event.data.toolCallId, toolName: event.data.toolName, mcpServerName: event.data.mcpServerName, mcpToolName: event.data.mcpToolName }); + }); + session.on('tool.execution_complete', (event: any) => { + console.log('[TOOL] execution_complete:', event.data.toolCallId); + poolSend(entry, { type: 'tool_end', toolCallId: event.data.toolCallId }); + }); + session.on('tool.execution_progress', (event: any) => { + console.log('[TOOL] execution_progress:', event.data.toolCallId, event.data.message); + poolSend(entry, { type: 'tool_progress', toolCallId: event.data.toolCallId, message: event.data.message }); + }); + session.on('session.mode_changed', (event: any) => { + poolSend(entry, { type: 'mode_changed', mode: event.data.newMode }); + }); + session.on('session.error', (event: any) => { + console.error('[SESSION] error event:', event.data.message); + poolSend(entry, { type: 'error', message: event.data.message }); + }); + session.on('session.title_changed', (event: any) => { + poolSend(entry, { type: 'title_changed', title: event.data.title }); + }); + session.on('assistant.usage', (event: any) => { + poolSend(entry, { + type: 'usage', + inputTokens: event.data.inputTokens, + outputTokens: event.data.outputTokens, + totalTokens: event.data.totalTokens, + reasoningTokens: event.data.reasoningTokens, + cacheReadTokens: event.data.cacheReadTokens, + cacheWriteTokens: event.data.cacheWriteTokens, + duration: event.data.duration, + cost: event.data.cost, + quotaSnapshots: normalizeQuotaSnapshots(event.data.quotaSnapshots), + copilotUsage: event.data.copilotUsage, + }); + }); + session.on('session.warning', (event: any) => { + poolSend(entry, { type: 'warning', message: event.data.message }); + }); + session.on('session.usage_info', (event: any) => { + poolSend(entry, { + type: 'context_info', + tokenLimit: event.data.tokenLimit, + currentTokens: event.data.currentTokens, + messagesLength: event.data.messagesLength, + }); + }); + session.on('subagent.started', (event: any) => { + poolSend(entry, { type: 'subagent_start', agentName: event.data.agentName, description: event.data?.description }); + }); + session.on('subagent.completed', (event: any) => { + poolSend(entry, { type: 'subagent_end', agentName: event.data.agentName }); + }); + session.on('session.info', (event: any) => { + poolSend(entry, { type: 'info', message: event.data?.message || event.data }); + }); + session.on('session.plan_changed', (event: any) => { + poolSend(entry, { type: 'plan_changed', content: event.data?.content, path: event.data?.path }); + // Read the full plan to sync UI + persist to disk + session.rpc.plan.read() + .then((plan: { exists?: boolean; content?: string; path?: string }) => { + if (plan?.exists && plan.content != null) { + // Send full plan to UI so the panel stays in sync + poolSend(entry, { type: 'plan', exists: true, content: plan.content, path: plan.path }); + // Persist plan changes to disk for CLI bidirectional sync + if (sessionId) { + const sessionDir = join(getSessionStateDir(), sessionId); + writeFile(join(sessionDir, 'plan.md'), plan.content, 'utf-8') + .catch((err: Error) => console.warn(`[PLAN] Failed to sync plan.md for ${sessionId}:`, err.message)); + } + } + }) + .catch((err: Error) => console.warn(`[PLAN] Failed to read plan:`, err.message)); + }); + session.on('session.compaction_start', () => { poolSend(entry, { type: 'compaction_start' }); }); + session.on('session.compaction_complete', (event: any) => { + poolSend(entry, { + type: 'compaction_complete', + tokensRemoved: event.data?.tokensRemoved, + messagesRemoved: event.data?.messagesRemoved, + preCompactionTokens: event.data?.preCompactionTokens, + postCompactionTokens: event.data?.postCompactionTokens, + }); + }); + session.on('session.shutdown', (event: any) => { + poolSend(entry, { + type: 'session_shutdown', + totalPremiumRequests: event.data?.totalPremiumRequests, + totalApiDurationMs: event.data?.totalApiDurationMs, + sessionStartTime: event.data?.sessionStartTime, + }); + }); + session.on('skill.invoked', (event: any) => { + poolSend(entry, { type: 'skill_invoked', skillName: event.data?.skillName }); + }); + session.on('subagent.failed', (event: any) => { + poolSend(entry, { type: 'subagent_failed', agentName: event.data?.agentName, error: event.data?.error }); + }); + session.on('subagent.selected', (event: any) => { + poolSend(entry, { type: 'subagent_selected', agentName: event.data?.agentName }); + }); + session.on('subagent.deselected', (event: any) => { + poolSend(entry, { type: 'subagent_deselected', agentName: event.data?.agentName }); + }); + session.on('session.model_change', (event: any) => { + poolSend(entry, { type: 'model_changed', model: event.data?.model || event.data?.newModel, source: 'sdk' }); + }); + session.on('elicitation.requested', (event: any) => { + poolSend(entry, { type: 'elicitation_requested', question: event.data?.question, choices: event.data?.choices, allowFreeform: event.data?.allowFreeform }); + }); + session.on('elicitation.completed', (event: any) => { + poolSend(entry, { type: 'elicitation_completed', answer: event.data?.answer }); + }); + session.on('exit_plan_mode.requested', () => { poolSend(entry, { type: 'exit_plan_mode_requested' }); }); + session.on('exit_plan_mode.completed', () => { poolSend(entry, { type: 'exit_plan_mode_completed' }); }); + session.on('session.idle', (event: any) => { + poolSend(entry, { type: 'session_idle', backgroundTasks: event.data?.backgroundTasks }); + const agents = event.data?.backgroundTasks?.agents; + if (Array.isArray(agents) && agents.length > 0) { + poolSend(entry, { type: 'fleet_status', agents }); + } + }); + session.on('session.task_complete', (event: any) => { + poolSend(entry, { type: 'task_complete', summary: event.data?.summary }); + }); + session.on('session.truncation', (event: any) => { + poolSend(entry, { + type: 'truncation', + tokenLimit: event.data?.tokenLimit, + preTruncationTokens: event.data?.preTruncationTokensInMessages, + preTruncationMessages: event.data?.preTruncationMessagesLength, + postTruncationTokens: event.data?.postTruncationTokensInMessages, + postTruncationMessages: event.data?.postTruncationMessagesLength, + }); + }); + session.on('tool.execution_partial_result', (event: any) => { + poolSend(entry, { type: 'tool_partial_result', toolCallId: event.data?.toolCallId, partialOutput: event.data?.partialOutput }); + }); + session.on('session.context_changed', (event: any) => { + poolSend(entry, { + type: 'context_changed', + cwd: event.data?.cwd, + gitRoot: event.data?.gitRoot, + repository: event.data?.repository, + branch: event.data?.branch, + }); + }); + session.on('session.workspace_file_changed', (event: any) => { + poolSend(entry, { type: 'workspace_file_changed', path: event.data?.path, operation: event.data?.operation }); + }); + + // Catch-all: log unhandled event types for debugging / future audit + const handledTypes = new Set([ + 'assistant.message_delta', 'assistant.reasoning_delta', 'assistant.reasoning', + 'assistant.intent', 'assistant.turn_start', 'assistant.turn_end', 'assistant.usage', + 'tool.execution_start', 'tool.execution_complete', 'tool.execution_progress', + 'tool.execution_partial_result', + 'session.mode_changed', 'session.error', 'session.title_changed', + 'session.warning', 'session.usage_info', 'session.info', + 'session.plan_changed', 'session.compaction_start', 'session.compaction_complete', + 'session.shutdown', 'session.model_change', 'session.idle', 'session.task_complete', + 'session.truncation', 'session.context_changed', 'session.workspace_file_changed', + 'subagent.started', 'subagent.completed', 'subagent.failed', + 'subagent.selected', 'subagent.deselected', + 'skill.invoked', + 'elicitation.requested', 'elicitation.completed', + 'exit_plan_mode.requested', 'exit_plan_mode.completed', + ]); + session.on((event: any) => { + if (!handledTypes.has(event.type)) { + console.log('[EVENT] unhandled SDK event:', event.type, JSON.stringify(event.data ?? {}).slice(0, 200)); + } + }); +} + +function makeUserInputHandler(entry: PoolEntry) { + return (request: any) => { + return new Promise<{ answer: string; wasFreeform: boolean }>((resolve) => { + entry.userInputResolve = resolve; + poolSend(entry, { + type: 'user_input_request', + question: request.question, + choices: request.choices, + allowFreeform: request.allowFreeform ?? true, + }); + }); + }; +} + +const PERMISSION_TIMEOUT_MS = 300_000; // 5 minutes + +function extractPermissionDisplay(request: any): { + kind: string; + toolName: string; + toolArgs: Record; +} { + const kind: string = request.kind ?? 'unknown'; + + switch (kind) { + case 'shell': + return { + kind, + toolName: request.fullCommandText ?? 'shell command', + toolArgs: { + ...(request.intention && { intention: request.intention }), + ...(request.possiblePaths?.length && { paths: request.possiblePaths }), + ...(request.possibleUrls?.length && { urls: request.possibleUrls.map((u: any) => u.url) }), + ...(request.warning && { warning: request.warning }), + }, + }; + case 'write': + return { + kind, + toolName: request.fileName ?? 'file', + toolArgs: { + ...(request.intention && { intention: request.intention }), + ...(request.diff && { diff: request.diff }), + }, + }; + case 'read': + return { + kind, + toolName: request.path ?? 'file', + toolArgs: { + ...(request.intention && { intention: request.intention }), + }, + }; + case 'mcp': + return { + kind, + toolName: request.toolName ?? request.toolTitle ?? request.serverName ?? 'mcp tool', + toolArgs: request.args ?? {}, + }; + case 'url': + return { + kind, + toolName: request.url ?? 'url', + toolArgs: { + ...(request.intention && { intention: request.intention }), + }, + }; + case 'custom-tool': + return { + kind, + toolName: request.toolName ?? 'custom tool', + toolArgs: request.args ?? {}, + }; + case 'memory': + return { + kind, + toolName: request.subject ?? 'memory', + toolArgs: { + ...(request.fact && { fact: request.fact }), + ...(request.citations && { citations: request.citations }), + }, + }; + default: + return { + kind, + toolName: request.toolName ?? request.tool?.name ?? kind, + toolArgs: request.args ?? request.tool?.args ?? {}, + }; + } +} + +function makePermissionHandler(entry: PoolEntry) { + return (request: any) => { + const { kind, toolName, toolArgs } = extractPermissionDisplay(request); + + // Check remembered preferences keyed by kind (so "always allow shell" covers all shell cmds) + const prefKey = kind; + const remembered = entry.permissionPreferences.get(prefKey); + if (remembered === 'allow') return Promise.resolve({ kind: 'approved' as const }); + if (remembered === 'deny') return Promise.resolve({ kind: 'denied-interactively-by-user' as const }); + + const requestId = `perm-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + return new Promise<{ kind: 'approved' } | { kind: 'denied-interactively-by-user'; feedback?: string }>((resolve) => { + const timeout = setTimeout(() => { + entry.permissionResolve = null; + resolve({ kind: 'denied-interactively-by-user', feedback: 'Permission request timed out' }); + }, PERMISSION_TIMEOUT_MS); + + entry.permissionResolve = (decision: string) => { + clearTimeout(timeout); + resolve( + decision === 'allow' + ? { kind: 'approved' } + : { kind: 'denied-interactively-by-user', feedback: 'User denied' }, + ); + }; + + poolSend(entry, { + type: 'permission_request', + requestId, + kind, + toolName, + toolArgs, + }); + }); + }; +} + +export function setupWebSocket( + server: Server, + sessionMiddleware: SessionMiddleware +): void { + const wss = new WebSocketServer({ server, path: '/ws' }); + + // Heartbeat — detect dead connections + const heartbeat = setInterval(() => { + wss.clients.forEach((ws: WebSocket & { isAlive?: boolean }) => { + if (ws.isAlive === false) return ws.terminate(); + ws.isAlive = false; + ws.ping(); + }); + }, HEARTBEAT_INTERVAL); + + wss.on('close', () => clearInterval(heartbeat)); + + wss.on('connection', async (ws: WebSocket, req: IncomingMessage) => { + console.log('[WS-SERVER] New connection attempt from', req.socket.remoteAddress); + (ws as any).isAlive = true; + ws.on('pong', () => { (ws as any).isAlive = true; }); + + // Validate WebSocket origin + const origin = req.headers.origin; + if (origin && !config.isDev) { + const baseOrigin = new URL(config.baseUrl).origin; + if (origin !== baseOrigin) { + logSecurity('warn', 'ws_forbidden_origin', { origin, expected: baseOrigin }); + ws.close(1008, 'Forbidden origin'); + return; + } + } + + // Extract Express session from the upgrade request + await new Promise((resolve) => { + sessionMiddleware(req, {} as any, resolve); + }); + + const session = (req as any).session; + console.log('[WS-SERVER] Session extracted:', !!session, 'token:', !!session?.githubToken, 'user:', session?.githubUser?.login); + const auth = checkAuth(session); + console.log('[WS-SERVER] Auth check result:', auth.authenticated, auth.error || 'ok'); + if (!auth.authenticated) { + logSecurity('warn', 'ws_unauthorized', { + ip: req.socket.remoteAddress, + reason: auth.error, + }); + ws.close(4001, auth.error ?? 'Unauthorized'); + return; + } + + // Validate token is still valid with GitHub (catches revoked tokens) + // Skip for tokens authenticated within the last 30 seconds (just validated by poll endpoint) + const authAge = session.githubAuthTime ? Date.now() - session.githubAuthTime : Infinity; + if (authAge > 30_000) { + const validation = await validateGitHubToken(session.githubToken); + if (!validation.valid && validation.reason === 'invalid_token') { + logSecurity('warn', 'ws_token_revoked', { user: session.githubUser?.login }); + await clearAuth(session); + ws.close(4001, 'Token revoked'); + return; + } + // Transient API errors are not treated as revocation — allow connection + } + + const githubToken: string = session.githubToken; + const userLogin: string = session.githubUser?.login || 'unknown'; + const reqUrl = new URL(req.url || '/', `http://${req.headers.host}`); + const rawTabId = reqUrl.searchParams.get('tabId') || 'default'; + const tabId = isValidTabId(rawTabId) ? rawTabId : 'default'; + const poolKey = `${userLogin}:${tabId}`; + console.log('[WS-SERVER] Authenticated user:', userLogin, 'tab:', tabId, 'checking pool...'); + let entry = sessionPool.get(poolKey); + + if (entry) { + console.log('[WS-SERVER] Existing pool entry found for', poolKey); + // Reattach to existing pool entry + if (entry.ws && entry.ws !== ws && entry.ws.readyState === WebSocket.OPEN) { + entry.ws.close(4002, 'Replaced by new connection'); + } + if (entry.ttlTimer) { + clearTimeout(entry.ttlTimer); + entry.ttlTimer = null; + } + entry.ws = ws; + + // Replay buffered messages + const buffer = entry.messageBuffer.splice(0); + for (const msg of buffer) { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(msg)); + } + } + + console.log('[WS-SERVER] Sending session_reconnected to', poolKey, 'hasSession:', !!entry.session); + poolSend(entry, { + type: 'session_reconnected', + user: userLogin, + hasSession: !!entry.session, + isProcessing: entry.isProcessing, + }); + } else { + // Create new pool entry — enforce per-user session cap + if (countUserSessions(userLogin) >= config.maxSessionsPerUser) { + console.log('[WS-SERVER] Session cap reached for', userLogin, '— evicting oldest'); + await evictOldestUserSession(userLogin); + } + console.log('[WS-SERVER] Creating new pool entry for', poolKey); + const client = createCopilotClient(githubToken, config.copilotConfigDir); + entry = createPoolEntry(client, ws); + sessionPool.set(poolKey, entry); + + console.log('[WS-SERVER] Sending connected to', poolKey); + poolSend(entry, { + type: 'connected', + user: userLogin, + }); + } + + // Capture entry reference for this connection's handlers + const connectionEntry = entry; + + ws.on('close', (code: number, reason: Buffer) => { + console.log('[WS-SERVER] Client disconnected:', poolKey, 'code:', code, 'reason:', reason?.toString()); + if (connectionEntry.ws === ws) { + connectionEntry.ws = null; + connectionEntry.ttlTimer = setTimeout(async () => { + await destroyPoolEntry(connectionEntry); + sessionPool.delete(poolKey); + }, config.sessionPoolTtl); + } + }); + + ws.on('message', async (raw) => { + try { + const msg = JSON.parse(raw.toString()); + console.log('[WS-SERVER] Message from', userLogin, ':', msg.type); + + if (!msg.type || !VALID_MESSAGE_TYPES.has(msg.type)) { + poolSend(connectionEntry, { type: 'error', message: 'Unknown message type' }); + return; + } + + switch (msg.type) { + case 'new_session': { + if (connectionEntry.session) { + try { await connectionEntry.session.disconnect(); } catch { /* ignore */ } + connectionEntry.session = null; + } + connectionEntry.userInputResolve = null; + connectionEntry.permissionResolve = null; + + try { + const customInstructions = typeof msg.customInstructions === 'string' + ? msg.customInstructions.slice(0, 2000) + : undefined; + + const excludedTools = Array.isArray(msg.excludedTools) + ? msg.excludedTools.filter((t: unknown) => typeof t === 'string') + : undefined; + + const infiniteSessions = msg.infiniteSessions && typeof msg.infiniteSessions === 'object' + ? { + enabled: msg.infiniteSessions.enabled !== false, + ...(typeof msg.infiniteSessions.backgroundCompactionThreshold === 'number' && { + backgroundCompactionThreshold: Math.max(0, Math.min(1, msg.infiniteSessions.backgroundCompactionThreshold)), + }), + ...(typeof msg.infiniteSessions.bufferExhaustionThreshold === 'number' && { + bufferExhaustionThreshold: Math.max(0, Math.min(1, msg.infiniteSessions.bufferExhaustionThreshold)), + }), + } + : undefined; + + const permissionMode = msg.mode === 'autopilot' ? 'approve_all' as const : 'prompt' as const; + + const customTools = Array.isArray(msg.customTools) ? msg.customTools.slice(0, 10) : undefined; + + const mcpServers = Array.isArray(msg.mcpServers) + ? msg.mcpServers + .filter((s: unknown) => { + if (!s || typeof s !== 'object') return false; + const obj = s as Record; + return ( + typeof obj.name === 'string' && + typeof obj.url === 'string' && + (obj.type === 'http' || obj.type === 'sse') && + typeof obj.headers === 'object' && obj.headers !== null && + Array.isArray(obj.tools) + ); + }) + .slice(0, 10) + .map((s: unknown) => { + const obj = s as Record; + return { + name: obj.name as string, + url: obj.url as string, + type: obj.type as 'http' | 'sse', + headers: obj.headers as Record, + tools: (obj.tools as unknown[]).filter((t): t is string => typeof t === 'string'), + }; + }) + : undefined; + + const disabledSkills = Array.isArray(msg.disabledSkills) + ? msg.disabledSkills.filter((s: unknown) => typeof s === 'string') + : undefined; + + const customAgents = Array.isArray(msg.customAgents) + ? msg.customAgents + .filter((a: unknown) => { + if (!a || typeof a !== 'object') return false; + const obj = a as Record; + return typeof obj.name === 'string' && typeof obj.prompt === 'string'; + }) + .slice(0, 10) + .map((a: unknown) => { + const obj = a as Record; + return { + name: obj.name as string, + displayName: typeof obj.displayName === 'string' ? obj.displayName : undefined, + description: typeof obj.description === 'string' ? obj.description : undefined, + tools: Array.isArray(obj.tools) + ? (obj.tools as unknown[]).filter((t): t is string => typeof t === 'string') + : undefined, + prompt: obj.prompt as string, + }; + }) + : undefined; + + const skillDirectories = await getSkillDirectories(); + + connectionEntry.session = await createCopilotSession(connectionEntry.client, githubToken, { + model: msg.model, + reasoningEffort: msg.reasoningEffort, + customInstructions, + excludedTools, + customTools, + infiniteSessions, + onUserInputRequest: makeUserInputHandler(connectionEntry), + permissionMode, + onPermissionRequest: makePermissionHandler(connectionEntry), + mcpServers, + configDir: config.copilotConfigDir, + skillDirectories, + disabledSkills, + customAgents, + }); + + wireSessionEvents(connectionEntry.session, connectionEntry, connectionEntry.session?.sessionId); + + // Set initial mode on the SDK session + if (msg.mode && VALID_MODES.has(msg.mode)) { + try { + await connectionEntry.session.rpc.mode.set({ mode: msg.mode }); + } catch (modeErr: any) { + console.warn('Initial mode set failed:', modeErr.message); + } + } + + poolSend(connectionEntry, { + type: 'session_created', + model: msg.model, + sessionId: connectionEntry.session?.sessionId, + }); + } catch (err: any) { + console.error('Session creation error:', err.message); + poolSend(connectionEntry, { + type: 'error', + message: `Failed to create session: ${err.message}`, + }); + } + break; + } + + case 'message': { + const content = typeof msg.content === 'string' ? msg.content : ''; + if (!content.trim() || content.length > MAX_MESSAGE_LENGTH) { + poolSend(connectionEntry, { type: 'error', message: `Message must be 1-${MAX_MESSAGE_LENGTH} characters` }); + return; + } + + if (!connectionEntry.session) { + poolSend(connectionEntry, { type: 'error', message: 'No active session. Send new_session first.' }); + return; + } + + const attachments = Array.isArray(msg.attachments) + ? msg.attachments + .filter((a: unknown) => { + const att = a as Record; + return typeof att.path === 'string' && typeof att.name === 'string'; + }) + .map((a: unknown) => { + const att = a as Record; + return { + type: 'file' as const, + path: att.path as string, + displayName: att.name as string, + }; + }) + : undefined; + + connectionEntry.isProcessing = true; + const sendMode = msg.mode === 'immediate' || msg.mode === 'enqueue' ? msg.mode : undefined; + await connectionEntry.session.send({ + prompt: content, + ...(attachments?.length ? { attachments } : {}), + ...(sendMode ? { mode: sendMode } : {}), + }); + break; + } + + case 'list_models': { + const models = await getAvailableModels(connectionEntry.client); + const modelArray = Array.isArray(models) ? models : []; + poolSend(connectionEntry, { type: 'models', models: modelArray }); + break; + } + + case 'set_mode': { + const mode = msg.mode; + if (!mode || !VALID_MODES.has(mode)) { + poolSend(connectionEntry, { type: 'error', message: 'Invalid mode. Use: interactive, plan, or autopilot' }); + return; + } + + if (!connectionEntry.session) { + poolSend(connectionEntry, { type: 'error', message: 'No active session. Send new_session first.' }); + return; + } + + try { + await connectionEntry.session.rpc.mode.set({ mode }); + + // Update permission handler: autopilot auto-approves, others prompt the user + if (mode === 'autopilot') { + connectionEntry.session.registerPermissionHandler(approveAll); + } else { + connectionEntry.session.registerPermissionHandler(makePermissionHandler(connectionEntry)); + } + + // Note: mode_changed is sent by the SDK event handler (session.mode_changed) + } catch (err: any) { + console.error('Mode switch error:', err.message); + poolSend(connectionEntry, { type: 'error', message: `Failed to switch mode: ${err.message}` }); + } + break; + } + + case 'abort': { + if (!connectionEntry.session) { + poolSend(connectionEntry, { type: 'error', message: 'No active session.' }); + return; + } + try { + await connectionEntry.session.abort(); + connectionEntry.isProcessing = false; + + // Resolve any dangling input/permission promises to prevent leaks + if (connectionEntry.userInputResolve) { + const resolve = connectionEntry.userInputResolve; + connectionEntry.userInputResolve = null; + resolve({ answer: '', wasFreeform: false }); + } + if (connectionEntry.permissionResolve) { + const resolve = connectionEntry.permissionResolve; + connectionEntry.permissionResolve = null; + resolve('deny'); + } + + poolSend(connectionEntry, { type: 'aborted' }); + } catch (err: any) { + console.error('Abort error:', err.message); + poolSend(connectionEntry, { type: 'error', message: `Failed to abort: ${err.message}` }); + } + break; + } + + case 'set_model': { + const newModel = typeof msg.model === 'string' ? msg.model.trim() : ''; + if (!newModel) { + poolSend(connectionEntry, { type: 'error', message: 'Model ID is required' }); + return; + } + if (!connectionEntry.session) { + poolSend(connectionEntry, { type: 'error', message: 'No active session. Send new_session first.' }); + return; + } + try { + await connectionEntry.session.setModel(newModel); + // Note: model_changed is sent by the SDK event handler (session.model_change) + } catch (err: any) { + console.error('Model change error:', err.message); + poolSend(connectionEntry, { type: 'error', message: `Failed to change model: ${err.message}` }); + } + break; + } + + case 'set_reasoning': { + const effort = msg.effort as string; + if (!effort || !VALID_REASONING.has(effort)) { + poolSend(connectionEntry, { type: 'error', message: 'Invalid reasoning effort. Use: low, medium, high, or xhigh' }); + return; + } + poolSend(connectionEntry, { type: 'reasoning_changed', effort }); + break; + } + + case 'user_input_response': { + if (!connectionEntry.userInputResolve) { + poolSend(connectionEntry, { type: 'error', message: 'No pending input request' }); + return; + } + const answer = typeof msg.answer === 'string' ? msg.answer : ''; + if (!answer.trim()) { + poolSend(connectionEntry, { type: 'error', message: 'Answer is required' }); + return; + } + const resolve = connectionEntry.userInputResolve; + connectionEntry.userInputResolve = null; + resolve({ answer, wasFreeform: msg.wasFreeform ?? true }); + break; + } + + case 'permission_response': { + if (!connectionEntry.permissionResolve) { + poolSend(connectionEntry, { type: 'error', message: 'No pending permission request' }); + return; + } + const decision = msg.decision; + if (!['allow', 'deny', 'always_allow', 'always_deny'].includes(decision)) { + poolSend(connectionEntry, { type: 'error', message: 'Invalid decision' }); + return; + } + // Key preferences by kind so "always allow shell" covers all shell commands + const prefKey = msg.kind ?? msg.toolName; + if (decision === 'always_allow') { + connectionEntry.permissionPreferences.set(prefKey, 'allow'); + } + if (decision === 'always_deny') { + connectionEntry.permissionPreferences.set(prefKey, 'deny'); + } + const permResolve = connectionEntry.permissionResolve; + connectionEntry.permissionResolve = null; + permResolve(decision.replace('always_', '')); + break; + } + + case 'list_tools': { + try { + const model = typeof msg.model === 'string' ? msg.model : undefined; + const result = await connectionEntry.client.rpc.tools.list({ model }); + poolSend(connectionEntry, { type: 'tools', tools: result?.tools || [] }); + } catch (err: any) { + console.error('List tools error:', err.message); + poolSend(connectionEntry, { type: 'tools', tools: [] }); + } + break; + } + + case 'list_agents': { + if (!connectionEntry.session) { + poolSend(connectionEntry, { type: 'error', message: 'No active session. Send new_session first.' }); + return; + } + try { + const agents = await connectionEntry.session.rpc.agent.list(); + let current = null; + try { + current = await connectionEntry.session.rpc.agent.getCurrent(); + } catch { /* no current agent */ } + poolSend(connectionEntry, { type: 'agents', agents: agents?.agents || [], current: current?.agent || null }); + } catch (err: any) { + console.error('List agents error:', err.message); + poolSend(connectionEntry, { type: 'agents', agents: [], current: null }); + } + break; + } + + case 'select_agent': { + if (!connectionEntry.session) { + poolSend(connectionEntry, { type: 'error', message: 'No active session. Send new_session first.' }); + return; + } + const agentName = typeof msg.name === 'string' ? msg.name.trim() : ''; + if (!agentName) { + poolSend(connectionEntry, { type: 'error', message: 'Agent name is required' }); + return; + } + try { + await connectionEntry.session.rpc.agent.select({ name: agentName }); + poolSend(connectionEntry, { type: 'agent_changed', agent: agentName }); + } catch (err: any) { + console.error('Select agent error:', err.message); + poolSend(connectionEntry, { type: 'error', message: `Failed to select agent: ${err.message}` }); + } + break; + } + + case 'deselect_agent': { + if (!connectionEntry.session) { + poolSend(connectionEntry, { type: 'error', message: 'No active session. Send new_session first.' }); + return; + } + try { + await connectionEntry.session.rpc.agent.deselect(); + poolSend(connectionEntry, { type: 'agent_changed', agent: null }); + } catch (err: any) { + console.error('Deselect agent error:', err.message); + poolSend(connectionEntry, { type: 'error', message: `Failed to deselect agent: ${err.message}` }); + } + break; + } + + case 'get_quota': { + try { + const result = await connectionEntry.client.rpc.account.getQuota(); + poolSend(connectionEntry, { + type: 'quota', + quotaSnapshots: normalizeQuotaSnapshots(result.quotaSnapshots), + }); + } catch (err: any) { + console.error('Get quota error:', err.message); + poolSend(connectionEntry, { type: 'error', message: `Failed to get quota: ${err.message}` }); + } + break; + } + + case 'compact': { + if (!connectionEntry.session) { + poolSend(connectionEntry, { type: 'error', message: 'No active session. Send new_session first.' }); + return; + } + try { + const result = await connectionEntry.session.rpc.compaction.compact(); + poolSend(connectionEntry, { type: 'compaction_result', ...result }); + } catch (err: any) { + console.error('Compaction error:', err.message); + poolSend(connectionEntry, { type: 'error', message: `Failed to compact: ${err.message}` }); + } + break; + } + + case 'list_sessions': { + try { + // start() is idempotent — no-op if already connected + console.log('[DEBUG list_sessions] Starting client…'); + await connectionEntry.client.start(); + console.log('[DEBUG list_sessions] client.listSessions()…'); + const sessions = await connectionEntry.client.listSessions(); + const rawList = Array.isArray(sessions) ? sessions : []; + console.log('[DEBUG list_sessions] SDK returned', rawList.length, 'sessions'); + + // Enrich each session with filesystem metadata in parallel + const sdkSessions = await Promise.all( + rawList.map(async (s: any) => { + const id = s.sessionId ?? s.id; + const enriched = await enrichSessionMetadata(id, s.context, s.isRemote); + return { + id, + title: s.summary ?? s.title, + updatedAt: s.modifiedTime ?? s.updatedAt, + model: s.model, + source: 'sdk' as const, + ...enriched, + }; + }), + ); + + // Merge with filesystem sessions the SDK may not know about + // (e.g. bundled sessions copied into a fresh container) + console.log('[DEBUG list_sessions] Scanning filesystem…'); + const fsSessions = await listSessionsFromFilesystem(); + console.log('[DEBUG list_sessions] Filesystem found', fsSessions.length, 'sessions'); + const sdkIds = new Set(sdkSessions.map((s) => s.id)); + const extraSessions = fsSessions.filter((s) => !sdkIds.has(s.id)).map((s) => ({ ...s, source: 'filesystem' as const })); + const list = [...sdkSessions, ...extraSessions]; + console.log('[DEBUG list_sessions] Sending', list.length, 'total (SDK:', sdkSessions.length, '+ FS extra:', extraSessions.length, ')'); + + poolSend(connectionEntry, { type: 'sessions', sessions: list }); + } catch (err: any) { + console.error('[DEBUG list_sessions] SDK error:', err.message); + // SDK failed — fall back to filesystem-only listing + try { + const fsSessions = await listSessionsFromFilesystem(); + console.log('[DEBUG list_sessions] Fallback: filesystem found', fsSessions.length, 'sessions'); + poolSend(connectionEntry, { type: 'sessions', sessions: fsSessions.map((s) => ({ ...s, source: 'filesystem' as const })) }); + } catch (fsErr: any) { + console.error('[DEBUG list_sessions] Filesystem fallback also failed:', fsErr.message); + poolSend(connectionEntry, { type: 'sessions', sessions: [] }); + } + } + break; + } + + case 'delete_session': { + const deleteId = typeof msg.sessionId === 'string' ? msg.sessionId.trim() : ''; + if (!deleteId) { + poolSend(connectionEntry, { type: 'error', message: 'Session ID is required' }); + return; + } + + // Prevent deleting the active session + if (connectionEntry.session?.sessionId === deleteId) { + poolSend(connectionEntry, { type: 'error', message: 'Cannot delete the active session' }); + return; + } + + try { + await connectionEntry.client.deleteSession(deleteId); + poolSend(connectionEntry, { type: 'session_deleted', sessionId: deleteId }); + } catch (err: any) { + // SDK doesn't know this session — try filesystem deletion + // (e.g. bundled or filesystem-only sessions) + try { + const deleted = await deleteSessionFromFilesystem(deleteId); + if (deleted) { + poolSend(connectionEntry, { type: 'session_deleted', sessionId: deleteId }); + } else { + poolSend(connectionEntry, { type: 'error', message: `Session not found: ${deleteId}` }); + } + } catch (fsErr: any) { + console.error('Delete session error:', err.message, '| Filesystem fallback:', fsErr.message); + poolSend(connectionEntry, { type: 'error', message: `Failed to delete session: ${err.message}` }); + } + } + break; + } + + case 'get_session_detail': { + const detailId = typeof msg.sessionId === 'string' ? msg.sessionId.trim() : ''; + console.log('[DEBUG get_session_detail] Requested:', JSON.stringify(detailId)); + if (!detailId) { + console.log('[DEBUG get_session_detail] Empty ID, sending error'); + poolSend(connectionEntry, { type: 'error', message: 'Session ID is required' }); + return; + } + + try { + console.log('[DEBUG get_session_detail] Calling getSessionDetail…'); + const detail = await getSessionDetail(detailId); + console.log('[DEBUG get_session_detail] Result:', detail ? `found (id=${detail.id})` : 'null'); + if (!detail) { + poolSend(connectionEntry, { type: 'error', message: 'Session not found' }); + return; + } + poolSend(connectionEntry, { type: 'session_detail', detail }); + console.log('[DEBUG get_session_detail] Sent session_detail response'); + } catch (err: any) { + console.error('[DEBUG get_session_detail] Error:', err.message, err.stack); + poolSend(connectionEntry, { type: 'error', message: `Failed to get session detail: ${err.message}` }); + } + break; + } + + case 'resume_session': { + const sessionId = typeof msg.sessionId === 'string' ? msg.sessionId.trim() : ''; + if (!sessionId) { + poolSend(connectionEntry, { type: 'error', message: 'Session ID is required' }); + return; + } + + if (connectionEntry.session) { + try { await connectionEntry.session.disconnect(); } catch { /* ignore */ } + connectionEntry.session = null; + } + connectionEntry.userInputResolve = null; + connectionEntry.permissionResolve = null; + + try { + // start() is idempotent — ensures the SDK has indexed all local sessions + await connectionEntry.client.start(); + + const resolvedConfigDir = config.copilotConfigDir || join((await import('node:os')).homedir(), '.copilot'); + + // Read filesystem plan for injection into resumed session context + const detail = await getSessionDetail(sessionId); + + let resumed = false; + + // Try native SDK resume first + try { + connectionEntry.session = await connectionEntry.client.resumeSession(sessionId, { + onPermissionRequest: (await import('@github/copilot-sdk')).approveAll, + streaming: true, + onUserInputRequest: makeUserInputHandler(connectionEntry), + configDir: resolvedConfigDir, + ...(detail?.plan && { + systemMessage: { + mode: 'append' as const, + content: `## Current Plan\n${detail.plan}`, + }, + }), + }); + resumed = true; + } catch (resumeErr: any) { + console.log(`[RESUME] SDK resumeSession failed for ${sessionId}: ${resumeErr.message}`); + } + + // Fallback: create a new session with context from the filesystem session + if (!resumed) { + console.log(`[RESUME] Attempting context-based fallback for ${sessionId}…`); + const context = await buildSessionContext(sessionId); + if (!context) { + throw new Error(`Session not found: ${sessionId}`); + } + + connectionEntry.session = await createCopilotSession(connectionEntry.client, githubToken, { + customInstructions: context, + onUserInputRequest: makeUserInputHandler(connectionEntry), + permissionMode: 'approve_all', + configDir: resolvedConfigDir, + }); + console.log(`[RESUME] Fallback session created for ${sessionId} with context injection`); + } + + wireSessionEvents(connectionEntry.session, connectionEntry, sessionId); + + // Read and send the restored session's mode to the client + try { + const modeResult = await connectionEntry.session.rpc.mode.get(); + if (modeResult?.mode && VALID_MODES.has(modeResult.mode)) { + poolSend(connectionEntry, { type: 'mode_changed', mode: modeResult.mode }); + // Restore correct permission handler for resumed mode + if (modeResult.mode === 'autopilot') { + connectionEntry.session.registerPermissionHandler(approveAll); + } else { + connectionEntry.session.registerPermissionHandler(makePermissionHandler(connectionEntry)); + } + } + } catch { + // Non-critical: mode will default to interactive on client + } + + // Restore the plan INTO the SDK so the agent's tools can access it + if (detail?.plan) { + try { + await connectionEntry.session.rpc.plan.update({ content: detail.plan }); + console.log(`[RESUME] Plan restored into SDK for session ${sessionId}`); + } catch (planErr: any) { + console.warn(`[RESUME] Failed to restore plan into SDK: ${planErr.message}`); + } + } + + // Read and send the restored session's plan to the client + try { + const planResult = await connectionEntry.session.rpc.plan.read(); + if (planResult?.exists) { + poolSend(connectionEntry, { type: 'plan', exists: true, content: planResult.content, path: planResult.path }); + } + } catch { + // Non-critical: plan panel will stay hidden + } + + poolSend(connectionEntry, { type: 'session_resumed', sessionId }); + } catch (err: any) { + console.error('Resume session error:', err.message); + console.error('Resume session stack:', err.stack); + poolSend(connectionEntry, { type: 'error', message: `Failed to resume session: ${err.message}` }); + } + break; + } + + case 'get_plan': { + if (!connectionEntry.session) { + poolSend(connectionEntry, { type: 'error', message: 'No active session. Send new_session first.' }); + return; + } + try { + const result = await connectionEntry.session.rpc.plan.read(); + poolSend(connectionEntry, { type: 'plan', exists: result?.exists ?? false, content: result?.content, path: result?.path }); + } catch (err: any) { + console.error('Get plan error:', err.message); + poolSend(connectionEntry, { type: 'plan', exists: false }); + } + break; + } + + case 'update_plan': { + if (!connectionEntry.session) { + poolSend(connectionEntry, { type: 'error', message: 'No active session. Send new_session first.' }); + return; + } + const planContent = typeof msg.content === 'string' ? msg.content : ''; + try { + await connectionEntry.session.rpc.plan.update({ content: planContent }); + poolSend(connectionEntry, { type: 'plan_updated' }); + } catch (err: any) { + console.error('Update plan error:', err.message); + poolSend(connectionEntry, { type: 'error', message: `Failed to update plan: ${err.message}` }); + } + break; + } + + case 'delete_plan': { + if (!connectionEntry.session) { + poolSend(connectionEntry, { type: 'error', message: 'No active session. Send new_session first.' }); + return; + } + try { + await connectionEntry.session.rpc.plan.delete(); + poolSend(connectionEntry, { type: 'plan_deleted' }); + } catch (err: any) { + console.error('Delete plan error:', err.message); + poolSend(connectionEntry, { type: 'error', message: `Failed to delete plan: ${err.message}` }); + } + break; + } + + case 'start_fleet': { + if (!connectionEntry.session) { + poolSend(connectionEntry, { type: 'error', message: 'No active session. Send new_session first.' }); + return; + } + const prompt = typeof msg.prompt === 'string' ? msg.prompt.trim() : ''; + if (!prompt || prompt.length > MAX_MESSAGE_LENGTH) { + poolSend(connectionEntry, { type: 'error', message: `Fleet prompt must be 1-${MAX_MESSAGE_LENGTH} characters` }); + return; + } + try { + connectionEntry.isProcessing = true; + const result = await connectionEntry.session.rpc.fleet.start({ prompt }); + poolSend(connectionEntry, { type: 'fleet_started', started: result.started }); + } catch (err: any) { + console.error('Fleet start error:', err.message); + connectionEntry.isProcessing = false; + poolSend(connectionEntry, { type: 'error', message: `Failed to start fleet: ${err.message}` }); + } + break; + } + } + } catch (err: any) { + console.error('WS message error:', err.message); + connectionEntry.isProcessing = false; + const errMsg = err?.message || 'An internal error occurred'; + const isTimeout = typeof errMsg === 'string' && errMsg.toLowerCase().includes('timeout'); + poolSend(connectionEntry, { + type: 'error', + message: isTimeout + ? `Request timed out. The model took too long to respond — try again or start a new session. (${errMsg})` + : errMsg, + }); + } + }); + + ws.on('error', (err) => { + console.error('WS error:', err.message); + }); + }); +} diff --git a/src/lib/server/ws/session-pool.test.ts b/src/lib/server/ws/session-pool.test.ts new file mode 100644 index 0000000..3ecdb9b --- /dev/null +++ b/src/lib/server/ws/session-pool.test.ts @@ -0,0 +1,254 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { WebSocket } from 'ws'; + +import { + cleanupAllSessions, + cleanupUserSessions, + countUserSessions, + createPoolEntry, + destroyPoolEntry, + evictOldestUserSession, + isValidTabId, + poolSend, + sessionPool, +} from './session-pool.js'; + +interface ClientMock { + stop: ReturnType; +} + +interface SessionMock { + disconnect: ReturnType; +} + +interface WsMock { + close: ReturnType; + readyState: number; + send: ReturnType; +} + +function createClientMock(): ClientMock { + return { + stop: vi.fn(async () => undefined), + }; +} + +function createSessionMock(): SessionMock { + return { + disconnect: vi.fn(async () => undefined), + }; +} + +function createWsMock(readyState: number = WebSocket.OPEN): WsMock { + return { + close: vi.fn(), + readyState, + send: vi.fn(), + }; +} + +beforeEach(() => { + sessionPool.clear(); + vi.useRealTimers(); +}); + +afterEach(async () => { + await cleanupAllSessions(); + sessionPool.clear(); + vi.restoreAllMocks(); + vi.useRealTimers(); +}); + +describe('createPoolEntry', () => { + it('creates a pool entry with the expected initial lifecycle state', () => { + const client = createClientMock(); + const ws = createWsMock(); + + const entry = createPoolEntry(client as never, ws as never); + + expect(entry).toMatchObject({ + client, + session: null, + ws, + messageBuffer: [], + ttlTimer: null, + userInputResolve: null, + permissionResolve: null, + isProcessing: false, + }); + expect(entry.permissionPreferences.size).toBe(0); + }); +}); + +describe('poolSend', () => { + it('sends messages immediately when a websocket is open', () => { + const entry = createPoolEntry(createClientMock() as never, createWsMock() as never); + + poolSend(entry, { type: 'delta', content: 'hello' }); + + expect(entry.ws?.send).toHaveBeenCalledWith(JSON.stringify({ type: 'delta', content: 'hello' })); + expect(entry.messageBuffer).toEqual([]); + }); + + it('buffers messages when the websocket is absent and keeps only the newest 500', () => { + const entry = createPoolEntry(createClientMock() as never, createWsMock() as never); + entry.ws = null; + + for (let index = 0; index < 550; index += 1) { + poolSend(entry, { type: 'delta', index }); + } + + expect(entry.messageBuffer).toHaveLength(500); + expect(entry.messageBuffer[0]).toEqual({ type: 'delta', index: 50 }); + expect(entry.messageBuffer.at(-1)).toEqual({ type: 'delta', index: 549 }); + }); +}); + +describe('destroyPoolEntry', () => { + it('clears TTL timers and disposes the session, client, and transient state', async () => { + vi.useFakeTimers(); + + const client = createClientMock(); + const ws = createWsMock(); + const entry = createPoolEntry(client as never, ws as never); + const session = createSessionMock(); + let timerTriggered = false; + + entry.session = session; + entry.userInputResolve = vi.fn(); + entry.permissionResolve = vi.fn(); + entry.permissionPreferences.set('shell', 'allow'); + entry.ttlTimer = setTimeout(() => { + timerTriggered = true; + }, 1000); + + await destroyPoolEntry(entry); + await vi.advanceTimersByTimeAsync(1000); + + expect(timerTriggered).toBe(false); + expect(session.disconnect).toHaveBeenCalledTimes(1); + expect(client.stop).toHaveBeenCalledTimes(1); + expect(entry.session).toBeNull(); + expect(entry.ttlTimer).toBeNull(); + expect(entry.userInputResolve).toBeNull(); + expect(entry.permissionResolve).toBeNull(); + expect(entry.permissionPreferences.size).toBe(0); + }); + + it('swallows disconnect and stop errors during teardown', async () => { + const client = createClientMock(); + const session = createSessionMock(); + client.stop.mockRejectedValue(new Error('stop failed')); + session.disconnect.mockRejectedValue(new Error('disconnect failed')); + + const entry = createPoolEntry(client as never, createWsMock() as never); + entry.session = session; + + await expect(destroyPoolEntry(entry)).resolves.toBeUndefined(); + }); +}); + +describe('session pool cleanup', () => { + it('cleanupAllSessions destroys and removes every pooled session', async () => { + const aliceEntry = createPoolEntry(createClientMock() as never, createWsMock() as never); + const bobEntry = createPoolEntry(createClientMock() as never, createWsMock() as never); + aliceEntry.session = createSessionMock(); + bobEntry.session = createSessionMock(); + + sessionPool.set('alice:tab-1', aliceEntry); + sessionPool.set('bob:tab-1', bobEntry); + + await cleanupAllSessions(); + + expect(sessionPool.size).toBe(0); + expect(aliceEntry.client.stop).toHaveBeenCalledTimes(1); + expect(bobEntry.client.stop).toHaveBeenCalledTimes(1); + }); + + it('cleanupUserSessions removes only entries for the targeted user', async () => { + const aliceEntry = createPoolEntry(createClientMock() as never, createWsMock() as never); + const bobEntry = createPoolEntry(createClientMock() as never, createWsMock() as never); + + sessionPool.set('alice:tab-1', aliceEntry); + sessionPool.set('alice:tab-2', createPoolEntry(createClientMock() as never, createWsMock() as never)); + sessionPool.set('bob:tab-1', bobEntry); + + await cleanupUserSessions('alice'); + + expect(sessionPool.has('alice:tab-1')).toBe(false); + expect(sessionPool.has('alice:tab-2')).toBe(false); + expect(sessionPool.get('bob:tab-1')).toBe(bobEntry); + }); + + it('ignores cleanup requests for users without active sessions', async () => { + const entry = createPoolEntry(createClientMock() as never, createWsMock() as never); + sessionPool.set('bob:tab-1', entry); + + await expect(cleanupUserSessions('alice')).resolves.toBeUndefined(); + expect(sessionPool.get('bob:tab-1')).toBe(entry); + }); + + it('counts pooled sessions per user', () => { + sessionPool.set('alice:tab-1', createPoolEntry(createClientMock() as never, createWsMock() as never)); + sessionPool.set('alice:tab-2', createPoolEntry(createClientMock() as never, createWsMock() as never)); + sessionPool.set('bob:tab-1', createPoolEntry(createClientMock() as never, createWsMock() as never)); + + expect(countUserSessions('alice')).toBe(2); + expect(countUserSessions('bob')).toBe(1); + expect(countUserSessions('charlie')).toBe(0); + }); +}); + +describe('eviction and map edge cases', () => { + it('evicts the oldest disconnected session before active websocket sessions', async () => { + const disconnected = createPoolEntry(createClientMock() as never, createWsMock(WebSocket.CLOSED) as never); + const active = createPoolEntry(createClientMock() as never, createWsMock(WebSocket.OPEN) as never); + + sessionPool.set('alice:old', disconnected); + sessionPool.set('alice:new', active); + + await evictOldestUserSession('alice'); + + expect(sessionPool.has('alice:old')).toBe(false); + expect(sessionPool.has('alice:new')).toBe(true); + expect(disconnected.client.stop).toHaveBeenCalledTimes(1); + expect(active.client.stop).not.toHaveBeenCalled(); + }); + + it('falls back to insertion order when all candidate sessions are active', async () => { + const first = createPoolEntry(createClientMock() as never, createWsMock(WebSocket.OPEN) as never); + const second = createPoolEntry(createClientMock() as never, createWsMock(WebSocket.OPEN) as never); + + sessionPool.set('alice:first', first); + sessionPool.set('alice:second', second); + + await evictOldestUserSession('alice'); + + expect(sessionPool.has('alice:first')).toBe(false); + expect(sessionPool.has('alice:second')).toBe(true); + }); + + it('allows replacing an existing key without increasing the per-user count', () => { + const original = createPoolEntry(createClientMock() as never, createWsMock() as never); + const replacement = createPoolEntry(createClientMock() as never, createWsMock() as never); + + sessionPool.set('alice:tab-1', original); + sessionPool.set('alice:tab-1', replacement); + + expect(countUserSessions('alice')).toBe(1); + expect(sessionPool.get('alice:tab-1')).toBe(replacement); + }); +}); + +describe('isValidTabId', () => { + it('accepts safe tab IDs and rejects invalid characters or oversized values', () => { + expect(isValidTabId('default')).toBe(true); + expect(isValidTabId('tab_123-ABC')).toBe(true); + expect(isValidTabId('x'.repeat(64))).toBe(true); + + expect(isValidTabId('x'.repeat(65))).toBe(false); + expect(isValidTabId('tab id')).toBe(false); + expect(isValidTabId('tab/123')).toBe(false); + expect(isValidTabId('')).toBe(false); + }); +}); diff --git a/src/lib/server/ws/session-pool.ts b/src/lib/server/ws/session-pool.ts new file mode 100644 index 0000000..615387d --- /dev/null +++ b/src/lib/server/ws/session-pool.ts @@ -0,0 +1,122 @@ +import { WebSocket } from 'ws'; +import type { CopilotClient } from '@github/copilot-sdk'; +import { config } from '../config.js'; + +const MAX_BUFFER_SIZE = 500; +const TAB_ID_PATTERN = /^[a-z0-9_-]{1,64}$/i; + +export interface PoolEntry { + client: CopilotClient; + session: any; + ws: WebSocket | null; + messageBuffer: Record[]; + ttlTimer: NodeJS.Timeout | null; + userInputResolve: ((response: { answer: string; wasFreeform: boolean }) => void) | null; + permissionResolve: ((decision: string) => void) | null; + permissionPreferences: Map; + isProcessing: boolean; +} + +export const sessionPool = new Map(); + +export function createPoolEntry(client: CopilotClient, ws: WebSocket): PoolEntry { + return { + client, + session: null, + ws, + messageBuffer: [], + ttlTimer: null, + userInputResolve: null, + permissionResolve: null, + permissionPreferences: new Map(), + isProcessing: false, + }; +} + +export async function destroyPoolEntry(entry: PoolEntry): Promise { + if (entry.ttlTimer) { + clearTimeout(entry.ttlTimer); + entry.ttlTimer = null; + } + if (entry.session) { + try { await entry.session.disconnect(); } catch { /* ignore */ } + entry.session = null; + } + entry.userInputResolve = null; + entry.permissionResolve = null; + entry.permissionPreferences.clear(); + try { await entry.client.stop(); } catch { /* ignore */ } +} + +export function poolSend(entry: PoolEntry, data: Record): void { + if (entry.ws && entry.ws.readyState === WebSocket.OPEN) { + entry.ws.send(JSON.stringify(data)); + } else { + if (entry.messageBuffer.length >= MAX_BUFFER_SIZE) { + entry.messageBuffer.shift(); + } + entry.messageBuffer.push(data); + } +} + +export async function cleanupAllSessions(): Promise { + const promises: Promise[] = []; + for (const [key, entry] of sessionPool) { + promises.push(destroyPoolEntry(entry)); + sessionPool.delete(key); + } + await Promise.allSettled(promises); +} + +/** Validate that a tabId is a UUID-like string (max 36 chars, alphanumeric + hyphens) */ +export function isValidTabId(tabId: string): boolean { + return TAB_ID_PATTERN.test(tabId); +} + +/** Destroy all pool entries for a specific user (e.g., on logout or token revocation) */ +export async function cleanupUserSessions(username: string): Promise { + const prefix = `${username}:`; + const promises: Promise[] = []; + for (const [key, entry] of sessionPool) { + if (key.startsWith(prefix)) { + promises.push(destroyPoolEntry(entry)); + sessionPool.delete(key); + } + } + await Promise.allSettled(promises); +} + +/** Count active pool entries for a specific user */ +export function countUserSessions(username: string): number { + const prefix = `${username}:`; + let count = 0; + for (const key of sessionPool.keys()) { + if (key.startsWith(prefix)) count++; + } + return count; +} + +/** Find and destroy the oldest pool entry for a user (the one with no active WS or earliest TTL) */ +export async function evictOldestUserSession(username: string): Promise { + const prefix = `${username}:`; + let oldestKey: string | null = null; + let oldestHasWs = true; + + for (const [key, entry] of sessionPool) { + if (!key.startsWith(prefix)) continue; + // Prefer evicting entries without an active WebSocket + const hasWs = entry.ws !== null && entry.ws.readyState === WebSocket.OPEN; + if (oldestKey === null || (!hasWs && oldestHasWs)) { + oldestKey = key; + oldestHasWs = hasWs; + } + } + + if (oldestKey) { + const entry = sessionPool.get(oldestKey); + if (entry) { + await destroyPoolEntry(entry); + sessionPool.delete(oldestKey); + } + } +} diff --git a/src/lib/stores/auth.svelte.ts b/src/lib/stores/auth.svelte.ts new file mode 100644 index 0000000..26d3e1b --- /dev/null +++ b/src/lib/stores/auth.svelte.ts @@ -0,0 +1,198 @@ +export interface DeviceCodeState { + user_code: string; + verification_uri: string; + expires_in: number; + interval: number; +} + +export type AuthStatus = 'idle' | 'polling' | 'authorized' | 'expired' | 'denied' | 'error'; + +export interface AuthUser { + login: string; + avatar_url: string; +} + +interface PollResponse { + status: string; + user?: AuthUser; + githubUser?: string; + error?: string; +} + +export type AuthStore = ReturnType; + +export function createAuthStore() { + let user = $state(null); + let authenticated = $state(false); + let deviceCode = $state(null); + let authStatus = $state('idle'); + let countdown = $state(0); + let errorMessage = $state(''); + + let countdownTimer: ReturnType | null = null; + let pollTimer: ReturnType | null = null; + let spinnerTimer: ReturnType | null = null; + + const spinChars = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧'] as const; + let spinIdx = $state(0); + const spinnerChar = $derived(spinChars[spinIdx]); + + const countdownFormatted = $derived.by(() => { + const m = Math.floor(countdown / 60); + const s = countdown % 60; + return `${m}:${s.toString().padStart(2, '0')}`; + }); + + function clearTimers(): void { + if (countdownTimer) { + clearInterval(countdownTimer); + countdownTimer = null; + } + if (pollTimer) { + clearTimeout(pollTimer); + pollTimer = null; + } + if (spinnerTimer) { + clearInterval(spinnerTimer); + spinnerTimer = null; + } + } + + async function checkStatus(): Promise { + try { + const res = await fetch('/auth/status'); + const data = (await res.json()) as { authenticated: boolean; user: AuthUser | null }; + authenticated = data.authenticated; + user = data.user ?? null; + } catch { + authenticated = false; + user = null; + } + } + + async function startDeviceFlow(): Promise { + authStatus = 'polling'; + errorMessage = ''; + + spinnerTimer = setInterval(() => { + spinIdx = (spinIdx + 1) % spinChars.length; + }, 100); + + try { + const res = await fetch('/auth/device/start', { method: 'POST' }); + const data = (await res.json()) as DeviceCodeState & { error?: string }; + if (data.error) throw new Error(data.error); + + deviceCode = { + user_code: data.user_code, + verification_uri: data.verification_uri, + expires_in: data.expires_in, + interval: data.interval, + }; + + // Countdown timer + const expiresAt = Date.now() + data.expires_in * 1000; + countdown = data.expires_in; + countdownTimer = setInterval(() => { + const remaining = Math.max(0, Math.round((expiresAt - Date.now()) / 1000)); + countdown = remaining; + if (remaining === 0 && countdownTimer) { + clearInterval(countdownTimer); + countdownTimer = null; + } + }, 1000); + + // Poll for authorization with exponential backoff on slow_down + let interval = (data.interval || 5) * 1000; + const poll = async (): Promise => { + try { + const result = await fetch('/auth/device/poll', { method: 'POST' }); + const pollData = (await result.json()) as PollResponse; + + if (pollData.status === 'authorized') { + console.log(`[AUTH-STORE] poll returned authorized, user=${pollData.user?.login ?? pollData.githubUser ?? 'unknown'}`); + clearTimers(); + authStatus = 'authorized'; + user = pollData.user ?? null; + authenticated = true; + return; + } + + if (pollData.status === 'expired') { + clearTimers(); + authStatus = 'expired'; + return; + } + + if (pollData.status === 'access_denied') { + clearTimers(); + authStatus = 'denied'; + return; + } + + if (pollData.status === 'slow_down') { + interval += 5000; + } + + pollTimer = setTimeout(poll, interval); + } catch { + errorMessage = 'Error checking status — retrying...'; + pollTimer = setTimeout(poll, interval * 2); + } + }; + + pollTimer = setTimeout(poll, interval); + } catch (err) { + clearTimers(); + authStatus = 'error'; + errorMessage = err instanceof Error ? err.message : 'Failed to start device flow.'; + } + } + + async function logout(): Promise { + try { + await fetch('/auth/logout', { method: 'POST' }); + } finally { + authenticated = false; + user = null; + authStatus = 'idle'; + deviceCode = null; + clearTimers(); + } + } + + function destroy(): void { + clearTimers(); + } + + return { + get user() { + return user; + }, + get authenticated() { + return authenticated; + }, + get deviceCode() { + return deviceCode; + }, + get authStatus() { + return authStatus; + }, + get countdown() { + return countdown; + }, + get countdownFormatted() { + return countdownFormatted; + }, + get errorMessage() { + return errorMessage; + }, + get spinnerChar() { + return spinnerChar; + }, + checkStatus, + startDeviceFlow, + logout, + destroy, + }; +} diff --git a/src/lib/stores/auth.test.ts b/src/lib/stores/auth.test.ts new file mode 100644 index 0000000..54fab09 --- /dev/null +++ b/src/lib/stores/auth.test.ts @@ -0,0 +1,241 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createAuthStore } from '$lib/stores/auth.svelte.js'; + +function jsonResponse(data: unknown, ok = true): Response { + return { + ok, + json: vi.fn().mockResolvedValue(data), + } as unknown as Response; +} + +describe('createAuthStore', () => { + const fetchMock = vi.fn(); + + beforeEach(() => { + fetchMock.mockReset(); + vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('starts with logged-out defaults', () => { + const store = createAuthStore(); + + expect(store.user).toBeNull(); + expect(store.authenticated).toBe(false); + expect(store.deviceCode).toBeNull(); + expect(store.authStatus).toBe('idle'); + expect(store.countdown).toBe(0); + expect(store.countdownFormatted).toBe('0:00'); + expect(store.errorMessage).toBe(''); + expect(store.spinnerChar).toBe('⠋'); + }); + + it('hydrates auth status from the server and falls back to logged out on fetch failure', async () => { + const store = createAuthStore(); + + fetchMock.mockResolvedValueOnce( + jsonResponse({ + authenticated: true, + user: { login: 'octocat', avatar_url: 'https://github.com/octocat.png' }, + }), + ); + + await store.checkStatus(); + + expect(fetchMock).toHaveBeenCalledWith('/auth/status'); + expect(store.authenticated).toBe(true); + expect(store.user).toEqual({ login: 'octocat', avatar_url: 'https://github.com/octocat.png' }); + + fetchMock.mockRejectedValueOnce(new Error('offline')); + + await store.checkStatus(); + + expect(store.authenticated).toBe(false); + expect(store.user).toBeNull(); + }); + + it('runs the device flow through authorization, countdown, and spinner updates', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2025-01-01T00:00:00Z')); + + fetchMock + .mockResolvedValueOnce( + jsonResponse({ + user_code: 'ABCD-EFGH', + verification_uri: 'https://github.com/login/device', + expires_in: 30, + interval: 5, + }), + ) + .mockResolvedValueOnce( + jsonResponse({ + status: 'authorized', + user: { login: 'octocat', avatar_url: 'https://github.com/octocat.png' }, + }), + ); + + const store = createAuthStore(); + await store.startDeviceFlow(); + + expect(store.authStatus).toBe('polling'); + expect(store.deviceCode).toEqual({ + user_code: 'ABCD-EFGH', + verification_uri: 'https://github.com/login/device', + expires_in: 30, + interval: 5, + }); + expect(store.countdown).toBe(30); + expect(store.countdownFormatted).toBe('0:30'); + + const spinnerBefore = store.spinnerChar; + await vi.advanceTimersByTimeAsync(100); + expect(store.spinnerChar).not.toBe(spinnerBefore); + + await vi.advanceTimersByTimeAsync(900); + expect(store.countdown).toBe(29); + expect(store.countdownFormatted).toBe('0:29'); + + await vi.advanceTimersByTimeAsync(4000); + + expect(fetchMock).toHaveBeenNthCalledWith(2, '/auth/device/poll', { method: 'POST' }); + expect(store.authStatus).toBe('authorized'); + expect(store.authenticated).toBe(true); + expect(store.user).toEqual({ login: 'octocat', avatar_url: 'https://github.com/octocat.png' }); + }); + + it('backs off after slow_down responses before polling again', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2025-01-01T00:00:00Z')); + + fetchMock + .mockResolvedValueOnce( + jsonResponse({ + user_code: 'SLOW-DOWN', + verification_uri: 'https://github.com/login/device', + expires_in: 60, + interval: 5, + }), + ) + .mockResolvedValueOnce(jsonResponse({ status: 'slow_down' })) + .mockResolvedValueOnce( + jsonResponse({ + status: 'authorized', + user: { login: 'hubot', avatar_url: 'https://github.com/hubot.png' }, + }), + ); + + const store = createAuthStore(); + await store.startDeviceFlow(); + + await vi.advanceTimersByTimeAsync(5000); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(store.authStatus).toBe('polling'); + + await vi.advanceTimersByTimeAsync(9000); + expect(fetchMock).toHaveBeenCalledTimes(2); + + await vi.advanceTimersByTimeAsync(1000); + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(store.authStatus).toBe('authorized'); + expect(store.user).toEqual({ login: 'hubot', avatar_url: 'https://github.com/hubot.png' }); + }); + + it.each([ + ['expired', 'expired'], + ['access_denied', 'denied'], + ] as const)('ends polling when the server reports %s', async (pollStatus, expectedStatus) => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2025-01-01T00:00:00Z')); + + fetchMock + .mockResolvedValueOnce( + jsonResponse({ + user_code: 'WAITING', + verification_uri: 'https://github.com/login/device', + expires_in: 30, + interval: 5, + }), + ) + .mockResolvedValueOnce(jsonResponse({ status: pollStatus })); + + const store = createAuthStore(); + await store.startDeviceFlow(); + await vi.advanceTimersByTimeAsync(5000); + + expect(store.authStatus).toBe(expectedStatus); + expect(store.authenticated).toBe(false); + expect(store.user).toBeNull(); + }); + + it('surfaces start-device-flow errors', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ error: 'Missing GitHub client id' })); + + const store = createAuthStore(); + await store.startDeviceFlow(); + + expect(store.authStatus).toBe('error'); + expect(store.errorMessage).toBe('Missing GitHub client id'); + expect(store.deviceCode).toBeNull(); + }); + + it('resets auth state on logout even when the request fails', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2025-01-01T00:00:00Z')); + + fetchMock + .mockResolvedValueOnce( + jsonResponse({ + user_code: 'LOGOUT', + verification_uri: 'https://github.com/login/device', + expires_in: 30, + interval: 5, + }), + ) + .mockResolvedValueOnce( + jsonResponse({ + status: 'authorized', + user: { login: 'octocat', avatar_url: 'https://github.com/octocat.png' }, + }), + ); + + const store = createAuthStore(); + await store.startDeviceFlow(); + await vi.advanceTimersByTimeAsync(5000); + + fetchMock.mockRejectedValueOnce(new Error('logout failed')); + await expect(store.logout()).rejects.toThrow('logout failed'); + + expect(store.authenticated).toBe(false); + expect(store.user).toBeNull(); + expect(store.authStatus).toBe('idle'); + expect(store.deviceCode).toBeNull(); + }); + + it('clears timers when destroyed before the next poll fires', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2025-01-01T00:00:00Z')); + + fetchMock.mockResolvedValueOnce( + jsonResponse({ + user_code: 'DESTROY', + verification_uri: 'https://github.com/login/device', + expires_in: 20, + interval: 5, + }), + ); + + const store = createAuthStore(); + await store.startDeviceFlow(); + store.destroy(); + + await vi.advanceTimersByTimeAsync(6000); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(store.countdown).toBe(20); + expect(store.authStatus).toBe('polling'); + }); +}); diff --git a/src/lib/stores/chat.svelte.ts b/src/lib/stores/chat.svelte.ts new file mode 100644 index 0000000..acd30a5 --- /dev/null +++ b/src/lib/stores/chat.svelte.ts @@ -0,0 +1,760 @@ +import type { + ChatMessage, + ChatMessageRole, + ToolCallState, + ServerMessage, + SessionMode, + ReasoningEffort, + ModelInfo, + ToolInfo, + AgentInfo, + SessionSummary, + SessionDetail, + UserInputState, + PermissionRequestState, + ContextInfo, + PlanState, + QuotaSnapshots, + QuotaSnapshot, + SessionUsageTotals, +} from '$lib/types/index.js'; +import { pickPrimaryQuota } from '$lib/types/index.js'; +import type { WsStore } from '$lib/stores/ws.svelte.js'; +import { notify } from '$lib/utils/notifications.js'; + +export interface ChatStore { + // Message state + readonly messages: ChatMessage[]; + readonly isStreaming: boolean; + readonly isWaiting: boolean; + readonly isReasoningStreaming: boolean; + readonly currentStreamContent: string; + readonly currentReasoningContent: string; + readonly activeToolCalls: Map; + + // Session state + readonly mode: SessionMode; + readonly currentModel: string; + readonly reasoningEffort: ReasoningEffort | null; + readonly currentAgent: string | null; + readonly fleetActive: boolean; + readonly fleetAgents: Array<{ agentId: string; agentType: string; status: 'running' | 'completed' | 'failed'; error?: string }>; + readonly sessionTitle: string | null; + readonly pendingUserInput: UserInputState | null; + readonly pendingPermission: PermissionRequestState | null; + + // Data lists + readonly models: Map; + readonly tools: ToolInfo[]; + readonly agents: (AgentInfo | string)[]; + readonly sessions: SessionSummary[]; + readonly sessionDetail: SessionDetail | null; + + // Context & quota + readonly contextInfo: ContextInfo | null; + readonly quotaSnapshots: QuotaSnapshots | null; + readonly sessionTotals: SessionUsageTotals; + + // Plan + readonly plan: PlanState; + + // Derived + readonly isConnected: boolean; + readonly canSend: boolean; + readonly canInterrupt: boolean; + + // Methods + handleServerMessage(msg: ServerMessage): void; + clearMessages(): void; + addUserMessage(content: string): void; + clearPendingPermission(): void; + clearPendingUserInput(): void; +} + +let nextId = 0; +function genId(): string { + return `msg-${Date.now()}-${nextId++}`; +} + +export function createChatStore(wsStore: WsStore): ChatStore { + // ── Message state ─────────────────────────────────────────────────────── + let messages = $state([]); + let isStreaming = $state(false); + let isWaiting = $state(false); + let isReasoningStreaming = $state(false); + let currentStreamContent = $state(''); + let currentReasoningContent = $state(''); + let activeToolCalls = $state(new Map()); + + // ── Session state ─────────────────────────────────────────────────────── + let mode = $state('interactive'); + let currentModel = $state(''); + let reasoningEffort = $state(null); + let currentAgent = $state(null); + let fleetActive = $state(false); + let currentFleetMessageId = $state(null); + let fleetAgents = $state>([]); + let sessionTitle = $state(null); + let currentSessionId = $state(null); + let pendingUserInput = $state(null); + let pendingPermission = $state(null); + + // ── Data lists ────────────────────────────────────────────────────────── + let models = $state(new Map()); + let tools = $state([]); + let agents = $state<(AgentInfo | string)[]>([]); + let sessions = $state([]); + let sessionDetail = $state(null); + + // ── Context & quota ───────────────────────────────────────────────────── + let contextInfo = $state(null); + let quotaSnapshots = $state(null); + let baselineUsedRequests = $state(null); + + const emptyTotals: SessionUsageTotals = { + inputTokens: 0, outputTokens: 0, reasoningTokens: 0, + cacheReadTokens: 0, cacheWriteTokens: 0, + totalCost: 0, totalDurationMs: 0, apiCalls: 0, premiumRequests: 0, + }; + let sessionTotals = $state({ ...emptyTotals }); + + // ── Plan ──────────────────────────────────────────────────────────────── + let plan = $state({ exists: false, content: '' }); + + // ── Derived values ────────────────────────────────────────────────────── + const isConnected = $derived(wsStore.connectionState === 'connected'); + const canSend = $derived(isConnected && wsStore.sessionReady); + const canInterrupt = $derived(isWaiting || isReasoningStreaming || isStreaming); + + // ── Internal helpers ──────────────────────────────────────────────────── + + function addMessage(role: ChatMessageRole, content: string, extra?: Partial): ChatMessage { + const msg: ChatMessage = { + id: genId(), + role, + content, + timestamp: Date.now(), + ...extra, + }; + messages = [...messages, msg]; + return msg; + } + + function addInfoMessage(content: string): void { + addMessage('info', content); + } + + function syncFleetMessage(content?: string): void { + if (!currentFleetMessageId) return; + const nextFleetAgents = fleetAgents.map((agent) => ({ ...agent })); + messages = messages.map(message => + message.id === currentFleetMessageId + ? { + ...message, + content: content ?? message.content, + fleetAgents: nextFleetAgents, + } + : message, + ); + } + + function finalizeStream(): void { + // Commit the streamed content as a complete assistant message (skip empty/whitespace-only) + if (currentStreamContent.trim()) { + addMessage('assistant', currentStreamContent); + } + isStreaming = false; + isWaiting = false; + currentStreamContent = ''; + currentReasoningContent = ''; + activeToolCalls = new Map(); + } + + // ── Server message handler ────────────────────────────────────────────── + + function handleServerMessage(msg: ServerMessage): void { + switch (msg.type) { + case 'connected': + break; + + case 'session_created': + currentModel = msg.model; + if (msg.sessionId) currentSessionId = msg.sessionId; + break; + + case 'session_reconnected': + if (msg.hasSession) { + addInfoMessage('Session reconnected'); + } + break; + + case 'turn_start': + currentReasoningContent = ''; + isReasoningStreaming = false; + isWaiting = true; + activeToolCalls = new Map(); + break; + + case 'reasoning_delta': + isWaiting = false; + isReasoningStreaming = true; + currentReasoningContent += msg.content; + break; + + case 'reasoning_done': { + const reasoningText = currentReasoningContent.trim() || msg.content?.trim() || ''; + if (reasoningText) { + addMessage('reasoning', reasoningText); + } + isReasoningStreaming = false; + currentReasoningContent = ''; + break; + } + + case 'intent': + addMessage('intent', msg.intent); + break; + + case 'tool_start': + isWaiting = false; + addMessage('tool', msg.toolName, { + toolCallId: msg.toolCallId, + toolName: msg.toolName, + toolStatus: 'running', + mcpServerName: msg.mcpServerName, + mcpToolName: msg.mcpToolName, + }); + break; + + case 'tool_progress': + messages = messages.map(m => + m.toolCallId === msg.toolCallId + ? { + ...m, + toolStatus: 'progress' as const, + toolProgressMessage: msg.message, + toolProgressMessages: [...(m.toolProgressMessages ?? []), msg.message], + } + : m, + ); + break; + + case 'tool_end': + messages = messages.map(m => + m.toolCallId === msg.toolCallId ? { ...m, toolStatus: 'complete' as const } : m, + ); + break; + + case 'delta': + isWaiting = false; + if (!isStreaming) { + isStreaming = true; + currentStreamContent = ''; + } + currentStreamContent += msg.content; + break; + + case 'turn_end': + case 'done': + notify('Response ready', { + body: currentStreamContent.trim().slice(0, 100) || undefined, + tag: 'response-ready', + }); + finalizeStream(); + break; + + case 'models': { + const newModels = new Map(); + for (const m of msg.models) { + if (typeof m === 'string') { + newModels.set(m, { id: m, name: m }); + } else { + newModels.set(m.id, m); + } + } + models = newModels; + break; + } + + case 'mode_changed': { + const modeLabels: Record = { + interactive: 'Ask', + plan: 'Plan', + autopilot: 'Auto', + }; + mode = msg.mode; + addInfoMessage(`Mode changed to ${modeLabels[msg.mode] ?? msg.mode}`); + break; + } + + case 'model_changed': + if (msg.model) { + currentModel = msg.model; + } + addInfoMessage(`Model changed to ${msg.model}`); + break; + + case 'title_changed': + sessionTitle = msg.title; + if (currentSessionId) { + sessions = sessions.map((s) => + s.id === currentSessionId ? { ...s, title: msg.title } : s, + ); + } + break; + + case 'usage': + addMessage('usage', '', { + inputTokens: msg.inputTokens, + outputTokens: msg.outputTokens, + reasoningTokens: msg.reasoningTokens, + cacheReadTokens: msg.cacheReadTokens, + cacheWriteTokens: msg.cacheWriteTokens, + duration: msg.duration, + cost: msg.cost, + quotaSnapshots: msg.quotaSnapshots, + copilotUsage: msg.copilotUsage, + }); + // Derive premium requests from quota snapshot delta + { + let premiumDelta = 0; + if (msg.quotaSnapshots) { + const primary = pickPrimaryQuota(msg.quotaSnapshots); + const currentUsed = primary?.snapshot.usedRequests; + if (currentUsed != null) { + if (baselineUsedRequests == null) { + // First snapshot — capture baseline (before this turn's usage) + // Estimate baseline as currentUsed minus cost (cost ≈ premium requests consumed this call) + baselineUsedRequests = currentUsed - (msg.cost ?? 0); + } + premiumDelta = currentUsed - baselineUsedRequests; + } + quotaSnapshots = msg.quotaSnapshots; + } + // Also try copilotUsage as fallback + const copilotPremium = msg.copilotUsage?.reduce( + (acc: number, item: { premiumRequests?: number }) => acc + (item.premiumRequests ?? 0), 0, + ) ?? 0; + const resolvedPremium = premiumDelta > 0 ? premiumDelta : copilotPremium; + sessionTotals = { + inputTokens: sessionTotals.inputTokens + (msg.inputTokens ?? 0), + outputTokens: sessionTotals.outputTokens + (msg.outputTokens ?? 0), + reasoningTokens: sessionTotals.reasoningTokens + (msg.reasoningTokens ?? 0), + cacheReadTokens: sessionTotals.cacheReadTokens + (msg.cacheReadTokens ?? 0), + cacheWriteTokens: sessionTotals.cacheWriteTokens + (msg.cacheWriteTokens ?? 0), + totalCost: sessionTotals.totalCost + (msg.cost ?? 0), + totalDurationMs: sessionTotals.totalDurationMs + (msg.duration ?? 0), + apiCalls: sessionTotals.apiCalls + 1, + premiumRequests: resolvedPremium, + }; + } + break; + + case 'warning': + addMessage('warning', msg.message); + break; + + case 'error': + addMessage('error', msg.message); + isStreaming = false; + isWaiting = false; + currentStreamContent = ''; + // Don't clear pendingUserInput — the error may be unrelated to the ask_user flow + pendingPermission = null; + notify('Something went wrong', { + body: msg.message, + tag: 'error', + }); + break; + + case 'aborted': + isStreaming = false; + isWaiting = false; + currentStreamContent = ''; + pendingUserInput = null; + pendingPermission = null; + addInfoMessage('Response stopped'); + break; + + case 'user_input_request': + case 'elicitation_requested': + pendingUserInput = { + pending: true, + question: msg.question, + choices: msg.choices, + allowFreeform: msg.allowFreeform, + }; + notify('Copilot is asking you something', { + body: msg.question, + tag: 'user-input', + requireInteraction: true, + }); + break; + + case 'elicitation_completed': + pendingUserInput = null; + break; + + case 'permission_request': + pendingPermission = { + requestId: msg.requestId, + kind: msg.kind, + toolName: msg.toolName, + toolArgs: msg.toolArgs, + }; + notify(`Tool approval needed: ${msg.kind}`, { + body: msg.toolName, + tag: msg.requestId, + requireInteraction: true, + }); + break; + + case 'tools': + tools = msg.tools; + break; + + case 'agents': + agents = msg.agents; + if (msg.current !== undefined) { + currentAgent = msg.current; + } + break; + + case 'agent_changed': + currentAgent = msg.agent; + addInfoMessage(msg.agent ? `Agent selected: @${msg.agent}` : 'Agent deselected'); + break; + + case 'quota': + if (msg.quotaSnapshots) { + quotaSnapshots = msg.quotaSnapshots; + } + break; + + case 'sessions': + sessions = msg.sessions; + break; + + case 'session_detail': + sessionDetail = msg.detail; + break; + + case 'session_resumed': + currentSessionId = msg.sessionId; + addInfoMessage(`Session resumed: ${msg.sessionId}`); + notify('Session restored — ready to continue', { + tag: 'session-resumed', + }); + break; + + case 'session_deleted': + sessions = sessions.filter((s) => s.id !== msg.sessionId); + addInfoMessage('Session deleted'); + break; + + case 'plan': + plan = { + exists: msg.exists, + content: msg.content ?? '', + path: msg.path, + }; + break; + + case 'plan_changed': + addInfoMessage('Plan updated'); + break; + + case 'plan_updated': + if (msg.content !== undefined || msg.path !== undefined) { + plan = { + exists: true, + content: msg.content ?? plan.content, + path: msg.path ?? plan.path, + }; + } + addInfoMessage('Plan saved'); + break; + + case 'plan_deleted': + addInfoMessage('Plan deleted'); + plan = { exists: false, content: '' }; + break; + + case 'compaction_start': + addInfoMessage('Compacting conversation…'); + break; + + case 'compaction_complete': { + let compactionMsg = 'Compaction complete'; + if (msg.preCompactionTokens != null && msg.postCompactionTokens != null) { + compactionMsg += `: ${msg.preCompactionTokens.toLocaleString()} → ${msg.postCompactionTokens.toLocaleString()} tokens`; + } + if (msg.tokensRemoved) { + compactionMsg += (msg.preCompactionTokens != null ? ', ' : ': ') + `removed ${msg.tokensRemoved.toLocaleString()} tokens`; + } + if (msg.messagesRemoved) { + compactionMsg += `, ${msg.messagesRemoved} messages`; + } + addInfoMessage(compactionMsg); + break; + } + + case 'compaction_result': + addInfoMessage( + 'Compaction result' + + (msg.tokensRemoved ? `: removed ${msg.tokensRemoved} tokens` : '') + + (msg.messagesRemoved ? `, ${msg.messagesRemoved} messages` : ''), + ); + break; + + case 'skill_invoked': + addMessage('skill', msg.skillName, { skillName: msg.skillName }); + break; + + case 'fleet_started': + fleetActive = msg.started; + if (msg.started) { + fleetAgents = []; + const fleetMessage = addMessage('fleet', 'Fleet mode activated — parallel agents are working', { + fleetAgents: [], + }); + currentFleetMessageId = fleetMessage.id; + } else { + currentFleetMessageId = null; + } + break; + + case 'fleet_status': + if (Array.isArray(msg.agents)) { + const nextFleetAgents = [...fleetAgents]; + for (const agent of msg.agents) { + const existingIndex = nextFleetAgents.findIndex(a => a.agentId === agent.agentId); + if (existingIndex === -1) { + nextFleetAgents.push({ ...agent, status: 'running' }); + } else { + nextFleetAgents[existingIndex] = { + ...nextFleetAgents[existingIndex], + agentType: agent.agentType, + }; + } + } + fleetAgents = nextFleetAgents; + syncFleetMessage(); + } + break; + + case 'subagent_start': + addMessage('subagent', `${msg.agentName} started`, { agentName: msg.agentName }); + if (fleetActive) { + const exists = fleetAgents.some(a => a.agentId === msg.agentName); + if (!exists) { + fleetAgents = [...fleetAgents, { agentId: msg.agentName, agentType: msg.agentName, status: 'running' }]; + } + syncFleetMessage(); + } + break; + + case 'subagent_end': + addMessage('subagent', `${msg.agentName} completed`, { agentName: msg.agentName }); + if (fleetActive) { + fleetAgents = fleetAgents.map(a => + a.agentId === msg.agentName ? { ...a, status: 'completed' as const } : a + ); + if (fleetAgents.length > 0 && fleetAgents.every(a => a.status !== 'running')) { + const completed = fleetAgents.filter(a => a.status === 'completed').length; + const failed = fleetAgents.filter(a => a.status === 'failed').length; + syncFleetMessage(`Fleet complete: ${completed} succeeded, ${failed} failed`); + fleetActive = false; + currentFleetMessageId = null; + } else { + syncFleetMessage(); + } + } + break; + + case 'subagent_failed': + addMessage('error', `Sub-agent ${msg.agentName ?? 'unknown'} failed${msg.error ? `: ${msg.error}` : ''}`); + if (fleetActive) { + fleetAgents = fleetAgents.map(a => + a.agentId === (msg.agentName ?? 'unknown') ? { ...a, status: 'failed' as const, error: msg.error } : a + ); + if (fleetAgents.length > 0 && fleetAgents.every(a => a.status !== 'running')) { + const completed = fleetAgents.filter(a => a.status === 'completed').length; + const failed = fleetAgents.filter(a => a.status === 'failed').length; + syncFleetMessage(`Fleet complete: ${completed} succeeded, ${failed} failed`); + fleetActive = false; + currentFleetMessageId = null; + } else { + syncFleetMessage(); + } + } + break; + + case 'subagent_selected': + currentAgent = msg.agentName; + break; + + case 'subagent_deselected': + currentAgent = null; + break; + + case 'info': + addInfoMessage(msg.message || 'Info'); + break; + + case 'exit_plan_mode_requested': + addInfoMessage('Exiting plan mode…'); + break; + + case 'exit_plan_mode_completed': + addInfoMessage('Exited plan mode'); + break; + + case 'context_info': + contextInfo = { + tokenLimit: msg.tokenLimit, + currentTokens: msg.currentTokens, + messagesLength: msg.messagesLength, + }; + break; + + case 'session_shutdown': + if (msg.totalPremiumRequests != null) { + sessionTotals = { ...sessionTotals, premiumRequests: msg.totalPremiumRequests }; + } + if (msg.totalApiDurationMs != null) { + sessionTotals = { ...sessionTotals, totalDurationMs: msg.totalApiDurationMs }; + } + addInfoMessage( + 'Session ended' + + (msg.totalPremiumRequests != null ? ` · ${msg.totalPremiumRequests} premium requests` : '') + + (msg.totalApiDurationMs != null ? ` · ${(msg.totalApiDurationMs / 1000).toFixed(1)}s total API time` : ''), + ); + break; + + case 'reasoning_changed': + reasoningEffort = msg.effort; + addInfoMessage(`Reasoning effort set to ${msg.effort}`); + break; + + case 'session_idle': + isStreaming = false; + isWaiting = false; + break; + + case 'task_complete': + if (msg.summary) { + addInfoMessage(`Task complete: ${msg.summary}`); + } + break; + + case 'truncation': + addInfoMessage( + `Context truncated: ${msg.preTruncationMessages} → ${msg.postTruncationMessages} messages` + + ` (${msg.preTruncationTokens} → ${msg.postTruncationTokens} tokens)`, + ); + break; + + case 'tool_partial_result': + messages = messages.map(m => + m.toolCallId === msg.toolCallId + ? { + ...m, + toolProgressMessages: [...(m.toolProgressMessages ?? []), msg.partialOutput], + } + : m, + ); + break; + + case 'context_changed': + addInfoMessage( + `Context: ${msg.repository ?? msg.cwd}` + + (msg.branch ? ` (${msg.branch})` : ''), + ); + break; + + case 'workspace_file_changed': + addInfoMessage(`Workspace file ${msg.operation}d: ${msg.path}`); + break; + } + } + + // ── Public mutations ──────────────────────────────────────────────────── + + function clearMessages(): void { + messages = []; + isStreaming = false; + isWaiting = false; + isReasoningStreaming = false; + currentStreamContent = ''; + currentReasoningContent = ''; + activeToolCalls = new Map(); + fleetActive = false; + currentFleetMessageId = null; + fleetAgents = []; + sessionTitle = null; + currentSessionId = null; + pendingUserInput = null; + pendingPermission = null; + contextInfo = null; + sessionDetail = null; + baselineUsedRequests = null; + sessionTotals = { ...emptyTotals }; + } + + function addUserMessage(content: string): void { + addMessage('user', content); + } + + function clearPendingPermission(): void { + pendingPermission = null; + } + + function clearPendingUserInput(): void { + pendingUserInput = null; + } + + // ── Return public interface ───────────────────────────────────────────── + + return { + get messages() { return messages; }, + get isStreaming() { return isStreaming; }, + get isWaiting() { return isWaiting; }, + get isReasoningStreaming() { return isReasoningStreaming; }, + get currentStreamContent() { return currentStreamContent; }, + get currentReasoningContent() { return currentReasoningContent; }, + get activeToolCalls() { return activeToolCalls; }, + + get mode() { return mode; }, + get currentModel() { return currentModel; }, + get reasoningEffort() { return reasoningEffort; }, + get currentAgent() { return currentAgent; }, + get fleetActive() { return fleetActive; }, + get fleetAgents() { return fleetAgents; }, + get sessionTitle() { return sessionTitle; }, + get pendingUserInput() { return pendingUserInput; }, + get pendingPermission() { return pendingPermission; }, + + get models() { return models; }, + get tools() { return tools; }, + get agents() { return agents; }, + get sessions() { return sessions; }, + get sessionDetail() { return sessionDetail; }, + + get contextInfo() { return contextInfo; }, + get quotaSnapshots() { return quotaSnapshots; }, + get sessionTotals() { return sessionTotals; }, + + get plan() { return plan; }, + + get isConnected() { return isConnected; }, + get canSend() { return canSend; }, + get canInterrupt() { return canInterrupt; }, + + handleServerMessage, + clearMessages, + addUserMessage, + clearPendingPermission, + clearPendingUserInput, + }; +} diff --git a/src/lib/stores/chat.test.ts b/src/lib/stores/chat.test.ts new file mode 100644 index 0000000..cf0f033 --- /dev/null +++ b/src/lib/stores/chat.test.ts @@ -0,0 +1,637 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createChatStore } from '$lib/stores/chat.svelte.js'; +import type { WsStore } from '$lib/stores/ws.svelte.js'; +import type { + AgentInfo, + ModelInfo, + QuotaSnapshots, + ServerMessage, + SessionDetail, + SessionSummary, + SessionUsageTotals, + ToolInfo, +} from '$lib/types/index.js'; + +const { notifyMock } = vi.hoisted(() => ({ + notifyMock: vi.fn(), +})); + +vi.mock('$lib/utils/notifications.js', () => ({ + notify: notifyMock, +})); + +function createWsStoreMock(options: { + connectionState?: WsStore['connectionState']; + sessionReady?: boolean; +} = {}): WsStore { + const { connectionState = 'connected', sessionReady = true } = options; + + return { + get connectionState() { return connectionState; }, + get sessionReady() { return sessionReady; }, + connect: vi.fn(), + disconnect: vi.fn(), + onMessage: vi.fn(() => () => {}), + send: vi.fn(), + sendMessage: vi.fn(), + newSession: vi.fn(), + resumeSession: vi.fn(), + setMode: vi.fn(), + setModel: vi.fn(), + setReasoning: vi.fn(), + abort: vi.fn(), + compact: vi.fn(), + listModels: vi.fn(), + listTools: vi.fn(), + listAgents: vi.fn(), + listSessions: vi.fn(), + selectAgent: vi.fn(), + deselectAgent: vi.fn(), + deleteSession: vi.fn(), + getSessionDetail: vi.fn(), + getQuota: vi.fn(), + getPlan: vi.fn(), + updatePlan: vi.fn(), + deletePlan: vi.fn(), + respondToUserInput: vi.fn(), + respondToPermission: vi.fn(), + }; +} + +function dispatch(store: ReturnType, ...messages: ServerMessage[]): void { + for (const message of messages) { + store.handleServerMessage(message); + } +} + +describe('createChatStore', () => { + beforeEach(() => { + notifyMock.mockReset(); + }); + + it('exposes initial ws-derived flags and clears message-related state', () => { + const store = createChatStore(createWsStoreMock()); + + expect(store.messages).toEqual([]); + expect(store.isConnected).toBe(true); + expect(store.canSend).toBe(true); + expect(store.mode).toBe('interactive'); + expect(store.plan).toEqual({ exists: false, content: '' }); + + store.addUserMessage('Hello'); + dispatch( + store, + { type: 'session_created', model: 'gpt-4.1', sessionId: 'session-1' }, + { type: 'title_changed', title: 'Current session' }, + { type: 'context_info', tokenLimit: 32000, currentTokens: 1200, messagesLength: 4 }, + { type: 'user_input_request', question: 'Continue?', choices: ['Yes'], allowFreeform: false }, + { + type: 'permission_request', + requestId: 'perm-1', + kind: 'tool', + toolName: 'bash', + toolArgs: { command: 'pwd' }, + }, + { type: 'connected', user: 'octocat' }, + ); + + expect(store.messages).toHaveLength(1); + expect(store.messages[0]).toMatchObject({ role: 'user', content: 'Hello' }); + expect(store.sessionTitle).toBe('Current session'); + expect(store.contextInfo).toEqual({ tokenLimit: 32000, currentTokens: 1200, messagesLength: 4 }); + expect(store.pendingUserInput).toMatchObject({ pending: true, question: 'Continue?' }); + expect(store.pendingPermission).toMatchObject({ requestId: 'perm-1', toolName: 'bash' }); + + store.clearPendingUserInput(); + store.clearPendingPermission(); + store.clearMessages(); + + expect(store.messages).toEqual([]); + expect(store.isStreaming).toBe(false); + expect(store.isWaiting).toBe(false); + expect(store.isReasoningStreaming).toBe(false); + expect(store.currentStreamContent).toBe(''); + expect(store.currentReasoningContent).toBe(''); + expect(store.sessionTitle).toBeNull(); + expect(store.contextInfo).toBeNull(); + expect(store.pendingUserInput).toBeNull(); + expect(store.pendingPermission).toBeNull(); + expect(store.sessionDetail).toBeNull(); + }); + + it('handles reasoning and assistant streaming lifecycles', () => { + const store = createChatStore(createWsStoreMock()); + + dispatch( + store, + { type: 'turn_start' }, + { type: 'reasoning_delta', reasoningId: 'reason-1', content: 'Thinking' }, + { type: 'reasoning_delta', reasoningId: 'reason-1', content: ' deeply' }, + ); + + expect(store.canSend).toBe(true); + expect(store.isWaiting).toBe(false); + expect(store.isReasoningStreaming).toBe(true); + expect(store.currentReasoningContent).toBe('Thinking deeply'); + + dispatch(store, { type: 'reasoning_done', reasoningId: 'reason-1' }); + + expect(store.isReasoningStreaming).toBe(false); + expect(store.currentReasoningContent).toBe(''); + expect(store.messages[0]).toMatchObject({ role: 'reasoning', content: 'Thinking deeply' }); + + dispatch( + store, + { type: 'delta', content: 'Hello' }, + { type: 'delta', content: ' world' }, + ); + + expect(store.isStreaming).toBe(true); + expect(store.canSend).toBe(true); + expect(store.currentStreamContent).toBe('Hello world'); + + dispatch(store, { type: 'done' }); + + expect(store.isStreaming).toBe(false); + expect(store.isWaiting).toBe(false); + expect(store.currentStreamContent).toBe(''); + expect(store.messages[1]).toMatchObject({ role: 'assistant', content: 'Hello world' }); + expect(notifyMock).toHaveBeenCalledWith('Response ready', { + body: 'Hello world', + tag: 'response-ready', + }); + }); + + it('uses reasoning fallback content, skips blank streams, and updates duplicate tool ids consistently', () => { + const store = createChatStore(createWsStoreMock()); + + dispatch(store, { type: 'reasoning_done', reasoningId: 'reason-2', content: 'Direct reasoning' }); + expect(store.messages[0]).toMatchObject({ role: 'reasoning', content: 'Direct reasoning' }); + + dispatch(store, { type: 'tool_progress', toolCallId: 'missing', message: 'Queued' }); + expect(store.messages).toHaveLength(1); + + dispatch( + store, + { + type: 'tool_start', + toolCallId: 'tool-1', + toolName: 'bash', + mcpServerName: 'local', + mcpToolName: 'exec', + }, + { type: 'tool_progress', toolCallId: 'tool-1', message: 'Running' }, + { type: 'tool_progress', toolCallId: 'tool-1', message: 'Collecting output' }, + { type: 'tool_end', toolCallId: 'tool-1' }, + { type: 'tool_start', toolCallId: 'duplicate', toolName: 'grep' }, + { type: 'tool_start', toolCallId: 'duplicate', toolName: 'grep' }, + { type: 'tool_end', toolCallId: 'duplicate' }, + ); + + expect(store.messages[1]).toMatchObject({ + role: 'tool', + content: 'bash', + toolCallId: 'tool-1', + toolName: 'bash', + toolStatus: 'complete', + toolProgressMessage: 'Collecting output', + toolProgressMessages: ['Running', 'Collecting output'], + mcpServerName: 'local', + mcpToolName: 'exec', + }); + expect(store.messages.slice(-2)).toEqual([ + expect.objectContaining({ toolCallId: 'duplicate', toolStatus: 'complete' }), + expect.objectContaining({ toolCallId: 'duplicate', toolStatus: 'complete' }), + ]); + + dispatch(store, { type: 'delta', content: ' ' }, { type: 'turn_end' }); + + expect(store.messages.filter((message) => message.role === 'assistant')).toHaveLength(0); + expect(notifyMock).toHaveBeenLastCalledWith('Response ready', { + body: undefined, + tag: 'response-ready', + }); + }); + + it('tracks model, tool, agent, and session metadata updates', () => { + const store = createChatStore(createWsStoreMock()); + const modelInfo: ModelInfo = { id: 'claude-sonnet', name: 'Claude Sonnet' }; + const tools: ToolInfo[] = [{ name: 'bash', description: 'Run shell commands' }]; + const agents: (AgentInfo | string)[] = ['explore', { name: 'reviewer', description: 'Reviews code' }]; + const sessions: SessionSummary[] = [ + { id: 'session-1', title: 'Old title' }, + { id: 'session-2', title: 'Keep me' }, + ]; + const detail: SessionDetail = { + id: 'session-1', + checkpoints: [], + }; + + dispatch( + store, + { type: 'session_created', model: 'gpt-4.1', sessionId: 'session-1' }, + { type: 'models', models: ['gpt-4.1', modelInfo] }, + { type: 'mode_changed', mode: 'plan' }, + { type: 'model_changed', model: 'gpt-5' }, + { type: 'tools', tools }, + { type: 'agents', agents, current: 'explore' }, + { type: 'agent_changed', agent: 'reviewer' }, + { type: 'sessions', sessions }, + { type: 'title_changed', title: 'Renamed session' }, + { type: 'session_detail', detail }, + { type: 'session_reconnected', user: 'octocat', hasSession: true }, + { type: 'session_reconnected', user: 'octocat', hasSession: false }, + { type: 'session_resumed', sessionId: 'session-2' }, + { type: 'session_deleted', sessionId: 'session-1' }, + ); + + expect(store.mode).toBe('plan'); + expect(store.currentModel).toBe('gpt-5'); + expect([...store.models.entries()]).toEqual([ + ['gpt-4.1', { id: 'gpt-4.1', name: 'gpt-4.1' }], + ['claude-sonnet', modelInfo], + ]); + expect(store.tools).toEqual(tools); + expect(store.agents).toEqual(agents); + expect(store.currentAgent).toBe('reviewer'); + expect(store.sessionTitle).toBe('Renamed session'); + expect(store.sessions).toEqual([{ id: 'session-2', title: 'Keep me' }]); + expect(store.sessionDetail).toEqual(detail); + expect(store.messages).toEqual([ + expect.objectContaining({ role: 'info', content: 'Mode changed to Plan' }), + expect.objectContaining({ role: 'info', content: 'Model changed to gpt-5' }), + expect.objectContaining({ role: 'info', content: 'Agent selected: @reviewer' }), + expect.objectContaining({ role: 'info', content: 'Session reconnected' }), + expect.objectContaining({ role: 'info', content: 'Session resumed: session-2' }), + expect.objectContaining({ role: 'info', content: 'Session deleted' }), + ]); + expect(notifyMock).toHaveBeenCalledWith('Session restored — ready to continue', { + tag: 'session-resumed', + }); + }); + + it('records usage, quota, context, plan, compaction, and reasoning-effort updates', () => { + const store = createChatStore(createWsStoreMock()); + const initialQuota: QuotaSnapshots = { + chat: { remainingPercentage: 85, resetDate: '2025-01-01T00:00:00Z' }, + }; + const updatedQuota: QuotaSnapshots = { + chat: { percentageUsed: 25, usedRequests: 10 }, + }; + + dispatch( + store, + { + type: 'usage', + inputTokens: 100, + outputTokens: 250, + reasoningTokens: 40, + cacheReadTokens: 50, + cacheWriteTokens: 10, + duration: 1200, + cost: 0.12, + quotaSnapshots: initialQuota, + copilotUsage: [{ type: 'prompt', tokens: 100 }], + }, + { type: 'quota' }, + { type: 'quota', quotaSnapshots: updatedQuota }, + { type: 'context_info', tokenLimit: 64000, currentTokens: 4000, messagesLength: 12 }, + { type: 'plan', exists: true, content: '1. Investigate\n2. Ship', path: '/tmp/plan.md' }, + { type: 'plan_changed' }, + { type: 'plan_updated', content: '1. Investigate\n2. Ship\n3. Verify', path: '/tmp/plan.md' }, + { type: 'compaction_start' }, + { type: 'compaction_complete', tokensRemoved: 120, messagesRemoved: 3, preCompactionTokens: 5000, postCompactionTokens: 4880 }, + { type: 'compaction_result', messagesRemoved: 1 }, + { type: 'reasoning_changed', effort: 'high' }, + ); + + expect(store.messages[0]).toMatchObject({ + role: 'usage', + inputTokens: 100, + outputTokens: 250, + reasoningTokens: 40, + cacheReadTokens: 50, + cacheWriteTokens: 10, + duration: 1200, + cost: 0.12, + quotaSnapshots: initialQuota, + copilotUsage: [{ type: 'prompt', tokens: 100 }], + }); + expect(store.quotaSnapshots).toEqual(updatedQuota); + expect(store.contextInfo).toEqual({ tokenLimit: 64000, currentTokens: 4000, messagesLength: 12 }); + expect(store.plan).toEqual({ exists: true, content: '1. Investigate\n2. Ship\n3. Verify', path: '/tmp/plan.md' }); + expect(store.reasoningEffort).toBe('high'); + + dispatch(store, { type: 'plan_deleted' }); + + expect(store.plan).toEqual({ exists: false, content: '' }); + expect(store.messages.slice(1)).toEqual([ + expect.objectContaining({ role: 'info', content: 'Plan updated' }), + expect.objectContaining({ role: 'info', content: 'Plan saved' }), + expect.objectContaining({ role: 'info', content: 'Compacting conversation…' }), + expect.objectContaining({ role: 'info', content: 'Compaction complete: 5,000 → 4,880 tokens, removed 120 tokens, 3 messages' }), + expect.objectContaining({ role: 'info', content: 'Compaction result, 1 messages' }), + expect.objectContaining({ role: 'info', content: 'Reasoning effort set to high' }), + expect.objectContaining({ role: 'info', content: 'Plan deleted' }), + ]); + }); + + it('manages warning, user input, permission, error, and abort flows', () => { + const store = createChatStore(createWsStoreMock()); + + dispatch( + store, + { type: 'warning', message: 'Careful now' }, + { type: 'user_input_request', question: 'Need approval?', choices: ['Yes', 'No'], allowFreeform: true }, + { + type: 'permission_request', + requestId: 'perm-2', + kind: 'tool', + toolName: 'grep', + toolArgs: { pattern: 'TODO' }, + }, + { type: 'turn_start' }, + { type: 'delta', content: 'Partial response' }, + { type: 'error', message: 'Something exploded' }, + { type: 'elicitation_completed', answer: 'Yes' }, + { type: 'elicitation_requested', question: 'Pick one', choices: ['A'], allowFreeform: false }, + { + type: 'permission_request', + requestId: 'perm-3', + kind: 'tool', + toolName: 'bash', + toolArgs: { command: 'ls' }, + }, + { type: 'aborted' }, + ); + + expect(store.messages).toEqual([ + expect.objectContaining({ role: 'warning', content: 'Careful now' }), + expect.objectContaining({ role: 'error', content: 'Something exploded' }), + expect.objectContaining({ role: 'info', content: 'Response stopped' }), + ]); + expect(store.isStreaming).toBe(false); + expect(store.isWaiting).toBe(false); + expect(store.currentStreamContent).toBe(''); + expect(store.pendingUserInput).toBeNull(); + expect(store.pendingPermission).toBeNull(); + expect(notifyMock).toHaveBeenCalledWith('Copilot is asking you something', { + body: 'Need approval?', + tag: 'user-input', + requireInteraction: true, + }); + expect(notifyMock).toHaveBeenCalledWith('Tool approval needed: tool', { + body: 'grep', + tag: 'perm-2', + requireInteraction: true, + }); + expect(notifyMock).toHaveBeenCalledWith('Something went wrong', { + body: 'Something exploded', + tag: 'error', + }); + }); + + it('records intent, skill, subagent, info, and exit-plan events', () => { + const store = createChatStore(createWsStoreMock()); + + dispatch( + store, + { type: 'intent', intent: 'Exploring codebase' }, + { type: 'skill_invoked', skillName: 'explain' }, + { type: 'subagent_start', agentName: 'reviewer' }, + { type: 'subagent_end', agentName: 'reviewer' }, + { type: 'subagent_failed', agentName: 'triager', error: 'boom' }, + { type: 'subagent_selected', agentName: 'reviewer' }, + { type: 'subagent_deselected' }, + { type: 'info', message: '' }, + { type: 'exit_plan_mode_requested' }, + { type: 'exit_plan_mode_completed' }, + ); + + expect(store.currentAgent).toBeNull(); + expect(store.messages).toEqual([ + expect.objectContaining({ role: 'intent', content: 'Exploring codebase' }), + expect.objectContaining({ role: 'skill', content: 'explain', skillName: 'explain' }), + expect.objectContaining({ role: 'subagent', content: 'reviewer started', agentName: 'reviewer' }), + expect.objectContaining({ role: 'subagent', content: 'reviewer completed', agentName: 'reviewer' }), + expect.objectContaining({ role: 'error', content: 'Sub-agent triager failed: boom' }), + expect.objectContaining({ role: 'info', content: 'Info' }), + expect.objectContaining({ role: 'info', content: 'Exiting plan mode…' }), + expect.objectContaining({ role: 'info', content: 'Exited plan mode' }), + ]); + }); + + it('handles fleet_started message', () => { + const store = createChatStore(createWsStoreMock()); + + store.handleServerMessage({ type: 'fleet_started', started: true }); + + expect(store.fleetActive).toBe(true); + const fleetMsg = store.messages.find(message => message.role === 'fleet'); + expect(fleetMsg).toBeDefined(); + expect(fleetMsg!.content).toContain('Fleet mode activated'); + }); + + it('tracks fleet agents through subagent lifecycle', () => { + const store = createChatStore(createWsStoreMock()); + + dispatch( + store, + { type: 'fleet_started', started: true }, + { type: 'subagent_start', agentName: 'researcher' }, + ); + + expect(store.fleetAgents).toHaveLength(1); + expect(store.fleetAgents[0].status).toBe('running'); + + store.handleServerMessage({ type: 'subagent_end', agentName: 'researcher' }); + + expect(store.fleetAgents[0].status).toBe('completed'); + }); + + it('marks fleet complete when all agents finish', () => { + const store = createChatStore(createWsStoreMock()); + + dispatch( + store, + { type: 'fleet_started', started: true }, + { type: 'subagent_start', agentName: 'agent1' }, + { type: 'subagent_start', agentName: 'agent2' }, + { type: 'subagent_end', agentName: 'agent1' }, + ); + + expect(store.fleetActive).toBe(true); + + store.handleServerMessage({ type: 'subagent_end', agentName: 'agent2' }); + + expect(store.fleetActive).toBe(false); + const completeMsg = store.messages.find( + message => message.role === 'fleet' && message.content.includes('complete'), + ); + expect(completeMsg).toBeDefined(); + }); + + it('handles fleet agent failures', () => { + const store = createChatStore(createWsStoreMock()); + + dispatch( + store, + { type: 'fleet_started', started: true }, + { type: 'subagent_start', agentName: 'agent1' }, + { type: 'subagent_failed', agentName: 'agent1', error: 'timeout' }, + ); + + expect(store.fleetAgents[0].status).toBe('failed'); + expect(store.fleetAgents[0].error).toBe('timeout'); + }); + + it('resets fleet state on clearMessages', () => { + const store = createChatStore(createWsStoreMock()); + + store.handleServerMessage({ type: 'fleet_started', started: true }); + store.clearMessages(); + + expect(store.fleetActive).toBe(false); + expect(store.fleetAgents).toHaveLength(0); + }); + + it('accumulates session usage totals across multiple usage messages', () => { + const store = createChatStore(createWsStoreMock()); + + expect(store.sessionTotals).toEqual({ + inputTokens: 0, outputTokens: 0, reasoningTokens: 0, + cacheReadTokens: 0, cacheWriteTokens: 0, + totalCost: 0, totalDurationMs: 0, apiCalls: 0, premiumRequests: 0, + }); + + dispatch(store, { + type: 'usage', + inputTokens: 100, + outputTokens: 200, + reasoningTokens: 30, + cacheReadTokens: 50, + cacheWriteTokens: 10, + duration: 500, + cost: 2, + }); + + expect(store.sessionTotals).toEqual({ + inputTokens: 100, outputTokens: 200, reasoningTokens: 30, + cacheReadTokens: 50, cacheWriteTokens: 10, + totalCost: 2, totalDurationMs: 500, apiCalls: 1, premiumRequests: 0, + }); + + dispatch(store, { + type: 'usage', + inputTokens: 150, + outputTokens: 300, + cost: 3, + duration: 700, + }); + + expect(store.sessionTotals).toEqual({ + inputTokens: 250, outputTokens: 500, reasoningTokens: 30, + cacheReadTokens: 50, cacheWriteTokens: 10, + totalCost: 5, totalDurationMs: 1200, apiCalls: 2, premiumRequests: 0, + }); + + // clearMessages resets totals + store.clearMessages(); + expect(store.sessionTotals.apiCalls).toBe(0); + expect(store.sessionTotals.inputTokens).toBe(0); + }); + + it('handles session_shutdown and updates premium requests', () => { + const store = createChatStore(createWsStoreMock()); + + dispatch(store, { + type: 'usage', + inputTokens: 100, + outputTokens: 200, + cost: 1, + }); + + dispatch(store, { + type: 'session_shutdown', + totalPremiumRequests: 5, + totalApiDurationMs: 3200, + sessionStartTime: '2026-01-01T00:00:00Z', + }); + + expect(store.sessionTotals.premiumRequests).toBe(5); + expect(store.sessionTotals.totalDurationMs).toBe(3200); + expect(store.messages).toEqual([ + expect.objectContaining({ role: 'usage' }), + expect.objectContaining({ role: 'info', content: 'Session ended · 5 premium requests · 3.2s total API time' }), + ]); + }); + + it('handles session_idle, task_complete, truncation, and workspace_file_changed events', () => { + const store = createChatStore(createWsStoreMock()); + + // session.idle clears streaming/waiting flags + dispatch(store, { type: 'turn_start' }); + expect(store.isWaiting).toBe(true); + dispatch(store, { type: 'delta', content: 'data' }); + expect(store.isStreaming).toBe(true); + dispatch(store, { type: 'session_idle' }); + expect(store.isStreaming).toBe(false); + expect(store.isWaiting).toBe(false); + + store.clearMessages(); + + // task_complete with summary adds info message + dispatch(store, { type: 'task_complete', summary: 'Refactored auth module' }); + expect(store.messages).toEqual([ + expect.objectContaining({ role: 'info', content: 'Task complete: Refactored auth module' }), + ]); + + store.clearMessages(); + + // task_complete without summary adds nothing + dispatch(store, { type: 'task_complete' }); + expect(store.messages).toEqual([]); + + store.clearMessages(); + + // truncation adds descriptive info message + dispatch(store, { + type: 'truncation', + tokenLimit: 128000, + preTruncationTokens: 100000, + preTruncationMessages: 50, + postTruncationTokens: 60000, + postTruncationMessages: 30, + }); + expect(store.messages).toEqual([ + expect.objectContaining({ + role: 'info', + content: 'Context truncated: 50 → 30 messages (100000 → 60000 tokens)', + }), + ]); + + store.clearMessages(); + + // context_changed and workspace_file_changed produce info messages + dispatch( + store, + { type: 'context_changed', cwd: '/tmp', gitRoot: '/tmp', repository: 'o/r', branch: 'main' }, + { type: 'workspace_file_changed', path: 'plan.md', operation: 'update' }, + ); + expect(store.messages).toEqual([ + expect.objectContaining({ role: 'info', content: 'Context: o/r (main)' }), + expect.objectContaining({ role: 'info', content: 'Workspace file updated: plan.md' }), + ]); + + store.clearMessages(); + + // tool_partial_result appends to tool progress messages + dispatch( + store, + { type: 'tool_start', toolCallId: 'tc-1', toolName: 'bash', mcpServerName: undefined, mcpToolName: undefined }, + { type: 'tool_partial_result', toolCallId: 'tc-1', partialOutput: 'partial data' }, + ); + const toolMsg = store.messages.find(m => m.toolCallId === 'tc-1'); + expect(toolMsg?.toolProgressMessages).toEqual(['partial data']); + }); +}); diff --git a/src/lib/stores/settings.svelte.ts b/src/lib/stores/settings.svelte.ts new file mode 100644 index 0000000..e42e8a8 --- /dev/null +++ b/src/lib/stores/settings.svelte.ts @@ -0,0 +1,241 @@ +import type { + SessionMode, + ReasoningEffort, + PersistedSettings, + CustomToolDefinition, + CustomAgentDefinition, + McpServerDefinition, + SkillDefinition, +} from '$lib/types/index.js'; + +const STORAGE_KEY = 'copilot-cli-settings'; + +const DEFAULT_SETTINGS: PersistedSettings = { + model: '', + mode: 'interactive', + reasoningEffort: 'medium', + customInstructions: '', + excludedTools: [], + customTools: [], + customAgents: [], + mcpServers: [], + disabledSkills: [], +}; + +const VALID_MODES = new Set(['interactive', 'plan', 'autopilot']); +const VALID_REASONING = new Set(['low', 'medium', 'high', 'xhigh']); + +function isValidCustomTool(t: unknown): t is CustomToolDefinition { + if (!t || typeof t !== 'object') return false; + const obj = t as Record; + return ( + typeof obj.name === 'string' && + typeof obj.description === 'string' && + typeof obj.webhookUrl === 'string' && + (obj.method === 'GET' || obj.method === 'POST') && + typeof obj.headers === 'object' && obj.headers !== null && + typeof obj.parameters === 'object' && obj.parameters !== null + ); +} + +function isValidCustomAgent(agent: unknown): agent is CustomAgentDefinition { + if (!agent || typeof agent !== 'object') return false; + const obj = agent as Record; + return ( + typeof obj.name === 'string' && + (typeof obj.displayName === 'undefined' || typeof obj.displayName === 'string') && + (typeof obj.description === 'undefined' || typeof obj.description === 'string') && + (typeof obj.tools === 'undefined' || (Array.isArray(obj.tools) && obj.tools.every((tool) => typeof tool === 'string'))) && + typeof obj.prompt === 'string' + ); +} + +function isValidMcpServer(s: unknown): s is McpServerDefinition { + if (!s || typeof s !== 'object') return false; + const obj = s as Record; + return ( + typeof obj.name === 'string' && + typeof obj.url === 'string' && + (obj.type === 'http' || obj.type === 'sse') && + typeof obj.headers === 'object' && obj.headers !== null && + Array.isArray(obj.tools) && + typeof obj.enabled === 'boolean' + ); +} + +export interface SettingsStore { + customInstructions: string; + excludedTools: string[]; + customTools: CustomToolDefinition[]; + customAgents: CustomAgentDefinition[]; + reasoningEffort: ReasoningEffort; + selectedModel: string; + selectedMode: SessionMode; + mcpServers: McpServerDefinition[]; + disabledSkills: string[]; + availableSkills: SkillDefinition[]; + load(): void; + save(): void; + syncFromServer(): Promise; + fetchSkills(): Promise; +} + +export function createSettingsStore(): SettingsStore { + let customInstructions = $state(DEFAULT_SETTINGS.customInstructions); + let excludedTools = $state([...DEFAULT_SETTINGS.excludedTools]); + let customTools = $state([...DEFAULT_SETTINGS.customTools]); + let customAgents = $state([...(DEFAULT_SETTINGS.customAgents ?? [])]); + let reasoningEffort = $state(DEFAULT_SETTINGS.reasoningEffort); + let selectedModel = $state(DEFAULT_SETTINGS.model); + let selectedMode = $state(DEFAULT_SETTINGS.mode); + let mcpServers = $state([...(DEFAULT_SETTINGS.mcpServers ?? [])]); + let disabledSkills = $state([...(DEFAULT_SETTINGS.disabledSkills ?? [])]); + let availableSkills = $state([]); + + function load(): void { + if (typeof localStorage === 'undefined') return; + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return; + const parsed = JSON.parse(raw) as Partial; + applySettings(parsed); + } catch { + // Ignore corrupt data + } + } + + function getCurrentData(): PersistedSettings { + return { + model: selectedModel, + mode: selectedMode, + reasoningEffort, + customInstructions, + excludedTools, + customTools, + customAgents, + mcpServers, + disabledSkills, + }; + } + + function applySettings(parsed: Partial): void { + if (parsed.model && typeof parsed.model === 'string') { + selectedModel = parsed.model; + } + // Mode is intentionally not restored — always defaults to 'interactive' (Ask) + if (parsed.reasoningEffort && VALID_REASONING.has(parsed.reasoningEffort as ReasoningEffort)) { + reasoningEffort = parsed.reasoningEffort as ReasoningEffort; + } + if (typeof parsed.customInstructions === 'string') { + customInstructions = parsed.customInstructions; + } + if (Array.isArray(parsed.excludedTools)) { + excludedTools = parsed.excludedTools.filter((t): t is string => typeof t === 'string'); + } + if (Array.isArray(parsed.customTools)) { + customTools = parsed.customTools.filter(isValidCustomTool).slice(0, 10); + } + if (Array.isArray(parsed.customAgents)) { + customAgents = parsed.customAgents.filter(isValidCustomAgent).slice(0, 10); + } + if (Array.isArray(parsed.mcpServers)) { + mcpServers = parsed.mcpServers.filter(isValidMcpServer).slice(0, 10); + } + if (Array.isArray(parsed.disabledSkills)) { + disabledSkills = parsed.disabledSkills.filter((s): s is string => typeof s === 'string'); + } + } + + function save(): void { + if (typeof localStorage === 'undefined') return; + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(getCurrentData())); + } catch { + // Ignore quota errors + } + // Fire-and-forget sync to server + syncToServer(); + } + + function syncToServer(): void { + try { + fetch('/api/settings', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ settings: getCurrentData() }), + }).catch(() => { /* ignore network errors */ }); + } catch { + // Ignore errors + } + } + + async function syncFromServer(): Promise { + try { + const res = await fetch('/api/settings'); + if (!res.ok) return; + const body = await res.json() as { settings?: PersistedSettings | null }; + if (body.settings) { + applySettings(body.settings); + // Update localStorage with server data + if (typeof localStorage !== 'undefined') { + localStorage.setItem(STORAGE_KEY, JSON.stringify(getCurrentData())); + } + } else { + // No server data yet — push current local settings to server + syncToServer(); + } + } catch { + // Ignore fetch errors — use local data + } + } + + async function fetchSkills(): Promise { + try { + const res = await fetch('/api/skills'); + if (!res.ok) return; + const body = await res.json() as { skills?: SkillDefinition[] }; + if (Array.isArray(body.skills)) { + availableSkills = body.skills; + } + } catch { + // Ignore fetch errors + } + } + + return { + get customInstructions() { return customInstructions; }, + set customInstructions(v: string) { customInstructions = v; save(); }, + + get excludedTools() { return excludedTools; }, + set excludedTools(v: string[]) { excludedTools = v; save(); }, + + get customTools() { return customTools; }, + set customTools(v: CustomToolDefinition[]) { customTools = v.slice(0, 10); save(); }, + + get customAgents() { return customAgents; }, + set customAgents(v: CustomAgentDefinition[]) { customAgents = v.slice(0, 10); save(); }, + + get reasoningEffort() { return reasoningEffort; }, + set reasoningEffort(v: ReasoningEffort) { reasoningEffort = v; save(); }, + + get selectedModel() { return selectedModel; }, + set selectedModel(v: string) { selectedModel = v; save(); }, + + get selectedMode() { return selectedMode; }, + set selectedMode(v: SessionMode) { selectedMode = v; save(); }, + + get mcpServers() { return mcpServers; }, + set mcpServers(v: McpServerDefinition[]) { mcpServers = v.slice(0, 10); save(); }, + + get disabledSkills() { return disabledSkills; }, + set disabledSkills(v: string[]) { disabledSkills = v; save(); }, + + get availableSkills() { return availableSkills; }, + set availableSkills(v: SkillDefinition[]) { availableSkills = v; }, + + load, + save, + syncFromServer, + fetchSkills, + }; +} diff --git a/src/lib/stores/settings.test.ts b/src/lib/stores/settings.test.ts new file mode 100644 index 0000000..aff6236 --- /dev/null +++ b/src/lib/stores/settings.test.ts @@ -0,0 +1,252 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createSettingsStore } from '$lib/stores/settings.svelte.js'; +import type { + CustomAgentDefinition, + CustomToolDefinition, + McpServerDefinition, + PersistedSettings, +} from '$lib/types/index.js'; + +const STORAGE_KEY = 'copilot-cli-settings'; + +function jsonResponse(data: unknown, ok = true): Response { + return { + ok, + json: vi.fn().mockResolvedValue(data), + } as unknown as Response; +} + +function makeCustomTool(name: string): CustomToolDefinition { + return { + name, + description: `${name} description`, + webhookUrl: `https://example.com/${name}`, + method: 'POST', + headers: { Authorization: 'Bearer token' }, + parameters: { + prompt: { type: 'string', description: 'Prompt text' }, + }, + }; +} + +function makeCustomAgent(name: string): CustomAgentDefinition { + return { + name, + displayName: `${name} display`, + description: `${name} description`, + tools: [`${name}.tool`], + prompt: `Prompt for ${name}`, + }; +} + +function makeMcpServer(name: string): McpServerDefinition { + return { + name, + url: `https://mcp.example.com/${name}`, + type: 'http', + headers: { Authorization: 'Bearer token' }, + tools: [`${name}.tool`], + enabled: true, + }; +} + +describe('createSettingsStore', () => { + const fetchMock = vi.fn(); + + beforeEach(() => { + fetchMock.mockReset(); + vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch); + localStorage.clear(); + }); + + it('starts with the expected defaults', () => { + const store = createSettingsStore(); + + expect(store.selectedModel).toBe(''); + expect(store.selectedMode).toBe('interactive'); + expect(store.reasoningEffort).toBe('medium'); + expect(store.customInstructions).toBe(''); + expect(store.excludedTools).toEqual([]); + expect(store.customTools).toEqual([]); + expect(store.customAgents).toEqual([]); + expect(store.mcpServers).toEqual([]); + }); + + it('persists setter updates to localStorage and syncs them to the server', () => { + const store = createSettingsStore(); + const tools = Array.from({ length: 12 }, (_, index) => makeCustomTool(`tool-${index}`)); + const agents = Array.from({ length: 12 }, (_, index) => makeCustomAgent(`agent-${index}`)); + const servers = Array.from({ length: 12 }, (_, index) => makeMcpServer(`server-${index}`)); + + store.selectedModel = 'gpt-5'; + store.selectedMode = 'autopilot'; + store.reasoningEffort = 'high'; + store.customInstructions = 'Be concise'; + store.excludedTools = ['bash', 'grep']; + store.customTools = tools; + store.customAgents = agents; + store.mcpServers = servers; + + const persisted = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') as PersistedSettings; + + expect(store.customTools).toHaveLength(10); + expect(store.customAgents).toHaveLength(10); + expect(store.mcpServers).toHaveLength(10); + expect(persisted).toMatchObject({ + model: 'gpt-5', + mode: 'autopilot', + reasoningEffort: 'high', + customInstructions: 'Be concise', + excludedTools: ['bash', 'grep'], + }); + expect(persisted.customTools).toHaveLength(10); + expect(persisted.customAgents).toHaveLength(10); + expect(persisted.mcpServers).toHaveLength(10); + expect(fetchMock).toHaveBeenCalledTimes(8); + expect(fetchMock).toHaveBeenLastCalledWith('/api/settings', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ settings: persisted }), + }); + }); + + it('persists custom agents in settings', () => { + const settings = createSettingsStore(); + + settings.customAgents = [ + { + name: 'researcher', + prompt: 'You are a research assistant', + description: 'Research agent', + }, + ]; + settings.save(); + + const settings2 = createSettingsStore(); + settings2.load(); + + expect(settings2.customAgents).toHaveLength(1); + expect(settings2.customAgents[0].name).toBe('researcher'); + expect(settings2.customAgents[0].prompt).toBe('You are a research assistant'); + }); + + it('loads valid persisted settings, filters invalid entries, and keeps mode interactive', () => { + const validTool = makeCustomTool('valid-tool'); + const validAgent = makeCustomAgent('valid-agent'); + const validServer = makeMcpServer('valid-server'); + + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + model: 'claude-sonnet', + mode: 'autopilot', + reasoningEffort: 'xhigh', + customInstructions: 'Use the docs', + excludedTools: ['bash', 42, null], + customTools: [ + validTool, + { ...validTool, name: 123 }, + ...Array.from({ length: 10 }, (_, index) => makeCustomTool(`extra-tool-${index}`)), + ], + customAgents: [ + validAgent, + { ...validAgent, prompt: 123 }, + ...Array.from({ length: 10 }, (_, index) => makeCustomAgent(`extra-agent-${index}`)), + ], + mcpServers: [ + validServer, + { ...validServer, enabled: 'yes' }, + ...Array.from({ length: 10 }, (_, index) => makeMcpServer(`extra-server-${index}`)), + ], + }), + ); + + const store = createSettingsStore(); + store.load(); + + expect(store.selectedModel).toBe('claude-sonnet'); + expect(store.selectedMode).toBe('interactive'); + expect(store.reasoningEffort).toBe('xhigh'); + expect(store.customInstructions).toBe('Use the docs'); + expect(store.excludedTools).toEqual(['bash']); + expect(store.customTools).toHaveLength(10); + expect(store.customTools[0]).toEqual(validTool); + expect(store.customAgents).toHaveLength(10); + expect(store.customAgents[0]).toEqual(validAgent); + expect(store.mcpServers).toHaveLength(10); + expect(store.mcpServers[0]).toEqual(validServer); + }); + + it('ignores corrupt localStorage payloads', () => { + localStorage.setItem(STORAGE_KEY, '{this is not valid json'); + + const store = createSettingsStore(); + store.load(); + + expect(store.selectedModel).toBe(''); + expect(store.selectedMode).toBe('interactive'); + expect(store.reasoningEffort).toBe('medium'); + }); + + it('pulls settings from the server and rewrites localStorage with the sanitized result', async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ + settings: { + model: 'gpt-4.1', + mode: 'plan', + reasoningEffort: 'low', + customInstructions: 'Server wins', + excludedTools: ['bash'], + customTools: [makeCustomTool('server-tool')], + customAgents: [makeCustomAgent('server-agent')], + mcpServers: [makeMcpServer('server-mcp')], + }, + }), + ); + + const store = createSettingsStore(); + await store.syncFromServer(); + + expect(fetchMock).toHaveBeenCalledWith('/api/settings'); + expect(store.selectedModel).toBe('gpt-4.1'); + expect(store.selectedMode).toBe('interactive'); + expect(store.reasoningEffort).toBe('low'); + expect(store.customInstructions).toBe('Server wins'); + expect(store.excludedTools).toEqual(['bash']); + expect(store.customTools).toEqual([makeCustomTool('server-tool')]); + expect(store.customAgents).toEqual([makeCustomAgent('server-agent')]); + expect(store.mcpServers).toEqual([makeMcpServer('server-mcp')]); + + const persisted = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') as PersistedSettings; + expect(persisted.mode).toBe('interactive'); + expect(persisted.model).toBe('gpt-4.1'); + }); + + it('pushes local settings to the server when no server snapshot exists', async () => { + const store = createSettingsStore(); + store.selectedModel = 'gpt-4o-mini'; + fetchMock.mockClear(); + fetchMock.mockResolvedValueOnce(jsonResponse({ settings: null })); + + await store.syncFromServer(); + + expect(fetchMock).toHaveBeenNthCalledWith(1, '/api/settings'); + expect(fetchMock).toHaveBeenNthCalledWith(2, '/api/settings', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + settings: { + model: 'gpt-4o-mini', + mode: 'interactive', + reasoningEffort: 'medium', + customInstructions: '', + excludedTools: [], + customTools: [], + customAgents: [], + mcpServers: [], + disabledSkills: [], + }, + }), + }); + }); +}); diff --git a/src/lib/stores/ws.svelte.ts b/src/lib/stores/ws.svelte.ts new file mode 100644 index 0000000..e0350f8 --- /dev/null +++ b/src/lib/stores/ws.svelte.ts @@ -0,0 +1,369 @@ +import type { + ConnectionState, + SessionMode, + ReasoningEffort, + ClientMessage, + ServerMessage, + NewSessionConfig, + MessageDeliveryMode, +} from '$lib/types/index.js'; +import { notify } from '$lib/utils/notifications.js'; + +const INITIAL_RECONNECT_DELAY = 3000; +const MAX_RECONNECT_DELAY = 60_000; +const UNAUTHORIZED_CODE = 4001; +const REPLACED_CODE = 4002; + +// Unique ID for this browser tab — persisted in sessionStorage to survive hard refreshes +const TAB_ID = typeof sessionStorage !== 'undefined' + ? sessionStorage.getItem('copilot-tab-id') ?? (() => { + const id = crypto.randomUUID(); + sessionStorage.setItem('copilot-tab-id', id); + return id; + })() + : crypto.randomUUID(); + +export interface WsStore { + readonly connectionState: ConnectionState; + readonly sessionReady: boolean; + + connect(): void; + disconnect(): void; + onMessage(handler: (msg: ServerMessage) => void): () => void; + + send(data: ClientMessage): void; + + // Typed send helpers + sendMessage( + content: string, + attachments?: Array<{ path: string; name: string; type: string }>, + mode?: MessageDeliveryMode, + ): void; + newSession(config: NewSessionConfig): void; + resumeSession(sessionId: string): void; + setMode(mode: SessionMode): void; + setModel(model: string): void; + setReasoning(effort: ReasoningEffort): void; + abort(): void; + compact(): void; + listModels(): void; + listTools(model?: string): void; + listAgents(): void; + listSessions(): void; + selectAgent(name: string): void; + deselectAgent(): void; + deleteSession(sessionId: string): void; + getSessionDetail(sessionId: string): void; + getQuota(): void; + getPlan(): void; + updatePlan(content: string): void; + deletePlan(): void; + respondToUserInput(answer: string, wasFreeform: boolean): void; + respondToPermission(requestId: string, kind: string, toolName: string, decision: 'allow' | 'deny' | 'always_allow' | 'always_deny'): void; +} + +export function createWsStore(): WsStore { + let connectionState = $state('disconnected'); + let sessionReady = $state(false); + let ws: WebSocket | null = null; + let reconnectTimer: ReturnType | null = null; + let reconnectDelay = INITIAL_RECONNECT_DELAY; + let messageHandlers: Array<(msg: ServerMessage) => void> = []; + let visibilityCleanup: (() => void) | null = null; + + // ── Internal helpers ──────────────────────────────────────────────────── + + function send(msg: ClientMessage): void { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(msg)); + } + } + + function dispatchMessage(msg: ServerMessage): void { + // Track session readiness from protocol messages + if (msg.type === 'session_created' || msg.type === 'session_resumed') { + sessionReady = true; + } + if (msg.type === 'session_reconnected') { + sessionReady = msg.hasSession; + } + + for (const handler of messageHandlers) { + handler(msg); + } + } + + function clearReconnectTimer(): void { + if (reconnectTimer !== null) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + } + + function scheduleReconnect(): void { + clearReconnectTimer(); + reconnectTimer = setTimeout(() => connect(), reconnectDelay); + reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY); + } + + function buildWsUrl(): string { + if (typeof window === 'undefined') return ''; + const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + return `${proto}//${window.location.host}/ws?tabId=${TAB_ID}`; + } + + function setupVisibilityHandler(): void { + if (typeof document === 'undefined' || visibilityCleanup) return; + + const handler = () => { + if (!document.hidden && (!ws || ws.readyState !== WebSocket.OPEN)) { + clearReconnectTimer(); + connect(); + } + }; + document.addEventListener('visibilitychange', handler); + visibilityCleanup = () => document.removeEventListener('visibilitychange', handler); + } + + // ── Connection lifecycle ──────────────────────────────────────────────── + + function connect(): void { + if (typeof window === 'undefined') return; + console.log(`[WS-STORE] connect() called, existing ws=${!!ws}, readyState=${ws?.readyState}`); + + // Close existing connection without triggering reconnect logic + if (ws) { + console.log('[WS-STORE] Closing existing connection before reconnect'); + ws.onclose = null; + try { ws.close(); } catch { /* ignore */ } + ws = null; + } + + connectionState = 'connecting'; + const socket = new WebSocket(buildWsUrl()); + + socket.onopen = () => { + console.log(`[WS-STORE] WebSocket connected`); + connectionState = 'connected'; + reconnectDelay = INITIAL_RECONNECT_DELAY; + clearReconnectTimer(); + }; + + socket.onmessage = (event: MessageEvent) => { + try { + const msg = JSON.parse(event.data as string) as ServerMessage; + console.log(`[WS-STORE] Received message: type=${msg.type}`); + dispatchMessage(msg); + } catch { + console.error('WS: failed to parse message'); + } + }; + + socket.onclose = (event: CloseEvent) => { + console.log(`[WS-STORE] WebSocket closed code=${event.code} reason=${event.reason}`); + connectionState = 'disconnected'; + sessionReady = false; + ws = null; + + if (event.code === UNAUTHORIZED_CODE) { + window.location.reload(); + return; + } + + // 4002 = replaced by a newer connection we opened — don't reconnect + // (the newer socket is already active) + if (event.code === REPLACED_CODE) { + return; + } + + notify('Session disconnected — trying to reconnect…', { + tag: 'session-disconnected', + }); + scheduleReconnect(); + }; + + socket.onerror = () => { + console.error(`[WS-STORE] WebSocket error`); + connectionState = 'error'; + }; + + ws = socket; + setupVisibilityHandler(); + } + + function disconnect(): void { + console.log(`[WS-STORE] disconnect() called, ws=${!!ws}`); + clearReconnectTimer(); + if (visibilityCleanup) { + visibilityCleanup(); + visibilityCleanup = null; + } + if (ws) { + ws.onclose = null; // prevent onclose from scheduling a reconnect + try { ws.close(); } catch { /* ignore */ } + ws = null; + } + connectionState = 'disconnected'; + sessionReady = false; + } + + // ── Message subscription ──────────────────────────────────────────────── + + function onMessage(handler: (msg: ServerMessage) => void): () => void { + messageHandlers.push(handler); + return () => { + messageHandlers = messageHandlers.filter((h) => h !== handler); + }; + } + + // ── Typed send functions ──────────────────────────────────────────────── + + function sendMessage( + content: string, + attachments?: Array<{ path: string; name: string; type: string }>, + mode?: MessageDeliveryMode, + ): void { + send({ + type: 'message', + content, + ...(attachments?.length ? { attachments } : {}), + ...(mode ? { mode } : {}), + }); + } + + function newSession(config: NewSessionConfig): void { + sessionReady = false; + const msg: ClientMessage = { + type: 'new_session', + model: config.model, + ...(config.mode && { mode: config.mode }), + ...(config.reasoningEffort && { reasoningEffort: config.reasoningEffort }), + ...(config.customInstructions?.trim() && { customInstructions: config.customInstructions.trim() }), + ...(config.excludedTools?.length && { excludedTools: config.excludedTools }), + ...(config.customTools?.length && { customTools: config.customTools }), + ...(config.customAgents?.length && { customAgents: config.customAgents }), + ...(config.mcpServers?.length && { mcpServers: config.mcpServers }), + ...(config.disabledSkills?.length && { disabledSkills: config.disabledSkills }), + }; + send(msg); + } + + function resumeSession(sessionId: string): void { + sessionReady = false; + send({ type: 'resume_session', sessionId }); + } + + function setMode(mode: SessionMode): void { + send({ type: 'set_mode', mode }); + } + + function setModel(model: string): void { + send({ type: 'set_model', model }); + } + + function setReasoning(effort: ReasoningEffort): void { + send({ type: 'set_reasoning', effort }); + } + + function abort(): void { + send({ type: 'abort' }); + } + + function compact(): void { + send({ type: 'compact' }); + } + + function listModels(): void { + send({ type: 'list_models' }); + } + + function listTools(model?: string): void { + const msg: ClientMessage = model + ? { type: 'list_tools', model } + : { type: 'list_tools' }; + send(msg); + } + + function listAgents(): void { + send({ type: 'list_agents' }); + } + + function listSessions(): void { + send({ type: 'list_sessions' }); + } + + function deleteSession(sessionId: string): void { + send({ type: 'delete_session', sessionId }); + } + + function getSessionDetail(sessionId: string): void { + send({ type: 'get_session_detail', sessionId }); + } + + function selectAgent(name: string): void { + send({ type: 'select_agent', name }); + } + + function deselectAgent(): void { + send({ type: 'deselect_agent' }); + } + + function getQuota(): void { + send({ type: 'get_quota' }); + } + + function getPlan(): void { + send({ type: 'get_plan' }); + } + + function updatePlan(content: string): void { + send({ type: 'update_plan', content }); + } + + function deletePlan(): void { + send({ type: 'delete_plan' }); + } + + function respondToUserInput(answer: string, wasFreeform: boolean): void { + send({ type: 'user_input_response', answer, wasFreeform }); + } + + function respondToPermission(requestId: string, kind: string, toolName: string, decision: 'allow' | 'deny' | 'always_allow' | 'always_deny'): void { + send({ type: 'permission_response', requestId, kind, toolName, decision }); + } + + // ── Return public interface ───────────────────────────────────────────── + + return { + get connectionState() { return connectionState; }, + get sessionReady() { return sessionReady; }, + + connect, + disconnect, + onMessage, + + send, + sendMessage, + newSession, + resumeSession, + setMode, + setModel, + setReasoning, + abort, + compact, + listModels, + listTools, + listAgents, + listSessions, + deleteSession, + getSessionDetail, + selectAgent, + deselectAgent, + getQuota, + getPlan, + updatePlan, + deletePlan, + respondToUserInput, + respondToPermission, + }; +} diff --git a/src/lib/stores/ws.test.ts b/src/lib/stores/ws.test.ts new file mode 100644 index 0000000..1004395 --- /dev/null +++ b/src/lib/stores/ws.test.ts @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createWsStore } from '$lib/stores/ws.svelte.js'; + +const { notifyMock } = vi.hoisted(() => ({ + notifyMock: vi.fn(), +})); + +vi.mock('$lib/utils/notifications.js', () => ({ + notify: notifyMock, +})); + +const sockets: MockWebSocket[] = []; + +class MockWebSocket { + static OPEN = 1; + static CLOSED = 3; + + readonly url: string; + readyState = MockWebSocket.OPEN; + send = vi.fn(); + close = vi.fn(() => { + this.readyState = MockWebSocket.CLOSED; + }); + onopen: ((event: Event) => void) | null = null; + onmessage: ((event: MessageEvent) => void) | null = null; + onclose: ((event: CloseEvent) => void) | null = null; + onerror: ((event: Event) => void) | null = null; + + constructor(url: string) { + this.url = url; + sockets.push(this); + } +} + +describe('createWsStore', () => { + beforeEach(() => { + sockets.length = 0; + notifyMock.mockReset(); + vi.stubGlobal('WebSocket', MockWebSocket as unknown as typeof WebSocket); + }); + + it.each(['immediate', 'enqueue'] as const)('sends message delivery mode %s', (mode) => { + const store = createWsStore(); + store.connect(); + + store.sendMessage('Ship it', undefined, mode); + + expect(sockets).toHaveLength(1); + expect(sockets[0].send).toHaveBeenCalledWith(JSON.stringify({ + type: 'message', + content: 'Ship it', + mode, + })); + }); + + it('passes custom agents when creating a new session', () => { + const store = createWsStore(); + store.connect(); + const customAgents = [ + { name: 'researcher', prompt: 'Research the codebase', description: 'Research agent' }, + ]; + + store.newSession({ + model: 'gpt-4.1', + customAgents, + }); + + expect(sockets).toHaveLength(1); + expect(sockets[0].send).toHaveBeenCalledWith(JSON.stringify({ + type: 'new_session', + model: 'gpt-4.1', + customAgents, + })); + }); +}); diff --git a/src/lib/types/index.js b/src/lib/types/index.js new file mode 100644 index 0000000..ffd62c5 --- /dev/null +++ b/src/lib/types/index.js @@ -0,0 +1,22 @@ +// ─── Shared enums & constants ─────────────────────────────────────────────── +/** Priority order for picking the most relevant quota snapshot */ +const QUOTA_PRIORITY = ['copilot_premium', 'premium_requests', 'premium_interactions']; +/** Pick the most relevant quota snapshot: premium types first, then any other key */ +export function pickPrimaryQuota(snapshots) { + if (!snapshots) + return null; + const keys = Object.keys(snapshots); + if (keys.length === 0) + return null; + for (const k of QUOTA_PRIORITY) { + if (snapshots[k]) + return { key: k, label: formatQuotaLabel(k), snapshot: snapshots[k] }; + } + // Fallback: first available key + const k = keys[0]; + return { key: k, label: formatQuotaLabel(k), snapshot: snapshots[k] }; +} +function formatQuotaLabel(key) { + return key.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); +} +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/src/lib/types/index.js.map b/src/lib/types/index.js.map new file mode 100644 index 0000000..0505ec9 --- /dev/null +++ b/src/lib/types/index.js.map @@ -0,0 +1 @@ +{"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":"AAAA,+EAA+E;AA4E/E,kEAAkE;AAClE,MAAM,cAAc,GAAG,CAAC,iBAAiB,EAAE,kBAAkB,EAAE,sBAAsB,CAAU,CAAC;AAEhG,qFAAqF;AACrF,MAAM,UAAU,gBAAgB,CAAC,SAAgC;IAC/D,IAAI,CAAC,SAAS;QAAE,OAAO,IAAI,CAAC;IAC5B,MAAM,IAAI,GAAG,MAAM,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;IACpC,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,IAAI,CAAC;IAEnC,KAAK,MAAM,CAAC,IAAI,cAAc,EAAE,CAAC;QAC/B,IAAI,SAAS,CAAC,CAAC,CAAC;YAAE,OAAO,EAAE,GAAG,EAAE,CAAC,EAAE,KAAK,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC;IAC1F,CAAC;IACD,gCAAgC;IAChC,MAAM,CAAC,GAAG,IAAI,CAAC,CAAC,CAAC,CAAC;IAClB,OAAO,EAAE,GAAG,EAAE,CAAC,EAAE,KAAK,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC;AACxE,CAAC;AAED,SAAS,gBAAgB,CAAC,GAAW;IACnC,OAAO,GAAG,CAAC,OAAO,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC,OAAO,CAAC,OAAO,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,WAAW,EAAE,CAAC,CAAC;AACvE,CAAC"} \ No newline at end of file diff --git a/src/lib/types/index.ts b/src/lib/types/index.ts new file mode 100644 index 0000000..e29ee21 --- /dev/null +++ b/src/lib/types/index.ts @@ -0,0 +1,855 @@ +// ─── Shared enums & constants ─────────────────────────────────────────────── + +export type SessionMode = 'interactive' | 'plan' | 'autopilot'; +export type ReasoningEffort = 'low' | 'medium' | 'high' | 'xhigh'; +export type ConnectionState = 'connecting' | 'connected' | 'disconnected' | 'error'; + +// ─── Model types ──────────────────────────────────────────────────────────── + +export interface ModelCapabilities { + limits?: { + max_context_window_tokens?: number; + max_prompt_tokens?: number; + }; + supports?: { + vision?: boolean; + reasoningEffort?: boolean; + }; +} + +export interface ModelInfo { + id: string; + name: string; + billing?: { multiplier: number }; + capabilities?: ModelCapabilities; + defaultReasoningEffort?: ReasoningEffort; + supportedReasoningEfforts?: string[]; +} + +// ─── Custom tool definitions ──────────────────────────────────────────────── + +export interface CustomToolDefinition { + name: string; + description: string; + webhookUrl: string; + method: 'GET' | 'POST'; + headers: Record; + parameters: Record; +} + +// ─── Tool / Agent types ───────────────────────────────────────────────────── + +export interface McpServerDefinition { + name: string; + url: string; + type: 'http' | 'sse'; + headers: Record; + tools: string[]; + enabled: boolean; +} + +export interface ToolInfo { + name: string; + namespacedName?: string; + description?: string; + mcpServerName?: string; +} + +export interface AgentInfo { + name: string; + description?: string; +} + +// ─── Quota types ──────────────────────────────────────────────────────────── + +export interface QuotaSnapshot { + remainingPercentage?: number; + percentageUsed?: number; + resetDate?: string; + usedRequests?: number; + entitlementRequests?: number; + overage?: number; + isUnlimitedEntitlement?: boolean; +} + +export type QuotaSnapshots = Record; + +/** Priority order for picking the most relevant quota snapshot */ +const QUOTA_PRIORITY = ['copilot_premium', 'premium_requests', 'premium_interactions'] as const; + +/** Pick the most relevant quota snapshot: premium types first, then any other key */ +export function pickPrimaryQuota(snapshots: QuotaSnapshots | null): { key: string; label: string; snapshot: QuotaSnapshot } | null { + if (!snapshots) return null; + const keys = Object.keys(snapshots); + if (keys.length === 0) return null; + + for (const k of QUOTA_PRIORITY) { + if (snapshots[k]) return { key: k, label: formatQuotaLabel(k), snapshot: snapshots[k] }; + } + // Fallback: first available key + const k = keys[0]; + return { key: k, label: formatQuotaLabel(k), snapshot: snapshots[k] }; +} + +function formatQuotaLabel(key: string): string { + return key.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); +} + +// ─── Session list types ───────────────────────────────────────────────────── + +export interface SessionSummary { + id: string; + title?: string; + model?: string; + updatedAt?: string; + cwd?: string; + repository?: string; + branch?: string; + checkpointCount?: number; + hasPlan?: boolean; + isRemote?: boolean; + /** Where the session was found: 'sdk' = indexed by Copilot CLI, 'filesystem' = on-disk only (bundled) */ + source?: 'sdk' | 'filesystem'; +} + +export interface CheckpointEntry { + number: number; + title: string; + filename: string; +} + +export interface SessionDetail { + id: string; + cwd?: string; + repository?: string; + branch?: string; + summary?: string; + createdAt?: string; + updatedAt?: string; + checkpoints: CheckpointEntry[]; + plan?: string; + isRemote?: boolean; +} + +// ─── Incoming server messages (discriminated union on `type`) ──────────────── + +export interface ConnectedMessage { + type: 'connected'; + user: string; +} + +export interface SessionCreatedMessage { + type: 'session_created'; + model: string; + sessionId?: string; +} + +export interface SessionReconnectedMessage { + type: 'session_reconnected'; + user: string; + hasSession: boolean; + isProcessing?: boolean; +} + +export interface TurnStartMessage { + type: 'turn_start'; +} + +export interface DeltaMessage { + type: 'delta'; + content: string; +} + +export interface TurnEndMessage { + type: 'turn_end'; +} + +export interface DoneMessage { + type: 'done'; +} + +export interface ReasoningDeltaMessage { + type: 'reasoning_delta'; + content: string; + reasoningId: string; +} + +export interface ReasoningDoneMessage { + type: 'reasoning_done'; + reasoningId: string; + content?: string; +} + +export interface IntentMessage { + type: 'intent'; + intent: string; +} + +export interface ToolStartMessage { + type: 'tool_start'; + toolCallId: string; + toolName: string; + mcpServerName?: string; + mcpToolName?: string; +} + +export interface ToolProgressMessage { + type: 'tool_progress'; + toolCallId: string; + message: string; +} + +export interface ToolEndMessage { + type: 'tool_end'; + toolCallId: string; +} + +export interface ModelsMessage { + type: 'models'; + models: (ModelInfo | string)[]; +} + +export interface ModeChangedMessage { + type: 'mode_changed'; + mode: SessionMode; +} + +export interface ModelChangedMessage { + type: 'model_changed'; + model: string; + source?: 'sdk' | string; +} + +export interface TitleChangedMessage { + type: 'title_changed'; + title: string; +} + +export interface CopilotUsageItem { + type: string; + model?: string; + tokens?: number; + premiumRequests?: number; +} + +export interface UsageMessage { + type: 'usage'; + inputTokens?: number; + outputTokens?: number; + totalTokens?: number; + reasoningTokens?: number; + cacheReadTokens?: number; + cacheWriteTokens?: number; + duration?: number; + cost?: number; + quotaSnapshots?: QuotaSnapshots; + copilotUsage?: CopilotUsageItem[]; +} + +export interface WarningMessage { + type: 'warning'; + message: string; +} + +export interface ErrorMessage { + type: 'error'; + message: string; +} + +export interface AbortedMessage { + type: 'aborted'; +} + +export interface UserInputRequestMessage { + type: 'user_input_request'; + question: string; + choices?: string[]; + allowFreeform: boolean; +} + +export interface PermissionRequestMessage { + type: 'permission_request'; + requestId: string; + kind: string; + toolName: string; + toolArgs: Record; +} + +export interface ToolsMessage { + type: 'tools'; + tools: ToolInfo[]; +} + +export interface AgentsMessage { + type: 'agents'; + agents: (AgentInfo | string)[]; + current: string | null; +} + +export interface AgentChangedMessage { + type: 'agent_changed'; + agent: string | null; +} + +export interface QuotaMessage { + type: 'quota'; + quotaSnapshots?: QuotaSnapshots; +} + +export interface SessionsMessage { + type: 'sessions'; + sessions: SessionSummary[]; +} + +export interface SessionDetailMessage { + type: 'session_detail'; + detail: SessionDetail; +} + +export interface SessionResumedMessage { + type: 'session_resumed'; + sessionId: string; +} + +export interface SessionDeletedMessage { + type: 'session_deleted'; + sessionId: string; +} + +export interface PlanMessage { + type: 'plan'; + exists: boolean; + content?: string; + path?: string; +} + +export interface PlanChangedMessage { + type: 'plan_changed'; + content?: string; + path?: string; +} + +export interface PlanUpdatedMessage { + type: 'plan_updated'; + content?: string; + path?: string; +} + +export interface PlanDeletedMessage { + type: 'plan_deleted'; +} + +export interface CompactionStartMessage { + type: 'compaction_start'; +} + +export interface CompactionCompleteMessage { + type: 'compaction_complete'; + tokensRemoved?: number; + messagesRemoved?: number; + preCompactionTokens?: number; + postCompactionTokens?: number; +} + +export interface CompactionResultMessage { + type: 'compaction_result'; + tokensRemoved?: number; + messagesRemoved?: number; +} + +export interface SkillInvokedMessage { + type: 'skill_invoked'; + skillName: string; +} + +export interface SubagentStartMessage { + type: 'subagent_start'; + agentName: string; + description?: string; +} + +export interface SubagentEndMessage { + type: 'subagent_end'; + agentName: string; +} + +export interface SubagentFailedMessage { + type: 'subagent_failed'; + agentName?: string; + error?: string; +} + +export interface SubagentSelectedMessage { + type: 'subagent_selected'; + agentName: string; +} + +export interface SubagentDeselectedMessage { + type: 'subagent_deselected'; + agentName?: string; +} + +export interface InfoMessage { + type: 'info'; + message: string; +} + +export interface ElicitationRequestedMessage { + type: 'elicitation_requested'; + question: string; + choices?: string[]; + allowFreeform: boolean; +} + +export interface ElicitationCompletedMessage { + type: 'elicitation_completed'; + answer?: string; +} + +export interface ExitPlanModeRequestedMessage { + type: 'exit_plan_mode_requested'; +} + +export interface ExitPlanModeCompletedMessage { + type: 'exit_plan_mode_completed'; +} + +export interface ContextInfoMessage { + type: 'context_info'; + tokenLimit: number; + currentTokens: number; + messagesLength: number; +} + +export interface ReasoningChangedMessage { + type: 'reasoning_changed'; + effort: ReasoningEffort; +} + +export interface SessionShutdownMessage { + type: 'session_shutdown'; + totalPremiumRequests?: number; + totalApiDurationMs?: number; + sessionStartTime?: string; +} + +export interface SessionIdleMessage { + type: 'session_idle'; + backgroundTasks?: { + agents: Array<{ agentId: string; agentType: string }>; + }; +} + +export interface TaskCompleteMessage { + type: 'task_complete'; + summary?: string; +} + +export interface TruncationMessage { + type: 'truncation'; + tokenLimit: number; + preTruncationTokens: number; + preTruncationMessages: number; + postTruncationTokens: number; + postTruncationMessages: number; +} + +export interface ToolPartialResultMessage { + type: 'tool_partial_result'; + toolCallId: string; + partialOutput: string; +} + +export interface ContextChangedMessage { + type: 'context_changed'; + cwd: string; + gitRoot?: string; + repository?: string; + branch?: string; +} + +export interface WorkspaceFileChangedMessage { + type: 'workspace_file_changed'; + path: string; + operation: 'create' | 'update'; +} + +export interface FleetStartedMessage { + type: 'fleet_started'; + started: boolean; +} + +export interface FleetStatusMessage { + type: 'fleet_status'; + agents: Array<{ agentId: string; agentType: string }>; +} + +export interface SessionUsageTotals { + inputTokens: number; + outputTokens: number; + reasoningTokens: number; + cacheReadTokens: number; + cacheWriteTokens: number; + totalCost: number; + totalDurationMs: number; + apiCalls: number; + premiumRequests: number; +} + +export type ServerMessage = + | ConnectedMessage + | SessionCreatedMessage + | SessionReconnectedMessage + | TurnStartMessage + | DeltaMessage + | TurnEndMessage + | DoneMessage + | ReasoningDeltaMessage + | ReasoningDoneMessage + | IntentMessage + | ToolStartMessage + | ToolProgressMessage + | ToolEndMessage + | ModelsMessage + | ModeChangedMessage + | ModelChangedMessage + | TitleChangedMessage + | UsageMessage + | WarningMessage + | ErrorMessage + | AbortedMessage + | UserInputRequestMessage + | PermissionRequestMessage + | ToolsMessage + | AgentsMessage + | AgentChangedMessage + | QuotaMessage + | SessionsMessage + | SessionDetailMessage + | SessionResumedMessage + | SessionDeletedMessage + | PlanMessage + | PlanChangedMessage + | PlanUpdatedMessage + | PlanDeletedMessage + | CompactionStartMessage + | CompactionCompleteMessage + | CompactionResultMessage + | SkillInvokedMessage + | SubagentStartMessage + | SubagentEndMessage + | SubagentFailedMessage + | SubagentSelectedMessage + | SubagentDeselectedMessage + | InfoMessage + | ElicitationRequestedMessage + | ElicitationCompletedMessage + | ExitPlanModeRequestedMessage + | ExitPlanModeCompletedMessage + | ContextInfoMessage + | ReasoningChangedMessage + | SessionShutdownMessage + | SessionIdleMessage + | TaskCompleteMessage + | TruncationMessage + | ToolPartialResultMessage + | ContextChangedMessage + | WorkspaceFileChangedMessage + | FleetStartedMessage + | FleetStatusMessage; + +// ─── File attachment ───────────────────────────────────────────────────────── + +export interface FileAttachment { + path: string; + name: string; + size: number; + type: string; +} + +// ─── Outgoing client messages (discriminated union on `type`) ──────────────── + +export interface NewSessionMessage { + type: 'new_session'; + model: string; + mode?: SessionMode; + reasoningEffort?: ReasoningEffort; + customInstructions?: string; + excludedTools?: string[]; + customTools?: CustomToolDefinition[]; + mcpServers?: McpServerDefinition[]; + disabledSkills?: string[]; + customAgents?: CustomAgentDefinition[]; +} + +export type MessageDeliveryMode = 'immediate' | 'enqueue'; + +export interface SendMessage { + type: 'message'; + content: string; + attachments?: Array<{ path: string; name: string; type: string }>; + mode?: MessageDeliveryMode; +} + +export interface ListModelsMessage { + type: 'list_models'; +} + +export interface SetModeMessage { + type: 'set_mode'; + mode: SessionMode; +} + +export interface AbortClientMessage { + type: 'abort'; +} + +export interface SetModelMessage { + type: 'set_model'; + model: string; +} + +export interface SetReasoningMessage { + type: 'set_reasoning'; + effort: ReasoningEffort; +} + +export interface UserInputResponseMessage { + type: 'user_input_response'; + answer: string; + wasFreeform: boolean; +} + +export interface PermissionResponseMessage { + type: 'permission_response'; + requestId: string; + kind: string; + toolName: string; + decision: 'allow' | 'deny' | 'always_allow' | 'always_deny'; +} + +export interface ListToolsMessage { + type: 'list_tools'; + model?: string; +} + +export interface ListAgentsMessage { + type: 'list_agents'; +} + +export interface SelectAgentMessage { + type: 'select_agent'; + name: string; +} + +export interface DeselectAgentMessage { + type: 'deselect_agent'; +} + +export interface GetQuotaMessage { + type: 'get_quota'; +} + +export interface CompactMessage { + type: 'compact'; +} + +export interface ListSessionsMessage { + type: 'list_sessions'; +} + +export interface ResumeSessionMessage { + type: 'resume_session'; + sessionId: string; +} + +export interface DeleteSessionMessage { + type: 'delete_session'; + sessionId: string; +} + +export interface GetSessionDetailMessage { + type: 'get_session_detail'; + sessionId: string; +} + +export interface GetPlanMessage { + type: 'get_plan'; +} + +export interface UpdatePlanMessage { + type: 'update_plan'; + content: string; +} + +export interface StartFleetMessage { + type: 'start_fleet'; + prompt: string; +} + +export interface DeletePlanMessage { + type: 'delete_plan'; +} + +export type ClientMessage = + | NewSessionMessage + | SendMessage + | ListModelsMessage + | SetModeMessage + | AbortClientMessage + | SetModelMessage + | SetReasoningMessage + | UserInputResponseMessage + | PermissionResponseMessage + | ListToolsMessage + | ListAgentsMessage + | SelectAgentMessage + | DeselectAgentMessage + | GetQuotaMessage + | CompactMessage + | ListSessionsMessage + | ResumeSessionMessage + | DeleteSessionMessage + | GetSessionDetailMessage + | GetPlanMessage + | UpdatePlanMessage + | DeletePlanMessage + | StartFleetMessage; + +// ─── Chat message type for rendering ──────────────────────────────────────── + +export type ChatMessageRole = + | 'user' + | 'assistant' + | 'tool' + | 'info' + | 'warning' + | 'error' + | 'intent' + | 'usage' + | 'skill' + | 'subagent' + | 'fleet' + | 'reasoning'; + +export interface ChatMessage { + id: string; + role: ChatMessageRole; + content: string; + timestamp: number; + toolCallId?: string; + toolName?: string; + toolStatus?: ToolCallStatus; + toolProgressMessage?: string; + toolProgressMessages?: string[]; + mcpServerName?: string; + mcpToolName?: string; + agentName?: string; + skillName?: string; + fleetAgents?: Array<{ agentId: string; agentType: string; status: 'running' | 'completed' | 'failed'; error?: string }>; + inputTokens?: number; + outputTokens?: number; + reasoningTokens?: number; + cacheReadTokens?: number; + cacheWriteTokens?: number; + duration?: number; + cost?: number; + quotaSnapshots?: QuotaSnapshots; + copilotUsage?: CopilotUsageItem[]; +} + +// ─── Tool call tracking ───────────────────────────────────────────────────── + +export type ToolCallStatus = 'running' | 'progress' | 'complete' | 'failed'; + +export interface ToolCallState { + id: string; + name: string; + mcpServerName?: string; + mcpToolName?: string; + status: ToolCallStatus; + message?: string; + progressMessages?: string[]; +} + +// ─── User input request state ─────────────────────────────────────────────── + +export interface UserInputState { + pending: boolean; + question: string; + choices?: string[]; + allowFreeform: boolean; +} + +// ─── Permission request state ─────────────────────────────────────────────── + +export interface PermissionRequestState { + requestId: string; + kind: string; + toolName: string; + toolArgs: Record; +} + +// ─── Context info state ───────────────────────────────────────────────────── + +export interface ContextInfo { + tokenLimit: number; + currentTokens: number; + messagesLength: number; +} + +// ─── Plan state ───────────────────────────────────────────────────────────── + +export interface PlanState { + exists: boolean; + content: string; + path?: string; +} + +// ─── New session configuration ────────────────────────────────────────────── + +export interface NewSessionConfig { + model: string; + mode?: SessionMode; + reasoningEffort?: ReasoningEffort; + customInstructions?: string; + excludedTools?: string[]; + customTools?: CustomToolDefinition[]; + mcpServers?: McpServerDefinition[]; + disabledSkills?: string[]; + customAgents?: CustomAgentDefinition[]; +} + +// ─── Settings (persisted to localStorage) ─────────────────────────────────── + +export interface PersistedSettings { + model: string; + mode: SessionMode; + reasoningEffort: ReasoningEffort; + customInstructions: string; + excludedTools: string[]; + customTools: CustomToolDefinition[]; + mcpServers?: McpServerDefinition[]; + disabledSkills?: string[]; + customAgents?: CustomAgentDefinition[]; +} + +// ─── Custom agent definitions ─────────────────────────────────────────────── + +export interface CustomAgentDefinition { + name: string; + displayName?: string; + description?: string; + tools?: string[]; + prompt: string; +} + +// ─── Skill definitions ────────────────────────────────────────────────────── + +export interface SkillDefinition { + name: string; + description: string; + directory: string; + license?: string; + allowedTools?: string; +} diff --git a/src/lib/utils/markdown.test.ts b/src/lib/utils/markdown.test.ts new file mode 100644 index 0000000..905fdd1 --- /dev/null +++ b/src/lib/utils/markdown.test.ts @@ -0,0 +1,191 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import hljs from 'highlight.js/lib/core'; +import { addCopyButtons, highlightCodeBlocks, renderMarkdown } from './markdown'; + +function renderIntoContainer(markdown: string): HTMLDivElement { + const container = document.createElement('div'); + container.innerHTML = renderMarkdown(markdown); + return container; +} + +describe('renderMarkdown', () => { + it('renders headings, emphasis, links, and paragraphs', () => { + const html = renderMarkdown('# Title\n\n**bold** *italic* [Docs](https://example.com)'); + + expect(html).toContain('

Title

'); + expect(html).toContain('bold'); + expect(html).toContain('italic'); + expect(html).toContain('Docs'); + }); + + it('renders unordered lists', () => { + const html = renderMarkdown('- first\n- second\n- third'); + + expect(html).toContain('
    '); + expect(html).toContain('
  • first
  • '); + expect(html).toContain('
  • second
  • '); + expect(html).toContain('
  • third
  • '); + }); + + it('converts single line breaks into br tags', () => { + const html = renderMarkdown('line one\nline two'); + + expect(html).toContain('

    line one
    line two

    '); + }); + + it('removes script tags from rendered HTML', () => { + const html = renderMarkdown('safe'); + + expect(html).not.toContain(' { + const html = renderMarkdown(''); + + expect(html).toContain(''); + expect(html).not.toContain('onerror'); + }); + + it('strips javascript URLs from links while preserving text', () => { + const container = renderIntoContainer('[click me](javascript:alert(1))'); + const link = container.querySelector('a'); + + expect(link).not.toBeNull(); + expect(link).not.toHaveAttribute('href'); + expect(link).toHaveTextContent('click me'); + }); + + it('preserves highlight span tags and attributes allowed by the sanitizer', () => { + const html = renderMarkdown('const'); + + expect(html).toContain('const'); + }); + + it('returns an empty string for empty input', () => { + expect(renderMarkdown('')).toBe(''); + }); + + it('throws for null input', () => { + expect(() => renderMarkdown(null as unknown as string)).toThrow(/input parameter is undefined or null/i); + }); + + it('throws for undefined input', () => { + expect(() => renderMarkdown(undefined as unknown as string)).toThrow(/input parameter is undefined or null/i); + }); + + it('renders very long strings without truncating content', () => { + const input = 'hello '.repeat(4000).trim(); + const html = renderMarkdown(input); + + expect(html.startsWith('

    hello hello')).toBe(true); + expect(html).toContain('hello hello hello'); + expect(html.length).toBeGreaterThan(input.length); + }); + + it('escapes special HTML characters in markdown text', () => { + const html = renderMarkdown('& < >'); + + expect(html).toContain('& < >'); + }); +}); + +describe('highlightCodeBlocks', () => { + it('highlights code blocks with an explicit language', () => { + const container = renderIntoContainer('```javascript\nconst value = 1;\n```'); + const code = container.querySelector('pre code') as HTMLElement; + + highlightCodeBlocks(container); + + expect(code.classList.contains('hljs')).toBe(true); + expect(code.classList.contains('language-javascript')).toBe(true); + expect(code.dataset.highlighted).toBe('true'); + expect(code.innerHTML).toContain('hljs-keyword'); + }); + + it('highlights code blocks even when no language is specified', () => { + const container = renderIntoContainer('```\nconst value = 1;\n```'); + const code = container.querySelector('pre code') as HTMLElement; + + highlightCodeBlocks(container); + + expect(code.classList.contains('hljs')).toBe(true); + expect(code.dataset.highlighted).toBe('true'); + expect(code.innerHTML).toMatch(/hljs-[\w-]+/); + }); + + it('skips blocks that were already highlighted', () => { + const container = document.createElement('div'); + container.innerHTML = '

    const value = 1;
    '; + const spy = vi.spyOn(hljs, 'highlightElement'); + + highlightCodeBlocks(container); + + expect(spy).not.toHaveBeenCalled(); + }); + + it('swallows highlighting errors and still marks the block as processed', () => { + const container = renderIntoContainer('```javascript\nconst value = 1;\n```'); + const code = container.querySelector('pre code') as HTMLElement; + vi.spyOn(hljs, 'highlightElement').mockImplementation(() => { + throw new Error('highlight failed'); + }); + + expect(() => highlightCodeBlocks(container)).not.toThrow(); + expect(code.dataset.highlighted).toBe('true'); + }); +}); + +describe('addCopyButtons', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + document.body.innerHTML = ''; + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('adds a copy button to each code block container', () => { + const container = renderIntoContainer('```bash\necho hello\n```\n\n```javascript\nconst value = 1;\n```'); + + addCopyButtons(container); + + expect(container.querySelectorAll('.copy-btn')).toHaveLength(2); + container.querySelectorAll('pre').forEach((pre) => { + expect(pre).toHaveStyle({ position: 'relative' }); + }); + }); + + it('does not add duplicate copy buttons when called twice', () => { + const container = renderIntoContainer('```bash\necho hello\n```'); + + addCopyButtons(container); + addCopyButtons(container); + + expect(container.querySelectorAll('.copy-btn')).toHaveLength(1); + }); + + it('copies code content and resets the button label after success', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }); + + const container = renderIntoContainer('```bash\necho hello\n```'); + const code = container.querySelector('code'); + addCopyButtons(container); + const button = container.querySelector('.copy-btn') as HTMLButtonElement; + + button.click(); + await Promise.resolve(); + + expect(writeText).toHaveBeenCalledWith(code?.textContent ?? ''); + expect(button).toHaveTextContent('Copied!'); + + await vi.advanceTimersByTimeAsync(1500); + expect(button).toHaveTextContent('Copy'); + }); +}); diff --git a/src/lib/utils/markdown.ts b/src/lib/utils/markdown.ts new file mode 100644 index 0000000..cb93798 --- /dev/null +++ b/src/lib/utils/markdown.ts @@ -0,0 +1,97 @@ +import { Marked } from 'marked'; +import DOMPurify from 'dompurify'; +import hljs from 'highlight.js/lib/core'; + +// Register only the languages commonly seen in Copilot CLI conversations +import bash from 'highlight.js/lib/languages/bash'; +import css from 'highlight.js/lib/languages/css'; +import diff from 'highlight.js/lib/languages/diff'; +import dockerfile from 'highlight.js/lib/languages/dockerfile'; +import go from 'highlight.js/lib/languages/go'; +import graphql from 'highlight.js/lib/languages/graphql'; +import java from 'highlight.js/lib/languages/java'; +import javascript from 'highlight.js/lib/languages/javascript'; +import json from 'highlight.js/lib/languages/json'; +import kotlin from 'highlight.js/lib/languages/kotlin'; +import markdown from 'highlight.js/lib/languages/markdown'; +import plaintext from 'highlight.js/lib/languages/plaintext'; +import python from 'highlight.js/lib/languages/python'; +import ruby from 'highlight.js/lib/languages/ruby'; +import rust from 'highlight.js/lib/languages/rust'; +import shell from 'highlight.js/lib/languages/shell'; +import sql from 'highlight.js/lib/languages/sql'; +import swift from 'highlight.js/lib/languages/swift'; +import typescript from 'highlight.js/lib/languages/typescript'; +import xml from 'highlight.js/lib/languages/xml'; +import yaml from 'highlight.js/lib/languages/yaml'; + +hljs.registerLanguage('bash', bash); +hljs.registerLanguage('css', css); +hljs.registerLanguage('diff', diff); +hljs.registerLanguage('dockerfile', dockerfile); +hljs.registerLanguage('go', go); +hljs.registerLanguage('graphql', graphql); +hljs.registerLanguage('java', java); +hljs.registerLanguage('javascript', javascript); +hljs.registerLanguage('json', json); +hljs.registerLanguage('kotlin', kotlin); +hljs.registerLanguage('markdown', markdown); +hljs.registerLanguage('plaintext', plaintext); +hljs.registerLanguage('python', python); +hljs.registerLanguage('ruby', ruby); +hljs.registerLanguage('rust', rust); +hljs.registerLanguage('shell', shell); +hljs.registerLanguage('sql', sql); +hljs.registerLanguage('swift', swift); +hljs.registerLanguage('typescript', typescript); +hljs.registerLanguage('xml', xml); +hljs.registerLanguage('yaml', yaml); + +const marked = new Marked({ + gfm: true, + breaks: true, +}); + +export function renderMarkdown(content: string): string { + const rawHtml = marked.parse(content) as string; + return DOMPurify.sanitize(rawHtml, { + ADD_TAGS: ['span'], + ADD_ATTR: ['class', 'data-highlighted'], + }); +} + +export function highlightCodeBlocks(container: HTMLElement): void { + container.querySelectorAll('pre code').forEach((block) => { + if (block instanceof HTMLElement && !block.dataset.highlighted) { + try { + hljs.highlightElement(block); + } catch { + /* ignore highlight errors */ + } + block.dataset.highlighted = 'true'; + } + }); +} + +export function addCopyButtons(container: HTMLElement): void { + container.querySelectorAll('pre').forEach((pre) => { + if (pre.querySelector('.copy-btn')) return; + + const btn = document.createElement('button'); + btn.className = 'copy-btn'; + btn.textContent = 'Copy'; + btn.addEventListener('click', () => { + const code = pre.querySelector('code'); + const text = code ? code.textContent ?? '' : pre.textContent ?? ''; + navigator.clipboard.writeText(text).then(() => { + btn.textContent = 'Copied!'; + setTimeout(() => { + btn.textContent = 'Copy'; + }, 1500); + }); + }); + + pre.style.position = 'relative'; + pre.appendChild(btn); + }); +} diff --git a/src/lib/utils/notifications.test.ts b/src/lib/utils/notifications.test.ts new file mode 100644 index 0000000..d43aca3 --- /dev/null +++ b/src/lib/utils/notifications.test.ts @@ -0,0 +1,110 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { notify } from '$lib/utils/notifications'; + +interface MockNotificationInstance { + onclick: (() => void) | null; + close: ReturnType; +} + +interface MockNotificationConstructor { + new (title: string, options?: NotificationOptions): MockNotificationInstance; + permission: NotificationPermission; + requestPermission: ReturnType; +} + +const focusSpy = vi.fn(); + +function setDocumentHidden(hidden: boolean): void { + Object.defineProperty(document, 'hidden', { + configurable: true, + value: hidden, + }); +} + +function installNotificationMock(permission: NotificationPermission): { + Notification: MockNotificationConstructor; + instances: MockNotificationInstance[]; +} { + const instances: MockNotificationInstance[] = []; + + const NotificationMock = vi.fn(function MockNotification(this: MockNotificationInstance) { + this.onclick = null; + this.close = vi.fn(); + instances.push(this); + }) as unknown as MockNotificationConstructor; + + NotificationMock.permission = permission; + NotificationMock.requestPermission = vi.fn(async () => NotificationMock.permission); + + Object.defineProperty(globalThis, 'Notification', { + configurable: true, + value: NotificationMock, + }); + + return { Notification: NotificationMock, instances }; +} + +beforeEach(() => { + setDocumentHidden(false); + focusSpy.mockReset(); + Object.defineProperty(window, 'focus', { + configurable: true, + value: focusSpy, + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('notify', () => { + it('does nothing when the document is visible', () => { + setDocumentHidden(false); + const { Notification } = installNotificationMock('granted'); + + notify('Visible tab'); + + expect(Notification).not.toHaveBeenCalled(); + }); + + it('fires a notification when permission is granted and the tab is hidden', () => { + setDocumentHidden(true); + const { Notification, instances } = installNotificationMock('granted'); + + notify('Build finished', { body: 'All checks passed', tag: 'build', requireInteraction: true }); + + expect(Notification).toHaveBeenCalledWith('Build finished', { + body: 'All checks passed', + icon: '/favicon.png', + tag: 'build', + requireInteraction: true, + }); + expect(instances).toHaveLength(1); + + instances[0].onclick?.(); + expect(focusSpy).toHaveBeenCalledTimes(1); + expect(instances[0].close).toHaveBeenCalledTimes(1); + }); + + it('requests permission lazily and fires when permission is granted', async () => { + setDocumentHidden(true); + const { Notification } = installNotificationMock('default'); + Notification.requestPermission.mockResolvedValueOnce('granted'); + + notify('Approval needed'); + await vi.waitFor(() => { + expect(Notification.requestPermission).toHaveBeenCalledTimes(1); + expect(Notification).toHaveBeenCalledTimes(1); + }); + }); + + it('does nothing when permission is denied', () => { + setDocumentHidden(true); + const { Notification } = installNotificationMock('denied'); + + notify('Denied'); + + expect(Notification.requestPermission).not.toHaveBeenCalled(); + expect(Notification).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/utils/notifications.ts b/src/lib/utils/notifications.ts new file mode 100644 index 0000000..f8c8859 --- /dev/null +++ b/src/lib/utils/notifications.ts @@ -0,0 +1,50 @@ +/** + * Thin wrapper around the Web Notifications API. + * + * Rules: + * - Only fires when `document.hidden === true` (tab is not visible). + * - Requests permission lazily on the first trigger, never on page load. + * - Silently no-ops when `Notification.permission === 'denied'`. + * - Deduplicates with `tag` so rapid events don't stack. + * - Clicking any notification focuses the tab and closes the notification. + */ + +export interface NotifyOptions { + body?: string; + tag?: string; + /** `true` for blocking events (approval, user input); `false` for informational ones. */ + requireInteraction?: boolean; +} + +function fireNotification(title: string, opts: NotifyOptions): void { + const notif = new Notification(title, { + body: opts.body, + icon: '/favicon.png', + tag: opts.tag, + requireInteraction: opts.requireInteraction ?? false, + }); + notif.onclick = () => { + window.focus(); + notif.close(); + }; +} + +/** + * Show a browser notification if the tab is hidden and permission allows it. + * Permission is requested lazily on the first call when status is `'default'`. + */ +export function notify(title: string, opts: NotifyOptions = {}): void { + if (typeof document === 'undefined' || typeof Notification === 'undefined') return; + if (!document.hidden) return; + + if (Notification.permission === 'granted') { + fireNotification(title, opts); + } else if (Notification.permission === 'default') { + Notification.requestPermission().then((perm) => { + if (perm === 'granted') { + fireNotification(title, opts); + } + }); + } + // 'denied' → silently no-op +} diff --git a/src/lib/utils/smoke.test.ts b/src/lib/utils/smoke.test.ts new file mode 100644 index 0000000..ddd6a70 --- /dev/null +++ b/src/lib/utils/smoke.test.ts @@ -0,0 +1,7 @@ +import { describe, it, expect } from 'vitest'; + +describe('smoke test', () => { + it('vitest is configured correctly', () => { + expect(1 + 1).toBe(2); + }); +}); diff --git a/src/routes/+error.svelte b/src/routes/+error.svelte new file mode 100644 index 0000000..33581a9 --- /dev/null +++ b/src/routes/+error.svelte @@ -0,0 +1,25 @@ + + +
    +

    {$page.status}

    +

    {$page.error?.message ?? 'Something went wrong'}

    + Return home +
    + + diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts new file mode 100644 index 0000000..176858e --- /dev/null +++ b/src/routes/+layout.server.ts @@ -0,0 +1,12 @@ +import type { LayoutServerLoad } from './$types'; +import { checkAuth } from '$lib/server/auth/guard'; + +export const load: LayoutServerLoad = async ({ locals }) => { + console.log(`[LAYOUT-LOAD] locals.session exists=${!!locals.session} hasToken=${!!locals.session?.githubToken} user=${locals.session?.githubUser?.login ?? 'none'}`); + const auth = checkAuth(locals.session); + console.log(`[LAYOUT-LOAD] checkAuth result: authenticated=${auth.authenticated} user=${auth.user?.login ?? 'none'} error=${auth.error ?? 'none'}`); + return { + authenticated: auth.authenticated, + user: auth.user, + }; +}; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte new file mode 100644 index 0000000..454f7b8 --- /dev/null +++ b/src/routes/+layout.svelte @@ -0,0 +1,6 @@ + + +{@render children()} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte new file mode 100644 index 0000000..abb1b25 --- /dev/null +++ b/src/routes/+page.svelte @@ -0,0 +1,423 @@ + + + + {chatStore.sessionTitle ? `${chatStore.sessionTitle} — Copilot Unleashed` : 'Copilot Unleashed'} + + +{#if data.authenticated} +
    + sidebarOpen = true} + onOpenModelSheet={() => modelSheetOpen = true} + onNewChat={handleNewChat} + /> + +
    + {#if chatStore.plan.exists} + wsStore.updatePlan(content)} + onDeletePlan={() => wsStore.deletePlan()} + /> + {/if} + + + {#if chatStore.messages.length === 0} + + {/if} + + + + {#if chatStore.pendingPermission} + + {/if} + + wsStore.abort()} + onSetMode={handleSetMode} + onUserInputResponse={handleUserInputResponse} + onFleet={(prompt) => { + chatStore.addUserMessage(`/fleet ${prompt}`); + wsStore.send({ type: 'start_fleet', prompt }); + }} + /> +
    + + sidebarOpen = false} + onNewChat={handleNewChat} + onOpenSessions={handleOpenSessions} + onOpenSettings={handleOpenSettings} + onLogout={handleLogout} + /> + + modelSheetOpen = false} + /> + + settingsOpen = false} + onSaveInstructions={(v) => { settings.customInstructions = v; }} + onToggleTool={(name, enabled) => { + if (enabled) { + settings.excludedTools = settings.excludedTools.filter(t => t !== name); + } else { + settings.excludedTools = [...settings.excludedTools, name]; + } + }} + onSaveCustomTools={(tools) => { settings.customTools = tools; }} + onSaveCustomAgents={(agents) => { settings.customAgents = agents; }} + onSaveMcpServers={(servers) => { settings.mcpServers = servers; }} + onToggleSkill={handleToggleSkill} + onSelectAgent={(name) => wsStore.selectAgent(name)} + onDeselectAgent={() => wsStore.deselectAgent()} + onCompact={() => wsStore.compact()} + onFetchTools={() => wsStore.listTools(chatStore.currentModel)} + onFetchAgents={() => wsStore.listAgents()} + onFetchQuota={() => wsStore.getQuota()} + onFetchSkills={() => settings.fetchSkills()} + /> + + sessionsOpen = false} + onResume={handleResumeSession} + onDelete={(id) => wsStore.deleteSession(id)} + onRequestDetail={(id) => wsStore.getSessionDetail(id)} + /> +
    +{:else} + +{/if} + + diff --git a/src/routes/api/client-error/+server.ts b/src/routes/api/client-error/+server.ts new file mode 100644 index 0000000..540ac4c --- /dev/null +++ b/src/routes/api/client-error/+server.ts @@ -0,0 +1,21 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { logSecurity } from '$lib/server/security-log'; + +export const POST: RequestHandler = async ({ request }) => { + try { + const body = await request.json(); + const { message, source, lineno, colno, stack, type } = body ?? {}; + logSecurity('warn', 'browser_error', { + type: String(type || 'error').slice(0, 50), + message: String(message || '').slice(0, 500), + source: String(source || '').slice(0, 200), + lineno: Number(lineno) || 0, + colno: Number(colno) || 0, + stack: String(stack || '').slice(0, 2000), + }); + } catch { + // Ignore malformed request bodies + } + return new Response(null, { status: 204 }); +}; diff --git a/src/routes/api/client-error/server.test.ts b/src/routes/api/client-error/server.test.ts new file mode 100644 index 0000000..af606a9 --- /dev/null +++ b/src/routes/api/client-error/server.test.ts @@ -0,0 +1,114 @@ +// @vitest-environment node +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('$lib/server/security-log', () => ({ + logSecurity: vi.fn(), +})); + +import { POST } from './+server'; +import { logSecurity } from '$lib/server/security-log'; + +function createEvent(request: Request) { + return { request } as any; +} + +describe('POST /api/client-error', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('accepts valid client error reports', async () => { + const request = new Request('http://localhost/api/client-error', { + method: 'POST', + body: JSON.stringify({ message: 'Boom', source: '/app.js', lineno: 12, colno: 7, stack: 'stack', type: 'error' }), + headers: { 'content-type': 'application/json' }, + }); + + const response = await POST(createEvent(request)); + + expect(response.status).toBe(204); + expect(logSecurity).toHaveBeenCalledWith('warn', 'browser_error', expect.objectContaining({ + type: 'error', + message: 'Boom', + source: '/app.js', + lineno: 12, + colno: 7, + stack: 'stack', + })); + }); + + it('truncates oversized string fields', async () => { + const message = 'm'.repeat(600); + const source = 's'.repeat(300); + const stack = 'k'.repeat(2500); + const type = 't'.repeat(80); + const request = new Request('http://localhost/api/client-error', { + method: 'POST', + body: JSON.stringify({ message, source, lineno: 1, colno: 2, stack, type }), + headers: { 'content-type': 'application/json' }, + }); + + await POST(createEvent(request)); + + expect(logSecurity).toHaveBeenCalledWith('warn', 'browser_error', { + type: type.slice(0, 50), + message: message.slice(0, 500), + source: source.slice(0, 200), + lineno: 1, + colno: 2, + stack: stack.slice(0, 2000), + }); + }); + + it('defaults missing fields to safe values', async () => { + const request = new Request('http://localhost/api/client-error', { + method: 'POST', + body: JSON.stringify({}), + headers: { 'content-type': 'application/json' }, + }); + + await POST(createEvent(request)); + + expect(logSecurity).toHaveBeenCalledWith('warn', 'browser_error', { + type: 'error', + message: '', + source: '', + lineno: 0, + colno: 0, + stack: '', + }); + }); + + it('coerces malformed scalar fields without failing', async () => { + const request = new Request('http://localhost/api/client-error', { + method: 'POST', + body: JSON.stringify({ message: 42, source: true, lineno: '7', colno: 'abc', stack: null, type: false }), + headers: { 'content-type': 'application/json' }, + }); + + const response = await POST(createEvent(request)); + + expect(response.status).toBe(204); + expect(logSecurity).toHaveBeenCalledWith('warn', 'browser_error', { + type: 'error', + message: '42', + source: 'true', + lineno: 7, + colno: 0, + stack: '', + }); + }); + + it('ignores malformed JSON bodies', async () => { + const request = new Request('http://localhost/api/client-error', { + method: 'POST', + body: '{bad json', + headers: { 'content-type': 'application/json' }, + }); + + const response = await POST(createEvent(request)); + + expect(response.status).toBe(204); + expect(logSecurity).not.toHaveBeenCalled(); + }); +}); diff --git a/src/routes/api/models/+server.ts b/src/routes/api/models/+server.ts new file mode 100644 index 0000000..e5c79ba --- /dev/null +++ b/src/routes/api/models/+server.ts @@ -0,0 +1,24 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { checkAuth } from '$lib/server/auth/guard'; +import { createCopilotClient } from '$lib/server/copilot/client'; +import { getAvailableModels } from '$lib/server/copilot/session'; + +export const GET: RequestHandler = async ({ locals }) => { + const auth = checkAuth(locals.session); + if (!auth.authenticated) { + return json({ error: auth.error }, { status: 401 }); + } + + try { + const client = createCopilotClient(locals.session!.githubToken!); + const models = await getAvailableModels(client); + const modelArray = Array.isArray(models) ? models : []; + await client.stop(); + return json({ models: modelArray }); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Unknown error'; + console.error('Models error:', message); + return json({ error: 'Failed to list models', models: [] }, { status: 500 }); + } +}; diff --git a/src/routes/api/models/server.test.ts b/src/routes/api/models/server.test.ts new file mode 100644 index 0000000..0db6c3c --- /dev/null +++ b/src/routes/api/models/server.test.ts @@ -0,0 +1,103 @@ +// @vitest-environment node +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('$lib/server/auth/guard', () => ({ + checkAuth: vi.fn(), +})); + +vi.mock('$lib/server/copilot/client', () => ({ + createCopilotClient: vi.fn(), +})); + +vi.mock('$lib/server/copilot/session', () => ({ + getAvailableModels: vi.fn(), +})); + +import { GET } from './+server'; +import { checkAuth } from '$lib/server/auth/guard'; +import { createCopilotClient } from '$lib/server/copilot/client'; +import { getAvailableModels } from '$lib/server/copilot/session'; + +type MockSession = { + githubToken?: string; + githubUser?: { login: string; name: string }; + githubAuthTime?: number; + save: (callback: (err?: Error) => void) => void; + destroy: (callback: (err?: Error) => void) => void; +}; + +function createEvent(session?: MockSession) { + return { + locals: { session }, + } as any; +} + +function createSession(overrides: Partial = {}): MockSession { + return { + save: vi.fn((callback: (err?: Error) => void) => callback()), + destroy: vi.fn((callback: (err?: Error) => void) => callback()), + ...overrides, + }; +} + +describe('GET /api/models', () => { + const stop = vi.fn(async () => undefined); + + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + vi.mocked(checkAuth).mockReturnValue({ + authenticated: true, + user: { login: 'octocat', name: 'Octocat' }, + }); + vi.mocked(createCopilotClient).mockReturnValue({ stop } as never); + vi.mocked(getAvailableModels).mockResolvedValue([{ id: 'gpt-4.1' }] as never); + }); + + it('rejects unauthenticated requests', async () => { + vi.mocked(checkAuth).mockReturnValue({ + authenticated: false, + user: null, + error: 'GitHub authentication required', + }); + + const response = await GET(createEvent()); + + expect(response.status).toBe(401); + expect(await response.json()).toEqual({ error: 'GitHub authentication required' }); + expect(createCopilotClient).not.toHaveBeenCalled(); + }); + + it('returns the available models for authenticated users', async () => { + const response = await GET(createEvent(createSession({ githubToken: 'token' }))); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ models: [{ id: 'gpt-4.1' }] }); + expect(createCopilotClient).toHaveBeenCalledWith('token'); + }); + + it('normalizes non-array model payloads to an empty array', async () => { + vi.mocked(getAvailableModels).mockResolvedValue({ data: 'invalid' } as never); + + const response = await GET(createEvent(createSession({ githubToken: 'token' }))); + + expect(await response.json()).toEqual({ models: [] }); + }); + + it('stops the Copilot client after a successful request', async () => { + await GET(createEvent(createSession({ githubToken: 'token' }))); + + expect(stop).toHaveBeenCalledTimes(1); + }); + + it('returns 500 when listing models fails', async () => { + vi.mocked(createCopilotClient).mockImplementation(() => { + throw new Error('boom'); + }); + + const response = await GET(createEvent(createSession({ githubToken: 'token' }))); + + expect(response.status).toBe(500); + expect(await response.json()).toEqual({ error: 'Failed to list models', models: [] }); + }); +}); diff --git a/src/routes/api/sessions/sync/+server.ts b/src/routes/api/sessions/sync/+server.ts new file mode 100644 index 0000000..82f2a40 --- /dev/null +++ b/src/routes/api/sessions/sync/+server.ts @@ -0,0 +1,161 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { validateGitHubToken } from '$lib/server/auth/github.js'; +import { config } from '$lib/server/config.js'; +import { getSessionStateDir, listSessionsFromFilesystem } from '$lib/server/copilot/session-metadata.js'; +import { writeFile, mkdir, readdir, stat } from 'node:fs/promises'; +import { join } from 'node:path'; + +const MAX_UPLOAD_SIZE = 50 * 1024 * 1024; // 50MB total per request + +/** + * Validate a Bearer token from the Authorization header. + * Returns the authenticated GitHub username or throws a 401. + */ +async function authenticateBearer(request: Request): Promise { + const authHeader = request.headers.get('authorization'); + if (!authHeader?.startsWith('Bearer ')) { + throw error(401, 'Missing or invalid Authorization header'); + } + + const token = authHeader.slice(7).trim(); + if (!token) { + throw error(401, 'Empty bearer token'); + } + + const result = await validateGitHubToken(token); + if (!result.valid) { + throw error(401, 'Invalid or expired GitHub token'); + } + + const login = result.user.login.toLowerCase(); + + // Check against ALLOWED_GITHUB_USERS if configured + if (config.allowedUsers.length > 0 && !config.allowedUsers.includes(login)) { + throw error(403, 'User not in allowed list'); + } + + return login; +} + +/** + * GET /api/sessions/sync + * + * Returns the list of session IDs and their last-updated timestamps + * that the remote instance knows about. Used by the sync script to + * compute deltas. + */ +export const GET: RequestHandler = async ({ request }) => { + const user = await authenticateBearer(request); + console.log(`[SYNC] GET from user=${user}`); + + const sessions = await listSessionsFromFilesystem(); + + const summary = sessions.map((s) => ({ + id: s.id, + updatedAt: s.updatedAt, + title: s.title, + repository: s.repository, + branch: s.branch, + })); + + return json({ sessions: summary }); +}; + +/** + * POST /api/sessions/sync + * + * Accepts session data and writes it to the session-state directory. + * Body: JSON with { sessions: Array<{ id, files: Record }> } + * + * Each session's files are written under session-state/{id}/. + * Paths are relative to the session directory (e.g., "workspace.yaml", + * "checkpoints/index.md"). Path traversal is rejected. + */ +export const POST: RequestHandler = async ({ request }) => { + const user = await authenticateBearer(request); + console.log(`[SYNC] POST from user=${user}`); + + // Validate content length + const contentLength = parseInt(request.headers.get('content-length') || '0', 10); + if (contentLength > MAX_UPLOAD_SIZE) { + return error(413, `Payload too large (max ${MAX_UPLOAD_SIZE / 1024 / 1024}MB)`); + } + + let body: { sessions: Array<{ id: string; files: Record }> }; + try { + body = await request.json(); + } catch { + return error(400, 'Invalid JSON body'); + } + + if (!Array.isArray(body.sessions)) { + return error(400, 'Missing sessions array'); + } + + const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + const stateDir = getSessionStateDir(); + const results: Array<{ id: string; status: 'created' | 'updated' | 'error'; error?: string }> = []; + + for (const session of body.sessions) { + if (!session.id || !UUID_RE.test(session.id)) { + results.push({ id: session.id || 'unknown', status: 'error', error: 'Invalid session ID' }); + continue; + } + + if (!session.files || typeof session.files !== 'object') { + results.push({ id: session.id, status: 'error', error: 'Missing files object' }); + continue; + } + + const sessionDir = join(stateDir, session.id); + + // Check if session already exists + let exists = false; + try { + const info = await stat(sessionDir); + exists = info.isDirectory(); + } catch { + // Doesn't exist — will create + } + + try { + await mkdir(sessionDir, { recursive: true }); + + for (const [relativePath, content] of Object.entries(session.files)) { + // Validate path: no traversal, no absolute paths + if (relativePath.includes('..') || relativePath.startsWith('/') || relativePath.includes('\\')) { + console.warn(`[SYNC] Rejected path traversal: ${relativePath} in session ${session.id}`); + continue; + } + + const fullPath = join(sessionDir, relativePath); + // Double-check resolved path is inside session dir + if (!fullPath.startsWith(sessionDir)) { + console.warn(`[SYNC] Path escaped session dir: ${fullPath}`); + continue; + } + + // Create subdirectories if needed + const dir = join(fullPath, '..'); + await mkdir(dir, { recursive: true }); + + await writeFile(fullPath, content, 'utf-8'); + } + + results.push({ id: session.id, status: exists ? 'updated' : 'created' }); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Unknown error'; + console.error(`[SYNC] Error writing session ${session.id}:`, message); + results.push({ id: session.id, status: 'error', error: message }); + } + } + + const created = results.filter((r) => r.status === 'created').length; + const updated = results.filter((r) => r.status === 'updated').length; + const errors = results.filter((r) => r.status === 'error').length; + + console.log(`[SYNC] Complete: ${created} created, ${updated} updated, ${errors} errors`); + + return json({ results, summary: { created, updated, errors } }); +}; diff --git a/src/routes/api/sessions/sync/server.test.ts b/src/routes/api/sessions/sync/server.test.ts new file mode 100644 index 0000000..0c0dd2f --- /dev/null +++ b/src/routes/api/sessions/sync/server.test.ts @@ -0,0 +1,256 @@ +// @vitest-environment node +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('$lib/server/auth/github.js', () => ({ + validateGitHubToken: vi.fn(), +})); + +vi.mock('$lib/server/config.js', () => ({ + config: { + allowedUsers: [] as string[], + }, +})); + +vi.mock('$lib/server/copilot/session-metadata.js', () => ({ + getSessionStateDir: vi.fn(() => '/tmp/session-state'), + listSessionsFromFilesystem: vi.fn(), +})); + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + default: actual, + mkdir: vi.fn(async () => undefined), + readdir: vi.fn(async () => []), + stat: vi.fn(async () => { + throw new Error('ENOENT'); + }), + writeFile: vi.fn(async () => undefined), + }; +}); + +import { GET, POST } from './+server'; +import { validateGitHubToken } from '$lib/server/auth/github.js'; +import { config } from '$lib/server/config.js'; +import { listSessionsFromFilesystem } from '$lib/server/copilot/session-metadata.js'; +import { mkdir, stat, writeFile } from 'node:fs/promises'; + +function createEvent(request: Request) { + return { + request, + url: new URL(request.url), + } as any; +} + +describe('/api/sessions/sync', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + config.allowedUsers = []; + vi.mocked(validateGitHubToken).mockResolvedValue({ + valid: true, + user: { login: 'octocat', name: 'Octocat' }, + }); + }); + + it('GET rejects missing bearer tokens', async () => { + const request = new Request('http://localhost/api/sessions/sync', { method: 'GET' }); + + await expect(GET(createEvent(request))).rejects.toMatchObject({ + status: 401, + body: { message: 'Missing or invalid Authorization header' }, + }); + }); + + it('GET rejects invalid bearer tokens', async () => { + vi.mocked(validateGitHubToken).mockResolvedValue({ valid: false, reason: 'invalid_token' }); + const request = new Request('http://localhost/api/sessions/sync', { + method: 'GET', + headers: { authorization: 'Bearer bad-token' }, + }); + + await expect(GET(createEvent(request))).rejects.toMatchObject({ + status: 401, + body: { message: 'Invalid or expired GitHub token' }, + }); + }); + + it('GET enforces the allowed user list when configured', async () => { + config.allowedUsers = ['hubot']; + const request = new Request('http://localhost/api/sessions/sync', { + method: 'GET', + headers: { authorization: 'Bearer good-token' }, + }); + + await expect(GET(createEvent(request))).rejects.toMatchObject({ + status: 403, + body: { message: 'User not in allowed list' }, + }); + }); + + it('GET returns session summaries for authenticated users', async () => { + vi.mocked(listSessionsFromFilesystem).mockResolvedValue([ + { + id: '11111111-1111-1111-1111-111111111111', + updatedAt: '2024-01-01T00:00:00Z', + title: 'Demo session', + repository: 'owner/repo', + branch: 'main', + checkpointCount: 2, + hasPlan: true, + }, + ] as never); + const request = new Request('http://localhost/api/sessions/sync', { + method: 'GET', + headers: { authorization: 'Bearer good-token' }, + }); + + const response = await GET(createEvent(request)); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + sessions: [ + { + id: '11111111-1111-1111-1111-111111111111', + updatedAt: '2024-01-01T00:00:00Z', + title: 'Demo session', + repository: 'owner/repo', + branch: 'main', + }, + ], + }); + }); + + it('POST rejects oversized payloads', async () => { + const request = new Request('http://localhost/api/sessions/sync', { + method: 'POST', + body: JSON.stringify({ sessions: [] }), + headers: { + authorization: 'Bearer good-token', + 'content-type': 'application/json', + 'content-length': String(50 * 1024 * 1024 + 1), + }, + }); + + await expect(POST(createEvent(request))).rejects.toMatchObject({ + status: 413, + body: { message: 'Payload too large (max 50MB)' }, + }); + }); + + it('POST rejects invalid JSON bodies', async () => { + const request = new Request('http://localhost/api/sessions/sync', { + method: 'POST', + body: '{bad json', + headers: { + authorization: 'Bearer good-token', + 'content-type': 'application/json', + }, + }); + + await expect(POST(createEvent(request))).rejects.toMatchObject({ + status: 400, + body: { message: 'Invalid JSON body' }, + }); + }); + + it('POST rejects requests without a sessions array', async () => { + const request = new Request('http://localhost/api/sessions/sync', { + method: 'POST', + body: JSON.stringify({}), + headers: { + authorization: 'Bearer good-token', + 'content-type': 'application/json', + }, + }); + + await expect(POST(createEvent(request))).rejects.toMatchObject({ + status: 400, + body: { message: 'Missing sessions array' }, + }); + }); + + it('POST reports invalid session IDs without writing files', async () => { + const request = new Request('http://localhost/api/sessions/sync', { + method: 'POST', + body: JSON.stringify({ + sessions: [{ id: 'not-a-uuid', files: { 'workspace.yaml': 'content' } }], + }), + headers: { + authorization: 'Bearer good-token', + 'content-type': 'application/json', + }, + }); + + const response = await POST(createEvent(request)); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + results: [{ id: 'not-a-uuid', status: 'error', error: 'Invalid session ID' }], + summary: { created: 0, updated: 0, errors: 1 }, + }); + expect(writeFile).not.toHaveBeenCalled(); + }); + + it('POST skips path traversal attempts and writes valid files', async () => { + const request = new Request('http://localhost/api/sessions/sync', { + method: 'POST', + body: JSON.stringify({ + sessions: [ + { + id: '11111111-1111-1111-1111-111111111111', + files: { + 'workspace.yaml': 'root: true', + 'checkpoints/index.md': '# checkpoints', + '../escape.txt': 'nope', + }, + }, + ], + }), + headers: { + authorization: 'Bearer good-token', + 'content-type': 'application/json', + }, + }); + + const response = await POST(createEvent(request)); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.summary).toEqual({ created: 1, updated: 0, errors: 0 }); + expect(mkdir).toHaveBeenCalledWith('/tmp/session-state/11111111-1111-1111-1111-111111111111', { recursive: true }); + expect(writeFile).toHaveBeenCalledTimes(2); + expect(writeFile).toHaveBeenCalledWith('/tmp/session-state/11111111-1111-1111-1111-111111111111/workspace.yaml', 'root: true', 'utf-8'); + expect(writeFile).toHaveBeenCalledWith('/tmp/session-state/11111111-1111-1111-1111-111111111111/checkpoints/index.md', '# checkpoints', 'utf-8'); + }); + + it('POST marks sessions as updated when they already exist', async () => { + vi.mocked(stat).mockResolvedValueOnce({ isDirectory: () => true } as never); + const request = new Request('http://localhost/api/sessions/sync', { + method: 'POST', + body: JSON.stringify({ + sessions: [ + { + id: '22222222-2222-2222-2222-222222222222', + files: { 'workspace.yaml': 'root: true' }, + }, + ], + }), + headers: { + authorization: 'Bearer good-token', + 'content-type': 'application/json', + }, + }); + + const response = await POST(createEvent(request)); + + expect(await response.json()).toEqual({ + results: [{ id: '22222222-2222-2222-2222-222222222222', status: 'updated' }], + summary: { created: 0, updated: 1, errors: 0 }, + }); + }); +}); diff --git a/src/routes/api/settings/+server.ts b/src/routes/api/settings/+server.ts new file mode 100644 index 0000000..007c635 --- /dev/null +++ b/src/routes/api/settings/+server.ts @@ -0,0 +1,45 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { checkAuth } from '$lib/server/auth/guard'; +import { loadUserSettings, saveUserSettings } from '$lib/server/settings-store'; +import type { PersistedSettings } from '$lib/types/index.js'; + +export const GET: RequestHandler = async ({ locals }) => { + const auth = checkAuth(locals.session); + if (!auth.authenticated || !auth.user?.login) { + return json({ error: 'Authentication required' }, { status: 401 }); + } + + try { + const settings = await loadUserSettings(auth.user.login); + if (!settings) { + return json({ settings: null }); + } + return json({ settings }); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Unknown error'; + console.error('Settings load error:', message); + return json({ error: 'Failed to load settings' }, { status: 500 }); + } +}; + +export const PUT: RequestHandler = async ({ locals, request }) => { + const auth = checkAuth(locals.session); + if (!auth.authenticated || !auth.user?.login) { + return json({ error: 'Authentication required' }, { status: 401 }); + } + + try { + const body = await request.json() as { settings?: PersistedSettings }; + if (!body.settings || typeof body.settings !== 'object') { + return json({ error: 'Missing settings object' }, { status: 400 }); + } + + await saveUserSettings(auth.user.login, body.settings); + return json({ ok: true }); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Unknown error'; + console.error('Settings save error:', message); + return json({ error: 'Failed to save settings' }, { status: 500 }); + } +}; diff --git a/src/routes/api/settings/server.test.ts b/src/routes/api/settings/server.test.ts new file mode 100644 index 0000000..903b3ef --- /dev/null +++ b/src/routes/api/settings/server.test.ts @@ -0,0 +1,152 @@ +// @vitest-environment node +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('$lib/server/auth/guard', () => ({ + checkAuth: vi.fn(), +})); + +vi.mock('$lib/server/settings-store', () => ({ + loadUserSettings: vi.fn(), + saveUserSettings: vi.fn(), +})); + +import { GET, PUT } from './+server'; +import { checkAuth } from '$lib/server/auth/guard'; +import { loadUserSettings, saveUserSettings } from '$lib/server/settings-store'; + +type MockSession = { + githubToken?: string; + githubUser?: { login: string; name: string }; + save: (callback: (err?: Error) => void) => void; + destroy: (callback: (err?: Error) => void) => void; +}; + +function createEvent(options: { request?: Request; session?: MockSession } = {}) { + const request = + options.request ?? + new Request('http://localhost/api/settings', { + method: 'GET', + }); + + return { + request, + locals: { session: options.session }, + url: new URL(request.url), + } as any; +} + +function createSession(overrides: Partial = {}): MockSession { + return { + save: vi.fn((callback: (err?: Error) => void) => callback()), + destroy: vi.fn((callback: (err?: Error) => void) => callback()), + ...overrides, + }; +} + +const authUser = { login: 'octocat', name: 'Octocat' }; +const settings = { + model: 'gpt-4.1', + mode: 'chat', + reasoningEffort: 'medium', + customInstructions: 'Be concise', + excludedTools: ['bash'], + customTools: [], +}; + +describe('/api/settings', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + vi.mocked(checkAuth).mockReturnValue({ authenticated: true, user: authUser }); + }); + + it('GET rejects unauthenticated requests', async () => { + vi.mocked(checkAuth).mockReturnValue({ authenticated: false, user: null, error: 'Authentication required' }); + + const response = await GET(createEvent()); + + expect(response.status).toBe(401); + expect(await response.json()).toEqual({ error: 'Authentication required' }); + }); + + it('GET returns null when no saved settings exist', async () => { + vi.mocked(loadUserSettings).mockResolvedValue(null); + + const response = await GET(createEvent({ session: createSession({ githubToken: 'token', githubUser: authUser }) })); + + expect(await response.json()).toEqual({ settings: null }); + }); + + it('GET returns saved settings for authenticated users', async () => { + vi.mocked(loadUserSettings).mockResolvedValue(settings as never); + + const response = await GET(createEvent({ session: createSession({ githubToken: 'token', githubUser: authUser }) })); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ settings }); + expect(loadUserSettings).toHaveBeenCalledWith('octocat'); + }); + + it('GET returns 500 when loading settings fails', async () => { + vi.mocked(loadUserSettings).mockRejectedValue(new Error('disk error')); + + const response = await GET(createEvent({ session: createSession({ githubToken: 'token', githubUser: authUser }) })); + + expect(response.status).toBe(500); + expect(await response.json()).toEqual({ error: 'Failed to load settings' }); + }); + + it('PUT rejects unauthenticated requests', async () => { + vi.mocked(checkAuth).mockReturnValue({ authenticated: false, user: null, error: 'Authentication required' }); + const request = new Request('http://localhost/api/settings', { + method: 'PUT', + body: JSON.stringify({ settings }), + headers: { 'content-type': 'application/json' }, + }); + + const response = await PUT(createEvent({ request })); + + expect(response.status).toBe(401); + }); + + it('PUT validates that a settings object is present', async () => { + const request = new Request('http://localhost/api/settings', { + method: 'PUT', + body: JSON.stringify({}), + headers: { 'content-type': 'application/json' }, + }); + + const response = await PUT(createEvent({ request, session: createSession({ githubToken: 'token', githubUser: authUser }) })); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: 'Missing settings object' }); + }); + + it('PUT saves settings for authenticated users', async () => { + const request = new Request('http://localhost/api/settings', { + method: 'PUT', + body: JSON.stringify({ settings }), + headers: { 'content-type': 'application/json' }, + }); + + const response = await PUT(createEvent({ request, session: createSession({ githubToken: 'token', githubUser: authUser }) })); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ ok: true }); + expect(saveUserSettings).toHaveBeenCalledWith('octocat', settings); + }); + + it('PUT returns 500 when saving settings fails', async () => { + vi.mocked(saveUserSettings).mockRejectedValue(new Error('disk error')); + const request = new Request('http://localhost/api/settings', { + method: 'PUT', + body: JSON.stringify({ settings }), + headers: { 'content-type': 'application/json' }, + }); + + const response = await PUT(createEvent({ request, session: createSession({ githubToken: 'token', githubUser: authUser }) })); + + expect(response.status).toBe(500); + expect(await response.json()).toEqual({ error: 'Failed to save settings' }); + }); +}); diff --git a/src/routes/api/skills/+server.ts b/src/routes/api/skills/+server.ts new file mode 100644 index 0000000..d5c31ec --- /dev/null +++ b/src/routes/api/skills/+server.ts @@ -0,0 +1,15 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from '@sveltejs/kit'; +import { scanSkills } from '$lib/server/skills/scanner.js'; + +export const GET: RequestHandler = async () => { + const skills = await scanSkills(); + return json({ + skills: skills.map(({ name, description, license, allowedTools }) => ({ + name, + description, + license, + allowedTools, + })), + }); +}; diff --git a/src/routes/api/upload/+server.ts b/src/routes/api/upload/+server.ts new file mode 100644 index 0000000..2a98128 --- /dev/null +++ b/src/routes/api/upload/+server.ts @@ -0,0 +1,123 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { writeFile, mkdir, rm } from 'node:fs/promises'; +import { randomUUID } from 'node:crypto'; +import { join, basename, extname } from 'node:path'; +import { tmpdir } from 'node:os'; +import type { FileAttachment } from '$lib/types/index.js'; + +const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB +const MAX_FILES = 5; +const CLEANUP_DELAY = 60 * 60 * 1000; // 1 hour + +const ALLOWED_EXTENSIONS = new Set([ + // Images + 'jpg', 'jpeg', 'png', 'gif', 'webp', + // Code + 'ts', 'js', 'py', 'go', 'rs', 'java', 'c', 'cpp', 'h', 'cs', 'rb', 'php', + // Docs + 'md', 'txt', 'json', 'yaml', 'yml', 'xml', 'html', 'css', 'csv', 'sql', +]); + +function sanitizeFilename(raw: string): string { + const name = basename(raw); + // Strip path traversal characters + return name.replace(/[/\\:*?"<>|]/g, '_'); +} + +function getExtension(filename: string): string { + return extname(filename).slice(1).toLowerCase(); +} + +export const POST: RequestHandler = async ({ request, locals }) => { + if (!locals.session?.githubToken) { + return error(401, 'Unauthorized'); + } + + let formData: FormData; + try { + formData = await request.formData(); + } catch { + return error(400, 'Invalid form data'); + } + + const entries = formData.getAll('files'); + if (entries.length === 0) { + return error(400, 'No files provided'); + } + + if (entries.length > MAX_FILES) { + return error(400, `Maximum ${MAX_FILES} files per upload`); + } + + const uploadId = randomUUID(); + const uploadDir = join(tmpdir(), 'copilot-uploads', uploadId); + await mkdir(uploadDir, { recursive: true }); + + const results: FileAttachment[] = []; + + for (const entry of entries) { + if (!(entry instanceof File)) { + continue; + } + + const hasUnsafePath = + entry.name !== basename(entry.name) || + entry.name.includes('\\') || + /(^|[\\/])\.\.([\\/]|$)/.test(entry.name); + if (hasUnsafePath) { + await rm(uploadDir, { recursive: true, force: true }); + return error(400, 'Invalid file path'); + } + + const safeName = sanitizeFilename(entry.name); + const ext = getExtension(safeName); + + if (!ALLOWED_EXTENSIONS.has(ext)) { + await rm(uploadDir, { recursive: true, force: true }); + return error(400, `File type .${ext} is not allowed`); + } + + if (entry.size > MAX_FILE_SIZE) { + await rm(uploadDir, { recursive: true, force: true }); + return error(400, `File ${safeName} exceeds 10MB limit`); + } + + if (entry.size === 0) { + continue; + } + + const filePath = join(uploadDir, safeName); + // Verify resolved path stays inside upload dir (path traversal prevention) + if (!filePath.startsWith(uploadDir)) { + await rm(uploadDir, { recursive: true, force: true }); + return error(400, 'Invalid file path'); + } + + const buffer = Buffer.from(await entry.arrayBuffer()); + await writeFile(filePath, buffer); + + results.push({ + path: filePath, + name: safeName, + size: entry.size, + type: entry.type || `application/${ext}`, + }); + } + + if (results.length === 0) { + await rm(uploadDir, { recursive: true, force: true }); + return error(400, 'No valid files uploaded'); + } + + // Schedule cleanup after 1 hour + setTimeout(async () => { + try { + await rm(uploadDir, { recursive: true, force: true }); + } catch { + // Directory may already be cleaned up + } + }, CLEANUP_DELAY); + + return json({ files: results }); +}; diff --git a/src/routes/api/upload/server.test.ts b/src/routes/api/upload/server.test.ts new file mode 100644 index 0000000..ed85472 --- /dev/null +++ b/src/routes/api/upload/server.test.ts @@ -0,0 +1,182 @@ +// @vitest-environment node +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + default: actual, + mkdir: vi.fn(async () => undefined), + rm: vi.fn(async () => undefined), + writeFile: vi.fn(async () => undefined), + }; +}); + +vi.mock('node:crypto', async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + default: actual, + randomUUID: vi.fn(() => 'upload-123'), + }; +}); + +vi.mock('node:os', async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + default: actual, + tmpdir: vi.fn(() => '/tmp'), + }; +}); + +import { POST } from './+server'; +import { mkdir, rm, writeFile } from 'node:fs/promises'; + +type MockSession = { + githubToken?: string; + save: (callback: (err?: Error) => void) => void; + destroy: (callback: (err?: Error) => void) => void; +}; + +function createEvent(request: Request, session?: MockSession) { + return { + request, + locals: { session }, + url: new URL(request.url), + } as any; +} + +function createSession(overrides: Partial = {}): MockSession { + return { + save: vi.fn((callback: (err?: Error) => void) => callback()), + destroy: vi.fn((callback: (err?: Error) => void) => callback()), + ...overrides, + }; +} + +function createUploadRequest(files: File[]): Request { + const formData = new FormData(); + for (const file of files) { + formData.append('files', file); + } + + return new Request('http://localhost/api/upload', { + method: 'POST', + body: formData, + }); +} + +describe('POST /api/upload', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(globalThis, 'setTimeout').mockImplementation(() => 0 as never); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('rejects unauthenticated requests', async () => { + const request = createUploadRequest([new File(['hello'], 'note.txt', { type: 'text/plain' })]); + + await expect(POST(createEvent(request))).rejects.toMatchObject({ + status: 401, + body: { message: 'Unauthorized' }, + }); + }); + + it('rejects invalid form data', async () => { + const request = new Request('http://localhost/api/upload', { + method: 'POST', + body: 'not-form-data', + headers: { 'content-type': 'text/plain' }, + }); + + await expect(POST(createEvent(request, createSession({ githubToken: 'token' })))).rejects.toMatchObject({ + status: 400, + body: { message: 'Invalid form data' }, + }); + }); + + it('rejects requests without files', async () => { + const request = createUploadRequest([]); + + await expect(POST(createEvent(request, createSession({ githubToken: 'token' })))).rejects.toMatchObject({ + status: 400, + body: { message: 'No files provided' }, + }); + }); + + it('enforces the maximum file count', async () => { + const files = Array.from({ length: 6 }, (_, index) => new File(['x'], `file-${index}.txt`, { type: 'text/plain' })); + const request = createUploadRequest(files); + + await expect(POST(createEvent(request, createSession({ githubToken: 'token' })))).rejects.toMatchObject({ + status: 400, + body: { message: 'Maximum 5 files per upload' }, + }); + }); + + it('rejects files with disallowed extensions', async () => { + const request = createUploadRequest([new File(['bad'], 'malware.exe', { type: 'application/octet-stream' })]); + + await expect(POST(createEvent(request, createSession({ githubToken: 'token' })))).rejects.toMatchObject({ + status: 400, + body: { message: 'File type .exe is not allowed' }, + }); + expect(rm).toHaveBeenCalledWith('/tmp/copilot-uploads/upload-123', { recursive: true, force: true }); + }); + + it('enforces the file size limit', async () => { + const oversized = new File([new Uint8Array(10 * 1024 * 1024 + 1)], 'huge.txt', { type: 'text/plain' }); + const request = createUploadRequest([oversized]); + + await expect(POST(createEvent(request, createSession({ githubToken: 'token' })))).rejects.toMatchObject({ + status: 400, + body: { message: 'File huge.txt exceeds 10MB limit' }, + }); + }); + + it('rejects path traversal attempts in file names', async () => { + const request = createUploadRequest([new File(['secret'], '../secret.txt', { type: 'text/plain' })]); + + await expect(POST(createEvent(request, createSession({ githubToken: 'token' })))).rejects.toMatchObject({ + status: 400, + body: { message: 'Invalid file path' }, + }); + }); + + it('rejects uploads when every file is empty', async () => { + const request = createUploadRequest([new File([''], 'empty.txt', { type: 'text/plain' })]); + + await expect(POST(createEvent(request, createSession({ githubToken: 'token' })))).rejects.toMatchObject({ + status: 400, + body: { message: 'No valid files uploaded' }, + }); + }); + + it('accepts valid uploads for authenticated users', async () => { + const request = createUploadRequest([new File(['hello'], 'notes.md', { type: 'text/markdown' })]); + + const response = await POST(createEvent(request, createSession({ githubToken: 'token' }))); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body).toEqual({ + files: [ + { + path: '/tmp/copilot-uploads/upload-123/notes.md', + name: 'notes.md', + size: 5, + type: 'text/markdown', + }, + ], + }); + expect(mkdir).toHaveBeenCalledWith('/tmp/copilot-uploads/upload-123', { recursive: true }); + expect(writeFile).toHaveBeenCalledWith('/tmp/copilot-uploads/upload-123/notes.md', expect.any(Buffer)); + }); +}); diff --git a/src/routes/api/version/+server.ts b/src/routes/api/version/+server.ts new file mode 100644 index 0000000..6da0a9c --- /dev/null +++ b/src/routes/api/version/+server.ts @@ -0,0 +1,16 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { createRequire } from 'module'; + +let sdkVersion = 'unknown'; +try { + const require = createRequire(import.meta.url); + const sdkPkg = require('@github/copilot-sdk/package.json') as { version: string }; + sdkVersion = sdkPkg.version; +} catch { + // keep 'unknown' if resolution fails +} + +export const GET: RequestHandler = async () => { + return json({ sdkVersion }); +}; diff --git a/src/routes/api/version/server.test.ts b/src/routes/api/version/server.test.ts new file mode 100644 index 0000000..47bfd54 --- /dev/null +++ b/src/routes/api/version/server.test.ts @@ -0,0 +1,42 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest'; +import { GET } from './+server'; + +function createEvent() { + return {} as any; +} + +describe('GET /api/version', () => { + it('returns 200', async () => { + const response = await GET(createEvent()); + + expect(response.status).toBe(200); + }); + + it('returns version info', async () => { + const response = await GET(createEvent()); + const body = await response.json(); + + expect(body).toHaveProperty('sdkVersion'); + }); + + it('returns sdkVersion as a string', async () => { + const response = await GET(createEvent()); + const body = await response.json(); + + expect(typeof body.sdkVersion).toBe('string'); + }); + + it('returns a stable payload across requests', async () => { + const first = await (await GET(createEvent())).json(); + const second = await (await GET(createEvent())).json(); + + expect(second).toEqual(first); + }); + + it('responds with JSON content type', async () => { + const response = await GET(createEvent()); + + expect(response.headers.get('content-type')).toContain('application/json'); + }); +}); diff --git a/src/routes/auth/device/poll/+server.ts b/src/routes/auth/device/poll/+server.ts new file mode 100644 index 0000000..9f371e9 --- /dev/null +++ b/src/routes/auth/device/poll/+server.ts @@ -0,0 +1,79 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { pollForToken, validateGitHubToken } from '$lib/server/auth/github'; +import { config } from '$lib/server/config'; +import { logSecurity } from '$lib/server/security-log'; +import { clearDeviceFlow, saveSession } from '$lib/server/auth/session-utils'; + +export const POST: RequestHandler = async ({ locals, getClientAddress }) => { + if (!locals.session) { + return json({ error: 'No session available' }, { status: 500 }); + } + + const session = locals.session; + const deviceCode = session.githubDeviceCode; + const expiry = session.githubDeviceExpiry; + + if (!deviceCode) { + return json({ error: 'No active device flow. Call /start first.' }, { status: 400 }); + } + + if (expiry && Date.now() > expiry) { + await clearDeviceFlow(session); + return json({ status: 'expired' }); + } + + try { + const result = await pollForToken(deviceCode); + + // Still waiting — return status directly (no session mutation needed) + if (result.status === 'pending' || result.status === 'slow_down') { + return json({ status: result.status }); + } + + // Terminal failures — clean up device flow state + if (result.status === 'access_denied' || result.status === 'expired') { + await clearDeviceFlow(session); + return json({ status: result.status }); + } + + // Authorized — validate token and store + if (!result.token) throw new Error('Token missing in authorized response'); + + const validation = await validateGitHubToken(result.token); + if (!validation.valid) throw new Error('Could not validate GitHub token'); + const user = validation.user; + + if ( + config.allowedUsers.length > 0 && + !config.allowedUsers.includes(user.login.toLowerCase()) + ) { + logSecurity('warn', 'auth_denied_not_allowed', { + user: user.login, + ip: getClientAddress(), + }); + return json( + { + status: 'forbidden', + error: 'Your GitHub account is not authorized to use this application.', + }, + { status: 403 } + ); + } + + delete session.githubDeviceCode; + delete session.githubDeviceExpiry; + session.githubToken = result.token; + session.githubUser = user; + session.githubAuthTime = Date.now(); + console.log(`[POLL] auth success, saving session. user=${user.login} hasToken=${!!session.githubToken}`); + await saveSession(session); + console.log(`[POLL] session saved successfully`); + + logSecurity('info', 'auth_success', { user: user.login }); + return json({ status: 'authorized', githubUser: user.login }); + } catch (err) { + console.error('GitHub device flow poll error:', err); + return json({ error: 'Device flow polling failed' }, { status: 500 }); + } +}; diff --git a/src/routes/auth/device/poll/server.test.ts b/src/routes/auth/device/poll/server.test.ts new file mode 100644 index 0000000..c17b5eb --- /dev/null +++ b/src/routes/auth/device/poll/server.test.ts @@ -0,0 +1,155 @@ +// @vitest-environment node +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('$lib/server/auth/github', () => ({ + pollForToken: vi.fn(), + validateGitHubToken: vi.fn(), +})); + +vi.mock('$lib/server/config', () => ({ + config: { + allowedUsers: [] as string[], + }, +})); + +vi.mock('$lib/server/security-log', () => ({ + logSecurity: vi.fn(), +})); + +vi.mock('$lib/server/auth/session-utils', () => ({ + clearDeviceFlow: vi.fn(async () => undefined), + saveSession: vi.fn(async () => undefined), +})); + +import { POST } from './+server'; +import { pollForToken, validateGitHubToken } from '$lib/server/auth/github'; +import { config } from '$lib/server/config'; +import { logSecurity } from '$lib/server/security-log'; +import { clearDeviceFlow, saveSession } from '$lib/server/auth/session-utils'; + +type MockSession = { + githubDeviceCode?: string; + githubDeviceExpiry?: number; + githubToken?: string; + githubUser?: { login: string; name: string }; + githubAuthTime?: number; + save: (callback: (err?: Error) => void) => void; + destroy: (callback: (err?: Error) => void) => void; +}; + +function createEvent(session?: MockSession) { + return { + locals: { session }, + getClientAddress: () => '127.0.0.1', + } as any; +} + +function createSession(overrides: Partial = {}): MockSession { + return { + save: vi.fn((callback: (err?: Error) => void) => callback()), + destroy: vi.fn((callback: (err?: Error) => void) => callback()), + ...overrides, + }; +} + +describe('POST /auth/device/poll', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + config.allowedUsers = []; + vi.mocked(pollForToken).mockResolvedValue({ status: 'authorized', token: 'github-token' }); + vi.mocked(validateGitHubToken).mockResolvedValue({ + valid: true, + user: { login: 'octocat', name: 'Octocat' }, + }); + }); + + it('returns 500 when no session is available', async () => { + const response = await POST(createEvent()); + + expect(response.status).toBe(500); + expect(await response.json()).toEqual({ error: 'No session available' }); + }); + + it('requires an active device flow', async () => { + const response = await POST(createEvent(createSession())); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: 'No active device flow. Call /start first.' }); + }); + + it('clears expired device flows', async () => { + const response = await POST(createEvent(createSession({ githubDeviceCode: 'device-code', githubDeviceExpiry: Date.now() - 1 }))); + + expect(await response.json()).toEqual({ status: 'expired' }); + expect(clearDeviceFlow).toHaveBeenCalledTimes(1); + expect(pollForToken).not.toHaveBeenCalled(); + }); + + it('returns pending while waiting for approval', async () => { + vi.mocked(pollForToken).mockResolvedValue({ status: 'pending' }); + + const response = await POST(createEvent(createSession({ githubDeviceCode: 'device-code', githubDeviceExpiry: Date.now() + 60_000 }))); + + expect(await response.json()).toEqual({ status: 'pending' }); + }); + + it('returns slow_down when GitHub asks the client to back off', async () => { + vi.mocked(pollForToken).mockResolvedValue({ status: 'slow_down' }); + + const response = await POST(createEvent(createSession({ githubDeviceCode: 'device-code', githubDeviceExpiry: Date.now() + 60_000 }))); + + expect(await response.json()).toEqual({ status: 'slow_down' }); + }); + + it('clears denied device flows', async () => { + vi.mocked(pollForToken).mockResolvedValue({ status: 'access_denied' }); + + const response = await POST(createEvent(createSession({ githubDeviceCode: 'device-code', githubDeviceExpiry: Date.now() + 60_000 }))); + + expect(await response.json()).toEqual({ status: 'access_denied' }); + expect(clearDeviceFlow).toHaveBeenCalledTimes(1); + }); + + it('rejects authenticated users outside the allowed list', async () => { + config.allowedUsers = ['hubot']; + const response = await POST(createEvent(createSession({ githubDeviceCode: 'device-code', githubDeviceExpiry: Date.now() + 60_000 }))); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ + status: 'forbidden', + error: 'Your GitHub account is not authorized to use this application.', + }); + expect(logSecurity).toHaveBeenCalledWith('warn', 'auth_denied_not_allowed', { + user: 'octocat', + ip: '127.0.0.1', + }); + expect(saveSession).not.toHaveBeenCalled(); + }); + + it('stores the validated GitHub token on success', async () => { + const session = createSession({ githubDeviceCode: 'device-code', githubDeviceExpiry: Date.now() + 60_000 }); + + const response = await POST(createEvent(session)); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ status: 'authorized', githubUser: 'octocat' }); + expect(session.githubDeviceCode).toBeUndefined(); + expect(session.githubDeviceExpiry).toBeUndefined(); + expect(session.githubToken).toBe('github-token'); + expect(session.githubUser).toEqual({ login: 'octocat', name: 'Octocat' }); + expect(session.githubAuthTime).toEqual(expect.any(Number)); + expect(saveSession).toHaveBeenCalledWith(session); + expect(logSecurity).toHaveBeenCalledWith('info', 'auth_success', { user: 'octocat' }); + }); + + it('returns 500 when GitHub token validation fails', async () => { + vi.mocked(validateGitHubToken).mockResolvedValue({ valid: false, reason: 'invalid_token' }); + + const response = await POST(createEvent(createSession({ githubDeviceCode: 'device-code', githubDeviceExpiry: Date.now() + 60_000 }))); + + expect(response.status).toBe(500); + expect(await response.json()).toEqual({ error: 'Device flow polling failed' }); + }); +}); diff --git a/src/routes/auth/device/start/+server.ts b/src/routes/auth/device/start/+server.ts new file mode 100644 index 0000000..84e2c6f --- /dev/null +++ b/src/routes/auth/device/start/+server.ts @@ -0,0 +1,28 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { requestDeviceCode } from '$lib/server/auth/github'; +import { saveSession } from '$lib/server/auth/session-utils'; + +export const POST: RequestHandler = async ({ locals }) => { + if (!locals.session) { + return json({ error: 'No session available' }, { status: 500 }); + } + + try { + const deviceData = await requestDeviceCode(); + + locals.session.githubDeviceCode = deviceData.device_code; + locals.session.githubDeviceExpiry = Date.now() + deviceData.expires_in * 1000; + await saveSession(locals.session); + + return json({ + user_code: deviceData.user_code, + verification_uri: deviceData.verification_uri, + expires_in: deviceData.expires_in, + interval: deviceData.interval, + }); + } catch (err) { + console.error('GitHub device flow start error:', err); + return json({ error: 'Failed to start device flow' }, { status: 500 }); + } +}; diff --git a/src/routes/auth/device/start/server.test.ts b/src/routes/auth/device/start/server.test.ts new file mode 100644 index 0000000..eee1d68 --- /dev/null +++ b/src/routes/auth/device/start/server.test.ts @@ -0,0 +1,109 @@ +// @vitest-environment node +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('$lib/server/auth/github', () => ({ + requestDeviceCode: vi.fn(), +})); + +vi.mock('$lib/server/auth/session-utils', () => ({ + saveSession: vi.fn(async () => undefined), +})); + +import { POST } from './+server'; +import { requestDeviceCode } from '$lib/server/auth/github'; +import { saveSession } from '$lib/server/auth/session-utils'; + +type MockSession = { + githubDeviceCode?: string; + githubDeviceExpiry?: number; + save: (callback: (err?: Error) => void) => void; + destroy: (callback: (err?: Error) => void) => void; +}; + +function createEvent(session?: MockSession) { + return { + locals: { session }, + } as any; +} + +function createSession(overrides: Partial = {}): MockSession { + return { + save: vi.fn((callback: (err?: Error) => void) => callback()), + destroy: vi.fn((callback: (err?: Error) => void) => callback()), + ...overrides, + }; +} + +describe('POST /auth/device/start', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + vi.setSystemTime(new Date('2024-01-01T00:00:00.000Z')); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + vi.mocked(requestDeviceCode).mockResolvedValue({ + device_code: 'device-code', + user_code: 'USER-CODE', + verification_uri: 'https://github.com/login/device', + expires_in: 900, + interval: 5, + }); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('returns 500 when no session is available', async () => { + const response = await POST(createEvent()); + + expect(response.status).toBe(500); + expect(await response.json()).toEqual({ error: 'No session available' }); + }); + + it('initiates the device flow', async () => { + await POST(createEvent(createSession())); + + expect(requestDeviceCode).toHaveBeenCalledTimes(1); + }); + + it('stores device flow details on the session', async () => { + const session = createSession(); + + await POST(createEvent(session)); + + expect(session.githubDeviceCode).toBe('device-code'); + expect(session.githubDeviceExpiry).toBe(Date.now() + 900_000); + }); + + it('persists the updated session', async () => { + const session = createSession(); + + await POST(createEvent(session)); + + expect(saveSession).toHaveBeenCalledWith(session); + }); + + it('returns the public device flow fields without exposing the device code', async () => { + const response = await POST(createEvent(createSession())); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body).toEqual({ + user_code: 'USER-CODE', + verification_uri: 'https://github.com/login/device', + expires_in: 900, + interval: 5, + }); + expect(body).not.toHaveProperty('device_code'); + }); + + it('returns 500 when the GitHub device flow request fails', async () => { + vi.mocked(requestDeviceCode).mockRejectedValue(new Error('github unavailable')); + + const response = await POST(createEvent(createSession())); + + expect(response.status).toBe(500); + expect(await response.json()).toEqual({ error: 'Failed to start device flow' }); + }); +}); diff --git a/src/routes/auth/logout/+server.ts b/src/routes/auth/logout/+server.ts new file mode 100644 index 0000000..2dc5f1a --- /dev/null +++ b/src/routes/auth/logout/+server.ts @@ -0,0 +1,15 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { clearAuth } from '$lib/server/auth/session-utils'; +import { cleanupUserSessions } from '$lib/server/ws/session-pool'; + +export const POST: RequestHandler = async ({ locals }) => { + if (locals.session) { + const username = locals.session.githubUser?.login; + await clearAuth(locals.session); + if (username) { + await cleanupUserSessions(username); + } + } + return json({ success: true }); +}; diff --git a/src/routes/auth/status/+server.ts b/src/routes/auth/status/+server.ts new file mode 100644 index 0000000..98e6439 --- /dev/null +++ b/src/routes/auth/status/+server.ts @@ -0,0 +1,18 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { checkAuth } from '$lib/server/auth/guard'; +import { clearAuth } from '$lib/server/auth/session-utils'; + +export const GET: RequestHandler = async ({ locals }) => { + const session = locals.session; + const auth = checkAuth(session); + + if (session?.githubToken && !auth.authenticated) { + await clearAuth(session); + } + + return json({ + authenticated: auth.authenticated, + githubUser: auth.user?.login ?? null, + }); +}; diff --git a/src/routes/health/+server.ts b/src/routes/health/+server.ts new file mode 100644 index 0000000..f4adf65 --- /dev/null +++ b/src/routes/health/+server.ts @@ -0,0 +1,6 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async () => { + return json({ status: 'ok' }); +}; diff --git a/src/routes/health/server.test.ts b/src/routes/health/server.test.ts new file mode 100644 index 0000000..4a00035 --- /dev/null +++ b/src/routes/health/server.test.ts @@ -0,0 +1,42 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest'; +import { GET } from './+server'; + +function createEvent() { + return {} as any; +} + +describe('GET /health', () => { + it('returns 200', async () => { + const response = await GET(createEvent()); + + expect(response.status).toBe(200); + }); + + it('returns the expected payload', async () => { + const response = await GET(createEvent()); + + expect(await response.json()).toEqual({ status: 'ok' }); + }); + + it('includes a status field', async () => { + const response = await GET(createEvent()); + const body = await response.json(); + + expect(body).toHaveProperty('status'); + }); + + it('returns a string status value', async () => { + const response = await GET(createEvent()); + const body = await response.json(); + + expect(body.status).toBe('ok'); + expect(typeof body.status).toBe('string'); + }); + + it('responds with JSON content type', async () => { + const response = await GET(createEvent()); + + expect(response.headers.get('content-type')).toContain('application/json'); + }); +}); diff --git a/src/test-setup.ts b/src/test-setup.ts new file mode 100644 index 0000000..f7dc186 --- /dev/null +++ b/src/test-setup.ts @@ -0,0 +1,2 @@ +// Global test setup for Vitest and Testing Library. +import '@testing-library/jest-dom/vitest'; diff --git a/static/.well-known/appspecific/com.chrome.devtools.json b/static/.well-known/appspecific/com.chrome.devtools.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/static/.well-known/appspecific/com.chrome.devtools.json @@ -0,0 +1 @@ +{} diff --git a/static/favicon.png b/static/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..785d6ed123f377aff7b579f287263a56601709d0 GIT binary patch literal 1786 zcmV004R> z004l5008;`004mK004C`008P>0026e000+ooVrmw00006VoOIv0RI600RN!9r;`8x z010qNS#tmY3ljhU3ljkVnw%H_000McNliru>H`T7ClT%NmiYhx23tu)K~z}7?Ur4P zoK+def6sY8XLp!sXS;1_>$Y24sa>Ka5CvjNVycNT#0wjh2!W`yR*ZUOV$dL_#A{8& z&kI2l;KryiG)5(n0QG}bs8(7^KbN+&+nMgn?(EF_an9q#JKfIgE>N$y;Yr>%=Y7v} z&hvl%&&N6NuDrt{*P`^7uPSW|o)Dp|1vUfgL|6u(FpV;aa9rVaRgzKwJAU}~+rOp8 z?{_Pi0a0oqe8Rw|ftv+Z0nq~WMByUvqEmjOuuq{L0(bu8&CR^&%#(W*RXii?Fz_vb z+kxO3navdTJNUkXKMB~+{dj3pOaA@Umlaha&ts1`{firS1jAbwDYMS0_BK+hF>ZD>J7Y z&BGSkE&iGk#H)nLX0H=e&9@WF4+6n{aRG>duSa(&o&biA<{kIc>uw#o6qL+o)$xo4zS0N69|&9#yx3PBXf)Cl z=bE!E?^GQ)~)%urBlG}pCU%I3%-w@7!oDGW9I6}2_-1G(~oK(~K>06@>z8y(zH z?{sPS?W-R3Nf>&x(u|}3ZUAuon(LSjyQJ8~38hNC6H_L>!@&e_U`YUb?^m*7c&jKQ zGm_B=JIF!+s+3C+#2!JVR}`^FyOc7k$s)k2%8X>B%80^kz=22ZS04Pi3W=T5%_3Ln|9jZI_gnWoFh%Tpva@8zwq zYPdqBS|$Y)#|s zgmaF1yG|$VuyJ%FQ4pbu1uiQ5&cTBM4S!DY1Nepry8uJ)b>!l4o;&(ij2KE`2_uFy zPv~a7mWa?_pv}1kFTA#&gTX;UKf zR#d@Yaf|>r_$hb;GXkQJI}6UDJpfoow0(@BomWMp0)AUOg>Hbqb+D)gn&k z8OW_&iUCmYQ|qE=SE(2CQ1rzKZD^ zSU-mY3P)AhI*&uZsvXP$Rnmi#WdC`C^w4?Sr5VuT@UiEcB+s8B*jOVybPjL>3|Msq zfIb!kjtU&`FDeI@3!it&LkOQ#2t+}=uPjB^f&*2 z#6GUxM(ANv1sukY`$Lck>@&(kP0LH(_y@lW@P@)Z3zHV!YlYEpXgS9CdIp{uU&Namq1;hk=J3oHnK}@*lcSnW`cy zn`}_HPvLIR@wC&DnfgguQ^)8`zfRU|;qnC4!Lwh)VEm9Eu2NdImdfa6$|K`Mr79q2 z1)dQ2nKQg54*kb3Wntwz^uwU-1lzy*QRz@$?yi{OqueofoI cEAOoQ4|4n<%+5-m$^ZZW07*qoM6N<$g1J>ktN;K2 literal 0 HcmV?d00001 diff --git a/static/img/logo.png b/static/img/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..b9bc965a72bebaf0c16e52eb6b29733bc458f5f0 GIT binary patch literal 10448 zcmb_?g@kGR)8&cl_P& z`w#B(oO;e#Ywfk(^`5o&*=I+7RC$k!Lx}?b0C45yq}2cbAnFnbzyhI8`p(6cs1vrM zoQ?|s@bvk=9SHcAMGgSa1LUP8K6zvvW@Ea3nwuXyg<7FKk@ykDjfK76&L{T*TaJ(j zOJr7&Ie>*8E23L6hM4e2Xnw%VOB!I!LeWciLTO()^>40{0gR6h*%Tf*bdw1g!`T9t z6YzE2&IV}jSi2Rn?y>`-!2IUT6A6O<^R3Un7#y-v$$11d6JPy;egl6KXxapP4oSRrx|Y|h~P^WU4d&%{7tP-s%zlXVzn-{E=aa+hSq0$mxZ(jii*b{pw}<3!cB7U zDjoK|HrJ6CxKmWST49T>YXp_GDgZct%?7aB#|kNabg~=A27{oUj9Bv38lj|cThtRP z2*pwN8}w2Sz6O9H&wyG`ZFa#_TK|yH2LMQGk62I2;6risGQ_eDOqbNZh?nVQPky0nM5y9nCX=^q~+t z&WA!|F&>a(#VAf;vdUpRr=$Umm~OZMW|m7LbYt)f_% zq3wW%e!$uv)4Q4s5*k)PJ_<+6Bw&(zti*HZxgFDo0MFH@$@OVvTJi5)pllbXGwq8uS);a&JZH?2fr+?XXjdvW4`-8g@!Nw*; z)%29=Py^2 zn1hRNV2dbo&L!v0RC*JOvFgi@U$MbR%yMeWW%NvV1?{dOCw(B~o+#Eh7U)zKeZ0$V zFGW>`r!7jLouwM?N$8foqY-U|tmv;B4|Pg?Uvb<_;_0USlgPOJWm11V&)pHziTsZo>#^5%@BKhs=5b_{^lbE&P2}z zE)yX8xtzX1jG`t_JCSy}Ms$WBkH-x(bJgNNn^VavIG{p@TL7*f9GcNE(B5E+wdeHJ z;G@R^j|qs~?Z`+cCaFwmcEvsox1#rylgH!l1C>W@XtTMUZn~bGs<5zy9f<|4-da~y zIwj0dk8*azG)w2F1U>SG8POBPY28#y9#g6>Hdqe! zk6thFBn~mHoLVsa1xJ5~H%cp+4uoVs_wQ}vv_4Ve6!p(o8_3*Lq%?N#Q5K#qrJU7O z=({bgDbAfQzw&5i>6~wtMR>lOeWj~GWfw3PwB+>nSl;5rs>gzm6DG$&&Dg54zM6LQ zK6K&jc?a9DKs$`5y!wan;WiG>$7R)gf%A^A{$Mb9xfgiL0NH1xT`6^}2rvB-Ho3dn zXI4?QRiioMrg?)~XWCF?uiSPz!=h)@zq*=fIoK+R&x8E+N?%_;HI?4ple4nIICCa| zt98Ar@3u+~I(%4VT2fls?QzJYH)?iR={y-tFadY(El<}Of6XDwkGz36UF_bEo?p4Q z*hnG%makH!J3d;3J6T@Dg3KDz>|eK?zW7n+GB$enPNXA$0iD0LoZMw>shA9N!Puu& zk$hp^@gpxJEyU)qJ&{9hCR{2wqvoKF%UX_CK3=McotRCGy5P;C3xw3-W2#v zEqjh{$Thte77t(Op(BMCdm=m1VjE_SeMt%mCBwC$_Zy!+k#703on6_M*q|f+#*Eo{ z6pwp`yUJ?11s|_0Q)JA=Wv(mLo!(-U$wPfNxxv#W`ZAnT2~ShpJc?nSF<-nVUigB~ z)Pe=78f6;bfG44w5^u9A$8{$oFqM4`cAb6M0Y_zNZgo<6nn zb4N_m?l$jsr;28%S|9-YO-ixzZMVIfI^}~wOyyUUjx~7XYx~q^hm(T!F5wH@=}+~B zC&q>R^n{i#vuaYuN(DqLPZ9XxmS2Geq$IE#(^NPJe4vN>`IZY083Uce{nMiU_2Z?>Bna%73$G{apI)Q(n_ua+ELtmVy^=_6s=6SSy zqzBU(kWPN;r>@!^Q^^~k*P~Tot1$c`N7uz1%bh;bT9?zfb;Hyht;`%M8pG{14!nM0 z+%~d3C*MHYw}bL)Z}N&^NR_-dNA)@aHKFj;Hds?y(z`bqDCtteTqG_*+_hkGdokwN z9Dx&vLHGlIC{AaR<*tnNQj6!H0|)L`tJfprJ~V~L2&(YhdV((;f1PIFeRhFgMPI!g zJ8f3y@7aCQ1ninPHfBy2z1Ps#MFY`GkE^l=QI^nEY3)cZ;tFX9LyTX6CE6% zh@JY+VUp6{%t}GjCVu@DYY3ash(f{ZQ=Hdjm#q-BX3DWQg@kt6b&#EGhS7vAK z*hZay^k%l*zMlcJNEEer{cg|29CPzQ$Pi!M{oy9Pq2c@|UVKXUPaNBm?DgnZyaqUi zJ=B*`=g;4x=ASYGZ1-T$6}3Ah=9&dSSagFeDrr)j27+@EvCb*H%@4w)mN7|V7#Lg= z9T4wi81Scp`xEyD#Y>T1w;prCYvAPPcmbW%y%f}QhaaNDxAkdgch=q~T2#_v{;6fu z&1F6&railFn&sy|vTko7PlM!557I51nzj#eJQExd@$)W~7qn1)4L!j`xg`H*kMO*W zw)%Je-t)bceXSxV>%LGf0>?5*vS6*-y1BT;(3+HM8$kK0EF=d?N5nGlrP+q>fa^P#MYUgG~%JF4Ir zp1>D~G0esnh>c1X`#Ua^ac5Z&Z&cW^sCPc2@Q04Q;nWLrH-Qaq6*~K zgSgJ+yjAeE?It6n_DHMOS>Ytdrv@&^$tRWtlgIU?e-PJrkncX1&&15#4Bm~N%Wbb2 zFw**;&i*>Ngi=94*S*EeOHrXA~s6f zOFJ6p)Rbdow~>wMy~O@EVb0BS)T!-O@hsV<=3IxyxWX6aFQc%4%#t+7sngt*XIY~3 z*zGpFP`+wVZDE@=K}S9iiC73`St});s{2c+Z8WKDCKE0+;|H)Zl(nEF{}kQNF9IDy zApVixoN0VUl_G1Gd&bNn@NWh-$74jNiiNRlWY2(z>+0~}x;P#|t;v#&;nqp%Q7%E^ z3FApAs=V?A`96^E;St`b$TUiNe~R{1^KLKg2rWO8bO-1N!8LtSk5`v74UC;m@TZ;b zHWFxqg&TbS^gSfGA`~JD&&%6rBe?cTh2%u07ByTo*I<8tyT$fW>O!DCBNDTj?>^g- zk$U4hnVyh|v_`?{Sv?-|1i_`+(;gpJ7wb%}gv{KyX9_{o9QlBD)} zUS$C&$uDCN91tsmapOBpmE1Xo( zGyYWBq=}N|0x#QyQbW42-?o6_N00D%I=u;AI|YMN4MtcYW8pJ8o#vrNoIyXrr8jm z-!v+E;P1ENnU?44RyUt9yve0#)xg&^QC|``W8lDQ{E~{E8top5bXtk$#YR%vNe6guqbS6@J7GjLfWE)`BK;pse0q}mMm@1RW9QPMqj>)JH|a`w)hA-CPDf+hG3z82x+84w}{Dy zc4*oeukfsx`rhGM=o-oS=IPb#bA{bQAt{a6NuAVhbEY5u6%tJN3DZR0hVA-WNny z)t_~&OPyt`09pz#OI{%Sc0+n;S@ofjw^0`AnHWP!OM4`c!GN8kaTcgAuU)Wst}L@a42zeR z%@G;LY$0O#-LGJayEDVG*AazyzNqkd3GFuL8zd7OBejD&@E`1BP~8npAO7Gz0sN&b z?KJ%(A#SB;Pn6a+W%At6emh<5roXe%==$|WfPXK;4G#hroJ>2NGLY`EdyWKCy3cDr zEob(q>Zhig-x}e6P3ubd+>B=`ZZ7x2WEM?kUt5_5gew>}^}A^r>?p8`#un35#b z?P%vE5HCRkwo@+UAU>IdWUtpO{5T)nWpK4mns^U?5fknsO|KGW-Hld;Osojgt~qtX z%F7|Xam>*v{bQz0kdAOXfynCLO|jkx&B+_>ifuCLO~WRLMlcn=-PhiRYr2_&18v+l zaTZ$Igti|I`FIAD=+?uLodVJ&$D}7EQxl!@={??v`oFD<_)=PO~ZV7?6=@ zUm{qtA&Sj4#uB%2(N-_4Qu^ZvmE7bl(KW{VSUNtMiV_}gTrU}(Fg$_KRZ+2*yr*Wa zaqD50fr?ZO%MiZ?cAgmdt5S={ za=GbRV~o8i;T>#b98mI7fA=lzt2!%QK+Lub!9aPaWd3nL7|ktpa!PCXKO_ zSAQFb?=h5-wV9@XSIz|Ro767hwe=|(SwXu*>^d&|$;&3UMcZaK9l6Ay6=paI;{0=} zE%n0wlxFohf_;8t+!U7{Ll0CHpqGY$GV(guXv=W@PpOt!8hxiov6e~Dcbbncd99Eb z(Ye}kgLR$@I4BE|zXtqv(p4~x+Cf?#n5L1+i>Ew<#83A`D0_3LaP7ukGq~*dvFjJE^ALBPr7D-n-MpM7rLc2tJ zL9^1`JLyv8!hYAA4|$TT5K_A2VjC&oxTk}_kQ8jhkeWobYxk*y!2AI)C0=BTpO}6h zo)x6G>&a-tbxZQl-9AyFnin>32h1IK_7jxw%@^~2qFr2Wuo2SDiE#UpVk`Xl0`SrL zW4CX&=A!q8Q?r|�f99PC#LeA`L_8hF*^bf1LOJp!jMa4d-}e!)iLY%VYP%<1<>0MnyU|lfAxS3)3bC zz^m_Vcm6f{EqF+Dt10(sGRH}SasO=1Z)-?`OTnfYTuXdF24#vf4?T~|EnS#i5gDU} z;vDbQoijo#RxQV#yk4&sUm4A4yW`J|J!dtM;d-|1+tdjyhur&b$SH0g&P$$F9qUV| zm@^6(bCJ)}fP@TMMpkd(tEa1i<0&zpE&XC97AUP#h0a#Jsg=#xo2%|skp8am$7~Mr zV5r!4*}5;Ndu!upvtGWFDENLKV~T68ORYZbgNJ!;8AN~Esq*r@7W($2lO>8j{dL32 zYg+Rr3y9cwewt%^_qdX}mmlm3Jy$Mvwhgz@9q@ML^<$zp z^_V5===ABY zh8$+lmEW#!W<%LBw6%MGzGO1%hwz43hSez4nJmVFk&s$rP%$J>ET5@ha+# zyiUD1=zPwdN{YVV^iS*A!38af2m^Vkcma8ZnDQebZB_kZ08t?}>sjte&kQ{=?=`93GXV4!wj5wUWVCCzJFjiM4`AU&p+2zf1JugN$k)6ARtM>Ud+-Ak zPz^5!WN$V7KbQ&D%sBMht)g_hF~u)%zSOubf{y^?hQq%8`czY_pe}kIHp~)W2}R1i zz$IE9aAOg@i|-3N3JbMUOz}X<6!f`(-!Wr1#Ffy=NZn1$F6S>bSYiNP$91{|&{^c# z7>cYlUHQOVX^TLdgl~iy+yHC9q9!tZ993P|QdHgodbq>_%OEYq=XYljxFA4|wxIn( zHwZmf|F_rR7grPys-!JCP^u;rbiH$B`oD2R0kx_Bi=sU`3hd9~$h`#=*c!U;05|Na z0^%5QQad=37(Mqk-0|E?^EZwdAk`^a1_jCY+lOY}T&>@r zU2?$vQZS>X+$H}1SNyJefGUvxtkQnUKan7z=Lv3Y$JeTQ_b)%%BdVPPQXnqru{axX zin58@x+{}B0`{)DtAcoy>ykrPO>H_Ks3go#<4zLnX8}C_!RxUE2Ob3w?X+tI{C+0q z`Mi=Qhp>T$t&^nQX7w%yMyi zT$?BRiM&dW6gYSV7=IX@d}(&~(LKXC8Sm2I+kh4Xd4p+JLat-GNP8EYf@d=SH*bpKlwCoZ^F*z`id84Zf`A zU>;l{I96efJl+8N4a42k^g+Zn&%bVP_19C6EC?WVk#*Cp*-MxSdu8mQ>u=cc!!hx< zCllMWy`PcZ)@5@wSJXoBg`6Cy(mJQ~lNaDs`nJDcS3&W3QkaSDR4GaN?1mYR3f%JG zntS>6x}E1K9e-$H4KyV6|5v!W}5&ExH!DxC>!c zb!n+un%e>%QCisQQ_N@KEbe>h!y!OwD|l>nVHhy}En%X;SY18QbGJ$>W)YtM(%<;oJjE4FF)d|62>ds~6}A-~SceKBW1AT`zFGb_q>7-)fAx**#q) zk$0Beo$FHYu}nvSdRQ&5^(nqrtoSGxBh@N{w8sDz9q^KcXvXPWc@>4XLuN_ew`T&n zEl14if!}p-sA1U&fQ#$^8U^_oOOU0{%di2ll#(8CGS07})R$H)N4AAFf%-9{VxWbK zpQv#R?5T=l8B$NPkbQbWrrcF47ZOJF{#|mweMPUY0jVL&TxqGs3@gd>IhU~@tQe}hB$`=K=l1BHejIFCWeM2kS z?P2!YoWAVU=ulU%Z<6~;=P?QYk^y!`bn-qANO^|DgUulD@0UUwOK%dlBTFP!eU5Bm z?7WakcugT(vs2->o4RQUi{U0&lsOODO!8uO<0K*O4FHxMUAo%M>{5WwJNs3tV&Q> zeM7dCXa^p*)ITk+ktb7%R;M%Nu2@YJ@rEBbc~#+f@RsJX-aCuYN$-NzKYZvsg3__i zPuc(W<*KYpe4a)?rW0f9 z*I?E(Cq!t@9g_L*u(bo$>r{DXY9-BFI^;v*GwgREOv5!ikxcPPHN9`TIbhH0k22%E zmsMUcnV(Kf_P#p*ER8QZS>czsh#?cGE{~I3jhxnZf_p$Y|7B05Z-EfQT|9+*^oGt471Ir-f>EOkigqQMryr$B@tLfL=4c{al<-< zkkh~*U26wAtFxBxhE*X*XV-|Ope{^2dPB{p59fi)9vEy%+k0cJR@CuocU%&2gxJn^ zBfbR=6r4wlZR+d1ep?9;F8*i)bdmz|EmfaA{b^-j$fMoEq5M1PlP}MUJO=4iK&b!h zV*TuZ8gju6{!+$Ip-kcyhh*hsq*H#nTo--;Wd7fdf3h+b0|OPIYG{3yK(pNy4R>oXI)1U=9Ea#)n7apZjUqcc9? z4i=&D+iAUS@#bDDhT*X%?h`9&ElnTmTVc&O2kDQD=adXpGK*bFN2Aw7v`#Cr4qU%# z_15N-vScASyHjtR(S2I|V{-k%2^q>ZJ2+}^4>Qrc_o6rmx02=JQ_bGTpt*4tjz4cu zWhf(DM3Q+FwTI8=m2Dz4{_u|@G%RBx`v*+TYfdJbmPS-Z|FT4VhbbnJQ@ku7DUMLv z(5**HNK}>KBkVyCpL7I6mTw2xnOpT7?QZ!m3p>=cE=1cVU7E#QnS>MF^VH{evEUGL zlpig-(+XAZS*rynDEt*C39;3?UMU1FeZ|p_F&$?zVX$bxViiVzgko;4n9;S(KxJ;5 zKU+V4VAFLscm`S5akQHZ>ywsnwL=ZG(I&kF6FbYX_?bYt4@0dR6@T|Greggg|)|79+6)wqBy*|Cq~`sO&$} zl3c(m8An5?uzgf16U6Y9VOmr_aG22fjxUFoW!JGI#`nm??r6v%;Y)8SPRTIL z9ujS`%B}`Sb<}r!F7@1|a~o-;xI^P)n_Vn{0KBQW$2=i)_*k%+EXYp+74e8?CNG?o@Ka6vBdF;Q5lhAtnwYFp91Ou7PT?EE z&w_T+Nx_3!FPwiz<_b_x|01C1c$T`EL64zku-7jv8S{i?`i)#*kFfw-&<;N-7*p=x zSt3j!fm$eNX9^=GfDhTug!Qyr49z0qS)z$#F12^ib}`O765eY*(1;_CTo?q+f(7@D zBnBHch}vz-^|t5>kpKPM$NT^o4iCx4X7dK&G zK~Boj+a@j`dcZb#F7D3bD$D^IIR-}8l~LokTS-sCu($9epKcC(W2nZG`ZaQ7u0_Gt zI&$nAK)uj90-R=-atojqb5AkT3!vsgGbbme4;J$l>=SBw@zsBu!LjJ zhgdwnchc^vxsbG%L-!Ws<6)tl)H@H(;AaU1Td4===EdeH&8+*(PHK+ooL0BF@8+L? zDV@*oZ^&MR8uUv&b>cvhV+YY={a { + await page.route('**/api/settings', (route) => { + if (route.request().method() === 'GET') { + return route.fulfill({ json: { settings: null } }); + } + + return route.fulfill({ json: { success: true } }); + }); +} + +async function mockDeviceFlowStart( + page: Page, + overrides: Partial = {}, +): Promise { + await page.route('**/auth/device/start', (route) => + route.fulfill({ + json: { + ...DEFAULT_DEVICE_FLOW, + ...overrides, + }, + }), + ); +} + +async function mockAuthenticatedWebSocket(page: Page): Promise { + await page.routeWebSocket('**/ws*', (ws) => { + ws.send(JSON.stringify({ type: 'connected', user: MOCK_USER.login })); + + ws.onMessage((data) => { + const message = JSON.parse(data as string) as { type?: string }; + + if (message.type === 'new_session') { + ws.send(JSON.stringify({ type: 'session_created', model: 'gpt-4.1' })); + } + + if (message.type === 'list_models') { + ws.send( + JSON.stringify({ + type: 'models', + models: [ + { + id: 'gpt-4.1', + name: 'GPT-4.1', + vendor: 'OpenAI', + capabilities: { supports: {} }, + }, + ], + }), + ); + } + }); + }); +} + +async function mockAuthReloadState( + page: Page, + authState: { authenticated: boolean }, +): Promise { + await page.route('/', async (route) => { + const response = await route.fetch(); + let html = await response.text(); + + if (authState.authenticated) { + html = html.replace( + /data:\{authenticated:false,user:null\}/g, + `data:{authenticated:true,user:{login:"${MOCK_USER.login}",name:"${MOCK_USER.name}"}}`, + ); + } + + await route.fulfill({ response, body: html }); + }); + + await page.route('**/__data.json*', (route) => + route.fulfill({ + contentType: 'application/json', + body: JSON.stringify(buildLayoutData(authState.authenticated)), + }), + ); + + await page.route('**/auth/status', (route) => + route.fulfill({ + json: { + authenticated: authState.authenticated, + user: authState.authenticated + ? { login: MOCK_USER.login, name: MOCK_USER.name } + : null, + githubUser: authState.authenticated ? MOCK_USER.login : null, + }, + }), + ); +} + +test.describe('Device flow authentication', () => { + test('completes the full device flow journey and opens the chat screen', async ({ page }) => { + const authState = { authenticated: false }; + let pollCount = 0; + + await mockAuthReloadState(page, authState); + await mockSettingsApi(page); + await mockAuthenticatedWebSocket(page); + await mockDeviceFlowStart(page); + await page.route('**/auth/device/poll', (route) => { + pollCount += 1; + + if (pollCount === 1) { + return route.fulfill({ json: { status: 'pending' } }); + } + + authState.authenticated = true; + return route.fulfill({ + json: { + status: 'authorized', + githubUser: MOCK_USER.login, + user: AUTHORIZED_USER, + }, + }); + }); + + await page.goto('/'); + + await expect(page.locator('.login-screen')).toBeVisible(); + await expect(page.locator('.device-code-text')).toHaveText('ABCD-1234'); + await expect(page.locator('.login-status')).toContainText('Waiting for authorization'); + + await expect(page.locator('.terminal')).toBeVisible({ timeout: 15000 }); + await expect(page.locator('.hamburger-btn')).toBeVisible({ timeout: 15000 }); + await expect(page.locator('.login-screen')).toBeHidden(); + }); + + test('shows an error state when the device flow cannot start', async ({ page }) => { + await page.route('**/auth/device/start', (route) => + route.fulfill({ + status: 500, + json: { error: 'Failed to start device flow' }, + }), + ); + + await page.goto('/'); + + await expect(page.locator('.login-screen')).toBeVisible(); + await expect(page.locator('.login-status')).toContainText('Failed to start device flow'); + await expect(page.locator('.spinner-char.failed')).toBeVisible(); + }); + + test('shows an expired message when the device code expires', async ({ page }) => { + await mockDeviceFlowStart(page); + await page.route('**/auth/device/poll', (route) => + route.fulfill({ json: { status: 'expired' } }), + ); + + await page.goto('/'); + + await expect(page.locator('.device-code-text')).toHaveText('ABCD-1234'); + await expect(page.locator('.login-status')).toContainText('Code expired'); + await expect(page.locator('.spinner-char.failed')).toBeVisible(); + }); + + test('shows a denied message when GitHub authorization is cancelled', async ({ page }) => { + await mockDeviceFlowStart(page); + await page.route('**/auth/device/poll', (route) => + route.fulfill({ json: { status: 'access_denied' } }), + ); + + await page.goto('/'); + + await expect(page.locator('.device-code-text')).toHaveText('ABCD-1234'); + await expect(page.locator('.login-status')).toContainText('Access denied'); + await expect(page.locator('.spinner-char.failed')).toBeVisible(); + }); + + test('displays the countdown timer while polling for authorization', async ({ page }) => { + await mockDeviceFlowStart(page, { + user_code: 'TIME-4321', + expires_in: 125, + interval: 60, + }); + await page.route('**/auth/device/poll', (route) => + route.fulfill({ json: { status: 'pending' } }), + ); + + await page.goto('/'); + + await expect(page.locator('.device-code-text')).toHaveText('TIME-4321'); + await expect(page.locator('.login-expires')).toBeVisible(); + await expect(page.locator('.login-expires')).toContainText(/Code expires in \d+:\d{2}/); + }); + + test('copies the device code to the clipboard', async ({ page }) => { + await page.addInitScript(() => { + const clipboardState = { value: '' }; + + Object.defineProperty(window, '__copiedText', { + value: clipboardState, + configurable: true, + }); + + Object.defineProperty(navigator, 'clipboard', { + value: { + writeText: async (text: string) => { + clipboardState.value = text; + }, + }, + configurable: true, + }); + }); + + await mockDeviceFlowStart(page, { user_code: 'COPY-2468', interval: 60 }); + await page.route('**/auth/device/poll', (route) => + route.fulfill({ json: { status: 'pending' } }), + ); + + await page.goto('/'); + + const copyButton = page.locator('button.copy-code-btn'); + await expect(copyButton).toBeVisible(); + await expect(page.locator('.device-code-text')).toHaveText('COPY-2468'); + + await copyButton.click(); + + await expect(copyButton).toHaveText('copied!'); + await expect + .poll(() => + page.evaluate(() => { + const windowWithClipboard = window as typeof window & { + __copiedText?: { value: string }; + }; + + return windowWithClipboard.__copiedText?.value ?? ''; + }), + ) + .toBe('COPY-2468'); + }); +}); + +test.describe('Authenticated session actions', () => { + test('signing out returns the user to the login screen', async ({ page }) => { + const authState = { authenticated: true }; + + await mockAuthReloadState(page, authState); + await mockSettingsApi(page); + await mockAuthenticatedWebSocket(page); + await page.route('**/auth/logout', (route) => { + authState.authenticated = false; + return route.fulfill({ json: { success: true } }); + }); + + await page.goto('/'); + await expect(page.locator('.terminal')).toBeVisible({ timeout: 10000 }); + + await page.locator('button.hamburger-btn').click(); + const signOutButton = page.locator('button.sidebar-action.sidebar-action-danger'); + await expect(signOutButton).toBeVisible(); + + await signOutButton.click(); + + await expect(page.locator('.login-screen')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('.terminal')).toBeHidden(); + }); +}); diff --git a/tests/chat-messaging.spec.ts b/tests/chat-messaging.spec.ts new file mode 100644 index 0000000..93970f7 --- /dev/null +++ b/tests/chat-messaging.spec.ts @@ -0,0 +1,269 @@ +import { test, expect, type Browser, type Page } from '@playwright/test'; +import { + createAuthenticatedPage, + mockWebSocket, + goToChat, + sendMessage, + createMessageSequence, + MOCK_USER, + MOCK_MODELS, + type MockWsOptions, +} from './helpers'; + +async function withAuthenticatedChat( + browser: Browser, + options: MockWsOptions, + run: (page: Page) => Promise, +) { + const { page, context } = await createAuthenticatedPage(browser); + + await mockWebSocket(page, { + defaultModel: MOCK_MODELS[0].id, + ...options, + }); + + await goToChat(page); + + try { + await run(page); + } finally { + await context.close(); + } +} + +test.describe('Chat messaging', () => { + test('shows the banner before the first message', async ({ browser }) => { + await withAuthenticatedChat(browser, {}, async (page) => { + await expect(page.locator('.banner-box')).toBeVisible(); + await expect(page.locator('button.send-btn')).toBeVisible(); + }); + }); + + test('sends a message and receives a response', async ({ browser }) => { + await withAuthenticatedChat( + browser, + { + onMessage: (msg, ws) => { + if (msg.type === 'message') { + const seq = createMessageSequence(ws); + seq + .send({ type: 'turn_start' }, 20) + .send({ type: 'delta', content: 'Hello!' }, 120) + .send({ type: 'turn_end' }, 120) + .send({ type: 'done' }, 50); + } + }, + }, + async (page) => { + await sendMessage(page, 'Hi there'); + + await expect(page.locator('.message.user .user-marker')).toContainText(MOCK_USER.login); + await expect(page.locator('.message.user .user-text')).toContainText('Hi there'); + await expect(page.locator('.message.assistant .content')).toContainText('Hello!'); + }, + ); + }); + + test('shows streaming state while assistant text is arriving', async ({ browser }) => { + await withAuthenticatedChat( + browser, + { + onMessage: (msg, ws) => { + if (msg.type === 'message') { + const seq = createMessageSequence(ws); + seq + .send({ type: 'turn_start' }, 20) + .send({ type: 'delta', content: 'Hello' }, 150) + .send({ type: 'delta', content: ' there' }, 180) + .send({ type: 'turn_end' }, 350) + .send({ type: 'done' }, 50); + } + }, + }, + async (page) => { + await sendMessage(page, 'Stream a response'); + + const streamingMessage = page.locator('.message.assistant.streaming'); + await expect(streamingMessage).toBeVisible(); + await expect(streamingMessage.locator('.content')).toContainText('Hello'); + await expect(page.locator('button.stop-btn')).toBeVisible(); + + await expect(streamingMessage).toHaveCount(0); + await expect(page.locator('.message.assistant .content')).toContainText('Hello there'); + }, + ); + }); + + test('renders tool call progress and completion', async ({ browser }) => { + await withAuthenticatedChat( + browser, + { + onMessage: (msg, ws) => { + if (msg.type === 'message') { + const seq = createMessageSequence(ws); + seq + .send({ type: 'turn_start' }, 20) + .send({ type: 'tool_start', toolCallId: 'tool-1', toolName: 'read_file', mcpServerName: 'filesystem' }, 80) + .send({ type: 'tool_progress', toolCallId: 'tool-1', message: 'Reading src/lib/app.ts…' }, 120) + .send({ type: 'tool_end', toolCallId: 'tool-1' }, 160) + .send({ type: 'delta', content: 'Finished reading the file.' }, 120) + .send({ type: 'turn_end' }, 80) + .send({ type: 'done' }, 50); + } + }, + }, + async (page) => { + await sendMessage(page, 'Inspect the app file'); + + await expect(page.locator('.tool-call-wrapper')).toBeVisible(); + await expect(page.locator('.tool-call-wrapper .tool-name')).toContainText('read_file'); + await expect(page.locator('.tool-call-wrapper .tool-status')).toContainText('Reading src/lib/app.ts…'); + + await page.locator('.tool-call.expandable').click(); + await expect(page.locator('.tool-progress-item')).toContainText('Reading src/lib/app.ts…'); + await expect(page.locator('.tool-call-wrapper .tool-status')).toContainText('done'); + await expect(page.locator('.message.assistant .content')).toContainText('Finished reading the file.'); + }, + ); + }); + + test('shows the reasoning block before the final answer', async ({ browser }) => { + await withAuthenticatedChat( + browser, + { + onMessage: (msg, ws) => { + if (msg.type === 'message') { + const seq = createMessageSequence(ws); + seq + .send({ type: 'turn_start' }, 20) + .send({ type: 'reasoning_delta', content: 'Thinking through the request. ', reasoningId: 'reasoning-1' }, 100) + .send({ type: 'reasoning_delta', content: 'Checking the best approach.', reasoningId: 'reasoning-1' }, 160) + .send({ type: 'delta', content: 'Here is the final answer.' }, 260) + .send({ type: 'turn_end' }, 320) + .send({ type: 'done' }, 50); + } + }, + }, + async (page) => { + await sendMessage(page, 'Explain your approach'); + + await expect(page.locator('.reasoning-block')).toBeVisible(); + await expect(page.locator('.reasoning-block .reasoning-content')).toContainText( + 'Thinking through the request. Checking the best approach.', + ); + await expect(page.locator('.message.assistant .content')).toContainText('Here is the final answer.'); + }, + ); + }); + + test('renders usage information after a completed turn', async ({ browser }) => { + await withAuthenticatedChat( + browser, + { + onMessage: (msg, ws) => { + if (msg.type === 'message') { + const seq = createMessageSequence(ws); + seq + .send({ type: 'turn_start' }, 20) + .send({ type: 'delta', content: 'Usage details coming up.' }, 100) + .send({ type: 'turn_end' }, 120) + .send({ type: 'done' }, 50) + .send({ type: 'usage', inputTokens: 12, outputTokens: 8 }, 50); + } + }, + }, + async (page) => { + await sendMessage(page, 'Show usage'); + + await expect(page.locator('.usage-line')).toBeVisible(); + await expect(page.locator('.usage-line')).toContainText('in: 12'); + await expect(page.locator('.usage-line')).toContainText('out: 8'); + }, + ); + }); + + test('shows the stop button during streaming and sends abort when clicked', async ({ browser }) => { + const outboundMessages: Array> = []; + + await withAuthenticatedChat( + browser, + { + onMessage: (msg, ws) => { + outboundMessages.push(msg); + + if (msg.type === 'message') { + const seq = createMessageSequence(ws); + seq + .send({ type: 'turn_start' }, 20) + .send({ type: 'delta', content: 'Still working…' }, 150); + } + }, + }, + async (page) => { + await sendMessage(page, 'Start streaming'); + + await expect(page.locator('.message.assistant.streaming')).toBeVisible(); + await expect(page.locator('button.stop-btn')).toBeVisible(); + + await page.locator('button.stop-btn').click(); + + await expect.poll(() => outboundMessages.filter((msg) => msg.type === 'abort').length).toBe(1); + }, + ); + }); + + test('renders websocket error messages', async ({ browser }) => { + await withAuthenticatedChat( + browser, + { + onMessage: (msg, ws) => { + if (msg.type === 'message') { + ws.send(JSON.stringify({ type: 'error', message: 'Something went wrong.' })); + } + }, + }, + async (page) => { + await sendMessage(page, 'Trigger an error'); + + await expect(page.locator('.message.error')).toBeVisible(); + await expect(page.locator('.message.error')).toContainText('Something went wrong.'); + }, + ); + }); + + test('renders websocket warning messages', async ({ browser }) => { + await withAuthenticatedChat( + browser, + { + onMessage: (msg, ws) => { + if (msg.type === 'message') { + ws.send(JSON.stringify({ type: 'warning', message: 'Model context is almost full.' })); + } + }, + }, + async (page) => { + await sendMessage(page, 'Trigger a warning'); + + await expect(page.locator('.message.warning')).toBeVisible(); + await expect(page.locator('.message.warning')).toContainText('Model context is almost full.'); + }, + ); + }); + + test('updates the top bar title when the session title changes', async ({ browser }) => { + await withAuthenticatedChat( + browser, + { + onMessage: (msg, ws) => { + if (msg.type === 'new_session') { + ws.send(JSON.stringify({ type: 'title_changed', title: 'Refactor websocket handling' })); + } + }, + }, + async (page) => { + await expect(page.locator('.title-text')).toBeVisible(); + await expect(page.locator('.title-text')).toContainText('Refactor websocket handling'); + }, + ); + }); +}); diff --git a/tests/chat.spec.ts b/tests/chat.spec.ts new file mode 100644 index 0000000..b7c8d0c --- /dev/null +++ b/tests/chat.spec.ts @@ -0,0 +1,78 @@ +import { test, expect, type Page } from '@playwright/test'; + +// SvelteKit renders the chat screen server-side when authenticated. +// Without a real session, the server returns `authenticated: false` and +// renders DeviceFlowLogin. To test the chat screen, we need the server +// to think we're authenticated. Since we can't easily mock server-side +// session in Playwright, we test what we can at the structural level. + +// The login screen IS rendered by default (no session) — so chat screen +// tests are limited to structural checks unless we inject a real session. + +test.describe('Chat screen structure', () => { + test('unauthenticated users see login, not chat', async ({ page }) => { + await page.goto('/'); + // Should see the login screen + await expect(page.locator('.login-screen')).toBeVisible(); + // Should NOT see the chat terminal + await expect(page.locator('.terminal')).not.toBeVisible(); + }); + + test('page loads without JS errors', async ({ page }) => { + const errors: string[] = []; + page.on('pageerror', (err) => errors.push(err.message)); + await page.goto('/'); + await page.waitForTimeout(2000); + // Filter out expected errors (WebSocket connection failures in test) + const unexpected = errors.filter( + (e) => !e.includes('WebSocket') && !e.includes('fetch'), + ); + expect(unexpected).toHaveLength(0); + }); + + test('health endpoint responds', async ({ page }) => { + const response = await page.request.get('/health'); + expect(response.ok()).toBeTruthy(); + }); + + test('auth status endpoint responds', async ({ page }) => { + const response = await page.request.get('/auth/status'); + expect(response.ok()).toBeTruthy(); + const data = await response.json(); + expect(data).toHaveProperty('authenticated'); + }); + + test('models API requires auth', async ({ page }) => { + const response = await page.request.get('/api/models'); + // Should return 401 or redirect when not authenticated + expect([401, 403, 302].includes(response.status()) || response.ok()).toBeTruthy(); + }); + + test('health endpoint returns valid JSON', async ({ page }) => { + const response = await page.request.get('/health'); + expect(response.status()).toBe(200); + expect(response.headers()['content-type']).toContain('application/json'); + + const data = await response.json(); + expect(data).toEqual({ status: 'ok' }); + }); + + test('auth status endpoint returns unauthenticated JSON shape', async ({ page }) => { + const response = await page.request.get('/auth/status'); + expect(response.status()).toBe(200); + expect(response.headers()['content-type']).toContain('application/json'); + + const data = await response.json(); + expect(data).toMatchObject({ authenticated: false, githubUser: null }); + expect(data).not.toHaveProperty('user'); + }); + + test('models API returns 401 when not authenticated', async ({ page }) => { + const response = await page.request.get('/api/models'); + expect(response.status()).toBe(401); + expect(response.headers()['content-type']).toContain('application/json'); + + const data = await response.json(); + expect(data).toHaveProperty('error'); + }); +}); diff --git a/tests/error-handling.spec.ts b/tests/error-handling.spec.ts new file mode 100644 index 0000000..271f000 --- /dev/null +++ b/tests/error-handling.spec.ts @@ -0,0 +1,222 @@ +import { test, expect, type Browser } from '@playwright/test'; +import { + createAuthenticatedPage, + mockWebSocket, + goToChat, + sendMessage, + MOCK_USER, +} from './helpers'; + +async function setupAuthenticatedChat( + browser: Browser, + options: Parameters[1] = {}, +) { + const session = await createAuthenticatedPage(browser); + await mockWebSocket(session.page, options); + + try { + await goToChat(session.page); + } catch { + await session.page.waitForSelector('.terminal', { state: 'visible', timeout: 10000 }); + await session.page.click('.newchat-btn'); + await session.page.waitForSelector('textarea:not([disabled])', { + state: 'visible', + timeout: 10000, + }); + } + + return session; +} + +test.describe('Error handling', () => { + test('404 page renders for unknown routes', async ({ page }) => { + const response = await page.goto('/nonexistent-route'); + + expect(response).not.toBeNull(); + expect(response?.status()).toBe(404); + + const bodyText = (await page.textContent('body'))?.trim() ?? ''; + expect(bodyText.length).toBeGreaterThan(0); + await expect(page.locator('.login-screen')).toHaveCount(0); + }); + + test('server error message displays in chat', async ({ browser }) => { + const { page, context } = await setupAuthenticatedChat(browser, { + onMessage: (msg, ws) => { + if (msg.type === 'message') { + setTimeout(() => { + ws.send(JSON.stringify({ type: 'error', message: 'Something went wrong' })); + }, 100); + } + }, + }); + + try { + await sendMessage(page, 'trigger an error'); + + await expect(page.locator('.message.user').last()).toContainText(MOCK_USER.login); + await expect(page.locator('.message.error')).toBeVisible(); + await expect(page.locator('.message.error')).toContainText('Something went wrong'); + } finally { + await context.close(); + } + }); + + test('warning message displays in chat', async ({ browser }) => { + const { page, context } = await setupAuthenticatedChat(browser, { + onMessage: (msg, ws) => { + if (msg.type === 'message') { + setTimeout(() => { + ws.send(JSON.stringify({ type: 'warning', message: 'Rate limit approaching' })); + }, 100); + } + }, + }); + + try { + await sendMessage(page, 'warn me'); + + await expect(page.locator('.message.warning')).toBeVisible(); + await expect(page.locator('.message.warning')).toContainText('Rate limit approaching'); + } finally { + await context.close(); + } + }); + + test('connection indicator shows connected', async ({ browser }) => { + const { page, context } = await setupAuthenticatedChat(browser); + + try { + await expect(page.locator('.conn-dot.dot-connected')).toBeVisible(); + await expect(page.locator('.conn-dot.dot-disconnected')).toHaveCount(0); + await expect(page.locator('.conn-dot.dot-connecting')).toHaveCount(0); + } finally { + await context.close(); + } + }); + + test('health endpoint returns OK', async ({ request }) => { + const response = await request.get('/health'); + + expect(response.status()).toBe(200); + expect(await response.json()).toEqual({ status: 'ok' }); + }); + + test('auth status shows unauthenticated', async ({ request }) => { + const response = await request.get('/auth/status'); + const data = await response.json(); + + expect(response.status()).toBe(200); + expect(data).toEqual({ authenticated: false, githubUser: null }); + }); + + test('API routes require auth', async ({ request }) => { + const response = await request.get('/api/models'); + const bodyText = await response.text(); + + expect(response.status()).not.toBe(200); + expect(bodyText.trim().length).toBeGreaterThan(0); + }); + + test('multiple errors display', async ({ browser }) => { + const { page, context } = await setupAuthenticatedChat(browser, { + onMessage: (msg, ws) => { + if (msg.type === 'message') { + setTimeout(() => { + ws.send(JSON.stringify({ type: 'error', message: 'First error' })); + }, 50); + setTimeout(() => { + ws.send(JSON.stringify({ type: 'error', message: 'Second error' })); + }, 100); + setTimeout(() => { + ws.send(JSON.stringify({ type: 'error', message: 'Third error' })); + }, 150); + } + }, + }); + + try { + await sendMessage(page, 'trigger multiple errors'); + + const errors = page.locator('.message.error'); + await expect(errors).toHaveCount(3); + await expect(errors.nth(0)).toContainText('First error'); + await expect(errors.nth(1)).toContainText('Second error'); + await expect(errors.nth(2)).toContainText('Third error'); + } finally { + await context.close(); + } + }); + + test('error after streaming', async ({ browser }) => { + const { page, context } = await setupAuthenticatedChat(browser, { + onMessage: (msg, ws) => { + if (msg.type === 'message') { + setTimeout(() => { + ws.send(JSON.stringify({ type: 'turn_start' })); + }, 50); + setTimeout(() => { + ws.send(JSON.stringify({ type: 'delta', content: 'Partial response' })); + }, 100); + setTimeout(() => { + ws.send(JSON.stringify({ type: 'delta', content: ' before failure' })); + }, 150); + setTimeout(() => { + ws.send(JSON.stringify({ type: 'error', message: 'Stream interrupted' })); + }, 200); + } + }, + }); + + try { + await sendMessage(page, 'start streaming'); + + await expect(page.locator('.message.error')).toBeVisible(); + await expect(page.locator('.message.error')).toContainText('Stream interrupted'); + await expect(page.locator('.message.assistant.streaming')).toHaveCount(0); + } finally { + await context.close(); + } + }); + + test('page loads without unexpected JS errors', async ({ browser }) => { + const { page, context } = await createAuthenticatedPage(browser); + const errors: string[] = []; + + page.on('pageerror', (err) => errors.push(err.message)); + + await mockWebSocket(page, { + onMessage: (msg, ws) => { + if (msg.type === 'message') { + setTimeout(() => { + ws.send(JSON.stringify({ type: 'turn_start' })); + }, 50); + setTimeout(() => { + ws.send(JSON.stringify({ type: 'delta', content: 'All good here' })); + }, 100); + setTimeout(() => { + ws.send(JSON.stringify({ type: 'turn_end' })); + }, 150); + } + }, + }); + + try { + await goToChat(page); + await expect(page.locator('.conn-dot.dot-connected')).toBeVisible(); + await sendMessage(page, 'sanity check'); + await expect(page.locator('.message.assistant')).toContainText('All good here'); + + const unexpected = errors.filter( + (e) => + !e.includes('WebSocket') && + !e.includes('fetch') && + !e.includes('Failed to fetch') && + !e.includes('NetworkError'), + ); + expect(unexpected).toHaveLength(0); + } finally { + await context.close(); + } + }); +}); diff --git a/tests/helpers.ts b/tests/helpers.ts new file mode 100644 index 0000000..3c4aea8 --- /dev/null +++ b/tests/helpers.ts @@ -0,0 +1,236 @@ +/** + * Shared Playwright test helpers for authenticated E2E tests. + * + * Provides: + * - createAuthenticatedPage() — patches SSR + __data.json + auth status + * - mockWebSocket() — standard WS mock with configurable handlers + * - MOCK_MODELS, MOCK_USER — shared test data + */ + +import type { Browser, Page, BrowserContext } from '@playwright/test'; + +// ── Shared test data ────────────────────────────────────────────────────────── + +export const MOCK_USER = { login: 'testuser', name: 'Test User' }; + +export const MOCK_MODELS = [ + { id: 'gpt-4.1', name: 'GPT-4.1', vendor: 'OpenAI', capabilities: { supports: {} } }, + { id: 'o3', name: 'o3', vendor: 'OpenAI', capabilities: { supports: { reasoningEffort: true } } }, + { id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6', vendor: 'Anthropic', capabilities: { supports: { reasoningEffort: true } } }, + { id: 'claude-haiku-4-6', name: 'Claude Haiku 4.6', vendor: 'Anthropic', capabilities: { supports: {} } }, + { id: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro', vendor: 'Google', capabilities: { supports: { reasoningEffort: true } } }, +]; + +export const MOCK_TOOLS = [ + { name: 'read_file', enabled: true, description: 'Read file contents' }, + { name: 'write_file', enabled: true, description: 'Write file contents' }, + { name: 'run_terminal', enabled: false, description: 'Execute terminal commands' }, +]; + +export const MOCK_AGENTS = [ + { slug: 'copilot', name: 'Copilot', description: 'Default assistant', current: true }, + { slug: 'reviewer', name: 'Code Reviewer', description: 'Reviews code changes', current: false }, +]; + +export const MOCK_SESSIONS = [ + { + id: 'session-1', + title: 'TypeScript refactoring', + model: 'gpt-4.1', + mode: 'ask', + branch: 'main', + workspacePath: '/home/user/project', + createdAt: new Date(Date.now() - 3600000).toISOString(), + updatedAt: new Date(Date.now() - 1800000).toISOString(), + messageCount: 12, + checkpointCount: 3, + }, + { + id: 'session-2', + title: 'Fix login bug', + model: 'claude-sonnet-4-6', + mode: 'agent', + branch: 'fix/login', + workspacePath: '/home/user/project', + createdAt: new Date(Date.now() - 86400000).toISOString(), + updatedAt: new Date(Date.now() - 43200000).toISOString(), + messageCount: 25, + checkpointCount: 5, + }, + { + id: 'session-3', + title: 'Add unit tests', + model: 'o3', + mode: 'plan', + branch: 'feat/tests', + workspacePath: '/home/user/project', + createdAt: new Date(Date.now() - 172800000).toISOString(), + updatedAt: new Date(Date.now() - 86400000).toISOString(), + messageCount: 8, + checkpointCount: 1, + }, +]; + +// ── Authenticated page helper ───────────────────────────────────────────────── + +export interface AuthenticatedPage { + page: Page; + context: BrowserContext; +} + +/** + * Creates a page with mocked authentication. + * Patches SSR HTML, __data.json, and /auth/status to simulate logged-in state. + */ +export async function createAuthenticatedPage( + browser: Browser, + viewport: { width: number; height: number } = { width: 1280, height: 800 }, +): Promise { + const context = await browser.newContext({ + viewport, + baseURL: process.env.PLAYWRIGHT_TEST_BASE_URL ?? 'http://localhost:3001', + }); + const page = await context.newPage(); + + // Patch SSR HTML to set authenticated=true + await page.route((url) => url.pathname === '/', async (route) => { + const response = await route.fetch(); + let html = await response.text(); + html = html.replace( + /authenticated:false,user:null/g, + `authenticated:true,user:{login:"${MOCK_USER.login}",name:"${MOCK_USER.name}"}`, + ); + await route.fulfill({ response, body: html }); + }); + + // Mock __data.json for client-side navigations + await page.route('**/__data.json*', (route) => + route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ + type: 'data', + nodes: [ + null, + { + type: 'data', + data: [ + { authenticated: true, user: { login: MOCK_USER.login, name: MOCK_USER.name } }, + 1, + ], + }, + ], + }), + }), + ); + + // Mock auth status + await page.route('**/auth/status', (route) => + route.fulfill({ + json: { authenticated: true, githubUser: MOCK_USER.login }, + }), + ); + + return { page, context }; +} + +// ── WebSocket mock helpers ──────────────────────────────────────────────────── + +export interface WsMessageHandler { + (msg: Record, ws: { send: (data: string) => void }): void; +} + +export interface MockWsOptions { + /** Called when a WS message is received from the client */ + onMessage?: WsMessageHandler; + /** Override the default model list */ + models?: typeof MOCK_MODELS; + /** Default model for new sessions */ + defaultModel?: string; + /** Whether to auto-respond to new_session */ + autoCreateSession?: boolean; + /** Whether to auto-respond to list_models */ + autoListModels?: boolean; +} + +/** + * Sets up a WebSocket mock on the page. + * By default: sends 'connected', auto-responds to list_models and new_session. + */ +export async function mockWebSocket(page: Page, options: MockWsOptions = {}) { + const { + onMessage, + models = MOCK_MODELS, + defaultModel = 'gpt-4.1', + autoCreateSession = true, + autoListModels = true, + } = options; + + await page.context().routeWebSocket('**/ws**', (ws) => { + setTimeout(() => { + ws.send(JSON.stringify({ type: 'connected', user: MOCK_USER.login })); + }, 10); + + ws.onMessage((data) => { + const msg = JSON.parse(data as string); + + if (autoListModels && msg.type === 'list_models') { + ws.send(JSON.stringify({ type: 'models', models })); + } + + if (autoCreateSession && msg.type === 'new_session') { + ws.send(JSON.stringify({ type: 'session_created', model: defaultModel })); + } + + if (onMessage) { + onMessage(msg, { send: (d: string) => ws.send(d) }); + } + }); + }); +} + +/** + * Navigates to the authenticated chat and waits for the terminal to be ready. + */ +export async function goToChat(page: Page) { + for (let attempt = 0; attempt < 3; attempt += 1) { + await page.goto('/', { waitUntil: 'networkidle' }); + + if (await page.locator('.terminal').isVisible().catch(() => false)) { + await page.waitForSelector('textarea:not([disabled])', { state: 'visible', timeout: 30000 }); + return; + } + + await page.waitForTimeout(1000); + } + + await page.waitForSelector('.terminal', { state: 'visible', timeout: 30000 }); + await page.waitForSelector('textarea:not([disabled])', { state: 'visible', timeout: 30000 }); +} + +/** + * Sends a chat message by filling textarea and pressing Enter. + */ +export async function sendMessage(page: Page, text: string) { + await page.fill('textarea', text); + await page.keyboard.press('Enter'); +} + +/** + * Creates a delayed message sender for simulating server response sequences. + */ +export function createMessageSequence(ws: { send: (data: string) => void }) { + let delay = 0; + const timeouts: ReturnType[] = []; + + return { + send(msg: Record, additionalDelay = 50) { + delay += additionalDelay; + const t = setTimeout(() => ws.send(JSON.stringify(msg)), delay); + timeouts.push(t); + return this; + }, + cleanup() { + timeouts.forEach(clearTimeout); + }, + }; +} diff --git a/tests/login.spec.ts b/tests/login.spec.ts new file mode 100644 index 0000000..892d17a --- /dev/null +++ b/tests/login.spec.ts @@ -0,0 +1,148 @@ +import { test, expect, type Page } from '@playwright/test'; + +// SvelteKit uses server-side auth check via +layout.server.ts. +// In test mode with no real session, the server returns unauthenticated, +// so the DeviceFlowLogin component renders by default. + +test.describe('Login screen', () => { + test('shows login screen by default (unauthenticated)', async ({ page }) => { + await page.goto('/'); + const loginScreen = page.locator('.login-screen'); + await expect(loginScreen).toBeVisible(); + }); + + test('displays login title', async ({ page }) => { + await page.goto('/'); + await expect(page.locator('.hero-title')).toContainText('Copilot Unleashed'); + }); + + test('shows device code area before flow completes', async ({ page }) => { + await page.route('**/auth/device/start', (route) => + route.fulfill({ + json: { + user_code: 'PLCH-0001', + verification_uri: 'https://github.com/login/device', + expires_in: 900, + interval: 5, + }, + }), + ); + await page.route('**/auth/device/poll', (route) => + route.fulfill({ json: { status: 'pending' } }), + ); + await page.goto('/'); + const codeText = page.locator('.device-code-text'); + await expect(codeText.first()).toBeVisible({ timeout: 10000 }); + }); + + test('shows copy button once device code loads', async ({ page }) => { + // Mock device flow start + await page.route('**/auth/device/start', (route) => + route.fulfill({ + json: { + user_code: 'ABCD-1234', + verification_uri: 'https://github.com/login/device', + expires_in: 900, + interval: 5, + }, + }), + ); + await page.route('**/auth/device/poll', (route) => + route.fulfill({ json: { status: 'pending' } }), + ); + + await page.goto('/'); + const copyBtn = page.locator('.copy-code-btn'); + await expect(copyBtn).toBeVisible({ timeout: 10000 }); + }); + + test('shows device code from server', async ({ page }) => { + await page.route('**/auth/device/start', (route) => + route.fulfill({ + json: { + user_code: 'TEST-9999', + verification_uri: 'https://github.com/login/device', + expires_in: 900, + interval: 5, + }, + }), + ); + await page.route('**/auth/device/poll', (route) => + route.fulfill({ json: { status: 'pending' } }), + ); + + await page.goto('/'); + const codeText = page.locator('.device-code-text').first(); + await expect(codeText).toHaveText('TEST-9999', { timeout: 10000 }); + }); + + test('shows GitHub device link', async ({ page }) => { + await page.route('**/auth/device/start', (route) => + route.fulfill({ + json: { + user_code: 'LINK-0001', + verification_uri: 'https://github.com/login/device', + expires_in: 900, + interval: 5, + }, + }), + ); + await page.route('**/auth/device/poll', (route) => + route.fulfill({ json: { status: 'pending' } }), + ); + + await page.goto('/'); + const link = page.locator('.device-link-btn'); + await expect(link).toBeVisible({ timeout: 10000 }); + await expect(link).toHaveAttribute('href', 'https://github.com/login/device'); + }); + + test('shows spinner while polling', async ({ page }) => { + await page.route('**/auth/device/start', (route) => + route.fulfill({ + json: { + user_code: 'SPIN-0001', + verification_uri: 'https://github.com/login/device', + expires_in: 900, + interval: 5, + }, + }), + ); + await page.route('**/auth/device/poll', (route) => + route.fulfill({ json: { status: 'pending' } }), + ); + + await page.goto('/'); + const spinner = page.locator('.spinner-char'); + await expect(spinner.first()).toBeVisible({ timeout: 10000 }); + }); + + test('shows error state when device flow start fails', async ({ page }) => { + await page.route('**/auth/device/start', (route) => + route.fulfill({ + status: 500, + contentType: 'application/json', + body: JSON.stringify({ error: 'Failed to start device flow' }), + }), + ); + + await page.goto('/'); + await expect(page.locator('.login-screen')).toBeVisible(); + await expect(page.locator('.hero')).toBeVisible(); + await expect(page.locator('.login-status')).toContainText('Failed to start device flow', { + timeout: 10000, + }); + }); + + test('login screen has hero section', async ({ page }) => { + await page.goto('/'); + await expect(page.locator('.hero')).toBeVisible(); + await expect(page.locator('.hero-tagline')).toContainText( + 'Multi-model AI chat powered by GitHub Copilot SDK', + ); + await expect(page.locator('.feature')).toHaveCount(3); + await expect(page.locator('.hero-features')).toContainText('GPT, Claude, Gemini — one interface'); + await expect(page.locator('.hero-features')).toContainText('Real-time streaming responses'); + await expect(page.locator('.hero-features')).toContainText('GitHub tools & MCP servers built-in'); + }); +}); diff --git a/tests/messages.spec.ts b/tests/messages.spec.ts new file mode 100644 index 0000000..17df30a --- /dev/null +++ b/tests/messages.spec.ts @@ -0,0 +1,99 @@ +import { test, expect, type Page } from '@playwright/test'; + +// SvelteKit renders chat screen only when authenticated (server-side). +// Without a real GitHub session, message rendering tests are limited. +// These tests verify the component structure renders without errors. + +test.describe('Message rendering — structural', () => { + test('login page loads without console errors', async ({ page }) => { + const errors: string[] = []; + page.on('pageerror', (err) => errors.push(err.message)); + + await page.goto('/'); + await page.waitForTimeout(2000); + + // Allow WebSocket/fetch errors (expected without real server session) + const unexpected = errors.filter( + (e) => + !e.includes('WebSocket') && + !e.includes('fetch') && + !e.includes('Failed to fetch') && + !e.includes('NetworkError'), + ); + expect(unexpected).toHaveLength(0); + }); + + test('login screen renders device flow UI', async ({ page }) => { + await page.route('**/auth/device/start', (route) => + route.fulfill({ + json: { + user_code: 'MSG-0001', + verification_uri: 'https://github.com/login/device', + expires_in: 900, + interval: 5, + }, + }), + ); + await page.route('**/auth/device/poll', (route) => + route.fulfill({ json: { status: 'pending' } }), + ); + + await page.goto('/'); + + // Device code visible + const code = page.locator('.device-code-text').first(); + await expect(code).toBeVisible({ timeout: 10000 }); + + // Login section structure present + await expect(page.locator('.login-section')).toBeVisible(); + await expect(page.locator('.login-status')).toBeVisible(); + }); + + test('error page renders for unknown routes', async ({ page }) => { + const response = await page.goto('/this-route-does-not-exist'); + // SvelteKit should render the error page (not a blank page) + const body = await page.textContent('body'); + expect(body).toBeTruthy(); + }); + + test('error page shows error content for 404', async ({ page }) => { + await page.route('**/_app/immutable/**', (route) => route.abort()); + + const response = await page.goto('/nonexistent'); + expect(response).toBeTruthy(); + expect(response!.status()).toBeGreaterThanOrEqual(400); + + await expect(page.locator('.error-page')).toBeVisible(); + await expect(page.locator('.error-page a')).toHaveAttribute('href', '/'); + await expect(page.locator('.error-page')).toContainText('Return home'); + + const heading = await page.locator('.error-page h1').textContent(); + const message = await page.locator('.error-page p').textContent(); + expect(heading?.trim().length).toBeGreaterThan(0); + expect(message?.trim().length).toBeGreaterThan(0); + }); + + test('login screen structure has all expected sections', async ({ page }) => { + await page.route('**/auth/device/start', (route) => + route.fulfill({ + json: { + user_code: 'SECT-0001', + verification_uri: 'https://github.com/login/device', + expires_in: 900, + interval: 5, + }, + }), + ); + await page.route('**/auth/device/poll', (route) => + route.fulfill({ json: { status: 'pending' } }), + ); + + await page.goto('/'); + await expect(page.locator('.hero')).toBeVisible(); + await expect(page.locator('.login-section')).toBeVisible(); + await expect(page.locator('.device-code-box')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('.device-code-text').first()).toHaveText('SECT-0001', { + timeout: 10000, + }); + }); +}); diff --git a/tests/model-selection.spec.ts b/tests/model-selection.spec.ts new file mode 100644 index 0000000..26246c3 --- /dev/null +++ b/tests/model-selection.spec.ts @@ -0,0 +1,238 @@ +import { test, expect, type Browser, type BrowserContext, type Page } from '@playwright/test'; +import { + createAuthenticatedPage, + mockWebSocket, + goToChat, + MOCK_MODELS, +} from './helpers'; + +type WsMessage = Record; + +interface ChatHarness { + page: Page; + context: BrowserContext; + sentMessages: WsMessage[]; +} + +interface OpenChatOptions { + defaultModel?: string; + onMessage?: (msg: WsMessage, ws: { send: (data: string) => void }) => void; +} + +async function openAuthenticatedChat(browser: Browser, options: OpenChatOptions = {}): Promise { + const { page, context } = await createAuthenticatedPage(browser); + const sentMessages: WsMessage[] = []; + + await mockWebSocket(page, { + defaultModel: options.defaultModel ?? 'gpt-4.1', + onMessage(msg, ws) { + sentMessages.push(msg); + options.onMessage?.(msg, ws); + }, + }); + + await goToChat(page); + await expect(page.locator('button.model-pill')).toBeVisible(); + + return { page, context, sentMessages }; +} + +async function openModelSheet(page: Page): Promise { + await page.click('button.model-pill'); + await expect(page.locator('.sheet-overlay')).toBeVisible(); + await expect(page.locator('.sheet-panel')).toBeVisible(); + await expect(page.locator('.model-list')).toBeVisible(); +} + +async function expectSentMessage( + sentMessages: WsMessage[], + predicate: (msg: WsMessage) => boolean, +): Promise { + await expect.poll(() => sentMessages.some(predicate)).toBe(true); +} + +test.describe('Model selection', () => { + test('model pill shows current model', async ({ browser }) => { + const { page, context } = await openAuthenticatedChat(browser); + + try { + await expect(page.locator('.conn-dot')).toHaveClass(/dot-connected/); + await expect(page.locator('.model-name')).toHaveText('gpt-4.1'); + } finally { + await context.close(); + } + }); + + test('opens model sheet', async ({ browser }) => { + const { page, context } = await openAuthenticatedChat(browser); + + try { + await page.click('button.model-pill'); + + await expect(page.locator('.sheet-overlay')).toBeVisible(); + await expect(page.locator('.sheet-panel')).toBeVisible(); + await expect(page.locator('.sheet-title')).toHaveText('Models'); + await expect(page.locator('button.sheet-close')).toBeVisible(); + await expect(page.locator('.model-list')).toBeVisible(); + } finally { + await context.close(); + } + }); + + test('model list displays all models', async ({ browser }) => { + const { page, context } = await openAuthenticatedChat(browser); + + try { + await openModelSheet(page); + + const items = page.locator('button.model-item'); + await expect(items).toHaveCount(MOCK_MODELS.length); + + for (const model of MOCK_MODELS) { + await expect(page.locator('button.model-item').filter({ hasText: model.id })).toBeVisible(); + } + } finally { + await context.close(); + } + }); + + test('switches model', async ({ browser }) => { + const { page, context, sentMessages } = await openAuthenticatedChat(browser, { + onMessage(msg, ws) { + if (msg.type === 'set_model' && msg.model === 'o3') { + ws.send(JSON.stringify({ type: 'model_changed', model: 'o3' })); + } + }, + }); + + try { + await openModelSheet(page); + await page.locator('button.model-item').filter({ hasText: 'o3' }).click(); + + await expectSentMessage( + sentMessages, + (msg) => msg.type === 'set_model' && msg.model === 'o3', + ); + await expect(page.locator('.model-name')).toHaveText('o3'); + } finally { + await context.close(); + } + }); + + test('selected model is highlighted', async ({ browser }) => { + const { page, context } = await openAuthenticatedChat(browser); + + try { + await openModelSheet(page); + + const selectedModel = page.locator('button.model-item.selected'); + await expect(selectedModel).toHaveCount(1); + await expect(selectedModel).toContainText('gpt-4.1'); + } finally { + await context.close(); + } + }); + + test('closes model sheet', async ({ browser }) => { + const { page, context } = await openAuthenticatedChat(browser); + + try { + await openModelSheet(page); + await page.click('button.sheet-close'); + await expect(page.locator('.sheet-overlay')).not.toBeVisible(); + + await openModelSheet(page); + await page.locator('.sheet-overlay').click({ position: { x: 10, y: 10 } }); + await expect(page.locator('.sheet-overlay')).not.toBeVisible(); + } finally { + await context.close(); + } + }); + + test('reasoning effort toggle works for reasoning models', async ({ browser }) => { + const { page, context, sentMessages } = await openAuthenticatedChat(browser, { + onMessage(msg, ws) { + if (msg.type === 'set_model' && msg.model === 'o3') { + ws.send(JSON.stringify({ type: 'model_changed', model: 'o3' })); + } + + if (msg.type === 'new_session' && msg.model === 'o3' && msg.reasoningEffort === 'high') { + ws.send(JSON.stringify({ type: 'session_created', model: 'o3' })); + } + }, + }); + + try { + await openModelSheet(page); + await page.locator('button.model-item').filter({ hasText: 'o3' }).click(); + await expect(page.locator('.model-name')).toHaveText('o3'); + + const reasoningSection = page.locator('.reasoning-section'); + const mediumButton = page.locator('button.reasoning-opt').filter({ hasText: 'Med' }); + const highButton = page.locator('button.reasoning-opt').filter({ hasText: 'High' }); + + await expect(reasoningSection).not.toHaveClass(/disabled/); + await expect(page.locator('button.model-item.selected')).toContainText('o3'); + await expect(page.locator('button.model-item.selected .model-item-badge')).toHaveText('reasoning'); + await expect(mediumButton).toHaveClass(/active/); + + await highButton.click(); + + await expectSentMessage( + sentMessages, + (msg) => + (msg.type === 'new_session' && msg.model === 'o3' && msg.reasoningEffort === 'high') || + (msg.type === 'set_reasoning' && msg.effort === 'high') || + (msg.type === 'set_reasoning_effort' && msg.effort === 'high'), + ); + await expect(highButton).toHaveClass(/active/); + await expect(mediumButton).not.toHaveClass(/active/); + } finally { + await context.close(); + } + }); + + test('switches mode', async ({ browser }) => { + const { page, context, sentMessages } = await openAuthenticatedChat(browser, { + onMessage(msg, ws) { + if (msg.type === 'set_mode') { + ws.send(JSON.stringify({ type: 'mode_changed', mode: msg.mode })); + } + }, + }); + + try { + const askButton = page.locator('button.mode-btn').filter({ hasText: 'Ask' }); + const planButton = page.locator('button.mode-btn').filter({ hasText: 'Plan' }); + const agentButton = page.locator('button.mode-btn').filter({ hasText: 'Agent' }); + + await expect(askButton).toHaveClass(/active/); + + await planButton.click(); + await expectSentMessage( + sentMessages, + (msg) => msg.type === 'set_mode' && msg.mode === 'plan', + ); + await expect(planButton).toHaveClass(/active/); + await expect(askButton).not.toHaveClass(/active/); + + await agentButton.click(); + await expectSentMessage( + sentMessages, + (msg) => msg.type === 'set_mode' && msg.mode === 'autopilot', + ); + await expect(agentButton).toHaveClass(/active/); + await expect(planButton).not.toHaveClass(/active/); + + await askButton.click(); + await expectSentMessage( + sentMessages, + (msg) => msg.type === 'set_mode' && msg.mode === 'interactive', + ); + await expect(askButton).toHaveClass(/active/); + await expect(agentButton).not.toHaveClass(/active/); + } finally { + await context.close(); + } + }); +}); diff --git a/tests/plan-mode.spec.ts b/tests/plan-mode.spec.ts new file mode 100644 index 0000000..f2eb613 --- /dev/null +++ b/tests/plan-mode.spec.ts @@ -0,0 +1,260 @@ +import { test, expect, type Browser } from '@playwright/test'; +import { + createAuthenticatedPage, + mockWebSocket, + goToChat, + sendMessage, + createMessageSequence, +} from './helpers'; + +const DEFAULT_PLAN = + '## My Plan\n\n- [x] Step 1: Setup\n- [ ] Step 2: Implement\n- [ ] Step 3: Test'; + +type WsMessage = Record; +type WsController = { send: (data: string) => void }; + +interface OpenPlanChatOptions { + initialPlan?: string | null; + onMessage?: (msg: WsMessage, ws: WsController) => void; +} + +async function openPlanChat(browser: Browser, options: OpenPlanChatOptions = {}) { + const { page, context } = await createAuthenticatedPage(browser); + const sentMessages: WsMessage[] = []; + let serverWs: WsController | null = null; + + await mockWebSocket(page, { + autoCreateSession: false, + onMessage: (msg, ws) => { + serverWs = ws; + sentMessages.push(msg); + + if (msg.type === 'new_session') { + ws.send(JSON.stringify({ type: 'session_created', model: 'gpt-4.1' })); + + const planContent = options.initialPlan === undefined ? DEFAULT_PLAN : options.initialPlan; + if (planContent !== null) { + setTimeout(() => { + ws.send(JSON.stringify({ type: 'plan', exists: true, content: planContent })); + }, 100); + } + } + + options.onMessage?.(msg, ws); + }, + }); + + await goToChat(page); + await expect.poll(() => serverWs !== null).toBe(true); + + return { + page, + context, + sentMessages, + sendServerMessage: (msg: WsMessage) => { + if (!serverWs) { + throw new Error('WebSocket not ready'); + } + serverWs.send(JSON.stringify(msg)); + }, + }; +} + +test.describe('Plan mode', () => { + test('plan panel appears when plan exists', async ({ browser }) => { + const { page, context } = await openPlanChat(browser); + + try { + await expect(page.locator('.plan-panel')).toBeVisible({ timeout: 5000 }); + await expect(page.locator('.plan-title')).toContainText('Plan'); + await expect(page.locator('.plan-content')).toContainText('Step 1: Setup'); + await expect(page.locator('.plan-content')).toContainText('Step 3: Test'); + } finally { + await context.close(); + } + }); + + test('plan content renders markdown', async ({ browser }) => { + const markdownPlan = + '## Markdown Plan\n\nThis is **bold** and _italic_.\n\n- First item\n- Second item'; + const { page, context } = await openPlanChat(browser, { initialPlan: markdownPlan }); + + try { + await expect(page.locator('.plan-panel')).toBeVisible({ timeout: 5000 }); + await expect(page.locator('.plan-content h2')).toHaveText('Markdown Plan'); + await expect(page.locator('.plan-content strong')).toHaveText('bold'); + await expect(page.locator('.plan-content em')).toHaveText('italic'); + await expect(page.locator('.plan-content ul li')).toHaveCount(2); + } finally { + await context.close(); + } + }); + + test('plan panel hidden when no plan exists', async ({ browser }) => { + const { page, context } = await openPlanChat(browser, { initialPlan: null }); + + try { + await page.waitForTimeout(200); + await expect(page.locator('.plan-panel')).toHaveCount(0); + } finally { + await context.close(); + } + }); + + test('collapse and expand plan', async ({ browser }) => { + const { page, context } = await openPlanChat(browser); + + try { + await expect(page.locator('.plan-panel')).toBeVisible({ timeout: 5000 }); + + await page.locator('button.plan-collapse-btn').click(); + await expect(page.locator('.plan-panel.collapsed')).toHaveCount(1); + await expect(page.locator('.plan-body')).toBeHidden(); + + await page.locator('button.plan-collapse-btn').click(); + await expect(page.locator('.plan-panel.collapsed')).toHaveCount(0); + await expect(page.locator('.plan-body')).toBeVisible(); + } finally { + await context.close(); + } + }); + + test('edit plan', async ({ browser }) => { + const updatedPlan = '## Updated Plan\n\n- [x] Step 1\n- [x] Step 2\n- [ ] Step 3'; + const { page, context, sentMessages } = await openPlanChat(browser); + + try { + await expect(page.locator('.plan-panel')).toBeVisible({ timeout: 5000 }); + + await page.locator('button.action-btn:has-text("Edit")').click(); + await expect(page.locator('textarea.plan-textarea')).toBeVisible(); + await expect(page.locator('textarea.plan-textarea')).toHaveValue(DEFAULT_PLAN); + await expect(page.locator('.plan-edit-actions')).toBeVisible(); + + await page.locator('textarea.plan-textarea').fill(updatedPlan); + await page.locator('button.action-btn:has-text("Save")').click(); + + await expect(page.locator('textarea.plan-textarea')).toHaveCount(0); + await expect.poll(() => sentMessages.find((msg) => msg.type === 'update_plan') ?? null).not.toBeNull(); + expect(sentMessages.find((msg) => msg.type === 'update_plan')).toEqual({ + type: 'update_plan', + content: updatedPlan, + }); + } finally { + await context.close(); + } + }); + + test('cancel edit', async ({ browser }) => { + const { page, context } = await openPlanChat(browser); + + try { + await expect(page.locator('.plan-panel')).toBeVisible({ timeout: 5000 }); + + await page.locator('button.action-btn:has-text("Edit")').click(); + await expect(page.locator('textarea.plan-textarea')).toBeVisible(); + + await page.locator('textarea.plan-textarea').fill('## Draft\n\n- [ ] Throw away changes'); + await page.locator('button.action-btn:has-text("Cancel")').click(); + + await expect(page.locator('textarea.plan-textarea')).toHaveCount(0); + await expect(page.locator('.plan-content')).toBeVisible(); + await expect(page.locator('.plan-content')).toContainText('Step 1: Setup'); + } finally { + await context.close(); + } + }); + + test('delete plan uses two-step confirm', async ({ browser }) => { + const { page, context, sentMessages } = await openPlanChat(browser, { + onMessage: (msg, ws) => { + if (msg.type === 'delete_plan') { + ws.send(JSON.stringify({ type: 'plan_deleted' })); + } + }, + }); + + try { + await expect(page.locator('.plan-panel')).toBeVisible({ timeout: 5000 }); + + await page.locator('button.action-btn.danger:has-text("Delete")').click(); + await expect(page.locator('button.action-btn.danger:has-text("Confirm")')).toBeVisible(); + await expect(page.locator('button.action-btn:has-text("Cancel")')).toBeVisible(); + + await page.locator('button.action-btn.danger:has-text("Confirm")').click(); + + await expect.poll(() => sentMessages.find((msg) => msg.type === 'delete_plan') ?? null).not.toBeNull(); + await expect(page.locator('.plan-panel')).toHaveCount(0); + } finally { + await context.close(); + } + }); + + test('cancel delete', async ({ browser }) => { + const { page, context, sentMessages } = await openPlanChat(browser); + + try { + await expect(page.locator('.plan-panel')).toBeVisible({ timeout: 5000 }); + + await page.locator('button.action-btn.danger:has-text("Delete")').click(); + await expect(page.locator('button.action-btn.danger:has-text("Confirm")')).toBeVisible(); + + await page.locator('button.action-btn:has-text("Cancel")').click(); + + await expect(page.locator('button.action-btn.danger:has-text("Delete")')).toBeVisible(); + await expect(page.locator('button.action-btn.danger:has-text("Confirm")')).toHaveCount(0); + expect(sentMessages.some((msg) => msg.type === 'delete_plan')).toBe(false); + } finally { + await context.close(); + } + }); + + test('plan updates from server', async ({ browser }) => { + const updatedPlan = '## Revised Plan\n\n- [x] Step 1: Setup\n- [x] Step 2: Implement\n- [ ] Step 3: Verify'; + const { page, context, sendServerMessage } = await openPlanChat(browser); + + try { + await expect(page.locator('.plan-panel')).toBeVisible({ timeout: 5000 }); + await expect(page.locator('.plan-content')).toContainText('Step 2: Implement'); + + sendServerMessage({ type: 'plan_updated', content: updatedPlan }); + + await expect(page.locator('.plan-content h2')).toHaveText('Revised Plan'); + await expect(page.locator('.plan-content')).toContainText('Step 3: Verify'); + } finally { + await context.close(); + } + }); + + test('plan can appear during chat', async ({ browser }) => { + const inTurnPlan = '## In-Flight Plan\n\n- [x] Understand request\n- [ ] Implement solution'; + const { page, context } = await openPlanChat(browser, { + initialPlan: null, + onMessage: (msg, ws) => { + if (msg.type === 'message') { + createMessageSequence(ws) + .send({ type: 'turn_start' }, 50) + .send({ + type: 'plan', + exists: true, + content: inTurnPlan, + }, 75) + .send({ type: 'delta', content: 'I have outlined the work and started implementing it.' }, 75) + .send({ type: 'turn_end' }, 50) + .send({ type: 'done' }, 50); + } + }, + }); + + try { + await sendMessage(page, 'Help me plan this feature'); + + await expect(page.locator('.message.user')).toContainText('Help me plan this feature'); + await expect(page.locator('.plan-panel')).toBeVisible({ timeout: 5000 }); + await expect(page.locator('.plan-content h2')).toHaveText('In-Flight Plan'); + await expect(page.locator('.message.assistant')).toContainText('started implementing it'); + } finally { + await context.close(); + } + }); +}); diff --git a/tests/responsive-chat.spec.ts b/tests/responsive-chat.spec.ts new file mode 100644 index 0000000..25486e2 --- /dev/null +++ b/tests/responsive-chat.spec.ts @@ -0,0 +1,187 @@ +import { test, expect, type Browser } from '@playwright/test'; +import { + createAuthenticatedPage, mockWebSocket, goToChat, sendMessage, + createMessageSequence, +} from './helpers'; + +const MOBILE_VIEWPORT = { width: 390, height: 844 } as const; +const TABLET_VIEWPORT = { width: 768, height: 1024 } as const; +const DESKTOP_VIEWPORT = { width: 1280, height: 800 } as const; + +const VIEWPORT_CASES = [ + { label: 'mobile', viewport: MOBILE_VIEWPORT }, + { label: 'tablet', viewport: TABLET_VIEWPORT }, + { label: 'desktop', viewport: DESKTOP_VIEWPORT }, +] as const; + +type Viewport = { width: number; height: number }; +type AuthenticatedChat = Awaited>; +type MockWsOptions = Parameters[1]; + +async function openAuthenticatedChat( + browser: Browser, + viewport: Viewport, + wsOptions?: MockWsOptions, +): Promise { + const session = await createAuthenticatedPage(browser, viewport); + await mockWebSocket(session.page, wsOptions); + await goToChat(session.page); + return session; +} + +async function expectCoreChatUI(page: AuthenticatedChat['page']) { + await expect(page.locator('.top-bar')).toBeVisible(); + await expect(page.locator('.terminal')).toBeVisible(); + await expect(page.locator('.input-area textarea')).toBeVisible(); + await expect(page.locator('button.send-btn')).toBeVisible(); +} + +test.describe('Responsive — authenticated chat', () => { + test('Chat renders at mobile viewport', async ({ browser }) => { + const { page, context } = await openAuthenticatedChat(browser, MOBILE_VIEWPORT); + + try { + await expectCoreChatUI(page); + } finally { + await context.close(); + } + }); + + test('Chat renders at tablet viewport', async ({ browser }) => { + const { page, context } = await openAuthenticatedChat(browser, TABLET_VIEWPORT); + + try { + await expectCoreChatUI(page); + } finally { + await context.close(); + } + }); + + test('Chat renders at desktop viewport', async ({ browser }) => { + const { page, context } = await openAuthenticatedChat(browser, DESKTOP_VIEWPORT); + + try { + await expectCoreChatUI(page); + } finally { + await context.close(); + } + }); + + test('No horizontal overflow at mobile', async ({ browser }) => { + const { page, context } = await openAuthenticatedChat(browser, MOBILE_VIEWPORT); + + try { + const bodyWidth = await page.locator('body').evaluate((el) => el.scrollWidth); + expect(bodyWidth).toBeLessThanOrEqual(MOBILE_VIEWPORT.width); + } finally { + await context.close(); + } + }); + + test('Sidebar opens on mobile', async ({ browser }) => { + const { page, context } = await openAuthenticatedChat(browser, MOBILE_VIEWPORT); + + try { + await page.locator('button.hamburger-btn').click(); + + await expect(page.locator('.sidebar-overlay')).toBeVisible(); + await expect(page.locator('.sidebar-panel')).toBeVisible(); + } finally { + await context.close(); + } + }); + + test('Sidebar closes on mobile', async ({ browser }) => { + const { page, context } = await openAuthenticatedChat(browser, MOBILE_VIEWPORT); + + try { + await page.locator('button.hamburger-btn').click(); + await expect(page.locator('.sidebar-panel')).toBeVisible(); + + await page.locator('.sidebar-overlay').click({ + position: { + x: MOBILE_VIEWPORT.width - 20, + y: 100, + }, + }); + + await expect(page.locator('.sidebar-overlay')).toHaveCount(0); + await expect(page.locator('.sidebar-panel')).toHaveCount(0); + } finally { + await context.close(); + } + }); + + test('Can send message on mobile', async ({ browser }) => { + const { page, context } = await openAuthenticatedChat(browser, MOBILE_VIEWPORT, { + onMessage: (msg, ws) => { + if (msg.type === 'message') { + createMessageSequence(ws) + .send({ type: 'turn_start' }, 100) + .send({ type: 'delta', content: 'Mobile response!' }, 100) + .send({ type: 'turn_end' }, 100) + .send({ type: 'done' }, 100); + } + }, + }); + + try { + await sendMessage(page, 'Hello from mobile'); + + await expect(page.locator('.message.user')).toContainText('Hello from mobile'); + await expect(page.locator('.message.assistant .content')).toContainText('Mobile response!'); + } finally { + await context.close(); + } + }); + + test('TopBar elements visible on all viewports', async ({ browser }) => { + for (const { label, viewport } of VIEWPORT_CASES) { + const { page, context } = await openAuthenticatedChat(browser, viewport); + + try { + await test.step(`checks top bar controls at ${label}`, async () => { + await expect(page.locator('button.hamburger-btn')).toBeVisible(); + await expect(page.locator('button.model-pill')).toBeVisible(); + await expect(page.locator('button.newchat-btn')).toBeVisible(); + }); + } finally { + await context.close(); + } + } + }); + + test('Mode selector accessible on mobile', async ({ browser }) => { + const { page, context } = await openAuthenticatedChat(browser, MOBILE_VIEWPORT); + + try { + const modeSelector = page.locator('.mode-selector'); + const modeButtons = page.locator('button.mode-btn'); + + await expect(modeSelector).toBeVisible(); + await expect(modeButtons).toHaveCount(3); + await expect(modeButtons.nth(0)).toBeVisible(); + await expect(modeButtons.nth(1)).toBeVisible(); + await expect(modeButtons.nth(2)).toBeVisible(); + } finally { + await context.close(); + } + }); + + test('Input area stays at bottom on mobile', async ({ browser }) => { + const { page, context } = await openAuthenticatedChat(browser, MOBILE_VIEWPORT); + + try { + const viewportHeight = page.viewportSize()?.height ?? MOBILE_VIEWPORT.height; + const inputAreaBox = await page.locator('.input-area').boundingBox(); + + expect(inputAreaBox).not.toBeNull(); + + const bottomGap = viewportHeight - ((inputAreaBox?.y ?? 0) + (inputAreaBox?.height ?? 0)); + expect(inputAreaBox?.y ?? 0).toBeGreaterThan(viewportHeight * 0.65); + expect(bottomGap).toBeLessThanOrEqual(24); + } finally { + await context.close(); + } + }); +}); diff --git a/tests/responsive.spec.ts b/tests/responsive.spec.ts new file mode 100644 index 0000000..a7d5fdc --- /dev/null +++ b/tests/responsive.spec.ts @@ -0,0 +1,76 @@ +import { test, expect, type Browser } from '@playwright/test'; + +async function openLoginPage(browser: Browser, viewport: { width: number; height: number }) { + const context = await browser.newContext({ viewport }); + const page = await context.newPage(); + + await page.route('**/auth/device/start', (route) => + route.fulfill({ + json: { + user_code: 'RESP-0001', + verification_uri: 'https://github.com/login/device', + expires_in: 900, + interval: 5, + }, + }), + ); + await page.route('**/auth/device/poll', (route) => + route.fulfill({ json: { status: 'pending' } }), + ); + + await page.goto('/'); + await expect(page.locator('.login-screen')).toBeVisible({ timeout: 10000 }); + return { page, context }; +} + +test.describe('Responsive — Login screen', () => { + test('login screen renders on small phone (320x568)', async ({ browser }) => { + const { page, context } = await openLoginPage(browser, { width: 320, height: 568 }); + + await expect(page.locator('.login-section')).toBeVisible(); + await expect(page.locator('.device-code-text').first()).toBeVisible(); + + // No horizontal overflow + const bodyWidth = await page.locator('body').evaluate((el) => el.scrollWidth); + expect(bodyWidth).toBeLessThanOrEqual(320); + + await context.close(); + }); + + test('login screen renders on tablet (768x1024)', async ({ browser }) => { + const { page, context } = await openLoginPage(browser, { width: 768, height: 1024 }); + + await expect(page.locator('.login-section')).toBeVisible(); + await expect(page.locator('.device-code-text').first()).toBeVisible(); + + await context.close(); + }); + + test('login screen renders on desktop (1280x800)', async ({ browser }) => { + const { page, context } = await openLoginPage(browser, { width: 1280, height: 800 }); + + await expect(page.locator('.login-section')).toBeVisible(); + + await context.close(); + }); +}); + +test.describe('Responsive — general', () => { + test('no horizontal overflow at 320px', async ({ browser }) => { + const { page, context } = await openLoginPage(browser, { width: 320, height: 568 }); + + const bodyWidth = await page.locator('body').evaluate((el) => el.scrollWidth); + expect(bodyWidth).toBeLessThanOrEqual(320); + + await context.close(); + }); + + test('page content fits within viewport at 375px', async ({ browser }) => { + const { page, context } = await openLoginPage(browser, { width: 375, height: 667 }); + + const bodyWidth = await page.locator('body').evaluate((el) => el.scrollWidth); + expect(bodyWidth).toBeLessThanOrEqual(375); + + await context.close(); + }); +}); diff --git a/tests/screenshots.spec.ts b/tests/screenshots.spec.ts new file mode 100644 index 0000000..76095f2 --- /dev/null +++ b/tests/screenshots.spec.ts @@ -0,0 +1,379 @@ +/** + * Screenshot generation — run with: + * npx playwright test tests/screenshots.spec.ts --project=desktop + * + * Outputs to docs/screenshots/ + * Naming: usecase-{slug}-{viewport}.png + * e.g. usecase-code-mobile.png, usecase-code-ipad.png, usecase-code-desktop.png + */ + +import { test, type Browser, type Page } from '@playwright/test'; +import path from 'path'; +import fs from 'fs'; + +const OUT_DIR = path.resolve('docs/screenshots'); + +test.beforeAll(() => { + fs.mkdirSync(OUT_DIR, { recursive: true }); +}); + +// ── Viewport definitions ────────────────────────────────────────────────────── + +const VIEWPORTS = [ + { name: 'mobile', width: 390, height: 844 }, + { name: 'ipad', width: 768, height: 1024 }, + { name: 'desktop', width: 1280, height: 800 }, +] as const; + +type ViewportName = typeof VIEWPORTS[number]['name']; + +// ── Shared model list ───────────────────────────────────────────────────────── + +const MODELS = [ + { id: 'gpt-4.1', name: 'GPT-4.1', vendor: 'OpenAI', capabilities: { supports: {} } }, + { id: 'o3', name: 'o3', vendor: 'OpenAI', capabilities: { supports: { reasoningEffort: true } } }, + { id: 'o4', name: 'o4', vendor: 'OpenAI', capabilities: { supports: { reasoningEffort: true } } }, + { id: 'o4-mini', name: 'o4-mini', vendor: 'OpenAI', capabilities: { supports: { reasoningEffort: true } } }, + { id: 'claude-opus-4-6', name: 'Claude Opus 4.6', vendor: 'Anthropic', capabilities: { supports: { reasoningEffort: true } } }, + { id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6', vendor: 'Anthropic', capabilities: { supports: { reasoningEffort: true } } }, + { id: 'claude-haiku-4-6', name: 'Claude Haiku 4.6', vendor: 'Anthropic', capabilities: { supports: {} } }, + { id: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro', vendor: 'Google', capabilities: { supports: { reasoningEffort: true } } }, + { id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash', vendor: 'Google', capabilities: { supports: {} } }, +]; + +// ── Auth mock helpers ───────────────────────────────────────────────────────── + +async function openLogin(browser: Browser, viewport: { width: number; height: number }) { + const ctx = await browser.newContext({ viewport }); + const page = await ctx.newPage(); + + await page.route('**/auth/device/start', (r) => + r.fulfill({ + json: { + user_code: 'B4F2-9AE1', + verification_uri: 'https://github.com/login/device', + expires_in: 900, + interval: 5, + }, + }), + ); + await page.route('**/auth/device/poll', (r) => + r.fulfill({ json: { status: 'pending' } }), + ); + + await page.goto('/'); + await page.waitForSelector('.device-code-text', { state: 'visible', timeout: 10000 }); + await page.waitForTimeout(500); + return { page, ctx }; +} + +// ── Login screens (all viewports) ──────────────────────────────────────────── + +for (const vp of VIEWPORTS) { + test(`screenshot: login — ${vp.name}`, async ({ browser }) => { + test.skip(!!process.env.CI, 'screenshot generation skipped in CI'); + const { page, ctx } = await openLogin(browser, vp); + await page.screenshot({ path: `${OUT_DIR}/login-${vp.name}.png`, fullPage: false }); + await ctx.close(); + }); +} + +// ── Use-case screens ────────────────────────────────────────────────────────── + +async function openChat(browser: Browser, viewport: { width: number; height: number }) { + const ctx = await browser.newContext({ viewport }); + const page = await ctx.newPage(); + + // Intercept SSR HTML: patch embedded SvelteKit data to set authenticated=true with user + await page.route('/', async (route) => { + const response = await route.fetch(); + let html = await response.text(); + // Replace the embedded data payload in the SvelteKit script block + html = html.replace( + /data:\{authenticated:false,user:null\}/g, + 'data:{authenticated:true,user:{login:"devartifex",name:"Dev Artifex"}}', + ); + // Also remove the login screen HTML so Svelte hydration matches + await route.fulfill({ response, body: html }); + }); + + // Mock __data.json for any client-side navigations + await page.route('**/__data.json*', (route) => + route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ + type: 'data', + nodes: [ + null, + { + type: 'data', + data: [ + { authenticated: true, user: { login: 'devartifex', name: 'Dev Artifex' } }, + 1, + ], + }, + ], + }), + }), + ); + + // Mock auth status endpoint + await page.route('**/auth/status', (route) => + route.fulfill({ json: { authenticated: true, githubUser: 'devartifex' } }), + ); + + return { page, ctx }; +} + +// ── Use case 1: Code generation ─────────────────────────────────────────────── +// User asks for a TypeScript hook → gets a rich syntax-highlighted code response + +for (const vp of VIEWPORTS) { + test(`screenshot: use case — code generation (${vp.name})`, async ({ browser }) => { + test.skip(!!process.env.CI, 'screenshot generation skipped in CI'); + + const { page, ctx } = await openChat(browser, vp); + + await page.routeWebSocket('**/ws', (ws) => { + ws.send(JSON.stringify({ type: 'connected', user: 'devartifex' })); + + ws.onMessage((data) => { + const msg = JSON.parse(data as string); + + if (msg.type === 'list_models') { + ws.send(JSON.stringify({ type: 'models', models: MODELS })); + } + + if (msg.type === 'new_session') { + ws.send(JSON.stringify({ type: 'session_created', model: 'claude-opus-4-6' })); + ws.send(JSON.stringify({ type: 'title_changed', title: 'TypeScript useDebounce hook' })); + } + + if (msg.type === 'message') { + setTimeout(() => { + ws.send(JSON.stringify({ type: 'turn_start' })); + ws.send(JSON.stringify({ + type: 'delta', + content: + "Here's a `useDebounce` hook in TypeScript:\n\n" + + '```typescript\n' + + "import { useState, useEffect } from 'react';\n\n" + + 'function useDebounce(value: T, delay: number): T {\n' + + ' const [debouncedValue, setDebouncedValue] = useState(value);\n\n' + + ' useEffect(() => {\n' + + ' const timer = setTimeout(() => {\n' + + ' setDebouncedValue(value);\n' + + ' }, delay);\n' + + ' return () => clearTimeout(timer);\n' + + ' }, [value, delay]);\n\n' + + ' return debouncedValue;\n' + + '}\n\n' + + 'export default useDebounce;\n' + + '```\n\n' + + '**Usage example:**\n\n' + + '```tsx\n' + + 'function SearchInput() {\n' + + " const [query, setQuery] = useState('');\n" + + ' const debouncedQuery = useDebounce(query, 300);\n\n' + + ' useEffect(() => {\n' + + ' if (debouncedQuery) fetchResults(debouncedQuery);\n' + + ' }, [debouncedQuery]);\n\n' + + ' return setQuery(e.target.value)} />;\n' + + '}\n' + + '```\n\n' + + 'The cleanup function cancels the pending timer whenever `value` or `delay` changes, preventing stale calls.', + })); + ws.send(JSON.stringify({ type: 'turn_end' })); + ws.send(JSON.stringify({ type: 'done' })); + ws.send(JSON.stringify({ type: 'usage', inputTokens: 38, outputTokens: 204 })); + }, 200); + } + }); + }); + + await page.goto('/'); + await page.waitForSelector('.terminal', { state: 'visible', timeout: 10000 }); + await page.waitForSelector('textarea:not([disabled])', { state: 'visible', timeout: 10000 }); + await page.fill('textarea', 'Write a TypeScript useDebounce hook for React'); + await page.keyboard.press('Enter'); + await page.waitForSelector('.message.assistant', { state: 'visible', timeout: 15000 }); + await page.waitForTimeout(600); + await page.screenshot({ path: `${OUT_DIR}/usecase-code-${vp.name}.png`, fullPage: false }); + await ctx.close(); + }); +} + +// ── Use case 2: Autopilot agent — plan panel + tool calls ───────────────────── +// Agent plans, calls GitHub + filesystem tools, then opens a PR autonomously + +for (const vp of VIEWPORTS) { + test(`screenshot: use case — autopilot agent (${vp.name})`, async ({ browser }) => { + test.skip(!!process.env.CI, 'screenshot generation skipped in CI'); + + const { page, ctx } = await openChat(browser, vp); + + await page.routeWebSocket('**/ws', (ws) => { + ws.send(JSON.stringify({ type: 'connected', user: 'devartifex' })); + + ws.onMessage((data) => { + const msg = JSON.parse(data as string); + + if (msg.type === 'list_models') { + ws.send(JSON.stringify({ type: 'models', models: MODELS })); + } + + if (msg.type === 'new_session') { + ws.send(JSON.stringify({ type: 'session_created', model: 'claude-opus-4-6' })); + ws.send(JSON.stringify({ type: 'mode_changed', mode: 'autopilot' })); + ws.send(JSON.stringify({ type: 'title_changed', title: 'Password reset — issue #88' })); + } + + if (msg.type === 'message') { + let t = 150; + const tick = (msg: object, delay: number) => { + t += delay; + setTimeout(() => ws.send(JSON.stringify(msg)), t); + }; + + tick({ type: 'plan', exists: true, content: + '## Password Reset — Issue #88\n\n' + + '- [x] Read issue details from GitHub\n' + + '- [x] Create `src/auth/reset-password.ts`\n' + + '- [x] Add `/api/auth/reset` endpoint\n' + + '- [x] Write `tests/reset-password.spec.ts`\n' + + '- [ ] Open pull request', + }, 0); + + tick({ type: 'turn_start' }, 80); + tick({ type: 'tool_start', toolCallId: 'tc1', toolName: 'get_issue', mcpServerName: 'github' }, 20); + tick({ type: 'tool_end', toolCallId: 'tc1' }, 350); + tick({ type: 'tool_start', toolCallId: 'tc2', toolName: 'create_file', mcpServerName: 'filesystem' }, 30); + tick({ type: 'tool_progress', toolCallId: 'tc2', message: 'Writing src/auth/reset-password.ts…' }, 200); + tick({ type: 'tool_end', toolCallId: 'tc2' }, 400); + tick({ type: 'tool_start', toolCallId: 'tc3', toolName: 'run_terminal', mcpServerName: 'shell' }, 30); + tick({ type: 'tool_progress', toolCallId: 'tc3', message: 'npm test — 12 passed' }, 300); + tick({ type: 'tool_end', toolCallId: 'tc3' }, 400); + tick({ type: 'tool_start', toolCallId: 'tc4', toolName: 'create_pull_request', mcpServerName: 'github' }, 30); + tick({ type: 'tool_end', toolCallId: 'tc4' }, 350); + + tick({ + type: 'delta', + content: + '✅ Done! Password reset flow is implemented and merged into a PR:\n\n' + + '- **`src/auth/reset-password.ts`** — HMAC token generation, bcrypt hashing, 1-hour expiry\n' + + '- **`/api/auth/reset`** — POST endpoint with rate limiting (5 req / 15 min per email)\n' + + '- **`tests/reset-password.spec.ts`** — 12 passing tests covering happy path, expired tokens, and brute-force protection\n' + + '- **[PR #91 opened](https://github.com/devartifex/app/pull/91)** — ready for review\n\n' + + 'All CI checks green. The plan file has been updated.', + }, 100); + tick({ type: 'turn_end' }, 50); + tick({ type: 'done' }, 50); + tick({ type: 'usage', inputTokens: 1820, outputTokens: 312 }, 50); + } + }); + }); + + await page.goto('/'); + await page.waitForSelector('.terminal', { state: 'visible', timeout: 10000 }); + await page.waitForSelector('textarea:not([disabled])', { state: 'visible', timeout: 10000 }); + await page.fill('textarea', 'Implement the password reset flow from issue #88, write the tests, open a PR'); + await page.keyboard.press('Enter'); + await page.waitForSelector('.plan-panel', { state: 'visible', timeout: 15000 }); + await page.waitForSelector('.message.assistant', { state: 'visible', timeout: 20000 }); + await page.waitForTimeout(600); + await page.screenshot({ path: `${OUT_DIR}/usecase-autopilot-${vp.name}.png`, fullPage: false }); + await ctx.close(); + }); +} + +// ── Use case 3: Extended thinking with o3 ──────────────────────────────────── +// o3 emits a reasoning trace before answering a complex architecture question + +for (const vp of VIEWPORTS) { + test(`screenshot: use case — extended reasoning (${vp.name})`, async ({ browser }) => { + test.skip(!!process.env.CI, 'screenshot generation skipped in CI'); + + const { page, ctx } = await openChat(browser, vp); + + await page.routeWebSocket('**/ws', (ws) => { + ws.send(JSON.stringify({ type: 'connected', user: 'devartifex' })); + + ws.onMessage((data) => { + const msg = JSON.parse(data as string); + + if (msg.type === 'list_models') { + ws.send(JSON.stringify({ type: 'models', models: MODELS })); + } + + if (msg.type === 'new_session') { + ws.send(JSON.stringify({ type: 'session_created', model: 'o3' })); + ws.send(JSON.stringify({ type: 'model_changed', model: 'o3' })); + ws.send(JSON.stringify({ type: 'title_changed', title: 'Distributed rate limiting at 1M req/s' })); + } + + if (msg.type === 'message') { + setTimeout(() => { + ws.send(JSON.stringify({ type: 'turn_start' })); + + // Stream reasoning chunks — do NOT send turn_end before screenshot; + // finalizeStream() clears currentReasoningContent, so we must screenshot + // while the turn is still in progress (streaming state). + const thinkingChunks = [ + 'The user wants a rate limiting design for 1M req/s. Let me think through the trade-offs carefully.\n\n', + 'Token bucket: maintains a counter per key that refills at a fixed rate. Allows bursting up to bucket capacity. At scale, sharing state requires a Redis INCR + TTL on every request — that\'s 1M Redis ops/s which is at or beyond practical limits (~500k–1M ops/s for a single Redis node).\n\n', + 'Sliding window (log): stores a sorted set of timestamps per key (ZADD/ZRANGEBYSCORE). Most accurate, but O(log n) per request and large memory footprint for high-traffic keys.\n\n', + 'Sliding window (count): approximate — divide window into sub-buckets and interpolate. Much cheaper: O(1) time, small memory.\n\n', + 'At 1M req/s I need to push state out of the hot path. Best approach: local in-process token buckets (sub-microsecond check) with async batched sync to Redis every 50–100ms. Accept ≈5% over-limit tolerance in exchange for near-zero latency overhead. Shard keys via consistent hashing across a Redis cluster.', + ]; + + let delay = 80; + for (const chunk of thinkingChunks) { + setTimeout(() => { + ws.send(JSON.stringify({ type: 'reasoning_delta', content: chunk, reasoningId: 'r1' })); + }, delay); + delay += 200; + } + + // Start streaming the answer — do NOT send turn_end yet so both + // the reasoning block and streaming answer are visible simultaneously. + setTimeout(() => { + ws.send(JSON.stringify({ + type: 'delta', + content: + '## Distributed Rate Limiting at 1M req/s\n\n' + + '### Algorithm comparison\n\n' + + '| Approach | Accuracy | Burst | Redis cost |\n' + + '|---|---|---|---|\n' + + '| Fixed window | Low (2× burst at boundary) | Yes | O(1) INCR |\n' + + '| Token bucket | High | Configurable | O(1) but needs sync |\n' + + '| Sliding window (log) | Exact | No | O(log n) ZADD |\n' + + '| Sliding window (count) | ~99% | No | O(1) |\n\n' + + '### Recommended architecture\n\n' + + '**Local token buckets + async Redis sync**\n\n' + + '1. Each app node holds per-key token buckets in-process (hash map)\n' + + '2. Every 50–100 ms, flush deltas to Redis via `INCRBY` pipeline\n' + + '3. Redis cluster with consistent hashing — 6 nodes × 150k ops/s = 900k ops/s headroom\n' + + '4. Tolerate ±5% over-counting; exact limits rarely matter at this scale\n\n' + + '**Result:** hot-path latency drops from ~2 ms (Redis round-trip) to ~0.05 ms (in-process check).', + })); + // Don't send turn_end — screenshot while still streaming + }, delay + 150); + }, 200); + } + }); + }); + + await page.goto('/'); + await page.waitForSelector('.terminal', { state: 'visible', timeout: 10000 }); + await page.waitForSelector('textarea:not([disabled])', { state: 'visible', timeout: 10000 }); + await page.fill('textarea', 'Design a distributed rate limiting system for 1M req/s. Token bucket vs sliding window?'); + await page.keyboard.press('Enter'); + // Screenshot while streaming: reasoning block is visible alongside the streaming answer + // (.message.assistant.streaming exists while turn_end hasn't been sent yet) + await page.waitForSelector('.reasoning-block', { state: 'visible', timeout: 15000 }); + await page.waitForSelector('.message.assistant.streaming', { state: 'visible', timeout: 20000 }); + await page.waitForTimeout(600); + await page.screenshot({ path: `${OUT_DIR}/usecase-reasoning-${vp.name}.png`, fullPage: false }); + await ctx.close(); + }); +} diff --git a/tests/session-management.spec.ts b/tests/session-management.spec.ts new file mode 100644 index 0000000..6224f6b --- /dev/null +++ b/tests/session-management.spec.ts @@ -0,0 +1,264 @@ +import { test, expect, type Browser, type Page } from '@playwright/test'; +import { + createAuthenticatedPage, + mockWebSocket, + goToChat, + MOCK_SESSIONS, +} from './helpers'; + +interface TestSessionSummary { + id: string; + title?: string; + model?: string; + updatedAt?: string; + cwd?: string; + repository?: string; + branch?: string; + checkpointCount?: number; + hasPlan?: boolean; + isRemote?: boolean; + source?: 'sdk' | 'filesystem'; +} + +interface TestSessionDetail { + id: string; + cwd?: string; + repository?: string; + branch?: string; + summary?: string; + createdAt?: string; + updatedAt?: string; + checkpoints: Array<{ + number: number; + title: string; + filename: string; + }>; + plan?: string; + isRemote?: boolean; +} + +interface SessionHarnessOptions { + sessions?: TestSessionSummary[]; + detail?: TestSessionDetail; +} + +const DEFAULT_SESSIONS: TestSessionSummary[] = MOCK_SESSIONS.map((session) => ({ + id: session.id, + title: session.title, + model: session.model, + updatedAt: session.updatedAt, + branch: session.branch, + checkpointCount: session.checkpointCount, +})); + +const DEFAULT_SESSION_DETAIL: TestSessionDetail = { + id: 'session-1', + repository: 'copilot-unleashed', + branch: 'main', + cwd: '/home/user/project', + summary: 'Refactored TypeScript utilities, tightened session typing, and prepared follow-up cleanup.', + createdAt: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(), + updatedAt: new Date(Date.now() - 30 * 60 * 1000).toISOString(), + checkpoints: [ + { number: 1, title: 'Before refactor', filename: 'checkpoint-1.md' }, + { number: 2, title: 'Typed session cleanup', filename: 'checkpoint-2.md' }, + ], + plan: '- Review typed changes\n- Resume implementation\n- Run verification', +}; + +const SEARCHABLE_SESSIONS: TestSessionSummary[] = [ + { id: 'search-1', title: 'Alpha planning', model: 'gpt-4.1', updatedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(), branch: 'feat/alpha' }, + { id: 'search-2', title: 'Beta follow-up', model: 'gpt-4.1', updatedAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(), branch: 'feat/beta' }, + { id: 'search-3', title: 'Gamma docs', model: 'claude-sonnet-4-6', updatedAt: new Date(Date.now() - 15 * 60 * 1000).toISOString(), branch: 'docs/gamma' }, + { id: 'search-4', title: 'Beta regression fix', model: 'o3', updatedAt: new Date(Date.now() - 20 * 60 * 1000).toISOString(), branch: 'fix/beta-regression' }, + { id: 'search-5', title: 'Delta polish', model: 'claude-haiku-4-6', updatedAt: new Date(Date.now() - 25 * 60 * 1000).toISOString(), branch: 'chore/delta' }, + { id: 'search-6', title: 'Epsilon deploy', model: 'gemini-2.5-pro', updatedAt: new Date(Date.now() - 30 * 60 * 1000).toISOString(), branch: 'ops/epsilon' }, +]; + +async function createSessionManagementPage( + browser: Browser, + options: SessionHarnessOptions = {}, +) { + const { page, context } = await createAuthenticatedPage(browser); + const sentMessages: Array> = []; + const detail = options.detail ?? DEFAULT_SESSION_DETAIL; + let currentSessions = [...(options.sessions ?? DEFAULT_SESSIONS)]; + + await mockWebSocket(page, { + onMessage: (msg, ws) => { + sentMessages.push(msg); + + if (msg.type === 'list_sessions') { + ws.send(JSON.stringify({ type: 'sessions', sessions: currentSessions })); + } + + if (msg.type === 'get_session_detail' && typeof msg.sessionId === 'string') { + const responseDetail = msg.sessionId === detail.id + ? detail + : { ...detail, id: msg.sessionId }; + ws.send(JSON.stringify({ + type: 'session_detail', + detail: responseDetail, + session: responseDetail, + })); + } + + if (msg.type === 'resume_session' && typeof msg.sessionId === 'string') { + ws.send(JSON.stringify({ type: 'session_resumed', sessionId: msg.sessionId })); + } + + if (msg.type === 'delete_session' && typeof msg.sessionId === 'string') { + currentSessions = currentSessions.filter((session) => session.id !== msg.sessionId); + ws.send(JSON.stringify({ type: 'session_deleted', sessionId: msg.sessionId })); + } + }, + }); + + await goToChat(page); + + return { page, context, sentMessages }; +} + +async function openSessionsSheet(page: Page) { + await page.click('button.hamburger-btn'); + await expect(page.locator('.sidebar-overlay')).toBeVisible(); + await expect(page.locator('.sidebar-panel')).toBeVisible(); + + await page.click('button.sidebar-action:has-text("Sessions")'); + await expect(page.locator('.sheet-overlay')).toBeVisible(); + await expect(page.locator('.sheet-panel')).toBeVisible(); +} + +test.describe('Session management', () => { + test('opens sessions sheet via sidebar', async ({ browser }) => { + const { page, context, sentMessages } = await createSessionManagementPage(browser); + + await openSessionsSheet(page); + + await expect(page.locator('.sheet-title')).toHaveText('Sessions'); + await expect(page.locator('.session-list')).toBeVisible(); + expect(sentMessages).toContainEqual(expect.objectContaining({ type: 'list_sessions' })); + + await context.close(); + }); + + test('renders the session list', async ({ browser }) => { + const { page, context } = await createSessionManagementPage(browser); + + await openSessionsSheet(page); + + await expect(page.locator('button.session-item')).toHaveCount(DEFAULT_SESSIONS.length); + await expect(page.locator('.session-item-title')).toHaveText([ + 'TypeScript refactoring', + 'Fix login bug', + 'Add unit tests', + ]); + await expect(page.locator('.session-item-meta').first()).toContainText('gpt-4.1'); + await expect(page.locator('.session-item-meta').nth(1)).toContainText('claude-sonnet-4-6'); + + await context.close(); + }); + + test('searches sessions when enough items are present', async ({ browser }) => { + const { page, context } = await createSessionManagementPage(browser, { + sessions: SEARCHABLE_SESSIONS, + }); + + await openSessionsSheet(page); + + await expect(page.locator('input.search-input')).toBeVisible(); + await page.fill('input.search-input', 'beta'); + + await expect(page.locator('button.session-item')).toHaveCount(2); + await expect(page.locator('.session-item-title')).toHaveText([ + 'Beta follow-up', + 'Beta regression fix', + ]); + + await context.close(); + }); + + test('shows session detail', async ({ browser }) => { + const { page, context, sentMessages } = await createSessionManagementPage(browser, { + detail: DEFAULT_SESSION_DETAIL, + }); + + await openSessionsSheet(page); + await page.click('button.session-item:has-text("TypeScript refactoring")'); + + await expect(page.locator('.sheet-detail')).toBeVisible(); + await expect(page.locator('button.sheet-back')).toBeVisible(); + await expect(page.locator('.preview-summary')).toContainText('Refactored TypeScript utilities'); + await expect(page.locator('.checkpoint-title').first()).toContainText('Before refactor'); + expect(sentMessages).toContainEqual( + expect.objectContaining({ type: 'get_session_detail', sessionId: 'session-1' }), + ); + + await context.close(); + }); + + test('resumes a session from detail view', async ({ browser }) => { + const { page, context, sentMessages } = await createSessionManagementPage(browser, { + detail: DEFAULT_SESSION_DETAIL, + }); + + page.on('dialog', (dialog) => dialog.accept()); + + await openSessionsSheet(page); + await page.click('button.session-item:has-text("TypeScript refactoring")'); + await expect(page.locator('.sheet-detail')).toBeVisible(); + + await page.click('button.resume-btn'); + + await expect(page.locator('.sheet-overlay')).toHaveCount(0); + expect(sentMessages).toContainEqual( + expect.objectContaining({ type: 'resume_session', sessionId: 'session-1' }), + ); + + await context.close(); + }); + + test('deletes a session', async ({ browser }) => { + const { page, context, sentMessages } = await createSessionManagementPage(browser); + + page.on('dialog', (dialog) => dialog.accept()); + + await openSessionsSheet(page); + await expect(page.locator('button.session-item')).toHaveCount(DEFAULT_SESSIONS.length); + + const sessionToDelete = page.locator('button.session-item', { hasText: 'TypeScript refactoring' }); + await sessionToDelete.locator('.session-delete-btn').click(); + + await expect(page.locator('button.session-item')).toHaveCount(DEFAULT_SESSIONS.length - 1); + await expect(page.locator('button.session-item', { hasText: 'TypeScript refactoring' })).toHaveCount(0); + expect(sentMessages).toContainEqual( + expect.objectContaining({ type: 'delete_session', sessionId: 'session-1' }), + ); + + await context.close(); + }); + + test('shows empty state when there are no sessions', async ({ browser }) => { + const { page, context } = await createSessionManagementPage(browser, { + sessions: [], + }); + + await openSessionsSheet(page); + + await expect(page.locator('.sheet-empty')).toBeVisible(); + await expect(page.locator('.sheet-empty')).toHaveText('No previous sessions found.'); + + await context.close(); + }); + + test('closes the sessions sheet', async ({ browser }) => { + const { page, context } = await createSessionManagementPage(browser); + + await openSessionsSheet(page); + await page.click('button.sheet-close'); + + await expect(page.locator('.sheet-overlay')).toHaveCount(0); + + await context.close(); + }); +}); diff --git a/tests/settings.spec.ts b/tests/settings.spec.ts new file mode 100644 index 0000000..72df77b --- /dev/null +++ b/tests/settings.spec.ts @@ -0,0 +1,330 @@ +import { test, expect, type Browser, type Page } from '@playwright/test'; +import { + createAuthenticatedPage, + mockWebSocket, + goToChat, + MOCK_TOOLS, + MOCK_AGENTS, +} from './helpers'; + +interface MockSettings { + model: string; + mode: 'interactive' | 'plan' | 'autopilot'; + reasoningEffort: 'low' | 'medium' | 'high' | 'xhigh'; + customInstructions: string; + excludedTools: string[]; + customTools: unknown[]; + mcpServers: Array<{ + name: string; + url: string; + type: 'http' | 'sse'; + headers: Record; + tools: string[]; + enabled: boolean; + }>; +} + +interface SetupOptions { + settings?: Partial; + onMessage?: (msg: Record, ws: { send: (data: string) => void }) => void; +} + +interface SetupResult { + page: Page; + close: () => Promise; + clientMessages: Record[]; + putPayloads: MockSettings[]; +} + +function createDefaultSettings(overrides: Partial = {}): MockSettings { + return { + model: '', + mode: 'interactive', + reasoningEffort: 'medium', + customInstructions: '', + excludedTools: [], + customTools: [], + mcpServers: [], + ...overrides, + }; +} + +async function mockSettingsApi(page: Page, initial: Partial = {}) { + let current = createDefaultSettings(initial); + const putPayloads: MockSettings[] = []; + + await page.route('**/api/settings', async (route, request) => { + if (request.method() === 'GET') { + await route.fulfill({ + json: { settings: current }, + }); + return; + } + + if (request.method() === 'PUT') { + const body = request.postDataJSON() as { settings?: MockSettings }; + + if (body.settings) { + current = body.settings; + putPayloads.push(body.settings); + } + + await route.fulfill({ json: { ok: true } }); + return; + } + + await route.continue(); + }); + + return { putPayloads }; +} + +async function setupSettingsPage(browser: Browser, options: SetupOptions = {}): Promise { + const { page, context } = await createAuthenticatedPage(browser); + const clientMessages: Record[] = []; + const { putPayloads } = await mockSettingsApi(page, options.settings); + + await mockWebSocket(page, { + onMessage: (msg, ws) => { + clientMessages.push(msg); + options.onMessage?.(msg, ws); + }, + }); + + await goToChat(page); + + return { + page, + clientMessages, + putPayloads, + close: () => context.close(), + }; +} + +async function openSettings(page: Page) { + await page.click('button.hamburger-btn'); + await expect(page.locator('.sidebar-panel')).toBeVisible(); + + await page.click('button.sidebar-action:has-text("Settings")'); + + await expect(page.locator('.settings-overlay')).toBeVisible(); + await expect(page.locator('.settings-panel')).toBeVisible(); +} + +function hasMessageType(messages: Record[], type: string): boolean { + return messages.some((msg) => msg.type === type); +} + +test.describe('Settings', () => { + test('opens settings via sidebar', async ({ browser }) => { + const app = await setupSettingsPage(browser); + const { page } = app; + + try { + await page.click('button.hamburger-btn'); + await expect(page.locator('.sidebar-panel')).toBeVisible(); + + await page.click('button.sidebar-action:has-text("Settings")'); + + await expect(page.locator('.settings-overlay')).toBeVisible(); + await expect(page.locator('.settings-panel')).toBeVisible(); + await expect(page.locator('.sidebar-panel')).toHaveCount(0); + } finally { + await app.close(); + } + }); + + test('shows the settings title', async ({ browser }) => { + const app = await setupSettingsPage(browser); + const { page } = app; + + try { + await openSettings(page); + await expect(page.locator('.settings-title')).toHaveText('Settings'); + } finally { + await app.close(); + } + }); + + test('shows all accordion sections in order', async ({ browser }) => { + const app = await setupSettingsPage(browser); + const { page } = app; + const expectedSections = [ + 'Custom Instructions', + 'Tools', + 'MCP Servers', + 'Agents', + 'Custom Tools', + 'Quota', + 'Compaction', + ]; + + try { + await openSettings(page); + + const sections = page.locator('button.settings-accordion-btn'); + await expect(sections).toHaveCount(expectedSections.length); + + for (const [index, title] of expectedSections.entries()) { + await expect(sections.nth(index)).toContainText(title); + } + } finally { + await app.close(); + } + }); + + test('expands and collapses an accordion section', async ({ browser }) => { + const app = await setupSettingsPage(browser); + const { page } = app; + const instructionsButton = page.locator('button.settings-accordion-btn', { hasText: 'Custom Instructions' }); + + try { + await openSettings(page); + await expect(page.locator('.settings-accordion-body')).toHaveCount(0); + + await instructionsButton.click(); + + await expect(instructionsButton).toHaveClass(/open/); + await expect(page.locator('.settings-accordion-body')).toHaveCount(1); + await expect(page.locator('textarea.settings-textarea')).toBeVisible(); + + await instructionsButton.click(); + + await expect(instructionsButton).not.toHaveClass(/open/); + await expect(page.locator('.settings-accordion-body')).toHaveCount(0); + } finally { + await app.close(); + } + }); + + test('keeps only one accordion section open at a time', async ({ browser }) => { + const app = await setupSettingsPage(browser); + const { page } = app; + const instructionsButton = page.locator('button.settings-accordion-btn', { hasText: 'Custom Instructions' }); + const toolsButton = page.getByRole('button', { name: /^Tools\b/ }); + + try { + await openSettings(page); + + await instructionsButton.click(); + await expect(instructionsButton).toHaveClass(/open/); + await expect(page.locator('textarea.settings-textarea')).toBeVisible(); + + await toolsButton.click(); + + await expect(toolsButton).toHaveClass(/open/); + await expect(instructionsButton).not.toHaveClass(/open/); + await expect(page.locator('textarea.settings-textarea')).toHaveCount(0); + await expect(page.locator('.settings-accordion-body')).toHaveCount(1); + } finally { + await app.close(); + } + }); + + test('saves custom instructions', async ({ browser }) => { + const app = await setupSettingsPage(browser, { + settings: { customInstructions: 'Keep answers concise.' }, + }); + const { page, putPayloads } = app; + const instructionsText = 'Always explain code changes briefly and include validation steps.'; + + try { + await openSettings(page); + await page.locator('button.settings-accordion-btn', { hasText: 'Custom Instructions' }).click(); + + const textarea = page.locator('textarea.settings-textarea'); + await expect(textarea).toHaveValue('Keep answers concise.'); + + await textarea.fill(instructionsText); + await page.locator('.settings-accordion-body .action-btn.save').click(); + + await expect.poll(() => putPayloads.length).toBe(1); + expect(putPayloads[0]?.customInstructions).toBe(instructionsText); + await expect(textarea).toHaveValue(instructionsText); + } finally { + await app.close(); + } + }); + + test('loads and renders tool toggles', async ({ browser }) => { + const app = await setupSettingsPage(browser, { + settings: { excludedTools: ['run_terminal'] }, + onMessage: (msg, ws) => { + if (msg.type === 'list_tools') { + ws.send(JSON.stringify({ type: 'tools', tools: MOCK_TOOLS })); + } + }, + }); + const { page, clientMessages } = app; + + try { + await openSettings(page); + await page.getByRole('button', { name: /^Tools\b/ }).click(); + + await expect.poll(() => hasMessageType(clientMessages, 'list_tools')).toBe(true); + await expect(page.locator('input.tool-toggle-check')).toHaveCount(MOCK_TOOLS.length); + await expect(page.locator('.tool-toggle-name')).toHaveText(MOCK_TOOLS.map((tool) => tool.name)); + await expect(page.locator('input.tool-toggle-check').nth(0)).toBeChecked(); + await expect(page.locator('input.tool-toggle-check').nth(1)).toBeChecked(); + await expect(page.locator('input.tool-toggle-check').nth(2)).not.toBeChecked(); + } finally { + await app.close(); + } + }); + + test('loads and renders agents with the active indicator', async ({ browser }) => { + const currentAgent = MOCK_AGENTS[0].name; + const app = await setupSettingsPage(browser, { + onMessage: (msg, ws) => { + if (msg.type === 'list_agents') { + ws.send(JSON.stringify({ type: 'agents', agents: MOCK_AGENTS, current: currentAgent })); + } + }, + }); + const { page, clientMessages } = app; + + try { + await openSettings(page); + await page.locator('button.settings-accordion-btn', { hasText: 'Agents' }).click(); + + await expect.poll(() => hasMessageType(clientMessages, 'list_agents')).toBe(true); + await expect(page.locator('.agent-item')).toHaveCount(MOCK_AGENTS.length); + await expect(page.locator('.agent-name')).toHaveText(MOCK_AGENTS.map((agent) => agent.name)); + await expect(page.locator('.agent-item.active')).toHaveCount(1); + await expect(page.locator('.agent-item.active .agent-name')).toHaveText(currentAgent); + await expect(page.locator('.agent-item.active .agent-current')).toHaveText('active'); + } finally { + await app.close(); + } + }); + + test('closes settings from the close button', async ({ browser }) => { + const app = await setupSettingsPage(browser); + const { page } = app; + + try { + await openSettings(page); + await page.click('button.settings-close'); + await expect(page.locator('.settings-overlay')).toHaveCount(0); + } finally { + await app.close(); + } + }); + + test('closes settings when the overlay is clicked', async ({ browser }) => { + const app = await setupSettingsPage(browser); + const { page } = app; + + try { + await openSettings(page); + + await page.locator('.settings-overlay').evaluate((overlay) => { + overlay.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + await expect(page.locator('.settings-overlay')).toHaveCount(0); + } finally { + await app.close(); + } + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..eb93c30 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "strict": true, + "allowJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true + }, + "exclude": ["src/index.ts", "src/server.ts", "src/config.ts", "src/auth/**/*", "src/copilot/**/*", "src/routes/auth.ts", "src/routes/api.ts", "src/ws/**/*", "src/types/**/*", "src/security-log.ts", "dist/**/*", "build/**/*"] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..e4631cb --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "dist", + "rootDir": "src/lib/server", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "sourceMap": true + }, + "include": ["src/lib/server/**/*"], + "exclude": ["node_modules"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..b0b9719 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,76 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig, type ViteDevServer } from 'vite'; + +function webSocketDevPlugin() { + return { + name: 'copilot-ws-dev', + async configureServer(server: ViteDevServer) { + if (!server.httpServer) return; + + // Dynamic import to avoid bundling server-only code + const { setupWebSocket } = await import('./dist/ws/handler.js'); + const session = (await import('express-session')).default; + const FileStoreFactory = (await import('session-file-store')).default; + + const FileStore = FileStoreFactory(session); + const sessionMiddleware = session({ + store: new FileStore({ + path: process.env.SESSION_STORE_PATH || '.sessions', + ttl: 86400, + retries: 0, + logFn: () => {}, + }), + secret: process.env.SESSION_SECRET || 'dev-secret-change-me', + resave: false, + saveUninitialized: false, + rolling: true, + cookie: { httpOnly: true, secure: false, sameSite: 'lax', maxAge: 30 * 24 * 60 * 60 * 1000 }, + }); + + // Bridge sessions to SvelteKit locals via globalThis + const { registerSession, deleteSessionById } = await import('./dist/session-store.js'); + + // Intercept HTTP requests to inject session + server.middlewares.use((req, res, next) => { + sessionMiddleware(req as any, res as any, () => { + const sessionId = registerSession((req as any).session); + req.headers['x-session-id'] = sessionId; + const origEnd = res.end.bind(res); + (res as any).end = function (...args: any[]) { + deleteSessionById(sessionId); + return origEnd(...args); + }; + next(); + }); + }); + + setupWebSocket(server.httpServer, sessionMiddleware); + console.log(' ✓ WebSocket server attached to Vite dev server'); + }, + }; +} + +export default defineConfig({ + plugins: [sveltekit(), webSocketDevPlugin()], + server: { + port: 5173, + }, + build: { + rollupOptions: { + output: { + manualChunks(id) { + // Keep Svelte runtime in one chunk to avoid circular deps + if (id.includes('node_modules/svelte')) { + return 'svelte'; + } + // Group markdown rendering libs together + if (id.includes('node_modules/highlight.js') || + id.includes('node_modules/marked') || + id.includes('node_modules/dompurify')) { + return 'markdown-vendor'; + } + }, + }, + }, + }, +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..78d6213 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,38 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vitest/config'; +import { fileURLToPath } from 'node:url'; + +export default defineConfig({ + plugins: [sveltekit()], + resolve: { + alias: { + $lib: fileURLToPath(new URL('./src/lib', import.meta.url)), + }, + }, + test: { + environment: 'jsdom', + environmentMatchGlobs: [['src/lib/server/**', 'node']], + include: ['src/**/*.test.ts'], + exclude: ['node_modules', 'tests/**'], + setupFiles: ['./src/test-setup.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'lcov'], + thresholds: { + lines: 50, + functions: 50, + branches: 50, + statements: 50, + }, + include: ['src/lib/**/*.ts'], + exclude: [ + '**/*.test.ts', + '**/*.d.ts', + '**/types/**', + 'src/lib/server/**', + 'src/lib/stores/**', + 'src/lib/utils/markdown.ts', + ], + }, + }, +}); From 03bd63d84a60b37ce9dd26f1fa8f4b1fde22ddf0 Mon Sep 17 00:00:00 2001 From: devartifex Date: Sat, 14 Mar 2026 16:09:53 +0100 Subject: [PATCH 02/48] chore: bump version to 3.0.0 and update README badges - Remove deploy.yml badge (workflow moved to private repo) - Add GitHub Release badge - Bump package.json version to 3.0.0 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 84464f6..8aa8b8b 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Copilot Unleashed

    + Latest Release CI - Deploy Node ≥24 TypeScript Svelte 5 diff --git a/package-lock.json b/package-lock.json index f8e921e..60037e8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "copilot-unleashed", - "version": "2.0.0", + "version": "3.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "copilot-unleashed", - "version": "2.0.0", + "version": "3.0.0", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 3d1bef3..71cacee 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "copilot-unleashed", - "version": "2.0.0", + "version": "3.0.0", "description": "Copilot Unleashed — Self-hosted multi-model AI chat powered by the GitHub Copilot SDK", "main": "build/index.js", "scripts": { From fd75bed91dce218496b7dc5f554fd5f14ea419c0 Mon Sep 17 00:00:00 2001 From: Gabriel Mercuri Date: Sat, 14 Mar 2026 17:07:33 +0100 Subject: [PATCH 03/48] =?UTF-8?q?feat:=20Phase=200+1=20=E2=80=94=20Infrast?= =?UTF-8?q?ructure,=20GHAS,=20GitHub=20Flow=20+=20SDK=20features?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ci: add CodeQL scanning workflow and secret scanning setup script - Create .github/workflows/codeql.yml (JS/TS analysis, weekly + PR triggers) - Create scripts/setup-security.sh for enabling secret scanning + push protection - Update SECURITY.md with secret scanning documentation Closes #78 Closes #79 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * ci: add branch protection script and release-please workflow - Create scripts/setup-branch-protection.sh (gh api, requires admin) - Create .github/workflows/release.yml (release-please for semver + changelog) - Create release-please-config.json and .release-please-manifest.json Closes #75 Closes #80 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * ci: enhance CI with Playwright E2E, conventional commit check, caching - Add e2e job with Playwright desktop tests and artifact upload on failure - Add commit-lint job checking PR title against conventional commits pattern - Add concurrency group to cancel redundant runs - Add npm cache via setup-node Closes #70 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: enhance PR template, YAML issue forms, and CODEOWNERS - Upgrade PR template with GitHub Flow + security checklist - Convert issue templates from Markdown to YAML forms - Add SDK feature issue template - Add security advisory contact link - Create CODEOWNERS with path-based ownership Closes #73 Closes #74 Closes #77 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * ci: add PR auto-labeler and stale issues/PR management - Create labeler config with 10 path-based labels (backend, frontend, sdk, etc.) - Create labeler.yml workflow using actions/labeler@v5 - Create stale.yml workflow (30-day stale, 7-day close, exempt security/killer-feature) Closes #71 Closes #72 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: add Copilot prompt files and rewrite copilot-instructions.md - Create 4 prompt files: generate-test, review-security, add-feature, fix-bug - Rewrite copilot-instructions.md with accurate counts (20 components, 78 message types) - Add skills system, testing sections, updated project structure Closes #76 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: adopt awesome-copilot skills, agents, instructions, and workflows Skills added (4): github-issues, doublecheck, copilot-spaces, automate-this Agents added (6): 4.1-Beast, critical-thinking, implementation-plan, refine-issue, polyglot-test-generator, adr-generator Instructions added (2): code-review-generic, performance-optimization Workflows added (2): codespell, check-pr-target Closes #86 Closes #87 Closes #88 Closes #69 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: add SDK SessionHooks support (#49) Wire all six SDK session hooks (onPreToolUse, onPostToolUse, onSessionStart, onSessionEnd, onErrorOccurred) to forward events over WebSocket as new message types. Changes: - Add HookPreToolMessage, HookPostToolMessage, HookSessionStartMessage, HookSessionEndMessage, HookErrorMessage types to ServerMessage union - Add HookEventCallback type and buildSessionHooks() factory to session.ts - Add onHookEvent option to CreateSessionOptions - Wire hooks in both session creation paths in handler.ts - Add 7 unit tests covering all hook types and wiring Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: validate attachment paths to prevent arbitrary file reads (#52) - Add isValidAttachmentPath() to ensure attachment paths are inside the upload directory (tmpdir/copilot-uploads/), preventing malicious WebSocket clients from reading arbitrary server files via the SDK - Log rejected paths via security logger at warn level - Add unit tests for path validation (8 tests covering traversal, relative paths, prefix spoofing, etc.) - Add image-specific upload tests verifying all 5 image types (jpg, jpeg, png, gif, webp) are accepted with correct MIME types - Add test verifying upload returns absolute server-side paths Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: complete MCP server integration (#51) - Extract parseMcpServers() helper with defense-in-depth enabled filtering - Pass MCP servers (GitHub + user) on resume_session (SDK + fallback) - Update ResumeSessionMessage type to include mcpServers - Client sends enabled MCP servers when resuming sessions - Add unit tests for MCP parser (9 tests) and session config (3 tests) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: add path traversal protection to session persistence (#55) - Add isValidSessionId() UUID validation for getSessionDetail/buildSessionContext - Reset isProcessing flag on resume to prevent stale state - Add 4 unit tests for UUID validation and path traversal rejection Closes #55 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .codespellrc | 3 + .github/CODEOWNERS | 30 ++ .github/ISSUE_TEMPLATE/bug_report.md | 28 -- .github/ISSUE_TEMPLATE/bug_report.yml | 64 +++ .github/ISSUE_TEMPLATE/config.yml | 4 + .github/ISSUE_TEMPLATE/feature_request.md | 19 - .github/ISSUE_TEMPLATE/feature_request.yml | 42 ++ .github/ISSUE_TEMPLATE/sdk_feature.yml | 41 ++ .github/agents/adr-generator.agent.md | 224 ++++++++++ .github/agents/critical-thinking.agent.md | 24 + .github/agents/implementation-plan.agent.md | 161 +++++++ .../agents/polyglot-test-generator.agent.md | 85 ++++ .github/agents/refine-issue.agent.md | 35 ++ .github/copilot-instructions.md | 64 ++- .../code-review-generic.instructions.md | 418 +++++++++++++++++ .../performance-optimization.instructions.md | 420 ++++++++++++++++++ .github/labeler.yml | 67 +++ .github/prompts/add-feature.prompt.md | 29 ++ .github/prompts/fix-bug.prompt.md | 25 ++ .github/prompts/generate-test.prompt.md | 26 ++ .github/prompts/review-security.prompt.md | 38 ++ .github/pull_request_template.md | 42 +- .github/workflows/check-pr-target.yml | 44 ++ .github/workflows/ci.yml | 57 ++- .github/workflows/codeql.yml | 33 ++ .github/workflows/codespell.yml | 22 + .github/workflows/labeler.yml | 19 + .github/workflows/release.yml | 18 + .github/workflows/stale.yml | 34 ++ .release-please-manifest.json | 3 + SECURITY.md | 8 + release-please-config.json | 20 + scripts/setup-branch-protection.sh | 39 ++ scripts/setup-security.sh | 60 +++ skills/automate-this/SKILL.md | 244 ++++++++++ skills/copilot-spaces/SKILL.md | 205 +++++++++ skills/doublecheck/SKILL.md | 277 ++++++++++++ skills/github-issues/SKILL.md | 201 +++++++++ .../server/copilot/session-metadata.test.ts | 27 ++ src/lib/server/copilot/session-metadata.ts | 16 +- src/lib/server/copilot/session.test.ts | 178 +++++++- src/lib/server/copilot/session.ts | 32 ++ .../server/ws/attachment-validation.test.ts | 48 ++ src/lib/server/ws/handler.ts | 117 +++-- src/lib/server/ws/parse-mcp-servers.test.ts | 92 ++++ src/lib/stores/ws.svelte.ts | 12 +- src/lib/types/index.ts | 37 +- src/routes/+page.svelte | 2 +- src/routes/api/upload/server.test.ts | 36 ++ 49 files changed, 3654 insertions(+), 116 deletions(-) create mode 100644 .codespellrc create mode 100644 .github/CODEOWNERS delete mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml delete mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/ISSUE_TEMPLATE/sdk_feature.yml create mode 100644 .github/agents/adr-generator.agent.md create mode 100644 .github/agents/critical-thinking.agent.md create mode 100644 .github/agents/implementation-plan.agent.md create mode 100644 .github/agents/polyglot-test-generator.agent.md create mode 100644 .github/agents/refine-issue.agent.md create mode 100644 .github/instructions/code-review-generic.instructions.md create mode 100644 .github/instructions/performance-optimization.instructions.md create mode 100644 .github/labeler.yml create mode 100644 .github/prompts/add-feature.prompt.md create mode 100644 .github/prompts/fix-bug.prompt.md create mode 100644 .github/prompts/generate-test.prompt.md create mode 100644 .github/prompts/review-security.prompt.md create mode 100644 .github/workflows/check-pr-target.yml create mode 100644 .github/workflows/codeql.yml create mode 100644 .github/workflows/codespell.yml create mode 100644 .github/workflows/labeler.yml create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/stale.yml create mode 100644 .release-please-manifest.json create mode 100644 release-please-config.json create mode 100755 scripts/setup-branch-protection.sh create mode 100755 scripts/setup-security.sh create mode 100644 skills/automate-this/SKILL.md create mode 100644 skills/copilot-spaces/SKILL.md create mode 100644 skills/doublecheck/SKILL.md create mode 100644 skills/github-issues/SKILL.md create mode 100644 src/lib/server/ws/attachment-validation.test.ts create mode 100644 src/lib/server/ws/parse-mcp-servers.test.ts diff --git a/.codespellrc b/.codespellrc new file mode 100644 index 0000000..9130cbb --- /dev/null +++ b/.codespellrc @@ -0,0 +1,3 @@ +[codespell] +skip = node_modules,build,dist,coverage,playwright-report,package-lock.json,.svelte-kit,bundled-sessions,bundled-session-store.db +ignore-words-list = crate,ot diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..55bf9ff --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,30 @@ +# Default owner for all files +* @devartifex + +# Backend / SDK integration +/src/lib/server/ @devartifex +/server.js @devartifex + +# Frontend components & stores +/src/lib/components/ @devartifex +/src/lib/stores/ @devartifex + +# Infrastructure & deployment +/Dockerfile @devartifex +/docker-compose.yml @devartifex +/infra/ @devartifex + +# CI/CD & GitHub config +/.github/workflows/ @devartifex +/.github/agents/ @devartifex +/.github/instructions/ @devartifex + +# Documentation +/docs/ @devartifex + +# Tests +/tests/ @devartifex +/playwright.config.ts @devartifex + +# Skills +/skills/ @devartifex diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index c9fc2d3..0000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -name: Bug report -about: Report a bug or unexpected behavior -title: '' -labels: bug -assignees: '' ---- - -**Describe the bug** -A clear description of what the bug is. - -**To reproduce** -Steps to reproduce the behavior: -1. Go to '...' -2. Click on '...' -3. See error - -**Expected behavior** -What you expected to happen. - -**Screenshots** -If applicable, add screenshots. - -**Environment** -- Browser: [e.g. Chrome 120, Safari 17] -- Device: [e.g. iPhone 15, Desktop] -- Deployment: [e.g. Docker, Azure] -- Node.js version: [e.g. 24.0.0] diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..702e2db --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,64 @@ +name: 🐛 Bug Report +description: Report a bug or unexpected behavior +labels: ["bug"] +body: + - type: markdown + attributes: + value: "Thanks for reporting a bug! Please fill out the form below." + - type: textarea + id: description + attributes: + label: Describe the bug + description: A clear description of what the bug is. + validations: + required: true + - type: textarea + id: steps + attributes: + label: Steps to reproduce + description: Steps to reproduce the behavior. + placeholder: | + 1. Go to '...' + 2. Click on '...' + 3. See error + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + description: What you expected to happen. + validations: + required: true + - type: textarea + id: screenshots + attributes: + label: Screenshots / Recordings + description: If applicable, add screenshots or screen recordings. + - type: dropdown + id: browser + attributes: + label: Browser + options: + - Chrome + - Firefox + - Safari + - Edge + - Other + validations: + required: true + - type: dropdown + id: device + attributes: + label: Device + options: + - Desktop + - Tablet + - Phone + validations: + required: true + - type: input + id: deployment + attributes: + label: Deployment type + placeholder: "Docker, Azure, Local dev" diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 0086358..db8d2bd 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1 +1,5 @@ blank_issues_enabled: true +contact_links: + - name: 🔒 Security Vulnerability + url: https://github.com/devartifex/copilot-unleashed/security/advisories/new + about: Report security vulnerabilities privately diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index a93d649..0000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -name: Feature request -about: Suggest a new feature or improvement -title: '' -labels: enhancement -assignees: '' ---- - -**Is your feature request related to a problem?** -A clear description of the problem. - -**Describe the solution you'd like** -What you want to happen. - -**Alternatives considered** -Any alternative solutions or features you've considered. - -**Additional context** -Any other context or screenshots. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..9339e71 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,42 @@ +name: ✨ Feature Request +description: Suggest a new feature or improvement +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: "Thanks for suggesting a feature!" + - type: textarea + id: problem + attributes: + label: Is your feature request related to a problem? + description: A clear description of the problem. + - type: textarea + id: solution + attributes: + label: Describe the solution you'd like + description: What you want to happen. + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: Any alternative solutions or features you've considered. + - type: dropdown + id: area + attributes: + label: Area + options: + - UI/UX + - Backend/SDK + - CI/CD + - Security + - Documentation + - Other + validations: + required: true + - type: textarea + id: context + attributes: + label: Additional context + description: Any other context or screenshots. diff --git a/.github/ISSUE_TEMPLATE/sdk_feature.yml b/.github/ISSUE_TEMPLATE/sdk_feature.yml new file mode 100644 index 0000000..523fb5f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/sdk_feature.yml @@ -0,0 +1,41 @@ +name: 🔌 SDK Feature +description: Track a Copilot SDK feature implementation or verification +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: "Track implementation/verification of a @github/copilot-sdk feature." + - type: input + id: sdk_feature + attributes: + label: SDK Feature Name + placeholder: "e.g., Hooks, MCP Servers, Image Input" + validations: + required: true + - type: input + id: sdk_doc + attributes: + label: SDK Documentation Link + placeholder: "https://github.com/github/copilot-sdk/blob/main/docs/features/..." + - type: dropdown + id: status + attributes: + label: Current Implementation Status + options: + - Not implemented + - Partially implemented + - Implemented but needs verification + validations: + required: true + - type: textarea + id: tasks + attributes: + label: Implementation Tasks + description: What needs to be done to complete this feature. + validations: + required: true + - type: textarea + id: affected_files + attributes: + label: Affected Files + description: Which files need to be created or modified. diff --git a/.github/agents/adr-generator.agent.md b/.github/agents/adr-generator.agent.md new file mode 100644 index 0000000..c67998f --- /dev/null +++ b/.github/agents/adr-generator.agent.md @@ -0,0 +1,224 @@ +--- +name: ADR Generator +description: Expert agent for creating comprehensive Architectural Decision Records (ADRs) with structured formatting optimized for AI consumption and human readability. +--- + +# ADR Generator Agent + +You are an expert in architectural documentation, this agent creates well-structured, comprehensive Architectural Decision Records that document important technical decisions with clear rationale, consequences, and alternatives. + +--- + +## Core Workflow + +### 1. Gather Required Information + +Before creating an ADR, collect the following inputs from the user or conversation context: + +- **Decision Title**: Clear, concise name for the decision +- **Context**: Problem statement, technical constraints, business requirements +- **Decision**: The chosen solution with rationale +- **Alternatives**: Other options considered and why they were rejected +- **Stakeholders**: People or teams involved in or affected by the decision + +**Input Validation:** If any required information is missing, ask the user to provide it before proceeding. + +### 2. Determine ADR Number + +- Check the `/docs/adr/` directory for existing ADRs +- Determine the next sequential 4-digit number (e.g., 0001, 0002, etc.) +- If the directory doesn't exist, start with 0001 + +### 3. Generate ADR Document in Markdown + +Create an ADR as a markdown file following the standardized format below with these requirements: + +- Generate the complete document in markdown format +- Use precise, unambiguous language +- Include both positive and negative consequences +- Document all alternatives with clear rejection rationale +- Use coded bullet points (3-letter codes + 3-digit numbers) for multi-item sections +- Structure content for both machine parsing and human reference +- Save the file to `/docs/adr/` with proper naming convention + +--- + +## Required ADR Structure (template) + +### Front Matter + +```yaml +--- +title: "ADR-NNNN: [Decision Title]" +status: "Proposed" +date: "YYYY-MM-DD" +authors: "[Stakeholder Names/Roles]" +tags: ["architecture", "decision"] +supersedes: "" +superseded_by: "" +--- +``` + +### Document Sections + +#### Status + +**Proposed** | Accepted | Rejected | Superseded | Deprecated + +Use "Proposed" for new ADRs unless otherwise specified. + +#### Context + +[Problem statement, technical constraints, business requirements, and environmental factors requiring this decision.] + +**Guidelines:** + +- Explain the forces at play (technical, business, organizational) +- Describe the problem or opportunity +- Include relevant constraints and requirements + +#### Decision + +[Chosen solution with clear rationale for selection.] + +**Guidelines:** + +- State the decision clearly and unambiguously +- Explain why this solution was chosen +- Include key factors that influenced the decision + +#### Consequences + +##### Positive + +- **POS-001**: [Beneficial outcomes and advantages] +- **POS-002**: [Performance, maintainability, scalability improvements] +- **POS-003**: [Alignment with architectural principles] + +##### Negative + +- **NEG-001**: [Trade-offs, limitations, drawbacks] +- **NEG-002**: [Technical debt or complexity introduced] +- **NEG-003**: [Risks and future challenges] + +**Guidelines:** + +- Be honest about both positive and negative impacts +- Include 3-5 items in each category +- Use specific, measurable consequences when possible + +#### Alternatives Considered + +For each alternative: + +##### [Alternative Name] + +- **ALT-XXX**: **Description**: [Brief technical description] +- **ALT-XXX**: **Rejection Reason**: [Why this option was not selected] + +**Guidelines:** + +- Document at least 2-3 alternatives +- Include the "do nothing" option if applicable +- Provide clear reasons for rejection +- Increment ALT codes across all alternatives + +#### Implementation Notes + +- **IMP-001**: [Key implementation considerations] +- **IMP-002**: [Migration or rollout strategy if applicable] +- **IMP-003**: [Monitoring and success criteria] + +**Guidelines:** + +- Include practical guidance for implementation +- Note any migration steps required +- Define success metrics + +#### References + +- **REF-001**: [Related ADRs] +- **REF-002**: [External documentation] +- **REF-003**: [Standards or frameworks referenced] + +**Guidelines:** + +- Link to related ADRs using relative paths +- Include external resources that informed the decision +- Reference relevant standards or frameworks + +--- + +## File Naming and Location + +### Naming Convention + +`adr-NNNN-[title-slug].md` + +**Examples:** + +- `adr-0001-database-selection.md` +- `adr-0015-microservices-architecture.md` +- `adr-0042-authentication-strategy.md` + +### Location + +All ADRs must be saved in: `/docs/adr/` + +### Title Slug Guidelines + +- Convert title to lowercase +- Replace spaces with hyphens +- Remove special characters +- Keep it concise (3-5 words maximum) + +--- + +## Quality Checklist + +Before finalizing the ADR, verify: + +- [ ] ADR number is sequential and correct +- [ ] File name follows naming convention +- [ ] Front matter is complete with all required fields +- [ ] Status is set appropriately (default: "Proposed") +- [ ] Date is in YYYY-MM-DD format +- [ ] Context clearly explains the problem/opportunity +- [ ] Decision is stated clearly and unambiguously +- [ ] At least 1 positive consequence documented +- [ ] At least 1 negative consequence documented +- [ ] At least 1 alternative documented with rejection reasons +- [ ] Implementation notes provide actionable guidance +- [ ] References include related ADRs and resources +- [ ] All coded items use proper format (e.g., POS-001, NEG-001) +- [ ] Language is precise and avoids ambiguity +- [ ] Document is formatted for readability + +--- + +## Important Guidelines + +1. **Be Objective**: Present facts and reasoning, not opinions +2. **Be Honest**: Document both benefits and drawbacks +3. **Be Clear**: Use unambiguous language +4. **Be Specific**: Provide concrete examples and impacts +5. **Be Complete**: Don't skip sections or use placeholders +6. **Be Consistent**: Follow the structure and coding system +7. **Be Timely**: Use the current date unless specified otherwise +8. **Be Connected**: Reference related ADRs when applicable +9. **Be Contextually Correct**: Ensure all information is accurate and up-to-date. Use the current + repository state as the source of truth. + +--- + +## Agent Success Criteria + +Your work is complete when: + +1. ADR file is created in `/docs/adr/` with correct naming +2. All required sections are filled with meaningful content +3. Consequences realistically reflect the decision's impact +4. Alternatives are thoroughly documented with clear rejection reasons +5. Implementation notes provide actionable guidance +6. Document follows all formatting standards +7. Quality checklist items are satisfied diff --git a/.github/agents/critical-thinking.agent.md b/.github/agents/critical-thinking.agent.md new file mode 100644 index 0000000..566de87 --- /dev/null +++ b/.github/agents/critical-thinking.agent.md @@ -0,0 +1,24 @@ +--- +description: 'Challenge assumptions and encourage critical thinking to ensure the best possible solution and outcomes.' +name: 'Critical thinking mode instructions' +tools: ['codebase', 'extensions', 'web/fetch', 'findTestFiles', 'githubRepo', 'problems', 'search', 'searchResults', 'usages'] +--- +# Critical thinking mode instructions + +You are in critical thinking mode. Your task is to challenge assumptions and encourage critical thinking to ensure the best possible solution and outcomes. You are not here to make code edits, but to help the engineer think through their approach and ensure they have considered all relevant factors. + +Your primary goal is to ask 'Why?'. You will continue to ask questions and probe deeper into the engineer's reasoning until you reach the root cause of their assumptions or decisions. This will help them clarify their understanding and ensure they are not overlooking important details. + +## Instructions + +- Do not suggest solutions or provide direct answers +- Encourage the engineer to explore different perspectives and consider alternative approaches. +- Ask challenging questions to help the engineer think critically about their assumptions and decisions. +- Avoid making assumptions about the engineer's knowledge or expertise. +- Play devil's advocate when necessary to help the engineer see potential pitfalls or flaws in their reasoning. +- Be detail-oriented in your questioning, but avoid being overly verbose or apologetic. +- Be firm in your guidance, but also friendly and supportive. +- Be free to argue against the engineer's assumptions and decisions, but do so in a way that encourages them to think critically about their approach rather than simply telling them what to do. +- Have strong opinions about the best way to approach problems, but hold these opinions loosely and be open to changing them based on new information or perspectives. +- Think strategically about the long-term implications of decisions and encourage the engineer to do the same. +- Do not ask multiple questions at once. Focus on one question at a time to encourage deep thinking and reflection and keep your questions concise. diff --git a/.github/agents/implementation-plan.agent.md b/.github/agents/implementation-plan.agent.md new file mode 100644 index 0000000..39079c6 --- /dev/null +++ b/.github/agents/implementation-plan.agent.md @@ -0,0 +1,161 @@ +--- +description: "Generate an implementation plan for new features or refactoring existing code." +name: "Implementation Plan Generation Mode" +tools: ["search/codebase", "search/usages", "vscode/vscodeAPI", "think", "read/problems", "search/changes", "execute/testFailure", "read/terminalSelection", "read/terminalLastCommand", "vscode/openSimpleBrowser", "web/fetch", "findTestFiles", "search/searchResults", "web/githubRepo", "vscode/extensions", "edit/editFiles", "execute/runNotebookCell", "read/getNotebookSummary", "read/readNotebookCellOutput", "search", "vscode/getProjectSetupInfo", "vscode/installExtension", "vscode/newWorkspace", "vscode/runCommand", "execute/getTerminalOutput", "execute/runInTerminal", "execute/createAndRunTask", "execute/getTaskOutput", "execute/runTask"] +--- + +# Implementation Plan Generation Mode + +## Primary Directive + +You are an AI agent operating in planning mode. Generate implementation plans that are fully executable by other AI systems or humans. + +## Execution Context + +This mode is designed for AI-to-AI communication and automated processing. All plans must be deterministic, structured, and immediately actionable by AI Agents or humans. + +## Core Requirements + +- Generate implementation plans that are fully executable by AI agents or humans +- Use deterministic language with zero ambiguity +- Structure all content for automated parsing and execution +- Ensure complete self-containment with no external dependencies for understanding +- DO NOT make any code edits - only generate structured plans + +## Plan Structure Requirements + +Plans must consist of discrete, atomic phases containing executable tasks. Each phase must be independently processable by AI agents or humans without cross-phase dependencies unless explicitly declared. + +## Phase Architecture + +- Each phase must have measurable completion criteria +- Tasks within phases must be executable in parallel unless dependencies are specified +- All task descriptions must include specific file paths, function names, and exact implementation details +- No task should require human interpretation or decision-making + +## AI-Optimized Implementation Standards + +- Use explicit, unambiguous language with zero interpretation required +- Structure all content as machine-parseable formats (tables, lists, structured data) +- Include specific file paths, line numbers, and exact code references where applicable +- Define all variables, constants, and configuration values explicitly +- Provide complete context within each task description +- Use standardized prefixes for all identifiers (REQ-, TASK-, etc.) +- Include validation criteria that can be automatically verified + +## Output File Specifications + +When creating plan files: + +- Save implementation plan files in `/plan/` directory +- Use naming convention: `[purpose]-[component]-[version].md` +- Purpose prefixes: `upgrade|refactor|feature|data|infrastructure|process|architecture|design` +- Example: `upgrade-system-command-4.md`, `feature-auth-module-1.md` +- File must be valid Markdown with proper front matter structure + +## Mandatory Template Structure + +All implementation plans must strictly adhere to the following template. Each section is required and must be populated with specific, actionable content. AI agents must validate template compliance before execution. + +## Template Validation Rules + +- All front matter fields must be present and properly formatted +- All section headers must match exactly (case-sensitive) +- All identifier prefixes must follow the specified format +- Tables must include all required columns with specific task details +- No placeholder text may remain in the final output + +## Status + +The status of the implementation plan must be clearly defined in the front matter and must reflect the current state of the plan. The status can be one of the following (status_color in brackets): `Completed` (bright green badge), `In progress` (yellow badge), `Planned` (blue badge), `Deprecated` (red badge), or `On Hold` (orange badge). It should also be displayed as a badge in the introduction section. + +```md +--- +goal: [Concise Title Describing the Package Implementation Plan's Goal] +version: [Optional: e.g., 1.0, Date] +date_created: [YYYY-MM-DD] +last_updated: [Optional: YYYY-MM-DD] +owner: [Optional: Team/Individual responsible for this spec] +status: 'Completed'|'In progress'|'Planned'|'Deprecated'|'On Hold' +tags: [Optional: List of relevant tags or categories, e.g., `feature`, `upgrade`, `chore`, `architecture`, `migration`, `bug` etc] +--- + +# Introduction + +![Status: ](https://img.shields.io/badge/status--) + +[A short concise introduction to the plan and the goal it is intended to achieve.] + +## 1. Requirements & Constraints + +[Explicitly list all requirements & constraints that affect the plan and constrain how it is implemented. Use bullet points or tables for clarity.] + +- **REQ-001**: Requirement 1 +- **SEC-001**: Security Requirement 1 +- **[3 LETTERS]-001**: Other Requirement 1 +- **CON-001**: Constraint 1 +- **GUD-001**: Guideline 1 +- **PAT-001**: Pattern to follow 1 + +## 2. Implementation Steps + +### Implementation Phase 1 + +- GOAL-001: [Describe the goal of this phase, e.g., "Implement feature X", "Refactor module Y", etc.] + +| Task | Description | Completed | Date | +| -------- | --------------------- | --------- | ---------- | +| TASK-001 | Description of task 1 | ✅ | 2025-04-25 | +| TASK-002 | Description of task 2 | | | +| TASK-003 | Description of task 3 | | | + +### Implementation Phase 2 + +- GOAL-002: [Describe the goal of this phase, e.g., "Implement feature X", "Refactor module Y", etc.] + +| Task | Description | Completed | Date | +| -------- | --------------------- | --------- | ---- | +| TASK-004 | Description of task 4 | | | +| TASK-005 | Description of task 5 | | | +| TASK-006 | Description of task 6 | | | + +## 3. Alternatives + +[A bullet point list of any alternative approaches that were considered and why they were not chosen. This helps to provide context and rationale for the chosen approach.] + +- **ALT-001**: Alternative approach 1 +- **ALT-002**: Alternative approach 2 + +## 4. Dependencies + +[List any dependencies that need to be addressed, such as libraries, frameworks, or other components that the plan relies on.] + +- **DEP-001**: Dependency 1 +- **DEP-002**: Dependency 2 + +## 5. Files + +[List the files that will be affected by the feature or refactoring task.] + +- **FILE-001**: Description of file 1 +- **FILE-002**: Description of file 2 + +## 6. Testing + +[List the tests that need to be implemented to verify the feature or refactoring task.] + +- **TEST-001**: Description of test 1 +- **TEST-002**: Description of test 2 + +## 7. Risks & Assumptions + +[List any risks or assumptions related to the implementation of the plan.] + +- **RISK-001**: Risk 1 +- **ASSUMPTION-001**: Assumption 1 + +## 8. Related Specifications / Further Reading + +[Link to related spec 1] +[Link to relevant external documentation] +``` diff --git a/.github/agents/polyglot-test-generator.agent.md b/.github/agents/polyglot-test-generator.agent.md new file mode 100644 index 0000000..334ade7 --- /dev/null +++ b/.github/agents/polyglot-test-generator.agent.md @@ -0,0 +1,85 @@ +--- +description: 'Orchestrates comprehensive test generation using Research-Plan-Implement pipeline. Use when asked to generate tests, write unit tests, improve test coverage, or add tests.' +name: 'Polyglot Test Generator' +--- + +# Test Generator Agent + +You coordinate test generation using the Research-Plan-Implement (RPI) pipeline. You are polyglot - you work with any programming language. + +## Pipeline Overview + +1. **Research** - Understand the codebase structure, testing patterns, and what needs testing +2. **Plan** - Create a phased test implementation plan +3. **Implement** - Execute the plan phase by phase, with verification + +## Workflow + +### Step 1: Clarify the Request + +First, understand what the user wants: +- What scope? (entire project, specific files, specific classes) +- Any priority areas? +- Any testing framework preferences? + +If the request is clear (e.g., "generate tests for this project"), proceed directly. + +### Step 2: Research Phase + +Call the `polyglot-test-researcher` subagent to analyze the codebase: + +``` +runSubagent({ + agent: "polyglot-test-researcher", + prompt: "Research the codebase at [PATH] for test generation. Identify: project structure, existing tests, source files to test, testing framework, build/test commands." +}) +``` + +The researcher will create `.testagent/research.md` with findings. + +### Step 3: Planning Phase + +Call the `polyglot-test-planner` subagent to create the test plan: + +``` +runSubagent({ + agent: "polyglot-test-planner", + prompt: "Create a test implementation plan based on the research at .testagent/research.md. Create phased approach with specific files and test cases." +}) +``` + +The planner will create `.testagent/plan.md` with phases. + +### Step 4: Implementation Phase + +Read the plan and execute each phase by calling the `polyglot-test-implementer` subagent: + +``` +runSubagent({ + agent: "polyglot-test-implementer", + prompt: "Implement Phase N from .testagent/plan.md: [phase description]. Ensure tests compile and pass." +}) +``` + +Call the implementer ONCE PER PHASE, sequentially. Wait for each phase to complete before starting the next. + +### Step 5: Report Results + +After all phases are complete: +- Summarize tests created +- Report any failures or issues +- Suggest next steps if needed + +## State Management + +All state is stored in `.testagent/` folder in the workspace: +- `.testagent/research.md` - Research findings +- `.testagent/plan.md` - Implementation plan +- `.testagent/status.md` - Progress tracking (optional) + +## Important Rules + +1. **Sequential phases** - Always complete one phase before starting the next +2. **Polyglot** - Detect the language and use appropriate patterns +3. **Verify** - Each phase should result in compiling, passing tests +4. **Don't skip** - If a phase fails, report it rather than skipping diff --git a/.github/agents/refine-issue.agent.md b/.github/agents/refine-issue.agent.md new file mode 100644 index 0000000..c3c6d1a --- /dev/null +++ b/.github/agents/refine-issue.agent.md @@ -0,0 +1,35 @@ +--- +description: 'Refine the requirement or issue with Acceptance Criteria, Technical Considerations, Edge Cases, and NFRs' +name: 'Refine Requirement or Issue' +tools: [ 'list_issues','githubRepo', 'search', 'add_issue_comment','create_issue','create_issue_comment','update_issue','delete_issue','get_issue', 'search_issues'] +--- + +# Refine Requirement or Issue Chat Mode + +When activated, this mode allows GitHub Copilot to analyze an existing issue and enrich it with structured details including: + +- Detailed description with context and background +- Acceptance criteria in a testable format +- Technical considerations and dependencies +- Potential edge cases and risks +- Expected NFR (Non-Functional Requirements) + +## Steps to Run +1. Read the issue description and understand the context. +2. Modify the issue description to include more details. +3. Add acceptance criteria in a testable format. +4. Include technical considerations and dependencies. +5. Add potential edge cases and risks. +6. Provide suggestions for effort estimation. +7. Review the refined requirement and make any necessary adjustments. + +## Usage + +To activate Requirement Refinement mode: + +1. Refer an existing issue in your prompt as `refine ` +2. Use the mode: `refine-issue` + +## Output + +Copilot will modify the issue description and add structured details to it. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index c8bac32..d17c5d5 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -9,11 +9,13 @@ Self-hosted multi-model AI chat platform powered by the official `@github/copilo ## Architecture - **Full-stack**: SvelteKit 5 with `adapter-node` (replaces Express + vanilla JS) -- **Frontend**: 17 Svelte 5 components with rune-based stores — dark theme, mobile-first +- **Frontend**: 20 Svelte 5 components with 4 rune-based stores — dark theme, mobile-first - **Real-time**: WebSocket via custom `server.js` entry, per-user `CopilotClient` lifecycle - **Auth**: GitHub Device Flow only (no client secret, no redirect URI) - **Session**: Express sessions bridged to SvelteKit via `x-session-id` header in `hooks.server.ts` -- **Deployment**: Docker container → Azure Container Apps via `azd up` or GitHub Actions +- **Skills**: `skills/` directory with `SKILL.md` definitions, discovered server-side and passed to the SDK via `skillDirectories` +- **Settings**: Persisted server-side via `/api/settings` and mirrored in `localStorage` +- **Deployment**: Docker container → Azure Container Apps via `azd up` ## Tech Stack @@ -28,36 +30,56 @@ Self-hosted multi-model AI chat platform powered by the official `@github/copilo | Markdown | `marked` + `dompurify` + `highlight.js` (npm, bundled by Vite) | | Security | Helmet-like CSP in hooks.server.ts, rate limiting, DOMPurify | | Sessions | express-session bridged to SvelteKit locals | -| Build | `vite build` → `build/` via adapter-node | -| Testing | Playwright (desktop + mobile viewports) | +| Build | `npm run build` (`tsc -p tsconfig.node.json` + `vite build`) → `build/` via adapter-node | +| Testing | Vitest (colocated `*.test.ts`) + Playwright (Desktop Chrome, Pixel 7, iPhone 14) | | Container | Multi-stage Dockerfile (builder + runtime) | -| IaC | Bicep (Container Apps, ACR, Managed Identity) | +| IaC | Bicep (Azure Container Apps) | ## Project Structure ``` server.js # Custom entry: HTTP + express-session + WebSocket + SvelteKit handler +skills/ # SDK skill definitions (currently 1 skill) +└── git-commit/SKILL.md # Built-in git commit workflow skill +tests/ # Playwright E2E suites and helpers svelte.config.js # SvelteKit config (adapter-node) vite.config.ts # Vite config +vitest.config.ts # Vitest config for colocated unit tests src/ ├── app.html # SvelteKit shell (viewport, theme-color, PWA meta) ├── app.css # Global reset, design tokens, highlight.js theme ├── hooks.server.ts # Session bridge, CSP headers, rate limiting +├── hooks.server.test.ts # Example colocated unit test pattern │ ├── lib/ -│ ├── components/ # 17 Svelte 5 components (see ARCHITECTURE.md) -│ ├── stores/ # Rune stores: auth, chat, settings, ws -│ ├── server/ # Server-only: auth, copilot, ws handler, config -│ ├── types/index.ts # All types: 34 server + 19 client message types +│ ├── components/ # 20 Svelte 5 components (see ARCHITECTURE.md) +│ ├── stores/ # 4 rune stores: auth, chat, settings, ws +│ ├── server/ # 22 server files: auth, copilot, settings, skills, ws, security +│ ├── types/index.ts # All message types: 60 server + 18 client = 78 total │ └── utils/markdown.ts # Shared markdown pipeline │ ├── routes/ -│ ├── +page.svelte # Main page: login or full chat screen -│ ├── +layout.server.ts # Root: auth check from session -│ ├── auth/device/… # Device Flow endpoints -│ ├── api/… # Models, upload, version, client-error -│ └── health/+server.ts # Health check +│ ├── +page.svelte # Main page: login or full chat screen (1 page route) +│ ├── +layout.svelte # App shell layout +│ ├── +layout.server.ts # Root auth/session bootstrap +│ ├── +error.svelte # Route error boundary +│ ├── auth/ +│ │ ├── device/start/+server.ts +│ │ ├── device/poll/+server.ts +│ │ ├── logout/+server.ts +│ │ └── status/+server.ts +│ ├── api/ +│ │ ├── client-error/+server.ts +│ │ ├── models/+server.ts +│ │ ├── settings/+server.ts +│ │ ├── skills/+server.ts +│ │ ├── upload/+server.ts +│ │ ├── version/+server.ts +│ │ └── sessions/sync/+server.ts +│ └── health/+server.ts # 12 +server.ts endpoint routes total +│ +└── **/*.test.ts # Vitest unit tests live next to source files ``` ## Conventions @@ -115,13 +137,18 @@ docker compose up --build npm install && npm run build && npm start # Development -npm run dev # Vite dev server +npm run dev # Docker Compose development stack +npm run dev:local # Local Vite dev server after building server-side TypeScript # Type check npm run check # svelte-check -# Tests -npx playwright test # E2E tests (desktop + mobile) +# Unit tests (Vitest, colocated as *.test.ts) +npm run test:unit +npm run test:unit:coverage + +# E2E tests (Playwright: Desktop Chrome, Pixel 7, iPhone 14) +npx playwright test ``` ## Important Notes @@ -131,5 +158,8 @@ npx playwright test # E2E tests (desktop + mobile) - WebSocket connections validate the GitHub token against GitHub's API on connect - Model defaults to `gpt-4.1` if not specified - Permission hooks: `approveAll` in autopilot mode, interactive prompts in other modes +- Settings sync through `/api/settings`: authenticated users get a server-side source of truth, while the client mirrors sanitized data in `localStorage` +- Skills are discovered from `skills/*/SKILL.md`, exposed via `/api/skills`, and passed into SDK sessions through `skillDirectories` - Custom tools use webhook HTTP calls with SSRF protection - The SDK's `@github/copilot` CLI needs `node:sqlite` which ships with Node 24 +- Unit tests live next to source as `*.test.ts`; Playwright E2E suites live in `tests/` diff --git a/.github/instructions/code-review-generic.instructions.md b/.github/instructions/code-review-generic.instructions.md new file mode 100644 index 0000000..bcd7365 --- /dev/null +++ b/.github/instructions/code-review-generic.instructions.md @@ -0,0 +1,418 @@ +--- +description: 'Generic code review instructions that can be customized for any project using GitHub Copilot' +applyTo: '**' +excludeAgent: ["coding-agent"] +--- + +# Generic Code Review Instructions + +Comprehensive code review guidelines for GitHub Copilot that can be adapted to any project. These instructions follow best practices from prompt engineering and provide a structured approach to code quality, security, testing, and architecture review. + +## Review Language + +When performing a code review, respond in **English** (or specify your preferred language). + +> **Customization Tip**: Change to your preferred language by replacing "English" with "Portuguese (Brazilian)", "Spanish", "French", etc. + +## Review Priorities + +When performing a code review, prioritize issues in the following order: + +### 🔴 CRITICAL (Block merge) +- **Security**: Vulnerabilities, exposed secrets, authentication/authorization issues +- **Correctness**: Logic errors, data corruption risks, race conditions +- **Breaking Changes**: API contract changes without versioning +- **Data Loss**: Risk of data loss or corruption + +### 🟡 IMPORTANT (Requires discussion) +- **Code Quality**: Severe violations of SOLID principles, excessive duplication +- **Test Coverage**: Missing tests for critical paths or new functionality +- **Performance**: Obvious performance bottlenecks (N+1 queries, memory leaks) +- **Architecture**: Significant deviations from established patterns + +### 🟢 SUGGESTION (Non-blocking improvements) +- **Readability**: Poor naming, complex logic that could be simplified +- **Optimization**: Performance improvements without functional impact +- **Best Practices**: Minor deviations from conventions +- **Documentation**: Missing or incomplete comments/documentation + +## General Review Principles + +When performing a code review, follow these principles: + +1. **Be specific**: Reference exact lines, files, and provide concrete examples +2. **Provide context**: Explain WHY something is an issue and the potential impact +3. **Suggest solutions**: Show corrected code when applicable, not just what's wrong +4. **Be constructive**: Focus on improving the code, not criticizing the author +5. **Recognize good practices**: Acknowledge well-written code and smart solutions +6. **Be pragmatic**: Not every suggestion needs immediate implementation +7. **Group related comments**: Avoid multiple comments about the same topic + +## Code Quality Standards + +When performing a code review, check for: + +### Clean Code +- Descriptive and meaningful names for variables, functions, and classes +- Single Responsibility Principle: each function/class does one thing well +- DRY (Don't Repeat Yourself): no code duplication +- Functions should be small and focused (ideally < 20-30 lines) +- Avoid deeply nested code (max 3-4 levels) +- Avoid magic numbers and strings (use constants) +- Code should be self-documenting; comments only when necessary + +### Examples +```javascript +// ❌ BAD: Poor naming and magic numbers +function calc(x, y) { + if (x > 100) return y * 0.15; + return y * 0.10; +} + +// ✅ GOOD: Clear naming and constants +const PREMIUM_THRESHOLD = 100; +const PREMIUM_DISCOUNT_RATE = 0.15; +const STANDARD_DISCOUNT_RATE = 0.10; + +function calculateDiscount(orderTotal, itemPrice) { + const isPremiumOrder = orderTotal > PREMIUM_THRESHOLD; + const discountRate = isPremiumOrder ? PREMIUM_DISCOUNT_RATE : STANDARD_DISCOUNT_RATE; + return itemPrice * discountRate; +} +``` + +### Error Handling +- Proper error handling at appropriate levels +- Meaningful error messages +- No silent failures or ignored exceptions +- Fail fast: validate inputs early +- Use appropriate error types/exceptions + +### Examples +```python +# ❌ BAD: Silent failure and generic error +def process_user(user_id): + try: + user = db.get(user_id) + user.process() + except: + pass + +# ✅ GOOD: Explicit error handling +def process_user(user_id): + if not user_id or user_id <= 0: + raise ValueError(f"Invalid user_id: {user_id}") + + try: + user = db.get(user_id) + except UserNotFoundError: + raise UserNotFoundError(f"User {user_id} not found in database") + except DatabaseError as e: + raise ProcessingError(f"Failed to retrieve user {user_id}: {e}") + + return user.process() +``` + +## Security Review + +When performing a code review, check for security issues: + +- **Sensitive Data**: No passwords, API keys, tokens, or PII in code or logs +- **Input Validation**: All user inputs are validated and sanitized +- **SQL Injection**: Use parameterized queries, never string concatenation +- **Authentication**: Proper authentication checks before accessing resources +- **Authorization**: Verify user has permission to perform action +- **Cryptography**: Use established libraries, never roll your own crypto +- **Dependency Security**: Check for known vulnerabilities in dependencies + +### Examples +```java +// ❌ BAD: SQL injection vulnerability +String query = "SELECT * FROM users WHERE email = '" + email + "'"; + +// ✅ GOOD: Parameterized query +PreparedStatement stmt = conn.prepareStatement( + "SELECT * FROM users WHERE email = ?" +); +stmt.setString(1, email); +``` + +```javascript +// ❌ BAD: Exposed secret in code +const API_KEY = "sk_live_abc123xyz789"; + +// ✅ GOOD: Use environment variables +const API_KEY = process.env.API_KEY; +``` + +## Testing Standards + +When performing a code review, verify test quality: + +- **Coverage**: Critical paths and new functionality must have tests +- **Test Names**: Descriptive names that explain what is being tested +- **Test Structure**: Clear Arrange-Act-Assert or Given-When-Then pattern +- **Independence**: Tests should not depend on each other or external state +- **Assertions**: Use specific assertions, avoid generic assertTrue/assertFalse +- **Edge Cases**: Test boundary conditions, null values, empty collections +- **Mock Appropriately**: Mock external dependencies, not domain logic + +### Examples +```typescript +// ❌ BAD: Vague name and assertion +test('test1', () => { + const result = calc(5, 10); + expect(result).toBeTruthy(); +}); + +// ✅ GOOD: Descriptive name and specific assertion +test('should calculate 10% discount for orders under $100', () => { + const orderTotal = 50; + const itemPrice = 20; + + const discount = calculateDiscount(orderTotal, itemPrice); + + expect(discount).toBe(2.00); +}); +``` + +## Performance Considerations + +When performing a code review, check for performance issues: + +- **Database Queries**: Avoid N+1 queries, use proper indexing +- **Algorithms**: Appropriate time/space complexity for the use case +- **Caching**: Utilize caching for expensive or repeated operations +- **Resource Management**: Proper cleanup of connections, files, streams +- **Pagination**: Large result sets should be paginated +- **Lazy Loading**: Load data only when needed + +### Examples +```python +# ❌ BAD: N+1 query problem +users = User.query.all() +for user in users: + orders = Order.query.filter_by(user_id=user.id).all() # N+1! + +# ✅ GOOD: Use JOIN or eager loading +users = User.query.options(joinedload(User.orders)).all() +for user in users: + orders = user.orders +``` + +## Architecture and Design + +When performing a code review, verify architectural principles: + +- **Separation of Concerns**: Clear boundaries between layers/modules +- **Dependency Direction**: High-level modules don't depend on low-level details +- **Interface Segregation**: Prefer small, focused interfaces +- **Loose Coupling**: Components should be independently testable +- **High Cohesion**: Related functionality grouped together +- **Consistent Patterns**: Follow established patterns in the codebase + +## Documentation Standards + +When performing a code review, check documentation: + +- **API Documentation**: Public APIs must be documented (purpose, parameters, returns) +- **Complex Logic**: Non-obvious logic should have explanatory comments +- **README Updates**: Update README when adding features or changing setup +- **Breaking Changes**: Document any breaking changes clearly +- **Examples**: Provide usage examples for complex features + +## Comment Format Template + +When performing a code review, use this format for comments: + +```markdown +**[PRIORITY] Category: Brief title** + +Detailed description of the issue or suggestion. + +**Why this matters:** +Explanation of the impact or reason for the suggestion. + +**Suggested fix:** +[code example if applicable] + +**Reference:** [link to relevant documentation or standard] +``` + +### Example Comments + +#### Critical Issue +````markdown +**🔴 CRITICAL - Security: SQL Injection Vulnerability** + +The query on line 45 concatenates user input directly into the SQL string, +creating a SQL injection vulnerability. + +**Why this matters:** +An attacker could manipulate the email parameter to execute arbitrary SQL commands, +potentially exposing or deleting all database data. + +**Suggested fix:** +```sql +-- Instead of: +query = "SELECT * FROM users WHERE email = '" + email + "'" + +-- Use: +PreparedStatement stmt = conn.prepareStatement( + "SELECT * FROM users WHERE email = ?" +); +stmt.setString(1, email); +``` + +**Reference:** OWASP SQL Injection Prevention Cheat Sheet +```` + +#### Important Issue +````markdown +**🟡 IMPORTANT - Testing: Missing test coverage for critical path** + +The `processPayment()` function handles financial transactions but has no tests +for the refund scenario. + +**Why this matters:** +Refunds involve money movement and should be thoroughly tested to prevent +financial errors or data inconsistencies. + +**Suggested fix:** +Add test case: +```javascript +test('should process full refund when order is cancelled', () => { + const order = createOrder({ total: 100, status: 'cancelled' }); + + const result = processPayment(order, { type: 'refund' }); + + expect(result.refundAmount).toBe(100); + expect(result.status).toBe('refunded'); +}); +``` +```` + +#### Suggestion +````markdown +**🟢 SUGGESTION - Readability: Simplify nested conditionals** + +The nested if statements on lines 30-40 make the logic hard to follow. + +**Why this matters:** +Simpler code is easier to maintain, debug, and test. + +**Suggested fix:** +```javascript +// Instead of nested ifs: +if (user) { + if (user.isActive) { + if (user.hasPermission('write')) { + // do something + } + } +} + +// Consider guard clauses: +if (!user || !user.isActive || !user.hasPermission('write')) { + return; +} +// do something +``` +```` + +## Review Checklist + +When performing a code review, systematically verify: + +### Code Quality +- [ ] Code follows consistent style and conventions +- [ ] Names are descriptive and follow naming conventions +- [ ] Functions/methods are small and focused +- [ ] No code duplication +- [ ] Complex logic is broken into simpler parts +- [ ] Error handling is appropriate +- [ ] No commented-out code or TODO without tickets + +### Security +- [ ] No sensitive data in code or logs +- [ ] Input validation on all user inputs +- [ ] No SQL injection vulnerabilities +- [ ] Authentication and authorization properly implemented +- [ ] Dependencies are up-to-date and secure + +### Testing +- [ ] New code has appropriate test coverage +- [ ] Tests are well-named and focused +- [ ] Tests cover edge cases and error scenarios +- [ ] Tests are independent and deterministic +- [ ] No tests that always pass or are commented out + +### Performance +- [ ] No obvious performance issues (N+1, memory leaks) +- [ ] Appropriate use of caching +- [ ] Efficient algorithms and data structures +- [ ] Proper resource cleanup + +### Architecture +- [ ] Follows established patterns and conventions +- [ ] Proper separation of concerns +- [ ] No architectural violations +- [ ] Dependencies flow in correct direction + +### Documentation +- [ ] Public APIs are documented +- [ ] Complex logic has explanatory comments +- [ ] README is updated if needed +- [ ] Breaking changes are documented + +## Project-Specific Customizations + +To customize this template for your project, add sections for: + +1. **Language/Framework specific checks** + - Example: "When performing a code review, verify React hooks follow rules of hooks" + - Example: "When performing a code review, check Spring Boot controllers use proper annotations" + +2. **Build and deployment** + - Example: "When performing a code review, verify CI/CD pipeline configuration is correct" + - Example: "When performing a code review, check database migrations are reversible" + +3. **Business logic rules** + - Example: "When performing a code review, verify pricing calculations include all applicable taxes" + - Example: "When performing a code review, check user consent is obtained before data processing" + +4. **Team conventions** + - Example: "When performing a code review, verify commit messages follow conventional commits format" + - Example: "When performing a code review, check branch names follow pattern: type/ticket-description" + +## Additional Resources + +For more information on effective code reviews and GitHub Copilot customization: + +- [GitHub Copilot Prompt Engineering](https://docs.github.com/en/copilot/concepts/prompting/prompt-engineering) +- [GitHub Copilot Custom Instructions](https://code.visualstudio.com/docs/copilot/customization/custom-instructions) +- [Awesome GitHub Copilot Repository](https://github.com/github/awesome-copilot) +- [GitHub Code Review Guidelines](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/reviewing-changes-in-pull-requests) +- [Google Engineering Practices - Code Review](https://google.github.io/eng-practices/review/) +- [OWASP Security Guidelines](https://owasp.org/) + +## Prompt Engineering Tips + +When performing a code review, apply these prompt engineering principles from the [GitHub Copilot documentation](https://docs.github.com/en/copilot/concepts/prompting/prompt-engineering): + +1. **Start General, Then Get Specific**: Begin with high-level architecture review, then drill into implementation details +2. **Give Examples**: Reference similar patterns in the codebase when suggesting changes +3. **Break Complex Tasks**: Review large PRs in logical chunks (security → tests → logic → style) +4. **Avoid Ambiguity**: Be specific about which file, line, and issue you're addressing +5. **Indicate Relevant Code**: Reference related code that might be affected by changes +6. **Experiment and Iterate**: If initial review misses something, review again with focused questions + +## Project Context + +This is a generic template. Customize this section with your project-specific information: + +- **Tech Stack**: [e.g., Java 17, Spring Boot 3.x, PostgreSQL] +- **Architecture**: [e.g., Hexagonal/Clean Architecture, Microservices] +- **Build Tool**: [e.g., Gradle, Maven, npm, pip] +- **Testing**: [e.g., JUnit 5, Jest, pytest] +- **Code Style**: [e.g., follows Google Style Guide] diff --git a/.github/instructions/performance-optimization.instructions.md b/.github/instructions/performance-optimization.instructions.md new file mode 100644 index 0000000..46a4002 --- /dev/null +++ b/.github/instructions/performance-optimization.instructions.md @@ -0,0 +1,420 @@ +--- +applyTo: '*' +description: 'The most comprehensive, practical, and engineer-authored performance optimization instructions for all languages, frameworks, and stacks. Covers frontend, backend, and database best practices with actionable guidance, scenario-based checklists, troubleshooting, and pro tips.' +--- + +# Performance Optimization Best Practices + +## Introduction + +Performance isn't just a buzzword—it's the difference between a product people love and one they abandon. I've seen firsthand how a slow app can frustrate users, rack up cloud bills, and even lose customers. This guide is a living collection of the most effective, real-world performance practices I've used and reviewed, covering frontend, backend, and database layers, as well as advanced topics. Use it as a reference, a checklist, and a source of inspiration for building fast, efficient, and scalable software. + +--- + +## General Principles + +- **Measure First, Optimize Second:** Always profile and measure before optimizing. Use benchmarks, profilers, and monitoring tools to identify real bottlenecks. Guessing is the enemy of performance. + - *Pro Tip:* Use tools like Chrome DevTools, Lighthouse, New Relic, Datadog, Py-Spy, or your language's built-in profilers. +- **Optimize for the Common Case:** Focus on optimizing code paths that are most frequently executed. Don't waste time on rare edge cases unless they're critical. +- **Avoid Premature Optimization:** Write clear, maintainable code first; optimize only when necessary. Premature optimization can make code harder to read and maintain. +- **Minimize Resource Usage:** Use memory, CPU, network, and disk resources efficiently. Always ask: "Can this be done with less?" +- **Prefer Simplicity:** Simple algorithms and data structures are often faster and easier to optimize. Don't over-engineer. +- **Document Performance Assumptions:** Clearly comment on any code that is performance-critical or has non-obvious optimizations. Future maintainers (including you) will thank you. +- **Understand the Platform:** Know the performance characteristics of your language, framework, and runtime. What's fast in Python may be slow in JavaScript, and vice versa. +- **Automate Performance Testing:** Integrate performance tests and benchmarks into your CI/CD pipeline. Catch regressions early. +- **Set Performance Budgets:** Define acceptable limits for load time, memory usage, API latency, etc. Enforce them with automated checks. + +--- + +## Frontend Performance + +### Rendering and DOM +- **Minimize DOM Manipulations:** Batch updates where possible. Frequent DOM changes are expensive. + - *Anti-pattern:* Updating the DOM in a loop. Instead, build a document fragment and append it once. +- **Virtual DOM Frameworks:** Use React, Vue, or similar efficiently—avoid unnecessary re-renders. + - *React Example:* Use `React.memo`, `useMemo`, and `useCallback` to prevent unnecessary renders. +- **Keys in Lists:** Always use stable keys in lists to help virtual DOM diffing. Avoid using array indices as keys unless the list is static. +- **Avoid Inline Styles:** Inline styles can trigger layout thrashing. Prefer CSS classes. +- **CSS Animations:** Use CSS transitions/animations over JavaScript for smoother, GPU-accelerated effects. +- **Defer Non-Critical Rendering:** Use `requestIdleCallback` or similar to defer work until the browser is idle. + +### Asset Optimization +- **Image Compression:** Use tools like ImageOptim, Squoosh, or TinyPNG. Prefer modern formats (WebP, AVIF) for web delivery. +- **SVGs for Icons:** SVGs scale well and are often smaller than PNGs for simple graphics. +- **Minification and Bundling:** Use Webpack, Rollup, or esbuild to bundle and minify JS/CSS. Enable tree-shaking to remove dead code. +- **Cache Headers:** Set long-lived cache headers for static assets. Use cache busting for updates. +- **Lazy Loading:** Use `loading="lazy"` for images, and dynamic imports for JS modules/components. +- **Font Optimization:** Use only the character sets you need. Subset fonts and use `font-display: swap`. + +### Network Optimization +- **Reduce HTTP Requests:** Combine files, use image sprites, and inline critical CSS. +- **HTTP/2 and HTTP/3:** Enable these protocols for multiplexing and lower latency. +- **Client-Side Caching:** Use Service Workers, IndexedDB, and localStorage for offline and repeat visits. +- **CDNs:** Serve static assets from a CDN close to your users. Use multiple CDNs for redundancy. +- **Defer/Async Scripts:** Use `defer` or `async` for non-critical JS to avoid blocking rendering. +- **Preload and Prefetch:** Use `` and `` for critical resources. + +### JavaScript Performance +- **Avoid Blocking the Main Thread:** Offload heavy computation to Web Workers. +- **Debounce/Throttle Events:** For scroll, resize, and input events, use debounce/throttle to limit handler frequency. +- **Memory Leaks:** Clean up event listeners, intervals, and DOM references. Use browser dev tools to check for detached nodes. +- **Efficient Data Structures:** Use Maps/Sets for lookups, TypedArrays for numeric data. +- **Avoid Global Variables:** Globals can cause memory leaks and unpredictable performance. +- **Avoid Deep Object Cloning:** Use shallow copies or libraries like lodash's `cloneDeep` only when necessary. + +### Accessibility and Performance +- **Accessible Components:** Ensure ARIA updates are not excessive. Use semantic HTML for both accessibility and performance. +- **Screen Reader Performance:** Avoid rapid DOM updates that can overwhelm assistive tech. + +### Framework-Specific Tips +#### React +- Use `React.memo`, `useMemo`, and `useCallback` to avoid unnecessary renders. +- Split large components and use code-splitting (`React.lazy`, `Suspense`). +- Avoid anonymous functions in render; they create new references on every render. +- Use `ErrorBoundary` to catch and handle errors gracefully. +- Profile with React DevTools Profiler. + +#### Angular +- Use OnPush change detection for components that don't need frequent updates. +- Avoid complex expressions in templates; move logic to the component class. +- Use `trackBy` in `ngFor` for efficient list rendering. +- Lazy load modules and components with the Angular Router. +- Profile with Angular DevTools. + +#### Vue +- Use computed properties over methods in templates for caching. +- Use `v-show` vs `v-if` appropriately (`v-show` is better for toggling visibility frequently). +- Lazy load components and routes with Vue Router. +- Profile with Vue Devtools. + +### Common Frontend Pitfalls +- Loading large JS bundles on initial page load. +- Not compressing images or using outdated formats. +- Failing to clean up event listeners, causing memory leaks. +- Overusing third-party libraries for simple tasks. +- Ignoring mobile performance (test on real devices!). + +### Frontend Troubleshooting +- Use Chrome DevTools' Performance tab to record and analyze slow frames. +- Use Lighthouse to audit performance and get actionable suggestions. +- Use WebPageTest for real-world load testing. +- Monitor Core Web Vitals (LCP, FID, CLS) for user-centric metrics. + +--- + +## Backend Performance + +### Algorithm and Data Structure Optimization +- **Choose the Right Data Structure:** Arrays for sequential access, hash maps for fast lookups, trees for hierarchical data, etc. +- **Efficient Algorithms:** Use binary search, quicksort, or hash-based algorithms where appropriate. +- **Avoid O(n^2) or Worse:** Profile nested loops and recursive calls. Refactor to reduce complexity. +- **Batch Processing:** Process data in batches to reduce overhead (e.g., bulk database inserts). +- **Streaming:** Use streaming APIs for large data sets to avoid loading everything into memory. + +### Concurrency and Parallelism +- **Asynchronous I/O:** Use async/await, callbacks, or event loops to avoid blocking threads. +- **Thread/Worker Pools:** Use pools to manage concurrency and avoid resource exhaustion. +- **Avoid Race Conditions:** Use locks, semaphores, or atomic operations where needed. +- **Bulk Operations:** Batch network/database calls to reduce round trips. +- **Backpressure:** Implement backpressure in queues and pipelines to avoid overload. + +### Caching +- **Cache Expensive Computations:** Use in-memory caches (Redis, Memcached) for hot data. +- **Cache Invalidation:** Use time-based (TTL), event-based, or manual invalidation. Stale cache is worse than no cache. +- **Distributed Caching:** For multi-server setups, use distributed caches and be aware of consistency issues. +- **Cache Stampede Protection:** Use locks or request coalescing to prevent thundering herd problems. +- **Don't Cache Everything:** Some data is too volatile or sensitive to cache. + +### API and Network +- **Minimize Payloads:** Use JSON, compress responses (gzip, Brotli), and avoid sending unnecessary data. +- **Pagination:** Always paginate large result sets. Use cursors for real-time data. +- **Rate Limiting:** Protect APIs from abuse and overload. +- **Connection Pooling:** Reuse connections for databases and external services. +- **Protocol Choice:** Use HTTP/2, gRPC, or WebSockets for high-throughput, low-latency communication. + +### Logging and Monitoring +- **Minimize Logging in Hot Paths:** Excessive logging can slow down critical code. +- **Structured Logging:** Use JSON or key-value logs for easier parsing and analysis. +- **Monitor Everything:** Latency, throughput, error rates, resource usage. Use Prometheus, Grafana, Datadog, or similar. +- **Alerting:** Set up alerts for performance regressions and resource exhaustion. + +### Language/Framework-Specific Tips +#### Node.js +- Use asynchronous APIs; avoid blocking the event loop (e.g., never use `fs.readFileSync` in production). +- Use clustering or worker threads for CPU-bound tasks. +- Limit concurrent open connections to avoid resource exhaustion. +- Use streams for large file or network data processing. +- Profile with `clinic.js`, `node --inspect`, or Chrome DevTools. + +#### Python +- Use built-in data structures (`dict`, `set`, `deque`) for speed. +- Profile with `cProfile`, `line_profiler`, or `Py-Spy`. +- Use `multiprocessing` or `asyncio` for parallelism. +- Avoid GIL bottlenecks in CPU-bound code; use C extensions or subprocesses. +- Use `lru_cache` for memoization. + +#### Java +- Use efficient collections (`ArrayList`, `HashMap`, etc.). +- Profile with VisualVM, JProfiler, or YourKit. +- Use thread pools (`Executors`) for concurrency. +- Tune JVM options for heap and garbage collection (`-Xmx`, `-Xms`, `-XX:+UseG1GC`). +- Use `CompletableFuture` for async programming. + +#### .NET +- Use `async/await` for I/O-bound operations. +- Use `Span` and `Memory` for efficient memory access. +- Profile with dotTrace, Visual Studio Profiler, or PerfView. +- Pool objects and connections where appropriate. +- Use `IAsyncEnumerable` for streaming data. + +### Common Backend Pitfalls +- Synchronous/blocking I/O in web servers. +- Not using connection pooling for databases. +- Over-caching or caching sensitive/volatile data. +- Ignoring error handling in async code. +- Not monitoring or alerting on performance regressions. + +### Backend Troubleshooting +- Use flame graphs to visualize CPU usage. +- Use distributed tracing (OpenTelemetry, Jaeger, Zipkin) to track request latency across services. +- Use heap dumps and memory profilers to find leaks. +- Log slow queries and API calls for analysis. + +--- + +## Database Performance + +### Query Optimization +- **Indexes:** Use indexes on columns that are frequently queried, filtered, or joined. Monitor index usage and drop unused indexes. +- **Avoid SELECT *:** Select only the columns you need. Reduces I/O and memory usage. +- **Parameterized Queries:** Prevent SQL injection and improve plan caching. +- **Query Plans:** Analyze and optimize query execution plans. Use `EXPLAIN` in SQL databases. +- **Avoid N+1 Queries:** Use joins or batch queries to avoid repeated queries in loops. +- **Limit Result Sets:** Use `LIMIT`/`OFFSET` or cursors for large tables. + +### Schema Design +- **Normalization:** Normalize to reduce redundancy, but denormalize for read-heavy workloads if needed. +- **Data Types:** Use the most efficient data types and set appropriate constraints. +- **Partitioning:** Partition large tables for scalability and manageability. +- **Archiving:** Regularly archive or purge old data to keep tables small and fast. +- **Foreign Keys:** Use them for data integrity, but be aware of performance trade-offs in high-write scenarios. + +### Transactions +- **Short Transactions:** Keep transactions as short as possible to reduce lock contention. +- **Isolation Levels:** Use the lowest isolation level that meets your consistency needs. +- **Avoid Long-Running Transactions:** They can block other operations and increase deadlocks. + +### Caching and Replication +- **Read Replicas:** Use for scaling read-heavy workloads. Monitor replication lag. +- **Cache Query Results:** Use Redis or Memcached for frequently accessed queries. +- **Write-Through/Write-Behind:** Choose the right strategy for your consistency needs. +- **Sharding:** Distribute data across multiple servers for scalability. + +### NoSQL Databases +- **Design for Access Patterns:** Model your data for the queries you need. +- **Avoid Hot Partitions:** Distribute writes/reads evenly. +- **Unbounded Growth:** Watch for unbounded arrays or documents. +- **Sharding and Replication:** Use for scalability and availability. +- **Consistency Models:** Understand eventual vs strong consistency and choose appropriately. + +### Common Database Pitfalls +- Missing or unused indexes. +- SELECT * in production queries. +- Not monitoring slow queries. +- Ignoring replication lag. +- Not archiving old data. + +### Database Troubleshooting +- Use slow query logs to identify bottlenecks. +- Use `EXPLAIN` to analyze query plans. +- Monitor cache hit/miss ratios. +- Use database-specific monitoring tools (pg_stat_statements, MySQL Performance Schema). + +--- + +## Code Review Checklist for Performance + +- [ ] Are there any obvious algorithmic inefficiencies (O(n^2) or worse)? +- [ ] Are data structures appropriate for their use? +- [ ] Are there unnecessary computations or repeated work? +- [ ] Is caching used where appropriate, and is invalidation handled correctly? +- [ ] Are database queries optimized, indexed, and free of N+1 issues? +- [ ] Are large payloads paginated, streamed, or chunked? +- [ ] Are there any memory leaks or unbounded resource usage? +- [ ] Are network requests minimized, batched, and retried on failure? +- [ ] Are assets optimized, compressed, and served efficiently? +- [ ] Are there any blocking operations in hot paths? +- [ ] Is logging in hot paths minimized and structured? +- [ ] Are performance-critical code paths documented and tested? +- [ ] Are there automated tests or benchmarks for performance-sensitive code? +- [ ] Are there alerts for performance regressions? +- [ ] Are there any anti-patterns (e.g., SELECT *, blocking I/O, global variables)? + +--- + +## Advanced Topics + +### Profiling and Benchmarking +- **Profilers:** Use language-specific profilers (Chrome DevTools, Py-Spy, VisualVM, dotTrace, etc.) to identify bottlenecks. +- **Microbenchmarks:** Write microbenchmarks for critical code paths. Use `benchmark.js`, `pytest-benchmark`, or JMH for Java. +- **A/B Testing:** Measure real-world impact of optimizations with A/B or canary releases. +- **Continuous Performance Testing:** Integrate performance tests into CI/CD. Use tools like k6, Gatling, or Locust. + +### Memory Management +- **Resource Cleanup:** Always release resources (files, sockets, DB connections) promptly. +- **Object Pooling:** Use for frequently created/destroyed objects (e.g., DB connections, threads). +- **Heap Monitoring:** Monitor heap usage and garbage collection. Tune GC settings for your workload. +- **Memory Leaks:** Use leak detection tools (Valgrind, LeakCanary, Chrome DevTools). + +### Scalability +- **Horizontal Scaling:** Design stateless services, use sharding/partitioning, and load balancers. +- **Auto-Scaling:** Use cloud auto-scaling groups and set sensible thresholds. +- **Bottleneck Analysis:** Identify and address single points of failure. +- **Distributed Systems:** Use idempotent operations, retries, and circuit breakers. + +### Security and Performance +- **Efficient Crypto:** Use hardware-accelerated and well-maintained cryptographic libraries. +- **Validation:** Validate inputs efficiently; avoid regexes in hot paths. +- **Rate Limiting:** Protect against DoS without harming legitimate users. + +### Mobile Performance +- **Startup Time:** Lazy load features, defer heavy work, and minimize initial bundle size. +- **Image/Asset Optimization:** Use responsive images and compress assets for mobile bandwidth. +- **Efficient Storage:** Use SQLite, Realm, or platform-optimized storage. +- **Profiling:** Use Android Profiler, Instruments (iOS), or Firebase Performance Monitoring. + +### Cloud and Serverless +- **Cold Starts:** Minimize dependencies and keep functions warm. +- **Resource Allocation:** Tune memory/CPU for serverless functions. +- **Managed Services:** Use managed caching, queues, and DBs for scalability. +- **Cost Optimization:** Monitor and optimize for cloud cost as a performance metric. + +--- + +## Practical Examples + +### Example 1: Debouncing User Input in JavaScript +```javascript +// BAD: Triggers API call on every keystroke +input.addEventListener('input', (e) => { + fetch(`/search?q=${e.target.value}`); +}); + +// GOOD: Debounce API calls +let timeout; +input.addEventListener('input', (e) => { + clearTimeout(timeout); + timeout = setTimeout(() => { + fetch(`/search?q=${e.target.value}`); + }, 300); +}); +``` + +### Example 2: Efficient SQL Query +```sql +-- BAD: Selects all columns and does not use an index +SELECT * FROM users WHERE email = 'user@example.com'; + +-- GOOD: Selects only needed columns and uses an index +SELECT id, name FROM users WHERE email = 'user@example.com'; +``` + +### Example 3: Caching Expensive Computation in Python +```python +# BAD: Recomputes result every time +result = expensive_function(x) + +# GOOD: Cache result +from functools import lru_cache + +@lru_cache(maxsize=128) +def expensive_function(x): + ... +result = expensive_function(x) +``` + +### Example 4: Lazy Loading Images in HTML +```html + + + + + +``` + +### Example 5: Asynchronous I/O in Node.js +```javascript +// BAD: Blocking file read +const data = fs.readFileSync('file.txt'); + +// GOOD: Non-blocking file read +fs.readFile('file.txt', (err, data) => { + if (err) throw err; + // process data +}); +``` + +### Example 6: Profiling a Python Function +```python +import cProfile +import pstats + +def slow_function(): + ... + +cProfile.run('slow_function()', 'profile.stats') +p = pstats.Stats('profile.stats') +p.sort_stats('cumulative').print_stats(10) +``` + +### Example 7: Using Redis for Caching in Node.js +```javascript +const redis = require('redis'); +const client = redis.createClient(); + +function getCachedData(key, fetchFunction) { + return new Promise((resolve, reject) => { + client.get(key, (err, data) => { + if (data) return resolve(JSON.parse(data)); + fetchFunction().then(result => { + client.setex(key, 3600, JSON.stringify(result)); + resolve(result); + }); + }); + }); +} +``` + +--- + +## References and Further Reading +- [Google Web Fundamentals: Performance](https://web.dev/performance/) +- [MDN Web Docs: Performance](https://developer.mozilla.org/en-US/docs/Web/Performance) +- [OWASP: Performance Testing](https://owasp.org/www-project-performance-testing/) +- [Microsoft Performance Best Practices](https://learn.microsoft.com/en-us/azure/architecture/best-practices/performance) +- [PostgreSQL Performance Optimization](https://wiki.postgresql.org/wiki/Performance_Optimization) +- [MySQL Performance Tuning](https://dev.mysql.com/doc/refman/8.0/en/optimization.html) +- [Node.js Performance Best Practices](https://nodejs.org/en/docs/guides/simple-profiling/) +- [Python Performance Tips](https://docs.python.org/3/library/profile.html) +- [Java Performance Tuning](https://www.oracle.com/java/technologies/javase/performance.html) +- [.NET Performance Guide](https://learn.microsoft.com/en-us/dotnet/standard/performance/) +- [WebPageTest](https://www.webpagetest.org/) +- [Lighthouse](https://developers.google.com/web/tools/lighthouse) +- [Prometheus](https://prometheus.io/) +- [Grafana](https://grafana.com/) +- [k6 Load Testing](https://k6.io/) +- [Gatling](https://gatling.io/) +- [Locust](https://locust.io/) +- [OpenTelemetry](https://opentelemetry.io/) +- [Jaeger](https://www.jaegertracing.io/) +- [Zipkin](https://zipkin.io/) + +--- + +## Conclusion + +Performance optimization is an ongoing process. Always measure, profile, and iterate. Use these best practices, checklists, and troubleshooting tips to guide your development and code reviews for high-performance, scalable, and efficient software. If you have new tips or lessons learned, add them here—let's keep this guide growing! + +--- + + diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 0000000..dd13124 --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,67 @@ +backend: + - changed-files: + - any-glob-to-any-file: + - 'src/lib/server/**' + - 'server.js' + +frontend: + - changed-files: + - any-glob-to-any-file: + - 'src/lib/components/**' + - 'src/lib/stores/**' + - 'src/app.css' + - 'src/app.html' + +sdk: + - changed-files: + - any-glob-to-any-file: + - 'src/lib/server/copilot/**' + +types: + - changed-files: + - any-glob-to-any-file: + - 'src/lib/types/**' + +tests: + - changed-files: + - any-glob-to-any-file: + - 'tests/**' + - 'src/**/*.test.ts' + - 'playwright.config.ts' + - 'vitest.config.ts' + +ci: + - changed-files: + - any-glob-to-any-file: + - '.github/workflows/**' + - 'Dockerfile' + - 'docker-compose.yml' + +documentation: + - changed-files: + - any-glob-to-any-file: + - 'docs/**' + - '*.md' + - '.github/*.md' + +infrastructure: + - changed-files: + - any-glob-to-any-file: + - 'infra/**' + - 'azure.yaml' + +copilot-config: + - changed-files: + - any-glob-to-any-file: + - '.github/agents/**' + - '.github/instructions/**' + - '.github/prompts/**' + - '.github/copilot-instructions.md' + - 'skills/**' + +security: + - changed-files: + - any-glob-to-any-file: + - 'src/lib/server/auth/**' + - 'src/hooks.server.ts' + - 'SECURITY.md' diff --git a/.github/prompts/add-feature.prompt.md b/.github/prompts/add-feature.prompt.md new file mode 100644 index 0000000..7e97ec1 --- /dev/null +++ b/.github/prompts/add-feature.prompt.md @@ -0,0 +1,29 @@ +--- +description: 'Add a new feature following project conventions' +--- + +Implement the requested feature following these project conventions: + +## Architecture +- **Backend**: Factory functions in `src/lib/server/`, named exports only +- **Frontend**: Svelte 5 components with runes ($state, $derived, $effect, $props) +- **Real-time**: WebSocket messages defined in `src/lib/types/index.ts` +- **State**: Rune-based stores in `src/lib/stores/` + +## Implementation Checklist +1. Define types in `src/lib/types/index.ts` (discriminated unions for messages) +2. Implement server logic in `src/lib/server/` +3. Wire WebSocket messages in `src/lib/server/ws/handler.ts` +4. Update store handlers in `src/lib/stores/chat.svelte.ts` +5. Create/update Svelte components in `src/lib/components/` +6. Add unit tests (Vitest, sibling `*.test.ts`) +7. Add E2E tests (Playwright, in `tests/`) +8. Run `npm run check` and `npm run build` +9. Update `docs/TEST-MATRIX.md` + +## Code Standards +- TypeScript strict mode, no `any` +- kebab-case filenames, camelCase variables, PascalCase types +- Component-scoped CSS with CSS custom properties +- Fail-fast validation, try-catch in handlers +- DOMPurify for any rendered HTML diff --git a/.github/prompts/fix-bug.prompt.md b/.github/prompts/fix-bug.prompt.md new file mode 100644 index 0000000..1badadd --- /dev/null +++ b/.github/prompts/fix-bug.prompt.md @@ -0,0 +1,25 @@ +--- +description: 'Diagnose and fix a bug systematically' +--- + +Fix the reported bug using this systematic approach: + +## Investigation +1. Reproduce the bug (identify exact steps) +2. Check relevant test files for missing coverage +3. Trace the data flow: Component → Store → WebSocket → Handler → SDK +4. Identify root cause + +## Fix +1. Write a failing test first (unit or E2E depending on bug location) +2. Implement the minimal fix +3. Verify the test passes +4. Check for similar patterns elsewhere in the codebase +5. Run full test suite: `npm run test:unit && npm run check && npm run build` + +## Verification +- [ ] Bug is reproducible before fix +- [ ] Fix addresses root cause (not just symptoms) +- [ ] New test covers the bug scenario +- [ ] No regressions in existing tests +- [ ] Works on all 3 device profiles if UI-related diff --git a/.github/prompts/generate-test.prompt.md b/.github/prompts/generate-test.prompt.md new file mode 100644 index 0000000..8a8eac6 --- /dev/null +++ b/.github/prompts/generate-test.prompt.md @@ -0,0 +1,26 @@ +--- +description: 'Generate tests for a component or module' +--- + +Generate comprehensive tests for the specified target: + +## Detection Rules +- **Svelte component** (`src/lib/components/*.svelte`) → Generate Playwright E2E test in `tests/` +- **Server module** (`src/lib/server/**/*.ts`) → Generate Vitest unit test as sibling `*.test.ts` +- **Store** (`src/lib/stores/*.svelte.ts`) → Generate Vitest unit test as sibling `*.test.ts` +- **API route** (`src/routes/**/*.ts`) → Generate Vitest unit test as sibling `server.test.ts` + +## Test Standards +- Use role-based locators (`getByRole`, `getByLabel`, `getByText`) for Playwright +- Use `test.describe()` blocks with descriptive names +- Test happy path, error cases, and edge cases +- For Playwright: test across desktop and mobile viewports +- For Vitest: mock at system boundaries, use `vi.fn()` for dependencies +- Follow patterns in existing test files (check `tests/helpers.ts` for E2E helpers) + +## Project Context +- Framework: SvelteKit 5 with Svelte 5 runes +- Unit tests: Vitest with jsdom (stores/utils) or node (server) environment +- E2E tests: Playwright with 3 projects (desktop, mobile/Pixel 7, iphone/iPhone 14) +- Auth helper: `createAuthenticatedPage()` from `tests/helpers.ts` +- WS mock: `mockWebSocket()` from `tests/helpers.ts` diff --git a/.github/prompts/review-security.prompt.md b/.github/prompts/review-security.prompt.md new file mode 100644 index 0000000..7a5a128 --- /dev/null +++ b/.github/prompts/review-security.prompt.md @@ -0,0 +1,38 @@ +--- +description: 'Security-focused code review for public repo' +--- + +Review the code changes with a security focus. This is a PUBLIC repository. + +## Check for: + +### Secrets & Credentials +- No API keys, tokens, passwords, or secrets in code or comments +- No references to private repos, internal infrastructure, or deployment details +- No hardcoded URLs pointing to private/internal services + +### Input Validation +- All user input validated and sanitized +- DOMPurify used for any HTML rendering +- Message length limits enforced (10K chars max) +- File upload limits enforced (10MB, 5 files, extension allowlist) + +### SSRF Prevention +- All user-provided URLs validated against private IP blocklist +- HTTPS required for webhook/tool URLs +- No unvalidated redirects + +### XSS Prevention +- No `innerHTML` without DOMPurify +- CSP headers maintained in hooks.server.ts +- Markdown rendering uses sanitized pipeline + +### Session Security +- httpOnly, secure, sameSite cookies +- Token revalidation on WebSocket connect +- Session data not exposed to client + +### WebSocket Security +- Message type whitelist enforced +- Origin validation +- Rate limiting active diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index a1eba8b..e05e116 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,24 +1,44 @@ ## Description - + + +Closes # ## Type of Change -- [ ] Bug fix -- [ ] New feature -- [ ] Refactoring -- [ ] Documentation -- [ ] CI/Build +- [ ] 🐛 Bug fix (non-breaking change that fixes an issue) +- [ ] ✨ New feature (non-breaking change that adds functionality) +- [ ] 💥 Breaking change (fix or feature that would cause existing functionality to change) +- [ ] ♻️ Refactoring (no functional changes) +- [ ] 📝 Documentation +- [ ] 🔧 CI/Build/Infrastructure +- [ ] 🔒 Security fix ## Testing -- [ ] I have added/updated unit tests for my changes -- [ ] I have added/updated integration tests if applicable -- [ ] All existing tests pass (`npm run test:unit`) +- [ ] I have added/updated **unit tests** for my changes (`npm run test:unit`) +- [ ] I have added/updated **E2E tests** if this changes UI behavior (`npx playwright test`) +- [ ] All existing tests pass +- [ ] I have tested on all 3 device profiles (desktop, mobile, iPhone) if UI changed - [ ] I have updated `docs/TEST-MATRIX.md` if this PR adds or modifies a feature -## Checklist +## Quality Checklist -- [ ] My code follows the project conventions +- [ ] My code follows the project conventions (TypeScript strict, Svelte 5 runes, factory functions) - [ ] I have run `npm run check` with no new errors - [ ] I have run `npm run build` successfully +- [ ] Self-reviewed my own code for clarity and correctness +- [ ] Added comments only where the code needs clarification +- [ ] No `console.log` or debug code left in + +## Security (for public repo) + +- [ ] No secrets, API keys, or credentials in code or comments +- [ ] No references to private repos, internal infrastructure, or deployment details +- [ ] Input validation added for any new user-facing inputs +- [ ] XSS prevention maintained (DOMPurify for HTML rendering) +- [ ] SSRF protection maintained for any new URL inputs + +## Screenshots / Recordings + + diff --git a/.github/workflows/check-pr-target.yml b/.github/workflows/check-pr-target.yml new file mode 100644 index 0000000..6d83ffe --- /dev/null +++ b/.github/workflows/check-pr-target.yml @@ -0,0 +1,44 @@ +name: Check PR Target Branch + +on: + pull_request: + types: [opened, reopened, edited] + +permissions: + pull-requests: write + +jobs: + check-target: + runs-on: ubuntu-latest + steps: + - name: Reject PR targeting unsupported branch + uses: actions/github-script@v7 + with: + script: | + const allowedBases = new Set(['main', 'master']); + const baseRef = context.payload.pull_request?.base?.ref; + + if (allowedBases.has(baseRef)) { + core.info(`PR targets allowed branch: ${baseRef}`); + return; + } + + const body = [ + '⚠️ **This PR targets an unsupported base branch.**', + '', + `This repository only accepts PRs targeting \`main\` or \`master\`, but this PR targets \`${baseRef}\`.`, + 'Please update the base branch before requesting review.', + '', + 'You can change the base branch using the **Edit** button at the top of this PR,', + `or run: \`gh pr edit ${{ github.event.pull_request.number }} --base main\`` + ].join('\n'); + + await github.rest.pulls.createReview({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number, + event: 'REQUEST_CHANGES', + body + }); + + core.setFailed(`PRs must target main or master. Received: ${baseRef}`); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a305510..01d45bb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,10 @@ on: pull_request: branches: [main, master] +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + jobs: check: runs-on: ubuntu-latest @@ -15,7 +19,7 @@ jobs: - uses: actions/setup-node@v4 with: node-version: '24' - cache: npm + cache: 'npm' - run: npm ci @@ -38,5 +42,52 @@ jobs: - name: Run unit tests with coverage run: npm run test:unit:coverage - # TODO: Add Playwright E2E tests in a future PR once CI installs browser binaries - # and starts the SvelteKit server for the test run environment. + e2e: + needs: check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '24' + cache: 'npm' + + - run: npm ci + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + + - name: Run Playwright tests + run: npx playwright test --project=desktop + env: + PORT: '3001' + GITHUB_CLIENT_ID: test-client-id + SESSION_SECRET: test-secret-for-playwright + NODE_ENV: development + + - name: Upload Playwright report + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: playwright-report/ + retention-days: 7 + + commit-lint: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - name: Check PR title follows conventional commits + env: + PR_TITLE: ${{ github.event.pull_request.title }} + run: | + pattern='^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?!?: .+' + if [[ ! "$PR_TITLE" =~ $pattern ]]; then + echo "❌ PR title does not follow Conventional Commits format" + echo "Expected: type(scope): description" + echo "Examples: feat: add new feature, fix(auth): resolve login bug" + echo "Got: $PR_TITLE" + exit 1 + fi + echo "✅ PR title follows Conventional Commits format" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..53ff461 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,33 @@ +name: CodeQL + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + schedule: + - cron: '0 6 * * 1' + +concurrency: + group: codeql-${{ github.ref }} + cancel-in-progress: true + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + security-events: write + contents: read + strategy: + matrix: + language: [javascript-typescript] + steps: + - uses: actions/checkout@v4 + - uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + - uses: github/codeql-action/autobuild@v3 + - uses: github/codeql-action/analyze@v3 + with: + category: '/language:${{ matrix.language }}' diff --git a/.github/workflows/codespell.yml b/.github/workflows/codespell.yml new file mode 100644 index 0000000..a2918f7 --- /dev/null +++ b/.github/workflows/codespell.yml @@ -0,0 +1,22 @@ +name: Check Spelling + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +permissions: + contents: read + +jobs: + codespell: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Check spelling with codespell + uses: codespell-project/actions-codespell@v2 + with: + check_filenames: true + check_hidden: false diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml new file mode 100644 index 0000000..e9f189e --- /dev/null +++ b/.github/workflows/labeler.yml @@ -0,0 +1,19 @@ +name: PR Labeler + +on: + pull_request_target: + types: [opened, synchronize, reopened] + +permissions: + contents: read + pull-requests: write + +jobs: + label: + runs-on: ubuntu-latest + steps: + - uses: actions/labeler@v5 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + configuration-path: .github/labeler.yml + sync-labels: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..74ae3dc --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,18 @@ +name: Release + +on: + push: + branches: [master] + +permissions: + contents: write + pull-requests: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: googleapis/release-please-action@v4 + with: + release-type: node + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 0000000..e5137b0 --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,34 @@ +name: Stale Issues & PRs + +on: + schedule: + - cron: '0 0 * * *' # Daily at midnight UTC + workflow_dispatch: + +permissions: + issues: write + pull-requests: write + +jobs: + stale: + runs-on: ubuntu-latest + steps: + - uses: actions/stale@v9 + with: + stale-issue-message: > + This issue has been automatically marked as stale because it has not had + recent activity. It will be closed in 7 days if no further activity occurs. + If this issue is still relevant, please comment to keep it open. + stale-pr-message: > + This PR has been automatically marked as stale because it has not had + recent activity. It will be closed in 7 days if no further activity occurs. + close-issue-message: > + This issue was closed because it has been stale for 7 days with no activity. + Feel free to reopen if this is still relevant. + days-before-stale: 30 + days-before-close: 7 + exempt-issue-labels: 'killer-feature,security,stretch-goal' + exempt-pr-labels: 'security' + stale-issue-label: 'stale' + stale-pr-label: 'stale' + remove-stale-when-updated: true diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..d4f6f29 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "3.0.0" +} diff --git a/SECURITY.md b/SECURITY.md index 0198b90..e67a0b4 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -44,6 +44,14 @@ This application follows security best practices: - **WebSocket**: Origin validation, session-based auth, message type whitelist, 10K char limit - **Infrastructure**: Managed identity for registry pull, HTTPS-only ingress, secrets stored natively in Container Apps (encrypted at rest) +## GitHub Secret Scanning + +GitHub secret scanning and push protection are enabled for this repository. + +- **Secret scanning** continuously scans committed content for supported credentials and other known secret formats. +- **Push protection** blocks supported secrets before they are pushed, helping prevent accidental credential exposure. +- **Repo admins** can re-run `scripts/setup-security.sh` to verify these settings or re-apply them if needed. + ## Self-Hosting Security Checklist If you deploy your own instance: diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..1aea534 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "packages": { + ".": { + "release-type": "node", + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": true, + "changelog-sections": [ + { "type": "feat", "section": "Features" }, + { "type": "fix", "section": "Bug Fixes" }, + { "type": "perf", "section": "Performance" }, + { "type": "refactor", "section": "Code Refactoring" }, + { "type": "docs", "section": "Documentation" }, + { "type": "test", "section": "Tests" }, + { "type": "ci", "section": "CI/CD" }, + { "type": "chore", "section": "Miscellaneous" } + ] + } + } +} diff --git a/scripts/setup-branch-protection.sh b/scripts/setup-branch-protection.sh new file mode 100755 index 0000000..16314e4 --- /dev/null +++ b/scripts/setup-branch-protection.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO="devartifex/copilot-unleashed" +BRANCH="master" + +echo "🔒 Setting up branch protection for $BRANCH..." + +gh api -X PUT "/repos/$REPO/branches/$BRANCH/protection" \ + --input - <<'EOF' +{ + "required_status_checks": { + "strict": true, + "contexts": ["check", "e2e", "commit-lint"] + }, + "enforce_admins": false, + "required_pull_request_reviews": { + "required_approving_review_count": 1, + "dismiss_stale_reviews": true, + "require_code_owner_reviews": true + }, + "restrictions": null, + "required_linear_history": true, + "allow_force_pushes": false, + "allow_deletions": false, + "required_conversation_resolution": true +} +EOF + +echo "✅ Branch protection configured for $BRANCH" +echo "" +echo "📋 Current protection rules:" +gh api "/repos/$REPO/branches/$BRANCH/protection" --jq '{ + required_reviews: .required_pull_request_reviews.required_approving_review_count, + status_checks: .required_status_checks.contexts, + linear_history: .required_linear_history.enabled, + force_push: .allow_force_pushes.enabled, + conversation_resolution: .required_conversation_resolution.enabled +}' 2>/dev/null || echo "⚠️ Run with admin access to verify" diff --git a/scripts/setup-security.sh b/scripts/setup-security.sh new file mode 100755 index 0000000..7ced619 --- /dev/null +++ b/scripts/setup-security.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO="${REPO:-devartifex/copilot-unleashed}" + +get_security_status() { + local field="$1" + + gh api "/repos/$REPO" --jq ".security_and_analysis.${field}.status // \"disabled\"" 2>/dev/null +} + +print_current_status() { + echo "" + echo "📋 Current security settings:" + gh api "/repos/$REPO" --jq '{ + secret_scanning: .security_and_analysis.secret_scanning.status, + push_protection: .security_and_analysis.secret_scanning_push_protection.status + }' 2>/dev/null || echo "⚠️ Run with repo admin permissions to verify" +} + +if ! secret_scanning_status="$(get_security_status 'secret_scanning')"; then + echo "❌ Unable to read current secret scanning status for $REPO" + echo " Make sure gh is authenticated and you have repository admin access." + exit 1 +fi + +if ! push_protection_status="$(get_security_status 'secret_scanning_push_protection')"; then + echo "❌ Unable to read current push protection status for $REPO" + echo " Make sure gh is authenticated and you have repository admin access." + exit 1 +fi + +echo "🔒 Checking current secret scanning configuration for $REPO..." +echo " Secret scanning: ${secret_scanning_status}" +echo " Push protection: ${push_protection_status}" + +patch_args=() + +if [[ "$secret_scanning_status" != "enabled" ]]; then + echo "🔒 Enabling secret scanning..." + patch_args+=(-f "security_and_analysis.secret_scanning.status=enabled") +else + echo "✅ Secret scanning is already enabled" +fi + +if [[ "$push_protection_status" != "enabled" ]]; then + echo "🛡️ Enabling push protection..." + patch_args+=(-f "security_and_analysis.secret_scanning_push_protection.status=enabled") +else + echo "✅ Push protection is already enabled" +fi + +if (( ${#patch_args[@]} > 0 )); then + gh api -X PATCH "/repos/$REPO" "${patch_args[@]}" --silent + echo "✅ Repository security settings updated" +else + echo "✅ No changes needed" +fi + +print_current_status diff --git a/skills/automate-this/SKILL.md b/skills/automate-this/SKILL.md new file mode 100644 index 0000000..3d0cac5 --- /dev/null +++ b/skills/automate-this/SKILL.md @@ -0,0 +1,244 @@ +--- +name: automate-this +description: 'Analyze a screen recording of a manual process and produce targeted, working automation scripts. Extracts frames and audio narration from video files, reconstructs the step-by-step workflow, and proposes automation at multiple complexity levels using tools already installed on the user machine.' +--- + +# Automate This + +Analyze a screen recording of a manual process and build working automation for it. + +The user records themselves doing something repetitive or tedious, hands you the video file, and you figure out what they're doing, why, and how to script it away. + +## Prerequisites Check + +Before analyzing any recording, verify the required tools are available. Run these checks silently and only surface problems: + +```bash +command -v ffmpeg >/dev/null 2>&1 && ffmpeg -version 2>/dev/null | head -1 || echo "NO_FFMPEG" +command -v whisper >/dev/null 2>&1 || command -v whisper-cpp >/dev/null 2>&1 || echo "NO_WHISPER" +``` + +- **ffmpeg is required.** If missing, tell the user: `brew install ffmpeg` (macOS) or the equivalent for their OS. +- **Whisper is optional.** Only needed if the recording has narration. If missing AND the recording has an audio track, suggest: `pip install openai-whisper` or `brew install whisper-cpp`. If the user declines, proceed with visual analysis only. + +## Phase 1: Extract Content from the Recording + +Given a video file path (typically on `~/Desktop/`), extract both visual frames and audio: + +### Frame Extraction + +Extract frames at one frame every 2 seconds. This balances coverage with context window limits. + +```bash +WORK_DIR=$(mktemp -d "${TMPDIR:-/tmp}/automate-this-XXXXXX") +chmod 700 "$WORK_DIR" +mkdir -p "$WORK_DIR/frames" +ffmpeg -y -i "" -vf "fps=0.5" -q:v 2 -loglevel warning "$WORK_DIR/frames/frame_%04d.jpg" +ls "$WORK_DIR/frames/" | wc -l +``` + +Use `$WORK_DIR` for all subsequent temp file paths in the session. The per-run directory with mode 0700 ensures extracted frames are only readable by the current user. + +If the recording is longer than 5 minutes (more than 150 frames), increase the interval to one frame every 4 seconds to stay within context limits. Tell the user you're sampling less frequently for longer recordings. + +### Audio Extraction and Transcription + +Check if the video has an audio track: + +```bash +ffprobe -i "" -show_streams -select_streams a -loglevel error | head -5 +``` + +If audio exists: + +```bash +ffmpeg -y -i "" -ac 1 -ar 16000 -loglevel warning "$WORK_DIR/audio.wav" + +# Use whichever whisper binary is available +if command -v whisper >/dev/null 2>&1; then + whisper "$WORK_DIR/audio.wav" --model small --language en --output_format txt --output_dir "$WORK_DIR/" + cat "$WORK_DIR/audio.txt" +elif command -v whisper-cpp >/dev/null 2>&1; then + whisper-cpp -m "$(brew --prefix 2>/dev/null)/share/whisper-cpp/models/ggml-small.bin" -l en -f "$WORK_DIR/audio.wav" -otxt -of "$WORK_DIR/audio" + cat "$WORK_DIR/audio.txt" +else + echo "NO_WHISPER" +fi +``` + +If neither whisper binary is available and the recording has audio, inform the user they're missing narration context and ask if they want to install Whisper (`pip install openai-whisper` or `brew install whisper-cpp`) or proceed with visual-only analysis. + +## Phase 2: Reconstruct the Process + +Analyze the extracted frames (and transcript, if available) to build a structured understanding of what the user did. Work through the frames sequentially and identify: + +1. **Applications used** — Which apps appear in the recording? (browser, terminal, Finder, mail client, spreadsheet, IDE, etc.) +2. **Sequence of actions** — What did the user do, in order? Click-by-click, step-by-step. +3. **Data flow** — What information moved between steps? (copied text, downloaded files, form inputs, etc.) +4. **Decision points** — Were there moments where the user paused, checked something, or made a choice? +5. **Repetition patterns** — Did the user do the same thing multiple times with different inputs? +6. **Pain points** — Where did the process look slow, error-prone, or tedious? The narration often reveals this directly ("I hate this part," "this always takes forever," "I have to do this for every single one"). + +Present this reconstruction to the user as a numbered step list and ask them to confirm it's accurate before proposing automation. This is critical — a wrong understanding leads to useless automation. + +Format: + +``` +Here's what I see you doing in this recording: + +1. Open Chrome and navigate to [specific URL] +2. Log in with credentials +3. Click through to the reporting dashboard +4. Download a CSV export +5. Open the CSV in Excel +6. Filter rows where column B is "pending" +7. Copy those rows into a new spreadsheet +8. Email the new spreadsheet to [recipient] + +You repeated steps 3-8 three times for different report types. + +[If narration was present]: You mentioned that the export step is the slowest +part and that you do this every Monday morning. + +Does this match what you were doing? Anything I got wrong or missed? +``` + +Do NOT proceed to Phase 3 until the user confirms the reconstruction is accurate. + +## Phase 3: Environment Fingerprint + +Before proposing automation, understand what the user actually has to work with. Run these checks: + +```bash +echo "=== OS ===" && uname -a +echo "=== Shell ===" && echo $SHELL +echo "=== Python ===" && { command -v python3 && python3 --version 2>&1; } || echo "not installed" +echo "=== Node ===" && { command -v node && node --version 2>&1; } || echo "not installed" +echo "=== Homebrew ===" && { command -v brew && echo "installed"; } || echo "not installed" +echo "=== Common Tools ===" && for cmd in curl jq playwright selenium osascript automator crontab; do command -v $cmd >/dev/null 2>&1 && echo "$cmd: yes" || echo "$cmd: no"; done +``` + +Use this to constrain proposals to tools the user already has. Never propose automation that requires installing five new things unless the simpler path genuinely doesn't work. + +## Phase 4: Propose Automation + +Based on the reconstructed process and the user's environment, propose automation at up to three tiers. Not every process needs three tiers — use judgment. + +### Tier Structure + +**Tier 1 — Quick Win (under 5 minutes to set up)** +The smallest useful automation. A shell alias, a one-liner, a keyboard shortcut, an AppleScript snippet. Automates the single most painful step, not the whole process. + +**Tier 2 — Script (under 30 minutes to set up)** +A standalone script (bash, Python, or Node — whichever the user has) that automates the full process end-to-end. Handles common errors. Can be run manually when needed. + +**Tier 3 — Full Automation (under 2 hours to set up)** +The script from Tier 2, plus: scheduled execution (cron, launchd, or GitHub Actions), logging, error notifications, and any necessary integration scaffolding (API keys, auth tokens, etc.). + +### Proposal Format + +For each tier, provide: + +``` +## Tier [N]: [Name] + +**What it automates:** [Which steps from the reconstruction] +**What stays manual:** [Which steps still need a human] +**Time savings:** [Estimated time saved per run, based on the recording length and repetition count] +**Prerequisites:** [Anything needed that isn't already installed — ideally nothing] + +**How it works:** +[2-3 sentence plain-English explanation] + +**The code:** +[Complete, working, commented code — not pseudocode] + +**How to test it:** +[Exact steps to verify it works, starting with a dry run if possible] + +**How to undo:** +[How to reverse any changes if something goes wrong] +``` + +### Application-Specific Automation Strategies + +Use these strategies based on which applications appear in the recording: + +**Browser-based workflows:** +- First choice: Check if the website has a public API. API calls are 10x more reliable than browser automation. Search for API documentation. +- Second choice: `curl` or `wget` for simple HTTP requests with known endpoints. +- Third choice: Playwright or Selenium for workflows that require clicking through UI. Prefer Playwright — it's faster and less flaky. +- Look for patterns: if the user is downloading the same report from a dashboard repeatedly, it's almost certainly available via API or direct URL with query parameters. + +**Spreadsheet and data workflows:** +- Python with pandas for data filtering, transformation, and aggregation. +- If the user is doing simple column operations in Excel, a 5-line Python script replaces the entire manual process. +- `csvkit` for quick command-line CSV manipulation without writing code. +- If the output needs to stay in Excel format, use openpyxl. + +**Email workflows:** +- macOS: `osascript` can control Mail.app to send emails with attachments. +- Cross-platform: Python `smtplib` for sending, `imaplib` for reading. +- If the email follows a template, generate the body from a template file with variable substitution. + +**File management workflows:** +- Shell scripts for move/copy/rename patterns. +- `find` + `xargs` for batch operations. +- `fswatch` or `watchman` for triggered-on-change automation. +- If the user is organizing files into folders by date or type, that's a 3-line shell script. + +**Terminal/CLI workflows:** +- Shell aliases for frequently typed commands. +- Shell functions for multi-step sequences. +- Makefiles for project-specific task sets. +- If the user ran the same command with different arguments, that's a loop. + +**macOS-specific workflows:** +- AppleScript/JXA for controlling native apps (Mail, Calendar, Finder, Preview, etc.). +- Shortcuts.app for simple multi-app workflows that don't need code. +- `automator` for file-based workflows. +- `launchd` plist files for scheduled tasks (prefer over cron on macOS). + +**Cross-application workflows (data moves between apps):** +- Identify the data transfer points. Each transfer is an automation opportunity. +- Clipboard-based transfers in the recording suggest the apps don't talk to each other — look for APIs, file-based handoffs, or direct integrations instead. +- If the user copies from App A and pastes into App B, the automation should read from A's data source and write to B's input format directly. + +### Making Proposals Targeted + +Apply these principles to every proposal: + +1. **Automate the bottleneck first.** The narration and timing in the recording reveal which step is actually painful. A 30-second automation of the worst step beats a 2-hour automation of the whole process. + +2. **Match the user's skill level.** If the recording shows someone comfortable in a terminal, propose shell scripts. If it shows someone navigating GUIs, propose something with a simple trigger (double-click a script, run a Shortcut, or type one command). + +3. **Estimate real time savings.** Count the recording duration and multiply by how often they do it. "This recording is 4 minutes. You said you do this daily. That's 17 hours per year. Tier 1 cuts it to 30 seconds each time — you get 16 hours back." + +4. **Handle the 80% case.** The first version of the automation should cover the common path perfectly. Edge cases can be handled in Tier 3 or flagged for manual intervention. + +5. **Preserve human checkpoints.** If the recording shows the user reviewing or approving something mid-process, keep that as a manual step. Don't automate judgment calls. + +6. **Propose dry runs.** Every script should have a mode where it shows what it *would* do without doing it. `--dry-run` flags, preview output, or confirmation prompts before destructive actions. + +7. **Account for auth and secrets.** If the process involves logging in or using credentials, never hardcode them. Use environment variables, keychain access (macOS `security` command), or prompt for them at runtime. + +8. **Consider failure modes.** What happens if the website is down? If the file doesn't exist? If the format changes? Good proposals mention this and handle it. + +## Phase 5: Build and Test + +When the user picks a tier: + +1. Write the complete automation code to a file (suggest a sensible location — the user's project directory if one exists, or `~/Desktop/` otherwise). +2. Walk through a dry run or test with the user watching. +3. If the test works, show how to run it for real. +4. If it fails, diagnose and fix — don't give up after one attempt. + +## Cleanup + +After analysis is complete (regardless of outcome), clean up extracted frames and audio: + +```bash +rm -rf "$WORK_DIR" +``` + +Tell the user you're cleaning up temporary files so they know nothing is left behind. diff --git a/skills/copilot-spaces/SKILL.md b/skills/copilot-spaces/SKILL.md new file mode 100644 index 0000000..616952a --- /dev/null +++ b/skills/copilot-spaces/SKILL.md @@ -0,0 +1,205 @@ +--- +name: copilot-spaces +description: 'Use Copilot Spaces to provide project-specific context to conversations. Use this skill when users mention a "Copilot space", want to load context from a shared knowledge base, discover available spaces, or ask questions grounded in curated project documentation, code, and instructions.' +--- + +# Copilot Spaces + +Use Copilot Spaces to bring curated, project-specific context into conversations. A Space is a shared collection of repositories, files, documentation, and instructions that grounds Copilot responses in your team's actual code and knowledge. + +## Available Tools + +### MCP Tools (Read-only) + +| Tool | Purpose | +|------|---------| +| `mcp__github__list_copilot_spaces` | List all spaces accessible to the current user | +| `mcp__github__get_copilot_space` | Load a space's full context by owner and name | + +### REST API via `gh api` (Full CRUD) + +The Spaces REST API supports creating, updating, deleting spaces, and managing collaborators. The MCP server only exposes read operations, so use `gh api` for writes. + +**User Spaces:** + +| Method | Endpoint | Purpose | +|--------|----------|---------| +| `POST` | `/users/{username}/copilot-spaces` | Create a space | +| `GET` | `/users/{username}/copilot-spaces` | List spaces | +| `GET` | `/users/{username}/copilot-spaces/{number}` | Get a space | +| `PUT` | `/users/{username}/copilot-spaces/{number}` | Update a space | +| `DELETE` | `/users/{username}/copilot-spaces/{number}` | Delete a space | + +**Organization Spaces:** Same pattern under `/orgs/{org}/copilot-spaces/...` + +**Collaborators:** Add, list, update, and remove collaborators at `.../collaborators` + +**Scope requirements:** PAT needs `read:user` for reads, `user` for writes. Add with `gh auth refresh -h github.com -s user`. + +**Note:** This API is functional but not yet in the public REST API docs. It may require the `copilot_spaces_api` feature flag. + +## When to Use Spaces + +- User mentions "Copilot space" or asks to "load a space" +- User wants answers grounded in specific project docs, code, or standards +- User asks "what spaces are available?" or "find a space for X" +- User needs onboarding context, architecture docs, or team-specific guidance +- User wants to follow a structured workflow defined in a Space (templates, checklists, multi-step processes) + +## Workflow + +### 1. Discover Spaces + +When a user asks what spaces are available or you need to find the right space: + +``` +Call mcp__github__list_copilot_spaces +``` + +This returns all spaces the user can access, each with a `name` and `owner_login`. Present relevant matches to the user. + +To filter for a specific user's spaces, match `owner_login` against the username (e.g., "show me my spaces"). + +### 2. Load a Space + +When a user names a specific space or you've identified the right one: + +``` +Call mcp__github__get_copilot_space with: + owner: "org-or-user" (the owner_login from the list) + name: "Space Name" (exact space name, case-sensitive) +``` + +This returns the space's full content: attached documentation, code context, custom instructions, and any other curated materials. Use this context to inform your responses. + +### 3. Follow the Breadcrumbs + +Space content often references external resources: GitHub issues, dashboards, repos, discussions, or other tools. Proactively fetch these using other MCP tools to gather complete context. For example: +- A space references an initiative tracking issue. Use `issue_read` to get the latest comments. +- A space links to a project board. Use project tools to check current status. +- A space mentions a repo's masterplan. Use `get_file_contents` to read it. + +### 4. Answer or Execute + +Once loaded, use the space content based on what it contains: + +**If the space contains reference material** (docs, code, standards): +- Answer questions about the project's architecture, patterns, or standards +- Generate code that follows the team's conventions +- Debug issues using project-specific knowledge + +**If the space contains workflow instructions** (templates, step-by-step processes): +- Follow the workflow as defined, step by step +- Gather data from the sources the workflow specifies +- Produce output in the format the workflow defines +- Show progress after each step so the user can steer + +### 5. Manage Spaces (via `gh api`) + +When a user wants to create, update, or delete a space, use `gh api`. First, find the space number from the list endpoint. + +**Update a space's instructions:** +```bash +gh api users/{username}/copilot-spaces/{number} \ + -X PUT \ + -f general_instructions="New instructions here" +``` + +**Update name, description, or instructions together:** +```bash +gh api users/{username}/copilot-spaces/{number} \ + -X PUT \ + -f name="Updated Name" \ + -f description="Updated description" \ + -f general_instructions="Updated instructions" +``` + +**Create a new space:** +```bash +gh api users/{username}/copilot-spaces \ + -X POST \ + -f name="My New Space" \ + -f general_instructions="Help me with..." \ + -f visibility="private" +``` + +**Attach resources (replaces entire resource list):** +```json +{ + "resources_attributes": [ + { "resource_type": "free_text", "metadata": { "name": "Notes", "text": "Content here" } }, + { "resource_type": "github_issue", "metadata": { "repository_id": 12345, "number": 42 } }, + { "resource_type": "github_file", "metadata": { "repository_id": 12345, "file_path": "docs/guide.md" } } + ] +} +``` + +**Delete a space:** +```bash +gh api users/{username}/copilot-spaces/{number} -X DELETE +``` + +**Updatable fields:** `name`, `description`, `general_instructions`, `icon_type`, `icon_color`, `visibility` ("private"/"public"), `base_role` ("no_access"/"reader"), `resources_attributes` + +## Examples + +### Example 1: User Asks for a Space + +**User**: "Load the Accessibility copilot space" + +**Action**: +1. Call `mcp__github__get_copilot_space` with owner `"github"`, name `"Accessibility"` +2. Use the returned context to answer questions about accessibility standards, MAS grades, compliance processes, etc. + +### Example 2: User Wants to Find Spaces + +**User**: "What copilot spaces are available for our team?" + +**Action**: +1. Call `mcp__github__list_copilot_spaces` +2. Filter/present spaces relevant to the user's org or interests +3. Offer to load any space they're interested in + +### Example 3: Context-Grounded Question + +**User**: "Using the security space, what's our policy on secret scanning?" + +**Action**: +1. Call `mcp__github__get_copilot_space` with the appropriate owner and name +2. Find the relevant policy in the space content +3. Answer based on the actual internal documentation + +### Example 4: Space as a Workflow Engine + +**User**: "Write my weekly update using the PM Weekly Updates space" + +**Action**: +1. Call `mcp__github__get_copilot_space` to load the space. It contains a template format and step-by-step instructions. +2. Follow the space's workflow: pull data from attached initiative issues, gather metrics, draft each section. +3. Fetch external resources referenced by the space (tracking issues, dashboards) using other MCP tools. +4. Show the draft after each section so the user can review and fill in gaps. +5. Produce the final output in the format the space defines. + +### Example 5: Update Space Instructions Programmatically + +**User**: "Update my PM Weekly Updates space to include a new writing guideline" + +**Action**: +1. Call `mcp__github__list_copilot_spaces` and find the space number (e.g., 19). +2. Call `mcp__github__get_copilot_space` to read current instructions. +3. Modify the instructions text as requested. +4. Push the update: +```bash +gh api users/labudis/copilot-spaces/19 -X PUT -f general_instructions="updated instructions..." +``` + +## Tips + +- Space names are **case-sensitive**. Use the exact name from `list_copilot_spaces`. +- Spaces can be owned by users or organizations. Always provide both `owner` and `name`. +- Space content can be large (20KB+). If returned as a temp file, use grep or view_range to find relevant sections rather than reading everything at once. +- If a space isn't found, suggest listing available spaces to find the right name. +- Spaces auto-update as underlying repos change, so the context is always current. +- Some spaces contain custom instructions that should guide your behavior (coding standards, preferred patterns, workflows). Treat these as directives, not suggestions. +- **Write operations** (`gh api` for create/update/delete) require the `user` PAT scope. If you get a 404 on write operations, run `gh auth refresh -h github.com -s user`. +- Resource updates **replace the entire array**. To add a resource, include all existing resources plus the new one. To remove one, include `{ "id": 123, "_destroy": true }` in the array. diff --git a/skills/doublecheck/SKILL.md b/skills/doublecheck/SKILL.md new file mode 100644 index 0000000..1002a1c --- /dev/null +++ b/skills/doublecheck/SKILL.md @@ -0,0 +1,277 @@ +--- +name: doublecheck +description: 'Three-layer verification pipeline for AI output. Extracts verifiable claims, finds supporting or contradicting sources via web search, runs adversarial review for hallucination patterns, and produces a structured verification report with source links for human review.' +--- + +# Doublecheck + +Run a three-layer verification pipeline on AI-generated output. The goal is not to tell the user what is true -- it is to extract every verifiable claim, find sources the user can check independently, and flag anything that looks like a hallucination pattern. + +## Activation + +Doublecheck operates in two modes: **active mode** (persistent) and **one-shot mode** (on demand). + +### Active Mode + +When the user invokes this skill without providing specific text to verify, activate persistent doublecheck mode. Respond with: + +> **Doublecheck is now active.** I'll verify factual claims in my responses before presenting them. You'll see an inline verification summary after each substantive response. Say "full report" on any response to get the complete three-layer verification with detailed sourcing. Turn it off anytime by saying "turn off doublecheck." + +Then follow ALL of the rules below for the remainder of the conversation: + +**Rule: Classify every response before sending it.** + +Before producing any substantive response, determine whether it contains verifiable claims. Classify the response: + +| Response type | Contains verifiable claims? | Action | +|--------------|---------------------------|--------| +| Factual analysis, legal guidance, regulatory interpretation, compliance guidance, or content with case citations or statutory references | Yes -- high density | Run full verification report (see high-stakes content rule below) | +| Summary of a document, research, or data | Yes -- moderate density | Run inline verification on key claims | +| Code generation, creative writing, brainstorming | Rarely | Skip verification; note that doublecheck mode doesn't apply to this type of content | +| Casual conversation, clarifying questions, status updates | No | Skip verification silently | + +**Rule: Inline verification for active mode.** + +When active mode applies, do NOT generate a separate full verification report for every response. Instead, embed verification directly into your response using this pattern: + +1. Generate your response normally. +2. After the response, add a `Verification` section. +3. In that section, list each verifiable claim with its confidence rating and a source link where available. + +Format: + +``` +--- +**Verification (N claims checked)** + +- [VERIFIED] "Claim text" -- Source: [URL] +- [VERIFIED] "Claim text" -- Source: [URL] +- [PLAUSIBLE] "Claim text" -- no specific source found +- [FABRICATION RISK] "Claim text" -- could not find this citation; verify before relying on it +``` + +For active mode, prioritize speed. Run web searches for citations, specific statistics, and any claim you have low confidence about. You do not need to search for claims that are common knowledge or that you have high confidence about -- just rate them PLAUSIBLE and move on. + +If any claim rates DISPUTED or FABRICATION RISK, call it out prominently before the verification section so the user sees it immediately. When auto-escalation applies (see below), place this callout at the top of the full report, before the summary table: + +``` +**Heads up:** I'm not confident about [specific claim]. I couldn't find a supporting source. You should verify this independently before relying on it. +``` + +**Rule: Auto-escalate to full report for high-risk findings.** + +If your inline verification identifies ANY claim rated DISPUTED or FABRICATION RISK, do not produce inline verification. Instead, place the "Heads up" callout at the top of your response and then produce the full three-layer verification report using the template in `assets/verification-report-template.md`. The user should not have to ask for the detailed report when something is clearly wrong. + +**Rule: Full report for high-stakes content.** + +If the response contains legal analysis, regulatory interpretation, compliance guidance, case citations, or statutory references, always produce the full verification report using the template in `assets/verification-report-template.md`. Do not use inline verification for these content types -- the stakes are too high for the abbreviated format. + +**Rule: Discoverability footer for inline verification.** + +When producing inline verification (not a full report), always append this line at the end of the verification section: + +``` +_Say "full report" for detailed three-layer verification with sources._ +``` + +**Rule: Offer full verification on request.** + +If the user says "full report," "run full verification," "verify that," "doublecheck that," or similar, run the complete three-layer pipeline (described below) and produce the full report using the template in `assets/verification-report-template.md`. + +### One-Shot Mode + +When the user invokes this skill and provides specific text to verify (or references previous output), run the complete three-layer pipeline and produce a full verification report using the template in `assets/verification-report-template.md`. + +### Deactivation + +When the user says "turn off doublecheck," "stop doublecheck," or similar, respond with: + +> **Doublecheck is now off.** I'll respond normally without inline verification. You can reactivate it anytime. + +--- + +## Layer 1: Self-Audit + +Re-read the target text with a critical lens. Your job in this layer is extraction and internal analysis -- no web searches yet. + +### Step 1: Extract Claims + +Go through the target text sentence by sentence and pull out every statement that asserts something verifiable. Categorize each claim: + +| Category | What to look for | Examples | +|----------|-----------------|---------| +| **Factual** | Assertions about how things are or were | "Python was created in 1991", "The GPL requires derivative works to be open-sourced" | +| **Statistical** | Numbers, percentages, quantities | "95% of enterprises use cloud services", "The contract has a 30-day termination clause" | +| **Citation** | References to specific documents, cases, laws, papers, or standards | "Under Section 230 of the CDA...", "In *Mayo v. Prometheus* (2012)..." | +| **Entity** | Claims about specific people, organizations, products, or places | "OpenAI was founded by Sam Altman and Elon Musk", "GDPR applies to EU residents" | +| **Causal** | Claims that X caused Y or X leads to Y | "This vulnerability allows remote code execution", "The regulation was passed in response to the 2008 financial crisis" | +| **Temporal** | Dates, timelines, sequences of events | "The deadline is March 15", "Version 2.0 was released before the security patch" | + +Assign each claim a temporary ID (C1, C2, C3...) for tracking through subsequent layers. + +### Step 2: Check Internal Consistency + +Review the extracted claims against each other: +- Does the text contradict itself anywhere? (e.g., states two different dates for the same event) +- Are there claims that are logically incompatible? +- Does the text make assumptions in one section that it contradicts in another? + +Flag any internal contradictions immediately -- these don't need external verification to identify as problems. + +### Step 3: Initial Confidence Assessment + +For each claim, make an initial assessment based only on your own knowledge: +- Do you recall this being accurate? +- Is this the kind of claim where models frequently hallucinate? (Specific citations, precise statistics, and exact dates are high-risk categories.) +- Is the claim specific enough to verify, or is it vague enough to be unfalsifiable? + +Record your initial confidence but do NOT report it as a finding yet. This is input for Layer 2, not output. + +--- + +## Layer 2: Source Verification + +For each extracted claim, search for external evidence. The purpose of this layer is to find URLs the user can visit to verify claims independently. + +### Search Strategy + +For each claim: + +1. **Formulate a search query** that would surface the primary source. For citations, search for the exact title or case name. For statistics, search for the specific number and topic. For factual claims, search for the key entities and relationships. + +2. **Run the search** using `web_search`. If the first search doesn't return relevant results, reformulate and try once more with different terms. + +3. **Evaluate what you find:** + - Did you find a primary or authoritative source that directly addresses the claim? + - Did you find contradicting information from a credible source? + - Did you find nothing relevant? (This is itself a signal -- real things usually have a web footprint.) + +4. **Record the result** with the source URL. Always provide the URL even if you also summarize what the source says. + +### What Counts as a Source + +Prefer primary and authoritative sources: +- Official documentation, specifications, and standards +- Court records, legislative texts, regulatory filings +- Peer-reviewed publications +- Official organizational websites and press releases +- Established reference works (encyclopedias, legal databases) + +Note when a source is secondary (news article, blog post, wiki page) vs. primary. The user can weigh accordingly. + +### Handling Citations Specifically + +Citations are the highest-risk category for hallucinations. For any claim that cites a specific case, statute, paper, standard, or document: + +1. Search for the exact citation (case name, title, section number). +2. If you find it, confirm the cited content actually says what the target text claims it says. +3. If you cannot find it at all, flag it as FABRICATION RISK. Models frequently generate plausible-sounding citations for things that don't exist. + +--- + +## Layer 3: Adversarial Review + +Switch your posture entirely. In Layers 1 and 2, you were trying to understand and verify the output. In this layer, **assume the output contains errors** and actively try to find them. + +### Hallucination Pattern Checklist + +Check for these common patterns: + +1. **Fabricated citations** -- The text cites a specific case, paper, or statute that you could not find in Layer 2. This is the most dangerous hallucination pattern because it looks authoritative. + +2. **Precise numbers without sources** -- The text states a specific statistic (e.g., "78% of companies...") without indicating where the number comes from. Models often generate plausible-sounding statistics that are entirely made up. + +3. **Confident specificity on uncertain topics** -- The text states something very specific about a topic where specifics are genuinely unknown or disputed. Watch for exact dates, precise dollar amounts, and definitive attributions in areas where experts disagree. + +4. **Plausible-but-wrong associations** -- The text associates a concept, ruling, or event with the wrong entity. For example, attributing a ruling to the wrong court, assigning a quote to the wrong person, or describing a law's provision incorrectly while getting the law's name right. + +5. **Temporal confusion** -- The text describes something as current that may be outdated, or describes a sequence of events in the wrong order. + +6. **Overgeneralization** -- The text states something as universally true when it applies only in specific jurisdictions, contexts, or time periods. Common in legal and regulatory content. + +7. **Missing qualifiers** -- The text presents a nuanced topic as settled or straightforward when significant exceptions, limitations, or counterarguments exist. + +### Adversarial Questions + +For each major claim that passed Layers 1 and 2, ask: +- What would make this claim wrong? +- Is there a common misconception in this area that the model might have picked up? +- If I were a subject matter expert, would I object to how this is stated? +- Is this claim from before or after my training data cutoff, and might it be outdated? + +### Red Flags to Escalate + +If you find any of these, flag them prominently in the report: +- A specific citation that cannot be found anywhere +- A statistic with no identifiable source +- A legal or regulatory claim that contradicts what authoritative sources say +- A claim that has been stated with high confidence but is actually disputed or uncertain + +--- + +## Producing the Verification Report + +After completing all three layers, produce the report using the template in `assets/verification-report-template.md`. + +### Confidence Ratings + +Assign each claim a final rating: + +| Rating | Meaning | What the user should do | +|--------|---------|------------------------| +| **VERIFIED** | Supporting source found and linked | Spot-check the source link if the claim is critical to your work | +| **PLAUSIBLE** | Consistent with general knowledge, no specific source found | Treat as reasonable but unconfirmed; verify independently if relying on it for decisions | +| **UNVERIFIED** | Could not find supporting or contradicting evidence | Do not rely on this claim without independent verification | +| **DISPUTED** | Found contradicting evidence from a credible source | Review the contradicting source; this claim may be wrong | +| **FABRICATION RISK** | Matches hallucination patterns (e.g., unfindable citation, unsourced precise statistic) | Assume this is wrong until you can confirm it from a primary source | + +### Report Principles + +- Provide links, not verdicts. The user decides what's true, not you. +- When you found contradicting information, present both sides with sources. Don't pick a winner. +- If a claim is unfalsifiable (too vague or subjective to verify), say so. "Unfalsifiable" is useful information. +- Be explicit about what you could not check. "I could not verify this" is different from "this is wrong." +- Group findings by severity. Lead with the items that need the most attention. + +### Limitations Disclosure + +Always include this at the end of the report: + +> **Limitations of this verification:** +> - This tool accelerates human verification; it does not replace it. +> - Web search results may not include the most recent information or paywalled sources. +> - The adversarial review uses the same underlying model that may have produced the original output. It catches many issues but cannot catch all of them. +> - A claim rated VERIFIED means a supporting source was found, not that the claim is definitely correct. Sources can be wrong too. +> - Claims rated PLAUSIBLE may still be wrong. The absence of contradicting evidence is not proof of accuracy. + +--- + +## Domain-Specific Guidance + +### Legal Content + +Legal content carries elevated hallucination risk because: +- Case names, citations, and holdings are frequently fabricated by models +- Jurisdictional nuances are often flattened or omitted +- Statutory language may be paraphrased in ways that change the legal meaning +- "Majority rule" and "minority rule" distinctions are often lost + +For legal content, give extra scrutiny to: case citations, statutory references, regulatory interpretations, and jurisdictional claims. Search legal databases when possible. + +### Medical and Scientific Content + +- Check that cited studies actually exist and that the results are accurately described +- Watch for outdated guidelines being presented as current +- Flag dosages, treatment protocols, or diagnostic criteria -- these change and errors can be dangerous + +### Financial and Regulatory Content + +- Verify specific dollar amounts, dates, and thresholds +- Check that regulatory requirements are attributed to the correct jurisdiction and are current +- Watch for tax law claims that may be outdated after recent legislative changes + +### Technical and Security Content + +- Verify CVE numbers, vulnerability descriptions, and affected versions +- Check that API specifications and configuration instructions match current documentation +- Watch for version-specific information that may be outdated diff --git a/skills/github-issues/SKILL.md b/skills/github-issues/SKILL.md new file mode 100644 index 0000000..4619bac --- /dev/null +++ b/skills/github-issues/SKILL.md @@ -0,0 +1,201 @@ +--- +name: github-issues +description: 'Create, update, and manage GitHub issues using MCP tools. Use this skill when users want to create bug reports, feature requests, or task issues, update existing issues, add labels/assignees/milestones, set issue fields (dates, priority, custom fields), set issue types, manage issue workflows, link issues, add dependencies, or track blocked-by/blocking relationships. Triggers on requests like "create an issue", "file a bug", "request a feature", "update issue X", "set the priority", "set the start date", "link issues", "add dependency", "blocked by", "blocking", or any GitHub issue management task.' +--- + +# GitHub Issues + +Manage GitHub issues using the `@modelcontextprotocol/server-github` MCP server. + +## Available Tools + +### MCP Tools (read operations) + +| Tool | Purpose | +|------|---------| +| `mcp__github__issue_read` | Read issue details, sub-issues, comments, labels (methods: get, get_comments, get_sub_issues, get_labels) | +| `mcp__github__list_issues` | List and filter repository issues by state, labels, date | +| `mcp__github__search_issues` | Search issues across repos using GitHub search syntax | +| `mcp__github__projects_list` | List projects, project fields, project items, status updates | +| `mcp__github__projects_get` | Get details of a project, field, item, or status update | +| `mcp__github__projects_write` | Add/update/delete project items, create status updates | + +### CLI / REST API (write operations) + +The MCP server does not currently support creating, updating, or commenting on issues. Use `gh api` for these operations. + +| Operation | Command | +|-----------|---------| +| Create issue | `gh api repos/{owner}/{repo}/issues -X POST -f title=... -f body=...` | +| Update issue | `gh api repos/{owner}/{repo}/issues/{number} -X PATCH -f title=... -f state=...` | +| Add comment | `gh api repos/{owner}/{repo}/issues/{number}/comments -X POST -f body=...` | +| Close issue | `gh api repos/{owner}/{repo}/issues/{number} -X PATCH -f state=closed` | +| Set issue type | Include `-f type=Bug` in the create call (REST API only, not supported by `gh issue create` CLI) | + +**Note:** `gh issue create` works for basic issue creation but does **not** support the `--type` flag. Use `gh api` when you need to set issue types. + +## Workflow + +1. **Determine action**: Create, update, or query? +2. **Gather context**: Get repo info, existing labels, milestones if needed +3. **Structure content**: Use appropriate template from [references/templates.md](references/templates.md) +4. **Execute**: Use MCP tools for reads, `gh api` for writes +5. **Confirm**: Report the issue URL to user + +## Creating Issues + +Use `gh api` to create issues. This supports all parameters including issue types. + +```bash +gh api repos/{owner}/{repo}/issues \ + -X POST \ + -f title="Issue title" \ + -f body="Issue body in markdown" \ + -f type="Bug" \ + --jq '{number, html_url}' +``` + +### Optional Parameters + +Add any of these flags to the `gh api` call: + +``` +-f type="Bug" # Issue type (Bug, Feature, Task, Epic, etc.) +-f labels[]="bug" # Labels (repeat for multiple) +-f assignees[]="username" # Assignees (repeat for multiple) +-f milestone=1 # Milestone number +``` + +**Issue types** are organization-level metadata. To discover available types, use: +```bash +gh api graphql -f query='{ organization(login: "ORG") { issueTypes(first: 10) { nodes { name } } } }' --jq '.data.organization.issueTypes.nodes[].name' +``` + +**Prefer issue types over labels for categorization.** When issue types are available (e.g., Bug, Feature, Task), use the `type` parameter instead of applying equivalent labels like `bug` or `enhancement`. Issue types are the canonical way to categorize issues on GitHub. Only fall back to labels when the org has no issue types configured. + +### Title Guidelines + +- Be specific and actionable +- Keep under 72 characters +- When issue types are set, don't add redundant prefixes like `[Bug]` +- Examples: + - `Login fails with SSO enabled` (with type=Bug) + - `Add dark mode support` (with type=Feature) + - `Add unit tests for auth module` (with type=Task) + +### Body Structure + +Always use the templates in [references/templates.md](references/templates.md). Choose based on issue type: + +| User Request | Template | +|--------------|----------| +| Bug, error, broken, not working | Bug Report | +| Feature, enhancement, add, new | Feature Request | +| Task, chore, refactor, update | Task | + +## Updating Issues + +Use `gh api` with PATCH: + +```bash +gh api repos/{owner}/{repo}/issues/{number} \ + -X PATCH \ + -f state=closed \ + -f title="Updated title" \ + --jq '{number, html_url}' +``` + +Only include fields you want to change. Available fields: `title`, `body`, `state` (open/closed), `labels`, `assignees`, `milestone`. + +## Examples + +### Example 1: Bug Report + +**User**: "Create a bug issue - the login page crashes when using SSO" + +**Action**: +```bash +gh api repos/github/awesome-copilot/issues \ + -X POST \ + -f title="Login page crashes when using SSO" \ + -f type="Bug" \ + -f body="## Description +The login page crashes when users attempt to authenticate using SSO. + +## Steps to Reproduce +1. Navigate to login page +2. Click 'Sign in with SSO' +3. Page crashes + +## Expected Behavior +SSO authentication should complete and redirect to dashboard. + +## Actual Behavior +Page becomes unresponsive and displays error." \ + --jq '{number, html_url}' +``` + +### Example 2: Feature Request + +**User**: "Create a feature request for dark mode with high priority" + +**Action**: +```bash +gh api repos/github/awesome-copilot/issues \ + -X POST \ + -f title="Add dark mode support" \ + -f type="Feature" \ + -f labels[]="high-priority" \ + -f body="## Summary +Add dark mode theme option for improved user experience and accessibility. + +## Motivation +- Reduces eye strain in low-light environments +- Increasingly expected by users + +## Proposed Solution +Implement theme toggle with system preference detection. + +## Acceptance Criteria +- [ ] Toggle switch in settings +- [ ] Persists user preference +- [ ] Respects system preference by default" \ + --jq '{number, html_url}' +``` + +## Common Labels + +Use these standard labels when applicable: + +| Label | Use For | +|-------|---------| +| `bug` | Something isn't working | +| `enhancement` | New feature or improvement | +| `documentation` | Documentation updates | +| `good first issue` | Good for newcomers | +| `help wanted` | Extra attention needed | +| `question` | Further information requested | +| `wontfix` | Will not be addressed | +| `duplicate` | Already exists | +| `high-priority` | Urgent issues | + +## Tips + +- Always confirm the repository context before creating issues +- Ask for missing critical information rather than guessing +- Link related issues when known: `Related to #123` +- For updates, fetch current issue first to preserve unchanged fields + +## Extended Capabilities + +The following features require REST or GraphQL APIs beyond the basic MCP tools. Each is documented in its own reference file so the agent only loads the knowledge it needs. + +| Capability | When to use | Reference | +|------------|-------------|-----------| +| Advanced search | Complex queries with boolean logic, date ranges, cross-repo search, issue field filters (`field.name:value`) | [references/search.md](references/search.md) | +| Sub-issues & parent issues | Breaking work into hierarchical tasks | [references/sub-issues.md](references/sub-issues.md) | +| Issue dependencies | Tracking blocked-by / blocking relationships | [references/dependencies.md](references/dependencies.md) | +| Issue types (advanced) | GraphQL operations beyond MCP `list_issue_types` / `type` param | [references/issue-types.md](references/issue-types.md) | +| Projects V2 | Project boards, progress reports, field management | [references/projects.md](references/projects.md) | +| Issue fields | Custom metadata: dates, priority, text, numbers (private preview) | [references/issue-fields.md](references/issue-fields.md) | +| Images in issues | Embedding images in issue bodies and comments via CLI | [references/images.md](references/images.md) | diff --git a/src/lib/server/copilot/session-metadata.test.ts b/src/lib/server/copilot/session-metadata.test.ts index 5f3273a..4f3e1d3 100644 --- a/src/lib/server/copilot/session-metadata.test.ts +++ b/src/lib/server/copilot/session-metadata.test.ts @@ -42,6 +42,7 @@ import { deleteSessionFromFilesystem, getSessionDetail, getSessionStateDir, + isValidSessionId, listSessionsFromFilesystem, } from './session-metadata.js'; @@ -314,3 +315,29 @@ describe('deleteSessionFromFilesystem', () => { expect(rmMock).toHaveBeenCalledWith(sessionDir, { recursive: true, force: true }); }); }); + +describe('isValidSessionId', () => { + it('accepts well-formed UUIDs', () => { + expect(isValidSessionId(sessionId)).toBe(true); + expect(isValidSessionId('AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE')).toBe(true); + }); + + it('rejects non-UUID strings', () => { + expect(isValidSessionId('')).toBe(false); + expect(isValidSessionId('not-a-uuid')).toBe(false); + expect(isValidSessionId('../../etc/passwd')).toBe(false); + expect(isValidSessionId('../escape')).toBe(false); + }); +}); + +describe('path traversal protection', () => { + it('getSessionDetail rejects path traversal attempts', async () => { + await expect(getSessionDetail('../../etc/passwd')).resolves.toBeNull(); + expect(accessMock).not.toHaveBeenCalled(); + }); + + it('buildSessionContext rejects path traversal attempts', async () => { + await expect(buildSessionContext('../../etc/passwd')).resolves.toBeNull(); + expect(readFileMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/server/copilot/session-metadata.ts b/src/lib/server/copilot/session-metadata.ts index 33e00a4..15025c4 100644 --- a/src/lib/server/copilot/session-metadata.ts +++ b/src/lib/server/copilot/session-metadata.ts @@ -22,6 +22,13 @@ export interface SessionDetail { isRemote?: boolean; } +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** Validate that a session ID is a well-formed UUID (prevents path traversal) */ +export function isValidSessionId(id: string): boolean { + return UUID_RE.test(id); +} + /** Resolve the session-state root directory */ export function getSessionStateDir(): string { const base = config.copilotConfigDir || join(homedir(), '.copilot'); @@ -113,6 +120,7 @@ export async function enrichSessionMetadata( /** Get full session detail for the preview panel */ export async function getSessionDetail(sessionId: string): Promise { + if (!isValidSessionId(sessionId)) return null; const sessionDir = join(getSessionStateDir(), sessionId); if (!await pathExists(sessionDir)) return null; @@ -216,11 +224,9 @@ export async function listSessionsFromFilesystem(): Promise } // UUID pattern — only pick directories that look like session IDs - const uuidRe = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - const results = await Promise.all( entries - .filter((name) => uuidRe.test(name)) + .filter((name) => isValidSessionId(name)) .map(async (sessionId): Promise => { const sessionDir = join(stateDir, sessionId); @@ -267,6 +273,7 @@ export async function listSessionsFromFilesystem(): Promise * Used as a fallback when resumeSession() fails for bundled/filesystem-only sessions. */ export async function buildSessionContext(sessionId: string): Promise { + if (!isValidSessionId(sessionId)) return null; const sessionDir = join(getSessionStateDir(), sessionId); if (!await pathExists(sessionDir)) return null; @@ -339,8 +346,7 @@ export async function buildSessionContext(sessionId: string): Promise { - const uuidRe = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - if (!uuidRe.test(sessionId)) return false; + if (!isValidSessionId(sessionId)) return false; const sessionDir = join(getSessionStateDir(), sessionId); if (!await pathExists(sessionDir)) return false; diff --git a/src/lib/server/copilot/session.test.ts b/src/lib/server/copilot/session.test.ts index f817249..786438e 100644 --- a/src/lib/server/copilot/session.test.ts +++ b/src/lib/server/copilot/session.test.ts @@ -15,7 +15,7 @@ vi.mock('../config.js', () => ({ }, })); -import { createCopilotSession, getAvailableModels } from './session.js'; +import { createCopilotSession, getAvailableModels, buildSessionHooks } from './session.js'; interface ClientMock { createSession: ReturnType; @@ -399,6 +399,182 @@ describe('createCopilotSession', () => { const config = getSessionConfig(client); expect(config.customAgents).toEqual(customAgents); }); + + it('supports SSE-type MCP servers alongside HTTP servers', async () => { + const client = createClientMock(); + + await createCopilotSession(client as unknown as Parameters[0], 'gh-token', { + mcpServers: [ + { + name: 'http-server', + url: 'https://api.example.com/mcp', + type: 'http', + headers: { 'X-Api-Key': 'key1' }, + tools: ['search'], + }, + { + name: 'sse-server', + url: 'https://stream.example.com/events', + type: 'sse', + headers: { Authorization: 'Bearer tok' }, + tools: [], + }, + ], + }); + + const mcpServers = getSessionConfig(client).mcpServers as Record>; + + // GitHub server always present + expect(mcpServers.github).toBeDefined(); + + // HTTP server preserved as-is + expect(mcpServers['http-server']).toEqual({ + type: 'http', + url: 'https://api.example.com/mcp', + headers: { 'X-Api-Key': 'key1' }, + tools: ['search'], + }); + + // SSE server with empty tools defaults to wildcard + expect(mcpServers['sse-server']).toEqual({ + type: 'sse', + url: 'https://stream.example.com/events', + headers: { Authorization: 'Bearer tok' }, + tools: ['*'], + }); + }); + + it('always includes GitHub MCP server with the provided token', async () => { + const client = createClientMock(); + + await createCopilotSession(client as unknown as Parameters[0], 'my-gh-token-123'); + + const mcpServers = getSessionConfig(client).mcpServers as Record>; + expect(mcpServers.github).toEqual({ + type: 'http', + url: 'https://api.githubcopilot.com/mcp/x/all', + headers: { Authorization: 'Bearer my-gh-token-123' }, + tools: ['*'], + }); + }); + + it('omits user MCP servers when none are provided', async () => { + const client = createClientMock(); + + await createCopilotSession(client as unknown as Parameters[0], 'gh-token', { + mcpServers: [], + }); + + const mcpServers = getSessionConfig(client).mcpServers as Record>; + expect(Object.keys(mcpServers)).toEqual(['github']); + }); + + it('wires session hooks when onHookEvent callback is provided', async () => { + const client = createClientMock(); + const onHookEvent = vi.fn(); + + await createCopilotSession(client as unknown as Parameters[0], 'gh-token', { + onHookEvent, + }); + + const config = getSessionConfig(client); + expect(config.hooks).toBeDefined(); + expect(config.hooks).toHaveProperty('onPreToolUse'); + expect(config.hooks).toHaveProperty('onPostToolUse'); + expect(config.hooks).toHaveProperty('onSessionStart'); + expect(config.hooks).toHaveProperty('onSessionEnd'); + expect(config.hooks).toHaveProperty('onErrorOccurred'); + }); + + it('omits hooks when onHookEvent is not provided', async () => { + const client = createClientMock(); + + await createCopilotSession(client as unknown as Parameters[0], 'gh-token'); + + const config = getSessionConfig(client); + expect(config.hooks).toBeUndefined(); + }); +}); + +describe('buildSessionHooks', () => { + it('sends hook_pre_tool message with tool name and args', () => { + const callback = vi.fn(); + const hooks = buildSessionHooks(callback); + + hooks!.onPreToolUse!( + { toolName: 'bash', toolArgs: { command: 'ls' }, timestamp: 1, cwd: '/tmp' }, + { sessionId: 's1' }, + ); + + expect(callback).toHaveBeenCalledWith({ + type: 'hook_pre_tool', + toolName: 'bash', + toolArgs: { command: 'ls' }, + }); + }); + + it('sends hook_post_tool message with tool name and args', () => { + const callback = vi.fn(); + const hooks = buildSessionHooks(callback); + + hooks!.onPostToolUse!( + { toolName: 'read', toolArgs: { path: '/file' }, toolResult: { content: 'ok' } as any, timestamp: 1, cwd: '/tmp' }, + { sessionId: 's1' }, + ); + + expect(callback).toHaveBeenCalledWith({ + type: 'hook_post_tool', + toolName: 'read', + toolArgs: { path: '/file' }, + }); + }); + + it('sends hook_session_start message with source', () => { + const callback = vi.fn(); + const hooks = buildSessionHooks(callback); + + hooks!.onSessionStart!( + { source: 'new', timestamp: 1, cwd: '/tmp' }, + { sessionId: 's1' }, + ); + + expect(callback).toHaveBeenCalledWith({ + type: 'hook_session_start', + source: 'new', + }); + }); + + it('sends hook_session_end message with reason', () => { + const callback = vi.fn(); + const hooks = buildSessionHooks(callback); + + hooks!.onSessionEnd!( + { reason: 'complete', timestamp: 1, cwd: '/tmp' }, + { sessionId: 's1' }, + ); + + expect(callback).toHaveBeenCalledWith({ + type: 'hook_session_end', + reason: 'complete', + }); + }); + + it('sends hook_error message with error details', () => { + const callback = vi.fn(); + const hooks = buildSessionHooks(callback); + + hooks!.onErrorOccurred!( + { error: 'timeout', errorContext: 'tool_execution', recoverable: true, timestamp: 1, cwd: '/tmp' }, + { sessionId: 's1' }, + ); + + expect(callback).toHaveBeenCalledWith({ + type: 'hook_error', + error: 'timeout', + errorContext: 'tool_execution', + recoverable: true, + }); + }); }); describe('getAvailableModels', () => { diff --git a/src/lib/server/copilot/session.ts b/src/lib/server/copilot/session.ts index 478f216..339c7da 100644 --- a/src/lib/server/copilot/session.ts +++ b/src/lib/server/copilot/session.ts @@ -1,5 +1,7 @@ import { CopilotClient, defineTool } from '@github/copilot-sdk'; import type { SessionConfig } from '@github/copilot-sdk'; + +export type HookEventCallback = (message: Record) => void; import { isIP } from 'node:net'; import { config } from '../config.js'; import { z } from 'zod'; @@ -51,6 +53,7 @@ export interface CreateSessionOptions { tools?: string[]; prompt: string; }>; + onHookEvent?: HookEventCallback; } function buildZodSchema(params: Record): z.ZodObject> { @@ -186,6 +189,31 @@ function buildCustomTools(customTools: CustomToolDefinition[]) { }); } +export function buildSessionHooks(onHookEvent: HookEventCallback): SessionConfig['hooks'] { + return { + onPreToolUse: (input) => { + onHookEvent({ type: 'hook_pre_tool', toolName: input.toolName, toolArgs: input.toolArgs }); + }, + onPostToolUse: (input) => { + onHookEvent({ type: 'hook_post_tool', toolName: input.toolName, toolArgs: input.toolArgs }); + }, + onSessionStart: (input) => { + onHookEvent({ type: 'hook_session_start', source: input.source }); + }, + onSessionEnd: (input) => { + onHookEvent({ type: 'hook_session_end', reason: input.reason }); + }, + onErrorOccurred: (input) => { + onHookEvent({ + type: 'hook_error', + error: input.error, + errorContext: input.errorContext, + recoverable: input.recoverable, + }); + }, + }; +} + export async function createCopilotSession( client: CopilotClient, githubToken: string, @@ -280,6 +308,10 @@ export async function createCopilotSession( sessionConfig.customAgents = options.customAgents; } + if (options.onHookEvent) { + sessionConfig.hooks = buildSessionHooks(options.onHookEvent); + } + return client.createSession(sessionConfig); } diff --git a/src/lib/server/ws/attachment-validation.test.ts b/src/lib/server/ws/attachment-validation.test.ts new file mode 100644 index 0000000..81d24d1 --- /dev/null +++ b/src/lib/server/ws/attachment-validation.test.ts @@ -0,0 +1,48 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest'; +import { isValidAttachmentPath } from './handler.js'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +const UPLOAD_PREFIX = join(tmpdir(), 'copilot-uploads'); + +describe('isValidAttachmentPath', () => { + it('accepts a valid upload path', () => { + const path = join(UPLOAD_PREFIX, 'uuid-123', 'photo.png'); + expect(isValidAttachmentPath(path)).toBe(true); + }); + + it('accepts nested paths inside the upload directory', () => { + const path = join(UPLOAD_PREFIX, 'uuid-456', 'subdir', 'file.jpg'); + expect(isValidAttachmentPath(path)).toBe(true); + }); + + it('rejects paths outside the upload directory', () => { + expect(isValidAttachmentPath('/etc/passwd')).toBe(false); + expect(isValidAttachmentPath('/home/user/.ssh/id_rsa')).toBe(false); + expect(isValidAttachmentPath('/tmp/other-dir/file.txt')).toBe(false); + }); + + it('rejects the upload prefix itself (must be inside it)', () => { + expect(isValidAttachmentPath(UPLOAD_PREFIX)).toBe(false); + }); + + it('rejects path traversal attempts', () => { + const traversal = join(UPLOAD_PREFIX, 'uuid-123', '..', '..', 'etc', 'passwd'); + expect(isValidAttachmentPath(traversal)).toBe(false); + }); + + it('rejects relative paths', () => { + expect(isValidAttachmentPath('photo.png')).toBe(false); + expect(isValidAttachmentPath('./uploads/photo.png')).toBe(false); + }); + + it('rejects empty strings', () => { + expect(isValidAttachmentPath('')).toBe(false); + }); + + it('rejects paths that match the prefix as a substring but are not inside it', () => { + // e.g. /tmp/copilot-uploads-evil/file.txt should not match /tmp/copilot-uploads/ + expect(isValidAttachmentPath(UPLOAD_PREFIX + '-evil/file.txt')).toBe(false); + }); +}); diff --git a/src/lib/server/ws/handler.ts b/src/lib/server/ws/handler.ts index b512689..926c44f 100644 --- a/src/lib/server/ws/handler.ts +++ b/src/lib/server/ws/handler.ts @@ -1,11 +1,12 @@ import { WebSocketServer, WebSocket } from 'ws'; import { Server, IncomingMessage } from 'http'; import { writeFile } from 'node:fs/promises'; -import { join } from 'node:path'; +import { join, resolve } from 'node:path'; +import { tmpdir } from 'node:os'; import { approveAll } from '@github/copilot-sdk'; import { createCopilotClient } from '../copilot/client.js'; import { createCopilotSession, getAvailableModels } from '../copilot/session.js'; -import { enrichSessionMetadata, getSessionDetail, getSessionStateDir, listSessionsFromFilesystem, buildSessionContext, deleteSessionFromFilesystem } from '../copilot/session-metadata.js'; +import { enrichSessionMetadata, getSessionDetail, getSessionStateDir, listSessionsFromFilesystem, buildSessionContext, deleteSessionFromFilesystem, isValidSessionId } from '../copilot/session-metadata.js'; import { config } from '../config.js'; import { logSecurity } from '../security-log.js'; import { validateGitHubToken } from '../auth/github.js'; @@ -32,6 +33,43 @@ const VALID_MESSAGE_TYPES = new Set([ const VALID_MODES = new Set(['interactive', 'plan', 'autopilot']); const VALID_REASONING = new Set(['low', 'medium', 'high', 'xhigh']); const HEARTBEAT_INTERVAL = 30_000; +const UPLOAD_DIR_PREFIX = join(tmpdir(), 'copilot-uploads'); + +/** Validate that an attachment path is an absolute path inside the upload directory (prevents arbitrary file reads). */ +export function isValidAttachmentPath(filePath: string): boolean { + const resolved = resolve(filePath); + return resolved.startsWith(UPLOAD_DIR_PREFIX + '/'); +} + +/** Parse and validate MCP server entries from a WebSocket message, filtering out disabled servers. */ +export function parseMcpServers(raw: unknown): Array<{ name: string; url: string; type: 'http' | 'sse'; headers: Record; tools: string[] }> | undefined { + if (!Array.isArray(raw)) return undefined; + const servers = raw + .filter((s: unknown) => { + if (!s || typeof s !== 'object') return false; + const obj = s as Record; + if (obj.enabled === false) return false; + return ( + typeof obj.name === 'string' && + typeof obj.url === 'string' && + (obj.type === 'http' || obj.type === 'sse') && + typeof obj.headers === 'object' && obj.headers !== null && + Array.isArray(obj.tools) + ); + }) + .slice(0, 10) + .map((s: unknown) => { + const obj = s as Record; + return { + name: obj.name as string, + url: obj.url as string, + type: obj.type as 'http' | 'sse', + headers: obj.headers as Record, + tools: (obj.tools as unknown[]).filter((t): t is string => typeof t === 'string'), + }; + }); + return servers.length > 0 ? servers : undefined; +} /** Normalize SDK quota snapshots: convert remainingPercentage from 0.0–1.0 to 0–100 and add percentageUsed */ function normalizeQuotaSnapshots(raw: Record | undefined): Record | undefined { @@ -550,31 +588,7 @@ export function setupWebSocket( const customTools = Array.isArray(msg.customTools) ? msg.customTools.slice(0, 10) : undefined; - const mcpServers = Array.isArray(msg.mcpServers) - ? msg.mcpServers - .filter((s: unknown) => { - if (!s || typeof s !== 'object') return false; - const obj = s as Record; - return ( - typeof obj.name === 'string' && - typeof obj.url === 'string' && - (obj.type === 'http' || obj.type === 'sse') && - typeof obj.headers === 'object' && obj.headers !== null && - Array.isArray(obj.tools) - ); - }) - .slice(0, 10) - .map((s: unknown) => { - const obj = s as Record; - return { - name: obj.name as string, - url: obj.url as string, - type: obj.type as 'http' | 'sse', - headers: obj.headers as Record, - tools: (obj.tools as unknown[]).filter((t): t is string => typeof t === 'string'), - }; - }) - : undefined; + const mcpServers = parseMcpServers(msg.mcpServers); const disabledSkills = Array.isArray(msg.disabledSkills) ? msg.disabledSkills.filter((s: unknown) => typeof s === 'string') @@ -619,6 +633,7 @@ export function setupWebSocket( skillDirectories, disabledSkills, customAgents, + onHookEvent: (message) => poolSend(connectionEntry, message), }); wireSessionEvents(connectionEntry.session, connectionEntry, connectionEntry.session?.sessionId); @@ -665,6 +680,15 @@ export function setupWebSocket( const att = a as Record; return typeof att.path === 'string' && typeof att.name === 'string'; }) + .filter((a: unknown) => { + const att = a as Record; + const path = att.path as string; + if (!isValidAttachmentPath(path)) { + logSecurity('warn', 'ATTACHMENT_PATH_REJECTED', { path }); + return false; + } + return true; + }) .map((a: unknown) => { const att = a as Record; return { @@ -974,6 +998,10 @@ export function setupWebSocket( poolSend(connectionEntry, { type: 'error', message: 'Session ID is required' }); return; } + if (!isValidSessionId(deleteId)) { + poolSend(connectionEntry, { type: 'error', message: 'Invalid session ID format' }); + return; + } // Prevent deleting the active session if (connectionEntry.session?.sessionId === deleteId) { @@ -1010,6 +1038,10 @@ export function setupWebSocket( poolSend(connectionEntry, { type: 'error', message: 'Session ID is required' }); return; } + if (!isValidSessionId(detailId)) { + poolSend(connectionEntry, { type: 'error', message: 'Invalid session ID format' }); + return; + } try { console.log('[DEBUG get_session_detail] Calling getSessionDetail…'); @@ -1034,6 +1066,10 @@ export function setupWebSocket( poolSend(connectionEntry, { type: 'error', message: 'Session ID is required' }); return; } + if (!isValidSessionId(sessionId)) { + poolSend(connectionEntry, { type: 'error', message: 'Invalid session ID format' }); + return; + } if (connectionEntry.session) { try { await connectionEntry.session.disconnect(); } catch { /* ignore */ } @@ -1041,6 +1077,7 @@ export function setupWebSocket( } connectionEntry.userInputResolve = null; connectionEntry.permissionResolve = null; + connectionEntry.isProcessing = false; try { // start() is idempotent — ensures the SDK has indexed all local sessions @@ -1051,6 +1088,29 @@ export function setupWebSocket( // Read filesystem plan for injection into resumed session context const detail = await getSessionDetail(sessionId); + // Parse MCP servers from the message so resumed sessions retain MCP access + const resumeMcpServers = parseMcpServers(msg.mcpServers); + + // Build the full MCP config (GitHub server + user servers) + const mcpServersConfig: Record = { + github: { + type: 'http', + url: 'https://api.githubcopilot.com/mcp/x/all', + headers: { Authorization: `Bearer ${githubToken}` }, + tools: ['*'], + }, + }; + if (resumeMcpServers) { + for (const s of resumeMcpServers) { + mcpServersConfig[s.name] = { + type: s.type, + url: s.url, + headers: s.headers, + tools: s.tools.length > 0 ? s.tools : ['*'], + }; + } + } + let resumed = false; // Try native SDK resume first @@ -1060,6 +1120,7 @@ export function setupWebSocket( streaming: true, onUserInputRequest: makeUserInputHandler(connectionEntry), configDir: resolvedConfigDir, + mcpServers: mcpServersConfig as any, ...(detail?.plan && { systemMessage: { mode: 'append' as const, @@ -1085,6 +1146,8 @@ export function setupWebSocket( onUserInputRequest: makeUserInputHandler(connectionEntry), permissionMode: 'approve_all', configDir: resolvedConfigDir, + mcpServers: resumeMcpServers, + onHookEvent: (message) => poolSend(connectionEntry, message), }); console.log(`[RESUME] Fallback session created for ${sessionId} with context injection`); } diff --git a/src/lib/server/ws/parse-mcp-servers.test.ts b/src/lib/server/ws/parse-mcp-servers.test.ts new file mode 100644 index 0000000..14b210f --- /dev/null +++ b/src/lib/server/ws/parse-mcp-servers.test.ts @@ -0,0 +1,92 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest'; +import { parseMcpServers } from './handler.js'; + +describe('parseMcpServers', () => { + it('returns undefined for non-array input', () => { + expect(parseMcpServers(undefined)).toBeUndefined(); + expect(parseMcpServers(null)).toBeUndefined(); + expect(parseMcpServers('string')).toBeUndefined(); + expect(parseMcpServers(42)).toBeUndefined(); + }); + + it('returns undefined for an empty array', () => { + expect(parseMcpServers([])).toBeUndefined(); + }); + + it('parses valid HTTP and SSE servers', () => { + const result = parseMcpServers([ + { name: 'api', url: 'https://api.example.com/mcp', type: 'http', headers: { 'X-Key': 'k1' }, tools: ['search'] }, + { name: 'stream', url: 'https://stream.example.com/events', type: 'sse', headers: {}, tools: [] }, + ]); + + expect(result).toEqual([ + { name: 'api', url: 'https://api.example.com/mcp', type: 'http', headers: { 'X-Key': 'k1' }, tools: ['search'] }, + { name: 'stream', url: 'https://stream.example.com/events', type: 'sse', headers: {}, tools: [] }, + ]); + }); + + it('filters out servers with enabled === false', () => { + const result = parseMcpServers([ + { name: 'active', url: 'https://a.example.com/mcp', type: 'http', headers: {}, tools: [], enabled: true }, + { name: 'disabled', url: 'https://b.example.com/mcp', type: 'http', headers: {}, tools: [], enabled: false }, + ]); + + expect(result).toEqual([ + { name: 'active', url: 'https://a.example.com/mcp', type: 'http', headers: {}, tools: [] }, + ]); + }); + + it('returns undefined when all servers are disabled', () => { + const result = parseMcpServers([ + { name: 's1', url: 'https://a.example.com/mcp', type: 'http', headers: {}, tools: [], enabled: false }, + { name: 's2', url: 'https://b.example.com/mcp', type: 'sse', headers: {}, tools: [], enabled: false }, + ]); + + expect(result).toBeUndefined(); + }); + + it('includes servers without an enabled field (defaults to enabled)', () => { + const result = parseMcpServers([ + { name: 'no-flag', url: 'https://a.example.com/mcp', type: 'http', headers: {}, tools: [] }, + ]); + + expect(result).toHaveLength(1); + expect(result![0].name).toBe('no-flag'); + }); + + it('rejects entries with missing or invalid fields', () => { + const result = parseMcpServers([ + null, + { name: 'missing-url', type: 'http', headers: {}, tools: [] }, + { name: 'bad-type', url: 'https://a.example.com', type: 'grpc', headers: {}, tools: [] }, + { name: 'no-headers', url: 'https://a.example.com', type: 'http', tools: [] }, + { name: 'no-tools', url: 'https://a.example.com', type: 'http', headers: {} }, + 'not-an-object', + ]); + + expect(result).toBeUndefined(); + }); + + it('limits to 10 servers', () => { + const servers = Array.from({ length: 15 }, (_, i) => ({ + name: `server-${i}`, + url: `https://s${i}.example.com/mcp`, + type: 'http' as const, + headers: {}, + tools: [], + })); + + const result = parseMcpServers(servers); + expect(result).toHaveLength(10); + expect(result![9].name).toBe('server-9'); + }); + + it('filters non-string values from tools arrays', () => { + const result = parseMcpServers([ + { name: 'mixed-tools', url: 'https://a.example.com/mcp', type: 'http', headers: {}, tools: ['valid', 42, null, 'also-valid'] }, + ]); + + expect(result![0].tools).toEqual(['valid', 'also-valid']); + }); +}); diff --git a/src/lib/stores/ws.svelte.ts b/src/lib/stores/ws.svelte.ts index e0350f8..64f6cc3 100644 --- a/src/lib/stores/ws.svelte.ts +++ b/src/lib/stores/ws.svelte.ts @@ -6,6 +6,7 @@ import type { ServerMessage, NewSessionConfig, MessageDeliveryMode, + McpServerDefinition, } from '$lib/types/index.js'; import { notify } from '$lib/utils/notifications.js'; @@ -40,7 +41,7 @@ export interface WsStore { mode?: MessageDeliveryMode, ): void; newSession(config: NewSessionConfig): void; - resumeSession(sessionId: string): void; + resumeSession(sessionId: string, mcpServers?: McpServerDefinition[]): void; setMode(mode: SessionMode): void; setModel(model: string): void; setReasoning(effort: ReasoningEffort): void; @@ -248,9 +249,14 @@ export function createWsStore(): WsStore { send(msg); } - function resumeSession(sessionId: string): void { + function resumeSession(sessionId: string, mcpServers?: McpServerDefinition[]): void { sessionReady = false; - send({ type: 'resume_session', sessionId }); + const enabledServers = mcpServers?.filter(s => s.enabled); + send({ + type: 'resume_session', + sessionId, + ...(enabledServers?.length && { mcpServers: enabledServers }), + }); } function setMode(mode: SessionMode): void { diff --git a/src/lib/types/index.ts b/src/lib/types/index.ts index e29ee21..3bdb065 100644 --- a/src/lib/types/index.ts +++ b/src/lib/types/index.ts @@ -484,6 +484,35 @@ export interface FleetStatusMessage { agents: Array<{ agentId: string; agentType: string }>; } +export interface HookPreToolMessage { + type: 'hook_pre_tool'; + toolName: string; + toolArgs?: unknown; +} + +export interface HookPostToolMessage { + type: 'hook_post_tool'; + toolName: string; + toolArgs?: unknown; +} + +export interface HookSessionStartMessage { + type: 'hook_session_start'; + source: string; +} + +export interface HookSessionEndMessage { + type: 'hook_session_end'; + reason: string; +} + +export interface HookErrorMessage { + type: 'hook_error'; + error: string; + errorContext: string; + recoverable: boolean; +} + export interface SessionUsageTotals { inputTokens: number; outputTokens: number; @@ -556,7 +585,12 @@ export type ServerMessage = | ContextChangedMessage | WorkspaceFileChangedMessage | FleetStartedMessage - | FleetStatusMessage; + | FleetStatusMessage + | HookPreToolMessage + | HookPostToolMessage + | HookSessionStartMessage + | HookSessionEndMessage + | HookErrorMessage; // ─── File attachment ───────────────────────────────────────────────────────── @@ -661,6 +695,7 @@ export interface ListSessionsMessage { export interface ResumeSessionMessage { type: 'resume_session'; sessionId: string; + mcpServers?: McpServerDefinition[]; } export interface DeleteSessionMessage { diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index abb1b25..b83140e 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -207,7 +207,7 @@ function handleResumeSession(sessionId: string): void { chatStore.clearMessages(); - wsStore.resumeSession(sessionId); + wsStore.resumeSession(sessionId, settings.mcpServers); sessionsOpen = false; } diff --git a/src/routes/api/upload/server.test.ts b/src/routes/api/upload/server.test.ts index ed85472..15f7d29 100644 --- a/src/routes/api/upload/server.test.ts +++ b/src/routes/api/upload/server.test.ts @@ -179,4 +179,40 @@ describe('POST /api/upload', () => { expect(mkdir).toHaveBeenCalledWith('/tmp/copilot-uploads/upload-123', { recursive: true }); expect(writeFile).toHaveBeenCalledWith('/tmp/copilot-uploads/upload-123/notes.md', expect.any(Buffer)); }); + + it.each([ + ['photo.jpg', 'image/jpeg'], + ['photo.jpeg', 'image/jpeg'], + ['screenshot.png', 'image/png'], + ['animation.gif', 'image/gif'], + ['modern.webp', 'image/webp'], + ])('accepts image file %s with type %s', async (name, mimeType) => { + const content = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); // dummy bytes + const request = createUploadRequest([new File([content], name, { type: mimeType })]); + + const response = await POST(createEvent(request, createSession({ githubToken: 'token' }))); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.files).toHaveLength(1); + expect(body.files[0]).toEqual({ + path: `/tmp/copilot-uploads/upload-123/${name}`, + name, + size: content.length, + type: mimeType, + }); + expect(writeFile).toHaveBeenCalledWith(`/tmp/copilot-uploads/upload-123/${name}`, expect.any(Buffer)); + }); + + it('returns absolute server-side paths for uploaded files', async () => { + const request = createUploadRequest([new File(['data'], 'image.png', { type: 'image/png' })]); + + const response = await POST(createEvent(request, createSession({ githubToken: 'token' }))); + const body = await response.json(); + + const filePath: string = body.files[0].path; + expect(filePath.startsWith('/')).toBe(true); + expect(filePath).toContain('/copilot-uploads/'); + expect(filePath).not.toContain('http'); + }); }); From 0bff0b79bdbcd8308ae47cb18933adee0780bad5 Mon Sep 17 00:00:00 2001 From: Gabriel Mercuri Date: Sat, 14 Mar 2026 17:34:25 +0100 Subject: [PATCH 04/48] feat: SDK feature completion, UX improvements, security hardening * ci: add CodeQL scanning workflow and secret scanning setup script - Create .github/workflows/codeql.yml (JS/TS analysis, weekly + PR triggers) - Create scripts/setup-security.sh for enabling secret scanning + push protection - Update SECURITY.md with secret scanning documentation Closes #78 Closes #79 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * ci: add branch protection script and release-please workflow - Create scripts/setup-branch-protection.sh (gh api, requires admin) - Create .github/workflows/release.yml (release-please for semver + changelog) - Create release-please-config.json and .release-please-manifest.json Closes #75 Closes #80 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * ci: enhance CI with Playwright E2E, conventional commit check, caching - Add e2e job with Playwright desktop tests and artifact upload on failure - Add commit-lint job checking PR title against conventional commits pattern - Add concurrency group to cancel redundant runs - Add npm cache via setup-node Closes #70 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: enhance PR template, YAML issue forms, and CODEOWNERS - Upgrade PR template with GitHub Flow + security checklist - Convert issue templates from Markdown to YAML forms - Add SDK feature issue template - Add security advisory contact link - Create CODEOWNERS with path-based ownership Closes #73 Closes #74 Closes #77 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * ci: add PR auto-labeler and stale issues/PR management - Create labeler config with 10 path-based labels (backend, frontend, sdk, etc.) - Create labeler.yml workflow using actions/labeler@v5 - Create stale.yml workflow (30-day stale, 7-day close, exempt security/killer-feature) Closes #71 Closes #72 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: add Copilot prompt files and rewrite copilot-instructions.md - Create 4 prompt files: generate-test, review-security, add-feature, fix-bug - Rewrite copilot-instructions.md with accurate counts (20 components, 78 message types) - Add skills system, testing sections, updated project structure Closes #76 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: adopt awesome-copilot skills, agents, instructions, and workflows Skills added (4): github-issues, doublecheck, copilot-spaces, automate-this Agents added (6): 4.1-Beast, critical-thinking, implementation-plan, refine-issue, polyglot-test-generator, adr-generator Instructions added (2): code-review-generic, performance-optimization Workflows added (2): codespell, check-pr-target Closes #86 Closes #87 Closes #88 Closes #69 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: expose infinite session compaction config in settings (#84) Add InfiniteSessionsSettings type with enabled, backgroundThreshold, and bufferThreshold fields. Wire through settings store (with clamping validation and localStorage persistence), WS types, WS store, page component, and handler mapping to SDK's InfiniteSessionConfig. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: SDK feature completion + UX improvements + security hardening SDK Features: - Image thumbnails in user messages with file chips (#31) - Directory and selection attachment types (#85) - Infinite session compaction config (#83) - @ file fuzzy mention with autocomplete (#34) - File serving route for uploaded images Security: - Path traversal protection on attachment validation - Workspace path validation for file mentions - Auth-required file serving endpoint Tests: 306 passing (29 test files) Closes #31 Closes #34 Closes #83 Closes #85 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/lib/components/ChatInput.svelte | 236 +++++++++++++++++- src/lib/components/ChatMessage.svelte | 145 ++++++++++- src/lib/server/copilot/session.test.ts | 1 + src/lib/server/files/workspace-path.test.ts | 33 +++ src/lib/server/files/workspace-path.ts | 7 + .../server/ws/attachment-validation.test.ts | 131 +++++++++- src/lib/server/ws/handler.ts | 141 ++++++++--- src/lib/stores/chat.svelte.ts | 7 +- src/lib/stores/settings.svelte.ts | 37 +++ src/lib/stores/settings.test.ts | 1 + src/lib/stores/ws.svelte.ts | 6 +- src/lib/types/index.ts | 31 ++- src/routes/+page.svelte | 7 +- src/routes/api/files/+server.ts | 110 ++++++++ src/routes/api/files/server.test.ts | 95 +++++++ .../files/[uploadId]/[filename]/+server.ts | 62 +++++ 16 files changed, 1002 insertions(+), 48 deletions(-) create mode 100644 src/lib/server/files/workspace-path.test.ts create mode 100644 src/lib/server/files/workspace-path.ts create mode 100644 src/routes/api/files/+server.ts create mode 100644 src/routes/api/files/server.test.ts create mode 100644 src/routes/api/upload/files/[uploadId]/[filename]/+server.ts diff --git a/src/lib/components/ChatInput.svelte b/src/lib/components/ChatInput.svelte index 1a9c7e2..845ff93 100644 --- a/src/lib/components/ChatInput.svelte +++ b/src/lib/components/ChatInput.svelte @@ -1,5 +1,6 @@ + + {#if open} +