mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-03-30 13:43:26 +08:00
Compare commits
38 Commits
e70d4d2237
...
4ff6831b2b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ff6831b2b | ||
|
|
182e9e78b9 | ||
|
|
0250de793a | ||
|
|
88fa1bdbbc | ||
|
|
2753db3a48 | ||
|
|
e50b05384a | ||
|
|
26f3c88902 | ||
|
|
df2d3a6d54 | ||
|
|
25c5d58c44 | ||
|
|
06af1acb8d | ||
|
|
6a0b231d34 | ||
|
|
a563df2a52 | ||
|
|
53e06a8850 | ||
|
|
93633e44f2 | ||
|
|
791da32c6b | ||
|
|
635eb108ab | ||
|
|
1e740724ca | ||
|
|
6737f3245b | ||
|
|
1b273de13f | ||
|
|
882157ac09 | ||
|
|
69799f2f80 | ||
|
|
b27c21732f | ||
|
|
332d0f444b | ||
|
|
45a0b62fcb | ||
|
|
a64a294b29 | ||
|
|
4d016babbb | ||
|
|
d2c1281e97 | ||
|
|
78ad952433 | ||
|
|
274cca025e | ||
|
|
18fcb88168 | ||
|
|
8604583d16 | ||
|
|
233b341557 | ||
|
|
a95fb54ee4 | ||
|
|
910ffa5530 | ||
|
|
b9a38b2680 | ||
|
|
14dfe4d110 | ||
|
|
3e98be3e39 | ||
|
|
3ec59c48bc |
642
llms.txt
642
llms.txt
@@ -1,642 +0,0 @@
|
||||
# OpenCode Documentation for LLMs
|
||||
|
||||
> OpenCode is an open-source AI coding agent available as a terminal interface, desktop application, or IDE extension. It helps developers write code, add features, and understand codebases through conversational interactions.
|
||||
|
||||
## Installation
|
||||
|
||||
Multiple installation methods are available:
|
||||
|
||||
```bash
|
||||
# curl script (recommended)
|
||||
curl -fsSL https://opencode.ai/install | bash
|
||||
|
||||
# Node.js package managers
|
||||
npm install -g opencode
|
||||
bun install -g opencode
|
||||
pnpm add -g opencode
|
||||
yarn global add opencode
|
||||
|
||||
# Homebrew (macOS/Linux)
|
||||
brew install anomalyco/tap/opencode
|
||||
|
||||
# Arch Linux
|
||||
paru -S opencode
|
||||
|
||||
# Windows
|
||||
choco install opencode # Chocolatey
|
||||
scoop install opencode # Scoop
|
||||
# Or use Docker/WSL (recommended for Windows)
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Configuration file: `opencode.json` or `opencode.jsonc` (with comments)
|
||||
|
||||
Schema: `https://opencode.ai/config.json`
|
||||
|
||||
### Core Settings
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"model": "anthropic/claude-sonnet-4-5",
|
||||
"small_model": "anthropic/claude-haiku-4-5",
|
||||
"default_agent": "build",
|
||||
"instructions": [
|
||||
"CONTRIBUTING.md",
|
||||
"docs/guidelines.md"
|
||||
],
|
||||
"plugin": [
|
||||
"opencode-helicone-session",
|
||||
"./.opencode/plugins"
|
||||
],
|
||||
"agent": { /* agent definitions */ },
|
||||
"command": { /* command definitions */ },
|
||||
"permission": {
|
||||
"edit": "ask",
|
||||
"bash": "ask",
|
||||
"mcp_*": "ask"
|
||||
},
|
||||
"tools": {
|
||||
"write": true,
|
||||
"bash": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Use `{env:VAR_NAME}` for environment variables and `{file:path}` for file contents in configuration values.
|
||||
|
||||
## Agents
|
||||
|
||||
OpenCode supports two agent types:
|
||||
|
||||
### Primary Agents
|
||||
Main assistants you interact with directly. Switch between them using Tab or configured keybinds.
|
||||
|
||||
### Subagents
|
||||
Specialized assistants that primary agents invoke automatically or through `@` mentions (e.g., `@general help me search`).
|
||||
|
||||
### Built-in Agents
|
||||
|
||||
| Agent | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| build | primary | Default agent with full tool access for development work |
|
||||
| plan | primary | Restricted agent for analysis; file edits and bash set to "ask" |
|
||||
| general | subagent | Full tool access for multi-step research tasks |
|
||||
| explore | subagent | Read-only agent for rapid codebase exploration |
|
||||
| compaction | system | Hidden agent for context compaction |
|
||||
| title | system | Hidden agent for title generation |
|
||||
| summary | system | Hidden agent for summarization |
|
||||
|
||||
### Agent Configuration
|
||||
|
||||
JSON format in `opencode.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"agent": {
|
||||
"code-reviewer": {
|
||||
"description": "Reviews code for best practices",
|
||||
"mode": "subagent",
|
||||
"model": "anthropic/claude-opus-4-5",
|
||||
"prompt": "{file:.opencode/prompts/agents/code-reviewer.txt}",
|
||||
"temperature": 0.3,
|
||||
"tools": {
|
||||
"write": false,
|
||||
"edit": false,
|
||||
"read": true,
|
||||
"bash": true
|
||||
},
|
||||
"permission": {
|
||||
"edit": "deny",
|
||||
"bash": {
|
||||
"*": "ask",
|
||||
"git status": "allow"
|
||||
}
|
||||
},
|
||||
"steps": 10
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Markdown format in `~/.config/opencode/agents/` or `.opencode/agents/`:
|
||||
|
||||
```markdown
|
||||
---
|
||||
description: Expert code review specialist
|
||||
mode: subagent
|
||||
model: anthropic/claude-opus-4-5
|
||||
temperature: 0.3
|
||||
tools:
|
||||
write: false
|
||||
read: true
|
||||
bash: true
|
||||
permission:
|
||||
edit: deny
|
||||
steps: 10
|
||||
---
|
||||
|
||||
You are an expert code reviewer. Review code for quality, security, and maintainability...
|
||||
```
|
||||
|
||||
### Agent Configuration Options
|
||||
|
||||
| Option | Purpose | Values |
|
||||
|--------|---------|--------|
|
||||
| description | Required field explaining agent purpose | string |
|
||||
| mode | Agent type | "primary", "subagent", or "all" |
|
||||
| model | Override default model | "provider/model-id" |
|
||||
| temperature | Control randomness | 0.0-1.0 (lower = focused) |
|
||||
| tools | Enable/disable specific tools | object or wildcards |
|
||||
| permission | Set tool permissions | "ask", "allow", or "deny" |
|
||||
| steps | Limit agentic iterations | number |
|
||||
| prompt | Reference custom prompt file | "{file:./path}" |
|
||||
| top_p | Alternative randomness control | 0.0-1.0 |
|
||||
|
||||
## Commands
|
||||
|
||||
### Built-in Commands
|
||||
|
||||
- `/init` - Initialize project analysis (creates AGENTS.md)
|
||||
- `/undo` - Undo last change
|
||||
- `/redo` - Redo undone change
|
||||
- `/share` - Generate shareable conversation link
|
||||
- `/help` - Show help
|
||||
- `/connect` - Configure API providers
|
||||
|
||||
### Custom Commands
|
||||
|
||||
**Markdown files** in `~/.config/opencode/commands/` (global) or `.opencode/commands/` (project):
|
||||
|
||||
```markdown
|
||||
---
|
||||
description: Create implementation plan
|
||||
agent: planner
|
||||
subtask: true
|
||||
---
|
||||
|
||||
Create a detailed implementation plan for: $ARGUMENTS
|
||||
|
||||
Include:
|
||||
- Requirements analysis
|
||||
- Architecture review
|
||||
- Step breakdown
|
||||
- Testing strategy
|
||||
```
|
||||
|
||||
**JSON configuration** in `opencode.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"command": {
|
||||
"plan": {
|
||||
"description": "Create implementation plan",
|
||||
"template": "Create a detailed implementation plan for: $ARGUMENTS",
|
||||
"agent": "planner",
|
||||
"subtask": true
|
||||
},
|
||||
"test": {
|
||||
"template": "Run tests with coverage for: $ARGUMENTS\n\nOutput:\n!`npm test`",
|
||||
"description": "Run tests with coverage",
|
||||
"agent": "build"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Template Variables
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `$ARGUMENTS` | All command arguments |
|
||||
| `$1`, `$2`, `$3` | Positional arguments |
|
||||
| `!`command`` | Include shell command output |
|
||||
| `@filepath` | Include file contents |
|
||||
|
||||
### Command Options
|
||||
|
||||
| Option | Purpose | Required |
|
||||
|--------|---------|----------|
|
||||
| template | Prompt text sent to LLM | Yes |
|
||||
| description | UI display text | No |
|
||||
| agent | Target agent for execution | No |
|
||||
| model | Override default LLM | No |
|
||||
| subtask | Force subagent invocation | No |
|
||||
|
||||
## Tools
|
||||
|
||||
### Built-in Tools
|
||||
|
||||
| Tool | Purpose | Permission Key |
|
||||
|------|---------|---------------|
|
||||
| bash | Execute shell commands | "bash" |
|
||||
| edit | Modify existing files using exact string replacements | "edit" |
|
||||
| write | Create new files or overwrite existing ones | "edit" |
|
||||
| read | Read file contents from codebase | "read" |
|
||||
| grep | Search file contents using regular expressions | "grep" |
|
||||
| glob | Find files by pattern matching | "glob" |
|
||||
| list | List files and directories | "list" |
|
||||
| lsp | Access code intelligence (experimental) | "lsp" |
|
||||
| patch | Apply patches to files | "edit" |
|
||||
| skill | Load skill files (SKILL.md) | "skill" |
|
||||
| todowrite | Manage todo lists during sessions | "todowrite" |
|
||||
| todoread | Read existing todo lists | "todoread" |
|
||||
| webfetch | Fetch and read web pages | "webfetch" |
|
||||
| question | Ask user questions during execution | "question" |
|
||||
|
||||
### Tool Permissions
|
||||
|
||||
```json
|
||||
{
|
||||
"permission": {
|
||||
"edit": "ask",
|
||||
"bash": "allow",
|
||||
"webfetch": "deny",
|
||||
"mcp_*": "ask"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Permission levels:
|
||||
- `"allow"` - Tool executes without restriction
|
||||
- `"deny"` - Tool cannot be used
|
||||
- `"ask"` - Requires user approval before execution
|
||||
|
||||
## Custom Tools
|
||||
|
||||
Location: `.opencode/tools/` (project) or `~/.config/opencode/tools/` (global)
|
||||
|
||||
### Tool Definition
|
||||
|
||||
```typescript
|
||||
import { tool } from "@opencode-ai/plugin"
|
||||
|
||||
export default tool({
|
||||
description: "Execute SQL query on the database",
|
||||
args: {
|
||||
query: tool.schema.string().describe("SQL query to execute"),
|
||||
database: tool.schema.string().optional().describe("Target database")
|
||||
},
|
||||
async execute(args, context) {
|
||||
// context.worktree - git repository root
|
||||
// context.directory - current working directory
|
||||
// context.sessionID - current session ID
|
||||
// context.agent - active agent identifier
|
||||
|
||||
const result = await someDbQuery(args.query)
|
||||
return { result }
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Multiple Tools Per File
|
||||
|
||||
```typescript
|
||||
// math.ts - creates math_add and math_multiply tools
|
||||
export const add = tool({
|
||||
description: "Add two numbers",
|
||||
args: {
|
||||
a: tool.schema.number(),
|
||||
b: tool.schema.number()
|
||||
},
|
||||
async execute({ a, b }) {
|
||||
return { result: a + b }
|
||||
}
|
||||
})
|
||||
|
||||
export const multiply = tool({
|
||||
description: "Multiply two numbers",
|
||||
args: {
|
||||
a: tool.schema.number(),
|
||||
b: tool.schema.number()
|
||||
},
|
||||
async execute({ a, b }) {
|
||||
return { result: a * b }
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Schema Types (Zod)
|
||||
|
||||
```typescript
|
||||
tool.schema.string()
|
||||
tool.schema.number()
|
||||
tool.schema.boolean()
|
||||
tool.schema.array(tool.schema.string())
|
||||
tool.schema.object({ key: tool.schema.string() })
|
||||
tool.schema.enum(["option1", "option2"])
|
||||
tool.schema.optional()
|
||||
tool.schema.describe("Description for LLM")
|
||||
```
|
||||
|
||||
## Plugins
|
||||
|
||||
Plugins extend OpenCode with custom hooks, tools, and behaviors.
|
||||
|
||||
### Plugin Structure
|
||||
|
||||
```typescript
|
||||
import { tool } from "@opencode-ai/plugin"
|
||||
|
||||
export const MyPlugin = async ({ project, client, $, directory, worktree }) => {
|
||||
// project - Current project information
|
||||
// client - OpenCode SDK client for AI interaction
|
||||
// $ - Bun's shell API for command execution
|
||||
// directory - Current working directory
|
||||
// worktree - Git worktree path
|
||||
|
||||
return {
|
||||
// Hook implementations
|
||||
"file.edited": async (event) => {
|
||||
// Handle file edit event
|
||||
},
|
||||
|
||||
"tool.execute.before": async (input, output) => {
|
||||
// Intercept before tool execution
|
||||
},
|
||||
|
||||
"session.idle": async (event) => {
|
||||
// Handle session idle
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Loading Plugins
|
||||
|
||||
1. **Local files**: Place in `.opencode/plugins/` (project) or `~/.config/opencode/plugins/` (global)
|
||||
2. **npm packages**: Specify in `opencode.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"plugin": [
|
||||
"opencode-helicone-session",
|
||||
"@my-org/custom-plugin",
|
||||
"./.opencode/plugins"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Available Hook Events
|
||||
|
||||
**Command Events:**
|
||||
- `command.executed` - After a command is executed
|
||||
|
||||
**File Events:**
|
||||
- `file.edited` - After a file is edited
|
||||
- `file.watcher.updated` - When file watcher detects changes
|
||||
|
||||
**Tool Events:**
|
||||
- `tool.execute.before` - Before tool execution (can modify input)
|
||||
- `tool.execute.after` - After tool execution (can modify output)
|
||||
|
||||
**Session Events:**
|
||||
- `session.created` - When session starts
|
||||
- `session.compacted` - After context compaction
|
||||
- `session.deleted` - When session ends
|
||||
- `session.idle` - When session becomes idle
|
||||
- `session.updated` - When session is updated
|
||||
- `session.status` - Session status changes
|
||||
|
||||
**Message Events:**
|
||||
- `message.updated` - When message is updated
|
||||
- `message.removed` - When message is removed
|
||||
- `message.part.updated` - When message part is updated
|
||||
|
||||
**LSP Events:**
|
||||
- `lsp.client.diagnostics` - LSP diagnostic updates
|
||||
- `lsp.updated` - LSP state updates
|
||||
|
||||
**Shell Events:**
|
||||
- `shell.env` - Modify shell environment variables
|
||||
|
||||
**TUI Events:**
|
||||
- `tui.prompt.append` - Append to TUI prompt
|
||||
- `tui.command.execute` - Execute TUI command
|
||||
- `tui.toast.show` - Show toast notification
|
||||
|
||||
**Other Events:**
|
||||
- `installation.updated` - Installation updates
|
||||
- `permission.asked` - Permission request
|
||||
- `server.connected` - Server connection
|
||||
- `todo.updated` - Todo list updates
|
||||
|
||||
### Hook Event Mapping (Claude Code → OpenCode)
|
||||
|
||||
| Claude Code Hook | OpenCode Plugin Event |
|
||||
|-----------------|----------------------|
|
||||
| PreToolUse | `tool.execute.before` |
|
||||
| PostToolUse | `tool.execute.after` |
|
||||
| Stop | `session.idle` or `session.status` |
|
||||
| SessionStart | `session.created` |
|
||||
| SessionEnd | `session.deleted` |
|
||||
| N/A | `file.edited`, `file.watcher.updated` |
|
||||
| N/A | `message.*`, `permission.*`, `lsp.*` |
|
||||
|
||||
### Plugin Example: Auto-Format
|
||||
|
||||
```typescript
|
||||
export const AutoFormatPlugin = async ({ $, directory }) => {
|
||||
return {
|
||||
"file.edited": async (event) => {
|
||||
if (event.path.match(/\.(ts|tsx|js|jsx)$/)) {
|
||||
await $`prettier --write ${event.path}`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Plugin Example: TypeScript Check
|
||||
|
||||
```typescript
|
||||
export const TypeCheckPlugin = async ({ $, client }) => {
|
||||
return {
|
||||
"tool.execute.after": async (input, output) => {
|
||||
if (input.tool === "edit" && input.args.filePath?.match(/\.tsx?$/)) {
|
||||
const result = await $`npx tsc --noEmit`.catch(e => e)
|
||||
if (result.exitCode !== 0) {
|
||||
client.app.log("warn", "TypeScript errors detected")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Providers
|
||||
|
||||
OpenCode integrates 75+ LLM providers via AI SDK and Models.dev.
|
||||
|
||||
### Supported Providers
|
||||
|
||||
- OpenCode Zen (recommended for beginners)
|
||||
- Anthropic (Claude)
|
||||
- OpenAI (GPT)
|
||||
- Google (Gemini)
|
||||
- Amazon Bedrock
|
||||
- Azure OpenAI
|
||||
- GitHub Copilot
|
||||
- Ollama (local)
|
||||
- And 70+ more
|
||||
|
||||
### Provider Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"provider": {
|
||||
"anthropic": {
|
||||
"options": {
|
||||
"baseURL": "https://api.anthropic.com/v1"
|
||||
}
|
||||
},
|
||||
"custom-provider": {
|
||||
"npm": "@ai-sdk/openai-compatible",
|
||||
"name": "Display Name",
|
||||
"options": {
|
||||
"baseURL": "https://api.example.com/v1",
|
||||
"apiKey": "{env:CUSTOM_API_KEY}"
|
||||
},
|
||||
"models": {
|
||||
"model-id": { "name": "Model Name" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Model Naming Convention
|
||||
|
||||
Format: `provider/model-id`
|
||||
|
||||
Examples:
|
||||
- `anthropic/claude-sonnet-4-5`
|
||||
- `anthropic/claude-opus-4-5`
|
||||
- `anthropic/claude-haiku-4-5`
|
||||
- `openai/gpt-4o`
|
||||
- `google/gemini-2.0-flash`
|
||||
|
||||
### API Key Setup
|
||||
|
||||
```bash
|
||||
# Interactive setup
|
||||
opencode
|
||||
/connect
|
||||
|
||||
# Environment variables
|
||||
export ANTHROPIC_API_KEY=sk-...
|
||||
export OPENAI_API_KEY=sk-...
|
||||
```
|
||||
|
||||
Keys stored in: `~/.local/share/opencode/auth.json`
|
||||
|
||||
## MCP (Model Context Protocol)
|
||||
|
||||
Configure MCP servers in `opencode.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcp": {
|
||||
"github": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-github"],
|
||||
"env": {
|
||||
"GITHUB_PERSONAL_ACCESS_TOKEN": "{env:GITHUB_TOKEN}"
|
||||
}
|
||||
},
|
||||
"filesystem": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/dir"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
MCP tool permissions use `mcp_*` wildcard:
|
||||
|
||||
```json
|
||||
{
|
||||
"permission": {
|
||||
"mcp_*": "ask"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Ecosystem Plugins
|
||||
|
||||
### Authentication & Provider Plugins
|
||||
- Alternative model access (ChatGPT Plus, Gemini, Antigravity)
|
||||
- Session tracking (Helicone headers)
|
||||
- OAuth integrations
|
||||
|
||||
### Development Tools
|
||||
- Sandbox isolation (Daytona integration)
|
||||
- Type injection for TypeScript/Svelte
|
||||
- DevContainer multi-branch support
|
||||
- Git worktree management
|
||||
|
||||
### Enhancement Plugins
|
||||
- Web search with citations
|
||||
- Markdown table formatting
|
||||
- Dynamic context token pruning
|
||||
- Desktop notifications
|
||||
- Persistent memory (Supermemory)
|
||||
- Background process management
|
||||
|
||||
### Plugin Discovery
|
||||
|
||||
- opencode.cafe - Community plugin registry
|
||||
- awesome-opencode - Curated list
|
||||
- GitHub search for "opencode-plugin"
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Key Shortcuts
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| Tab | Toggle between Plan and Build modes |
|
||||
| @ | Reference files or mention agents |
|
||||
| / | Execute commands |
|
||||
|
||||
### Common Commands
|
||||
|
||||
```bash
|
||||
/init # Initialize project
|
||||
/connect # Configure API providers
|
||||
/share # Share conversation
|
||||
/undo # Undo last change
|
||||
/redo # Redo undone change
|
||||
/help # Show help
|
||||
```
|
||||
|
||||
### File Locations
|
||||
|
||||
| Purpose | Project | Global |
|
||||
|---------|---------|--------|
|
||||
| Configuration | `opencode.json` | `~/.config/opencode/config.json` |
|
||||
| Agents | `.opencode/agents/` | `~/.config/opencode/agents/` |
|
||||
| Commands | `.opencode/commands/` | `~/.config/opencode/commands/` |
|
||||
| Plugins | `.opencode/plugins/` | `~/.config/opencode/plugins/` |
|
||||
| Tools | `.opencode/tools/` | `~/.config/opencode/tools/` |
|
||||
| Auth | - | `~/.local/share/opencode/auth.json` |
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
```bash
|
||||
# Verify credentials
|
||||
opencode auth list
|
||||
|
||||
# Check configuration
|
||||
cat opencode.json | jq .
|
||||
|
||||
# Test provider connection
|
||||
/connect
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
For more information: https://opencode.ai/docs/
|
||||
@@ -3481,6 +3481,189 @@ Some random content without the expected ### Context to Load section
|
||||
assert.ok(result.stdout.length > 0, 'Should still pass through truncated data');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Round 89: post-edit-typecheck.js error detection path (relevantLines) ──
|
||||
console.log('\nRound 89: post-edit-typecheck.js (TypeScript error detection path):');
|
||||
|
||||
if (await asyncTest('filters TypeScript errors to edited file when tsc reports errors', async () => {
|
||||
// post-edit-typecheck.js lines 60-85: when execFileSync('npx', ['tsc', ...]) throws,
|
||||
// the catch block filters error output by file path candidates and logs relevant lines.
|
||||
// All existing tests either have no tsconfig (tsc never runs) or valid TS (tsc succeeds).
|
||||
// This test creates a .ts file with a type error and a tsconfig.json.
|
||||
const testDir = createTestDir();
|
||||
fs.writeFileSync(path.join(testDir, 'tsconfig.json'), JSON.stringify({
|
||||
compilerOptions: { strict: true, noEmit: true }
|
||||
}));
|
||||
const testFile = path.join(testDir, 'broken.ts');
|
||||
// Intentional type error: assigning string to number
|
||||
fs.writeFileSync(testFile, 'const x: number = "not a number";\n');
|
||||
|
||||
const stdinJson = JSON.stringify({ tool_input: { file_path: testFile } });
|
||||
const result = await runScript(path.join(scriptsDir, 'post-edit-typecheck.js'), stdinJson);
|
||||
|
||||
// Core: script must exit 0 and pass through stdin data regardless
|
||||
assert.strictEqual(result.code, 0, 'Should exit 0 even when tsc finds errors');
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
assert.strictEqual(parsed.tool_input.file_path, testFile,
|
||||
'Should pass through original stdin data with file_path intact');
|
||||
|
||||
// If tsc is available and ran, check that error output is filtered to this file
|
||||
if (result.stderr.includes('TypeScript errors in')) {
|
||||
assert.ok(result.stderr.includes('broken.ts'),
|
||||
`Should reference the edited file basename. Got: ${result.stderr}`);
|
||||
}
|
||||
// Either way, no crash and data passes through (verified above)
|
||||
cleanupTestDir(testDir);
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Round 89: extractSessionSummary entry.name + entry.input fallback paths ──
|
||||
console.log('\nRound 89: session-end.js (entry.name + entry.input fallback in extractSessionSummary):');
|
||||
|
||||
if (await asyncTest('extracts tool name from entry.name and file path from entry.input (fallback format)', async () => {
|
||||
// session-end.js line 63: const toolName = entry.tool_name || entry.name || '';
|
||||
// session-end.js line 66: const filePath = entry.tool_input?.file_path || entry.input?.file_path || '';
|
||||
// All existing tests use tool_name + tool_input format. This tests the name + input fallback.
|
||||
const testDir = createTestDir();
|
||||
const transcriptPath = path.join(testDir, 'transcript.jsonl');
|
||||
|
||||
const lines = [
|
||||
'{"type":"user","content":"Fix the auth module"}',
|
||||
// Tool entries using "name" + "input" instead of "tool_name" + "tool_input"
|
||||
'{"type":"tool_use","name":"Edit","input":{"file_path":"/src/auth.ts"}}',
|
||||
'{"type":"tool_use","name":"Write","input":{"file_path":"/src/new-helper.ts"}}',
|
||||
// Also include a tool with tool_name but entry.input (mixed format)
|
||||
'{"tool_name":"Read","input":{"file_path":"/src/config.ts"}}',
|
||||
];
|
||||
fs.writeFileSync(transcriptPath, lines.join('\n'));
|
||||
|
||||
const stdinJson = JSON.stringify({ transcript_path: transcriptPath });
|
||||
const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson, {
|
||||
HOME: testDir
|
||||
});
|
||||
assert.strictEqual(result.code, 0, 'Should exit 0');
|
||||
|
||||
// Read the session file to verify tool names and file paths were extracted
|
||||
const claudeDir = path.join(testDir, '.claude', 'sessions');
|
||||
if (fs.existsSync(claudeDir)) {
|
||||
const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.tmp'));
|
||||
if (files.length > 0) {
|
||||
const content = fs.readFileSync(path.join(claudeDir, files[0]), 'utf8');
|
||||
// Tools from entry.name fallback
|
||||
assert.ok(content.includes('Edit'),
|
||||
`Should extract Edit tool from entry.name fallback. Got: ${content}`);
|
||||
assert.ok(content.includes('Write'),
|
||||
`Should extract Write tool from entry.name fallback. Got: ${content}`);
|
||||
// File paths from entry.input fallback
|
||||
assert.ok(content.includes('/src/auth.ts'),
|
||||
`Should extract file path from entry.input.file_path fallback. Got: ${content}`);
|
||||
assert.ok(content.includes('/src/new-helper.ts'),
|
||||
`Should extract Write file from entry.input.file_path fallback. Got: ${content}`);
|
||||
}
|
||||
}
|
||||
cleanupTestDir(testDir);
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Round 90: readStdinJson timeout path (utils.js lines 215-229) ──
|
||||
console.log('\nRound 90: readStdinJson (timeout fires when stdin stays open):');
|
||||
|
||||
if (await asyncTest('readStdinJson resolves with {} when stdin never closes (timeout fires, no data)', async () => {
|
||||
// utils.js line 215: setTimeout fires because stdin 'end' never arrives.
|
||||
// Line 225: data.trim() is empty → resolves with {}.
|
||||
// Exercises: removeAllListeners, process.stdin.unref(), and the empty-data timeout resolution.
|
||||
const script = 'const u=require("./scripts/lib/utils");u.readStdinJson({timeoutMs:100}).then(d=>{process.stdout.write(JSON.stringify(d));process.exit(0)})';
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn('node', ['-e', script], {
|
||||
cwd: path.resolve(__dirname, '..', '..'),
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
// Don't write anything or close stdin — force the timeout to fire
|
||||
let stdout = '';
|
||||
child.stdout.on('data', d => stdout += d);
|
||||
const timer = setTimeout(() => { child.kill(); reject(new Error('Test timed out')); }, 5000);
|
||||
child.on('close', (code) => {
|
||||
clearTimeout(timer);
|
||||
try {
|
||||
assert.strictEqual(code, 0, 'Should exit 0 via timeout resolution');
|
||||
const parsed = JSON.parse(stdout);
|
||||
assert.deepStrictEqual(parsed, {}, 'Should resolve with {} when no data received before timeout');
|
||||
resolve();
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await asyncTest('readStdinJson resolves with {} when timeout fires with invalid partial JSON', async () => {
|
||||
// utils.js lines 224-228: setTimeout fires, data.trim() is non-empty,
|
||||
// JSON.parse(data) throws → catch at line 226 resolves with {}.
|
||||
const script = 'const u=require("./scripts/lib/utils");u.readStdinJson({timeoutMs:100}).then(d=>{process.stdout.write(JSON.stringify(d));process.exit(0)})';
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn('node', ['-e', script], {
|
||||
cwd: path.resolve(__dirname, '..', '..'),
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
// Write partial invalid JSON but don't close stdin — timeout fires with unparseable data
|
||||
child.stdin.write('{"incomplete":');
|
||||
let stdout = '';
|
||||
child.stdout.on('data', d => stdout += d);
|
||||
const timer = setTimeout(() => { child.kill(); reject(new Error('Test timed out')); }, 5000);
|
||||
child.on('close', (code) => {
|
||||
clearTimeout(timer);
|
||||
try {
|
||||
assert.strictEqual(code, 0, 'Should exit 0 via timeout resolution');
|
||||
const parsed = JSON.parse(stdout);
|
||||
assert.deepStrictEqual(parsed, {}, 'Should resolve with {} when partial JSON cannot be parsed');
|
||||
resolve();
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Round 94: session-end.js tools used but no files modified ──
|
||||
console.log('\nRound 94: session-end.js (tools used without files modified):');
|
||||
|
||||
if (await asyncTest('session file includes Tools Used but omits Files Modified when only Read/Grep used', async () => {
|
||||
// session-end.js buildSummarySection (lines 217-228):
|
||||
// filesModified.length > 0 → include "### Files Modified" section
|
||||
// toolsUsed.length > 0 → include "### Tools Used" section
|
||||
// Previously tested: BOTH present (Round ~10) and NEITHER present (Round ~10).
|
||||
// Untested combination: toolsUsed present, filesModified empty.
|
||||
// Transcript with Read/Grep tools (don't add to filesModified) and user messages.
|
||||
const testDir = createTestDir();
|
||||
const transcriptPath = path.join(testDir, 'transcript.jsonl');
|
||||
|
||||
const lines = [
|
||||
'{"type":"user","content":"Search the codebase for auth handlers"}',
|
||||
'{"type":"tool_use","tool_name":"Read","tool_input":{"file_path":"/src/auth.ts"}}',
|
||||
'{"type":"tool_use","tool_name":"Grep","tool_input":{"pattern":"handler"}}',
|
||||
'{"type":"user","content":"Check the test file too"}',
|
||||
'{"type":"tool_use","tool_name":"Read","tool_input":{"file_path":"/tests/auth.test.ts"}}',
|
||||
];
|
||||
fs.writeFileSync(transcriptPath, lines.join('\n'));
|
||||
|
||||
const stdinJson = JSON.stringify({ transcript_path: transcriptPath });
|
||||
const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson, {
|
||||
HOME: testDir
|
||||
});
|
||||
assert.strictEqual(result.code, 0, 'Should exit 0');
|
||||
|
||||
const claudeDir = path.join(testDir, '.claude', 'sessions');
|
||||
if (fs.existsSync(claudeDir)) {
|
||||
const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.tmp'));
|
||||
if (files.length > 0) {
|
||||
const content = fs.readFileSync(path.join(claudeDir, files[0]), 'utf8');
|
||||
assert.ok(content.includes('### Tools Used'), 'Should include Tools Used section');
|
||||
assert.ok(content.includes('Read'), 'Should list Read tool');
|
||||
assert.ok(content.includes('Grep'), 'Should list Grep tool');
|
||||
assert.ok(!content.includes('### Files Modified'),
|
||||
'Should NOT include Files Modified section (Read/Grep do not modify files)');
|
||||
}
|
||||
}
|
||||
cleanupTestDir(testDir);
|
||||
})) passed++; else failed++;
|
||||
|
||||
// Summary
|
||||
console.log('\n=== Test Results ===');
|
||||
console.log(`Passed: ${passed}`);
|
||||
|
||||
@@ -1363,6 +1363,206 @@ function runTests() {
|
||||
cleanupTestDir(testDir);
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Round 91: getCommandPattern with empty action string ──
|
||||
console.log('\nRound 91: getCommandPattern (empty action):');
|
||||
|
||||
if (test('getCommandPattern with empty string returns valid regex pattern', () => {
|
||||
// package-manager.js line 401-409: Empty action falls to the else branch.
|
||||
// escapeRegex('') returns '', producing patterns like 'npm run ', 'yarn '.
|
||||
// The resulting combined regex should be compilable (not throw).
|
||||
const pattern = pm.getCommandPattern('');
|
||||
assert.ok(typeof pattern === 'string', 'Should return a string');
|
||||
assert.ok(pattern.length > 0, 'Should return non-empty pattern');
|
||||
// Verify the pattern compiles without error
|
||||
const regex = new RegExp(pattern);
|
||||
assert.ok(regex instanceof RegExp, 'Pattern should compile to valid RegExp');
|
||||
// The pattern should match package manager commands with trailing space
|
||||
assert.ok(regex.test('npm run '), 'Should match "npm run " with trailing space');
|
||||
assert.ok(regex.test('yarn '), 'Should match "yarn " with trailing space');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Round 91: detectFromPackageJson with whitespace-only packageManager ──
|
||||
console.log('\nRound 91: detectFromPackageJson (whitespace-only packageManager):');
|
||||
|
||||
if (test('detectFromPackageJson returns null for whitespace-only packageManager field', () => {
|
||||
// package-manager.js line 114-119: " " is truthy, so enters the if block.
|
||||
// " ".split('@')[0] = " " which doesn't match any PACKAGE_MANAGERS key.
|
||||
const testDir = createTestDir();
|
||||
fs.writeFileSync(
|
||||
path.join(testDir, 'package.json'),
|
||||
JSON.stringify({ packageManager: ' ' }));
|
||||
const result = pm.detectFromPackageJson(testDir);
|
||||
assert.strictEqual(result, null, 'Whitespace-only packageManager should return null');
|
||||
cleanupTestDir(testDir);
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Round 92: detectFromPackageJson with empty string packageManager ──
|
||||
console.log('\nRound 92: detectFromPackageJson (empty string packageManager):');
|
||||
|
||||
if (test('detectFromPackageJson returns null for empty string packageManager field', () => {
|
||||
// package-manager.js line 114: if (pkg.packageManager) — empty string "" is falsy,
|
||||
// so the if block is skipped entirely. Function returns null without attempting split.
|
||||
// This is distinct from Round 91's whitespace test (" " is truthy and enters the if).
|
||||
const testDir = createTestDir();
|
||||
fs.writeFileSync(
|
||||
path.join(testDir, 'package.json'),
|
||||
JSON.stringify({ name: 'test', packageManager: '' }));
|
||||
const result = pm.detectFromPackageJson(testDir);
|
||||
assert.strictEqual(result, null, 'Empty string packageManager should return null (falsy)');
|
||||
cleanupTestDir(testDir);
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Round 94: detectFromPackageJson with scoped package name ──
|
||||
console.log('\nRound 94: detectFromPackageJson (scoped package name @scope/pkg@version):');
|
||||
|
||||
if (test('detectFromPackageJson returns null for scoped package name (@scope/pkg@version)', () => {
|
||||
// package-manager.js line 116: pmName = pkg.packageManager.split('@')[0]
|
||||
// For "@pnpm/exe@8.0.0", split('@') → ['', 'pnpm/exe', '8.0.0'], so [0] = ''
|
||||
// PACKAGE_MANAGERS[''] is undefined → returns null.
|
||||
// Scoped npm packages like @pnpm/exe are a real-world pattern but the
|
||||
// packageManager field spec uses unscoped names (e.g., "pnpm@8"), so returning
|
||||
// null is the correct defensive behaviour for this edge case.
|
||||
const testDir = createTestDir();
|
||||
fs.writeFileSync(
|
||||
path.join(testDir, 'package.json'),
|
||||
JSON.stringify({ name: 'test', packageManager: '@pnpm/exe@8.0.0' }));
|
||||
const result = pm.detectFromPackageJson(testDir);
|
||||
assert.strictEqual(result, null,
|
||||
'Scoped package name should return null (split("@")[0] is empty string)');
|
||||
cleanupTestDir(testDir);
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Round 94: getPackageManager with empty string CLAUDE_PACKAGE_MANAGER ──
|
||||
console.log('\nRound 94: getPackageManager (empty string CLAUDE_PACKAGE_MANAGER env var):');
|
||||
|
||||
if (test('getPackageManager skips empty string CLAUDE_PACKAGE_MANAGER (falsy short-circuit)', () => {
|
||||
// package-manager.js line 168: if (envPm && PACKAGE_MANAGERS[envPm])
|
||||
// Empty string '' is falsy — the && short-circuits before checking PACKAGE_MANAGERS.
|
||||
// This is distinct from the 'totally-fake-pm' test (truthy but unknown PM).
|
||||
const originalEnv = process.env.CLAUDE_PACKAGE_MANAGER;
|
||||
try {
|
||||
process.env.CLAUDE_PACKAGE_MANAGER = '';
|
||||
const result = pm.getPackageManager();
|
||||
assert.notStrictEqual(result.source, 'environment',
|
||||
'Empty string env var should NOT be treated as environment source');
|
||||
assert.ok(result.name, 'Should still return a valid package manager name');
|
||||
} finally {
|
||||
if (originalEnv !== undefined) {
|
||||
process.env.CLAUDE_PACKAGE_MANAGER = originalEnv;
|
||||
} else {
|
||||
delete process.env.CLAUDE_PACKAGE_MANAGER;
|
||||
}
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Round 104: detectFromLockFile with null projectDir (no input validation) ──
|
||||
console.log('\nRound 104: detectFromLockFile (null projectDir — throws TypeError):');
|
||||
if (test('detectFromLockFile(null) throws TypeError (path.join rejects null)', () => {
|
||||
// package-manager.js line 95: `path.join(projectDir, pm.lockFile)` — there is no
|
||||
// guard checking that projectDir is a string before passing it to path.join().
|
||||
// When projectDir is null, path.join(null, 'package-lock.json') throws a TypeError
|
||||
// because path.join only accepts string arguments.
|
||||
assert.throws(
|
||||
() => pm.detectFromLockFile(null),
|
||||
{ name: 'TypeError' },
|
||||
'path.join(null, ...) should throw TypeError (no input validation in detectFromLockFile)'
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Round 105: getExecCommand with object args (bypasses SAFE_ARGS_REGEX, coerced to [object Object]) ──
|
||||
console.log('\nRound 105: getExecCommand (object args — typeof bypass coerces to [object Object]):');
|
||||
|
||||
if (test('getExecCommand with args={} bypasses SAFE_ARGS validation and coerces to "[object Object]"', () => {
|
||||
// package-manager.js line 334: `if (args && typeof args === 'string' && !SAFE_ARGS_REGEX.test(args))`
|
||||
// When args is an object: typeof {} === 'object' (not 'string'), so the
|
||||
// SAFE_ARGS_REGEX check is entirely SKIPPED.
|
||||
// Line 339: `args ? ' ' + args : ''` — object is truthy, so it reaches
|
||||
// string concatenation which calls {}.toString() → "[object Object]"
|
||||
// Final command: "npx prettier [object Object]" — brackets bypass validation.
|
||||
const cmd = pm.getExecCommand('prettier', {});
|
||||
assert.ok(cmd.includes('[object Object]'),
|
||||
'Object args should be coerced to "[object Object]" via implicit toString()');
|
||||
// Verify the SAFE_ARGS regex WOULD reject this string if it were a string arg
|
||||
assert.throws(
|
||||
() => pm.getExecCommand('prettier', '[object Object]'),
|
||||
/unsafe characters/,
|
||||
'Same string as explicit string arg is correctly rejected by SAFE_ARGS_REGEX');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Round 109: getExecCommand with ../ path traversal in binary — SAFE_NAME_REGEX allows it ──
|
||||
console.log('\nRound 109: getExecCommand (path traversal in binary — SAFE_NAME_REGEX permits ../ in binary name):');
|
||||
if (test('getExecCommand accepts ../../../etc/passwd as binary because SAFE_NAME_REGEX allows ../', () => {
|
||||
const originalEnv = process.env.CLAUDE_PACKAGE_MANAGER;
|
||||
try {
|
||||
process.env.CLAUDE_PACKAGE_MANAGER = 'npm';
|
||||
// SAFE_NAME_REGEX = /^[@a-zA-Z0-9_.\/-]+$/ individually allows . and /
|
||||
const cmd = pm.getExecCommand('../../../etc/passwd');
|
||||
assert.strictEqual(cmd, 'npx ../../../etc/passwd',
|
||||
'Path traversal in binary passes SAFE_NAME_REGEX because . and / are individually allowed');
|
||||
// Also verify scoped path traversal
|
||||
const cmd2 = pm.getExecCommand('@scope/../../evil');
|
||||
assert.strictEqual(cmd2, 'npx @scope/../../evil',
|
||||
'Scoped path traversal also passes the regex');
|
||||
} finally {
|
||||
if (originalEnv !== undefined) {
|
||||
process.env.CLAUDE_PACKAGE_MANAGER = originalEnv;
|
||||
} else {
|
||||
delete process.env.CLAUDE_PACKAGE_MANAGER;
|
||||
}
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Round 108: getRunCommand with path traversal — SAFE_NAME_REGEX allows ../ sequences ──
|
||||
console.log('\nRound 108: getRunCommand (path traversal — SAFE_NAME_REGEX permits ../ via allowed / and . chars):');
|
||||
if (test('getRunCommand accepts @scope/../../evil because SAFE_NAME_REGEX allows ../', () => {
|
||||
const originalEnv = process.env.CLAUDE_PACKAGE_MANAGER;
|
||||
try {
|
||||
process.env.CLAUDE_PACKAGE_MANAGER = 'npm';
|
||||
// SAFE_NAME_REGEX = /^[@a-zA-Z0-9_.\/-]+$/ allows each char individually,
|
||||
// so '../' passes despite being a path traversal sequence
|
||||
const cmd = pm.getRunCommand('@scope/../../evil');
|
||||
assert.strictEqual(cmd, 'npm run @scope/../../evil',
|
||||
'Path traversal passes SAFE_NAME_REGEX because / and . are individually allowed');
|
||||
// Also verify plain ../ passes
|
||||
const cmd2 = pm.getRunCommand('../../../etc/passwd');
|
||||
assert.strictEqual(cmd2, 'npm run ../../../etc/passwd',
|
||||
'Bare ../ traversal also passes the regex');
|
||||
} finally {
|
||||
if (originalEnv !== undefined) {
|
||||
process.env.CLAUDE_PACKAGE_MANAGER = originalEnv;
|
||||
} else {
|
||||
delete process.env.CLAUDE_PACKAGE_MANAGER;
|
||||
}
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Round 111: getExecCommand with newline in args — SAFE_ARGS_REGEX \s includes \n ──
|
||||
console.log('\nRound 111: getExecCommand (newline in args — SAFE_ARGS_REGEX \\s matches \\n):');
|
||||
if (test('getExecCommand accepts newline in args because SAFE_ARGS_REGEX \\s includes \\n', () => {
|
||||
// SAFE_ARGS_REGEX = /^[@a-zA-Z0-9\s_.\/:=,'"*+-]+$/
|
||||
// \s matches [\t\n\v\f\r ] — includes newline!
|
||||
// This means "file.js\nmalicious" passes the regex.
|
||||
const originalEnv = process.env.CLAUDE_PACKAGE_MANAGER;
|
||||
try {
|
||||
process.env.CLAUDE_PACKAGE_MANAGER = 'npm';
|
||||
// Newline in args should pass SAFE_ARGS_REGEX because \s matches \n
|
||||
const cmd = pm.getExecCommand('prettier', 'file.js\necho injected');
|
||||
assert.strictEqual(cmd, 'npx prettier file.js\necho injected',
|
||||
'Newline passes SAFE_ARGS_REGEX (\\s includes \\n) — potential command injection vector');
|
||||
// Tab also passes
|
||||
const cmd2 = pm.getExecCommand('eslint', 'file.js\t--fix');
|
||||
assert.strictEqual(cmd2, 'npx eslint file.js\t--fix',
|
||||
'Tab also passes SAFE_ARGS_REGEX via \\s');
|
||||
// Carriage return also passes
|
||||
const cmd3 = pm.getExecCommand('tsc', 'src\r--strict');
|
||||
assert.strictEqual(cmd3, 'npx tsc src\r--strict',
|
||||
'Carriage return passes via \\s');
|
||||
} finally {
|
||||
if (originalEnv !== undefined) process.env.CLAUDE_PACKAGE_MANAGER = originalEnv;
|
||||
else delete process.env.CLAUDE_PACKAGE_MANAGER;
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
// Summary
|
||||
console.log('\n=== Test Results ===');
|
||||
console.log(`Passed: ${passed}`);
|
||||
|
||||
@@ -1223,6 +1223,605 @@ function runTests() {
|
||||
resetAliases();
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Round 90: saveAliases backup restore double failure (inner catch restoreErr) ──
|
||||
console.log('\nRound 90: saveAliases (backup restore double failure):');
|
||||
|
||||
if (test('saveAliases triggers inner restoreErr catch when both save and restore fail', () => {
|
||||
// session-aliases.js lines 131-137: When saveAliases fails (outer catch),
|
||||
// it tries to restore from backup. If the restore ALSO fails, the inner
|
||||
// catch at line 135 logs restoreErr. No existing test creates this double-fault.
|
||||
if (process.platform === 'win32') {
|
||||
console.log(' (skipped — chmod not reliable on Windows)');
|
||||
return;
|
||||
}
|
||||
const isoHome = path.join(os.tmpdir(), `ecc-r90-restore-fail-${Date.now()}`);
|
||||
const claudeDir = path.join(isoHome, '.claude');
|
||||
fs.mkdirSync(claudeDir, { recursive: true });
|
||||
|
||||
// Pre-create a backup file while directory is still writable
|
||||
const backupPath = path.join(claudeDir, 'session-aliases.json.bak');
|
||||
fs.writeFileSync(backupPath, JSON.stringify({ aliases: {}, version: '1.0' }));
|
||||
|
||||
// Make .claude directory read-only (0o555):
|
||||
// 1. writeFileSync(tempPath) → EACCES (can't create file in read-only dir) — outer catch
|
||||
// 2. copyFileSync(backupPath, aliasesPath) → EACCES (can't create target) — inner catch (line 135)
|
||||
fs.chmodSync(claudeDir, 0o555);
|
||||
|
||||
const origH = process.env.HOME;
|
||||
const origP = process.env.USERPROFILE;
|
||||
process.env.HOME = isoHome;
|
||||
process.env.USERPROFILE = isoHome;
|
||||
|
||||
try {
|
||||
delete require.cache[require.resolve('../../scripts/lib/session-aliases')];
|
||||
delete require.cache[require.resolve('../../scripts/lib/utils')];
|
||||
const freshAliases = require('../../scripts/lib/session-aliases');
|
||||
|
||||
const result = freshAliases.saveAliases({ aliases: { x: 1 }, version: '1.0' });
|
||||
assert.strictEqual(result, false, 'Should return false when save fails');
|
||||
|
||||
// Backup should still exist (restore also failed, so backup was not consumed)
|
||||
assert.ok(fs.existsSync(backupPath), 'Backup should still exist after double failure');
|
||||
} finally {
|
||||
process.env.HOME = origH;
|
||||
process.env.USERPROFILE = origP;
|
||||
delete require.cache[require.resolve('../../scripts/lib/session-aliases')];
|
||||
delete require.cache[require.resolve('../../scripts/lib/utils')];
|
||||
try { fs.chmodSync(claudeDir, 0o755); } catch { /* best-effort */ }
|
||||
fs.rmSync(isoHome, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Round 95: renameAlias with same old and new name (self-rename) ──
|
||||
console.log('\nRound 95: renameAlias (self-rename same name):');
|
||||
|
||||
if (test('renameAlias returns "already exists" error when renaming alias to itself', () => {
|
||||
resetAliases();
|
||||
// Create an alias first
|
||||
const created = aliases.setAlias('self-rename', '/path/session', 'Self Rename');
|
||||
assert.strictEqual(created.success, true, 'Setup: alias should be created');
|
||||
|
||||
// Attempt to rename to the same name
|
||||
const result = aliases.renameAlias('self-rename', 'self-rename');
|
||||
assert.strictEqual(result.success, false, 'Renaming to itself should fail');
|
||||
assert.ok(result.error.includes('already exists'),
|
||||
'Error should indicate alias already exists (line 333-334 check)');
|
||||
|
||||
// Verify original alias is still intact
|
||||
const resolved = aliases.resolveAlias('self-rename');
|
||||
assert.ok(resolved, 'Original alias should still exist after failed self-rename');
|
||||
assert.strictEqual(resolved.sessionPath, '/path/session',
|
||||
'Alias data should be preserved');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Round 100: cleanupAliases callback returning falsy non-boolean 0 ──
|
||||
console.log('\nRound 100: cleanupAliases (callback returns 0 — falsy non-boolean coercion):');
|
||||
if (test('cleanupAliases removes alias when callback returns 0 (falsy coercion: !0 === true)', () => {
|
||||
resetAliases();
|
||||
aliases.setAlias('zero-test', '/sessions/some-session', '2026-01-15');
|
||||
// callback returns 0 (a falsy value) — !0 === true → alias is removed
|
||||
const result = aliases.cleanupAliases(() => 0);
|
||||
assert.strictEqual(result.removed, 1,
|
||||
'Alias should be removed because !0 === true (JavaScript falsy coercion)');
|
||||
assert.strictEqual(result.success, true,
|
||||
'Cleanup should succeed');
|
||||
const resolved = aliases.resolveAlias('zero-test');
|
||||
assert.strictEqual(resolved, null,
|
||||
'Alias should no longer exist after removal');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Round 102: setAlias with title=0 (falsy number coercion) ──
|
||||
console.log('\nRound 102: setAlias (title=0 — falsy coercion silently converts to null):');
|
||||
if (test('setAlias with title=0 stores null (0 || null === null due to JavaScript falsy coercion)', () => {
|
||||
// session-aliases.js line 221: `title: title || null` — the value 0 is falsy
|
||||
// in JavaScript, so `0 || null` evaluates to `null`. This means numeric
|
||||
// titles like 0 are silently discarded.
|
||||
resetAliases();
|
||||
const result = aliases.setAlias('zero-title', '/sessions/test', 0);
|
||||
assert.strictEqual(result.success, true,
|
||||
'setAlias should succeed (0 is valid as a truthy check bypass)');
|
||||
assert.strictEqual(result.title, null,
|
||||
'Title should be null because 0 || null === null (falsy coercion)');
|
||||
const resolved = aliases.resolveAlias('zero-title');
|
||||
assert.strictEqual(resolved.title, null,
|
||||
'Persisted title should be null after round-trip through saveAliases/loadAliases');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Round 103: loadAliases with array aliases in JSON (typeof [] === 'object' bypass) ──
|
||||
console.log('\nRound 103: loadAliases (array aliases — typeof bypass):');
|
||||
if (test('loadAliases accepts array aliases because typeof [] === "object" passes validation', () => {
|
||||
// session-aliases.js line 58: `typeof data.aliases !== 'object'` is the guard.
|
||||
// Arrays are typeof 'object' in JavaScript, so {"aliases": [1,2,3]} passes
|
||||
// validation. The returned data.aliases is an array, not a plain object.
|
||||
// Downstream code (Object.keys, Object.entries, bracket access) behaves
|
||||
// differently on arrays vs objects but doesn't crash — it just produces
|
||||
// unexpected results like numeric string keys "0", "1", "2".
|
||||
resetAliases();
|
||||
const aliasesPath = aliases.getAliasesPath();
|
||||
fs.writeFileSync(aliasesPath, JSON.stringify({
|
||||
version: '1.0',
|
||||
aliases: ['item0', 'item1', 'item2'],
|
||||
metadata: { totalCount: 3, lastUpdated: new Date().toISOString() }
|
||||
}));
|
||||
const data = aliases.loadAliases();
|
||||
// The array passes the typeof 'object' check and is returned as-is
|
||||
assert.ok(Array.isArray(data.aliases),
|
||||
'data.aliases should be an array (typeof [] === "object" bypasses guard)');
|
||||
assert.strictEqual(data.aliases.length, 3,
|
||||
'Array should have 3 elements');
|
||||
// Object.keys on an array returns ["0", "1", "2"] — numeric index strings
|
||||
const keys = Object.keys(data.aliases);
|
||||
assert.deepStrictEqual(keys, ['0', '1', '2'],
|
||||
'Object.keys of array returns numeric string indices, not named alias keys');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Round 104: resolveSessionAlias with path-traversal input (passthrough without validation) ──
|
||||
console.log('\nRound 104: resolveSessionAlias (path-traversal input — returned unchanged):');
|
||||
if (test('resolveSessionAlias returns path-traversal input as-is when alias lookup fails', () => {
|
||||
// session-aliases.js lines 365-374: resolveSessionAlias first tries resolveAlias(),
|
||||
// which rejects '../etc/passwd' because the regex /^[a-zA-Z0-9_-]+$/ fails on dots
|
||||
// and slashes (returns null). Then the function falls through to line 373:
|
||||
// `return aliasOrId` — returning the potentially dangerous input unchanged.
|
||||
// Callers that blindly use this return value could be at risk.
|
||||
resetAliases();
|
||||
const traversal = '../etc/passwd';
|
||||
const result = aliases.resolveSessionAlias(traversal);
|
||||
assert.strictEqual(result, traversal,
|
||||
'Path-traversal input should be returned as-is (resolveAlias rejects it, fallback returns input)');
|
||||
// Also test with another invalid alias pattern
|
||||
const dotSlash = './../../secrets';
|
||||
const result2 = aliases.resolveSessionAlias(dotSlash);
|
||||
assert.strictEqual(result2, dotSlash,
|
||||
'Another path-traversal pattern also returned unchanged');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Round 107: setAlias with whitespace-only title (not trimmed unlike sessionPath) ──
|
||||
console.log('\nRound 107: setAlias (whitespace-only title — truthy string stored as-is, unlike sessionPath which is trim-checked):');
|
||||
if (test('setAlias stores whitespace-only title as-is (no trim validation, unlike sessionPath)', () => {
|
||||
resetAliases();
|
||||
// sessionPath with whitespace is rejected (line 195: sessionPath.trim().length === 0)
|
||||
const pathResult = aliases.setAlias('ws-path', ' ');
|
||||
assert.strictEqual(pathResult.success, false,
|
||||
'Whitespace-only sessionPath is rejected by trim check');
|
||||
// But title with whitespace is stored as-is (line 221: title || null — whitespace is truthy)
|
||||
const titleResult = aliases.setAlias('ws-title', '/valid/path', ' ');
|
||||
assert.strictEqual(titleResult.success, true,
|
||||
'Whitespace-only title is accepted (no trim check on title)');
|
||||
assert.strictEqual(titleResult.title, ' ',
|
||||
'Title stored as whitespace string (truthy, so title || null returns the whitespace)');
|
||||
// Verify persisted correctly
|
||||
const loaded = aliases.loadAliases();
|
||||
assert.strictEqual(loaded.aliases['ws-title'].title, ' ',
|
||||
'Whitespace title persists in JSON as-is');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Round 111: setAlias with exactly 128-character alias — off-by-one boundary ──
|
||||
console.log('\nRound 111: setAlias (128-char alias — exact boundary of > 128 check):');
|
||||
if (test('setAlias accepts alias of exactly 128 characters (128 is NOT > 128)', () => {
|
||||
// session-aliases.js line 199: if (alias.length > 128)
|
||||
// 128 is NOT > 128, so exactly 128 chars is ACCEPTED.
|
||||
// Existing test only checks 129 (rejected).
|
||||
resetAliases();
|
||||
const alias128 = 'a'.repeat(128);
|
||||
const result = aliases.setAlias(alias128, '/path/to/session');
|
||||
assert.strictEqual(result.success, true,
|
||||
'128-char alias should be accepted (128 is NOT > 128)');
|
||||
assert.strictEqual(result.isNew, true);
|
||||
// Verify it can be resolved
|
||||
const resolved = aliases.resolveAlias(alias128);
|
||||
assert.notStrictEqual(resolved, null, '128-char alias should be resolvable');
|
||||
assert.strictEqual(resolved.sessionPath, '/path/to/session');
|
||||
// Confirm 129 is rejected (boundary)
|
||||
const result129 = aliases.setAlias('b'.repeat(129), '/path');
|
||||
assert.strictEqual(result129.success, false, '129-char alias should be rejected');
|
||||
assert.ok(result129.error.includes('128'),
|
||||
'Error message should mention 128-char limit');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Round 112: resolveAlias rejects Unicode characters in alias name ──
|
||||
console.log('\nRound 112: resolveAlias (Unicode rejection):');
|
||||
if (test('resolveAlias returns null for alias names containing Unicode characters', () => {
|
||||
resetAliases();
|
||||
// First create a valid alias to ensure the store works
|
||||
aliases.setAlias('valid-alias', '/path/to/session');
|
||||
const validResult = aliases.resolveAlias('valid-alias');
|
||||
assert.notStrictEqual(validResult, null, 'Valid ASCII alias should resolve');
|
||||
|
||||
// Unicode accented characters — rejected by /^[a-zA-Z0-9_-]+$/
|
||||
const accentedResult = aliases.resolveAlias('café-session');
|
||||
assert.strictEqual(accentedResult, null,
|
||||
'Accented character "é" should be rejected by [a-zA-Z0-9_-]');
|
||||
|
||||
const umlautResult = aliases.resolveAlias('über-test');
|
||||
assert.strictEqual(umlautResult, null,
|
||||
'Umlaut "ü" should be rejected by [a-zA-Z0-9_-]');
|
||||
|
||||
// CJK characters
|
||||
const cjkResult = aliases.resolveAlias('会議-notes');
|
||||
assert.strictEqual(cjkResult, null,
|
||||
'CJK characters should be rejected');
|
||||
|
||||
// Emoji
|
||||
const emojiResult = aliases.resolveAlias('rocket-🚀');
|
||||
assert.strictEqual(emojiResult, null,
|
||||
'Emoji should be rejected by the ASCII-only regex');
|
||||
|
||||
// Cyrillic characters that look like Latin (homoglyphs)
|
||||
const cyrillicResult = aliases.resolveAlias('tеst'); // 'е' is Cyrillic U+0435
|
||||
assert.strictEqual(cyrillicResult, null,
|
||||
'Cyrillic homoglyph "е" (U+0435) should be rejected even though it looks like "e"');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Round 114: listAliases with non-string search (number) — TypeError on toLowerCase ──
|
||||
console.log('\nRound 114: listAliases (non-string search — number triggers TypeError):');
|
||||
if (test('listAliases throws TypeError when search option is a number (no toLowerCase method)', () => {
|
||||
resetAliases();
|
||||
|
||||
// Set up some aliases to search through
|
||||
aliases.setAlias('alpha-session', '/path/to/alpha');
|
||||
aliases.setAlias('beta-session', '/path/to/beta');
|
||||
|
||||
// String search works fine — baseline
|
||||
const stringResult = aliases.listAliases({ search: 'alpha' });
|
||||
assert.strictEqual(stringResult.length, 1, 'String search should find 1 match');
|
||||
assert.strictEqual(stringResult[0].name, 'alpha-session');
|
||||
|
||||
// Numeric search — search.toLowerCase() at line 261 of session-aliases.js
|
||||
// throws TypeError because Number.prototype has no toLowerCase method.
|
||||
// The code does NOT guard against non-string search values.
|
||||
assert.throws(
|
||||
() => aliases.listAliases({ search: 123 }),
|
||||
(err) => err instanceof TypeError && /toLowerCase/.test(err.message),
|
||||
'Numeric search value should throw TypeError from toLowerCase call'
|
||||
);
|
||||
|
||||
// Boolean search — also lacks toLowerCase
|
||||
assert.throws(
|
||||
() => aliases.listAliases({ search: true }),
|
||||
(err) => err instanceof TypeError && /toLowerCase/.test(err.message),
|
||||
'Boolean search value should also throw TypeError'
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Round 115: updateAliasTitle with empty string — stored as null via || but returned as "" ──
|
||||
console.log('\nRound 115: updateAliasTitle (empty string title — stored null, returned ""):');
|
||||
if (test('updateAliasTitle with empty string stores null but returns empty string (|| coercion mismatch)', () => {
|
||||
resetAliases();
|
||||
|
||||
// Create alias with a title
|
||||
aliases.setAlias('r115-alias', '/path/to/session', 'Original Title');
|
||||
const before = aliases.resolveAlias('r115-alias');
|
||||
assert.strictEqual(before.title, 'Original Title', 'Baseline: title should be set');
|
||||
|
||||
// Update title with empty string
|
||||
// Line 383: typeof "" === 'string' → passes validation
|
||||
// Line 393: "" || null → null (empty string is falsy in JS)
|
||||
// Line 400: returns { title: "" } (original parameter, not stored value)
|
||||
const result = aliases.updateAliasTitle('r115-alias', '');
|
||||
assert.strictEqual(result.success, true, 'Should succeed (empty string passes validation)');
|
||||
assert.strictEqual(result.title, '', 'Return value reflects the input parameter (empty string)');
|
||||
|
||||
// But what's actually stored?
|
||||
const after = aliases.resolveAlias('r115-alias');
|
||||
assert.strictEqual(after.title, null,
|
||||
'Stored title should be null because "" || null evaluates to null');
|
||||
|
||||
// Contrast: non-empty string is stored as-is
|
||||
aliases.updateAliasTitle('r115-alias', 'New Title');
|
||||
const withTitle = aliases.resolveAlias('r115-alias');
|
||||
assert.strictEqual(withTitle.title, 'New Title', 'Non-empty string stored as-is');
|
||||
|
||||
// null explicitly clears title
|
||||
aliases.updateAliasTitle('r115-alias', null);
|
||||
const cleared = aliases.resolveAlias('r115-alias');
|
||||
assert.strictEqual(cleared.title, null, 'null clears title');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Round 116: loadAliases with extra unknown fields — silently preserved ──
|
||||
console.log('\nRound 116: loadAliases (extra unknown JSON fields — preserved by loose validation):');
|
||||
if (test('loadAliases preserves extra unknown fields because only aliases key is validated', () => {
|
||||
resetAliases();
|
||||
|
||||
// Manually write an aliases file with extra fields
|
||||
const aliasesPath = aliases.getAliasesPath();
|
||||
const customData = {
|
||||
version: '1.0',
|
||||
aliases: {
|
||||
'test-session': {
|
||||
sessionPath: '/path/to/session',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
title: 'Test'
|
||||
}
|
||||
},
|
||||
metadata: {
|
||||
totalCount: 1,
|
||||
lastUpdated: '2026-01-01T00:00:00.000Z'
|
||||
},
|
||||
customField: 'extra data',
|
||||
debugInfo: { level: 3, verbose: true },
|
||||
tags: ['important', 'test']
|
||||
};
|
||||
fs.writeFileSync(aliasesPath, JSON.stringify(customData, null, 2), 'utf8');
|
||||
|
||||
// loadAliases only validates data.aliases — extra fields pass through
|
||||
const loaded = aliases.loadAliases();
|
||||
assert.ok(loaded.aliases['test-session'], 'Should load the valid alias');
|
||||
assert.strictEqual(loaded.aliases['test-session'].title, 'Test');
|
||||
assert.strictEqual(loaded.customField, 'extra data',
|
||||
'Extra string field should be preserved');
|
||||
assert.deepStrictEqual(loaded.debugInfo, { level: 3, verbose: true },
|
||||
'Extra object field should be preserved');
|
||||
assert.deepStrictEqual(loaded.tags, ['important', 'test'],
|
||||
'Extra array field should be preserved');
|
||||
|
||||
// After saving, extra fields survive a round-trip (saveAliases only updates metadata)
|
||||
aliases.setAlias('new-alias', '/path/to/new');
|
||||
const reloaded = aliases.loadAliases();
|
||||
assert.ok(reloaded.aliases['new-alias'], 'New alias should be saved');
|
||||
assert.strictEqual(reloaded.customField, 'extra data',
|
||||
'Extra field should survive save/load round-trip');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Round 118: renameAlias to the same name — "already exists" because self-check ──
|
||||
console.log('\nRound 118: renameAlias (same name — "already exists" because data.aliases[newAlias] is truthy):');
|
||||
if (test('renameAlias to the same name returns "already exists" error (no self-rename short-circuit)', () => {
|
||||
resetAliases();
|
||||
aliases.setAlias('same-name', '/path/to/session');
|
||||
|
||||
// Rename 'same-name' → 'same-name'
|
||||
// Line 333: data.aliases[newAlias] → truthy (the alias exists under that name)
|
||||
// Returns error before checking if oldAlias === newAlias
|
||||
const result = aliases.renameAlias('same-name', 'same-name');
|
||||
assert.strictEqual(result.success, false, 'Should fail');
|
||||
assert.ok(result.error.includes('already exists'),
|
||||
'Error should say "already exists" (not "same name" or a no-op success)');
|
||||
|
||||
// Verify alias is unchanged
|
||||
const resolved = aliases.resolveAlias('same-name');
|
||||
assert.ok(resolved, 'Original alias should still exist');
|
||||
assert.strictEqual(resolved.sessionPath, '/path/to/session');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Round 118: setAlias reserved names — case-insensitive rejection ──
|
||||
console.log('\nRound 118: setAlias (reserved names — case-insensitive rejection):');
|
||||
if (test('setAlias rejects all reserved names case-insensitively (list, help, remove, delete, create, set)', () => {
|
||||
resetAliases();
|
||||
|
||||
// All reserved names in lowercase
|
||||
const reserved = ['list', 'help', 'remove', 'delete', 'create', 'set'];
|
||||
for (const name of reserved) {
|
||||
const result = aliases.setAlias(name, '/path/to/session');
|
||||
assert.strictEqual(result.success, false,
|
||||
`'${name}' should be rejected as reserved`);
|
||||
assert.ok(result.error.includes('reserved'),
|
||||
`Error for '${name}' should mention "reserved"`);
|
||||
}
|
||||
|
||||
// Case-insensitive: uppercase variants also rejected
|
||||
const upperResult = aliases.setAlias('LIST', '/path/to/session');
|
||||
assert.strictEqual(upperResult.success, false,
|
||||
'"LIST" (uppercase) should be rejected (toLowerCase check)');
|
||||
|
||||
const mixedResult = aliases.setAlias('Help', '/path/to/session');
|
||||
assert.strictEqual(mixedResult.success, false,
|
||||
'"Help" (mixed case) should be rejected');
|
||||
|
||||
const allCapsResult = aliases.setAlias('DELETE', '/path/to/session');
|
||||
assert.strictEqual(allCapsResult.success, false,
|
||||
'"DELETE" (all caps) should be rejected');
|
||||
|
||||
// Non-reserved names work fine
|
||||
const validResult = aliases.setAlias('my-session', '/path/to/session');
|
||||
assert.strictEqual(validResult.success, true,
|
||||
'Non-reserved name should succeed');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Round 119: renameAlias with reserved newAlias name — parallel reserved check ──
|
||||
console.log('\nRound 119: renameAlias (reserved newAlias name — parallel check to setAlias):');
|
||||
if (test('renameAlias rejects reserved names for newAlias (same reserved list as setAlias)', () => {
|
||||
resetAliases();
|
||||
aliases.setAlias('my-alias', '/path/to/session');
|
||||
|
||||
// Rename to reserved name 'list' — should fail
|
||||
const listResult = aliases.renameAlias('my-alias', 'list');
|
||||
assert.strictEqual(listResult.success, false, '"list" should be rejected');
|
||||
assert.ok(listResult.error.includes('reserved'),
|
||||
'Error should mention "reserved"');
|
||||
|
||||
// Rename to reserved name 'help' (uppercase) — should fail
|
||||
const helpResult = aliases.renameAlias('my-alias', 'Help');
|
||||
assert.strictEqual(helpResult.success, false, '"Help" should be rejected');
|
||||
|
||||
// Rename to reserved name 'delete' — should fail
|
||||
const deleteResult = aliases.renameAlias('my-alias', 'DELETE');
|
||||
assert.strictEqual(deleteResult.success, false, '"DELETE" should be rejected');
|
||||
|
||||
// Verify alias is unchanged
|
||||
const resolved = aliases.resolveAlias('my-alias');
|
||||
assert.ok(resolved, 'Original alias should still exist after failed renames');
|
||||
assert.strictEqual(resolved.sessionPath, '/path/to/session');
|
||||
|
||||
// Valid rename works
|
||||
const validResult = aliases.renameAlias('my-alias', 'new-valid-name');
|
||||
assert.strictEqual(validResult.success, true, 'Non-reserved name should succeed');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Round 120: setAlias max length boundary — 128 accepted, 129 rejected ──
|
||||
console.log('\nRound 120: setAlias (max alias length boundary — 128 ok, 129 rejected):');
|
||||
if (test('setAlias accepts exactly 128-char alias name but rejects 129 chars (> 128 boundary)', () => {
|
||||
resetAliases();
|
||||
|
||||
// 128 characters — exactly at limit (alias.length > 128 is false)
|
||||
const name128 = 'a'.repeat(128);
|
||||
const result128 = aliases.setAlias(name128, '/path/to/session');
|
||||
assert.strictEqual(result128.success, true,
|
||||
'128-char alias should be accepted (128 > 128 is false)');
|
||||
|
||||
// 129 characters — just over limit
|
||||
const name129 = 'a'.repeat(129);
|
||||
const result129 = aliases.setAlias(name129, '/path/to/session');
|
||||
assert.strictEqual(result129.success, false,
|
||||
'129-char alias should be rejected (129 > 128 is true)');
|
||||
assert.ok(result129.error.includes('128'),
|
||||
'Error should mention the 128 character limit');
|
||||
|
||||
// 1 character — minimum valid
|
||||
const name1 = 'x';
|
||||
const result1 = aliases.setAlias(name1, '/path/to/session');
|
||||
assert.strictEqual(result1.success, true,
|
||||
'Single character alias should be accepted');
|
||||
|
||||
// Verify the 128-char alias was actually stored
|
||||
const resolved = aliases.resolveAlias(name128);
|
||||
assert.ok(resolved, '128-char alias should be resolvable');
|
||||
assert.strictEqual(resolved.sessionPath, '/path/to/session');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Round 121: setAlias sessionPath validation — null, empty, whitespace, non-string ──
|
||||
console.log('\nRound 121: setAlias (sessionPath validation — null, empty, whitespace, non-string):');
|
||||
if (test('setAlias rejects invalid sessionPath: null, empty, whitespace-only, and non-string types', () => {
|
||||
resetAliases();
|
||||
|
||||
// null sessionPath → falsy → rejected
|
||||
const nullResult = aliases.setAlias('test-alias', null);
|
||||
assert.strictEqual(nullResult.success, false, 'null path should fail');
|
||||
assert.ok(nullResult.error.includes('empty'), 'Error should mention empty');
|
||||
|
||||
// undefined sessionPath → falsy → rejected
|
||||
const undefResult = aliases.setAlias('test-alias', undefined);
|
||||
assert.strictEqual(undefResult.success, false, 'undefined path should fail');
|
||||
|
||||
// empty string → falsy → rejected
|
||||
const emptyResult = aliases.setAlias('test-alias', '');
|
||||
assert.strictEqual(emptyResult.success, false, 'Empty string path should fail');
|
||||
|
||||
// whitespace-only → passes falsy check but trim().length === 0 → rejected
|
||||
const wsResult = aliases.setAlias('test-alias', ' ');
|
||||
assert.strictEqual(wsResult.success, false, 'Whitespace-only path should fail');
|
||||
|
||||
// number → typeof !== 'string' → rejected
|
||||
const numResult = aliases.setAlias('test-alias', 42);
|
||||
assert.strictEqual(numResult.success, false, 'Number path should fail');
|
||||
|
||||
// boolean → typeof !== 'string' → rejected
|
||||
const boolResult = aliases.setAlias('test-alias', true);
|
||||
assert.strictEqual(boolResult.success, false, 'Boolean path should fail');
|
||||
|
||||
// Valid path works
|
||||
const validResult = aliases.setAlias('test-alias', '/valid/path');
|
||||
assert.strictEqual(validResult.success, true, 'Valid string path should succeed');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Round 122: listAliases limit edge cases — limit=0, negative, NaN bypassed (JS falsy) ──
|
||||
console.log('\nRound 122: listAliases (limit edge cases — 0/negative/NaN are falsy, return all):');
|
||||
if (test('listAliases limit=0 returns all aliases because 0 is falsy in JS (no slicing)', () => {
|
||||
resetAliases();
|
||||
aliases.setAlias('alias-a', '/path/a');
|
||||
aliases.setAlias('alias-b', '/path/b');
|
||||
aliases.setAlias('alias-c', '/path/c');
|
||||
|
||||
// limit=0: 0 is falsy → `if (0 && 0 > 0)` short-circuits → no slicing → ALL returned
|
||||
const zeroResult = aliases.listAliases({ limit: 0 });
|
||||
assert.strictEqual(zeroResult.length, 3,
|
||||
'limit=0 should return ALL aliases (0 is falsy in JS)');
|
||||
|
||||
// limit=-1: -1 is truthy but -1 > 0 is false → no slicing → ALL returned
|
||||
const negResult = aliases.listAliases({ limit: -1 });
|
||||
assert.strictEqual(negResult.length, 3,
|
||||
'limit=-1 should return ALL aliases (-1 > 0 is false)');
|
||||
|
||||
// limit=NaN: NaN is falsy → no slicing → ALL returned
|
||||
const nanResult = aliases.listAliases({ limit: NaN });
|
||||
assert.strictEqual(nanResult.length, 3,
|
||||
'limit=NaN should return ALL aliases (NaN is falsy)');
|
||||
|
||||
// limit=1: normal case — returns exactly 1
|
||||
const oneResult = aliases.listAliases({ limit: 1 });
|
||||
assert.strictEqual(oneResult.length, 1,
|
||||
'limit=1 should return exactly 1 alias');
|
||||
|
||||
// limit=2: returns exactly 2
|
||||
const twoResult = aliases.listAliases({ limit: 2 });
|
||||
assert.strictEqual(twoResult.length, 2,
|
||||
'limit=2 should return exactly 2 aliases');
|
||||
|
||||
// limit=100 (more than total): returns all 3
|
||||
const bigResult = aliases.listAliases({ limit: 100 });
|
||||
assert.strictEqual(bigResult.length, 3,
|
||||
'limit > total should return all aliases');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Round 125: loadAliases with __proto__ key in JSON — no prototype pollution ──
|
||||
console.log('\nRound 125: loadAliases (__proto__ key in JSON — safe, no prototype pollution):');
|
||||
if (test('loadAliases with __proto__ alias key does not pollute Object prototype', () => {
|
||||
// JSON.parse('{"__proto__":...}') creates a normal property named "__proto__",
|
||||
// it does NOT modify Object.prototype. This is safe but worth documenting.
|
||||
// The alias would be accessible via data.aliases['__proto__'] and iterable
|
||||
// via Object.entries, but it won't affect other objects.
|
||||
resetAliases();
|
||||
|
||||
// Write raw JSON string with __proto__ as an alias name.
|
||||
// IMPORTANT: Cannot use JSON.stringify(obj) because {'__proto__':...} in JS
|
||||
// sets the prototype rather than creating an own property, so stringify drops it.
|
||||
// Must write the JSON string directly to simulate a maliciously crafted file.
|
||||
const aliasesPath = aliases.getAliasesPath();
|
||||
const now = new Date().toISOString();
|
||||
const rawJson = `{
|
||||
"version": "1.0.0",
|
||||
"aliases": {
|
||||
"__proto__": {
|
||||
"sessionPath": "/evil/path",
|
||||
"createdAt": "${now}",
|
||||
"title": "Prototype Pollution Attempt"
|
||||
},
|
||||
"normal": {
|
||||
"sessionPath": "/normal/path",
|
||||
"createdAt": "${now}",
|
||||
"title": "Normal Alias"
|
||||
}
|
||||
},
|
||||
"metadata": { "totalCount": 2, "lastUpdated": "${now}" }
|
||||
}`;
|
||||
fs.writeFileSync(aliasesPath, rawJson);
|
||||
|
||||
// Load aliases — should NOT pollute prototype
|
||||
const data = aliases.loadAliases();
|
||||
|
||||
// Verify __proto__ did NOT pollute Object.prototype
|
||||
const freshObj = {};
|
||||
assert.strictEqual(freshObj.sessionPath, undefined,
|
||||
'Object.prototype should NOT have sessionPath (no pollution)');
|
||||
assert.strictEqual(freshObj.title, undefined,
|
||||
'Object.prototype should NOT have title (no pollution)');
|
||||
|
||||
// The __proto__ key IS accessible as a normal property
|
||||
assert.ok(data.aliases['__proto__'],
|
||||
'__proto__ key exists as normal property in parsed aliases');
|
||||
assert.strictEqual(data.aliases['__proto__'].sessionPath, '/evil/path',
|
||||
'__proto__ alias data is accessible normally');
|
||||
|
||||
// Normal alias also works
|
||||
assert.ok(data.aliases['normal'],
|
||||
'Normal alias coexists with __proto__ key');
|
||||
|
||||
// resolveAlias with '__proto__' — rejected by regex (underscores ok but __ prefix works)
|
||||
// Actually ^[a-zA-Z0-9_-]+$ would ACCEPT '__proto__' since _ is allowed
|
||||
const resolved = aliases.resolveAlias('__proto__');
|
||||
// If the regex accepts it, it should find the alias
|
||||
if (resolved) {
|
||||
assert.strictEqual(resolved.sessionPath, '/evil/path',
|
||||
'resolveAlias can access __proto__ alias (regex allows underscores)');
|
||||
}
|
||||
|
||||
// Object.keys should enumerate __proto__ from JSON.parse
|
||||
const keys = Object.keys(data.aliases);
|
||||
assert.ok(keys.includes('__proto__'),
|
||||
'Object.keys includes __proto__ from JSON.parse (normal property)');
|
||||
assert.ok(keys.includes('normal'),
|
||||
'Object.keys includes normal alias');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// Summary
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user