Compare commits

...

38 Commits

Author SHA1 Message Date
Affaan Mustafa
4ff6831b2b Delete llms.txt
not necessary + opencode focused, open to a PR for a new one
2026-02-13 18:47:48 -08:00
Affaan Mustafa
182e9e78b9 test: add 3 edge-case tests for readFile binary, output() NaN/Infinity, loadAliases __proto__ safety
Round 125: Tests for readFile returning garbled strings (not null) on binary
files, output() handling undefined/NaN/Infinity as non-objects logged directly
(and JSON.stringify converting NaN/Infinity to null in objects), and loadAliases
with __proto__ key in JSON proving no prototype pollution occurs.
Total: 935 tests, all passing.
2026-02-13 18:44:07 -08:00
Affaan Mustafa
0250de793a test: add 3 edge-case tests for findFiles dotfiles, getAllSessions date format, parseSessionMetadata title regex
Round 124: Tests for findFiles matching dotfiles (unlike shell glob where *
excludes hidden files), getAllSessions strict date equality filter (wrong format
silently returns empty), and parseSessionMetadata title regex edge cases
(no space after #, ## heading, multiple H1, greedy \s+ crossing newlines).
Total: 932 tests, all passing.
2026-02-13 18:40:07 -08:00
Affaan Mustafa
88fa1bdbbc test: add 3 edge-case tests for countInFile overlapping, replaceInFile $& tokens, parseSessionMetadata CRLF
Round 123: Tests for countInFile non-overlapping regex match behavior (aaa with
/aa/g returns 1 not 2), replaceInFile with $& and $$ substitution tokens in
replacement strings, and parseSessionMetadata CRLF section boundary bleed where
\n\n fails to match \r\n\r\n. Total: 929 tests, all passing.
2026-02-13 18:36:09 -08:00
Affaan Mustafa
2753db3a48 test: add 3 edge-case tests for findFiles dot escaping, listAliases limit falsy values, getSessionById old format
Round 122: Tests for findFiles glob dot escaping (*.txt must not match filetxt),
listAliases limit=0/negative/NaN returning all due to JS falsy check, and
getSessionById matching old YYYY-MM-DD-session.tmp filenames via noIdMatch path.
Total: 926 tests, all passing.
2026-02-13 18:30:42 -08:00
Affaan Mustafa
e50b05384a test: add Round 121 tests for findFiles ? glob, setAlias path validation, and time metadata extraction
- findFiles: ? glob pattern matches single character only (converted to . regex)
- setAlias: rejects null, empty, whitespace-only, and non-string sessionPath values
- parseSessionMetadata: Started/Last Updated time extraction — present, missing, loose regex

Total tests: 923
2026-02-13 18:25:56 -08:00
Affaan Mustafa
26f3c88902 test: add Round 120 tests for replaceInFile empty search, setAlias length boundary, and notes extraction
- replaceInFile: empty string search — replace prepends at pos 0, replaceAll inserts between every char
- setAlias: 128-char alias accepted (boundary), 129-char rejected (> 128 check)
- parseSessionMetadata: "Notes for Next Session" extraction — last section, empty, ### boundary, markdown

Total tests: 920
2026-02-13 18:23:55 -08:00
Affaan Mustafa
df2d3a6d54 test: add Round 119 tests for appendFile type safety, renameAlias reserved names, and context extraction
- appendFile: null/undefined/number content throws TypeError (no try/catch like writeFile)
- renameAlias: reserved names rejected for newAlias (parallel check to setAlias, case-insensitive)
- parseSessionMetadata: "Context to Load" code block extraction — missing close, nested blocks, empty

Total tests: 917
2026-02-13 18:21:39 -08:00
Affaan Mustafa
25c5d58c44 test: add Round 118 edge-case tests for writeFile type safety, renameAlias self, and reserved alias names
- writeFile: null/undefined/number content throws TypeError (no try/catch unlike replaceInFile)
- renameAlias: same-name rename returns "already exists" (no self-rename short-circuit)
- setAlias: reserved names (list, help, remove, delete, create, set) rejected case-insensitively

Total tests: 914
2026-02-13 18:19:21 -08:00
Affaan Mustafa
06af1acb8d test: add Round 117 edge-case tests for grepFile CRLF, getSessionSize boundaries, and parseSessionFilename case
- grepFile: CRLF content leaves trailing \r on lines, breaking anchored $ patterns
- getSessionSize: boundary formatting at 0B, 1023B→"1023 B", 1024B→"1.0 KB", non-existent→"0 B"
- parseSessionFilename: [a-z0-9] regex rejects uppercase short IDs (case-sensitive design)

Total tests: 911
2026-02-13 18:16:10 -08:00
Affaan Mustafa
6a0b231d34 test: add Round 116 edge-case tests for replaceInFile null coercion, loadAliases extra fields, and ensureDir null path
- replaceInFile: null/undefined replacement coerced to string "null"/"undefined" by JS String.replace ToString
- loadAliases: extra unknown JSON fields silently preserved through load/save round-trip (loose validation)
- ensureDir: null/undefined path throws wrapped Error (ERR_INVALID_ARG_TYPE → re-thrown)

Total tests: 908
2026-02-13 18:11:58 -08:00
Affaan Mustafa
a563df2a52 test: add edge-case tests for countInFile empty pattern, parseSessionMetadata CRLF, and updateAliasTitle empty string coercion (round 115) 2026-02-13 18:05:28 -08:00
Affaan Mustafa
53e06a8850 test: add edge-case tests for listAliases type coercion, replaceInFile options.all with RegExp, and output BigInt serialization (round 114) 2026-02-13 18:01:25 -08:00
Affaan Mustafa
93633e44f2 test: add 3 tests for century leap years, zero-width regex, and markdown titles (Round 113)
- parseSessionFilename rejects Feb 29 in century non-leap years (1900, 2100) but accepts 2000/2400
- replaceInFile with /(?:)/g zero-width regex inserts at every position boundary
- parseSessionMetadata preserves raw markdown formatting (**bold**, `code`, _italic_) in titles

Total: 899 tests
2026-02-13 17:54:48 -08:00
Affaan Mustafa
791da32c6b test: add 3 tests for Unicode alias rejection, newline-in-path heuristic, and read-only append (Round 112)
- resolveAlias rejects Unicode characters (accented, CJK, emoji, Cyrillic homoglyphs)
- getSessionStats treats absolute .tmp paths with embedded newlines as content, not file paths
- appendSessionContent returns false on EACCES for read-only files

Total: 896 tests
2026-02-13 17:47:50 -08:00
Affaan Mustafa
635eb108ab test: add 3 tests for nested backtick context truncation, newline args injection, alias 128-char boundary
Round 111: Tests for parseSessionMetadata context regex truncation at
nested triple backticks (lazy [\s\S]*? stops early), getExecCommand
accepting newline/tab/CR in args via \s in SAFE_ARGS_REGEX, and setAlias
accepting exactly 128-character alias (off-by-one boundary). 893 tests total.
2026-02-13 17:41:58 -08:00
Affaan Mustafa
1e740724ca test: add 3 tests for findFiles root-unreadable, parseSessionFilename year 0000, uppercase ID rejection
Round 110: Tests for findFiles with unreadable root directory returning
empty array (vs Round 71 which tested subdirectory), parseSessionFilename
year 0000 exposing JS Date 0-99→1900-1999 mapping quirk, and uppercase
session ID rejection by [a-z0-9]{8,} regex. 890 tests total.
2026-02-13 17:30:38 -08:00
Affaan Mustafa
6737f3245b test: add 3 tests for appendFile new-file creation, getExecCommand traversal, getAllSessions non-session skip
Round 109:
- appendFile creating new file in non-existent directory (ensureDir + appendFileSync)
- getExecCommand with ../ path traversal in binary (SAFE_NAME_REGEX allows ../)
- getAllSessions skips .tmp files that don't match session filename format
2026-02-13 17:24:36 -08:00
Affaan Mustafa
1b273de13f test: add 3 tests for grepFile Unicode, SAFE_NAME_REGEX traversal, getSessionSize boundary
Round 108:
- grepFile with Unicode/emoji content (UTF-16 string matching on split lines)
- getRunCommand accepts ../ path traversal via SAFE_NAME_REGEX (allows / and . individually)
- getSessionSize exact 1024-byte B→KB boundary and 1MB KB→MB boundary
2026-02-13 17:18:06 -08:00
Affaan Mustafa
882157ac09 test: add 3 tests for Round 107 (881 total)
- grepFile with ^$ pattern verifies empty line matching including trailing newline phantom
- replaceInFile with self-reintroducing replacement confirms single-pass behavior
- setAlias with whitespace-only title exposes missing trim validation vs sessionPath
2026-02-13 17:11:32 -08:00
Affaan Mustafa
69799f2f80 test: add 3 tests for Round 106 (878 total)
- countInFile with named capture groups verifies match(g) ignores group details
- grepFile with multiline (m) flag confirms flag is preserved unlike stripped g
- getAllSessions with array/object limit tests Number() coercion edge cases
2026-02-13 17:07:13 -08:00
Affaan Mustafa
b27c21732f test: add 3 edge-case tests for regex boundary, sticky flag, and type bypass (Round 105)
- parseSessionMetadata: blank line within Completed section truncates items
  due to regex lookahead (?=###|\n\n|$) stopping at \n\n boundary
- grepFile: sticky (y) flag not stripped like g flag, causing stateful
  .test() behavior that misses matching lines
- getExecCommand: object args bypass SAFE_ARGS_REGEX (typeof !== 'string')
  but coerce to "[object Object]" in command string
2026-02-13 16:59:56 -08:00
Affaan Mustafa
332d0f444b test: add Round 104 edge-case tests (detectFromLockFile null, resolveSessionAlias traversal, whitespace notes)
- detectFromLockFile(null): throws TypeError — no input validation before
  path.join (package-manager.js:95)
- resolveSessionAlias('../etc/passwd'): returns path-traversal input unchanged
  when alias lookup fails, documenting the passthrough behavior
- parseSessionMetadata with whitespace-only notes: trim() → "" → hasNotes=false,
  whitespace-only notes treated as absent

Total tests: 872 (all passing)
2026-02-13 16:45:47 -08:00
Affaan Mustafa
45a0b62fcb test: add Round 103 edge-case tests (countInFile bool, grepFile numeric, loadAliases array)
- countInFile(file, false): boolean falls to else-return-0 type guard (utils.js:443)
- grepFile(file, 0): numeric pattern implicitly coerced via RegExp constructor,
  contrasting with countInFile which explicitly rejects non-string non-RegExp
- loadAliases with array aliases: typeof [] === 'object' bypasses validation
  at session-aliases.js:58, returning array instead of plain object

Total tests: 869 (all passing)
2026-02-13 16:08:47 -08:00
Affaan Mustafa
a64a294b29 test: add 3 edge-case tests for looksLikePath heuristic, falsy title coercion, and checkbox regex (Round 102)
- getSessionStats with Unix nonexistent .tmp path triggers looksLikePath
  heuristic → readFile returns null → zeroed stats via null content path
- setAlias with title=0 silently converts to null (0 || null === null)
- parseSessionMetadata skips [x] checked items in In Progress section
  (regex only matches unchecked [ ] checkboxes)

Total tests: 866
2026-02-13 16:02:18 -08:00
Affaan Mustafa
4d016babbb test: round 101 — output() circular crash, getSessionStats type confusion, appendSessionContent null
- output() throws TypeError on circular reference object (JSON.stringify has no try/catch)
- getSessionStats(123) throws TypeError (number reaches parseSessionMetadata, .match() fails)
- appendSessionContent(null) returns false (TypeError caught by try/catch)

Total tests: 863
2026-02-13 15:54:02 -08:00
Affaan Mustafa
d2c1281e97 test: round 100 — findFiles maxAge+recursive interaction, parseSessionMetadata ### truncation, cleanupAliases falsy coercion
- findFiles with both maxAge AND recursive combined (option interaction test)
- parseSessionMetadata truncates item text at embedded ### due to lazy regex
- cleanupAliases callback returning 0 (falsy non-boolean) removes alias via !0 coercion

Total tests: 860
2026-02-13 15:49:06 -08:00
Affaan Mustafa
78ad952433 test: add 3 tests for no-match rewrite, CR-only grepFile, and null write (R99)
- replaceInFile returns true even when pattern doesn't match (silent rewrite)
- grepFile treats CR-only (\r) file as single line (splits on \n only)
- writeSessionContent(null) returns false (TypeError caught by try/catch)
2026-02-13 15:41:15 -08:00
Affaan Mustafa
274cca025e test: add 3 tests for null-input crashes and negative maxAge boundary (R98)
- getSessionById(null) throws TypeError at line 297 (null.length)
- parseSessionFilename(null) throws TypeError at line 30 (null.match())
- findFiles with maxAge: -1 deterministically excludes all files
2026-02-13 15:35:18 -08:00
Affaan Mustafa
18fcb88168 test: add 3 tests for whitespace ID, lastIndex reuse, and whitespace search (Round 97) 2026-02-13 15:28:06 -08:00
Affaan Mustafa
8604583d16 test: add 3 tests for session-manager edge cases (Round 96)
- parseSessionFilename rejects Feb 30 (Date rollover check)
- getAllSessions with limit: Infinity bypasses pagination
- getAllSessions with limit: null demonstrates destructuring default bypass (null !== undefined)

Total: 848 tests, all passing
2026-02-13 15:13:55 -08:00
Affaan Mustafa
233b341557 test: add 3 tests for alternation regex, double-negative clamping, and self-rename (Round 95) 2026-02-13 14:50:49 -08:00
Affaan Mustafa
a95fb54ee4 test: add 3 tests for scoped pkg detection, empty env var, and tools-without-files (Round 94)
- detectFromPackageJson with scoped package name (@scope/pkg@version)
  returns null because split('@')[0] yields empty string
- getPackageManager skips empty string CLAUDE_PACKAGE_MANAGER via
  falsy short-circuit (distinct from unknown PM name test)
- session-end buildSummarySection includes Tools Used but omits
  Files Modified when transcript has only Read/Grep tools

Total tests: 842
2026-02-13 14:44:40 -08:00
Affaan Mustafa
910ffa5530 test: add 3 tests for regex boundary and flag logic gaps (round 93)
- getSessionStats: drive letter without slash (Z:nosession.tmp) treated as content
- countInFile: case-insensitive regex with g flag auto-appended (/foo/i → /foo/ig)
- countInFile: case-insensitive regex with g flag preserved (/foo/gi stays /foo/gi)
2026-02-13 14:21:03 -08:00
Affaan Mustafa
b9a38b2680 test: add Round 92 tests for object pattern, UNC path, and empty packageManager
- Test countInFile returns 0 for object pattern type (non-string non-RegExp)
- Test getSessionStats treats Windows UNC path as content (not file path)
- Test detectFromPackageJson returns null for empty string packageManager field

Total tests: 836
2026-02-13 14:05:24 -08:00
Affaan Mustafa
14dfe4d110 test: add Round 91 tests for empty action pattern, whitespace PM, and mixed separators
- Test getCommandPattern('') produces valid regex for empty action string
- Test detectFromPackageJson returns null for whitespace-only packageManager
- Test getSessionStats treats mixed Windows path separators as file path

Total tests: 833
2026-02-13 14:02:41 -08:00
Affaan Mustafa
3e98be3e39 test: add Round 90 tests for readStdinJson timeout and saveAliases double failure
- Test readStdinJson timeout path when stdin never closes (resolves with {})
- Test readStdinJson timeout path with partial invalid JSON (catch resolves with {})
- Test saveAliases backup restore double failure (inner restoreErr catch at line 135)

Total tests: 830
2026-02-13 13:59:03 -08:00
Affaan Mustafa
3ec59c48bc test: add 3 tests for subdirectory skip, TypeScript error detection, and entry.name fallback (Round 89)
- getAllSessions skips subdirectories in sessions dir (!entry.isFile() branch)
- post-edit-typecheck.js error detection path when tsc reports errors (relevantLines > 0)
- extractSessionSummary extracts tools via entry.name + entry.input fallback format
2026-02-13 13:39:16 -08:00
6 changed files with 3207 additions and 642 deletions

642
llms.txt
View File

@@ -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/

View File

@@ -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}`);

View File

@@ -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}`);

View File

@@ -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