Compare commits
93 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
456d9cea65 | ||
|
|
30f893b766 | ||
|
|
c905e1cb7a | ||
|
|
d3e2b36e3d | ||
|
|
5f0b6d49f5 | ||
|
|
b45408dd9c | ||
|
|
6c8527f29b | ||
|
|
cd4da93bf2 | ||
|
|
71b2f1518a | ||
|
|
dcda8769cc | ||
|
|
a94fbadd57 | ||
|
|
23b49c4a5c | ||
|
|
b4973954e3 | ||
|
|
6d50fbe563 | ||
|
|
9850dd0f6e | ||
|
|
34aaef2219 | ||
|
|
faca80caa9 | ||
|
|
0c3fbd724b | ||
|
|
c7455708f8 | ||
|
|
bffa1ad43d | ||
|
|
6560dedd4c | ||
|
|
b7e32a99f2 | ||
|
|
a06e656565 | ||
|
|
30ed086c40 | ||
|
|
7c15b06da7 | ||
|
|
0e7ee2ac30 | ||
|
|
ca93d2f0fe | ||
|
|
3ab4529bc7 | ||
|
|
9d3e152b19 | ||
|
|
68c8f3dda7 | ||
|
|
03f6e72c9b | ||
|
|
4fd9f0fd04 | ||
|
|
4413336724 | ||
|
|
895f366a11 | ||
|
|
acc19fcd41 | ||
|
|
68e0a32183 | ||
|
|
dee89c1556 | ||
|
|
315c75c51e | ||
|
|
3dd80889a5 | ||
|
|
8f6ed5b20f | ||
|
|
01500f1ebe | ||
|
|
48f6c5e06d | ||
|
|
3e32afe646 | ||
|
|
d11c4a1f81 | ||
|
|
5558ddf468 | ||
|
|
aa03d9b811 | ||
|
|
28a0dd06c7 | ||
|
|
995b7751af | ||
|
|
5087788f66 | ||
|
|
19524c8a27 | ||
|
|
fbb4d46945 | ||
|
|
5dc8d577a4 | ||
|
|
c249763d7e | ||
|
|
b2d618e851 | ||
|
|
6f348a8a5c | ||
|
|
1da0adcbe8 | ||
|
|
8a9d966a3d | ||
|
|
76f8c500cb | ||
|
|
388516bcc5 | ||
|
|
8dff875929 | ||
|
|
966cc90a02 | ||
|
|
1d27d78127 | ||
|
|
38156d49f3 | ||
|
|
897eea0263 | ||
|
|
9b59ef66e4 | ||
|
|
0d938059f9 | ||
|
|
9d35f23725 | ||
|
|
aa1646f82c | ||
|
|
e47ab084fd | ||
|
|
baf6358736 | ||
|
|
488c89156b | ||
|
|
c4957a469d | ||
|
|
d481c596bd | ||
|
|
655d511294 | ||
|
|
7dedd6cf90 | ||
|
|
bd18f231f5 | ||
|
|
de439edc22 | ||
|
|
04500bae7d | ||
|
|
1cb6b3de7d | ||
|
|
912a56db85 | ||
|
|
a5d9929c0a | ||
|
|
7f43f160b5 | ||
|
|
af67bc8592 | ||
|
|
c74d79e28a | ||
|
|
fc5298d778 | ||
|
|
3e8e3db961 | ||
|
|
6fa5cac616 | ||
|
|
158ccabf24 | ||
|
|
2efbf2650f | ||
|
|
acded4ba2a | ||
|
|
911e43445f | ||
|
|
3049e1ebfb | ||
|
|
62921b9e44 |
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -14,6 +14,8 @@ body:
|
||||
label: Prerequisites
|
||||
description: Please confirm the following before submitting
|
||||
options:
|
||||
- label: I will write this issue in English (see our [Language Policy](https://github.com/code-yeongyu/oh-my-opencode/blob/dev/CONTRIBUTING.md#language-policy))
|
||||
required: true
|
||||
- label: I have searched existing issues to avoid duplicates
|
||||
required: true
|
||||
- label: I am using the latest version of oh-my-opencode
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
2
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -14,6 +14,8 @@ body:
|
||||
label: Prerequisites
|
||||
description: Please confirm the following before submitting
|
||||
options:
|
||||
- label: I will write this issue in English (see our [Language Policy](https://github.com/code-yeongyu/oh-my-opencode/blob/dev/CONTRIBUTING.md#language-policy))
|
||||
required: true
|
||||
- label: I have searched existing issues and discussions to avoid duplicates
|
||||
required: true
|
||||
- label: This feature request is specific to oh-my-opencode (not OpenCode core)
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/general.yml
vendored
2
.github/ISSUE_TEMPLATE/general.yml
vendored
@@ -14,6 +14,8 @@ body:
|
||||
label: Prerequisites
|
||||
description: Please confirm the following before submitting
|
||||
options:
|
||||
- label: I will write this issue in English (see our [Language Policy](https://github.com/code-yeongyu/oh-my-opencode/blob/dev/CONTRIBUTING.md#language-policy))
|
||||
required: true
|
||||
- label: I have searched existing issues and discussions
|
||||
required: true
|
||||
- label: I have read the [documentation](https://github.com/code-yeongyu/oh-my-opencode#readme)
|
||||
|
||||
30
.github/workflows/ci.yml
vendored
30
.github/workflows/ci.yml
vendored
@@ -44,8 +44,34 @@ jobs:
|
||||
env:
|
||||
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi"
|
||||
|
||||
- name: Run tests
|
||||
run: bun test
|
||||
- name: Run mock-heavy tests (isolated)
|
||||
run: |
|
||||
# These files use mock.module() which pollutes module cache
|
||||
# Run them in separate processes to prevent cross-file contamination
|
||||
bun test src/plugin-handlers
|
||||
bun test src/hooks/atlas
|
||||
bun test src/hooks/compaction-context-injector
|
||||
bun test src/features/tmux-subagent
|
||||
|
||||
- name: Run remaining tests
|
||||
run: |
|
||||
# Run all other tests (mock-heavy ones are re-run but that's acceptable)
|
||||
bun test bin script src/cli src/config src/mcp src/index.test.ts \
|
||||
src/agents src/tools src/shared \
|
||||
src/hooks/anthropic-context-window-limit-recovery \
|
||||
src/hooks/claude-code-compatibility \
|
||||
src/hooks/context-injection \
|
||||
src/hooks/provider-toast \
|
||||
src/hooks/session-notification \
|
||||
src/hooks/sisyphus \
|
||||
src/hooks/todo-continuation-enforcer \
|
||||
src/features/background-agent \
|
||||
src/features/builtin-commands \
|
||||
src/features/builtin-skills \
|
||||
src/features/claude-code-session-state \
|
||||
src/features/hook-message-injector \
|
||||
src/features/opencode-skill-loader \
|
||||
src/features/skill-mcp-manager
|
||||
|
||||
typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
2
.github/workflows/cla.yml
vendored
2
.github/workflows/cla.yml
vendored
@@ -25,7 +25,7 @@ jobs:
|
||||
path-to-signatures: 'signatures/cla.json'
|
||||
path-to-document: 'https://github.com/code-yeongyu/oh-my-opencode/blob/master/CLA.md'
|
||||
branch: 'dev'
|
||||
allowlist: bot*,dependabot*,github-actions*,*[bot],sisyphus-dev-ai
|
||||
allowlist: code-yeongyu,bot*,dependabot*,github-actions*,*[bot],sisyphus-dev-ai
|
||||
custom-notsigned-prcomment: |
|
||||
Thank you for your contribution! Before we can merge this PR, we need you to sign our [Contributor License Agreement (CLA)](https://github.com/code-yeongyu/oh-my-opencode/blob/master/CLA.md).
|
||||
|
||||
|
||||
30
.github/workflows/publish.yml
vendored
30
.github/workflows/publish.yml
vendored
@@ -45,8 +45,34 @@ jobs:
|
||||
env:
|
||||
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi"
|
||||
|
||||
- name: Run tests
|
||||
run: bun test
|
||||
- name: Run mock-heavy tests (isolated)
|
||||
run: |
|
||||
# These files use mock.module() which pollutes module cache
|
||||
# Run them in separate processes to prevent cross-file contamination
|
||||
bun test src/plugin-handlers
|
||||
bun test src/hooks/atlas
|
||||
bun test src/hooks/compaction-context-injector
|
||||
bun test src/features/tmux-subagent
|
||||
|
||||
- name: Run remaining tests
|
||||
run: |
|
||||
# Run all other tests (mock-heavy ones are re-run but that's acceptable)
|
||||
bun test bin script src/cli src/config src/mcp src/index.test.ts \
|
||||
src/agents src/tools src/shared \
|
||||
src/hooks/anthropic-context-window-limit-recovery \
|
||||
src/hooks/claude-code-compatibility \
|
||||
src/hooks/context-injection \
|
||||
src/hooks/provider-toast \
|
||||
src/hooks/session-notification \
|
||||
src/hooks/sisyphus \
|
||||
src/hooks/todo-continuation-enforcer \
|
||||
src/features/background-agent \
|
||||
src/features/builtin-commands \
|
||||
src/features/builtin-skills \
|
||||
src/features/claude-code-session-state \
|
||||
src/features/hook-message-injector \
|
||||
src/features/opencode-skill-loader \
|
||||
src/features/skill-mcp-manager
|
||||
|
||||
typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
38
.github/workflows/sisyphus-agent.yml
vendored
38
.github/workflows/sisyphus-agent.yml
vendored
@@ -152,6 +152,41 @@ jobs:
|
||||
"limit": { "context": 200000, "output": 64000 }
|
||||
}
|
||||
}
|
||||
} |
|
||||
.provider["zai-coding-plan"] = {
|
||||
"name": "Z.AI Coding Plan",
|
||||
"npm": "@ai-sdk/openai-compatible",
|
||||
"options": {
|
||||
"baseURL": "https://api.z.ai/api/paas/v4"
|
||||
},
|
||||
"models": {
|
||||
"glm-4.7": {
|
||||
"id": "glm-4.7",
|
||||
"name": "GLM 4.7",
|
||||
"limit": { "context": 128000, "output": 16000 }
|
||||
},
|
||||
"glm-4.6v": {
|
||||
"id": "glm-4.6v",
|
||||
"name": "GLM 4.6 Vision",
|
||||
"limit": { "context": 128000, "output": 16000 }
|
||||
}
|
||||
}
|
||||
} |
|
||||
.provider.openai = {
|
||||
"name": "OpenAI",
|
||||
"npm": "@ai-sdk/openai",
|
||||
"models": {
|
||||
"gpt-5.2": {
|
||||
"id": "gpt-5.2",
|
||||
"name": "GPT-5.2",
|
||||
"limit": { "context": 128000, "output": 16000 }
|
||||
},
|
||||
"gpt-5.2-codex": {
|
||||
"id": "gpt-5.2-codex",
|
||||
"name": "GPT-5.2 Codex",
|
||||
"limit": { "context": 128000, "output": 32000 }
|
||||
}
|
||||
}
|
||||
}
|
||||
' "$OPENCODE_JSON" > /tmp/oc.json && mv /tmp/oc.json "$OPENCODE_JSON"
|
||||
|
||||
@@ -287,6 +322,9 @@ jobs:
|
||||
)
|
||||
jq --arg append "$PROMPT_APPEND" '.agents.Sisyphus.prompt_append = $append' "$OMO_JSON" > /tmp/omo.json && mv /tmp/omo.json "$OMO_JSON"
|
||||
|
||||
# Add categories configuration for unspecified-low to use GLM 4.7
|
||||
jq '.categories["unspecified-low"] = { "model": "zai-coding-plan/glm-4.7" }' "$OMO_JSON" > /tmp/omo.json && mv /tmp/omo.json "$OMO_JSON"
|
||||
|
||||
mkdir -p ~/.local/share/opencode
|
||||
echo "$OPENCODE_AUTH_JSON" > ~/.local/share/opencode/auth.json
|
||||
chmod 600 ~/.local/share/opencode/auth.json
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -33,3 +33,4 @@ yarn.lock
|
||||
test-injection/
|
||||
notepad.md
|
||||
oauth-success.html
|
||||
.188e87dbff6e7fd9-00000000.bun-build
|
||||
|
||||
@@ -2768,7 +2768,8 @@
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"playwright",
|
||||
"agent-browser"
|
||||
"agent-browser",
|
||||
"dev-browser"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -2808,6 +2809,50 @@
|
||||
"minimum": 20
|
||||
}
|
||||
}
|
||||
},
|
||||
"sisyphus": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tasks": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"storage_path": {
|
||||
"default": ".sisyphus/tasks",
|
||||
"type": "string"
|
||||
},
|
||||
"claude_code_compat": {
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"swarm": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"storage_path": {
|
||||
"default": ".sisyphus/teams",
|
||||
"type": "string"
|
||||
},
|
||||
"ui_mode": {
|
||||
"default": "toast",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"toast",
|
||||
"tmux",
|
||||
"both"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
31
bun.lock
31
bun.lock
@@ -18,6 +18,7 @@
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"picocolors": "^1.1.1",
|
||||
"picomatch": "^4.0.2",
|
||||
"vscode-jsonrpc": "^8.2.0",
|
||||
"zod": "^4.1.8",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -27,13 +28,13 @@
|
||||
"typescript": "^5.7.3",
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"oh-my-opencode-darwin-arm64": "3.1.0",
|
||||
"oh-my-opencode-darwin-x64": "3.1.0",
|
||||
"oh-my-opencode-linux-arm64": "3.1.0",
|
||||
"oh-my-opencode-linux-arm64-musl": "3.1.0",
|
||||
"oh-my-opencode-linux-x64": "3.1.0",
|
||||
"oh-my-opencode-linux-x64-musl": "3.1.0",
|
||||
"oh-my-opencode-windows-x64": "3.1.0",
|
||||
"oh-my-opencode-darwin-arm64": "3.1.6",
|
||||
"oh-my-opencode-darwin-x64": "3.1.6",
|
||||
"oh-my-opencode-linux-arm64": "3.1.6",
|
||||
"oh-my-opencode-linux-arm64-musl": "3.1.6",
|
||||
"oh-my-opencode-linux-x64": "3.1.6",
|
||||
"oh-my-opencode-linux-x64-musl": "3.1.6",
|
||||
"oh-my-opencode-windows-x64": "3.1.6",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -225,19 +226,19 @@
|
||||
|
||||
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||
|
||||
"oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.1.0", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-8j7XI+n1bz7xIg35Zpjqp1AqoIoFWuVZdYyI9vTAZ0b6ta/mIlNOWPLAbFyEHfKelA9g3Xa+4sYnKPSxU5dQoA=="],
|
||||
"oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.1.6", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-KK+ptnkBigvDYbRtF/B5izEC4IoXDS8mAnRHWFBSCINhzQR2No6AtEcwijd6vKBPR+/r71ofq/8mTsIeb1PEVQ=="],
|
||||
|
||||
"oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.1.0", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-Kd/3KpnF07cw+qBAyLwA0y8tp3S0X8b8HWH55WGlVp6m4gvQ432kKgDum/jat1vqP/3J8hm4P/sly5ibY5gMqw=="],
|
||||
"oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.1.6", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-UkPI/RUi7INarFasBUZ4Rous6RUQXsU2nr0V8KFJp+70END43D/96dDUwX+zmPtpDhD+DfWkejuwzqfkZJ2ZDQ=="],
|
||||
|
||||
"oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.1.0", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-qy/QohHGM6eSQjHVEgibsDauUvlAgYPw5xrQqa9cVLo1hL4KMIhb+i4wGAxCK2p84rG2bfC2m8+IfZUxhhwcTg=="],
|
||||
"oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.1.6", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-gvmvgh7WtTtcHiCbG7z43DOYfY/jrf2S6TX/jBMX2/e1AGkcLKwz30NjGhZxeK5SyzxRVypgfZZK1IuriRgbdA=="],
|
||||
|
||||
"oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.1.0", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-HIO7zj3M5QAYOfgvFM7Djeuen9kdZD4RA51wzXcXiPj1FPAuBNAW9N7lTEGYBSgObgwX+vXnC3HwLSF7nqkw8w=="],
|
||||
"oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.1.6", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-j3R76pmQ4HGVGFJUMMCeF/1lO3Jg7xFdpcBUKCeFh42N1jMgn1aeyxkAaJYB9RwCF/p6+P8B6gVDLCEDu2mxjA=="],
|
||||
|
||||
"oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.1.0", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-zcKaibnEhvbReiTsqbg+dog/Z3pnBx4v6R3AR5nVhGBO27hRSAXgA/fviYyE5bWD591WB7Pqwduf0t854ilKjw=="],
|
||||
"oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.1.6", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-VDdo0tHCOr5nm7ajd652u798nPNOLRSTcPOnVh6vIPddkZ+ujRke+enOKOw9Pd5e+4AkthqHBwFXNm2VFgnEKg=="],
|
||||
|
||||
"oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.1.0", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-xmtHEyAhY93Djg5qEauvMqSF0x3tf8pzOGdKB6CuZmhCG69fZXk/dEwPrO0vKbOeGMV/T4K6HAg1+8Ue1N1ZaQ=="],
|
||||
"oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.1.6", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-hBG/dhsr8PZelUlYsPBruSLnelB9ocB7H92I+S9svTpDVo67rAmXOoR04twKQ9TeCO4ShOa6hhMhbQnuI8fgNw=="],
|
||||
|
||||
"oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.1.0", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-pDgHd0mGWWVsiO0fT8C7bi6CziOXU38g+k2dWlGm1YXCMzyrrWZZCF7oIp+EzJB02saSCF/oJ2f1/uj/VPeLMA=="],
|
||||
"oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.1.6", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-c8Awp03p2DsbS0G589nzveRCeJPgJRJ0vQrha4ChRmmo31Qc5OSmJ5xuMaF8L4nM+/trbTgAQMFMtCMLgtC8IQ=="],
|
||||
|
||||
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
|
||||
|
||||
@@ -303,6 +304,8 @@
|
||||
|
||||
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
|
||||
|
||||
"vscode-jsonrpc": ["vscode-jsonrpc@8.2.1", "", {}, "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ=="],
|
||||
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||
|
||||
@@ -134,7 +134,41 @@ bunx oh-my-opencode run [prompt]
|
||||
|
||||
---
|
||||
|
||||
## 6. `auth` - Authentication Management
|
||||
## 6. `mcp oauth` - MCP OAuth Management
|
||||
|
||||
Manages OAuth 2.1 authentication for remote MCP servers.
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
# Login to an OAuth-protected MCP server
|
||||
bunx oh-my-opencode mcp oauth login <server-name> --server-url https://api.example.com
|
||||
|
||||
# Login with explicit client ID and scopes
|
||||
bunx oh-my-opencode mcp oauth login my-api --server-url https://api.example.com --client-id my-client --scopes "read,write"
|
||||
|
||||
# Remove stored OAuth tokens
|
||||
bunx oh-my-opencode mcp oauth logout <server-name>
|
||||
|
||||
# Check OAuth token status
|
||||
bunx oh-my-opencode mcp oauth status [server-name]
|
||||
```
|
||||
|
||||
### Options
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--server-url <url>` | MCP server URL (required for login) |
|
||||
| `--client-id <id>` | OAuth client ID (optional if server supports Dynamic Client Registration) |
|
||||
| `--scopes <scopes>` | Comma-separated OAuth scopes |
|
||||
|
||||
### Token Storage
|
||||
|
||||
Tokens are stored in `~/.config/opencode/mcp-oauth.json` with `0600` permissions (owner read/write only). Key format: `{serverHost}/{resource}`.
|
||||
|
||||
---
|
||||
|
||||
## 7. `auth` - Authentication Management
|
||||
|
||||
Manages Google Antigravity OAuth authentication. Required for using Gemini models.
|
||||
|
||||
@@ -153,7 +187,7 @@ bunx oh-my-opencode auth status
|
||||
|
||||
---
|
||||
|
||||
## 7. Configuration Files
|
||||
## 8. Configuration Files
|
||||
|
||||
The CLI searches for configuration files in the following locations (in priority order):
|
||||
|
||||
@@ -183,7 +217,7 @@ Configuration files support **JSONC (JSON with Comments)** format. You can use c
|
||||
|
||||
---
|
||||
|
||||
## 8. Troubleshooting
|
||||
## 9. Troubleshooting
|
||||
|
||||
### "OpenCode version too old" Error
|
||||
|
||||
@@ -213,7 +247,7 @@ bunx oh-my-opencode doctor --category authentication
|
||||
|
||||
---
|
||||
|
||||
## 9. Non-Interactive Mode
|
||||
## 10. Non-Interactive Mode
|
||||
|
||||
Use the `--no-tui` option for CI/CD environments.
|
||||
|
||||
@@ -227,7 +261,7 @@ bunx oh-my-opencode doctor --json > doctor-report.json
|
||||
|
||||
---
|
||||
|
||||
## 10. Developer Information
|
||||
## 11. Developer Information
|
||||
|
||||
### CLI Structure
|
||||
|
||||
|
||||
@@ -85,6 +85,66 @@ When both `oh-my-opencode.jsonc` and `oh-my-opencode.json` files exist, `.jsonc`
|
||||
|
||||
**Recommended**: For Google Gemini authentication, install the [`opencode-antigravity-auth`](https://github.com/NoeFabris/opencode-antigravity-auth) plugin (`@latest`). It provides multi-account load balancing, variant-based thinking levels, dual quota system (Antigravity + Gemini CLI), and active maintenance. See [Installation > Google Gemini](docs/guide/installation.md#google-gemini-antigravity-oauth).
|
||||
|
||||
## Ollama Provider
|
||||
|
||||
**IMPORTANT**: When using Ollama as a provider, you **must** disable streaming to avoid JSON parsing errors.
|
||||
|
||||
### Required Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"explore": {
|
||||
"model": "ollama/qwen3-coder",
|
||||
"stream": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Why `stream: false` is Required
|
||||
|
||||
Ollama returns NDJSON (newline-delimited JSON) when streaming is enabled, but Claude Code SDK expects a single JSON object. This causes `JSON Parse error: Unexpected EOF` when agents attempt tool calls.
|
||||
|
||||
**Example of the problem**:
|
||||
```json
|
||||
// Ollama streaming response (NDJSON - multiple lines)
|
||||
{"message":{"tool_calls":[...]}, "done":false}
|
||||
{"message":{"content":""}, "done":true}
|
||||
|
||||
// Claude Code SDK expects (single JSON object)
|
||||
{"message":{"tool_calls":[...], "content":""}, "done":true}
|
||||
```
|
||||
|
||||
### Supported Models
|
||||
|
||||
Common Ollama models that work with oh-my-opencode:
|
||||
|
||||
| Model | Best For | Configuration |
|
||||
|-------|----------|---------------|
|
||||
| `ollama/qwen3-coder` | Code generation, build fixes | `{"model": "ollama/qwen3-coder", "stream": false}` |
|
||||
| `ollama/ministral-3:14b` | Exploration, codebase search | `{"model": "ollama/ministral-3:14b", "stream": false}` |
|
||||
| `ollama/lfm2.5-thinking` | Documentation, writing | `{"model": "ollama/lfm2.5-thinking", "stream": false}` |
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
If you encounter `JSON Parse error: Unexpected EOF`:
|
||||
|
||||
1. **Verify `stream: false` is set** in your agent configuration
|
||||
2. **Check Ollama is running**: `curl http://localhost:11434/api/tags`
|
||||
3. **Test with curl**:
|
||||
```bash
|
||||
curl -s http://localhost:11434/api/chat \
|
||||
-d '{"model": "qwen3-coder", "messages": [{"role": "user", "content": "Hello"}], "stream": false}'
|
||||
```
|
||||
4. **See detailed troubleshooting**: [docs/troubleshooting/ollama-streaming-issue.md](troubleshooting/ollama-streaming-issue.md)
|
||||
|
||||
### Future SDK Fix
|
||||
|
||||
The proper long-term fix requires Claude Code SDK to parse NDJSON responses correctly. Until then, use `stream: false` as a workaround.
|
||||
|
||||
**Tracking**: https://github.com/code-yeongyu/oh-my-opencode/issues/1124
|
||||
|
||||
## Agents
|
||||
|
||||
Override built-in agent settings:
|
||||
@@ -103,7 +163,39 @@ Override built-in agent settings:
|
||||
}
|
||||
```
|
||||
|
||||
Each agent supports: `model`, `temperature`, `top_p`, `prompt`, `prompt_append`, `tools`, `disable`, `description`, `mode`, `color`, `permission`.
|
||||
Each agent supports: `model`, `temperature`, `top_p`, `prompt`, `prompt_append`, `tools`, `disable`, `description`, `mode`, `color`, `permission`, `category`, `variant`, `maxTokens`, `thinking`, `reasoningEffort`, `textVerbosity`, `providerOptions`.
|
||||
|
||||
### Additional Agent Options
|
||||
|
||||
| Option | Type | Description |
|
||||
| ------------------- | ------- | ----------------------------------------------------------------------------------------------- |
|
||||
| `category` | string | Category name to inherit model and other settings from category defaults |
|
||||
| `variant` | string | Model variant (e.g., `max`, `high`, `medium`, `low`, `xhigh`) |
|
||||
| `maxTokens` | number | Maximum tokens for response. Passed directly to OpenCode SDK. |
|
||||
| `thinking` | object | Extended thinking configuration for Anthropic models. See [Thinking Options](#thinking-options) below. |
|
||||
| `reasoningEffort` | string | OpenAI reasoning effort level. Values: `low`, `medium`, `high`, `xhigh`. |
|
||||
| `textVerbosity` | string | Text verbosity level. Values: `low`, `medium`, `high`. |
|
||||
| `providerOptions` | object | Provider-specific options passed directly to OpenCode SDK. |
|
||||
|
||||
#### Thinking Options (Anthropic)
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"oracle": {
|
||||
"thinking": {
|
||||
"type": "enabled",
|
||||
"budgetTokens": 200000
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
| ------------- | ------- | ------- | -------------------------------------------- |
|
||||
| `type` | string | - | `enabled` or `disabled` |
|
||||
| `budgetTokens`| number | - | Maximum budget tokens for extended thinking |
|
||||
|
||||
Use `prompt_append` to add extra instructions without replacing the default system prompt:
|
||||
|
||||
@@ -153,7 +245,7 @@ Or disable via `disabled_agents` in `~/.config/opencode/oh-my-opencode.json` or
|
||||
}
|
||||
```
|
||||
|
||||
Available agents: `oracle`, `librarian`, `explore`, `multimodal-looker`
|
||||
Available agents: `sisyphus`, `prometheus`, `oracle`, `librarian`, `explore`, `multimodal-looker`, `metis`, `momus`, `atlas`
|
||||
|
||||
## Built-in Skills
|
||||
|
||||
@@ -172,6 +264,105 @@ Disable built-in skills via `disabled_skills` in `~/.config/opencode/oh-my-openc
|
||||
|
||||
Available built-in skills: `playwright`, `agent-browser`, `git-master`
|
||||
|
||||
## Skills Configuration
|
||||
|
||||
Configure advanced skills settings including custom skill sources, enabling/disabling specific skills, and defining custom skills.
|
||||
|
||||
```json
|
||||
{
|
||||
"skills": {
|
||||
"sources": [
|
||||
{ "path": "./custom-skills", "recursive": true },
|
||||
"https://example.com/skill.yaml"
|
||||
],
|
||||
"enable": ["my-custom-skill"],
|
||||
"disable": ["other-skill"],
|
||||
"my-skill": {
|
||||
"description": "Custom skill description",
|
||||
"template": "Custom prompt template",
|
||||
"from": "source-file.ts",
|
||||
"model": "custom/model",
|
||||
"agent": "custom-agent",
|
||||
"subtask": true,
|
||||
"argument-hint": "usage hint",
|
||||
"license": "MIT",
|
||||
"compatibility": ">= 3.0.0",
|
||||
"metadata": {
|
||||
"author": "Your Name"
|
||||
},
|
||||
"allowed-tools": ["tool1", "tool2"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Sources
|
||||
|
||||
Load skills from local directories or remote URLs:
|
||||
|
||||
```json
|
||||
{
|
||||
"skills": {
|
||||
"sources": [
|
||||
{ "path": "./custom-skills", "recursive": true },
|
||||
{ "path": "./single-skill.yaml" },
|
||||
"https://example.com/skill.yaml",
|
||||
"https://raw.githubusercontent.com/user/repo/main/skills/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Default | Description |
|
||||
| ----------- | ------- | ---------------------------------------------- |
|
||||
| `path` | - | Local file/directory path or remote URL |
|
||||
| `recursive` | `false` | Recursively load from directory |
|
||||
| `glob` | - | Glob pattern for file selection |
|
||||
|
||||
### Enable/Disable Skills
|
||||
|
||||
```json
|
||||
{
|
||||
"skills": {
|
||||
"enable": ["skill-1", "skill-2"],
|
||||
"disable": ["disabled-skill"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Skill Definition
|
||||
|
||||
Define custom skills directly in your config:
|
||||
|
||||
| Option | Default | Description |
|
||||
| ---------------- | ------- | ------------------------------------------------------------------------------------ |
|
||||
| `description` | - | Human-readable description of the skill |
|
||||
| `template` | - | Custom prompt template for the skill |
|
||||
| `from` | - | Source file to load template from |
|
||||
| `model` | - | Override model for this skill |
|
||||
| `agent` | - | Override agent for this skill |
|
||||
| `subtask` | `false` | Whether to run as a subtask |
|
||||
| `argument-hint` | - | Hint for how to use the skill |
|
||||
| `license` | - | Skill license |
|
||||
| `compatibility` | - | Required oh-my-opencode version compatibility |
|
||||
| `metadata` | - | Additional metadata as key-value pairs |
|
||||
| `allowed-tools` | - | Array of tools this skill is allowed to use |
|
||||
|
||||
**Example: Custom skill**
|
||||
|
||||
```json
|
||||
{
|
||||
"skills": {
|
||||
"data-analyst": {
|
||||
"description": "Specialized for data analysis tasks",
|
||||
"template": "You are a data analyst. Focus on statistical analysis, visualization, and data interpretation.",
|
||||
"model": "openai/gpt-5.2",
|
||||
"allowed-tools": ["read", "bash", "lsp_diagnostics"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Browser Automation
|
||||
|
||||
Choose between two browser automation providers:
|
||||
@@ -495,6 +686,7 @@ Configure concurrency limits for background agent tasks. This controls how many
|
||||
{
|
||||
"background_task": {
|
||||
"defaultConcurrency": 5,
|
||||
"staleTimeoutMs": 180000,
|
||||
"providerConcurrency": {
|
||||
"anthropic": 3,
|
||||
"openai": 5,
|
||||
@@ -511,6 +703,7 @@ Configure concurrency limits for background agent tasks. This controls how many
|
||||
| Option | Default | Description |
|
||||
| --------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------- |
|
||||
| `defaultConcurrency` | - | Default maximum concurrent background tasks for all providers/models |
|
||||
| `staleTimeoutMs` | `180000` | Stale timeout in milliseconds - interrupt tasks with no activity for this duration (minimum: 60000 = 1 minute) |
|
||||
| `providerConcurrency` | - | Per-provider concurrency limits. Keys are provider names (e.g., `anthropic`, `openai`, `google`) |
|
||||
| `modelConcurrency` | - | Per-model concurrency limits. Keys are full model names (e.g., `anthropic/claude-opus-4-5`). Overrides provider limits. |
|
||||
|
||||
@@ -525,27 +718,96 @@ Configure concurrency limits for background agent tasks. This controls how many
|
||||
|
||||
Categories enable domain-specific task delegation via the `delegate_task` tool. Each category applies runtime presets (model, temperature, prompt additions) when calling the `Sisyphus-Junior` agent.
|
||||
|
||||
**Default Categories:**
|
||||
### Built-in Categories
|
||||
|
||||
| Category | Model | Description |
|
||||
| ---------------- | ----------------------------- | ---------------------------------------------------------------------------- |
|
||||
| `visual` | `google/gemini-3-pro` | Frontend, UI/UX, design-focused tasks. High creativity (temp 0.7). |
|
||||
| `business-logic` | `openai/gpt-5.2` | Backend logic, architecture, strategic reasoning. Low creativity (temp 0.1). |
|
||||
All 7 categories come with optimal model defaults, but **you must configure them to use those defaults**:
|
||||
|
||||
**Usage:**
|
||||
| Category | Built-in Default Model | Description |
|
||||
| -------------------- | ---------------------------------- | -------------------------------------------------------------------- |
|
||||
| `visual-engineering` | `google/gemini-3-pro-preview` | Frontend, UI/UX, design, styling, animation |
|
||||
| `ultrabrain` | `openai/gpt-5.2-codex` (xhigh) | Deep logical reasoning, complex architecture decisions |
|
||||
| `artistry` | `google/gemini-3-pro-preview` (max)| Highly creative/artistic tasks, novel ideas |
|
||||
| `quick` | `anthropic/claude-haiku-4-5` | Trivial tasks - single file changes, typo fixes, simple modifications|
|
||||
| `unspecified-low` | `anthropic/claude-sonnet-4-5` | Tasks that don't fit other categories, low effort required |
|
||||
| `unspecified-high` | `anthropic/claude-opus-4-5` (max) | Tasks that don't fit other categories, high effort required |
|
||||
| `writing` | `google/gemini-3-flash-preview` | Documentation, prose, technical writing |
|
||||
|
||||
### ⚠️ Critical: Model Resolution Priority
|
||||
|
||||
**Categories DO NOT use their built-in defaults unless configured.** Model resolution follows this priority:
|
||||
|
||||
```
|
||||
// Via delegate_task tool
|
||||
delegate_task(category="visual", prompt="Create a responsive dashboard component")
|
||||
delegate_task(category="business-logic", prompt="Design the payment processing flow")
|
||||
1. User-configured model (in oh-my-opencode.json)
|
||||
2. Category's built-in default (if you add category to config)
|
||||
3. System default model (from opencode.json)
|
||||
```
|
||||
|
||||
// Or target a specific agent directly
|
||||
**Example Problem:**
|
||||
|
||||
```json
|
||||
// opencode.json
|
||||
{ "model": "anthropic/claude-sonnet-4-5" }
|
||||
|
||||
// oh-my-opencode.json (empty categories section)
|
||||
{}
|
||||
|
||||
// Result: ALL categories use claude-sonnet-4-5 (wasteful!)
|
||||
// - quick tasks use Sonnet instead of Haiku (expensive)
|
||||
// - ultrabrain uses Sonnet instead of GPT-5.2 (inferior reasoning)
|
||||
// - visual tasks use Sonnet instead of Gemini (suboptimal for UI)
|
||||
```
|
||||
|
||||
### Recommended Configuration
|
||||
|
||||
**To use optimal models for each category, add them to your config:**
|
||||
|
||||
```json
|
||||
{
|
||||
"categories": {
|
||||
"visual-engineering": {
|
||||
"model": "google/gemini-3-pro-preview"
|
||||
},
|
||||
"ultrabrain": {
|
||||
"model": "openai/gpt-5.2-codex",
|
||||
"variant": "xhigh"
|
||||
},
|
||||
"artistry": {
|
||||
"model": "google/gemini-3-pro-preview",
|
||||
"variant": "max"
|
||||
},
|
||||
"quick": {
|
||||
"model": "anthropic/claude-haiku-4-5" // Fast + cheap for trivial tasks
|
||||
},
|
||||
"unspecified-low": {
|
||||
"model": "anthropic/claude-sonnet-4-5"
|
||||
},
|
||||
"unspecified-high": {
|
||||
"model": "anthropic/claude-opus-4-5",
|
||||
"variant": "max"
|
||||
},
|
||||
"writing": {
|
||||
"model": "google/gemini-3-flash-preview"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Only configure categories you have access to.** Unconfigured categories fall back to your system default model.
|
||||
|
||||
### Usage
|
||||
|
||||
```javascript
|
||||
// Via delegate_task tool
|
||||
delegate_task(category="visual-engineering", prompt="Create a responsive dashboard component")
|
||||
delegate_task(category="ultrabrain", prompt="Design the payment processing flow")
|
||||
|
||||
// Or target a specific agent directly (bypasses categories)
|
||||
delegate_task(agent="oracle", prompt="Review this architecture")
|
||||
```
|
||||
|
||||
**Custom Categories:**
|
||||
### Custom Categories
|
||||
|
||||
Add custom categories in `oh-my-opencode.json`:
|
||||
Add your own categories or override built-in ones:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -555,15 +817,22 @@ Add custom categories in `oh-my-opencode.json`:
|
||||
"temperature": 0.2,
|
||||
"prompt_append": "Focus on data analysis, ML pipelines, and statistical methods."
|
||||
},
|
||||
"visual": {
|
||||
"model": "google/gemini-3-pro",
|
||||
"visual-engineering": {
|
||||
"model": "google/gemini-3-pro-preview",
|
||||
"prompt_append": "Use shadcn/ui components and Tailwind CSS."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Each category supports: `model`, `temperature`, `top_p`, `maxTokens`, `thinking`, `reasoningEffort`, `textVerbosity`, `tools`, `prompt_append`.
|
||||
Each category supports: `model`, `temperature`, `top_p`, `maxTokens`, `thinking`, `reasoningEffort`, `textVerbosity`, `tools`, `prompt_append`, `variant`, `description`, `is_unstable_agent`.
|
||||
|
||||
### Additional Category Options
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
| ------------------ | ------- | ------- | --------------------------------------------------------------------------------------------------- |
|
||||
| `description` | string | - | Human-readable description of the category's purpose. Shown in delegate_task prompt. |
|
||||
| `is_unstable_agent`| boolean | `false` | Mark agent as unstable - forces background mode for monitoring. Auto-enabled for gemini models. |
|
||||
|
||||
## Model Resolution System
|
||||
|
||||
@@ -697,10 +966,93 @@ Disable specific built-in hooks via `disabled_hooks` in `~/.config/opencode/oh-m
|
||||
}
|
||||
```
|
||||
|
||||
Available hooks: `todo-continuation-enforcer`, `context-window-monitor`, `session-recovery`, `session-notification`, `comment-checker`, `grep-output-truncator`, `tool-output-truncator`, `directory-agents-injector`, `directory-readme-injector`, `empty-task-response-detector`, `think-mode`, `anthropic-context-window-limit-recovery`, `rules-injector`, `background-notification`, `auto-update-checker`, `startup-toast`, `keyword-detector`, `agent-usage-reminder`, `non-interactive-env`, `interactive-bash-session`, `compaction-context-injector`, `thinking-block-validator`, `claude-code-hooks`, `ralph-loop`, `preemptive-compaction`
|
||||
Available hooks: `todo-continuation-enforcer`, `context-window-monitor`, `session-recovery`, `session-notification`, `comment-checker`, `grep-output-truncator`, `tool-output-truncator`, `directory-agents-injector`, `directory-readme-injector`, `empty-task-response-detector`, `think-mode`, `anthropic-context-window-limit-recovery`, `rules-injector`, `background-notification`, `auto-update-checker`, `startup-toast`, `keyword-detector`, `agent-usage-reminder`, `non-interactive-env`, `interactive-bash-session`, `compaction-context-injector`, `thinking-block-validator`, `claude-code-hooks`, `ralph-loop`, `preemptive-compaction`, `auto-slash-command`, `sisyphus-junior-notepad`, `start-work`
|
||||
|
||||
**Note on `directory-agents-injector`**: This hook is **automatically disabled** when running on OpenCode 1.1.37+ because OpenCode now has native support for dynamically resolving AGENTS.md files from subdirectories (PR #10678). This prevents duplicate AGENTS.md injection. For older OpenCode versions, the hook remains active to provide the same functionality.
|
||||
|
||||
**Note on `auto-update-checker` and `startup-toast`**: The `startup-toast` hook is a sub-feature of `auto-update-checker`. To disable only the startup toast notification while keeping update checking enabled, add `"startup-toast"` to `disabled_hooks`. To disable all update checking features (including the toast), add `"auto-update-checker"` to `disabled_hooks`.
|
||||
|
||||
## Disabled Commands
|
||||
|
||||
Disable specific built-in commands via `disabled_commands` in `~/.config/opencode/oh-my-opencode.json` or `.opencode/oh-my-opencode.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"disabled_commands": ["init-deep", "start-work"]
|
||||
}
|
||||
```
|
||||
|
||||
Available commands: `init-deep`, `start-work`
|
||||
|
||||
## Comment Checker
|
||||
|
||||
Configure comment-checker hook behavior. The comment checker warns when excessive comments are added to code.
|
||||
|
||||
```json
|
||||
{
|
||||
"comment_checker": {
|
||||
"custom_prompt": "Your custom warning message. Use {{comments}} placeholder for detected comments XML."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Default | Description |
|
||||
| ------------- | ------- | -------------------------------------------------------------------------- |
|
||||
| `custom_prompt` | - | Custom warning message to replace the default. Use `{{comments}}` placeholder. |
|
||||
|
||||
## Notification
|
||||
|
||||
Configure notification behavior for background task completion.
|
||||
|
||||
```json
|
||||
{
|
||||
"notification": {
|
||||
"force_enable": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Default | Description |
|
||||
| -------------- | ------- | ---------------------------------------------------------------------------------------------- |
|
||||
| `force_enable` | `false` | Force enable session-notification even if external notification plugins are detected. Default: `false`. |
|
||||
|
||||
## Sisyphus Tasks & Swarm
|
||||
|
||||
Configure Sisyphus Tasks and Swarm systems for advanced task management and multi-agent orchestration.
|
||||
|
||||
```json
|
||||
{
|
||||
"sisyphus": {
|
||||
"tasks": {
|
||||
"enabled": false,
|
||||
"storage_path": ".sisyphus/tasks",
|
||||
"claude_code_compat": false
|
||||
},
|
||||
"swarm": {
|
||||
"enabled": false,
|
||||
"storage_path": ".sisyphus/teams",
|
||||
"ui_mode": "toast"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Tasks Configuration
|
||||
|
||||
| Option | Default | Description |
|
||||
| -------------------- | ------------------ | ------------------------------------------------------------------------- |
|
||||
| `enabled` | `false` | Enable Sisyphus Tasks system |
|
||||
| `storage_path` | `.sisyphus/tasks` | Storage path for tasks (relative to project root) |
|
||||
| `claude_code_compat` | `false` | Enable Claude Code path compatibility mode |
|
||||
|
||||
### Swarm Configuration
|
||||
|
||||
| Option | Default | Description |
|
||||
| -------------- | ------------------ | -------------------------------------------------------------- |
|
||||
| `enabled` | `false` | Enable Sisyphus Swarm system for multi-agent orchestration |
|
||||
| `storage_path` | `.sisyphus/teams` | Storage path for teams (relative to project root) |
|
||||
| `ui_mode` | `toast` | UI mode: `toast` (notifications), `tmux` (panes), or `both` |
|
||||
|
||||
## MCPs
|
||||
|
||||
Exa, Context7 and grep.app MCP enabled by default.
|
||||
@@ -742,6 +1094,38 @@ Add LSP servers via the `lsp` option in `~/.config/opencode/oh-my-opencode.json`
|
||||
|
||||
Each server supports: `command`, `extensions`, `priority`, `env`, `initialization`, `disabled`.
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
| -------------- | -------- | ------- | ---------------------------------------------------------------------- |
|
||||
| `command` | array | - | Command to start the LSP server (executable + args) |
|
||||
| `extensions` | array | - | File extensions this server handles (e.g., `[".ts", ".tsx"]`) |
|
||||
| `priority` | number | - | Server priority when multiple servers match a file |
|
||||
| `env` | object | - | Environment variables for the LSP server (key-value pairs) |
|
||||
| `initialization`| object | - | Custom initialization options passed to the LSP server |
|
||||
| `disabled` | boolean | `false` | Whether to disable this LSP server |
|
||||
|
||||
**Example with advanced options:**
|
||||
|
||||
```json
|
||||
{
|
||||
"lsp": {
|
||||
"typescript-language-server": {
|
||||
"command": ["typescript-language-server", "--stdio"],
|
||||
"extensions": [".ts", ".tsx"],
|
||||
"priority": 10,
|
||||
"env": {
|
||||
"NODE_OPTIONS": "--max-old-space-size=4096"
|
||||
},
|
||||
"initialization": {
|
||||
"preferences": {
|
||||
"includeInlayParameterNameHints": "all",
|
||||
"includeInlayFunctionParameterTypeHints": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Experimental
|
||||
|
||||
Opt-in experimental features that may change or be removed in future versions. Use with caution.
|
||||
@@ -751,7 +1135,29 @@ Opt-in experimental features that may change or be removed in future versions. U
|
||||
"experimental": {
|
||||
"truncate_all_tool_outputs": true,
|
||||
"aggressive_truncation": true,
|
||||
"auto_resume": true
|
||||
"auto_resume": true,
|
||||
"dynamic_context_pruning": {
|
||||
"enabled": false,
|
||||
"notification": "detailed",
|
||||
"turn_protection": {
|
||||
"enabled": true,
|
||||
"turns": 3
|
||||
},
|
||||
"protected_tools": ["task", "todowrite", "lsp_rename"],
|
||||
"strategies": {
|
||||
"deduplication": {
|
||||
"enabled": true
|
||||
},
|
||||
"supersede_writes": {
|
||||
"enabled": true,
|
||||
"aggressive": false
|
||||
},
|
||||
"purge_errors": {
|
||||
"enabled": true,
|
||||
"turns": 5
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -760,7 +1166,72 @@ Opt-in experimental features that may change or be removed in future versions. U
|
||||
| --------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `truncate_all_tool_outputs` | `false` | Truncates ALL tool outputs instead of just whitelisted tools (Grep, Glob, LSP, AST-grep). Tool output truncator is enabled by default - disable via `disabled_hooks`. |
|
||||
| `aggressive_truncation` | `false` | When token limit is exceeded, aggressively truncates tool outputs to fit within limits. More aggressive than the default truncation behavior. Falls back to summarize/revert if insufficient. |
|
||||
| `auto_resume` | `false` | Automatically resumes session after successful recovery from thinking block errors or thinking disabled violations. Extracts the last user message and continues. |
|
||||
| `auto_resume` | `false` | Automatically resumes session after successful recovery from thinking block errors or thinking disabled violations. Extracts last user message and continues. |
|
||||
| `dynamic_context_pruning` | See below | Dynamic context pruning configuration for managing context window usage automatically. See [Dynamic Context Pruning](#dynamic-context-pruning) below. |
|
||||
|
||||
### Dynamic Context Pruning
|
||||
|
||||
Dynamic context pruning automatically manages context window by intelligently pruning old tool outputs. This feature helps maintain performance in long sessions.
|
||||
|
||||
```json
|
||||
{
|
||||
"experimental": {
|
||||
"dynamic_context_pruning": {
|
||||
"enabled": false,
|
||||
"notification": "detailed",
|
||||
"turn_protection": {
|
||||
"enabled": true,
|
||||
"turns": 3
|
||||
},
|
||||
"protected_tools": ["task", "todowrite", "todoread", "lsp_rename", "session_read", "session_write", "session_search"],
|
||||
"strategies": {
|
||||
"deduplication": {
|
||||
"enabled": true
|
||||
},
|
||||
"supersede_writes": {
|
||||
"enabled": true,
|
||||
"aggressive": false
|
||||
},
|
||||
"purge_errors": {
|
||||
"enabled": true,
|
||||
"turns": 5
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Default | Description |
|
||||
| ----------------- | ------- | ----------------------------------------------------------------------------------------- |
|
||||
| `enabled` | `false` | Enable dynamic context pruning |
|
||||
| `notification` | `detailed` | Notification level: `off`, `minimal`, or `detailed` |
|
||||
| `turn_protection` | See below | Turn protection settings - prevent pruning recent tool outputs |
|
||||
|
||||
#### Turn Protection
|
||||
|
||||
| Option | Default | Description |
|
||||
| --------- | ------- | ------------------------------------------------------------ |
|
||||
| `enabled` | `true` | Enable turn protection |
|
||||
| `turns` | `3` | Number of recent turns to protect from pruning (1-10) |
|
||||
|
||||
#### Protected Tools
|
||||
|
||||
Tools that should never be pruned (default):
|
||||
|
||||
```json
|
||||
["task", "todowrite", "todoread", "lsp_rename", "session_read", "session_write", "session_search"]
|
||||
```
|
||||
|
||||
#### Pruning Strategies
|
||||
|
||||
| Strategy | Option | Default | Description |
|
||||
| ------------------- | ------------ | ------- | ---------------------------------------------------------------------------- |
|
||||
| **deduplication** | `enabled` | `true` | Remove duplicate tool calls (same tool + same args) |
|
||||
| **supersede_writes**| `enabled` | `true` | Prune write inputs when file subsequently read |
|
||||
| | `aggressive` | `false` | Aggressive mode: prune any write if ANY subsequent read |
|
||||
| **purge_errors** | `enabled` | `true` | Prune errored tool inputs after N turns |
|
||||
| | `turns` | `5` | Number of turns before pruning errors (1-20) |
|
||||
|
||||
**Warning**: These features are experimental and may cause unexpected behavior. Enable only if you understand the implications.
|
||||
|
||||
|
||||
@@ -320,7 +320,7 @@ Hooks intercept and modify behavior at key points in the agent lifecycle.
|
||||
|
||||
| Hook | Event | Description |
|
||||
|------|-------|-------------|
|
||||
| **directory-agents-injector** | PostToolUse | Auto-injects AGENTS.md when reading files. Walks from file to project root, collecting all AGENTS.md files. |
|
||||
| **directory-agents-injector** | PostToolUse | Auto-injects AGENTS.md when reading files. Walks from file to project root, collecting all AGENTS.md files. **Deprecated for OpenCode 1.1.37+** - Auto-disabled when native AGENTS.md injection is available. |
|
||||
| **directory-readme-injector** | PostToolUse | Auto-injects README.md for directory context. |
|
||||
| **rules-injector** | PostToolUse | Injects rules from `.claude/rules/` when conditions match. Supports globs and alwaysApply. |
|
||||
| **compaction-context-injector** | Stop | Preserves critical context during session compaction. |
|
||||
@@ -521,6 +521,37 @@ mcp:
|
||||
|
||||
The `skill_mcp` tool invokes these operations with full schema discovery.
|
||||
|
||||
#### OAuth-Enabled MCPs
|
||||
|
||||
Skills can define OAuth-protected remote MCP servers. OAuth 2.1 with full RFC compliance (RFC 9728, 8414, 8707, 7591) is supported:
|
||||
|
||||
```yaml
|
||||
---
|
||||
description: My API skill
|
||||
mcp:
|
||||
my-api:
|
||||
url: https://api.example.com/mcp
|
||||
oauth:
|
||||
clientId: ${CLIENT_ID}
|
||||
scopes: ["read", "write"]
|
||||
---
|
||||
```
|
||||
|
||||
When a skill MCP has `oauth` configured:
|
||||
- **Auto-discovery**: Fetches `/.well-known/oauth-protected-resource` (RFC 9728), falls back to `/.well-known/oauth-authorization-server` (RFC 8414)
|
||||
- **Dynamic Client Registration**: Auto-registers with servers supporting RFC 7591 (clientId becomes optional)
|
||||
- **PKCE**: Mandatory for all flows
|
||||
- **Resource Indicators**: Auto-generated from MCP URL per RFC 8707
|
||||
- **Token Storage**: Persisted in `~/.config/opencode/mcp-oauth.json` (chmod 0600)
|
||||
- **Auto-refresh**: Tokens refresh on 401; step-up authorization on 403 with `WWW-Authenticate`
|
||||
- **Dynamic Port**: OAuth callback server uses an auto-discovered available port
|
||||
|
||||
Pre-authenticate via CLI:
|
||||
|
||||
```bash
|
||||
bunx oh-my-opencode mcp oauth login <server-name> --server-url https://api.example.com
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Context Injection
|
||||
|
||||
126
docs/troubleshooting/ollama-streaming-issue.md
Normal file
126
docs/troubleshooting/ollama-streaming-issue.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# Ollama Streaming Issue - JSON Parse Error
|
||||
|
||||
## Problem
|
||||
|
||||
When using Ollama as a provider with oh-my-opencode agents, you may encounter:
|
||||
|
||||
```
|
||||
JSON Parse error: Unexpected EOF
|
||||
```
|
||||
|
||||
This occurs when agents attempt tool calls (e.g., `explore` agent using `mcp_grep_search`).
|
||||
|
||||
## Root Cause
|
||||
|
||||
Ollama returns **NDJSON** (newline-delimited JSON) when `stream: true` is used in API requests:
|
||||
|
||||
```json
|
||||
{"message":{"tool_calls":[{"function":{"name":"read","arguments":{"filePath":"README.md"}}}]}, "done":false}
|
||||
{"message":{"content":""}, "done":true}
|
||||
```
|
||||
|
||||
Claude Code SDK expects a single JSON object, not multiple NDJSON lines, causing the parse error.
|
||||
|
||||
### Why This Happens
|
||||
|
||||
- **Ollama API**: Returns streaming responses as NDJSON by design
|
||||
- **Claude Code SDK**: Doesn't properly handle NDJSON responses for tool calls
|
||||
- **oh-my-opencode**: Passes through the SDK's behavior (can't fix at this layer)
|
||||
|
||||
## Solutions
|
||||
|
||||
### Option 1: Disable Streaming (Recommended - Immediate Fix)
|
||||
|
||||
Configure your Ollama provider to use `stream: false`:
|
||||
|
||||
```json
|
||||
{
|
||||
"provider": "ollama",
|
||||
"model": "qwen3-coder",
|
||||
"stream": false
|
||||
}
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- Works immediately
|
||||
- No code changes needed
|
||||
- Simple configuration
|
||||
|
||||
**Cons:**
|
||||
- Slightly slower response time (no streaming)
|
||||
- Less interactive feedback
|
||||
|
||||
### Option 2: Use Non-Tool Agents Only
|
||||
|
||||
If you need streaming, avoid agents that use tools:
|
||||
|
||||
- ✅ **Safe**: Simple text generation, non-tool tasks
|
||||
- ❌ **Problematic**: Any agent with tool calls (explore, librarian, etc.)
|
||||
|
||||
### Option 3: Wait for SDK Fix (Long-term)
|
||||
|
||||
The proper fix requires Claude Code SDK to:
|
||||
|
||||
1. Detect NDJSON responses
|
||||
2. Parse each line separately
|
||||
3. Merge `tool_calls` from multiple lines
|
||||
4. Return a single merged response
|
||||
|
||||
**Tracking**: https://github.com/code-yeongyu/oh-my-opencode/issues/1124
|
||||
|
||||
## Workaround Implementation
|
||||
|
||||
Until the SDK is fixed, here's how to implement NDJSON parsing (for SDK maintainers):
|
||||
|
||||
```typescript
|
||||
async function parseOllamaStreamResponse(response: string): Promise<object> {
|
||||
const lines = response.split('\n').filter(line => line.trim());
|
||||
const mergedMessage = { tool_calls: [] };
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const json = JSON.parse(line);
|
||||
if (json.message?.tool_calls) {
|
||||
mergedMessage.tool_calls.push(...json.message.tool_calls);
|
||||
}
|
||||
if (json.message?.content) {
|
||||
mergedMessage.content = json.message.content;
|
||||
}
|
||||
} catch (e) {
|
||||
// Skip malformed lines
|
||||
console.warn('Skipping malformed NDJSON line:', line);
|
||||
}
|
||||
}
|
||||
|
||||
return mergedMessage;
|
||||
}
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
To verify the fix works:
|
||||
|
||||
```bash
|
||||
# Test with curl (should work with stream: false)
|
||||
curl -s http://localhost:11434/api/chat \
|
||||
-d '{
|
||||
"model": "qwen3-coder",
|
||||
"messages": [{"role": "user", "content": "Read file README.md"}],
|
||||
"stream": false,
|
||||
"tools": [{"type": "function", "function": {"name": "read", "description": "Read a file", "parameters": {"type": "object", "properties": {"filePath": {"type": "string"}}, "required": ["filePath"]}}}]
|
||||
}'
|
||||
```
|
||||
|
||||
## Related Issues
|
||||
|
||||
- **oh-my-opencode**: https://github.com/code-yeongyu/oh-my-opencode/issues/1124
|
||||
- **Ollama API Docs**: https://github.com/ollama/ollama/blob/main/docs/api.md
|
||||
|
||||
## Getting Help
|
||||
|
||||
If you encounter this issue:
|
||||
|
||||
1. Check your Ollama provider configuration
|
||||
2. Set `stream: false` as a workaround
|
||||
3. Report any additional errors to the issue tracker
|
||||
4. Provide your configuration (without secrets) for debugging
|
||||
17
package.json
17
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode",
|
||||
"version": "3.1.1",
|
||||
"version": "3.1.8",
|
||||
"description": "The Best AI Agent Harness - Batteries-Included OpenCode Plugin with Multi-Model Orchestration, Parallel Background Agents, and Crafted LSP/AST Tools",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
@@ -64,6 +64,7 @@
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"picocolors": "^1.1.1",
|
||||
"picomatch": "^4.0.2",
|
||||
"vscode-jsonrpc": "^8.2.0",
|
||||
"zod": "^4.1.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -73,13 +74,13 @@
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"oh-my-opencode-darwin-arm64": "3.1.1",
|
||||
"oh-my-opencode-darwin-x64": "3.1.1",
|
||||
"oh-my-opencode-linux-arm64": "3.1.1",
|
||||
"oh-my-opencode-linux-arm64-musl": "3.1.1",
|
||||
"oh-my-opencode-linux-x64": "3.1.1",
|
||||
"oh-my-opencode-linux-x64-musl": "3.1.1",
|
||||
"oh-my-opencode-windows-x64": "3.1.1"
|
||||
"oh-my-opencode-darwin-arm64": "3.1.8",
|
||||
"oh-my-opencode-darwin-x64": "3.1.8",
|
||||
"oh-my-opencode-linux-arm64": "3.1.8",
|
||||
"oh-my-opencode-linux-arm64-musl": "3.1.8",
|
||||
"oh-my-opencode-linux-x64": "3.1.8",
|
||||
"oh-my-opencode-linux-x64-musl": "3.1.8",
|
||||
"oh-my-opencode-windows-x64": "3.1.8"
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"@ast-grep/cli",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-darwin-arm64",
|
||||
"version": "3.1.1",
|
||||
"version": "3.1.8",
|
||||
"description": "Platform-specific binary for oh-my-opencode (darwin-arm64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
22
packages/darwin-x64-baseline/package.json
Normal file
22
packages/darwin-x64-baseline/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "oh-my-opencode-darwin-x64-baseline",
|
||||
"version": "3.1.1",
|
||||
"description": "Platform-specific binary for oh-my-opencode (darwin-x64-baseline, no AVX2)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/code-yeongyu/oh-my-opencode"
|
||||
},
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"files": [
|
||||
"bin"
|
||||
],
|
||||
"bin": {
|
||||
"oh-my-opencode": "./bin/oh-my-opencode"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-darwin-x64",
|
||||
"version": "3.1.1",
|
||||
"version": "3.1.8",
|
||||
"description": "Platform-specific binary for oh-my-opencode (darwin-x64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-arm64-musl",
|
||||
"version": "3.1.1",
|
||||
"version": "3.1.8",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-arm64-musl)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-arm64",
|
||||
"version": "3.1.1",
|
||||
"version": "3.1.8",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-arm64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
25
packages/linux-x64-baseline/package.json
Normal file
25
packages/linux-x64-baseline/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-x64-baseline",
|
||||
"version": "3.1.1",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-x64-baseline, no AVX2)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/code-yeongyu/oh-my-opencode"
|
||||
},
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"files": [
|
||||
"bin"
|
||||
],
|
||||
"bin": {
|
||||
"oh-my-opencode": "./bin/oh-my-opencode"
|
||||
}
|
||||
}
|
||||
25
packages/linux-x64-musl-baseline/package.json
Normal file
25
packages/linux-x64-musl-baseline/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-x64-musl-baseline",
|
||||
"version": "3.1.1",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-x64-musl-baseline, no AVX2)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/code-yeongyu/oh-my-opencode"
|
||||
},
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"files": [
|
||||
"bin"
|
||||
],
|
||||
"bin": {
|
||||
"oh-my-opencode": "./bin/oh-my-opencode"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-x64-musl",
|
||||
"version": "3.1.1",
|
||||
"version": "3.1.8",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-x64-musl)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-x64",
|
||||
"version": "3.1.1",
|
||||
"version": "3.1.8",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-x64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
22
packages/windows-x64-baseline/package.json
Normal file
22
packages/windows-x64-baseline/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "oh-my-opencode-windows-x64-baseline",
|
||||
"version": "3.1.1",
|
||||
"description": "Platform-specific binary for oh-my-opencode (windows-x64-baseline, no AVX2)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/code-yeongyu/oh-my-opencode"
|
||||
},
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"files": [
|
||||
"bin"
|
||||
],
|
||||
"bin": {
|
||||
"oh-my-opencode": "./bin/oh-my-opencode.exe"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-windows-x64",
|
||||
"version": "3.1.1",
|
||||
"version": "3.1.8",
|
||||
"description": "Platform-specific binary for oh-my-opencode (windows-x64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
79
script/build-binaries.test.ts
Normal file
79
script/build-binaries.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
// script/build-binaries.test.ts
|
||||
// Tests for platform binary build configuration
|
||||
|
||||
import { describe, expect, it } from "bun:test";
|
||||
|
||||
// Import PLATFORMS from build-binaries.ts
|
||||
// We need to export it first, but for now we'll test the expected structure
|
||||
const EXPECTED_BASELINE_TARGETS = [
|
||||
"bun-linux-x64-baseline",
|
||||
"bun-linux-x64-musl-baseline",
|
||||
"bun-darwin-x64-baseline",
|
||||
"bun-windows-x64-baseline",
|
||||
];
|
||||
|
||||
describe("build-binaries", () => {
|
||||
describe("PLATFORMS array", () => {
|
||||
it("includes baseline variants for non-AVX2 CPU support", async () => {
|
||||
// given
|
||||
const module = await import("./build-binaries.ts");
|
||||
const platforms = (module as { PLATFORMS: { target: string }[] }).PLATFORMS;
|
||||
const targets = platforms.map((p) => p.target);
|
||||
|
||||
// when
|
||||
const hasAllBaselineTargets = EXPECTED_BASELINE_TARGETS.every((baseline) =>
|
||||
targets.includes(baseline)
|
||||
);
|
||||
|
||||
// then
|
||||
expect(hasAllBaselineTargets).toBe(true);
|
||||
for (const baseline of EXPECTED_BASELINE_TARGETS) {
|
||||
expect(targets).toContain(baseline);
|
||||
}
|
||||
});
|
||||
|
||||
it("has correct directory names for baseline platforms", async () => {
|
||||
// given
|
||||
const module = await import("./build-binaries.ts");
|
||||
const platforms = (module as { PLATFORMS: { dir: string; target: string }[] }).PLATFORMS;
|
||||
|
||||
// when
|
||||
const baselinePlatforms = platforms.filter((p) => p.target.includes("baseline"));
|
||||
|
||||
// then
|
||||
expect(baselinePlatforms.length).toBe(4);
|
||||
expect(baselinePlatforms.map((p) => p.dir)).toContain("linux-x64-baseline");
|
||||
expect(baselinePlatforms.map((p) => p.dir)).toContain("linux-x64-musl-baseline");
|
||||
expect(baselinePlatforms.map((p) => p.dir)).toContain("darwin-x64-baseline");
|
||||
expect(baselinePlatforms.map((p) => p.dir)).toContain("windows-x64-baseline");
|
||||
});
|
||||
|
||||
it("has correct binary names for baseline platforms", async () => {
|
||||
// given
|
||||
const module = await import("./build-binaries.ts");
|
||||
const platforms = (module as { PLATFORMS: { dir: string; target: string; binary: string }[] }).PLATFORMS;
|
||||
|
||||
// when
|
||||
const windowsBaseline = platforms.find((p) => p.target === "bun-windows-x64-baseline");
|
||||
const linuxBaseline = platforms.find((p) => p.target === "bun-linux-x64-baseline");
|
||||
|
||||
// then
|
||||
expect(windowsBaseline?.binary).toBe("oh-my-opencode.exe");
|
||||
expect(linuxBaseline?.binary).toBe("oh-my-opencode");
|
||||
});
|
||||
|
||||
it("has descriptions mentioning no AVX2 for baseline platforms", async () => {
|
||||
// given
|
||||
const module = await import("./build-binaries.ts");
|
||||
const platforms = (module as { PLATFORMS: { target: string; description: string }[] }).PLATFORMS;
|
||||
|
||||
// when
|
||||
const baselinePlatforms = platforms.filter((p) => p.target.includes("baseline"));
|
||||
|
||||
// then
|
||||
for (const platform of baselinePlatforms) {
|
||||
expect(platform.description).toContain("no AVX2");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -13,14 +13,18 @@ interface PlatformTarget {
|
||||
description: string;
|
||||
}
|
||||
|
||||
const PLATFORMS: PlatformTarget[] = [
|
||||
export const PLATFORMS: PlatformTarget[] = [
|
||||
{ dir: "darwin-arm64", target: "bun-darwin-arm64", binary: "oh-my-opencode", description: "macOS ARM64" },
|
||||
{ dir: "darwin-x64", target: "bun-darwin-x64", binary: "oh-my-opencode", description: "macOS x64" },
|
||||
{ dir: "darwin-x64-baseline", target: "bun-darwin-x64-baseline", binary: "oh-my-opencode", description: "macOS x64 (no AVX2)" },
|
||||
{ dir: "linux-x64", target: "bun-linux-x64", binary: "oh-my-opencode", description: "Linux x64 (glibc)" },
|
||||
{ dir: "linux-x64-baseline", target: "bun-linux-x64-baseline", binary: "oh-my-opencode", description: "Linux x64 (glibc, no AVX2)" },
|
||||
{ dir: "linux-arm64", target: "bun-linux-arm64", binary: "oh-my-opencode", description: "Linux ARM64 (glibc)" },
|
||||
{ dir: "linux-x64-musl", target: "bun-linux-x64-musl", binary: "oh-my-opencode", description: "Linux x64 (musl)" },
|
||||
{ dir: "linux-x64-musl-baseline", target: "bun-linux-x64-musl-baseline", binary: "oh-my-opencode", description: "Linux x64 (musl, no AVX2)" },
|
||||
{ dir: "linux-arm64-musl", target: "bun-linux-arm64-musl", binary: "oh-my-opencode", description: "Linux ARM64 (musl)" },
|
||||
{ dir: "windows-x64", target: "bun-windows-x64", binary: "oh-my-opencode.exe", description: "Windows x64" },
|
||||
{ dir: "windows-x64-baseline", target: "bun-windows-x64-baseline", binary: "oh-my-opencode.exe", description: "Windows x64 (no AVX2)" },
|
||||
];
|
||||
|
||||
const ENTRY_POINT = "src/cli/index.ts";
|
||||
|
||||
@@ -879,6 +879,102 @@
|
||||
"created_at": "2026-01-26T23:20:30Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1157
|
||||
},
|
||||
{
|
||||
"name": "ghtndl",
|
||||
"id": 117787238,
|
||||
"comment_id": 3802593326,
|
||||
"created_at": "2026-01-27T01:27:17Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1158
|
||||
},
|
||||
{
|
||||
"name": "alvinunreal",
|
||||
"id": 204474669,
|
||||
"comment_id": 3796402213,
|
||||
"created_at": "2026-01-25T10:26:58Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1100
|
||||
},
|
||||
{
|
||||
"name": "MoerAI",
|
||||
"id": 26067127,
|
||||
"comment_id": 3803968993,
|
||||
"created_at": "2026-01-27T09:00:57Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1172
|
||||
},
|
||||
{
|
||||
"name": "moha-abdi",
|
||||
"id": 83307623,
|
||||
"comment_id": 3804988070,
|
||||
"created_at": "2026-01-27T12:36:21Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1179
|
||||
},
|
||||
{
|
||||
"name": "zycaskevin",
|
||||
"id": 223135116,
|
||||
"comment_id": 3806137669,
|
||||
"created_at": "2026-01-27T16:20:38Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1184
|
||||
},
|
||||
{
|
||||
"name": "agno01",
|
||||
"id": 4479380,
|
||||
"comment_id": 3808373433,
|
||||
"created_at": "2026-01-28T01:02:02Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1188
|
||||
},
|
||||
{
|
||||
"name": "rooftop-Owl",
|
||||
"id": 254422872,
|
||||
"comment_id": 3809867225,
|
||||
"created_at": "2026-01-28T08:46:58Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1197
|
||||
},
|
||||
{
|
||||
"name": "youming-ai",
|
||||
"id": 173424537,
|
||||
"comment_id": 3811195276,
|
||||
"created_at": "2026-01-28T13:04:16Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1203
|
||||
},
|
||||
{
|
||||
"name": "KennyDizi",
|
||||
"id": 16578966,
|
||||
"comment_id": 3811619818,
|
||||
"created_at": "2026-01-28T14:26:10Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1214
|
||||
},
|
||||
{
|
||||
"name": "mrdavidlaing",
|
||||
"id": 227505,
|
||||
"comment_id": 3813542625,
|
||||
"created_at": "2026-01-28T19:51:34Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1226
|
||||
},
|
||||
{
|
||||
"name": "Lynricsy",
|
||||
"id": 62173814,
|
||||
"comment_id": 3816370548,
|
||||
"created_at": "2026-01-29T09:00:28Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1241
|
||||
},
|
||||
{
|
||||
"name": "LeekJay",
|
||||
"id": 39609783,
|
||||
"comment_id": 3819009761,
|
||||
"created_at": "2026-01-29T17:03:24Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1254
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -529,7 +529,7 @@ export function createAtlasAgent(ctx: OrchestratorContext): AgentConfig {
|
||||
])
|
||||
return {
|
||||
description:
|
||||
"Orchestrates work via delegate_task() to complete ALL tasks in a todo list until fully done",
|
||||
"Orchestrates work via delegate_task() to complete ALL tasks in a todo list until fully done. (Atlas - OhMyOpenCode)",
|
||||
mode: "primary" as const,
|
||||
...(ctx.model ? { model: ctx.model } : {}),
|
||||
temperature: 0.1,
|
||||
|
||||
@@ -33,7 +33,7 @@ export function createExploreAgent(model: string): AgentConfig {
|
||||
|
||||
return {
|
||||
description:
|
||||
'Contextual grep for codebases. Answers "Where is X?", "Which file has Y?", "Find the code that does Z". Fire multiple in parallel for broad searches. Specify thoroughness: "quick" for basic, "medium" for moderate, "very thorough" for comprehensive analysis.',
|
||||
'Contextual grep for codebases. Answers "Where is X?", "Which file has Y?", "Find the code that does Z". Fire multiple in parallel for broad searches. Specify thoroughness: "quick" for basic, "medium" for moderate, "very thorough" for comprehensive analysis. (Explore - OhMyOpenCode)',
|
||||
mode: "subagent" as const,
|
||||
model,
|
||||
temperature: 0.1,
|
||||
|
||||
@@ -30,7 +30,7 @@ export function createLibrarianAgent(model: string): AgentConfig {
|
||||
|
||||
return {
|
||||
description:
|
||||
"Specialized codebase understanding agent for multi-repository analysis, searching remote codebases, retrieving official documentation, and finding implementation examples using GitHub CLI, Context7, and Web Search. MUST BE USED when users ask to look up code in remote repositories, explain library internals, or find usage examples in open source.",
|
||||
"Specialized codebase understanding agent for multi-repository analysis, searching remote codebases, retrieving official documentation, and finding implementation examples using GitHub CLI, Context7, and Web Search. MUST BE USED when users ask to look up code in remote repositories, explain library internals, or find usage examples in open source. (Librarian - OhMyOpenCode)",
|
||||
mode: "subagent" as const,
|
||||
model,
|
||||
temperature: 0.1,
|
||||
|
||||
@@ -230,6 +230,8 @@ call_omo_agent(subagent_type="librarian", prompt="Find OSS implementations of Z.
|
||||
- [Risk 2]: [Mitigation]
|
||||
|
||||
## Directives for Prometheus
|
||||
|
||||
### Core Directives
|
||||
- MUST: [Required action]
|
||||
- MUST: [Required action]
|
||||
- MUST NOT: [Forbidden action]
|
||||
@@ -237,6 +239,29 @@ call_omo_agent(subagent_type="librarian", prompt="Find OSS implementations of Z.
|
||||
- PATTERN: Follow \`[file:lines]\`
|
||||
- TOOL: Use \`[specific tool]\` for [purpose]
|
||||
|
||||
### QA/Acceptance Criteria Directives (MANDATORY)
|
||||
> **ZERO USER INTERVENTION PRINCIPLE**: All acceptance criteria MUST be executable by agents.
|
||||
|
||||
- MUST: Write acceptance criteria as executable commands (curl, bun test, playwright actions)
|
||||
- MUST: Include exact expected outputs, not vague descriptions
|
||||
- MUST: Specify verification tool for each deliverable type (playwright for UI, curl for API, etc.)
|
||||
- MUST NOT: Create criteria requiring "user manually tests..."
|
||||
- MUST NOT: Create criteria requiring "user visually confirms..."
|
||||
- MUST NOT: Create criteria requiring "user clicks/interacts..."
|
||||
- MUST NOT: Use placeholders without concrete examples (bad: "[endpoint]", good: "/api/users")
|
||||
|
||||
Example of GOOD acceptance criteria:
|
||||
\`\`\`
|
||||
curl -s http://localhost:3000/api/health | jq '.status'
|
||||
# Assert: Output is "ok"
|
||||
\`\`\`
|
||||
|
||||
Example of BAD acceptance criteria (FORBIDDEN):
|
||||
\`\`\`
|
||||
User opens browser and checks if the page loads correctly.
|
||||
User confirms the button works as expected.
|
||||
\`\`\`
|
||||
|
||||
## Recommended Approach
|
||||
[1-2 sentence summary of how to proceed]
|
||||
\`\`\`
|
||||
@@ -263,12 +288,16 @@ call_omo_agent(subagent_type="librarian", prompt="Find OSS implementations of Z.
|
||||
- Ask generic questions ("What's the scope?")
|
||||
- Proceed without addressing ambiguity
|
||||
- Make assumptions about user's codebase
|
||||
- Suggest acceptance criteria requiring user intervention ("user manually tests", "user confirms", "user clicks")
|
||||
- Leave QA/acceptance criteria vague or placeholder-heavy
|
||||
|
||||
**ALWAYS**:
|
||||
- Classify intent FIRST
|
||||
- Be specific ("Should this change UserService only, or also AuthService?")
|
||||
- Explore before asking (for Build/Research intents)
|
||||
- Provide actionable directives for Prometheus
|
||||
- Include QA automation directives in every output
|
||||
- Ensure acceptance criteria are agent-executable (commands, not human actions)
|
||||
`
|
||||
|
||||
const metisRestrictions = createAgentToolRestrictions([
|
||||
@@ -281,7 +310,7 @@ const metisRestrictions = createAgentToolRestrictions([
|
||||
export function createMetisAgent(model: string): AgentConfig {
|
||||
return {
|
||||
description:
|
||||
"Pre-planning consultant that analyzes requests to identify hidden intentions, ambiguities, and AI failure points.",
|
||||
"Pre-planning consultant that analyzes requests to identify hidden intentions, ambiguities, and AI failure points. (Metis - OhMyOpenCode)",
|
||||
mode: "subagent" as const,
|
||||
model,
|
||||
temperature: 0.3,
|
||||
|
||||
@@ -399,7 +399,7 @@ export function createMomusAgent(model: string): AgentConfig {
|
||||
|
||||
const base = {
|
||||
description:
|
||||
"Expert reviewer for evaluating work plans against rigorous clarity, verifiability, and completeness standards.",
|
||||
"Expert reviewer for evaluating work plans against rigorous clarity, verifiability, and completeness standards. (Momus - OhMyOpenCode)",
|
||||
mode: "subagent" as const,
|
||||
model,
|
||||
temperature: 0.1,
|
||||
|
||||
@@ -14,7 +14,7 @@ export function createMultimodalLookerAgent(model: string): AgentConfig {
|
||||
|
||||
return {
|
||||
description:
|
||||
"Analyze media files (PDFs, images, diagrams) that require interpretation beyond raw text. Extracts specific information or summaries from documents, describes visual content. Use when you need analyzed/extracted data rather than literal file contents.",
|
||||
"Analyze media files (PDFs, images, diagrams) that require interpretation beyond raw text. Extracts specific information or summaries from documents, describes visual content. Use when you need analyzed/extracted data rather than literal file contents. (Multimodal-Looker - OhMyOpenCode)",
|
||||
mode: "subagent" as const,
|
||||
model,
|
||||
temperature: 0.1,
|
||||
|
||||
@@ -105,7 +105,7 @@ export function createOracleAgent(model: string): AgentConfig {
|
||||
|
||||
const base = {
|
||||
description:
|
||||
"Read-only consultation agent. High-IQ reasoning specialist for debugging hard problems and high-difficulty architecture design.",
|
||||
"Read-only consultation agent. High-IQ reasoning specialist for debugging hard problems and high-difficulty architecture design. (Oracle - OhMyOpenCode)",
|
||||
mode: "subagent" as const,
|
||||
model,
|
||||
temperature: 0.1,
|
||||
|
||||
@@ -863,6 +863,20 @@ Generate plan to: \`.sisyphus/plans/{name}.md\`
|
||||
\`\`\`markdown
|
||||
# {Plan Title}
|
||||
|
||||
## TL;DR
|
||||
|
||||
> **Quick Summary**: [1-2 sentences capturing the core objective and approach]
|
||||
>
|
||||
> **Deliverables**: [Bullet list of concrete outputs]
|
||||
> - [Output 1]
|
||||
> - [Output 2]
|
||||
>
|
||||
> **Estimated Effort**: [Quick | Short | Medium | Large | XL]
|
||||
> **Parallel Execution**: [YES - N waves | NO - sequential]
|
||||
> **Critical Path**: [Task X → Task Y → Task Z]
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
### Original Request
|
||||
@@ -939,53 +953,89 @@ Each TODO follows RED-GREEN-REFACTOR:
|
||||
- Example: Create \`src/__tests__/example.test.ts\`
|
||||
- Verify: \`bun test\` → 1 test passes
|
||||
|
||||
### If Manual QA Only
|
||||
### If Automated Verification Only (NO User Intervention)
|
||||
|
||||
**CRITICAL**: Without automated tests, manual verification MUST be exhaustive.
|
||||
> **CRITICAL PRINCIPLE: ZERO USER INTERVENTION**
|
||||
>
|
||||
> **NEVER** create acceptance criteria that require:
|
||||
> - "User manually tests..." / "사용자가 직접 테스트..."
|
||||
> - "User visually confirms..." / "사용자가 눈으로 확인..."
|
||||
> - "User interacts with..." / "사용자가 직접 조작..."
|
||||
> - "Ask user to verify..." / "사용자에게 확인 요청..."
|
||||
> - ANY step that requires a human to perform an action
|
||||
>
|
||||
> **ALL verification MUST be automated and executable by the agent.**
|
||||
> If a verification cannot be automated, find an automated alternative or explicitly note it as a known limitation.
|
||||
|
||||
Each TODO includes detailed verification procedures:
|
||||
Each TODO includes EXECUTABLE verification procedures that agents can run directly:
|
||||
|
||||
**By Deliverable Type:**
|
||||
|
||||
| Type | Verification Tool | Procedure |
|
||||
|------|------------------|-----------|
|
||||
| **Frontend/UI** | Playwright browser | Navigate, interact, screenshot |
|
||||
| **TUI/CLI** | interactive_bash (tmux) | Run command, verify output |
|
||||
| **API/Backend** | curl / httpie | Send request, verify response |
|
||||
| **Library/Module** | Node/Python REPL | Import, call, verify |
|
||||
| **Config/Infra** | Shell commands | Apply, verify state |
|
||||
| Type | Verification Tool | Automated Procedure |
|
||||
|------|------------------|---------------------|
|
||||
| **Frontend/UI** | Playwright browser via playwright skill | Agent navigates, clicks, screenshots, asserts DOM state |
|
||||
| **TUI/CLI** | interactive_bash (tmux) | Agent runs command, captures output, validates expected strings |
|
||||
| **API/Backend** | curl / httpie via Bash | Agent sends request, parses response, validates JSON fields |
|
||||
| **Library/Module** | Node/Python REPL via Bash | Agent imports, calls function, compares output |
|
||||
| **Config/Infra** | Shell commands via Bash | Agent applies config, runs state check, validates output |
|
||||
|
||||
**Evidence Required:**
|
||||
- Commands run with actual output
|
||||
- Screenshots for visual changes
|
||||
- Response bodies for API changes
|
||||
- Terminal output for CLI changes
|
||||
**Evidence Requirements (Agent-Executable):**
|
||||
- Command output captured and compared against expected patterns
|
||||
- Screenshots saved to .sisyphus/evidence/ for visual verification
|
||||
- JSON response fields validated with specific assertions
|
||||
- Exit codes checked (0 = success)
|
||||
|
||||
---
|
||||
|
||||
## Task Flow
|
||||
## Execution Strategy
|
||||
|
||||
### Parallel Execution Waves
|
||||
|
||||
> Maximize throughput by grouping independent tasks into parallel waves.
|
||||
> Each wave completes before the next begins.
|
||||
|
||||
\`\`\`
|
||||
Task 1 → Task 2 → Task 3
|
||||
↘ Task 4 (parallel)
|
||||
Wave 1 (Start Immediately):
|
||||
├── Task 1: [no dependencies]
|
||||
└── Task 5: [no dependencies]
|
||||
|
||||
Wave 2 (After Wave 1):
|
||||
├── Task 2: [depends: 1]
|
||||
├── Task 3: [depends: 1]
|
||||
└── Task 6: [depends: 5]
|
||||
|
||||
Wave 3 (After Wave 2):
|
||||
└── Task 4: [depends: 2, 3]
|
||||
|
||||
Critical Path: Task 1 → Task 2 → Task 4
|
||||
Parallel Speedup: ~40% faster than sequential
|
||||
\`\`\`
|
||||
|
||||
## Parallelization
|
||||
### Dependency Matrix
|
||||
|
||||
| Group | Tasks | Reason |
|
||||
|-------|-------|--------|
|
||||
| A | 2, 3 | Independent files |
|
||||
| Task | Depends On | Blocks | Can Parallelize With |
|
||||
|------|------------|--------|---------------------|
|
||||
| 1 | None | 2, 3 | 5 |
|
||||
| 2 | 1 | 4 | 3, 6 |
|
||||
| 3 | 1 | 4 | 2, 6 |
|
||||
| 4 | 2, 3 | None | None (final) |
|
||||
| 5 | None | 6 | 1 |
|
||||
| 6 | 5 | None | 2, 3 |
|
||||
|
||||
| Task | Depends On | Reason |
|
||||
|------|------------|--------|
|
||||
| 4 | 1 | Requires output from 1 |
|
||||
### Agent Dispatch Summary
|
||||
|
||||
| Wave | Tasks | Recommended Agents |
|
||||
|------|-------|-------------------|
|
||||
| 1 | 1, 5 | delegate_task(category="...", load_skills=[...], run_in_background=true) |
|
||||
| 2 | 2, 3, 6 | dispatch parallel after Wave 1 completes |
|
||||
| 3 | 4 | final integration task |
|
||||
|
||||
---
|
||||
|
||||
## TODOs
|
||||
|
||||
> Implementation + Test = ONE Task. Never separate.
|
||||
> Specify parallelizability for EVERY task.
|
||||
> EVERY task MUST have: Recommended Agent Profile + Parallelization info.
|
||||
|
||||
- [ ] 1. [Task Title]
|
||||
|
||||
@@ -996,7 +1046,21 @@ Task 1 → Task 2 → Task 3
|
||||
**Must NOT do**:
|
||||
- [Specific exclusions from guardrails]
|
||||
|
||||
**Parallelizable**: YES (with 3, 4) | NO (depends on 0)
|
||||
**Recommended Agent Profile**:
|
||||
> Select category + skills based on task domain. Justify each choice.
|
||||
- **Category**: \`[visual-engineering | ultrabrain | artistry | quick | unspecified-low | unspecified-high | writing]\`
|
||||
- Reason: [Why this category fits the task domain]
|
||||
- **Skills**: [\`skill-1\`, \`skill-2\`]
|
||||
- \`skill-1\`: [Why needed - domain overlap explanation]
|
||||
- \`skill-2\`: [Why needed - domain overlap explanation]
|
||||
- **Skills Evaluated but Omitted**:
|
||||
- \`omitted-skill\`: [Why domain doesn't overlap]
|
||||
|
||||
**Parallelization**:
|
||||
- **Can Run In Parallel**: YES | NO
|
||||
- **Parallel Group**: Wave N (with Tasks X, Y) | Sequential
|
||||
- **Blocks**: [Tasks that depend on this task completing]
|
||||
- **Blocked By**: [Tasks this depends on] | None (can start immediately)
|
||||
|
||||
**References** (CRITICAL - Be Exhaustive):
|
||||
|
||||
@@ -1029,53 +1093,76 @@ Task 1 → Task 2 → Task 3
|
||||
|
||||
**Acceptance Criteria**:
|
||||
|
||||
> CRITICAL: Acceptance = EXECUTION, not just "it should work".
|
||||
> The executor MUST run these commands and verify output.
|
||||
> **CRITICAL: AGENT-EXECUTABLE VERIFICATION ONLY**
|
||||
>
|
||||
> - Acceptance = EXECUTION by the agent, not "user checks if it works"
|
||||
> - Every criterion MUST be verifiable by running a command or using a tool
|
||||
> - NO steps like "user opens browser", "user clicks", "user confirms"
|
||||
> - If you write "[placeholder]" - REPLACE IT with actual values based on task context
|
||||
|
||||
**If TDD (tests enabled):**
|
||||
- [ ] Test file created: \`[path].test.ts\`
|
||||
- [ ] Test covers: [specific scenario]
|
||||
- [ ] \`bun test [file]\` → PASS (N tests, 0 failures)
|
||||
- [ ] Test file created: src/auth/login.test.ts
|
||||
- [ ] Test covers: successful login returns JWT token
|
||||
- [ ] bun test src/auth/login.test.ts → PASS (3 tests, 0 failures)
|
||||
|
||||
**Manual Execution Verification (ALWAYS include, even with tests):**
|
||||
**Automated Verification (ALWAYS include, choose by deliverable type):**
|
||||
|
||||
*Choose based on deliverable type:*
|
||||
**For Frontend/UI changes** (using playwright skill):
|
||||
\\\`\\\`\\\`
|
||||
# Agent executes via playwright browser automation:
|
||||
1. Navigate to: http://localhost:3000/login
|
||||
2. Fill: input[name="email"] with "test@example.com"
|
||||
3. Fill: input[name="password"] with "password123"
|
||||
4. Click: button[type="submit"]
|
||||
5. Wait for: selector ".dashboard-welcome" to be visible
|
||||
6. Assert: text "Welcome back" appears on page
|
||||
7. Screenshot: .sisyphus/evidence/task-1-login-success.png
|
||||
\\\`\\\`\\\`
|
||||
|
||||
**For Frontend/UI changes:**
|
||||
- [ ] Using playwright browser automation:
|
||||
- Navigate to: \`http://localhost:[port]/[path]\`
|
||||
- Action: [click X, fill Y, scroll to Z]
|
||||
- Verify: [visual element appears, animation completes, state changes]
|
||||
- Screenshot: Save evidence to \`.sisyphus/evidence/[task-id]-[step].png\`
|
||||
**For TUI/CLI changes** (using interactive_bash):
|
||||
\\\`\\\`\\\`
|
||||
# Agent executes via tmux session:
|
||||
1. Command: ./my-cli --config test.yaml
|
||||
2. Wait for: "Configuration loaded" in output
|
||||
3. Send keys: "q" to quit
|
||||
4. Assert: Exit code 0
|
||||
5. Assert: Output contains "Goodbye"
|
||||
\\\`\\\`\\\`
|
||||
|
||||
**For TUI/CLI changes:**
|
||||
- [ ] Using interactive_bash (tmux session):
|
||||
- Command: \`[exact command to run]\`
|
||||
- Input sequence: [if interactive, list inputs]
|
||||
- Expected output contains: \`[expected string or pattern]\`
|
||||
- Exit code: [0 for success, specific code if relevant]
|
||||
**For API/Backend changes** (using Bash curl):
|
||||
\\\`\\\`\\\`bash
|
||||
# Agent runs:
|
||||
curl -s -X POST http://localhost:8080/api/users \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"email":"new@test.com","name":"Test User"}' \\
|
||||
| jq '.id'
|
||||
# Assert: Returns non-empty UUID
|
||||
# Assert: HTTP status 201
|
||||
\\\`\\\`\\\`
|
||||
|
||||
**For API/Backend changes:**
|
||||
- [ ] Request: \`curl -X [METHOD] http://localhost:[port]/[endpoint] -H "Content-Type: application/json" -d '[body]'\`
|
||||
- [ ] Response status: [200/201/etc]
|
||||
- [ ] Response body contains: \`{"key": "expected_value"}\`
|
||||
**For Library/Module changes** (using Bash node/bun):
|
||||
\\\`\\\`\\\`bash
|
||||
# Agent runs:
|
||||
bun -e "import { validateEmail } from './src/utils/validate'; console.log(validateEmail('test@example.com'))"
|
||||
# Assert: Output is "true"
|
||||
|
||||
**For Library/Module changes:**
|
||||
- [ ] REPL verification:
|
||||
\`\`\`
|
||||
> import { [function] } from '[module]'
|
||||
> [function]([args])
|
||||
Expected: [output]
|
||||
\`\`\`
|
||||
bun -e "import { validateEmail } from './src/utils/validate'; console.log(validateEmail('invalid'))"
|
||||
# Assert: Output is "false"
|
||||
\\\`\\\`\\\`
|
||||
|
||||
**For Config/Infra changes:**
|
||||
- [ ] Apply: \`[command to apply config]\`
|
||||
- [ ] Verify state: \`[command to check state]\` → \`[expected output]\`
|
||||
**For Config/Infra changes** (using Bash):
|
||||
\\\`\\\`\\\`bash
|
||||
# Agent runs:
|
||||
docker compose up -d
|
||||
# Wait 5s for containers
|
||||
docker compose ps --format json | jq '.[].State'
|
||||
# Assert: All states are "running"
|
||||
\\\`\\\`\\\`
|
||||
|
||||
**Evidence Required:**
|
||||
- [ ] Command output captured (copy-paste actual terminal output)
|
||||
- [ ] Screenshot saved (for visual changes)
|
||||
- [ ] Response body logged (for API changes)
|
||||
**Evidence to Capture:**
|
||||
- [ ] Terminal output from verification commands (actual output, not expected)
|
||||
- [ ] Screenshot files in .sisyphus/evidence/ for UI changes
|
||||
- [ ] JSON response bodies for API changes
|
||||
|
||||
**Commit**: YES | NO (groups with N)
|
||||
- Message: \`type(scope): desc\`
|
||||
|
||||
@@ -84,7 +84,7 @@ export function createSisyphusJuniorAgentWithOverrides(
|
||||
|
||||
const base: AgentConfig = {
|
||||
description: override?.description ??
|
||||
"Sisyphus-Junior - Focused task executor. Same discipline, no delegation.",
|
||||
"Focused task executor. Same discipline, no delegation. (Sisyphus-Junior - OhMyOpenCode)",
|
||||
mode: "subagent" as const,
|
||||
model,
|
||||
temperature,
|
||||
|
||||
@@ -433,7 +433,7 @@ export function createSisyphusAgent(
|
||||
const permission = { question: "allow", call_omo_agent: "deny" } as AgentConfig["permission"]
|
||||
const base = {
|
||||
description:
|
||||
"Sisyphus - Powerful AI orchestrator from OhMyOpenCode. Plans obsessively with todos, assesses search complexity before exploration, delegates strategically via category+skills combinations. Uses explore for internal code (parallel-friendly), librarian for external docs.",
|
||||
"Powerful AI orchestrator. Plans obsessively with todos, assesses search complexity before exploration, delegates strategically via category+skills combinations. Uses explore for internal code (parallel-friendly), librarian for external docs. (Sisyphus - OhMyOpenCode)",
|
||||
mode: "primary" as const,
|
||||
model,
|
||||
maxTokens: 64000,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, test, expect, beforeEach } from "bun:test"
|
||||
import { describe, test, expect, beforeEach, spyOn, afterEach } from "bun:test"
|
||||
import { createBuiltinAgents } from "./utils"
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import { clearSkillCache } from "../features/opencode-skill-loader/skill-content"
|
||||
import * as connectedProvidersCache from "../shared/connected-providers-cache"
|
||||
|
||||
const TEST_DEFAULT_MODEL = "anthropic/claude-opus-4-5"
|
||||
|
||||
@@ -46,17 +47,31 @@ describe("createBuiltinAgents with model overrides", () => {
|
||||
expect(agents.sisyphus.reasoningEffort).toBeUndefined()
|
||||
})
|
||||
|
||||
test("Oracle uses first fallback entry when no availableModels provided (no cache scenario)", async () => {
|
||||
// #given - no available models simulates CI without model cache
|
||||
test("Oracle uses connected provider fallback when availableModels is empty and cache exists", async () => {
|
||||
// #given - connected providers cache has "openai", which matches oracle's first fallback entry
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
|
||||
|
||||
// #when
|
||||
const agents = await createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL)
|
||||
|
||||
// #then - uses first fallback entry (openai/gpt-5.2) instead of system default
|
||||
// #then - oracle resolves via connected cache fallback to openai/gpt-5.2 (not system default)
|
||||
expect(agents.oracle.model).toBe("openai/gpt-5.2")
|
||||
expect(agents.oracle.reasoningEffort).toBe("medium")
|
||||
expect(agents.oracle.textVerbosity).toBe("high")
|
||||
expect(agents.oracle.thinking).toBeUndefined()
|
||||
cacheSpy.mockRestore()
|
||||
})
|
||||
|
||||
test("Oracle created without model field when no cache exists (first run scenario)", async () => {
|
||||
// #given - no cache at all (first run)
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(null)
|
||||
|
||||
// #when
|
||||
const agents = await createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL)
|
||||
|
||||
// #then - oracle should be created with system default model (fallback to systemDefaultModel)
|
||||
expect(agents.oracle).toBeDefined()
|
||||
expect(agents.oracle.model).toBe(TEST_DEFAULT_MODEL)
|
||||
cacheSpy.mockRestore()
|
||||
})
|
||||
|
||||
test("Oracle with GPT model override has reasoningEffort, no thinking", async () => {
|
||||
@@ -107,26 +122,42 @@ describe("createBuiltinAgents with model overrides", () => {
|
||||
})
|
||||
|
||||
describe("createBuiltinAgents without systemDefaultModel", () => {
|
||||
test("creates agents successfully without systemDefaultModel", async () => {
|
||||
// #given - no systemDefaultModel provided
|
||||
test("agents created via connected cache fallback even without systemDefaultModel", async () => {
|
||||
// #given - connected cache has "openai", which matches oracle's fallback chain
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
|
||||
|
||||
// #when
|
||||
const agents = await createBuiltinAgents([], {}, undefined, undefined)
|
||||
|
||||
// #then - agents should still be created using fallback chain
|
||||
// #then - connected cache enables model resolution despite no systemDefaultModel
|
||||
expect(agents.oracle).toBeDefined()
|
||||
expect(agents.oracle.model).toBe("openai/gpt-5.2")
|
||||
cacheSpy.mockRestore()
|
||||
})
|
||||
|
||||
test("sisyphus uses fallback chain when systemDefaultModel undefined", async () => {
|
||||
// #given - no systemDefaultModel
|
||||
test("agents NOT created when no cache and no systemDefaultModel (first run without defaults)", async () => {
|
||||
// #given
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(null)
|
||||
|
||||
// #when
|
||||
const agents = await createBuiltinAgents([], {}, undefined, undefined)
|
||||
|
||||
// #then - sisyphus should use its fallback chain
|
||||
// #then
|
||||
expect(agents.oracle).toBeUndefined()
|
||||
cacheSpy.mockRestore()
|
||||
})
|
||||
|
||||
test("sisyphus created via connected cache fallback even without systemDefaultModel", async () => {
|
||||
// #given - connected cache has "anthropic", which matches sisyphus's first fallback entry
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["anthropic"])
|
||||
|
||||
// #when
|
||||
const agents = await createBuiltinAgents([], {}, undefined, undefined)
|
||||
|
||||
// #then - connected cache enables model resolution despite no systemDefaultModel
|
||||
expect(agents.sisyphus).toBeDefined()
|
||||
expect(agents.sisyphus.model).toBe("anthropic/claude-opus-4-5")
|
||||
cacheSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -376,3 +407,119 @@ describe("buildAgent with category and skills", () => {
|
||||
expect(agent.prompt).not.toContain("agent-browser open")
|
||||
})
|
||||
})
|
||||
|
||||
describe("override.category expansion in createBuiltinAgents", () => {
|
||||
test("standard agent override with category expands category properties", async () => {
|
||||
// #given
|
||||
const overrides = {
|
||||
oracle: { category: "ultrabrain" } as any,
|
||||
}
|
||||
|
||||
// #when
|
||||
const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL)
|
||||
|
||||
// #then - ultrabrain category: model=openai/gpt-5.2-codex, variant=xhigh
|
||||
expect(agents.oracle).toBeDefined()
|
||||
expect(agents.oracle.model).toBe("openai/gpt-5.2-codex")
|
||||
expect(agents.oracle.variant).toBe("xhigh")
|
||||
})
|
||||
|
||||
test("standard agent override with category AND direct variant - direct wins", async () => {
|
||||
// #given - ultrabrain has variant=xhigh, but direct override says "max"
|
||||
const overrides = {
|
||||
oracle: { category: "ultrabrain", variant: "max" } as any,
|
||||
}
|
||||
|
||||
// #when
|
||||
const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL)
|
||||
|
||||
// #then - direct variant overrides category variant
|
||||
expect(agents.oracle).toBeDefined()
|
||||
expect(agents.oracle.variant).toBe("max")
|
||||
})
|
||||
|
||||
test("standard agent override with category AND direct reasoningEffort - direct wins", async () => {
|
||||
// #given - custom category has reasoningEffort=xhigh, direct override says "low"
|
||||
const categories = {
|
||||
"test-cat": {
|
||||
model: "openai/gpt-5.2",
|
||||
reasoningEffort: "xhigh" as const,
|
||||
},
|
||||
}
|
||||
const overrides = {
|
||||
oracle: { category: "test-cat", reasoningEffort: "low" } as any,
|
||||
}
|
||||
|
||||
// #when
|
||||
const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL, categories)
|
||||
|
||||
// #then - direct reasoningEffort wins over category
|
||||
expect(agents.oracle).toBeDefined()
|
||||
expect(agents.oracle.reasoningEffort).toBe("low")
|
||||
})
|
||||
|
||||
test("standard agent override with category applies reasoningEffort from category when no direct override", async () => {
|
||||
// #given - custom category has reasoningEffort, no direct reasoningEffort in override
|
||||
const categories = {
|
||||
"reasoning-cat": {
|
||||
model: "openai/gpt-5.2",
|
||||
reasoningEffort: "high" as const,
|
||||
},
|
||||
}
|
||||
const overrides = {
|
||||
oracle: { category: "reasoning-cat" } as any,
|
||||
}
|
||||
|
||||
// #when
|
||||
const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL, categories)
|
||||
|
||||
// #then - category reasoningEffort is applied
|
||||
expect(agents.oracle).toBeDefined()
|
||||
expect(agents.oracle.reasoningEffort).toBe("high")
|
||||
})
|
||||
|
||||
test("sisyphus override with category expands category properties", async () => {
|
||||
// #given
|
||||
const overrides = {
|
||||
sisyphus: { category: "ultrabrain" } as any,
|
||||
}
|
||||
|
||||
// #when
|
||||
const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL)
|
||||
|
||||
// #then - ultrabrain category: model=openai/gpt-5.2-codex, variant=xhigh
|
||||
expect(agents.sisyphus).toBeDefined()
|
||||
expect(agents.sisyphus.model).toBe("openai/gpt-5.2-codex")
|
||||
expect(agents.sisyphus.variant).toBe("xhigh")
|
||||
})
|
||||
|
||||
test("atlas override with category expands category properties", async () => {
|
||||
// #given
|
||||
const overrides = {
|
||||
atlas: { category: "ultrabrain" } as any,
|
||||
}
|
||||
|
||||
// #when
|
||||
const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL)
|
||||
|
||||
// #then - ultrabrain category: model=openai/gpt-5.2-codex, variant=xhigh
|
||||
expect(agents.atlas).toBeDefined()
|
||||
expect(agents.atlas.model).toBe("openai/gpt-5.2-codex")
|
||||
expect(agents.atlas.variant).toBe("xhigh")
|
||||
})
|
||||
|
||||
test("override with non-existent category has no effect on config", async () => {
|
||||
// #given
|
||||
const overrides = {
|
||||
oracle: { category: "non-existent-category" } as any,
|
||||
}
|
||||
|
||||
// #when
|
||||
const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL)
|
||||
|
||||
// #then - no category-specific variant/reasoningEffort applied from non-existent category
|
||||
expect(agents.oracle).toBeDefined()
|
||||
const agentsWithoutOverride = await createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL)
|
||||
expect(agents.oracle.model).toBe(agentsWithoutOverride.oracle.model)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -120,6 +120,33 @@ export function createEnvContext(): string {
|
||||
</omo-env>`
|
||||
}
|
||||
|
||||
/**
|
||||
* Expands a category reference from an agent override into concrete config properties.
|
||||
* Category properties are applied unconditionally (overwriting factory defaults),
|
||||
* because the user's chosen category should take priority over factory base values.
|
||||
* Direct override properties applied later via mergeAgentConfig() will supersede these.
|
||||
*/
|
||||
function applyCategoryOverride(
|
||||
config: AgentConfig,
|
||||
categoryName: string,
|
||||
mergedCategories: Record<string, CategoryConfig>
|
||||
): AgentConfig {
|
||||
const categoryConfig = mergedCategories[categoryName]
|
||||
if (!categoryConfig) return config
|
||||
|
||||
const result = { ...config } as AgentConfig & Record<string, unknown>
|
||||
if (categoryConfig.model) result.model = categoryConfig.model
|
||||
if (categoryConfig.variant !== undefined) result.variant = categoryConfig.variant
|
||||
if (categoryConfig.temperature !== undefined) result.temperature = categoryConfig.temperature
|
||||
if (categoryConfig.reasoningEffort !== undefined) result.reasoningEffort = categoryConfig.reasoningEffort
|
||||
if (categoryConfig.textVerbosity !== undefined) result.textVerbosity = categoryConfig.textVerbosity
|
||||
if (categoryConfig.thinking !== undefined) result.thinking = categoryConfig.thinking
|
||||
if (categoryConfig.top_p !== undefined) result.top_p = categoryConfig.top_p
|
||||
if (categoryConfig.maxTokens !== undefined) result.maxTokens = categoryConfig.maxTokens
|
||||
|
||||
return result as AgentConfig
|
||||
}
|
||||
|
||||
function mergeAgentConfig(
|
||||
base: AgentConfig,
|
||||
override: AgentOverrideConfig
|
||||
@@ -149,7 +176,8 @@ export async function createBuiltinAgents(
|
||||
gitMasterConfig?: GitMasterConfig,
|
||||
discoveredSkills: LoadedSkill[] = [],
|
||||
client?: any,
|
||||
browserProvider?: BrowserAutomationProvider
|
||||
browserProvider?: BrowserAutomationProvider,
|
||||
uiSelectedModel?: string
|
||||
): Promise<Record<string, AgentConfig>> {
|
||||
const connectedProviders = readConnectedProvidersCache()
|
||||
const availableModels = client
|
||||
@@ -198,6 +226,7 @@ export async function createBuiltinAgents(
|
||||
const requirement = AGENT_MODEL_REQUIREMENTS[agentName]
|
||||
|
||||
const resolution = resolveModelWithFallback({
|
||||
uiSelectedModel,
|
||||
userModel: override?.model,
|
||||
fallbackChain: requirement?.fallbackChain,
|
||||
availableModels,
|
||||
@@ -208,18 +237,23 @@ export async function createBuiltinAgents(
|
||||
|
||||
let config = buildAgent(source, model, mergedCategories, gitMasterConfig, browserProvider)
|
||||
|
||||
// Apply variant from override or resolved fallback chain
|
||||
if (override?.variant) {
|
||||
config = { ...config, variant: override.variant }
|
||||
} else if (resolvedVariant) {
|
||||
// Apply resolved variant from model fallback chain
|
||||
if (resolvedVariant) {
|
||||
config = { ...config, variant: resolvedVariant }
|
||||
}
|
||||
|
||||
// Expand override.category into concrete properties (higher priority than factory/resolved)
|
||||
const overrideCategory = (override as Record<string, unknown> | undefined)?.category as string | undefined
|
||||
if (overrideCategory) {
|
||||
config = applyCategoryOverride(config, overrideCategory, mergedCategories)
|
||||
}
|
||||
|
||||
if (agentName === "librarian" && directory && config.prompt) {
|
||||
const envContext = createEnvContext()
|
||||
config = { ...config, prompt: config.prompt + envContext }
|
||||
}
|
||||
|
||||
// Direct override properties take highest priority
|
||||
if (override) {
|
||||
config = mergeAgentConfig(config, override)
|
||||
}
|
||||
@@ -241,6 +275,7 @@ export async function createBuiltinAgents(
|
||||
const sisyphusRequirement = AGENT_MODEL_REQUIREMENTS["sisyphus"]
|
||||
|
||||
const sisyphusResolution = resolveModelWithFallback({
|
||||
uiSelectedModel,
|
||||
userModel: sisyphusOverride?.model,
|
||||
fallbackChain: sisyphusRequirement?.fallbackChain,
|
||||
availableModels,
|
||||
@@ -258,12 +293,15 @@ export async function createBuiltinAgents(
|
||||
availableCategories
|
||||
)
|
||||
|
||||
if (sisyphusOverride?.variant) {
|
||||
sisyphusConfig = { ...sisyphusConfig, variant: sisyphusOverride.variant }
|
||||
} else if (sisyphusResolvedVariant) {
|
||||
if (sisyphusResolvedVariant) {
|
||||
sisyphusConfig = { ...sisyphusConfig, variant: sisyphusResolvedVariant }
|
||||
}
|
||||
|
||||
const sisOverrideCategory = (sisyphusOverride as Record<string, unknown> | undefined)?.category as string | undefined
|
||||
if (sisOverrideCategory) {
|
||||
sisyphusConfig = applyCategoryOverride(sisyphusConfig, sisOverrideCategory, mergedCategories)
|
||||
}
|
||||
|
||||
if (directory && sisyphusConfig.prompt) {
|
||||
const envContext = createEnvContext()
|
||||
sisyphusConfig = { ...sisyphusConfig, prompt: sisyphusConfig.prompt + envContext }
|
||||
@@ -282,6 +320,7 @@ export async function createBuiltinAgents(
|
||||
const atlasRequirement = AGENT_MODEL_REQUIREMENTS["atlas"]
|
||||
|
||||
const atlasResolution = resolveModelWithFallback({
|
||||
uiSelectedModel,
|
||||
userModel: orchestratorOverride?.model,
|
||||
fallbackChain: atlasRequirement?.fallbackChain,
|
||||
availableModels,
|
||||
@@ -298,12 +337,15 @@ export async function createBuiltinAgents(
|
||||
userCategories: categories,
|
||||
})
|
||||
|
||||
if (orchestratorOverride?.variant) {
|
||||
orchestratorConfig = { ...orchestratorConfig, variant: orchestratorOverride.variant }
|
||||
} else if (atlasResolvedVariant) {
|
||||
if (atlasResolvedVariant) {
|
||||
orchestratorConfig = { ...orchestratorConfig, variant: atlasResolvedVariant }
|
||||
}
|
||||
|
||||
const atlasOverrideCategory = (orchestratorOverride as Record<string, unknown> | undefined)?.category as string | undefined
|
||||
if (atlasOverrideCategory) {
|
||||
orchestratorConfig = applyCategoryOverride(orchestratorConfig, atlasOverrideCategory, mergedCategories)
|
||||
}
|
||||
|
||||
if (orchestratorOverride) {
|
||||
orchestratorConfig = mergeAgentConfig(orchestratorConfig, orchestratorOverride)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { getDependencyCheckDefinitions } from "./dependencies"
|
||||
import { getGhCliCheckDefinition } from "./gh"
|
||||
import { getLspCheckDefinition } from "./lsp"
|
||||
import { getMcpCheckDefinitions } from "./mcp"
|
||||
import { getMcpOAuthCheckDefinition } from "./mcp-oauth"
|
||||
import { getVersionCheckDefinition } from "./version"
|
||||
|
||||
export * from "./opencode"
|
||||
@@ -19,6 +20,7 @@ export * from "./dependencies"
|
||||
export * from "./gh"
|
||||
export * from "./lsp"
|
||||
export * from "./mcp"
|
||||
export * from "./mcp-oauth"
|
||||
export * from "./version"
|
||||
|
||||
export function getAllCheckDefinitions(): CheckDefinition[] {
|
||||
@@ -32,6 +34,7 @@ export function getAllCheckDefinitions(): CheckDefinition[] {
|
||||
getGhCliCheckDefinition(),
|
||||
getLspCheckDefinition(),
|
||||
...getMcpCheckDefinitions(),
|
||||
getMcpOAuthCheckDefinition(),
|
||||
getVersionCheckDefinition(),
|
||||
]
|
||||
}
|
||||
|
||||
133
src/cli/doctor/checks/mcp-oauth.test.ts
Normal file
133
src/cli/doctor/checks/mcp-oauth.test.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { describe, it, expect, spyOn, afterEach } from "bun:test"
|
||||
import * as mcpOauth from "./mcp-oauth"
|
||||
|
||||
describe("mcp-oauth check", () => {
|
||||
describe("getMcpOAuthCheckDefinition", () => {
|
||||
it("returns check definition with correct properties", () => {
|
||||
// #given
|
||||
// #when getting definition
|
||||
const def = mcpOauth.getMcpOAuthCheckDefinition()
|
||||
|
||||
// #then should have correct structure
|
||||
expect(def.id).toBe("mcp-oauth-tokens")
|
||||
expect(def.name).toBe("MCP OAuth Tokens")
|
||||
expect(def.category).toBe("tools")
|
||||
expect(def.critical).toBe(false)
|
||||
expect(typeof def.check).toBe("function")
|
||||
})
|
||||
})
|
||||
|
||||
describe("checkMcpOAuthTokens", () => {
|
||||
let readStoreSpy: ReturnType<typeof spyOn>
|
||||
|
||||
afterEach(() => {
|
||||
readStoreSpy?.mockRestore()
|
||||
})
|
||||
|
||||
it("returns skip when no tokens stored", async () => {
|
||||
// #given no OAuth tokens configured
|
||||
readStoreSpy = spyOn(mcpOauth, "readTokenStore").mockReturnValue(null)
|
||||
|
||||
// #when checking OAuth tokens
|
||||
const result = await mcpOauth.checkMcpOAuthTokens()
|
||||
|
||||
// #then should skip
|
||||
expect(result.status).toBe("skip")
|
||||
expect(result.message).toContain("No OAuth")
|
||||
})
|
||||
|
||||
it("returns pass when all tokens valid", async () => {
|
||||
// #given valid tokens with future expiry (expiresAt is in epoch seconds)
|
||||
const futureTime = Math.floor(Date.now() / 1000) + 3600
|
||||
readStoreSpy = spyOn(mcpOauth, "readTokenStore").mockReturnValue({
|
||||
"example.com/resource1": {
|
||||
accessToken: "token1",
|
||||
expiresAt: futureTime,
|
||||
},
|
||||
"example.com/resource2": {
|
||||
accessToken: "token2",
|
||||
expiresAt: futureTime,
|
||||
},
|
||||
})
|
||||
|
||||
// #when checking OAuth tokens
|
||||
const result = await mcpOauth.checkMcpOAuthTokens()
|
||||
|
||||
// #then should pass
|
||||
expect(result.status).toBe("pass")
|
||||
expect(result.message).toContain("2")
|
||||
expect(result.message).toContain("valid")
|
||||
})
|
||||
|
||||
it("returns warn when some tokens expired", async () => {
|
||||
// #given mix of valid and expired tokens (expiresAt is in epoch seconds)
|
||||
const futureTime = Math.floor(Date.now() / 1000) + 3600
|
||||
const pastTime = Math.floor(Date.now() / 1000) - 3600
|
||||
readStoreSpy = spyOn(mcpOauth, "readTokenStore").mockReturnValue({
|
||||
"example.com/resource1": {
|
||||
accessToken: "token1",
|
||||
expiresAt: futureTime,
|
||||
},
|
||||
"example.com/resource2": {
|
||||
accessToken: "token2",
|
||||
expiresAt: pastTime,
|
||||
},
|
||||
})
|
||||
|
||||
// #when checking OAuth tokens
|
||||
const result = await mcpOauth.checkMcpOAuthTokens()
|
||||
|
||||
// #then should warn
|
||||
expect(result.status).toBe("warn")
|
||||
expect(result.message).toContain("1")
|
||||
expect(result.message).toContain("expired")
|
||||
expect(result.details?.some((d: string) => d.includes("Expired"))).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it("returns pass when tokens have no expiry", async () => {
|
||||
// #given tokens without expiry info
|
||||
readStoreSpy = spyOn(mcpOauth, "readTokenStore").mockReturnValue({
|
||||
"example.com/resource1": {
|
||||
accessToken: "token1",
|
||||
},
|
||||
})
|
||||
|
||||
// #when checking OAuth tokens
|
||||
const result = await mcpOauth.checkMcpOAuthTokens()
|
||||
|
||||
// #then should pass (no expiry = assume valid)
|
||||
expect(result.status).toBe("pass")
|
||||
expect(result.message).toContain("1")
|
||||
})
|
||||
|
||||
it("includes token details in output", async () => {
|
||||
// #given multiple tokens
|
||||
const futureTime = Math.floor(Date.now() / 1000) + 3600
|
||||
readStoreSpy = spyOn(mcpOauth, "readTokenStore").mockReturnValue({
|
||||
"api.example.com/v1": {
|
||||
accessToken: "token1",
|
||||
expiresAt: futureTime,
|
||||
},
|
||||
"auth.example.com/oauth": {
|
||||
accessToken: "token2",
|
||||
expiresAt: futureTime,
|
||||
},
|
||||
})
|
||||
|
||||
// #when checking OAuth tokens
|
||||
const result = await mcpOauth.checkMcpOAuthTokens()
|
||||
|
||||
// #then should list tokens in details
|
||||
expect(result.details).toBeDefined()
|
||||
expect(result.details?.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
result.details?.some((d: string) => d.includes("api.example.com"))
|
||||
).toBe(true)
|
||||
expect(
|
||||
result.details?.some((d: string) => d.includes("auth.example.com"))
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
80
src/cli/doctor/checks/mcp-oauth.ts
Normal file
80
src/cli/doctor/checks/mcp-oauth.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import type { CheckResult, CheckDefinition } from "../types"
|
||||
import { CHECK_IDS, CHECK_NAMES } from "../constants"
|
||||
import { getMcpOauthStoragePath } from "../../../features/mcp-oauth/storage"
|
||||
import { existsSync, readFileSync } from "node:fs"
|
||||
|
||||
interface OAuthTokenData {
|
||||
accessToken: string
|
||||
refreshToken?: string
|
||||
expiresAt?: number
|
||||
clientInfo?: {
|
||||
clientId: string
|
||||
clientSecret?: string
|
||||
}
|
||||
}
|
||||
|
||||
type TokenStore = Record<string, OAuthTokenData>
|
||||
|
||||
export function readTokenStore(): TokenStore | null {
|
||||
const filePath = getMcpOauthStoragePath()
|
||||
if (!existsSync(filePath)) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(filePath, "utf-8")
|
||||
return JSON.parse(content) as TokenStore
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkMcpOAuthTokens(): Promise<CheckResult> {
|
||||
const store = readTokenStore()
|
||||
|
||||
if (!store || Object.keys(store).length === 0) {
|
||||
return {
|
||||
name: CHECK_NAMES[CHECK_IDS.MCP_OAUTH_TOKENS],
|
||||
status: "skip",
|
||||
message: "No OAuth tokens configured",
|
||||
details: ["Optional: Configure OAuth tokens for MCP servers"],
|
||||
}
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const tokens = Object.entries(store)
|
||||
const expiredTokens = tokens.filter(
|
||||
([, token]) => token.expiresAt && token.expiresAt < now
|
||||
)
|
||||
|
||||
if (expiredTokens.length > 0) {
|
||||
return {
|
||||
name: CHECK_NAMES[CHECK_IDS.MCP_OAUTH_TOKENS],
|
||||
status: "warn",
|
||||
message: `${expiredTokens.length} of ${tokens.length} token(s) expired`,
|
||||
details: [
|
||||
...tokens
|
||||
.filter(([, token]) => !token.expiresAt || token.expiresAt >= now)
|
||||
.map(([key]) => `Valid: ${key}`),
|
||||
...expiredTokens.map(([key]) => `Expired: ${key}`),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: CHECK_NAMES[CHECK_IDS.MCP_OAUTH_TOKENS],
|
||||
status: "pass",
|
||||
message: `${tokens.length} OAuth token(s) valid`,
|
||||
details: tokens.map(([key]) => `Configured: ${key}`),
|
||||
}
|
||||
}
|
||||
|
||||
export function getMcpOAuthCheckDefinition(): CheckDefinition {
|
||||
return {
|
||||
id: CHECK_IDS.MCP_OAUTH_TOKENS,
|
||||
name: CHECK_NAMES[CHECK_IDS.MCP_OAUTH_TOKENS],
|
||||
category: "tools",
|
||||
check: checkMcpOAuthTokens,
|
||||
critical: false,
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ export const CHECK_IDS = {
|
||||
LSP_SERVERS: "lsp-servers",
|
||||
MCP_BUILTIN: "mcp-builtin",
|
||||
MCP_USER: "mcp-user",
|
||||
MCP_OAUTH_TOKENS: "mcp-oauth-tokens",
|
||||
VERSION_STATUS: "version-status",
|
||||
} as const
|
||||
|
||||
@@ -50,6 +51,7 @@ export const CHECK_NAMES: Record<string, string> = {
|
||||
[CHECK_IDS.LSP_SERVERS]: "LSP Servers",
|
||||
[CHECK_IDS.MCP_BUILTIN]: "Built-in MCP Servers",
|
||||
[CHECK_IDS.MCP_USER]: "User MCP Configuration",
|
||||
[CHECK_IDS.MCP_OAUTH_TOKENS]: "MCP OAuth Tokens",
|
||||
[CHECK_IDS.VERSION_STATUS]: "Version Status",
|
||||
} as const
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { install } from "./install"
|
||||
import { run } from "./run"
|
||||
import { getLocalVersion } from "./get-local-version"
|
||||
import { doctor } from "./doctor"
|
||||
import { createMcpOAuthCommand } from "./mcp-oauth"
|
||||
import type { InstallArgs } from "./types"
|
||||
import type { RunOptions } from "./run"
|
||||
import type { GetLocalVersionOptions } from "./get-local-version/types"
|
||||
@@ -150,4 +151,6 @@ program
|
||||
console.log(`oh-my-opencode v${VERSION}`)
|
||||
})
|
||||
|
||||
program.addCommand(createMcpOAuthCommand())
|
||||
|
||||
program.parse()
|
||||
|
||||
123
src/cli/mcp-oauth/index.test.ts
Normal file
123
src/cli/mcp-oauth/index.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import { Command } from "commander"
|
||||
import { createMcpOAuthCommand } from "./index"
|
||||
|
||||
describe("mcp oauth command", () => {
|
||||
|
||||
describe("command structure", () => {
|
||||
it("creates mcp command group with oauth subcommand", () => {
|
||||
// given
|
||||
const mcpCommand = createMcpOAuthCommand()
|
||||
|
||||
// when
|
||||
const subcommands = mcpCommand.commands.map((cmd: Command) => cmd.name())
|
||||
|
||||
// then
|
||||
expect(subcommands).toContain("oauth")
|
||||
})
|
||||
|
||||
it("oauth subcommand has login, logout, and status subcommands", () => {
|
||||
// given
|
||||
const mcpCommand = createMcpOAuthCommand()
|
||||
const oauthCommand = mcpCommand.commands.find((cmd: Command) => cmd.name() === "oauth")
|
||||
|
||||
// when
|
||||
const subcommands = oauthCommand?.commands.map((cmd: Command) => cmd.name()) ?? []
|
||||
|
||||
// then
|
||||
expect(subcommands).toContain("login")
|
||||
expect(subcommands).toContain("logout")
|
||||
expect(subcommands).toContain("status")
|
||||
})
|
||||
})
|
||||
|
||||
describe("login subcommand", () => {
|
||||
it("exists and has description", () => {
|
||||
// given
|
||||
const mcpCommand = createMcpOAuthCommand()
|
||||
const oauthCommand = mcpCommand.commands.find((cmd: Command) => cmd.name() === "oauth")
|
||||
const loginCommand = oauthCommand?.commands.find((cmd: Command) => cmd.name() === "login")
|
||||
|
||||
// when
|
||||
const description = loginCommand?.description() ?? ""
|
||||
|
||||
// then
|
||||
expect(loginCommand).toBeDefined()
|
||||
expect(description).toContain("OAuth")
|
||||
})
|
||||
|
||||
it("accepts --server-url option", () => {
|
||||
// given
|
||||
const mcpCommand = createMcpOAuthCommand()
|
||||
const oauthCommand = mcpCommand.commands.find((cmd: Command) => cmd.name() === "oauth")
|
||||
const loginCommand = oauthCommand?.commands.find((cmd: Command) => cmd.name() === "login")
|
||||
|
||||
// when
|
||||
const options = loginCommand?.options ?? []
|
||||
const serverUrlOption = options.find((opt: { long?: string }) => opt.long === "--server-url")
|
||||
|
||||
// then
|
||||
expect(serverUrlOption).toBeDefined()
|
||||
})
|
||||
|
||||
it("accepts --client-id option", () => {
|
||||
// given
|
||||
const mcpCommand = createMcpOAuthCommand()
|
||||
const oauthCommand = mcpCommand.commands.find((cmd: Command) => cmd.name() === "oauth")
|
||||
const loginCommand = oauthCommand?.commands.find((cmd: Command) => cmd.name() === "login")
|
||||
|
||||
// when
|
||||
const options = loginCommand?.options ?? []
|
||||
const clientIdOption = options.find((opt: { long?: string }) => opt.long === "--client-id")
|
||||
|
||||
// then
|
||||
expect(clientIdOption).toBeDefined()
|
||||
})
|
||||
|
||||
it("accepts --scopes option", () => {
|
||||
// given
|
||||
const mcpCommand = createMcpOAuthCommand()
|
||||
const oauthCommand = mcpCommand.commands.find((cmd: Command) => cmd.name() === "oauth")
|
||||
const loginCommand = oauthCommand?.commands.find((cmd: Command) => cmd.name() === "login")
|
||||
|
||||
// when
|
||||
const options = loginCommand?.options ?? []
|
||||
const scopesOption = options.find((opt: { long?: string }) => opt.long === "--scopes")
|
||||
|
||||
// then
|
||||
expect(scopesOption).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("logout subcommand", () => {
|
||||
it("exists and has description", () => {
|
||||
// given
|
||||
const mcpCommand = createMcpOAuthCommand()
|
||||
const oauthCommand = mcpCommand.commands.find((cmd: Command) => cmd.name() === "oauth")
|
||||
const logoutCommand = oauthCommand?.commands.find((cmd: Command) => cmd.name() === "logout")
|
||||
|
||||
// when
|
||||
const description = logoutCommand?.description() ?? ""
|
||||
|
||||
// then
|
||||
expect(logoutCommand).toBeDefined()
|
||||
expect(description).toContain("tokens")
|
||||
})
|
||||
})
|
||||
|
||||
describe("status subcommand", () => {
|
||||
it("exists and has description", () => {
|
||||
// given
|
||||
const mcpCommand = createMcpOAuthCommand()
|
||||
const oauthCommand = mcpCommand.commands.find((cmd: Command) => cmd.name() === "oauth")
|
||||
const statusCommand = oauthCommand?.commands.find((cmd: Command) => cmd.name() === "status")
|
||||
|
||||
// when
|
||||
const description = statusCommand?.description() ?? ""
|
||||
|
||||
// then
|
||||
expect(statusCommand).toBeDefined()
|
||||
expect(description).toContain("status")
|
||||
})
|
||||
})
|
||||
})
|
||||
43
src/cli/mcp-oauth/index.ts
Normal file
43
src/cli/mcp-oauth/index.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Command } from "commander"
|
||||
import { login } from "./login"
|
||||
import { logout } from "./logout"
|
||||
import { status } from "./status"
|
||||
|
||||
export function createMcpOAuthCommand(): Command {
|
||||
const mcp = new Command("mcp").description("MCP server management")
|
||||
|
||||
const oauth = new Command("oauth").description("OAuth token management for MCP servers")
|
||||
|
||||
oauth
|
||||
.command("login <server-name>")
|
||||
.description("Authenticate with an MCP server using OAuth")
|
||||
.option("--server-url <url>", "OAuth server URL (required if not in config)")
|
||||
.option("--client-id <id>", "OAuth client ID (optional, uses DCR if not provided)")
|
||||
.option("--scopes <scopes...>", "OAuth scopes to request")
|
||||
.action(async (serverName: string, options) => {
|
||||
const exitCode = await login(serverName, options)
|
||||
process.exit(exitCode)
|
||||
})
|
||||
|
||||
oauth
|
||||
.command("logout <server-name>")
|
||||
.description("Remove stored OAuth tokens for an MCP server")
|
||||
.option("--server-url <url>", "OAuth server URL (use if server name differs from URL)")
|
||||
.action(async (serverName: string, options) => {
|
||||
const exitCode = await logout(serverName, options)
|
||||
process.exit(exitCode)
|
||||
})
|
||||
|
||||
oauth
|
||||
.command("status [server-name]")
|
||||
.description("Show OAuth token status for MCP servers")
|
||||
.action(async (serverName: string | undefined) => {
|
||||
const exitCode = await status(serverName)
|
||||
process.exit(exitCode)
|
||||
})
|
||||
|
||||
mcp.addCommand(oauth)
|
||||
return mcp
|
||||
}
|
||||
|
||||
export { login, logout, status }
|
||||
80
src/cli/mcp-oauth/login.test.ts
Normal file
80
src/cli/mcp-oauth/login.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, mock } from "bun:test"
|
||||
|
||||
const mockLogin = mock(() => Promise.resolve({ accessToken: "test-token", expiresAt: 1710000000 }))
|
||||
|
||||
mock.module("../../features/mcp-oauth/provider", () => ({
|
||||
McpOAuthProvider: class MockMcpOAuthProvider {
|
||||
constructor(public options: { serverUrl: string; clientId?: string; scopes?: string[] }) {}
|
||||
async login() {
|
||||
return mockLogin()
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
const { login } = await import("./login")
|
||||
|
||||
describe("login command", () => {
|
||||
beforeEach(() => {
|
||||
mockLogin.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// cleanup
|
||||
})
|
||||
|
||||
it("returns error code when server-url is not provided", async () => {
|
||||
// given
|
||||
const serverName = "test-server"
|
||||
const options = {}
|
||||
|
||||
// when
|
||||
const exitCode = await login(serverName, options)
|
||||
|
||||
// then
|
||||
expect(exitCode).toBe(1)
|
||||
})
|
||||
|
||||
it("returns success code when login succeeds", async () => {
|
||||
// given
|
||||
const serverName = "test-server"
|
||||
const options = {
|
||||
serverUrl: "https://oauth.example.com",
|
||||
}
|
||||
|
||||
// when
|
||||
const exitCode = await login(serverName, options)
|
||||
|
||||
// then
|
||||
expect(exitCode).toBe(0)
|
||||
expect(mockLogin).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("returns error code when login throws", async () => {
|
||||
// given
|
||||
const serverName = "test-server"
|
||||
const options = {
|
||||
serverUrl: "https://oauth.example.com",
|
||||
}
|
||||
mockLogin.mockRejectedValueOnce(new Error("Network error"))
|
||||
|
||||
// when
|
||||
const exitCode = await login(serverName, options)
|
||||
|
||||
// then
|
||||
expect(exitCode).toBe(1)
|
||||
})
|
||||
|
||||
it("returns error code when server-url is missing", async () => {
|
||||
// given
|
||||
const serverName = "test-server"
|
||||
const options = {
|
||||
clientId: "test-client-id",
|
||||
}
|
||||
|
||||
// when
|
||||
const exitCode = await login(serverName, options)
|
||||
|
||||
// then
|
||||
expect(exitCode).toBe(1)
|
||||
})
|
||||
})
|
||||
38
src/cli/mcp-oauth/login.ts
Normal file
38
src/cli/mcp-oauth/login.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { McpOAuthProvider } from "../../features/mcp-oauth/provider"
|
||||
|
||||
export interface LoginOptions {
|
||||
serverUrl?: string
|
||||
clientId?: string
|
||||
scopes?: string[]
|
||||
}
|
||||
|
||||
export async function login(serverName: string, options: LoginOptions): Promise<number> {
|
||||
try {
|
||||
const serverUrl = options.serverUrl
|
||||
if (!serverUrl) {
|
||||
console.error(`Error: --server-url is required for server "${serverName}"`)
|
||||
return 1
|
||||
}
|
||||
|
||||
const provider = new McpOAuthProvider({
|
||||
serverUrl,
|
||||
clientId: options.clientId,
|
||||
scopes: options.scopes,
|
||||
})
|
||||
|
||||
console.log(`Authenticating with ${serverName}...`)
|
||||
const tokenData = await provider.login()
|
||||
|
||||
console.log(`✓ Successfully authenticated with ${serverName}`)
|
||||
if (tokenData.expiresAt) {
|
||||
const expiryDate = new Date(tokenData.expiresAt * 1000)
|
||||
console.log(` Token expires at: ${expiryDate.toISOString()}`)
|
||||
}
|
||||
|
||||
return 0
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
console.error(`Error: Failed to authenticate with ${serverName}: ${message}`)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
65
src/cli/mcp-oauth/logout.test.ts
Normal file
65
src/cli/mcp-oauth/logout.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, mock } from "bun:test"
|
||||
import { existsSync, mkdirSync, rmSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { tmpdir } from "node:os"
|
||||
import { saveToken } from "../../features/mcp-oauth/storage"
|
||||
|
||||
const { logout } = await import("./logout")
|
||||
|
||||
describe("logout command", () => {
|
||||
const TEST_CONFIG_DIR = join(tmpdir(), "mcp-oauth-logout-test-" + Date.now())
|
||||
let originalConfigDir: string | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
originalConfigDir = process.env.OPENCODE_CONFIG_DIR
|
||||
process.env.OPENCODE_CONFIG_DIR = TEST_CONFIG_DIR
|
||||
if (!existsSync(TEST_CONFIG_DIR)) {
|
||||
mkdirSync(TEST_CONFIG_DIR, { recursive: true })
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (originalConfigDir === undefined) {
|
||||
delete process.env.OPENCODE_CONFIG_DIR
|
||||
} else {
|
||||
process.env.OPENCODE_CONFIG_DIR = originalConfigDir
|
||||
}
|
||||
if (existsSync(TEST_CONFIG_DIR)) {
|
||||
rmSync(TEST_CONFIG_DIR, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("returns success code when logout succeeds", async () => {
|
||||
// given
|
||||
const serverUrl = "https://test-server.example.com"
|
||||
saveToken(serverUrl, serverUrl, { accessToken: "test-token" })
|
||||
|
||||
// when
|
||||
const exitCode = await logout("test-server", { serverUrl })
|
||||
|
||||
// then
|
||||
expect(exitCode).toBe(0)
|
||||
})
|
||||
|
||||
it("handles non-existent server gracefully", async () => {
|
||||
// given
|
||||
const serverName = "non-existent-server"
|
||||
|
||||
// when
|
||||
const exitCode = await logout(serverName, { serverUrl: "https://nonexistent.example.com" })
|
||||
|
||||
// then
|
||||
expect(exitCode).toBe(0)
|
||||
})
|
||||
|
||||
it("returns error when --server-url is not provided", async () => {
|
||||
// given
|
||||
const serverName = "test-server"
|
||||
|
||||
// when
|
||||
const exitCode = await logout(serverName)
|
||||
|
||||
// then
|
||||
expect(exitCode).toBe(1)
|
||||
})
|
||||
})
|
||||
30
src/cli/mcp-oauth/logout.ts
Normal file
30
src/cli/mcp-oauth/logout.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { deleteToken } from "../../features/mcp-oauth/storage"
|
||||
|
||||
export interface LogoutOptions {
|
||||
serverUrl?: string
|
||||
}
|
||||
|
||||
export async function logout(serverName: string, options?: LogoutOptions): Promise<number> {
|
||||
try {
|
||||
const serverUrl = options?.serverUrl
|
||||
if (!serverUrl) {
|
||||
console.error(`Error: --server-url is required for logout. Token storage uses server URLs, not names.`)
|
||||
console.error(` Usage: mcp oauth logout ${serverName} --server-url https://your-server.example.com`)
|
||||
return 1
|
||||
}
|
||||
|
||||
const success = deleteToken(serverUrl, serverUrl)
|
||||
|
||||
if (success) {
|
||||
console.log(`✓ Successfully removed tokens for ${serverName}`)
|
||||
return 0
|
||||
}
|
||||
|
||||
console.error(`Error: Failed to remove tokens for ${serverName}`)
|
||||
return 1
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
console.error(`Error: Failed to remove tokens for ${serverName}: ${message}`)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
48
src/cli/mcp-oauth/status.test.ts
Normal file
48
src/cli/mcp-oauth/status.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "bun:test"
|
||||
import { status } from "./status"
|
||||
|
||||
describe("status command", () => {
|
||||
beforeEach(() => {
|
||||
// setup
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// cleanup
|
||||
})
|
||||
|
||||
it("returns success code when checking status for specific server", async () => {
|
||||
// given
|
||||
const serverName = "test-server"
|
||||
|
||||
// when
|
||||
const exitCode = await status(serverName)
|
||||
|
||||
// then
|
||||
expect(typeof exitCode).toBe("number")
|
||||
expect(exitCode).toBe(0)
|
||||
})
|
||||
|
||||
it("returns success code when checking status for all servers", async () => {
|
||||
// given
|
||||
const serverName = undefined
|
||||
|
||||
// when
|
||||
const exitCode = await status(serverName)
|
||||
|
||||
// then
|
||||
expect(typeof exitCode).toBe("number")
|
||||
expect(exitCode).toBe(0)
|
||||
})
|
||||
|
||||
it("handles non-existent server gracefully", async () => {
|
||||
// given
|
||||
const serverName = "non-existent-server"
|
||||
|
||||
// when
|
||||
const exitCode = await status(serverName)
|
||||
|
||||
// then
|
||||
expect(typeof exitCode).toBe("number")
|
||||
expect(exitCode).toBe(0)
|
||||
})
|
||||
})
|
||||
50
src/cli/mcp-oauth/status.ts
Normal file
50
src/cli/mcp-oauth/status.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { listAllTokens, listTokensByHost } from "../../features/mcp-oauth/storage"
|
||||
|
||||
export async function status(serverName: string | undefined): Promise<number> {
|
||||
try {
|
||||
if (serverName) {
|
||||
const tokens = listTokensByHost(serverName)
|
||||
|
||||
if (Object.keys(tokens).length === 0) {
|
||||
console.log(`No tokens found for ${serverName}`)
|
||||
return 0
|
||||
}
|
||||
|
||||
console.log(`OAuth Status for ${serverName}:`)
|
||||
for (const [key, token] of Object.entries(tokens)) {
|
||||
console.log(` ${key}:`)
|
||||
console.log(` Access Token: [REDACTED]`)
|
||||
if (token.refreshToken) {
|
||||
console.log(` Refresh Token: [REDACTED]`)
|
||||
}
|
||||
if (token.expiresAt) {
|
||||
const expiryDate = new Date(token.expiresAt * 1000)
|
||||
const now = Date.now() / 1000
|
||||
const isExpired = token.expiresAt < now
|
||||
const tokenStatus = isExpired ? "EXPIRED" : "VALID"
|
||||
console.log(` Expiry: ${expiryDate.toISOString()} (${tokenStatus})`)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
const tokens = listAllTokens()
|
||||
if (Object.keys(tokens).length === 0) {
|
||||
console.log("No OAuth tokens stored")
|
||||
return 0
|
||||
}
|
||||
|
||||
console.log("Stored OAuth Tokens:")
|
||||
for (const [key, token] of Object.entries(tokens)) {
|
||||
const isExpired = token.expiresAt && token.expiresAt < Date.now() / 1000
|
||||
const tokenStatus = isExpired ? "EXPIRED" : "VALID"
|
||||
console.log(` ${key}: ${tokenStatus}`)
|
||||
}
|
||||
|
||||
return 0
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
console.error(`Error: Failed to get token status: ${message}`)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,7 @@ describe("createEventState", () => {
|
||||
expect(state.lastOutput).toBe("")
|
||||
expect(state.lastPartText).toBe("")
|
||||
expect(state.currentTool).toBe(null)
|
||||
expect(state.hasReceivedMeaningfulWork).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -126,6 +127,119 @@ describe("event handling", () => {
|
||||
expect(state.mainSessionIdle).toBe(false)
|
||||
})
|
||||
|
||||
it("hasReceivedMeaningfulWork is false initially after session.idle", async () => {
|
||||
// #given - session goes idle without any assistant output (race condition scenario)
|
||||
const ctx = createMockContext("my-session")
|
||||
const state = createEventState()
|
||||
|
||||
const payload: EventPayload = {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: "my-session" },
|
||||
}
|
||||
|
||||
const events = toAsyncIterable([payload])
|
||||
const { processEvents } = await import("./events")
|
||||
|
||||
// #when
|
||||
await processEvents(ctx, events, state)
|
||||
|
||||
// #then - idle but no meaningful work yet
|
||||
expect(state.mainSessionIdle).toBe(true)
|
||||
expect(state.hasReceivedMeaningfulWork).toBe(false)
|
||||
})
|
||||
|
||||
it("message.updated with assistant role sets hasReceivedMeaningfulWork", async () => {
|
||||
// #given
|
||||
const ctx = createMockContext("my-session")
|
||||
const state = createEventState()
|
||||
|
||||
const payload: EventPayload = {
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: { sessionID: "my-session", role: "assistant" },
|
||||
},
|
||||
}
|
||||
|
||||
const events = toAsyncIterable([payload])
|
||||
const { processEvents } = await import("./events")
|
||||
|
||||
// #when
|
||||
await processEvents(ctx, events, state)
|
||||
|
||||
// #then
|
||||
expect(state.hasReceivedMeaningfulWork).toBe(true)
|
||||
})
|
||||
|
||||
it("message.updated with user role does not set hasReceivedMeaningfulWork", async () => {
|
||||
// #given - user message should not count as meaningful work
|
||||
const ctx = createMockContext("my-session")
|
||||
const state = createEventState()
|
||||
|
||||
const payload: EventPayload = {
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: { sessionID: "my-session", role: "user" },
|
||||
},
|
||||
}
|
||||
|
||||
const events = toAsyncIterable([payload])
|
||||
const { processEvents } = await import("./events")
|
||||
|
||||
// #when
|
||||
await processEvents(ctx, events, state)
|
||||
|
||||
// #then - user role should not count as meaningful work
|
||||
expect(state.hasReceivedMeaningfulWork).toBe(false)
|
||||
})
|
||||
|
||||
it("tool.execute sets hasReceivedMeaningfulWork", async () => {
|
||||
// #given
|
||||
const ctx = createMockContext("my-session")
|
||||
const state = createEventState()
|
||||
|
||||
const payload: EventPayload = {
|
||||
type: "tool.execute",
|
||||
properties: {
|
||||
sessionID: "my-session",
|
||||
name: "read_file",
|
||||
input: { filePath: "/src/index.ts" },
|
||||
},
|
||||
}
|
||||
|
||||
const events = toAsyncIterable([payload])
|
||||
const { processEvents } = await import("./events")
|
||||
|
||||
// #when
|
||||
await processEvents(ctx, events, state)
|
||||
|
||||
// #then
|
||||
expect(state.hasReceivedMeaningfulWork).toBe(true)
|
||||
})
|
||||
|
||||
it("tool.execute from different session does not set hasReceivedMeaningfulWork", async () => {
|
||||
// #given
|
||||
const ctx = createMockContext("my-session")
|
||||
const state = createEventState()
|
||||
|
||||
const payload: EventPayload = {
|
||||
type: "tool.execute",
|
||||
properties: {
|
||||
sessionID: "other-session",
|
||||
name: "read_file",
|
||||
input: { filePath: "/src/index.ts" },
|
||||
},
|
||||
}
|
||||
|
||||
const events = toAsyncIterable([payload])
|
||||
const { processEvents } = await import("./events")
|
||||
|
||||
// #when
|
||||
await processEvents(ctx, events, state)
|
||||
|
||||
// #then - different session's tool call shouldn't count
|
||||
expect(state.hasReceivedMeaningfulWork).toBe(false)
|
||||
})
|
||||
|
||||
it("session.status with busy type sets mainSessionIdle to false", async () => {
|
||||
// #given
|
||||
const ctx = createMockContext("my-session")
|
||||
@@ -136,6 +250,7 @@ describe("event handling", () => {
|
||||
lastOutput: "",
|
||||
lastPartText: "",
|
||||
currentTool: null,
|
||||
hasReceivedMeaningfulWork: false,
|
||||
}
|
||||
|
||||
const payload: EventPayload = {
|
||||
|
||||
@@ -63,6 +63,8 @@ export interface EventState {
|
||||
lastOutput: string
|
||||
lastPartText: string
|
||||
currentTool: string | null
|
||||
/** Set to true when the main session has produced meaningful work (text, tool call, or tool result) */
|
||||
hasReceivedMeaningfulWork: boolean
|
||||
}
|
||||
|
||||
export function createEventState(): EventState {
|
||||
@@ -73,6 +75,7 @@ export function createEventState(): EventState {
|
||||
lastOutput: "",
|
||||
lastPartText: "",
|
||||
currentTool: null,
|
||||
hasReceivedMeaningfulWork: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,7 +116,9 @@ function logEventVerbose(ctx: RunContext, payload: EventPayload): void {
|
||||
const isMainSession = sessionID === ctx.sessionID
|
||||
const sessionTag = isMainSession
|
||||
? pc.green("[MAIN]")
|
||||
: pc.yellow(`[${String(sessionID).slice(0, 8)}]`)
|
||||
: sessionID
|
||||
? pc.yellow(`[${String(sessionID).slice(0, 8)}]`)
|
||||
: pc.dim("[system]")
|
||||
|
||||
switch (payload.type) {
|
||||
case "session.idle":
|
||||
@@ -124,8 +129,6 @@ function logEventVerbose(ctx: RunContext, payload: EventPayload): void {
|
||||
}
|
||||
|
||||
case "message.part.updated": {
|
||||
// Skip verbose logging for partial message updates
|
||||
// Only log tool invocation state changes, not text streaming
|
||||
const partProps = props as MessagePartUpdatedProps | undefined
|
||||
const part = partProps?.part
|
||||
if (part?.type === "tool-invocation") {
|
||||
@@ -133,6 +136,11 @@ function logEventVerbose(ctx: RunContext, payload: EventPayload): void {
|
||||
console.error(
|
||||
pc.dim(`${sessionTag} message.part (tool): ${toolPart.toolName} [${toolPart.state}]`)
|
||||
)
|
||||
} else if (part?.type === "text" && part.text) {
|
||||
const preview = part.text.slice(0, 80).replace(/\n/g, "\\n")
|
||||
console.error(
|
||||
pc.dim(`${sessionTag} message.part (text): "${preview}${part.text.length > 80 ? "..." : ""}"`)
|
||||
)
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -140,11 +148,10 @@ function logEventVerbose(ctx: RunContext, payload: EventPayload): void {
|
||||
case "message.updated": {
|
||||
const msgProps = props as MessageUpdatedProps | undefined
|
||||
const role = msgProps?.info?.role ?? "unknown"
|
||||
const content = msgProps?.content ?? ""
|
||||
const preview = content.slice(0, 100).replace(/\n/g, "\\n")
|
||||
console.error(
|
||||
pc.dim(`${sessionTag} message.updated (${role}): "${preview}${content.length > 100 ? "..." : ""}"`)
|
||||
)
|
||||
const model = msgProps?.info?.modelID
|
||||
const agent = msgProps?.info?.agent
|
||||
const details = [role, agent, model].filter(Boolean).join(", ")
|
||||
console.error(pc.dim(`${sessionTag} message.updated (${details})`))
|
||||
break
|
||||
}
|
||||
|
||||
@@ -241,6 +248,7 @@ function handleMessagePartUpdated(
|
||||
const newText = part.text.slice(state.lastPartText.length)
|
||||
if (newText) {
|
||||
process.stdout.write(newText)
|
||||
state.hasReceivedMeaningfulWork = true
|
||||
}
|
||||
state.lastPartText = part.text
|
||||
}
|
||||
@@ -257,16 +265,7 @@ function handleMessageUpdated(
|
||||
if (props?.info?.sessionID !== ctx.sessionID) return
|
||||
if (props?.info?.role !== "assistant") return
|
||||
|
||||
const content = props.content
|
||||
if (!content || content === state.lastOutput) return
|
||||
|
||||
if (state.lastPartText.length === 0) {
|
||||
const newContent = content.slice(state.lastOutput.length)
|
||||
if (newContent) {
|
||||
process.stdout.write(newContent)
|
||||
}
|
||||
}
|
||||
state.lastOutput = content
|
||||
state.hasReceivedMeaningfulWork = true
|
||||
}
|
||||
|
||||
function handleToolExecute(
|
||||
@@ -296,6 +295,7 @@ function handleToolExecute(
|
||||
}
|
||||
}
|
||||
|
||||
state.hasReceivedMeaningfulWork = true
|
||||
process.stdout.write(`\n${pc.cyan(">")} ${pc.bold(toolName)}${inputPreview}\n`)
|
||||
}
|
||||
|
||||
|
||||
@@ -31,8 +31,18 @@ export async function run(options: RunOptions): Promise<number> {
|
||||
}
|
||||
|
||||
try {
|
||||
// Support custom OpenCode server port via environment variable
|
||||
// This allows Open Agent and other orchestrators to run multiple
|
||||
// concurrent missions without port conflicts
|
||||
const serverPort = process.env.OPENCODE_SERVER_PORT
|
||||
? parseInt(process.env.OPENCODE_SERVER_PORT, 10)
|
||||
: undefined
|
||||
const serverHostname = process.env.OPENCODE_SERVER_HOSTNAME || undefined
|
||||
|
||||
const { client, server } = await createOpencode({
|
||||
signal: abortController.signal,
|
||||
...(serverPort && !isNaN(serverPort) ? { port: serverPort } : {}),
|
||||
...(serverHostname ? { hostname: serverHostname } : {}),
|
||||
})
|
||||
|
||||
const cleanup = () => {
|
||||
@@ -133,6 +143,14 @@ export async function run(options: RunOptions): Promise<number> {
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Guard against premature completion: don't check completion until the
|
||||
// session has produced meaningful work (text output, tool call, or tool result).
|
||||
// Without this, a session that goes busy->idle before the LLM responds
|
||||
// would exit immediately because 0 todos + 0 children = "complete".
|
||||
if (!eventState.hasReceivedMeaningfulWork) {
|
||||
continue
|
||||
}
|
||||
|
||||
const shouldExit = await checkCompletionConditions(ctx)
|
||||
if (shouldExit) {
|
||||
console.log(pc.green("\n\nAll tasks completed."))
|
||||
|
||||
@@ -44,8 +44,13 @@ export interface SessionStatusProps {
|
||||
}
|
||||
|
||||
export interface MessageUpdatedProps {
|
||||
info?: { sessionID?: string; role?: string }
|
||||
content?: string
|
||||
info?: {
|
||||
sessionID?: string
|
||||
role?: string
|
||||
modelID?: string
|
||||
providerID?: string
|
||||
agent?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface MessagePartUpdatedProps {
|
||||
|
||||
@@ -313,13 +313,14 @@ export const GitMasterConfigSchema = z.object({
|
||||
include_co_authored_by: z.boolean().default(true),
|
||||
})
|
||||
|
||||
export const BrowserAutomationProviderSchema = z.enum(["playwright", "agent-browser"])
|
||||
export const BrowserAutomationProviderSchema = z.enum(["playwright", "agent-browser", "dev-browser"])
|
||||
|
||||
export const BrowserAutomationConfigSchema = z.object({
|
||||
/**
|
||||
* Browser automation provider to use for the "playwright" skill.
|
||||
* - "playwright": Uses Playwright MCP server (@playwright/mcp) - default
|
||||
* - "agent-browser": Uses Vercel's agent-browser CLI (requires: bun add -g agent-browser)
|
||||
* - "dev-browser": Uses dev-browser skill with persistent browser state
|
||||
*/
|
||||
provider: BrowserAutomationProviderSchema.default("playwright"),
|
||||
})
|
||||
@@ -339,6 +340,29 @@ export const TmuxConfigSchema = z.object({
|
||||
main_pane_min_width: z.number().min(40).default(120),
|
||||
agent_pane_min_width: z.number().min(20).default(40),
|
||||
})
|
||||
|
||||
export const SisyphusTasksConfigSchema = z.object({
|
||||
/** Enable Sisyphus Tasks system (default: false) */
|
||||
enabled: z.boolean().default(false),
|
||||
/** Storage path for tasks (default: .sisyphus/tasks) */
|
||||
storage_path: z.string().default(".sisyphus/tasks"),
|
||||
/** Enable Claude Code path compatibility mode */
|
||||
claude_code_compat: z.boolean().default(false),
|
||||
})
|
||||
|
||||
export const SisyphusSwarmConfigSchema = z.object({
|
||||
/** Enable Sisyphus Swarm system (default: false) */
|
||||
enabled: z.boolean().default(false),
|
||||
/** Storage path for teams (default: .sisyphus/teams) */
|
||||
storage_path: z.string().default(".sisyphus/teams"),
|
||||
/** UI mode: toast notifications, tmux panes, or both */
|
||||
ui_mode: z.enum(["toast", "tmux", "both"]).default("toast"),
|
||||
})
|
||||
|
||||
export const SisyphusConfigSchema = z.object({
|
||||
tasks: SisyphusTasksConfigSchema.optional(),
|
||||
swarm: SisyphusSwarmConfigSchema.optional(),
|
||||
})
|
||||
export const OhMyOpenCodeConfigSchema = z.object({
|
||||
$schema: z.string().optional(),
|
||||
disabled_mcps: z.array(AnyMcpNameSchema).optional(),
|
||||
@@ -360,6 +384,7 @@ export const OhMyOpenCodeConfigSchema = z.object({
|
||||
git_master: GitMasterConfigSchema.optional(),
|
||||
browser_automation_engine: BrowserAutomationConfigSchema.optional(),
|
||||
tmux: TmuxConfigSchema.optional(),
|
||||
sisyphus: SisyphusConfigSchema.optional(),
|
||||
})
|
||||
|
||||
export type OhMyOpenCodeConfig = z.infer<typeof OhMyOpenCodeConfigSchema>
|
||||
@@ -386,5 +411,8 @@ export type BrowserAutomationProvider = z.infer<typeof BrowserAutomationProvider
|
||||
export type BrowserAutomationConfig = z.infer<typeof BrowserAutomationConfigSchema>
|
||||
export type TmuxConfig = z.infer<typeof TmuxConfigSchema>
|
||||
export type TmuxLayout = z.infer<typeof TmuxLayoutSchema>
|
||||
export type SisyphusTasksConfig = z.infer<typeof SisyphusTasksConfigSchema>
|
||||
export type SisyphusSwarmConfig = z.infer<typeof SisyphusSwarmConfigSchema>
|
||||
export type SisyphusConfig = z.infer<typeof SisyphusConfigSchema>
|
||||
|
||||
export { AnyMcpNameSchema, type AnyMcpName, McpNameSchema, type McpName } from "../mcp/types"
|
||||
|
||||
@@ -170,6 +170,7 @@ function createBackgroundManager(): BackgroundManager {
|
||||
const client = {
|
||||
session: {
|
||||
prompt: async () => ({}),
|
||||
abort: async () => ({}),
|
||||
},
|
||||
}
|
||||
return new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
|
||||
@@ -1053,6 +1054,7 @@ describe("BackgroundManager.resume model persistence", () => {
|
||||
promptCalls.push(args)
|
||||
return {}
|
||||
},
|
||||
abort: async () => ({}),
|
||||
},
|
||||
}
|
||||
manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
|
||||
@@ -1926,3 +1928,162 @@ describe("BackgroundManager.checkAndInterruptStaleTasks", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("BackgroundManager.shutdown session abort", () => {
|
||||
test("should call session.abort for all running tasks during shutdown", () => {
|
||||
// #given
|
||||
const abortedSessionIDs: string[] = []
|
||||
const client = {
|
||||
session: {
|
||||
prompt: async () => ({}),
|
||||
abort: async (args: { path: { id: string } }) => {
|
||||
abortedSessionIDs.push(args.path.id)
|
||||
return {}
|
||||
},
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
|
||||
|
||||
const task1: BackgroundTask = {
|
||||
id: "task-1",
|
||||
sessionID: "session-1",
|
||||
parentSessionID: "parent-1",
|
||||
parentMessageID: "msg-1",
|
||||
description: "Running task 1",
|
||||
prompt: "Test",
|
||||
agent: "test-agent",
|
||||
status: "running",
|
||||
startedAt: new Date(),
|
||||
}
|
||||
const task2: BackgroundTask = {
|
||||
id: "task-2",
|
||||
sessionID: "session-2",
|
||||
parentSessionID: "parent-2",
|
||||
parentMessageID: "msg-2",
|
||||
description: "Running task 2",
|
||||
prompt: "Test",
|
||||
agent: "test-agent",
|
||||
status: "running",
|
||||
startedAt: new Date(),
|
||||
}
|
||||
|
||||
getTaskMap(manager).set(task1.id, task1)
|
||||
getTaskMap(manager).set(task2.id, task2)
|
||||
|
||||
// #when
|
||||
manager.shutdown()
|
||||
|
||||
// #then
|
||||
expect(abortedSessionIDs).toContain("session-1")
|
||||
expect(abortedSessionIDs).toContain("session-2")
|
||||
expect(abortedSessionIDs).toHaveLength(2)
|
||||
})
|
||||
|
||||
test("should not call session.abort for completed or cancelled tasks", () => {
|
||||
// #given
|
||||
const abortedSessionIDs: string[] = []
|
||||
const client = {
|
||||
session: {
|
||||
prompt: async () => ({}),
|
||||
abort: async (args: { path: { id: string } }) => {
|
||||
abortedSessionIDs.push(args.path.id)
|
||||
return {}
|
||||
},
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
|
||||
|
||||
const completedTask: BackgroundTask = {
|
||||
id: "task-completed",
|
||||
sessionID: "session-completed",
|
||||
parentSessionID: "parent-1",
|
||||
parentMessageID: "msg-1",
|
||||
description: "Completed task",
|
||||
prompt: "Test",
|
||||
agent: "test-agent",
|
||||
status: "completed",
|
||||
startedAt: new Date(),
|
||||
completedAt: new Date(),
|
||||
}
|
||||
const cancelledTask: BackgroundTask = {
|
||||
id: "task-cancelled",
|
||||
sessionID: "session-cancelled",
|
||||
parentSessionID: "parent-2",
|
||||
parentMessageID: "msg-2",
|
||||
description: "Cancelled task",
|
||||
prompt: "Test",
|
||||
agent: "test-agent",
|
||||
status: "cancelled",
|
||||
startedAt: new Date(),
|
||||
completedAt: new Date(),
|
||||
}
|
||||
const pendingTask: BackgroundTask = {
|
||||
id: "task-pending",
|
||||
parentSessionID: "parent-3",
|
||||
parentMessageID: "msg-3",
|
||||
description: "Pending task",
|
||||
prompt: "Test",
|
||||
agent: "test-agent",
|
||||
status: "pending",
|
||||
queuedAt: new Date(),
|
||||
}
|
||||
|
||||
getTaskMap(manager).set(completedTask.id, completedTask)
|
||||
getTaskMap(manager).set(cancelledTask.id, cancelledTask)
|
||||
getTaskMap(manager).set(pendingTask.id, pendingTask)
|
||||
|
||||
// #when
|
||||
manager.shutdown()
|
||||
|
||||
// #then
|
||||
expect(abortedSessionIDs).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("should call onShutdown callback during shutdown", () => {
|
||||
// #given
|
||||
let shutdownCalled = false
|
||||
const client = {
|
||||
session: {
|
||||
prompt: async () => ({}),
|
||||
abort: async () => ({}),
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager(
|
||||
{ client, directory: tmpdir() } as unknown as PluginInput,
|
||||
undefined,
|
||||
{
|
||||
onShutdown: () => {
|
||||
shutdownCalled = true
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// #when
|
||||
manager.shutdown()
|
||||
|
||||
// #then
|
||||
expect(shutdownCalled).toBe(true)
|
||||
})
|
||||
|
||||
test("should not throw when onShutdown callback throws", () => {
|
||||
// #given
|
||||
const client = {
|
||||
session: {
|
||||
prompt: async () => ({}),
|
||||
abort: async () => ({}),
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager(
|
||||
{ client, directory: tmpdir() } as unknown as PluginInput,
|
||||
undefined,
|
||||
{
|
||||
onShutdown: () => {
|
||||
throw new Error("cleanup failed")
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// #when / #then
|
||||
expect(() => manager.shutdown()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -79,6 +79,7 @@ export class BackgroundManager {
|
||||
private config?: BackgroundTaskConfig
|
||||
private tmuxEnabled: boolean
|
||||
private onSubagentSessionCreated?: OnSubagentSessionCreated
|
||||
private onShutdown?: () => void
|
||||
|
||||
private queuesByKey: Map<string, QueueItem[]> = new Map()
|
||||
private processingKeys: Set<string> = new Set()
|
||||
@@ -89,6 +90,7 @@ export class BackgroundManager {
|
||||
options?: {
|
||||
tmuxConfig?: TmuxConfig
|
||||
onSubagentSessionCreated?: OnSubagentSessionCreated
|
||||
onShutdown?: () => void
|
||||
}
|
||||
) {
|
||||
this.tasks = new Map()
|
||||
@@ -100,6 +102,7 @@ export class BackgroundManager {
|
||||
this.config = config
|
||||
this.tmuxEnabled = options?.tmuxConfig?.enabled ?? false
|
||||
this.onSubagentSessionCreated = options?.onSubagentSessionCreated
|
||||
this.onShutdown = options?.onShutdown
|
||||
this.registerProcessCleanup()
|
||||
}
|
||||
|
||||
@@ -224,7 +227,10 @@ export class BackgroundManager {
|
||||
body: {
|
||||
parentID: input.parentSessionID,
|
||||
title: `Background: ${input.description}`,
|
||||
},
|
||||
permission: [
|
||||
{ permission: "question", action: "deny" as const, pattern: "*" },
|
||||
],
|
||||
} as any,
|
||||
query: {
|
||||
directory: parentDirectory,
|
||||
},
|
||||
@@ -294,11 +300,19 @@ export class BackgroundManager {
|
||||
|
||||
// Use prompt() instead of promptAsync() to properly initialize agent loop (fire-and-forget)
|
||||
// Include model if caller provided one (e.g., from Sisyphus category configs)
|
||||
// IMPORTANT: variant must be a top-level field in the body, NOT nested inside model
|
||||
// OpenCode's PromptInput schema expects: { model: { providerID, modelID }, variant: "max" }
|
||||
const launchModel = input.model
|
||||
? { providerID: input.model.providerID, modelID: input.model.modelID }
|
||||
: undefined
|
||||
const launchVariant = input.model?.variant
|
||||
|
||||
this.client.session.prompt({
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
agent: input.agent,
|
||||
...(input.model ? { model: input.model } : {}),
|
||||
...(launchModel ? { model: launchModel } : {}),
|
||||
...(launchVariant ? { variant: launchVariant } : {}),
|
||||
system: input.skillContent,
|
||||
tools: {
|
||||
...getAgentToolRestrictions(input.agent),
|
||||
@@ -542,11 +556,18 @@ export class BackgroundManager {
|
||||
|
||||
// Use prompt() instead of promptAsync() to properly initialize agent loop
|
||||
// Include model if task has one (preserved from original launch with category config)
|
||||
// variant must be top-level in body, not nested inside model (OpenCode PromptInput schema)
|
||||
const resumeModel = existingTask.model
|
||||
? { providerID: existingTask.model.providerID, modelID: existingTask.model.modelID }
|
||||
: undefined
|
||||
const resumeVariant = existingTask.model?.variant
|
||||
|
||||
this.client.session.prompt({
|
||||
path: { id: existingTask.sessionID },
|
||||
body: {
|
||||
agent: existingTask.agent,
|
||||
...(existingTask.model ? { model: existingTask.model } : {}),
|
||||
...(resumeModel ? { model: resumeModel } : {}),
|
||||
...(resumeVariant ? { variant: resumeVariant } : {}),
|
||||
tools: {
|
||||
...getAgentToolRestrictions(existingTask.agent),
|
||||
task: false,
|
||||
@@ -1328,7 +1349,25 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
|
||||
log("[background-agent] Shutting down BackgroundManager")
|
||||
this.stopPolling()
|
||||
|
||||
// Release concurrency for all running tasks first
|
||||
// Abort all running sessions to prevent zombie processes (#1240)
|
||||
for (const task of this.tasks.values()) {
|
||||
if (task.status === "running" && task.sessionID) {
|
||||
this.client.session.abort({
|
||||
path: { id: task.sessionID },
|
||||
}).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
// Notify shutdown listeners (e.g., tmux cleanup)
|
||||
if (this.onShutdown) {
|
||||
try {
|
||||
this.onShutdown()
|
||||
} catch (error) {
|
||||
log("[background-agent] Error in onShutdown callback:", error)
|
||||
}
|
||||
}
|
||||
|
||||
// Release concurrency for all running tasks
|
||||
for (const task of this.tasks.values()) {
|
||||
if (task.concurrencyKey) {
|
||||
this.concurrencyManager.release(task.concurrencyKey)
|
||||
|
||||
@@ -55,7 +55,6 @@ ${REFACTOR_TEMPLATE}
|
||||
},
|
||||
"start-work": {
|
||||
description: "(builtin) Start Sisyphus work session from Prometheus plan",
|
||||
agent: "atlas",
|
||||
template: `<command-instruction>
|
||||
${START_WORK_TEMPLATE}
|
||||
</command-instruction>
|
||||
|
||||
@@ -7,6 +7,10 @@ export interface ClaudeCodeMcpServer {
|
||||
args?: string[]
|
||||
env?: Record<string, string>
|
||||
headers?: Record<string, string>
|
||||
oauth?: {
|
||||
clientId?: string
|
||||
scopes?: string[]
|
||||
}
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, test, expect, beforeEach } from "bun:test"
|
||||
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
|
||||
import {
|
||||
setSessionAgent,
|
||||
getSessionAgent,
|
||||
@@ -13,9 +13,11 @@ describe("claude-code-session-state", () => {
|
||||
beforeEach(() => {
|
||||
// #given - clean state before each test
|
||||
_resetForTesting()
|
||||
clearSessionAgent("test-session-1")
|
||||
clearSessionAgent("test-session-2")
|
||||
clearSessionAgent("test-prometheus-session")
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// #then - cleanup after each test to prevent pollution
|
||||
_resetForTesting()
|
||||
})
|
||||
|
||||
describe("setSessionAgent", () => {
|
||||
@@ -92,9 +94,9 @@ describe("claude-code-session-state", () => {
|
||||
expect(getMainSessionID()).toBe(mainID)
|
||||
})
|
||||
|
||||
test.skip("should return undefined when not set", () => {
|
||||
// #given - not set
|
||||
// TODO: Fix flaky test - parallel test execution causes state pollution
|
||||
test("should return undefined when not set", () => {
|
||||
// #given - explicit reset to ensure clean state (parallel test isolation)
|
||||
_resetForTesting()
|
||||
// #then
|
||||
expect(getMainSessionID()).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -14,6 +14,7 @@ export function getMainSessionID(): string | undefined {
|
||||
export function _resetForTesting(): void {
|
||||
_mainSessionID = undefined
|
||||
subagentSessions.clear()
|
||||
sessionAgentMap.clear()
|
||||
}
|
||||
|
||||
const sessionAgentMap = new Map<string, string>()
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { MessageMeta, OriginalMessageContext, TextPart, ToolPermission } fr
|
||||
|
||||
export interface StoredMessage {
|
||||
agent?: string
|
||||
model?: { providerID?: string; modelID?: string }
|
||||
model?: { providerID?: string; modelID?: string; variant?: string }
|
||||
tools?: Record<string, ToolPermission>
|
||||
}
|
||||
|
||||
@@ -141,9 +141,17 @@ export function injectHookMessage(
|
||||
const resolvedAgent = originalMessage.agent ?? fallback?.agent ?? "general"
|
||||
const resolvedModel =
|
||||
originalMessage.model?.providerID && originalMessage.model?.modelID
|
||||
? { providerID: originalMessage.model.providerID, modelID: originalMessage.model.modelID }
|
||||
? {
|
||||
providerID: originalMessage.model.providerID,
|
||||
modelID: originalMessage.model.modelID,
|
||||
...(originalMessage.model.variant ? { variant: originalMessage.model.variant } : {})
|
||||
}
|
||||
: fallback?.model?.providerID && fallback?.model?.modelID
|
||||
? { providerID: fallback.model.providerID, modelID: fallback.model.modelID }
|
||||
? {
|
||||
providerID: fallback.model.providerID,
|
||||
modelID: fallback.model.modelID,
|
||||
...(fallback.model.variant ? { variant: fallback.model.variant } : {})
|
||||
}
|
||||
: undefined
|
||||
const resolvedTools = originalMessage.tools ?? fallback?.tools
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface MessageMeta {
|
||||
model?: {
|
||||
providerID: string
|
||||
modelID: string
|
||||
variant?: string
|
||||
}
|
||||
path?: {
|
||||
cwd: string
|
||||
@@ -25,6 +26,7 @@ export interface OriginalMessageContext {
|
||||
model?: {
|
||||
providerID?: string
|
||||
modelID?: string
|
||||
variant?: string
|
||||
}
|
||||
path?: {
|
||||
cwd?: string
|
||||
|
||||
129
src/features/mcp-oauth/callback-server.test.ts
Normal file
129
src/features/mcp-oauth/callback-server.test.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { afterEach, describe, expect, it } from "bun:test"
|
||||
import { findAvailablePort, startCallbackServer, type CallbackServer } from "./callback-server"
|
||||
|
||||
describe("findAvailablePort", () => {
|
||||
it("returns the start port when it is available", async () => {
|
||||
//#given
|
||||
const startPort = 19877
|
||||
|
||||
//#when
|
||||
const port = await findAvailablePort(startPort)
|
||||
|
||||
//#then
|
||||
expect(port).toBeGreaterThanOrEqual(startPort)
|
||||
expect(port).toBeLessThan(startPort + 20)
|
||||
})
|
||||
|
||||
it("skips busy ports and returns next available", async () => {
|
||||
//#given
|
||||
const blocker = Bun.serve({
|
||||
port: 19877,
|
||||
hostname: "127.0.0.1",
|
||||
fetch: () => new Response(),
|
||||
})
|
||||
|
||||
//#when
|
||||
const port = await findAvailablePort(19877)
|
||||
|
||||
//#then
|
||||
expect(port).toBeGreaterThan(19877)
|
||||
blocker.stop(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("startCallbackServer", () => {
|
||||
let server: CallbackServer | null = null
|
||||
|
||||
afterEach(() => {
|
||||
server?.close()
|
||||
server = null
|
||||
})
|
||||
|
||||
it("starts server and returns port", async () => {
|
||||
//#given - no preconditions
|
||||
|
||||
//#when
|
||||
server = await startCallbackServer()
|
||||
|
||||
//#then
|
||||
expect(server.port).toBeGreaterThanOrEqual(19877)
|
||||
expect(typeof server.waitForCallback).toBe("function")
|
||||
expect(typeof server.close).toBe("function")
|
||||
})
|
||||
|
||||
it("resolves callback with code and state from query params", async () => {
|
||||
//#given
|
||||
server = await startCallbackServer()
|
||||
const callbackUrl = `http://127.0.0.1:${server.port}/oauth/callback?code=test-code&state=test-state`
|
||||
|
||||
//#when
|
||||
const fetchPromise = fetch(callbackUrl)
|
||||
const result = await server.waitForCallback()
|
||||
const response = await fetchPromise
|
||||
|
||||
//#then
|
||||
expect(result).toEqual({ code: "test-code", state: "test-state" })
|
||||
expect(response.status).toBe(200)
|
||||
const html = await response.text()
|
||||
expect(html).toContain("Authorization successful")
|
||||
})
|
||||
|
||||
it("returns 404 for non-callback routes", async () => {
|
||||
//#given
|
||||
server = await startCallbackServer()
|
||||
|
||||
//#when
|
||||
const response = await fetch(`http://127.0.0.1:${server.port}/other`)
|
||||
|
||||
//#then
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
it("returns 400 and rejects when code is missing", async () => {
|
||||
//#given
|
||||
server = await startCallbackServer()
|
||||
const callbackRejection = server.waitForCallback().catch((e: Error) => e)
|
||||
|
||||
//#when
|
||||
const response = await fetch(`http://127.0.0.1:${server.port}/oauth/callback?state=s`)
|
||||
|
||||
//#then
|
||||
expect(response.status).toBe(400)
|
||||
const error = await callbackRejection
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
expect((error as Error).message).toContain("missing code or state")
|
||||
})
|
||||
|
||||
it("returns 400 and rejects when state is missing", async () => {
|
||||
//#given
|
||||
server = await startCallbackServer()
|
||||
const callbackRejection = server.waitForCallback().catch((e: Error) => e)
|
||||
|
||||
//#when
|
||||
const response = await fetch(`http://127.0.0.1:${server.port}/oauth/callback?code=c`)
|
||||
|
||||
//#then
|
||||
expect(response.status).toBe(400)
|
||||
const error = await callbackRejection
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
expect((error as Error).message).toContain("missing code or state")
|
||||
})
|
||||
|
||||
it("close stops the server immediately", async () => {
|
||||
//#given
|
||||
server = await startCallbackServer()
|
||||
const port = server.port
|
||||
|
||||
//#when
|
||||
server.close()
|
||||
server = null
|
||||
|
||||
//#then
|
||||
try {
|
||||
await fetch(`http://127.0.0.1:${port}/oauth/callback?code=c&state=s`)
|
||||
expect(true).toBe(false)
|
||||
} catch (error) {
|
||||
expect(error).toBeDefined()
|
||||
}
|
||||
})
|
||||
})
|
||||
124
src/features/mcp-oauth/callback-server.ts
Normal file
124
src/features/mcp-oauth/callback-server.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
const DEFAULT_PORT = 19877
|
||||
const MAX_PORT_ATTEMPTS = 20
|
||||
const TIMEOUT_MS = 5 * 60 * 1000
|
||||
|
||||
export type OAuthCallbackResult = {
|
||||
code: string
|
||||
state: string
|
||||
}
|
||||
|
||||
export type CallbackServer = {
|
||||
port: number
|
||||
waitForCallback: () => Promise<OAuthCallbackResult>
|
||||
close: () => void
|
||||
}
|
||||
|
||||
const SUCCESS_HTML = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>OAuth Authorized</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #0a0a0a; color: #fafafa; }
|
||||
.container { text-align: center; }
|
||||
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
|
||||
p { color: #888; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Authorization successful</h1>
|
||||
<p>You can close this window and return to your terminal.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
async function isPortAvailable(port: number): Promise<boolean> {
|
||||
try {
|
||||
const server = Bun.serve({
|
||||
port,
|
||||
hostname: "127.0.0.1",
|
||||
fetch: () => new Response(),
|
||||
})
|
||||
server.stop(true)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function findAvailablePort(startPort: number = DEFAULT_PORT): Promise<number> {
|
||||
for (let attempt = 0; attempt < MAX_PORT_ATTEMPTS; attempt++) {
|
||||
const port = startPort + attempt
|
||||
if (await isPortAvailable(port)) {
|
||||
return port
|
||||
}
|
||||
}
|
||||
throw new Error(`No available port found in range ${startPort}-${startPort + MAX_PORT_ATTEMPTS - 1}`)
|
||||
}
|
||||
|
||||
export async function startCallbackServer(startPort: number = DEFAULT_PORT): Promise<CallbackServer> {
|
||||
const port = await findAvailablePort(startPort)
|
||||
|
||||
let resolveCallback: ((result: OAuthCallbackResult) => void) | null = null
|
||||
let rejectCallback: ((error: Error) => void) | null = null
|
||||
|
||||
const callbackPromise = new Promise<OAuthCallbackResult>((resolve, reject) => {
|
||||
resolveCallback = resolve
|
||||
rejectCallback = reject
|
||||
})
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
rejectCallback?.(new Error("OAuth callback timed out after 5 minutes"))
|
||||
server.stop(true)
|
||||
}, TIMEOUT_MS)
|
||||
|
||||
const server = Bun.serve({
|
||||
port,
|
||||
hostname: "127.0.0.1",
|
||||
fetch(request: Request): Response {
|
||||
const url = new URL(request.url)
|
||||
|
||||
if (url.pathname !== "/oauth/callback") {
|
||||
return new Response("Not Found", { status: 404 })
|
||||
}
|
||||
|
||||
const oauthError = url.searchParams.get("error")
|
||||
if (oauthError) {
|
||||
const description = url.searchParams.get("error_description") ?? oauthError
|
||||
clearTimeout(timeoutId)
|
||||
rejectCallback?.(new Error(`OAuth authorization failed: ${description}`))
|
||||
setTimeout(() => server.stop(true), 100)
|
||||
return new Response(`Authorization failed: ${description}`, { status: 400 })
|
||||
}
|
||||
|
||||
const code = url.searchParams.get("code")
|
||||
const state = url.searchParams.get("state")
|
||||
|
||||
if (!code || !state) {
|
||||
clearTimeout(timeoutId)
|
||||
rejectCallback?.(new Error("OAuth callback missing code or state parameter"))
|
||||
setTimeout(() => server.stop(true), 100)
|
||||
return new Response("Missing code or state parameter", { status: 400 })
|
||||
}
|
||||
|
||||
resolveCallback?.({ code, state })
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
setTimeout(() => server.stop(true), 100)
|
||||
|
||||
return new Response(SUCCESS_HTML, {
|
||||
headers: { "content-type": "text/html; charset=utf-8" },
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
port,
|
||||
waitForCallback: () => callbackPromise,
|
||||
close: () => {
|
||||
clearTimeout(timeoutId)
|
||||
server.stop(true)
|
||||
},
|
||||
}
|
||||
}
|
||||
164
src/features/mcp-oauth/dcr.test.ts
Normal file
164
src/features/mcp-oauth/dcr.test.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import {
|
||||
getOrRegisterClient,
|
||||
type ClientCredentials,
|
||||
type ClientRegistrationStorage,
|
||||
type DcrFetch,
|
||||
} from "./dcr"
|
||||
|
||||
function createStorage(initial: ClientCredentials | null):
|
||||
& ClientRegistrationStorage
|
||||
& { getLastKey: () => string | null; getLastSet: () => ClientCredentials | null } {
|
||||
let stored = initial
|
||||
let lastKey: string | null = null
|
||||
let lastSet: ClientCredentials | null = null
|
||||
|
||||
return {
|
||||
getClientRegistration: () => stored,
|
||||
setClientRegistration: (serverIdentifier: string, credentials: ClientCredentials) => {
|
||||
lastKey = serverIdentifier
|
||||
lastSet = credentials
|
||||
stored = credentials
|
||||
},
|
||||
getLastKey: () => lastKey,
|
||||
getLastSet: () => lastSet,
|
||||
}
|
||||
}
|
||||
|
||||
describe("getOrRegisterClient", () => {
|
||||
it("returns cached registration when available", async () => {
|
||||
// #given
|
||||
const storage = createStorage({
|
||||
clientId: "cached-client",
|
||||
clientSecret: "cached-secret",
|
||||
})
|
||||
const fetchMock: DcrFetch = async () => {
|
||||
throw new Error("fetch should not be called")
|
||||
}
|
||||
|
||||
// #when
|
||||
const result = await getOrRegisterClient({
|
||||
registrationEndpoint: "https://server.example.com/register",
|
||||
serverIdentifier: "server-1",
|
||||
clientName: "Test Client",
|
||||
redirectUris: ["https://app.example.com/callback"],
|
||||
tokenEndpointAuthMethod: "client_secret_post",
|
||||
storage,
|
||||
fetch: fetchMock,
|
||||
})
|
||||
|
||||
// #then
|
||||
expect(result).toEqual({
|
||||
clientId: "cached-client",
|
||||
clientSecret: "cached-secret",
|
||||
})
|
||||
})
|
||||
|
||||
it("registers client and stores credentials when endpoint available", async () => {
|
||||
// #given
|
||||
const storage = createStorage(null)
|
||||
let fetchCalled = false
|
||||
const fetchMock: DcrFetch = async (
|
||||
input: string,
|
||||
init?: { method?: string; headers?: Record<string, string>; body?: string }
|
||||
) => {
|
||||
fetchCalled = true
|
||||
expect(input).toBe("https://server.example.com/register")
|
||||
if (typeof init?.body !== "string") {
|
||||
throw new Error("Expected request body string")
|
||||
}
|
||||
const payload = JSON.parse(init.body)
|
||||
expect(payload).toEqual({
|
||||
redirect_uris: ["https://app.example.com/callback"],
|
||||
client_name: "Test Client",
|
||||
grant_types: ["authorization_code", "refresh_token"],
|
||||
response_types: ["code"],
|
||||
token_endpoint_auth_method: "client_secret_post",
|
||||
})
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
client_id: "registered-client",
|
||||
client_secret: "registered-secret",
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// #when
|
||||
const result = await getOrRegisterClient({
|
||||
registrationEndpoint: "https://server.example.com/register",
|
||||
serverIdentifier: "server-2",
|
||||
clientName: "Test Client",
|
||||
redirectUris: ["https://app.example.com/callback"],
|
||||
tokenEndpointAuthMethod: "client_secret_post",
|
||||
storage,
|
||||
fetch: fetchMock,
|
||||
})
|
||||
|
||||
// #then
|
||||
expect(fetchCalled).toBe(true)
|
||||
expect(result).toEqual({
|
||||
clientId: "registered-client",
|
||||
clientSecret: "registered-secret",
|
||||
})
|
||||
expect(storage.getLastKey()).toBe("server-2")
|
||||
expect(storage.getLastSet()).toEqual({
|
||||
clientId: "registered-client",
|
||||
clientSecret: "registered-secret",
|
||||
})
|
||||
})
|
||||
|
||||
it("uses config client id when registration endpoint missing", async () => {
|
||||
// #given
|
||||
const storage = createStorage(null)
|
||||
let fetchCalled = false
|
||||
const fetchMock: DcrFetch = async () => {
|
||||
fetchCalled = true
|
||||
return {
|
||||
ok: false,
|
||||
json: async () => ({}),
|
||||
}
|
||||
}
|
||||
|
||||
// #when
|
||||
const result = await getOrRegisterClient({
|
||||
registrationEndpoint: undefined,
|
||||
serverIdentifier: "server-3",
|
||||
clientName: "Test Client",
|
||||
redirectUris: ["https://app.example.com/callback"],
|
||||
tokenEndpointAuthMethod: "client_secret_post",
|
||||
clientId: "config-client",
|
||||
storage,
|
||||
fetch: fetchMock,
|
||||
})
|
||||
|
||||
// #then
|
||||
expect(fetchCalled).toBe(false)
|
||||
expect(result).toEqual({ clientId: "config-client" })
|
||||
})
|
||||
|
||||
it("falls back to config client id when registration fails", async () => {
|
||||
// #given
|
||||
const storage = createStorage(null)
|
||||
const fetchMock: DcrFetch = async () => {
|
||||
throw new Error("network error")
|
||||
}
|
||||
|
||||
// #when
|
||||
const result = await getOrRegisterClient({
|
||||
registrationEndpoint: "https://server.example.com/register",
|
||||
serverIdentifier: "server-4",
|
||||
clientName: "Test Client",
|
||||
redirectUris: ["https://app.example.com/callback"],
|
||||
tokenEndpointAuthMethod: "client_secret_post",
|
||||
clientId: "fallback-client",
|
||||
storage,
|
||||
fetch: fetchMock,
|
||||
})
|
||||
|
||||
// #then
|
||||
expect(result).toEqual({ clientId: "fallback-client" })
|
||||
expect(storage.getLastSet()).toBeNull()
|
||||
})
|
||||
})
|
||||
98
src/features/mcp-oauth/dcr.ts
Normal file
98
src/features/mcp-oauth/dcr.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
export type ClientRegistrationRequest = {
|
||||
redirect_uris: string[]
|
||||
client_name: string
|
||||
grant_types: ["authorization_code", "refresh_token"]
|
||||
response_types: ["code"]
|
||||
token_endpoint_auth_method: "none" | "client_secret_post"
|
||||
}
|
||||
|
||||
export type ClientCredentials = {
|
||||
clientId: string
|
||||
clientSecret?: string
|
||||
}
|
||||
|
||||
export type ClientRegistrationStorage = {
|
||||
getClientRegistration: (serverIdentifier: string) => ClientCredentials | null
|
||||
setClientRegistration: (
|
||||
serverIdentifier: string,
|
||||
credentials: ClientCredentials
|
||||
) => void
|
||||
}
|
||||
|
||||
export type DynamicClientRegistrationOptions = {
|
||||
registrationEndpoint?: string | null
|
||||
serverIdentifier?: string
|
||||
clientName: string
|
||||
redirectUris: string[]
|
||||
tokenEndpointAuthMethod: "none" | "client_secret_post"
|
||||
clientId?: string | null
|
||||
storage: ClientRegistrationStorage
|
||||
fetch?: DcrFetch
|
||||
}
|
||||
|
||||
export type DcrFetch = (
|
||||
input: string,
|
||||
init?: { method?: string; headers?: Record<string, string>; body?: string }
|
||||
) => Promise<{ ok: boolean; json: () => Promise<unknown> }>
|
||||
|
||||
export async function getOrRegisterClient(
|
||||
options: DynamicClientRegistrationOptions
|
||||
): Promise<ClientCredentials | null> {
|
||||
const serverIdentifier =
|
||||
options.serverIdentifier ?? options.registrationEndpoint ?? "default"
|
||||
const existing = options.storage.getClientRegistration(serverIdentifier)
|
||||
if (existing) return existing
|
||||
|
||||
if (!options.registrationEndpoint) {
|
||||
return options.clientId ? { clientId: options.clientId } : null
|
||||
}
|
||||
|
||||
const fetchImpl = options.fetch ?? globalThis.fetch
|
||||
const request: ClientRegistrationRequest = {
|
||||
redirect_uris: options.redirectUris,
|
||||
client_name: options.clientName,
|
||||
grant_types: ["authorization_code", "refresh_token"],
|
||||
response_types: ["code"],
|
||||
token_endpoint_auth_method: options.tokenEndpointAuthMethod,
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetchImpl(options.registrationEndpoint, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(request),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
return options.clientId ? { clientId: options.clientId } : null
|
||||
}
|
||||
|
||||
const data: unknown = await response.json()
|
||||
const parsed = parseRegistrationResponse(data)
|
||||
if (!parsed) {
|
||||
return options.clientId ? { clientId: options.clientId } : null
|
||||
}
|
||||
|
||||
options.storage.setClientRegistration(serverIdentifier, parsed)
|
||||
return parsed
|
||||
} catch {
|
||||
return options.clientId ? { clientId: options.clientId } : null
|
||||
}
|
||||
}
|
||||
|
||||
function parseRegistrationResponse(data: unknown): ClientCredentials | null {
|
||||
if (!isRecord(data)) return null
|
||||
const clientId = data.client_id
|
||||
if (typeof clientId !== "string" || clientId.length === 0) return null
|
||||
|
||||
const clientSecret = data.client_secret
|
||||
if (typeof clientSecret === "string" && clientSecret.length > 0) {
|
||||
return { clientId, clientSecret }
|
||||
}
|
||||
|
||||
return { clientId }
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null
|
||||
}
|
||||
175
src/features/mcp-oauth/discovery.test.ts
Normal file
175
src/features/mcp-oauth/discovery.test.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
|
||||
import { discoverOAuthServerMetadata, resetDiscoveryCache } from "./discovery"
|
||||
|
||||
describe("discoverOAuthServerMetadata", () => {
|
||||
const originalFetch = globalThis.fetch
|
||||
|
||||
beforeEach(() => {
|
||||
resetDiscoveryCache()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(globalThis, "fetch", { value: originalFetch, configurable: true })
|
||||
})
|
||||
|
||||
test("returns endpoints from PRM + AS discovery", () => {
|
||||
// #given
|
||||
const resource = "https://mcp.example.com"
|
||||
const prmUrl = new URL("/.well-known/oauth-protected-resource", resource).toString()
|
||||
const authServer = "https://auth.example.com"
|
||||
const asUrl = new URL("/.well-known/oauth-authorization-server", authServer).toString()
|
||||
const calls: string[] = []
|
||||
const fetchMock = async (input: string | URL) => {
|
||||
const url = typeof input === "string" ? input : input.toString()
|
||||
calls.push(url)
|
||||
if (url === prmUrl) {
|
||||
return new Response(JSON.stringify({ authorization_servers: [authServer] }), { status: 200 })
|
||||
}
|
||||
if (url === asUrl) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
authorization_endpoint: "https://auth.example.com/authorize",
|
||||
token_endpoint: "https://auth.example.com/token",
|
||||
registration_endpoint: "https://auth.example.com/register",
|
||||
}),
|
||||
{ status: 200 }
|
||||
)
|
||||
}
|
||||
return new Response("not found", { status: 404 })
|
||||
}
|
||||
Object.defineProperty(globalThis, "fetch", { value: fetchMock, configurable: true })
|
||||
|
||||
// #when
|
||||
return discoverOAuthServerMetadata(resource).then((result) => {
|
||||
// #then
|
||||
expect(result).toEqual({
|
||||
authorizationEndpoint: "https://auth.example.com/authorize",
|
||||
tokenEndpoint: "https://auth.example.com/token",
|
||||
registrationEndpoint: "https://auth.example.com/register",
|
||||
resource,
|
||||
})
|
||||
expect(calls).toEqual([prmUrl, asUrl])
|
||||
})
|
||||
})
|
||||
|
||||
test("falls back to RFC 8414 when PRM returns 404", () => {
|
||||
// #given
|
||||
const resource = "https://mcp.example.com"
|
||||
const prmUrl = new URL("/.well-known/oauth-protected-resource", resource).toString()
|
||||
const asUrl = new URL("/.well-known/oauth-authorization-server", resource).toString()
|
||||
const calls: string[] = []
|
||||
const fetchMock = async (input: string | URL) => {
|
||||
const url = typeof input === "string" ? input : input.toString()
|
||||
calls.push(url)
|
||||
if (url === prmUrl) {
|
||||
return new Response("not found", { status: 404 })
|
||||
}
|
||||
if (url === asUrl) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
authorization_endpoint: "https://mcp.example.com/authorize",
|
||||
token_endpoint: "https://mcp.example.com/token",
|
||||
}),
|
||||
{ status: 200 }
|
||||
)
|
||||
}
|
||||
return new Response("not found", { status: 404 })
|
||||
}
|
||||
Object.defineProperty(globalThis, "fetch", { value: fetchMock, configurable: true })
|
||||
|
||||
// #when
|
||||
return discoverOAuthServerMetadata(resource).then((result) => {
|
||||
// #then
|
||||
expect(result).toEqual({
|
||||
authorizationEndpoint: "https://mcp.example.com/authorize",
|
||||
tokenEndpoint: "https://mcp.example.com/token",
|
||||
registrationEndpoint: undefined,
|
||||
resource,
|
||||
})
|
||||
expect(calls).toEqual([prmUrl, asUrl])
|
||||
})
|
||||
})
|
||||
|
||||
test("throws when both PRM and AS discovery return 404", () => {
|
||||
// #given
|
||||
const resource = "https://mcp.example.com"
|
||||
const prmUrl = new URL("/.well-known/oauth-protected-resource", resource).toString()
|
||||
const asUrl = new URL("/.well-known/oauth-authorization-server", resource).toString()
|
||||
const fetchMock = async (input: string | URL) => {
|
||||
const url = typeof input === "string" ? input : input.toString()
|
||||
if (url === prmUrl || url === asUrl) {
|
||||
return new Response("not found", { status: 404 })
|
||||
}
|
||||
return new Response("not found", { status: 404 })
|
||||
}
|
||||
Object.defineProperty(globalThis, "fetch", { value: fetchMock, configurable: true })
|
||||
|
||||
// #when
|
||||
const result = discoverOAuthServerMetadata(resource)
|
||||
|
||||
// #then
|
||||
return expect(result).rejects.toThrow("OAuth authorization server metadata not found")
|
||||
})
|
||||
|
||||
test("throws when AS metadata is malformed", () => {
|
||||
// #given
|
||||
const resource = "https://mcp.example.com"
|
||||
const prmUrl = new URL("/.well-known/oauth-protected-resource", resource).toString()
|
||||
const authServer = "https://auth.example.com"
|
||||
const asUrl = new URL("/.well-known/oauth-authorization-server", authServer).toString()
|
||||
const fetchMock = async (input: string | URL) => {
|
||||
const url = typeof input === "string" ? input : input.toString()
|
||||
if (url === prmUrl) {
|
||||
return new Response(JSON.stringify({ authorization_servers: [authServer] }), { status: 200 })
|
||||
}
|
||||
if (url === asUrl) {
|
||||
return new Response(JSON.stringify({ authorization_endpoint: "https://auth.example.com/authorize" }), {
|
||||
status: 200,
|
||||
})
|
||||
}
|
||||
return new Response("not found", { status: 404 })
|
||||
}
|
||||
Object.defineProperty(globalThis, "fetch", { value: fetchMock, configurable: true })
|
||||
|
||||
// #when
|
||||
const result = discoverOAuthServerMetadata(resource)
|
||||
|
||||
// #then
|
||||
return expect(result).rejects.toThrow("token_endpoint")
|
||||
})
|
||||
|
||||
test("caches discovery results per resource URL", () => {
|
||||
// #given
|
||||
const resource = "https://mcp.example.com"
|
||||
const prmUrl = new URL("/.well-known/oauth-protected-resource", resource).toString()
|
||||
const authServer = "https://auth.example.com"
|
||||
const asUrl = new URL("/.well-known/oauth-authorization-server", authServer).toString()
|
||||
const calls: string[] = []
|
||||
const fetchMock = async (input: string | URL) => {
|
||||
const url = typeof input === "string" ? input : input.toString()
|
||||
calls.push(url)
|
||||
if (url === prmUrl) {
|
||||
return new Response(JSON.stringify({ authorization_servers: [authServer] }), { status: 200 })
|
||||
}
|
||||
if (url === asUrl) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
authorization_endpoint: "https://auth.example.com/authorize",
|
||||
token_endpoint: "https://auth.example.com/token",
|
||||
}),
|
||||
{ status: 200 }
|
||||
)
|
||||
}
|
||||
return new Response("not found", { status: 404 })
|
||||
}
|
||||
Object.defineProperty(globalThis, "fetch", { value: fetchMock, configurable: true })
|
||||
|
||||
// #when
|
||||
return discoverOAuthServerMetadata(resource)
|
||||
.then(() => discoverOAuthServerMetadata(resource))
|
||||
.then(() => {
|
||||
// #then
|
||||
expect(calls).toEqual([prmUrl, asUrl])
|
||||
})
|
||||
})
|
||||
})
|
||||
123
src/features/mcp-oauth/discovery.ts
Normal file
123
src/features/mcp-oauth/discovery.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
export interface OAuthServerMetadata {
|
||||
authorizationEndpoint: string
|
||||
tokenEndpoint: string
|
||||
registrationEndpoint?: string
|
||||
resource: string
|
||||
}
|
||||
|
||||
const discoveryCache = new Map<string, OAuthServerMetadata>()
|
||||
const pendingDiscovery = new Map<string, Promise<OAuthServerMetadata>>()
|
||||
|
||||
function parseHttpsUrl(value: string, label: string): URL {
|
||||
const parsed = new URL(value)
|
||||
if (parsed.protocol !== "https:") {
|
||||
throw new Error(`${label} must use https`)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
function readStringField(source: Record<string, unknown>, field: string): string {
|
||||
const value = source[field]
|
||||
if (typeof value !== "string" || value.length === 0) {
|
||||
throw new Error(`OAuth metadata missing ${field}`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
async function fetchMetadata(url: string): Promise<{ ok: true; json: Record<string, unknown> } | { ok: false; status: number }> {
|
||||
const response = await fetch(url, { headers: { accept: "application/json" } })
|
||||
if (!response.ok) {
|
||||
return { ok: false, status: response.status }
|
||||
}
|
||||
const json = (await response.json().catch(() => null)) as Record<string, unknown> | null
|
||||
if (!json || typeof json !== "object") {
|
||||
throw new Error("OAuth metadata response is not valid JSON")
|
||||
}
|
||||
return { ok: true, json }
|
||||
}
|
||||
|
||||
async function fetchAuthorizationServerMetadata(issuer: string, resource: string): Promise<OAuthServerMetadata> {
|
||||
const issuerUrl = parseHttpsUrl(issuer, "Authorization server URL")
|
||||
const issuerPath = issuerUrl.pathname.replace(/\/+$/, "")
|
||||
const metadataUrl = new URL(`/.well-known/oauth-authorization-server${issuerPath}`, issuerUrl).toString()
|
||||
const metadata = await fetchMetadata(metadataUrl)
|
||||
|
||||
if (!metadata.ok) {
|
||||
if (metadata.status === 404) {
|
||||
throw new Error("OAuth authorization server metadata not found")
|
||||
}
|
||||
throw new Error(`OAuth authorization server metadata fetch failed (${metadata.status})`)
|
||||
}
|
||||
|
||||
const authorizationEndpoint = parseHttpsUrl(
|
||||
readStringField(metadata.json, "authorization_endpoint"),
|
||||
"authorization_endpoint"
|
||||
).toString()
|
||||
const tokenEndpoint = parseHttpsUrl(
|
||||
readStringField(metadata.json, "token_endpoint"),
|
||||
"token_endpoint"
|
||||
).toString()
|
||||
const registrationEndpointValue = metadata.json.registration_endpoint
|
||||
const registrationEndpoint =
|
||||
typeof registrationEndpointValue === "string" && registrationEndpointValue.length > 0
|
||||
? parseHttpsUrl(registrationEndpointValue, "registration_endpoint").toString()
|
||||
: undefined
|
||||
|
||||
return {
|
||||
authorizationEndpoint,
|
||||
tokenEndpoint,
|
||||
registrationEndpoint,
|
||||
resource,
|
||||
}
|
||||
}
|
||||
|
||||
function parseAuthorizationServers(metadata: Record<string, unknown>): string[] {
|
||||
const servers = metadata.authorization_servers
|
||||
if (!Array.isArray(servers)) return []
|
||||
return servers.filter((server): server is string => typeof server === "string" && server.length > 0)
|
||||
}
|
||||
|
||||
export async function discoverOAuthServerMetadata(resource: string): Promise<OAuthServerMetadata> {
|
||||
const resourceUrl = parseHttpsUrl(resource, "Resource server URL")
|
||||
const resourceKey = resourceUrl.toString()
|
||||
|
||||
const cached = discoveryCache.get(resourceKey)
|
||||
if (cached) return cached
|
||||
|
||||
const pending = pendingDiscovery.get(resourceKey)
|
||||
if (pending) return pending
|
||||
|
||||
const discoveryPromise = (async () => {
|
||||
const prmUrl = new URL("/.well-known/oauth-protected-resource", resourceUrl).toString()
|
||||
const prmResponse = await fetchMetadata(prmUrl)
|
||||
|
||||
if (prmResponse.ok) {
|
||||
const authServers = parseAuthorizationServers(prmResponse.json)
|
||||
if (authServers.length === 0) {
|
||||
throw new Error("OAuth protected resource metadata missing authorization_servers")
|
||||
}
|
||||
return fetchAuthorizationServerMetadata(authServers[0], resource)
|
||||
}
|
||||
|
||||
if (prmResponse.status !== 404) {
|
||||
throw new Error(`OAuth protected resource metadata fetch failed (${prmResponse.status})`)
|
||||
}
|
||||
|
||||
return fetchAuthorizationServerMetadata(resourceKey, resource)
|
||||
})()
|
||||
|
||||
pendingDiscovery.set(resourceKey, discoveryPromise)
|
||||
|
||||
try {
|
||||
const result = await discoveryPromise
|
||||
discoveryCache.set(resourceKey, result)
|
||||
return result
|
||||
} finally {
|
||||
pendingDiscovery.delete(resourceKey)
|
||||
}
|
||||
}
|
||||
|
||||
export function resetDiscoveryCache(): void {
|
||||
discoveryCache.clear()
|
||||
pendingDiscovery.clear()
|
||||
}
|
||||
1
src/features/mcp-oauth/index.ts
Normal file
1
src/features/mcp-oauth/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./schema"
|
||||
223
src/features/mcp-oauth/provider.test.ts
Normal file
223
src/features/mcp-oauth/provider.test.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import { describe, expect, it, beforeEach, afterEach, mock } from "bun:test"
|
||||
import { createHash, randomBytes } from "node:crypto"
|
||||
import { McpOAuthProvider, generateCodeVerifier, generateCodeChallenge, buildAuthorizationUrl } from "./provider"
|
||||
import type { OAuthTokenData } from "./storage"
|
||||
|
||||
describe("McpOAuthProvider", () => {
|
||||
describe("generateCodeVerifier", () => {
|
||||
it("returns a base64url-encoded 32-byte random string", () => {
|
||||
//#given
|
||||
const verifier = generateCodeVerifier()
|
||||
|
||||
//#when
|
||||
const decoded = Buffer.from(verifier, "base64url")
|
||||
|
||||
//#then
|
||||
expect(decoded.length).toBe(32)
|
||||
expect(verifier).toMatch(/^[A-Za-z0-9_-]+$/)
|
||||
})
|
||||
|
||||
it("produces unique values on each call", () => {
|
||||
//#given
|
||||
const first = generateCodeVerifier()
|
||||
|
||||
//#when
|
||||
const second = generateCodeVerifier()
|
||||
|
||||
//#then
|
||||
expect(first).not.toBe(second)
|
||||
})
|
||||
})
|
||||
|
||||
describe("generateCodeChallenge", () => {
|
||||
it("returns SHA256 base64url digest of the verifier", () => {
|
||||
//#given
|
||||
const verifier = "test-verifier-value"
|
||||
const expected = createHash("sha256").update(verifier).digest("base64url")
|
||||
|
||||
//#when
|
||||
const challenge = generateCodeChallenge(verifier)
|
||||
|
||||
//#then
|
||||
expect(challenge).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe("buildAuthorizationUrl", () => {
|
||||
it("builds URL with all required PKCE parameters", () => {
|
||||
//#given
|
||||
const endpoint = "https://auth.example.com/authorize"
|
||||
|
||||
//#when
|
||||
const url = buildAuthorizationUrl(endpoint, {
|
||||
clientId: "my-client",
|
||||
redirectUri: "http://127.0.0.1:8912/callback",
|
||||
codeChallenge: "challenge-value",
|
||||
state: "state-value",
|
||||
scopes: ["openid", "profile"],
|
||||
resource: "https://mcp.example.com",
|
||||
})
|
||||
|
||||
//#then
|
||||
const parsed = new URL(url)
|
||||
expect(parsed.origin + parsed.pathname).toBe("https://auth.example.com/authorize")
|
||||
expect(parsed.searchParams.get("response_type")).toBe("code")
|
||||
expect(parsed.searchParams.get("client_id")).toBe("my-client")
|
||||
expect(parsed.searchParams.get("redirect_uri")).toBe("http://127.0.0.1:8912/callback")
|
||||
expect(parsed.searchParams.get("code_challenge")).toBe("challenge-value")
|
||||
expect(parsed.searchParams.get("code_challenge_method")).toBe("S256")
|
||||
expect(parsed.searchParams.get("state")).toBe("state-value")
|
||||
expect(parsed.searchParams.get("scope")).toBe("openid profile")
|
||||
expect(parsed.searchParams.get("resource")).toBe("https://mcp.example.com")
|
||||
})
|
||||
|
||||
it("omits scope when empty", () => {
|
||||
//#given
|
||||
const endpoint = "https://auth.example.com/authorize"
|
||||
|
||||
//#when
|
||||
const url = buildAuthorizationUrl(endpoint, {
|
||||
clientId: "my-client",
|
||||
redirectUri: "http://127.0.0.1:8912/callback",
|
||||
codeChallenge: "challenge-value",
|
||||
state: "state-value",
|
||||
scopes: [],
|
||||
})
|
||||
|
||||
//#then
|
||||
const parsed = new URL(url)
|
||||
expect(parsed.searchParams.has("scope")).toBe(false)
|
||||
})
|
||||
|
||||
it("omits resource when undefined", () => {
|
||||
//#given
|
||||
const endpoint = "https://auth.example.com/authorize"
|
||||
|
||||
//#when
|
||||
const url = buildAuthorizationUrl(endpoint, {
|
||||
clientId: "my-client",
|
||||
redirectUri: "http://127.0.0.1:8912/callback",
|
||||
codeChallenge: "challenge-value",
|
||||
state: "state-value",
|
||||
})
|
||||
|
||||
//#then
|
||||
const parsed = new URL(url)
|
||||
expect(parsed.searchParams.has("resource")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("constructor and basic methods", () => {
|
||||
it("stores serverUrl and optional clientId and scopes", () => {
|
||||
//#given
|
||||
const options = {
|
||||
serverUrl: "https://mcp.example.com",
|
||||
clientId: "my-client",
|
||||
scopes: ["openid"],
|
||||
}
|
||||
|
||||
//#when
|
||||
const provider = new McpOAuthProvider(options)
|
||||
|
||||
//#then
|
||||
expect(provider.tokens()).toBeNull()
|
||||
expect(provider.clientInformation()).toBeNull()
|
||||
expect(provider.codeVerifier()).toBeNull()
|
||||
})
|
||||
|
||||
it("defaults scopes to empty array", () => {
|
||||
//#given
|
||||
const options = { serverUrl: "https://mcp.example.com" }
|
||||
|
||||
//#when
|
||||
const provider = new McpOAuthProvider(options)
|
||||
|
||||
//#then
|
||||
expect(provider.redirectUrl()).toBe("http://127.0.0.1:19877/callback")
|
||||
})
|
||||
})
|
||||
|
||||
describe("saveCodeVerifier / codeVerifier", () => {
|
||||
it("stores and retrieves code verifier", () => {
|
||||
//#given
|
||||
const provider = new McpOAuthProvider({ serverUrl: "https://mcp.example.com" })
|
||||
|
||||
//#when
|
||||
provider.saveCodeVerifier("my-verifier")
|
||||
|
||||
//#then
|
||||
expect(provider.codeVerifier()).toBe("my-verifier")
|
||||
})
|
||||
})
|
||||
|
||||
describe("saveTokens / tokens", () => {
|
||||
let originalEnv: string | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
originalEnv = process.env.OPENCODE_CONFIG_DIR
|
||||
const { mkdirSync } = require("node:fs")
|
||||
const { tmpdir } = require("node:os")
|
||||
const { join } = require("node:path")
|
||||
const testDir = join(tmpdir(), "mcp-oauth-provider-test-" + Date.now())
|
||||
mkdirSync(testDir, { recursive: true })
|
||||
process.env.OPENCODE_CONFIG_DIR = testDir
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (originalEnv === undefined) {
|
||||
delete process.env.OPENCODE_CONFIG_DIR
|
||||
} else {
|
||||
process.env.OPENCODE_CONFIG_DIR = originalEnv
|
||||
}
|
||||
})
|
||||
|
||||
it("persists and loads token data via storage", () => {
|
||||
//#given
|
||||
const provider = new McpOAuthProvider({ serverUrl: "https://mcp.example.com" })
|
||||
const tokenData: OAuthTokenData = {
|
||||
accessToken: "access-token-123",
|
||||
refreshToken: "refresh-token-456",
|
||||
expiresAt: 1710000000,
|
||||
}
|
||||
|
||||
//#when
|
||||
const saved = provider.saveTokens(tokenData)
|
||||
const loaded = provider.tokens()
|
||||
|
||||
//#then
|
||||
expect(saved).toBe(true)
|
||||
expect(loaded).toEqual(tokenData)
|
||||
})
|
||||
})
|
||||
|
||||
describe("redirectToAuthorization", () => {
|
||||
it("throws when no client information is set", async () => {
|
||||
//#given
|
||||
const provider = new McpOAuthProvider({ serverUrl: "https://mcp.example.com" })
|
||||
const metadata = {
|
||||
authorizationEndpoint: "https://auth.example.com/authorize",
|
||||
tokenEndpoint: "https://auth.example.com/token",
|
||||
resource: "https://mcp.example.com",
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = provider.redirectToAuthorization(metadata)
|
||||
|
||||
//#then
|
||||
await expect(result).rejects.toThrow("No client information available")
|
||||
})
|
||||
})
|
||||
|
||||
describe("redirectUrl", () => {
|
||||
it("returns localhost callback URL with default port", () => {
|
||||
//#given
|
||||
const provider = new McpOAuthProvider({ serverUrl: "https://mcp.example.com" })
|
||||
|
||||
//#when
|
||||
const url = provider.redirectUrl()
|
||||
|
||||
//#then
|
||||
expect(url).toBe("http://127.0.0.1:19877/callback")
|
||||
})
|
||||
})
|
||||
})
|
||||
295
src/features/mcp-oauth/provider.ts
Normal file
295
src/features/mcp-oauth/provider.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
import { createHash, randomBytes } from "node:crypto"
|
||||
import { createServer } from "node:http"
|
||||
import { spawn } from "node:child_process"
|
||||
import type { OAuthTokenData } from "./storage"
|
||||
import { loadToken, saveToken } from "./storage"
|
||||
import { discoverOAuthServerMetadata } from "./discovery"
|
||||
import type { OAuthServerMetadata } from "./discovery"
|
||||
import { getOrRegisterClient } from "./dcr"
|
||||
import type { ClientCredentials, ClientRegistrationStorage } from "./dcr"
|
||||
import { findAvailablePort } from "./callback-server"
|
||||
|
||||
export type McpOAuthProviderOptions = {
|
||||
serverUrl: string
|
||||
clientId?: string
|
||||
scopes?: string[]
|
||||
}
|
||||
|
||||
type CallbackResult = {
|
||||
code: string
|
||||
state: string
|
||||
}
|
||||
|
||||
function generateCodeVerifier(): string {
|
||||
return randomBytes(32).toString("base64url")
|
||||
}
|
||||
|
||||
function generateCodeChallenge(verifier: string): string {
|
||||
return createHash("sha256").update(verifier).digest("base64url")
|
||||
}
|
||||
|
||||
function buildAuthorizationUrl(
|
||||
authorizationEndpoint: string,
|
||||
options: {
|
||||
clientId: string
|
||||
redirectUri: string
|
||||
codeChallenge: string
|
||||
state: string
|
||||
scopes?: string[]
|
||||
resource?: string
|
||||
}
|
||||
): string {
|
||||
const url = new URL(authorizationEndpoint)
|
||||
url.searchParams.set("response_type", "code")
|
||||
url.searchParams.set("client_id", options.clientId)
|
||||
url.searchParams.set("redirect_uri", options.redirectUri)
|
||||
url.searchParams.set("code_challenge", options.codeChallenge)
|
||||
url.searchParams.set("code_challenge_method", "S256")
|
||||
url.searchParams.set("state", options.state)
|
||||
if (options.scopes && options.scopes.length > 0) {
|
||||
url.searchParams.set("scope", options.scopes.join(" "))
|
||||
}
|
||||
if (options.resource) {
|
||||
url.searchParams.set("resource", options.resource)
|
||||
}
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000
|
||||
|
||||
function startCallbackServer(port: number): Promise<CallbackResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let timeoutId: ReturnType<typeof setTimeout>
|
||||
|
||||
const server = createServer((request, response) => {
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
const requestUrl = new URL(request.url ?? "/", `http://localhost:${port}`)
|
||||
const code = requestUrl.searchParams.get("code")
|
||||
const state = requestUrl.searchParams.get("state")
|
||||
const error = requestUrl.searchParams.get("error")
|
||||
|
||||
if (error) {
|
||||
const errorDescription = requestUrl.searchParams.get("error_description") ?? error
|
||||
response.writeHead(400, { "content-type": "text/html" })
|
||||
response.end("<html><body><h1>Authorization failed</h1></body></html>")
|
||||
server.close()
|
||||
reject(new Error(`OAuth authorization error: ${errorDescription}`))
|
||||
return
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
response.writeHead(400, { "content-type": "text/html" })
|
||||
response.end("<html><body><h1>Missing code or state</h1></body></html>")
|
||||
server.close()
|
||||
reject(new Error("OAuth callback missing code or state parameter"))
|
||||
return
|
||||
}
|
||||
|
||||
response.writeHead(200, { "content-type": "text/html" })
|
||||
response.end("<html><body><h1>Authorization successful. You can close this tab.</h1></body></html>")
|
||||
server.close()
|
||||
resolve({ code, state })
|
||||
})
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
server.close()
|
||||
reject(new Error("OAuth callback timed out after 5 minutes"))
|
||||
}, CALLBACK_TIMEOUT_MS)
|
||||
|
||||
server.listen(port, "127.0.0.1")
|
||||
server.on("error", (err) => {
|
||||
clearTimeout(timeoutId)
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function openBrowser(url: string): void {
|
||||
const platform = process.platform
|
||||
let cmd: string
|
||||
let args: string[]
|
||||
|
||||
if (platform === "darwin") {
|
||||
cmd = "open"
|
||||
args = [url]
|
||||
} else if (platform === "win32") {
|
||||
cmd = "explorer"
|
||||
args = [url]
|
||||
} else {
|
||||
cmd = "xdg-open"
|
||||
args = [url]
|
||||
}
|
||||
|
||||
try {
|
||||
const child = spawn(cmd, args, { stdio: "ignore", detached: true })
|
||||
child.on("error", () => {})
|
||||
child.unref()
|
||||
} catch {
|
||||
// Browser open failed — user must navigate manually
|
||||
}
|
||||
}
|
||||
|
||||
export class McpOAuthProvider {
|
||||
private readonly serverUrl: string
|
||||
private readonly configClientId: string | undefined
|
||||
private readonly scopes: string[]
|
||||
private storedCodeVerifier: string | null = null
|
||||
private storedClientInfo: ClientCredentials | null = null
|
||||
private callbackPort: number | null = null
|
||||
|
||||
constructor(options: McpOAuthProviderOptions) {
|
||||
this.serverUrl = options.serverUrl
|
||||
this.configClientId = options.clientId
|
||||
this.scopes = options.scopes ?? []
|
||||
}
|
||||
|
||||
tokens(): OAuthTokenData | null {
|
||||
return loadToken(this.serverUrl, this.serverUrl)
|
||||
}
|
||||
|
||||
saveTokens(tokenData: OAuthTokenData): boolean {
|
||||
return saveToken(this.serverUrl, this.serverUrl, tokenData)
|
||||
}
|
||||
|
||||
clientInformation(): ClientCredentials | null {
|
||||
if (this.storedClientInfo) return this.storedClientInfo
|
||||
const tokenData = this.tokens()
|
||||
if (tokenData?.clientInfo) {
|
||||
this.storedClientInfo = tokenData.clientInfo
|
||||
return this.storedClientInfo
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
redirectUrl(): string {
|
||||
return `http://127.0.0.1:${this.callbackPort ?? 19877}/callback`
|
||||
}
|
||||
|
||||
saveCodeVerifier(verifier: string): void {
|
||||
this.storedCodeVerifier = verifier
|
||||
}
|
||||
|
||||
codeVerifier(): string | null {
|
||||
return this.storedCodeVerifier
|
||||
}
|
||||
|
||||
async redirectToAuthorization(metadata: OAuthServerMetadata): Promise<CallbackResult> {
|
||||
const verifier = generateCodeVerifier()
|
||||
this.saveCodeVerifier(verifier)
|
||||
const challenge = generateCodeChallenge(verifier)
|
||||
const state = randomBytes(16).toString("hex")
|
||||
|
||||
const clientInfo = this.clientInformation()
|
||||
if (!clientInfo) {
|
||||
throw new Error("No client information available. Run login() or register a client first.")
|
||||
}
|
||||
|
||||
if (this.callbackPort === null) {
|
||||
this.callbackPort = await findAvailablePort()
|
||||
}
|
||||
|
||||
const authUrl = buildAuthorizationUrl(metadata.authorizationEndpoint, {
|
||||
clientId: clientInfo.clientId,
|
||||
redirectUri: this.redirectUrl(),
|
||||
codeChallenge: challenge,
|
||||
state,
|
||||
scopes: this.scopes,
|
||||
resource: metadata.resource,
|
||||
})
|
||||
|
||||
const callbackPromise = startCallbackServer(this.callbackPort)
|
||||
openBrowser(authUrl)
|
||||
|
||||
const result = await callbackPromise
|
||||
if (result.state !== state) {
|
||||
throw new Error("OAuth state mismatch")
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async login(): Promise<OAuthTokenData> {
|
||||
const metadata = await discoverOAuthServerMetadata(this.serverUrl)
|
||||
|
||||
const clientRegistrationStorage: ClientRegistrationStorage = {
|
||||
getClientRegistration: () => this.storedClientInfo,
|
||||
setClientRegistration: (_serverIdentifier: string, credentials: ClientCredentials) => {
|
||||
this.storedClientInfo = credentials
|
||||
},
|
||||
}
|
||||
|
||||
const clientInfo = await getOrRegisterClient({
|
||||
registrationEndpoint: metadata.registrationEndpoint,
|
||||
serverIdentifier: this.serverUrl,
|
||||
clientName: "oh-my-opencode",
|
||||
redirectUris: [this.redirectUrl()],
|
||||
tokenEndpointAuthMethod: "none",
|
||||
clientId: this.configClientId,
|
||||
storage: clientRegistrationStorage,
|
||||
})
|
||||
|
||||
if (!clientInfo) {
|
||||
throw new Error("Failed to obtain client credentials. Provide a clientId or ensure the server supports DCR.")
|
||||
}
|
||||
|
||||
this.storedClientInfo = clientInfo
|
||||
|
||||
const { code } = await this.redirectToAuthorization(metadata)
|
||||
const verifier = this.codeVerifier()
|
||||
if (!verifier) {
|
||||
throw new Error("Code verifier not found")
|
||||
}
|
||||
|
||||
const tokenResponse = await fetch(metadata.tokenEndpoint, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
redirect_uri: this.redirectUrl(),
|
||||
client_id: clientInfo.clientId,
|
||||
code_verifier: verifier,
|
||||
...(metadata.resource ? { resource: metadata.resource } : {}),
|
||||
}).toString(),
|
||||
})
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
let errorDetail = `${tokenResponse.status}`
|
||||
try {
|
||||
const body = (await tokenResponse.json()) as Record<string, unknown>
|
||||
if (body.error) {
|
||||
errorDetail = `${tokenResponse.status} ${body.error}`
|
||||
if (body.error_description) {
|
||||
errorDetail += `: ${body.error_description}`
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Response body not JSON
|
||||
}
|
||||
throw new Error(`Token exchange failed: ${errorDetail}`)
|
||||
}
|
||||
|
||||
const tokenData = (await tokenResponse.json()) as Record<string, unknown>
|
||||
const accessToken = tokenData.access_token
|
||||
if (typeof accessToken !== "string") {
|
||||
throw new Error("Token response missing access_token")
|
||||
}
|
||||
|
||||
const oauthTokenData: OAuthTokenData = {
|
||||
accessToken,
|
||||
refreshToken: typeof tokenData.refresh_token === "string" ? tokenData.refresh_token : undefined,
|
||||
expiresAt:
|
||||
typeof tokenData.expires_in === "number" ? Math.floor(Date.now() / 1000) + tokenData.expires_in : undefined,
|
||||
clientInfo: {
|
||||
clientId: clientInfo.clientId,
|
||||
clientSecret: clientInfo.clientSecret,
|
||||
},
|
||||
}
|
||||
|
||||
this.saveTokens(oauthTokenData)
|
||||
return oauthTokenData
|
||||
}
|
||||
}
|
||||
|
||||
export { generateCodeVerifier, generateCodeChallenge, buildAuthorizationUrl, startCallbackServer }
|
||||
121
src/features/mcp-oauth/resource-indicator.test.ts
Normal file
121
src/features/mcp-oauth/resource-indicator.test.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { addResourceToParams, getResourceIndicator } from "./resource-indicator"
|
||||
|
||||
describe("getResourceIndicator", () => {
|
||||
it("returns URL unchanged when already normalized", () => {
|
||||
// #given
|
||||
const url = "https://mcp.example.com"
|
||||
|
||||
// #when
|
||||
const result = getResourceIndicator(url)
|
||||
|
||||
// #then
|
||||
expect(result).toBe("https://mcp.example.com")
|
||||
})
|
||||
|
||||
it("strips trailing slash", () => {
|
||||
// #given
|
||||
const url = "https://mcp.example.com/"
|
||||
|
||||
// #when
|
||||
const result = getResourceIndicator(url)
|
||||
|
||||
// #then
|
||||
expect(result).toBe("https://mcp.example.com")
|
||||
})
|
||||
|
||||
it("strips query parameters", () => {
|
||||
// #given
|
||||
const url = "https://mcp.example.com/v1?token=abc&debug=true"
|
||||
|
||||
// #when
|
||||
const result = getResourceIndicator(url)
|
||||
|
||||
// #then
|
||||
expect(result).toBe("https://mcp.example.com/v1")
|
||||
})
|
||||
|
||||
it("strips fragment", () => {
|
||||
// #given
|
||||
const url = "https://mcp.example.com/v1#section"
|
||||
|
||||
// #when
|
||||
const result = getResourceIndicator(url)
|
||||
|
||||
// #then
|
||||
expect(result).toBe("https://mcp.example.com/v1")
|
||||
})
|
||||
|
||||
it("strips query and trailing slash together", () => {
|
||||
// #given
|
||||
const url = "https://mcp.example.com/api/?key=val"
|
||||
|
||||
// #when
|
||||
const result = getResourceIndicator(url)
|
||||
|
||||
// #then
|
||||
expect(result).toBe("https://mcp.example.com/api")
|
||||
})
|
||||
|
||||
it("preserves path segments", () => {
|
||||
// #given
|
||||
const url = "https://mcp.example.com/org/project/v2"
|
||||
|
||||
// #when
|
||||
const result = getResourceIndicator(url)
|
||||
|
||||
// #then
|
||||
expect(result).toBe("https://mcp.example.com/org/project/v2")
|
||||
})
|
||||
|
||||
it("preserves port number", () => {
|
||||
// #given
|
||||
const url = "https://mcp.example.com:8443/api/"
|
||||
|
||||
// #when
|
||||
const result = getResourceIndicator(url)
|
||||
|
||||
// #then
|
||||
expect(result).toBe("https://mcp.example.com:8443/api")
|
||||
})
|
||||
})
|
||||
|
||||
describe("addResourceToParams", () => {
|
||||
it("sets resource parameter on empty params", () => {
|
||||
// #given
|
||||
const params = new URLSearchParams()
|
||||
const resource = "https://mcp.example.com"
|
||||
|
||||
// #when
|
||||
addResourceToParams(params, resource)
|
||||
|
||||
// #then
|
||||
expect(params.get("resource")).toBe("https://mcp.example.com")
|
||||
})
|
||||
|
||||
it("adds resource alongside existing parameters", () => {
|
||||
// #given
|
||||
const params = new URLSearchParams({ grant_type: "authorization_code" })
|
||||
const resource = "https://mcp.example.com/v1"
|
||||
|
||||
// #when
|
||||
addResourceToParams(params, resource)
|
||||
|
||||
// #then
|
||||
expect(params.get("grant_type")).toBe("authorization_code")
|
||||
expect(params.get("resource")).toBe("https://mcp.example.com/v1")
|
||||
})
|
||||
|
||||
it("overwrites existing resource parameter", () => {
|
||||
// #given
|
||||
const params = new URLSearchParams({ resource: "https://old.example.com" })
|
||||
const resource = "https://new.example.com"
|
||||
|
||||
// #when
|
||||
addResourceToParams(params, resource)
|
||||
|
||||
// #then
|
||||
expect(params.get("resource")).toBe("https://new.example.com")
|
||||
expect(params.getAll("resource")).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
16
src/features/mcp-oauth/resource-indicator.ts
Normal file
16
src/features/mcp-oauth/resource-indicator.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export function getResourceIndicator(url: string): string {
|
||||
const parsed = new URL(url)
|
||||
parsed.search = ""
|
||||
parsed.hash = ""
|
||||
|
||||
let normalized = parsed.toString()
|
||||
if (normalized.endsWith("/")) {
|
||||
normalized = normalized.slice(0, -1)
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function addResourceToParams(params: URLSearchParams, resource: string): void {
|
||||
params.set("resource", resource)
|
||||
}
|
||||
60
src/features/mcp-oauth/schema.test.ts
Normal file
60
src/features/mcp-oauth/schema.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/// <reference types="bun-types" />
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { McpOauthSchema } from "./schema"
|
||||
|
||||
describe("McpOauthSchema", () => {
|
||||
test("parses empty oauth config", () => {
|
||||
//#given
|
||||
const input = {}
|
||||
|
||||
//#when
|
||||
const result = McpOauthSchema.parse(input)
|
||||
|
||||
//#then
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
test("parses oauth config with clientId", () => {
|
||||
//#given
|
||||
const input = { clientId: "client-123" }
|
||||
|
||||
//#when
|
||||
const result = McpOauthSchema.parse(input)
|
||||
|
||||
//#then
|
||||
expect(result).toEqual({ clientId: "client-123" })
|
||||
})
|
||||
|
||||
test("parses oauth config with scopes", () => {
|
||||
//#given
|
||||
const input = { scopes: ["openid", "profile"] }
|
||||
|
||||
//#when
|
||||
const result = McpOauthSchema.parse(input)
|
||||
|
||||
//#then
|
||||
expect(result).toEqual({ scopes: ["openid", "profile"] })
|
||||
})
|
||||
|
||||
test("rejects non-string clientId", () => {
|
||||
//#given
|
||||
const input = { clientId: 123 }
|
||||
|
||||
//#when
|
||||
const result = McpOauthSchema.safeParse(input)
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
test("rejects non-string scopes", () => {
|
||||
//#given
|
||||
const input = { scopes: ["openid", 42] }
|
||||
|
||||
//#when
|
||||
const result = McpOauthSchema.safeParse(input)
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
8
src/features/mcp-oauth/schema.ts
Normal file
8
src/features/mcp-oauth/schema.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const McpOauthSchema = z.object({
|
||||
clientId: z.string().optional(),
|
||||
scopes: z.array(z.string()).optional(),
|
||||
})
|
||||
|
||||
export type McpOauth = z.infer<typeof McpOauthSchema>
|
||||
223
src/features/mcp-oauth/step-up.test.ts
Normal file
223
src/features/mcp-oauth/step-up.test.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { isStepUpRequired, mergeScopes, parseWwwAuthenticate } from "./step-up"
|
||||
|
||||
describe("parseWwwAuthenticate", () => {
|
||||
it("parses scope from simple Bearer header", () => {
|
||||
// #given
|
||||
const header = 'Bearer scope="read write"'
|
||||
|
||||
// #when
|
||||
const result = parseWwwAuthenticate(header)
|
||||
|
||||
// #then
|
||||
expect(result).toEqual({ requiredScopes: ["read", "write"] })
|
||||
})
|
||||
|
||||
it("parses scope with error fields", () => {
|
||||
// #given
|
||||
const header = 'Bearer error="insufficient_scope", scope="admin"'
|
||||
|
||||
// #when
|
||||
const result = parseWwwAuthenticate(header)
|
||||
|
||||
// #then
|
||||
expect(result).toEqual({
|
||||
requiredScopes: ["admin"],
|
||||
error: "insufficient_scope",
|
||||
})
|
||||
})
|
||||
|
||||
it("parses all fields including error_description", () => {
|
||||
// #given
|
||||
const header =
|
||||
'Bearer realm="example", error="insufficient_scope", error_description="Need admin access", scope="admin write"'
|
||||
|
||||
// #when
|
||||
const result = parseWwwAuthenticate(header)
|
||||
|
||||
// #then
|
||||
expect(result).toEqual({
|
||||
requiredScopes: ["admin", "write"],
|
||||
error: "insufficient_scope",
|
||||
errorDescription: "Need admin access",
|
||||
})
|
||||
})
|
||||
|
||||
it("returns null for non-Bearer scheme", () => {
|
||||
// #given
|
||||
const header = 'Basic realm="example"'
|
||||
|
||||
// #when
|
||||
const result = parseWwwAuthenticate(header)
|
||||
|
||||
// #then
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("returns null when no scope parameter present", () => {
|
||||
// #given
|
||||
const header = 'Bearer error="invalid_token"'
|
||||
|
||||
// #when
|
||||
const result = parseWwwAuthenticate(header)
|
||||
|
||||
// #then
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("returns null for empty scope value", () => {
|
||||
// #given
|
||||
const header = 'Bearer scope=""'
|
||||
|
||||
// #when
|
||||
const result = parseWwwAuthenticate(header)
|
||||
|
||||
// #then
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("returns null for bare Bearer with no params", () => {
|
||||
// #given
|
||||
const header = "Bearer"
|
||||
|
||||
// #when
|
||||
const result = parseWwwAuthenticate(header)
|
||||
|
||||
// #then
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("handles case-insensitive Bearer prefix", () => {
|
||||
// #given
|
||||
const header = 'bearer scope="read"'
|
||||
|
||||
// #when
|
||||
const result = parseWwwAuthenticate(header)
|
||||
|
||||
// #then
|
||||
expect(result).toEqual({ requiredScopes: ["read"] })
|
||||
})
|
||||
|
||||
it("parses single scope value", () => {
|
||||
// #given
|
||||
const header = 'Bearer scope="admin"'
|
||||
|
||||
// #when
|
||||
const result = parseWwwAuthenticate(header)
|
||||
|
||||
// #then
|
||||
expect(result).toEqual({ requiredScopes: ["admin"] })
|
||||
})
|
||||
})
|
||||
|
||||
describe("mergeScopes", () => {
|
||||
it("merges new scopes into existing", () => {
|
||||
// #given
|
||||
const existing = ["read", "write"]
|
||||
const required = ["admin", "write"]
|
||||
|
||||
// #when
|
||||
const result = mergeScopes(existing, required)
|
||||
|
||||
// #then
|
||||
expect(result).toEqual(["read", "write", "admin"])
|
||||
})
|
||||
|
||||
it("returns required when existing is empty", () => {
|
||||
// #given
|
||||
const existing: string[] = []
|
||||
const required = ["read", "write"]
|
||||
|
||||
// #when
|
||||
const result = mergeScopes(existing, required)
|
||||
|
||||
// #then
|
||||
expect(result).toEqual(["read", "write"])
|
||||
})
|
||||
|
||||
it("returns existing when required is empty", () => {
|
||||
// #given
|
||||
const existing = ["read"]
|
||||
const required: string[] = []
|
||||
|
||||
// #when
|
||||
const result = mergeScopes(existing, required)
|
||||
|
||||
// #then
|
||||
expect(result).toEqual(["read"])
|
||||
})
|
||||
|
||||
it("deduplicates identical scopes", () => {
|
||||
// #given
|
||||
const existing = ["read", "write"]
|
||||
const required = ["read", "write"]
|
||||
|
||||
// #when
|
||||
const result = mergeScopes(existing, required)
|
||||
|
||||
// #then
|
||||
expect(result).toEqual(["read", "write"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("isStepUpRequired", () => {
|
||||
it("returns step-up info for 403 with WWW-Authenticate", () => {
|
||||
// #given
|
||||
const statusCode = 403
|
||||
const headers = { "www-authenticate": 'Bearer scope="admin"' }
|
||||
|
||||
// #when
|
||||
const result = isStepUpRequired(statusCode, headers)
|
||||
|
||||
// #then
|
||||
expect(result).toEqual({ requiredScopes: ["admin"] })
|
||||
})
|
||||
|
||||
it("returns null for non-403 status", () => {
|
||||
// #given
|
||||
const statusCode = 401
|
||||
const headers = { "www-authenticate": 'Bearer scope="admin"' }
|
||||
|
||||
// #when
|
||||
const result = isStepUpRequired(statusCode, headers)
|
||||
|
||||
// #then
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("returns null when no WWW-Authenticate header", () => {
|
||||
// #given
|
||||
const statusCode = 403
|
||||
const headers = { "content-type": "application/json" }
|
||||
|
||||
// #when
|
||||
const result = isStepUpRequired(statusCode, headers)
|
||||
|
||||
// #then
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("handles capitalized WWW-Authenticate header", () => {
|
||||
// #given
|
||||
const statusCode = 403
|
||||
const headers = { "WWW-Authenticate": 'Bearer scope="read write"' }
|
||||
|
||||
// #when
|
||||
const result = isStepUpRequired(statusCode, headers)
|
||||
|
||||
// #then
|
||||
expect(result).toEqual({ requiredScopes: ["read", "write"] })
|
||||
})
|
||||
|
||||
it("returns null for 403 with unparseable WWW-Authenticate", () => {
|
||||
// #given
|
||||
const statusCode = 403
|
||||
const headers = { "www-authenticate": 'Basic realm="example"' }
|
||||
|
||||
// #when
|
||||
const result = isStepUpRequired(statusCode, headers)
|
||||
|
||||
// #then
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
79
src/features/mcp-oauth/step-up.ts
Normal file
79
src/features/mcp-oauth/step-up.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
export interface StepUpInfo {
|
||||
requiredScopes: string[]
|
||||
error?: string
|
||||
errorDescription?: string
|
||||
}
|
||||
|
||||
export function parseWwwAuthenticate(header: string): StepUpInfo | null {
|
||||
const trimmed = header.trim()
|
||||
const lowerHeader = trimmed.toLowerCase()
|
||||
const bearerIndex = lowerHeader.indexOf("bearer")
|
||||
if (bearerIndex === -1) {
|
||||
return null
|
||||
}
|
||||
|
||||
const params = trimmed.slice(bearerIndex + "bearer".length).trim()
|
||||
if (params.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const scope = extractParam(params, "scope")
|
||||
if (scope === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
const requiredScopes = scope
|
||||
.split(/\s+/)
|
||||
.filter((s) => s.length > 0)
|
||||
|
||||
if (requiredScopes.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const info: StepUpInfo = { requiredScopes }
|
||||
|
||||
const error = extractParam(params, "error")
|
||||
if (error !== null) {
|
||||
info.error = error
|
||||
}
|
||||
|
||||
const errorDescription = extractParam(params, "error_description")
|
||||
if (errorDescription !== null) {
|
||||
info.errorDescription = errorDescription
|
||||
}
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
function extractParam(params: string, name: string): string | null {
|
||||
const quotedPattern = new RegExp(`${name}="([^"]*)"`)
|
||||
const quotedMatch = quotedPattern.exec(params)
|
||||
if (quotedMatch) {
|
||||
return quotedMatch[1]
|
||||
}
|
||||
|
||||
const unquotedPattern = new RegExp(`${name}=([^\\s,]+)`)
|
||||
const unquotedMatch = unquotedPattern.exec(params)
|
||||
return unquotedMatch?.[1] ?? null
|
||||
}
|
||||
|
||||
export function mergeScopes(existing: string[], required: string[]): string[] {
|
||||
const set = new Set(existing)
|
||||
for (const scope of required) {
|
||||
set.add(scope)
|
||||
}
|
||||
return [...set]
|
||||
}
|
||||
|
||||
export function isStepUpRequired(statusCode: number, headers: Record<string, string>): StepUpInfo | null {
|
||||
if (statusCode !== 403) {
|
||||
return null
|
||||
}
|
||||
|
||||
const wwwAuth = headers["www-authenticate"] ?? headers["WWW-Authenticate"]
|
||||
if (!wwwAuth) {
|
||||
return null
|
||||
}
|
||||
|
||||
return parseWwwAuthenticate(wwwAuth)
|
||||
}
|
||||
136
src/features/mcp-oauth/storage.test.ts
Normal file
136
src/features/mcp-oauth/storage.test.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { describe, expect, test, beforeEach, afterEach } from "bun:test"
|
||||
import { existsSync, mkdirSync, rmSync, readFileSync, statSync, writeFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { tmpdir } from "node:os"
|
||||
import {
|
||||
deleteToken,
|
||||
getMcpOauthStoragePath,
|
||||
listAllTokens,
|
||||
listTokensByHost,
|
||||
loadToken,
|
||||
saveToken,
|
||||
} from "./storage"
|
||||
import type { OAuthTokenData } from "./storage"
|
||||
|
||||
describe("mcp-oauth storage", () => {
|
||||
const TEST_CONFIG_DIR = join(tmpdir(), "mcp-oauth-test-" + Date.now())
|
||||
let originalConfigDir: string | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
originalConfigDir = process.env.OPENCODE_CONFIG_DIR
|
||||
process.env.OPENCODE_CONFIG_DIR = TEST_CONFIG_DIR
|
||||
if (!existsSync(TEST_CONFIG_DIR)) {
|
||||
mkdirSync(TEST_CONFIG_DIR, { recursive: true })
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (originalConfigDir === undefined) {
|
||||
delete process.env.OPENCODE_CONFIG_DIR
|
||||
} else {
|
||||
process.env.OPENCODE_CONFIG_DIR = originalConfigDir
|
||||
}
|
||||
if (existsSync(TEST_CONFIG_DIR)) {
|
||||
rmSync(TEST_CONFIG_DIR, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("should save tokens with {host}/{resource} key and set 0600 permissions", () => {
|
||||
// #given
|
||||
const token: OAuthTokenData = {
|
||||
accessToken: "access-1",
|
||||
refreshToken: "refresh-1",
|
||||
expiresAt: 1710000000,
|
||||
clientInfo: { clientId: "client-1", clientSecret: "secret-1" },
|
||||
}
|
||||
|
||||
// #when
|
||||
const success = saveToken("https://example.com:443", "mcp/v1", token)
|
||||
const storagePath = getMcpOauthStoragePath()
|
||||
const parsed = JSON.parse(readFileSync(storagePath, "utf-8")) as Record<string, OAuthTokenData>
|
||||
const mode = statSync(storagePath).mode & 0o777
|
||||
|
||||
// #then
|
||||
expect(success).toBe(true)
|
||||
expect(Object.keys(parsed)).toEqual(["example.com/mcp/v1"])
|
||||
expect(parsed["example.com/mcp/v1"].accessToken).toBe("access-1")
|
||||
expect(mode).toBe(0o600)
|
||||
})
|
||||
|
||||
test("should load a saved token", () => {
|
||||
// #given
|
||||
const token: OAuthTokenData = { accessToken: "access-2", refreshToken: "refresh-2" }
|
||||
saveToken("api.example.com", "resource-a", token)
|
||||
|
||||
// #when
|
||||
const loaded = loadToken("api.example.com:8443", "resource-a")
|
||||
|
||||
// #then
|
||||
expect(loaded).toEqual(token)
|
||||
})
|
||||
|
||||
test("should delete a token", () => {
|
||||
// #given
|
||||
const token: OAuthTokenData = { accessToken: "access-3" }
|
||||
saveToken("api.example.com", "resource-b", token)
|
||||
|
||||
// #when
|
||||
const success = deleteToken("api.example.com", "resource-b")
|
||||
const loaded = loadToken("api.example.com", "resource-b")
|
||||
|
||||
// #then
|
||||
expect(success).toBe(true)
|
||||
expect(loaded).toBeNull()
|
||||
})
|
||||
|
||||
test("should list tokens by host", () => {
|
||||
// #given
|
||||
saveToken("api.example.com", "resource-a", { accessToken: "access-a" })
|
||||
saveToken("api.example.com", "resource-b", { accessToken: "access-b" })
|
||||
saveToken("other.example.com", "resource-c", { accessToken: "access-c" })
|
||||
|
||||
// #when
|
||||
const entries = listTokensByHost("api.example.com:5555")
|
||||
|
||||
// #then
|
||||
expect(Object.keys(entries).sort()).toEqual([
|
||||
"api.example.com/resource-a",
|
||||
"api.example.com/resource-b",
|
||||
])
|
||||
expect(entries["api.example.com/resource-a"].accessToken).toBe("access-a")
|
||||
})
|
||||
|
||||
test("should handle missing storage file", () => {
|
||||
// #given
|
||||
const storagePath = getMcpOauthStoragePath()
|
||||
if (existsSync(storagePath)) {
|
||||
rmSync(storagePath, { force: true })
|
||||
}
|
||||
|
||||
// #when
|
||||
const loaded = loadToken("api.example.com", "resource-a")
|
||||
const entries = listTokensByHost("api.example.com")
|
||||
|
||||
// #then
|
||||
expect(loaded).toBeNull()
|
||||
expect(entries).toEqual({})
|
||||
})
|
||||
|
||||
test("should handle invalid JSON", () => {
|
||||
// #given
|
||||
const storagePath = getMcpOauthStoragePath()
|
||||
const dir = join(storagePath, "..")
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
writeFileSync(storagePath, "{not-valid-json", "utf-8")
|
||||
|
||||
// #when
|
||||
const loaded = loadToken("api.example.com", "resource-a")
|
||||
const entries = listTokensByHost("api.example.com")
|
||||
|
||||
// #then
|
||||
expect(loaded).toBeNull()
|
||||
expect(entries).toEqual({})
|
||||
})
|
||||
})
|
||||
153
src/features/mcp-oauth/storage.ts
Normal file
153
src/features/mcp-oauth/storage.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"
|
||||
import { dirname, join } from "node:path"
|
||||
import { getOpenCodeConfigDir } from "../../shared"
|
||||
|
||||
export interface OAuthTokenData {
|
||||
accessToken: string
|
||||
refreshToken?: string
|
||||
expiresAt?: number
|
||||
clientInfo?: {
|
||||
clientId: string
|
||||
clientSecret?: string
|
||||
}
|
||||
}
|
||||
|
||||
type TokenStore = Record<string, OAuthTokenData>
|
||||
|
||||
const STORAGE_FILE_NAME = "mcp-oauth.json"
|
||||
|
||||
export function getMcpOauthStoragePath(): string {
|
||||
return join(getOpenCodeConfigDir({ binary: "opencode" }), STORAGE_FILE_NAME)
|
||||
}
|
||||
|
||||
function normalizeHost(serverHost: string): string {
|
||||
let host = serverHost.trim()
|
||||
if (!host) return host
|
||||
|
||||
if (host.includes("://")) {
|
||||
try {
|
||||
host = new URL(host).hostname
|
||||
} catch {
|
||||
host = host.split("/")[0]
|
||||
}
|
||||
} else {
|
||||
host = host.split("/")[0]
|
||||
}
|
||||
|
||||
if (host.startsWith("[")) {
|
||||
const closing = host.indexOf("]")
|
||||
if (closing !== -1) {
|
||||
host = host.slice(0, closing + 1)
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
if (host.includes(":")) {
|
||||
host = host.split(":")[0]
|
||||
}
|
||||
|
||||
return host
|
||||
}
|
||||
|
||||
function normalizeResource(resource: string): string {
|
||||
return resource.replace(/^\/+/, "")
|
||||
}
|
||||
|
||||
function buildKey(serverHost: string, resource: string): string {
|
||||
const host = normalizeHost(serverHost)
|
||||
const normalizedResource = normalizeResource(resource)
|
||||
return `${host}/${normalizedResource}`
|
||||
}
|
||||
|
||||
function readStore(): TokenStore | null {
|
||||
const filePath = getMcpOauthStoragePath()
|
||||
if (!existsSync(filePath)) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(filePath, "utf-8")
|
||||
return JSON.parse(content) as TokenStore
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function writeStore(store: TokenStore): boolean {
|
||||
const filePath = getMcpOauthStoragePath()
|
||||
|
||||
try {
|
||||
const dir = dirname(filePath)
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
|
||||
writeFileSync(filePath, JSON.stringify(store, null, 2), { encoding: "utf-8", mode: 0o600 })
|
||||
chmodSync(filePath, 0o600)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function loadToken(serverHost: string, resource: string): OAuthTokenData | null {
|
||||
const store = readStore()
|
||||
if (!store) return null
|
||||
|
||||
const key = buildKey(serverHost, resource)
|
||||
return store[key] ?? null
|
||||
}
|
||||
|
||||
export function saveToken(serverHost: string, resource: string, token: OAuthTokenData): boolean {
|
||||
const store = readStore() ?? {}
|
||||
const key = buildKey(serverHost, resource)
|
||||
store[key] = token
|
||||
return writeStore(store)
|
||||
}
|
||||
|
||||
export function deleteToken(serverHost: string, resource: string): boolean {
|
||||
const store = readStore()
|
||||
if (!store) return true
|
||||
|
||||
const key = buildKey(serverHost, resource)
|
||||
if (!(key in store)) {
|
||||
return true
|
||||
}
|
||||
|
||||
delete store[key]
|
||||
|
||||
if (Object.keys(store).length === 0) {
|
||||
try {
|
||||
const filePath = getMcpOauthStoragePath()
|
||||
if (existsSync(filePath)) {
|
||||
unlinkSync(filePath)
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return writeStore(store)
|
||||
}
|
||||
|
||||
export function listTokensByHost(serverHost: string): TokenStore {
|
||||
const store = readStore()
|
||||
if (!store) return {}
|
||||
|
||||
const host = normalizeHost(serverHost)
|
||||
const prefix = `${host}/`
|
||||
const result: TokenStore = {}
|
||||
|
||||
for (const [key, value] of Object.entries(store)) {
|
||||
if (key.startsWith(prefix)) {
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function listAllTokens(): TokenStore {
|
||||
return readStore() ?? {}
|
||||
}
|
||||
@@ -128,8 +128,15 @@ $ARGUMENTS
|
||||
}
|
||||
}
|
||||
|
||||
function parseAllowedTools(allowedTools: string | undefined): string[] | undefined {
|
||||
function parseAllowedTools(allowedTools: string | string[] | undefined): string[] | undefined {
|
||||
if (!allowedTools) return undefined
|
||||
|
||||
// Handle YAML array format: already parsed as string[]
|
||||
if (Array.isArray(allowedTools)) {
|
||||
return allowedTools.map(t => t.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
// Handle space-separated string format: "Read Write Edit Bash"
|
||||
return allowedTools.split(/\s+/).filter(Boolean)
|
||||
}
|
||||
|
||||
|
||||
@@ -270,4 +270,121 @@ Skill body.
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("allowed-tools parsing", () => {
|
||||
it("parses space-separated allowed-tools string", async () => {
|
||||
// #given
|
||||
const skillContent = `---
|
||||
name: space-separated-tools
|
||||
description: Skill with space-separated allowed-tools
|
||||
allowed-tools: Read Write Edit Bash
|
||||
---
|
||||
Skill body.
|
||||
`
|
||||
createTestSkill("space-separated-tools", skillContent)
|
||||
|
||||
// #when
|
||||
const { discoverSkills } = await import("./loader")
|
||||
const originalCwd = process.cwd()
|
||||
process.chdir(TEST_DIR)
|
||||
|
||||
try {
|
||||
const skills = await discoverSkills({ includeClaudeCodePaths: false })
|
||||
const skill = skills.find(s => s.name === "space-separated-tools")
|
||||
|
||||
// #then
|
||||
expect(skill).toBeDefined()
|
||||
expect(skill?.allowedTools).toEqual(["Read", "Write", "Edit", "Bash"])
|
||||
} finally {
|
||||
process.chdir(originalCwd)
|
||||
}
|
||||
})
|
||||
|
||||
it("parses YAML inline array allowed-tools", async () => {
|
||||
// #given
|
||||
const skillContent = `---
|
||||
name: yaml-inline-array
|
||||
description: Skill with YAML inline array allowed-tools
|
||||
allowed-tools: [Read, Write, Edit, Bash]
|
||||
---
|
||||
Skill body.
|
||||
`
|
||||
createTestSkill("yaml-inline-array", skillContent)
|
||||
|
||||
// #when
|
||||
const { discoverSkills } = await import("./loader")
|
||||
const originalCwd = process.cwd()
|
||||
process.chdir(TEST_DIR)
|
||||
|
||||
try {
|
||||
const skills = await discoverSkills({ includeClaudeCodePaths: false })
|
||||
const skill = skills.find(s => s.name === "yaml-inline-array")
|
||||
|
||||
// #then
|
||||
expect(skill).toBeDefined()
|
||||
expect(skill?.allowedTools).toEqual(["Read", "Write", "Edit", "Bash"])
|
||||
} finally {
|
||||
process.chdir(originalCwd)
|
||||
}
|
||||
})
|
||||
|
||||
it("parses YAML multi-line array allowed-tools", async () => {
|
||||
// #given
|
||||
const skillContent = `---
|
||||
name: yaml-multiline-array
|
||||
description: Skill with YAML multi-line array allowed-tools
|
||||
allowed-tools:
|
||||
- Read
|
||||
- Write
|
||||
- Edit
|
||||
- Bash
|
||||
---
|
||||
Skill body.
|
||||
`
|
||||
createTestSkill("yaml-multiline-array", skillContent)
|
||||
|
||||
// #when
|
||||
const { discoverSkills } = await import("./loader")
|
||||
const originalCwd = process.cwd()
|
||||
process.chdir(TEST_DIR)
|
||||
|
||||
try {
|
||||
const skills = await discoverSkills({ includeClaudeCodePaths: false })
|
||||
const skill = skills.find(s => s.name === "yaml-multiline-array")
|
||||
|
||||
// #then
|
||||
expect(skill).toBeDefined()
|
||||
expect(skill?.allowedTools).toEqual(["Read", "Write", "Edit", "Bash"])
|
||||
} finally {
|
||||
process.chdir(originalCwd)
|
||||
}
|
||||
})
|
||||
|
||||
it("returns undefined for skill without allowed-tools", async () => {
|
||||
// #given
|
||||
const skillContent = `---
|
||||
name: no-allowed-tools
|
||||
description: Skill without allowed-tools field
|
||||
---
|
||||
Skill body.
|
||||
`
|
||||
createTestSkill("no-allowed-tools", skillContent)
|
||||
|
||||
// #when
|
||||
const { discoverSkills } = await import("./loader")
|
||||
const originalCwd = process.cwd()
|
||||
process.chdir(TEST_DIR)
|
||||
|
||||
try {
|
||||
const skills = await discoverSkills({ includeClaudeCodePaths: false })
|
||||
const skill = skills.find(s => s.name === "no-allowed-tools")
|
||||
|
||||
// #then
|
||||
expect(skill).toBeDefined()
|
||||
expect(skill?.allowedTools).toBeUndefined()
|
||||
} finally {
|
||||
process.chdir(originalCwd)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -50,8 +50,15 @@ async function loadMcpJsonFromDir(skillDir: string): Promise<SkillMcpConfig | un
|
||||
return undefined
|
||||
}
|
||||
|
||||
function parseAllowedTools(allowedTools: string | undefined): string[] | undefined {
|
||||
function parseAllowedTools(allowedTools: string | string[] | undefined): string[] | undefined {
|
||||
if (!allowedTools) return undefined
|
||||
|
||||
// Handle YAML array format: already parsed as string[]
|
||||
if (Array.isArray(allowedTools)) {
|
||||
return allowedTools.map(t => t.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
// Handle space-separated string format: "Read Write Edit Bash"
|
||||
return allowedTools.split(/\s+/).filter(Boolean)
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,14 @@ import { parseFrontmatter } from "../../shared/frontmatter"
|
||||
import { sanitizeModelField } from "../../shared/model-sanitizer"
|
||||
import { deepMerge } from "../../shared/deep-merge"
|
||||
|
||||
function parseAllowedToolsFromMetadata(allowedTools: string | string[] | undefined): string[] | undefined {
|
||||
if (!allowedTools) return undefined
|
||||
if (Array.isArray(allowedTools)) {
|
||||
return allowedTools.map(t => t.trim()).filter(Boolean)
|
||||
}
|
||||
return allowedTools.split(/\s+/).filter(Boolean)
|
||||
}
|
||||
|
||||
const SCOPE_PRIORITY: Record<SkillScope, number> = {
|
||||
builtin: 1,
|
||||
config: 2,
|
||||
@@ -119,7 +127,7 @@ $ARGUMENTS
|
||||
}
|
||||
|
||||
const allowedTools = entry["allowed-tools"] ||
|
||||
(fileMetadata["allowed-tools"] ? fileMetadata["allowed-tools"].split(/\s+/).filter(Boolean) : undefined)
|
||||
(fileMetadata["allowed-tools"] ? parseAllowedToolsFromMetadata(fileMetadata["allowed-tools"]) : undefined)
|
||||
|
||||
return {
|
||||
name,
|
||||
|
||||
@@ -13,7 +13,7 @@ export interface SkillMetadata {
|
||||
license?: string
|
||||
compatibility?: string
|
||||
metadata?: Record<string, string>
|
||||
"allowed-tools"?: string
|
||||
"allowed-tools"?: string | string[]
|
||||
mcp?: SkillMcpConfig
|
||||
}
|
||||
|
||||
|
||||
112
src/features/sisyphus-swarm/mailbox/types.test.ts
Normal file
112
src/features/sisyphus-swarm/mailbox/types.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import {
|
||||
MailboxMessageSchema,
|
||||
PermissionRequestSchema,
|
||||
PermissionResponseSchema,
|
||||
ShutdownRequestSchema,
|
||||
TaskAssignmentSchema,
|
||||
JoinRequestSchema,
|
||||
ProtocolMessageSchema,
|
||||
} from "./types"
|
||||
|
||||
describe("MailboxMessageSchema", () => {
|
||||
//#given a valid mailbox message
|
||||
//#when parsing
|
||||
//#then it should succeed
|
||||
it("parses valid message", () => {
|
||||
const msg = {
|
||||
from: "agent-001",
|
||||
text: '{"type":"idle_notification"}',
|
||||
timestamp: "2026-01-27T10:00:00Z",
|
||||
read: false,
|
||||
}
|
||||
expect(MailboxMessageSchema.safeParse(msg).success).toBe(true)
|
||||
})
|
||||
|
||||
//#given a message with optional color
|
||||
//#when parsing
|
||||
//#then it should succeed
|
||||
it("parses message with color", () => {
|
||||
const msg = {
|
||||
from: "agent-001",
|
||||
text: "{}",
|
||||
timestamp: "2026-01-27T10:00:00Z",
|
||||
color: "blue",
|
||||
read: true,
|
||||
}
|
||||
expect(MailboxMessageSchema.safeParse(msg).success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("ProtocolMessageSchema", () => {
|
||||
//#given permission_request message
|
||||
//#when parsing
|
||||
//#then it should succeed
|
||||
it("parses permission_request", () => {
|
||||
const msg = {
|
||||
type: "permission_request",
|
||||
requestId: "req-123",
|
||||
toolName: "Bash",
|
||||
input: { command: "rm -rf /" },
|
||||
agentId: "agent-001",
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
expect(PermissionRequestSchema.safeParse(msg).success).toBe(true)
|
||||
})
|
||||
|
||||
//#given permission_response message
|
||||
//#when parsing
|
||||
//#then it should succeed
|
||||
it("parses permission_response", () => {
|
||||
const approved = {
|
||||
type: "permission_response",
|
||||
requestId: "req-123",
|
||||
decision: "approved",
|
||||
updatedInput: { command: "ls" },
|
||||
}
|
||||
expect(PermissionResponseSchema.safeParse(approved).success).toBe(true)
|
||||
|
||||
const rejected = {
|
||||
type: "permission_response",
|
||||
requestId: "req-123",
|
||||
decision: "rejected",
|
||||
feedback: "Too dangerous",
|
||||
}
|
||||
expect(PermissionResponseSchema.safeParse(rejected).success).toBe(true)
|
||||
})
|
||||
|
||||
//#given shutdown_request message
|
||||
//#when parsing
|
||||
//#then it should succeed
|
||||
it("parses shutdown messages", () => {
|
||||
const request = { type: "shutdown_request" }
|
||||
expect(ShutdownRequestSchema.safeParse(request).success).toBe(true)
|
||||
})
|
||||
|
||||
//#given task_assignment message
|
||||
//#when parsing
|
||||
//#then it should succeed
|
||||
it("parses task_assignment", () => {
|
||||
const msg = {
|
||||
type: "task_assignment",
|
||||
taskId: "1",
|
||||
subject: "Fix bug",
|
||||
description: "Fix the auth bug",
|
||||
assignedBy: "team-lead",
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
expect(TaskAssignmentSchema.safeParse(msg).success).toBe(true)
|
||||
})
|
||||
|
||||
//#given join_request message
|
||||
//#when parsing
|
||||
//#then it should succeed
|
||||
it("parses join_request", () => {
|
||||
const msg = {
|
||||
type: "join_request",
|
||||
agentName: "new-agent",
|
||||
sessionId: "sess-123",
|
||||
}
|
||||
expect(JoinRequestSchema.safeParse(msg).success).toBe(true)
|
||||
})
|
||||
})
|
||||
153
src/features/sisyphus-swarm/mailbox/types.ts
Normal file
153
src/features/sisyphus-swarm/mailbox/types.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const MailboxMessageSchema = z.object({
|
||||
from: z.string(),
|
||||
text: z.string(),
|
||||
timestamp: z.string(),
|
||||
color: z.string().optional(),
|
||||
read: z.boolean(),
|
||||
})
|
||||
|
||||
export type MailboxMessage = z.infer<typeof MailboxMessageSchema>
|
||||
|
||||
export const PermissionRequestSchema = z.object({
|
||||
type: z.literal("permission_request"),
|
||||
requestId: z.string(),
|
||||
toolName: z.string(),
|
||||
input: z.unknown(),
|
||||
agentId: z.string(),
|
||||
timestamp: z.number(),
|
||||
})
|
||||
|
||||
export type PermissionRequest = z.infer<typeof PermissionRequestSchema>
|
||||
|
||||
export const PermissionResponseSchema = z.object({
|
||||
type: z.literal("permission_response"),
|
||||
requestId: z.string(),
|
||||
decision: z.enum(["approved", "rejected"]),
|
||||
updatedInput: z.unknown().optional(),
|
||||
feedback: z.string().optional(),
|
||||
permissionUpdates: z.unknown().optional(),
|
||||
})
|
||||
|
||||
export type PermissionResponse = z.infer<typeof PermissionResponseSchema>
|
||||
|
||||
export const ShutdownRequestSchema = z.object({
|
||||
type: z.literal("shutdown_request"),
|
||||
})
|
||||
|
||||
export type ShutdownRequest = z.infer<typeof ShutdownRequestSchema>
|
||||
|
||||
export const ShutdownApprovedSchema = z.object({
|
||||
type: z.literal("shutdown_approved"),
|
||||
})
|
||||
|
||||
export type ShutdownApproved = z.infer<typeof ShutdownApprovedSchema>
|
||||
|
||||
export const ShutdownRejectedSchema = z.object({
|
||||
type: z.literal("shutdown_rejected"),
|
||||
reason: z.string().optional(),
|
||||
})
|
||||
|
||||
export type ShutdownRejected = z.infer<typeof ShutdownRejectedSchema>
|
||||
|
||||
export const TaskAssignmentSchema = z.object({
|
||||
type: z.literal("task_assignment"),
|
||||
taskId: z.string(),
|
||||
subject: z.string(),
|
||||
description: z.string(),
|
||||
assignedBy: z.string(),
|
||||
timestamp: z.number(),
|
||||
})
|
||||
|
||||
export type TaskAssignment = z.infer<typeof TaskAssignmentSchema>
|
||||
|
||||
export const TaskCompletedSchema = z.object({
|
||||
type: z.literal("task_completed"),
|
||||
taskId: z.string(),
|
||||
agentId: z.string(),
|
||||
timestamp: z.number(),
|
||||
})
|
||||
|
||||
export type TaskCompleted = z.infer<typeof TaskCompletedSchema>
|
||||
|
||||
export const IdleNotificationSchema = z.object({
|
||||
type: z.literal("idle_notification"),
|
||||
})
|
||||
|
||||
export type IdleNotification = z.infer<typeof IdleNotificationSchema>
|
||||
|
||||
export const JoinRequestSchema = z.object({
|
||||
type: z.literal("join_request"),
|
||||
agentName: z.string(),
|
||||
sessionId: z.string(),
|
||||
})
|
||||
|
||||
export type JoinRequest = z.infer<typeof JoinRequestSchema>
|
||||
|
||||
export const JoinApprovedSchema = z.object({
|
||||
type: z.literal("join_approved"),
|
||||
agentName: z.string(),
|
||||
teamName: z.string(),
|
||||
})
|
||||
|
||||
export type JoinApproved = z.infer<typeof JoinApprovedSchema>
|
||||
|
||||
export const JoinRejectedSchema = z.object({
|
||||
type: z.literal("join_rejected"),
|
||||
reason: z.string().optional(),
|
||||
})
|
||||
|
||||
export type JoinRejected = z.infer<typeof JoinRejectedSchema>
|
||||
|
||||
export const PlanApprovalRequestSchema = z.object({
|
||||
type: z.literal("plan_approval_request"),
|
||||
requestId: z.string(),
|
||||
plan: z.string(),
|
||||
agentId: z.string(),
|
||||
})
|
||||
|
||||
export type PlanApprovalRequest = z.infer<typeof PlanApprovalRequestSchema>
|
||||
|
||||
export const PlanApprovalResponseSchema = z.object({
|
||||
type: z.literal("plan_approval_response"),
|
||||
requestId: z.string(),
|
||||
decision: z.enum(["approved", "rejected"]),
|
||||
feedback: z.string().optional(),
|
||||
})
|
||||
|
||||
export type PlanApprovalResponse = z.infer<typeof PlanApprovalResponseSchema>
|
||||
|
||||
export const ModeSetRequestSchema = z.object({
|
||||
type: z.literal("mode_set_request"),
|
||||
mode: z.enum(["acceptEdits", "bypassPermissions", "default", "delegate", "dontAsk", "plan"]),
|
||||
})
|
||||
|
||||
export type ModeSetRequest = z.infer<typeof ModeSetRequestSchema>
|
||||
|
||||
export const TeamPermissionUpdateSchema = z.object({
|
||||
type: z.literal("team_permission_update"),
|
||||
permissions: z.record(z.string(), z.unknown()),
|
||||
})
|
||||
|
||||
export type TeamPermissionUpdate = z.infer<typeof TeamPermissionUpdateSchema>
|
||||
|
||||
export const ProtocolMessageSchema = z.discriminatedUnion("type", [
|
||||
PermissionRequestSchema,
|
||||
PermissionResponseSchema,
|
||||
ShutdownRequestSchema,
|
||||
ShutdownApprovedSchema,
|
||||
ShutdownRejectedSchema,
|
||||
TaskAssignmentSchema,
|
||||
TaskCompletedSchema,
|
||||
IdleNotificationSchema,
|
||||
JoinRequestSchema,
|
||||
JoinApprovedSchema,
|
||||
JoinRejectedSchema,
|
||||
PlanApprovalRequestSchema,
|
||||
PlanApprovalResponseSchema,
|
||||
ModeSetRequestSchema,
|
||||
TeamPermissionUpdateSchema,
|
||||
])
|
||||
|
||||
export type ProtocolMessage = z.infer<typeof ProtocolMessageSchema>
|
||||
178
src/features/sisyphus-tasks/storage.test.ts
Normal file
178
src/features/sisyphus-tasks/storage.test.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "bun:test"
|
||||
import { join } from "path"
|
||||
import { mkdirSync, rmSync, existsSync, writeFileSync, readFileSync } from "fs"
|
||||
import { z } from "zod"
|
||||
import {
|
||||
getTaskDir,
|
||||
getTaskPath,
|
||||
getTeamDir,
|
||||
getInboxPath,
|
||||
ensureDir,
|
||||
readJsonSafe,
|
||||
writeJsonAtomic,
|
||||
} from "./storage"
|
||||
|
||||
const TEST_DIR = join(import.meta.dirname, ".test-storage")
|
||||
|
||||
describe("Storage Utilities", () => {
|
||||
beforeEach(() => {
|
||||
rmSync(TEST_DIR, { recursive: true, force: true })
|
||||
mkdirSync(TEST_DIR, { recursive: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(TEST_DIR, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe("getTaskDir", () => {
|
||||
//#given default config (no claude_code_compat)
|
||||
//#when getting task directory
|
||||
//#then it should return .sisyphus/tasks/{listId}
|
||||
it("returns sisyphus path by default", () => {
|
||||
const config = { sisyphus: { tasks: { storage_path: ".sisyphus/tasks" } } }
|
||||
const result = getTaskDir("list-123", config as any)
|
||||
expect(result).toContain(".sisyphus/tasks/list-123")
|
||||
})
|
||||
|
||||
//#given claude_code_compat enabled
|
||||
//#when getting task directory
|
||||
//#then it should return Claude Code path
|
||||
it("returns claude code path when compat enabled", () => {
|
||||
const config = {
|
||||
sisyphus: {
|
||||
tasks: {
|
||||
storage_path: ".sisyphus/tasks",
|
||||
claude_code_compat: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
const result = getTaskDir("list-123", config as any)
|
||||
expect(result).toContain(".cache/claude-code/tasks/list-123")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getTaskPath", () => {
|
||||
//#given list and task IDs
|
||||
//#when getting task path
|
||||
//#then it should return path to task JSON file
|
||||
it("returns path to task JSON", () => {
|
||||
const config = { sisyphus: { tasks: { storage_path: ".sisyphus/tasks" } } }
|
||||
const result = getTaskPath("list-123", "1", config as any)
|
||||
expect(result).toContain("list-123/1.json")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getTeamDir", () => {
|
||||
//#given team name and default config
|
||||
//#when getting team directory
|
||||
//#then it should return .sisyphus/teams/{teamName}
|
||||
it("returns sisyphus team path", () => {
|
||||
const config = { sisyphus: { swarm: { storage_path: ".sisyphus/teams" } } }
|
||||
const result = getTeamDir("my-team", config as any)
|
||||
expect(result).toContain(".sisyphus/teams/my-team")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getInboxPath", () => {
|
||||
//#given team and agent names
|
||||
//#when getting inbox path
|
||||
//#then it should return path to inbox JSON file
|
||||
it("returns path to inbox JSON", () => {
|
||||
const config = { sisyphus: { swarm: { storage_path: ".sisyphus/teams" } } }
|
||||
const result = getInboxPath("my-team", "agent-001", config as any)
|
||||
expect(result).toContain("my-team/inboxes/agent-001.json")
|
||||
})
|
||||
})
|
||||
|
||||
describe("ensureDir", () => {
|
||||
//#given a non-existent directory path
|
||||
//#when calling ensureDir
|
||||
//#then it should create the directory
|
||||
it("creates directory if not exists", () => {
|
||||
const dirPath = join(TEST_DIR, "new-dir", "nested")
|
||||
ensureDir(dirPath)
|
||||
expect(existsSync(dirPath)).toBe(true)
|
||||
})
|
||||
|
||||
//#given an existing directory
|
||||
//#when calling ensureDir
|
||||
//#then it should not throw
|
||||
it("does not throw for existing directory", () => {
|
||||
const dirPath = join(TEST_DIR, "existing")
|
||||
mkdirSync(dirPath, { recursive: true })
|
||||
expect(() => ensureDir(dirPath)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("readJsonSafe", () => {
|
||||
//#given a valid JSON file matching schema
|
||||
//#when reading with readJsonSafe
|
||||
//#then it should return parsed object
|
||||
it("reads and parses valid JSON", () => {
|
||||
const testSchema = z.object({ name: z.string(), value: z.number() })
|
||||
const filePath = join(TEST_DIR, "test.json")
|
||||
writeFileSync(filePath, JSON.stringify({ name: "test", value: 42 }))
|
||||
|
||||
const result = readJsonSafe(filePath, testSchema)
|
||||
expect(result).toEqual({ name: "test", value: 42 })
|
||||
})
|
||||
|
||||
//#given a non-existent file
|
||||
//#when reading with readJsonSafe
|
||||
//#then it should return null
|
||||
it("returns null for non-existent file", () => {
|
||||
const testSchema = z.object({ name: z.string() })
|
||||
const result = readJsonSafe(join(TEST_DIR, "missing.json"), testSchema)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
//#given invalid JSON content
|
||||
//#when reading with readJsonSafe
|
||||
//#then it should return null
|
||||
it("returns null for invalid JSON", () => {
|
||||
const testSchema = z.object({ name: z.string() })
|
||||
const filePath = join(TEST_DIR, "invalid.json")
|
||||
writeFileSync(filePath, "not valid json")
|
||||
|
||||
const result = readJsonSafe(filePath, testSchema)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
//#given JSON that doesn't match schema
|
||||
//#when reading with readJsonSafe
|
||||
//#then it should return null
|
||||
it("returns null for schema mismatch", () => {
|
||||
const testSchema = z.object({ name: z.string(), required: z.number() })
|
||||
const filePath = join(TEST_DIR, "mismatch.json")
|
||||
writeFileSync(filePath, JSON.stringify({ name: "test" }))
|
||||
|
||||
const result = readJsonSafe(filePath, testSchema)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("writeJsonAtomic", () => {
|
||||
//#given data to write
|
||||
//#when calling writeJsonAtomic
|
||||
//#then it should write to file atomically
|
||||
it("writes JSON atomically", () => {
|
||||
const filePath = join(TEST_DIR, "atomic.json")
|
||||
const data = { key: "value", number: 123 }
|
||||
|
||||
writeJsonAtomic(filePath, data)
|
||||
|
||||
const content = readFileSync(filePath, "utf-8")
|
||||
expect(JSON.parse(content)).toEqual(data)
|
||||
})
|
||||
|
||||
//#given a deeply nested path
|
||||
//#when calling writeJsonAtomic
|
||||
//#then it should create parent directories
|
||||
it("creates parent directories", () => {
|
||||
const filePath = join(TEST_DIR, "deep", "nested", "file.json")
|
||||
writeJsonAtomic(filePath, { test: true })
|
||||
|
||||
expect(existsSync(filePath)).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
82
src/features/sisyphus-tasks/storage.ts
Normal file
82
src/features/sisyphus-tasks/storage.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { join, dirname } from "path"
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync } from "fs"
|
||||
import { homedir } from "os"
|
||||
import type { z } from "zod"
|
||||
import type { OhMyOpenCodeConfig } from "../../config/schema"
|
||||
|
||||
export function getTaskDir(listId: string, config: Partial<OhMyOpenCodeConfig>): string {
|
||||
const tasksConfig = config.sisyphus?.tasks
|
||||
|
||||
if (tasksConfig?.claude_code_compat) {
|
||||
return join(homedir(), ".cache", "claude-code", "tasks", listId)
|
||||
}
|
||||
|
||||
const storagePath = tasksConfig?.storage_path ?? ".sisyphus/tasks"
|
||||
return join(process.cwd(), storagePath, listId)
|
||||
}
|
||||
|
||||
export function getTaskPath(listId: string, taskId: string, config: Partial<OhMyOpenCodeConfig>): string {
|
||||
return join(getTaskDir(listId, config), `${taskId}.json`)
|
||||
}
|
||||
|
||||
export function getTeamDir(teamName: string, config: Partial<OhMyOpenCodeConfig>): string {
|
||||
const swarmConfig = config.sisyphus?.swarm
|
||||
|
||||
if (swarmConfig?.storage_path?.includes("claude")) {
|
||||
return join(homedir(), ".claude", "teams", teamName)
|
||||
}
|
||||
|
||||
const storagePath = swarmConfig?.storage_path ?? ".sisyphus/teams"
|
||||
return join(process.cwd(), storagePath, teamName)
|
||||
}
|
||||
|
||||
export function getInboxPath(teamName: string, agentName: string, config: Partial<OhMyOpenCodeConfig>): string {
|
||||
return join(getTeamDir(teamName, config), "inboxes", `${agentName}.json`)
|
||||
}
|
||||
|
||||
export function ensureDir(dirPath: string): void {
|
||||
if (!existsSync(dirPath)) {
|
||||
mkdirSync(dirPath, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
export function readJsonSafe<T>(filePath: string, schema: z.ZodType<T>): T | null {
|
||||
try {
|
||||
if (!existsSync(filePath)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const content = readFileSync(filePath, "utf-8")
|
||||
const parsed = JSON.parse(content)
|
||||
const result = schema.safeParse(parsed)
|
||||
|
||||
if (!result.success) {
|
||||
return null
|
||||
}
|
||||
|
||||
return result.data
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function writeJsonAtomic(filePath: string, data: unknown): void {
|
||||
const dir = dirname(filePath)
|
||||
ensureDir(dir)
|
||||
|
||||
const tempPath = `${filePath}.tmp.${Date.now()}`
|
||||
|
||||
try {
|
||||
writeFileSync(tempPath, JSON.stringify(data, null, 2), "utf-8")
|
||||
renameSync(tempPath, filePath)
|
||||
} catch (error) {
|
||||
try {
|
||||
if (existsSync(tempPath)) {
|
||||
unlinkSync(tempPath)
|
||||
}
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
82
src/features/sisyphus-tasks/types.test.ts
Normal file
82
src/features/sisyphus-tasks/types.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import { TaskSchema, TaskStatusSchema, type Task } from "./types"
|
||||
|
||||
describe("TaskSchema", () => {
|
||||
//#given a valid task object
|
||||
//#when parsing with TaskSchema
|
||||
//#then it should succeed
|
||||
it("parses valid task object", () => {
|
||||
const validTask = {
|
||||
id: "1",
|
||||
subject: "Fix authentication bug",
|
||||
description: "Users report 401 errors",
|
||||
status: "pending",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
}
|
||||
|
||||
const result = TaskSchema.safeParse(validTask)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
//#given a task with all optional fields
|
||||
//#when parsing with TaskSchema
|
||||
//#then it should succeed
|
||||
it("parses task with optional fields", () => {
|
||||
const taskWithOptionals = {
|
||||
id: "2",
|
||||
subject: "Add unit tests",
|
||||
description: "Write tests for auth module",
|
||||
activeForm: "Adding unit tests",
|
||||
owner: "agent-001",
|
||||
status: "in_progress",
|
||||
blocks: ["3"],
|
||||
blockedBy: ["1"],
|
||||
metadata: { priority: "high", labels: ["bug"] },
|
||||
}
|
||||
|
||||
const result = TaskSchema.safeParse(taskWithOptionals)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
//#given an invalid status value
|
||||
//#when parsing with TaskSchema
|
||||
//#then it should fail
|
||||
it("rejects invalid status", () => {
|
||||
const invalidTask = {
|
||||
id: "1",
|
||||
subject: "Test",
|
||||
description: "Test",
|
||||
status: "invalid_status",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
}
|
||||
|
||||
const result = TaskSchema.safeParse(invalidTask)
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
//#given missing required fields
|
||||
//#when parsing with TaskSchema
|
||||
//#then it should fail
|
||||
it("rejects missing required fields", () => {
|
||||
const invalidTask = {
|
||||
id: "1",
|
||||
// missing subject, description, status, blocks, blockedBy
|
||||
}
|
||||
|
||||
const result = TaskSchema.safeParse(invalidTask)
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("TaskStatusSchema", () => {
|
||||
//#given valid status values
|
||||
//#when parsing
|
||||
//#then all should succeed
|
||||
it("accepts valid statuses", () => {
|
||||
expect(TaskStatusSchema.safeParse("pending").success).toBe(true)
|
||||
expect(TaskStatusSchema.safeParse("in_progress").success).toBe(true)
|
||||
expect(TaskStatusSchema.safeParse("completed").success).toBe(true)
|
||||
})
|
||||
})
|
||||
41
src/features/sisyphus-tasks/types.ts
Normal file
41
src/features/sisyphus-tasks/types.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const TaskStatusSchema = z.enum(["pending", "in_progress", "completed"])
|
||||
export type TaskStatus = z.infer<typeof TaskStatusSchema>
|
||||
|
||||
export const TaskSchema = z.object({
|
||||
id: z.string(),
|
||||
subject: z.string(),
|
||||
description: z.string(),
|
||||
activeForm: z.string().optional(),
|
||||
owner: z.string().optional(),
|
||||
status: TaskStatusSchema,
|
||||
blocks: z.array(z.string()),
|
||||
blockedBy: z.array(z.string()),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
})
|
||||
|
||||
export type Task = z.infer<typeof TaskSchema>
|
||||
|
||||
export const TaskCreateInputSchema = z.object({
|
||||
subject: z.string().describe("Task title"),
|
||||
description: z.string().describe("Detailed description"),
|
||||
activeForm: z.string().optional().describe("Text shown when in progress"),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
})
|
||||
|
||||
export type TaskCreateInput = z.infer<typeof TaskCreateInputSchema>
|
||||
|
||||
export const TaskUpdateInputSchema = z.object({
|
||||
taskId: z.string().describe("Task ID to update"),
|
||||
subject: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
activeForm: z.string().optional(),
|
||||
status: z.enum(["pending", "in_progress", "completed", "deleted"]).optional(),
|
||||
addBlocks: z.array(z.string()).optional().describe("Task IDs this task will block"),
|
||||
addBlockedBy: z.array(z.string()).optional().describe("Task IDs that block this task"),
|
||||
owner: z.string().optional(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
})
|
||||
|
||||
export type TaskUpdateInput = z.infer<typeof TaskUpdateInputSchema>
|
||||
@@ -3,8 +3,6 @@ import { SkillMcpManager } from "./manager"
|
||||
import type { SkillMcpClientInfo, SkillMcpServerContext } from "./types"
|
||||
import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types"
|
||||
|
||||
|
||||
|
||||
// Mock the MCP SDK transports to avoid network calls
|
||||
const mockHttpConnect = mock(() => Promise.reject(new Error("Mocked HTTP connection failure")))
|
||||
const mockHttpClose = mock(() => Promise.resolve())
|
||||
@@ -24,6 +22,21 @@ mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
const mockTokens = mock(() => null as { accessToken: string; refreshToken?: string; expiresAt?: number } | null)
|
||||
const mockLogin = mock(() => Promise.resolve({ accessToken: "new-token" }))
|
||||
|
||||
mock.module("../mcp-oauth/provider", () => ({
|
||||
McpOAuthProvider: class MockMcpOAuthProvider {
|
||||
constructor(public options: { serverUrl: string; clientId?: string; scopes?: string[] }) {}
|
||||
tokens() {
|
||||
return mockTokens()
|
||||
}
|
||||
async login() {
|
||||
return mockLogin()
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -518,7 +531,6 @@ describe("SkillMcpManager", () => {
|
||||
skillName: "retry-skill",
|
||||
}
|
||||
|
||||
// Mock client that fails first time with "Not connected", then succeeds
|
||||
let callCount = 0
|
||||
const mockClient = {
|
||||
callTool: mock(async () => {
|
||||
@@ -531,7 +543,6 @@ describe("SkillMcpManager", () => {
|
||||
close: mock(() => Promise.resolve()),
|
||||
}
|
||||
|
||||
// Spy on getOrCreateClientWithRetry to inject mock client
|
||||
const getOrCreateSpy = spyOn(manager as any, "getOrCreateClientWithRetry")
|
||||
getOrCreateSpy.mockResolvedValue(mockClient)
|
||||
|
||||
@@ -539,9 +550,9 @@ describe("SkillMcpManager", () => {
|
||||
const result = await manager.callTool(info, context, "test-tool", {})
|
||||
|
||||
// #then
|
||||
expect(callCount).toBe(2) // First call fails, second succeeds
|
||||
expect(callCount).toBe(2)
|
||||
expect(result).toEqual([{ type: "text", text: "success" }])
|
||||
expect(getOrCreateSpy).toHaveBeenCalledTimes(2) // Called twice due to retry
|
||||
expect(getOrCreateSpy).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it("should fail after 3 retry attempts", async () => {
|
||||
@@ -558,7 +569,6 @@ describe("SkillMcpManager", () => {
|
||||
skillName: "fail-skill",
|
||||
}
|
||||
|
||||
// Mock client that always fails with "Not connected"
|
||||
const mockClient = {
|
||||
callTool: mock(async () => {
|
||||
throw new Error("Not connected")
|
||||
@@ -573,7 +583,7 @@ describe("SkillMcpManager", () => {
|
||||
await expect(manager.callTool(info, context, "test-tool", {})).rejects.toThrow(
|
||||
/Failed after 3 reconnection attempts/
|
||||
)
|
||||
expect(getOrCreateSpy).toHaveBeenCalledTimes(3) // Initial + 2 retries
|
||||
expect(getOrCreateSpy).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it("should not retry on non-connection errors", async () => {
|
||||
@@ -590,7 +600,6 @@ describe("SkillMcpManager", () => {
|
||||
skillName: "error-skill",
|
||||
}
|
||||
|
||||
// Mock client that fails with non-connection error
|
||||
const mockClient = {
|
||||
callTool: mock(async () => {
|
||||
throw new Error("Tool not found")
|
||||
@@ -605,7 +614,194 @@ describe("SkillMcpManager", () => {
|
||||
await expect(manager.callTool(info, context, "test-tool", {})).rejects.toThrow(
|
||||
"Tool not found"
|
||||
)
|
||||
expect(getOrCreateSpy).toHaveBeenCalledTimes(1) // No retry
|
||||
expect(getOrCreateSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("OAuth integration", () => {
|
||||
beforeEach(() => {
|
||||
mockTokens.mockClear()
|
||||
mockLogin.mockClear()
|
||||
})
|
||||
|
||||
it("injects Authorization header when oauth config has stored tokens", async () => {
|
||||
// #given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "oauth-server",
|
||||
skillName: "oauth-skill",
|
||||
sessionID: "session-oauth-1",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {
|
||||
url: "https://mcp.example.com/mcp",
|
||||
oauth: {
|
||||
clientId: "my-client",
|
||||
scopes: ["read", "write"],
|
||||
},
|
||||
}
|
||||
mockTokens.mockReturnValue({ accessToken: "stored-access-token" })
|
||||
|
||||
// #when
|
||||
try {
|
||||
await manager.getOrCreateClient(info, config)
|
||||
} catch { /* connection fails in test */ }
|
||||
|
||||
// #then
|
||||
const headers = lastTransportInstance.options?.requestInit?.headers as Record<string, string> | undefined
|
||||
expect(headers?.Authorization).toBe("Bearer stored-access-token")
|
||||
})
|
||||
|
||||
it("does not inject Authorization header when no stored tokens exist and login fails", async () => {
|
||||
// #given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "oauth-no-token",
|
||||
skillName: "oauth-skill",
|
||||
sessionID: "session-oauth-2",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {
|
||||
url: "https://mcp.example.com/mcp",
|
||||
oauth: {
|
||||
clientId: "my-client",
|
||||
},
|
||||
}
|
||||
mockTokens.mockReturnValue(null)
|
||||
mockLogin.mockRejectedValue(new Error("Login failed"))
|
||||
|
||||
// #when
|
||||
try {
|
||||
await manager.getOrCreateClient(info, config)
|
||||
} catch { /* connection fails in test */ }
|
||||
|
||||
// #then
|
||||
const headers = lastTransportInstance.options?.requestInit?.headers as Record<string, string> | undefined
|
||||
expect(headers?.Authorization).toBeUndefined()
|
||||
})
|
||||
|
||||
it("preserves existing static headers alongside OAuth token", async () => {
|
||||
// #given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "oauth-with-headers",
|
||||
skillName: "oauth-skill",
|
||||
sessionID: "session-oauth-3",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {
|
||||
url: "https://mcp.example.com/mcp",
|
||||
headers: {
|
||||
"X-Custom": "custom-value",
|
||||
},
|
||||
oauth: {
|
||||
clientId: "my-client",
|
||||
},
|
||||
}
|
||||
mockTokens.mockReturnValue({ accessToken: "oauth-token" })
|
||||
|
||||
// #when
|
||||
try {
|
||||
await manager.getOrCreateClient(info, config)
|
||||
} catch { /* connection fails in test */ }
|
||||
|
||||
// #then
|
||||
const headers = lastTransportInstance.options?.requestInit?.headers as Record<string, string> | undefined
|
||||
expect(headers?.["X-Custom"]).toBe("custom-value")
|
||||
expect(headers?.Authorization).toBe("Bearer oauth-token")
|
||||
})
|
||||
|
||||
it("does not create auth provider when oauth config is absent", async () => {
|
||||
// #given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "no-oauth-server",
|
||||
skillName: "test-skill",
|
||||
sessionID: "session-no-oauth",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {
|
||||
url: "https://mcp.example.com/mcp",
|
||||
headers: {
|
||||
Authorization: "Bearer static-token",
|
||||
},
|
||||
}
|
||||
|
||||
// #when
|
||||
try {
|
||||
await manager.getOrCreateClient(info, config)
|
||||
} catch { /* connection fails in test */ }
|
||||
|
||||
// #then
|
||||
const headers = lastTransportInstance.options?.requestInit?.headers as Record<string, string> | undefined
|
||||
expect(headers?.Authorization).toBe("Bearer static-token")
|
||||
expect(mockTokens).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("handles step-up auth by triggering re-login on 403 with scope", async () => {
|
||||
// #given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "stepup-server",
|
||||
skillName: "stepup-skill",
|
||||
sessionID: "session-stepup-1",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {
|
||||
url: "https://mcp.example.com/mcp",
|
||||
oauth: {
|
||||
clientId: "my-client",
|
||||
scopes: ["read"],
|
||||
},
|
||||
}
|
||||
const context: SkillMcpServerContext = {
|
||||
config,
|
||||
skillName: "stepup-skill",
|
||||
}
|
||||
|
||||
mockTokens.mockReturnValue({ accessToken: "initial-token" })
|
||||
mockLogin.mockResolvedValue({ accessToken: "upgraded-token" })
|
||||
|
||||
let callCount = 0
|
||||
const mockClient = {
|
||||
callTool: mock(async () => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
throw new Error('403 WWW-Authenticate: Bearer scope="admin write"')
|
||||
}
|
||||
return { content: [{ type: "text", text: "success" }] }
|
||||
}),
|
||||
close: mock(() => Promise.resolve()),
|
||||
}
|
||||
|
||||
const getOrCreateSpy = spyOn(manager as any, "getOrCreateClientWithRetry")
|
||||
getOrCreateSpy.mockResolvedValue(mockClient)
|
||||
|
||||
// #when
|
||||
const result = await manager.callTool(info, context, "test-tool", {})
|
||||
|
||||
// #then
|
||||
expect(result).toEqual([{ type: "text", text: "success" }])
|
||||
expect(mockLogin).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("does not attempt step-up when oauth config is absent", async () => {
|
||||
// #given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "no-stepup-server",
|
||||
skillName: "no-stepup-skill",
|
||||
sessionID: "session-no-stepup",
|
||||
}
|
||||
const context: SkillMcpServerContext = {
|
||||
config: {
|
||||
url: "https://mcp.example.com/mcp",
|
||||
},
|
||||
skillName: "no-stepup-skill",
|
||||
}
|
||||
|
||||
const mockClient = {
|
||||
callTool: mock(async () => {
|
||||
throw new Error('403 WWW-Authenticate: Bearer scope="admin"')
|
||||
}),
|
||||
close: mock(() => Promise.resolve()),
|
||||
}
|
||||
|
||||
const getOrCreateSpy = spyOn(manager as any, "getOrCreateClientWithRetry")
|
||||
getOrCreateSpy.mockResolvedValue(mockClient)
|
||||
|
||||
// #when / #then
|
||||
await expect(manager.callTool(info, context, "test-tool", {})).rejects.toThrow(/403/)
|
||||
expect(mockLogin).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,8 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/
|
||||
import type { Tool, Resource, Prompt } from "@modelcontextprotocol/sdk/types.js"
|
||||
import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types"
|
||||
import { expandEnvVarsInObject } from "../claude-code-mcp-loader/env-expander"
|
||||
import { McpOAuthProvider } from "../mcp-oauth/provider"
|
||||
import { isStepUpRequired, mergeScopes } from "../mcp-oauth/step-up"
|
||||
import { createCleanMcpEnvironment } from "./env-cleaner"
|
||||
import type { SkillMcpClientInfo, SkillMcpServerContext } from "./types"
|
||||
|
||||
@@ -60,6 +62,7 @@ function getConnectionType(config: ClaudeCodeMcpServer): ConnectionType | null {
|
||||
export class SkillMcpManager {
|
||||
private clients: Map<string, ManagedClient> = new Map()
|
||||
private pendingConnections: Map<string, Promise<Client>> = new Map()
|
||||
private authProviders: Map<string, McpOAuthProvider> = new Map()
|
||||
private cleanupRegistered = false
|
||||
private cleanupInterval: ReturnType<typeof setInterval> | null = null
|
||||
private readonly IDLE_TIMEOUT = 5 * 60 * 1000
|
||||
@@ -68,6 +71,28 @@ export class SkillMcpManager {
|
||||
return `${info.sessionID}:${info.skillName}:${info.serverName}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create an McpOAuthProvider for a given server URL + oauth config.
|
||||
* Providers are cached by server URL to reuse tokens across reconnections.
|
||||
*/
|
||||
private getOrCreateAuthProvider(
|
||||
serverUrl: string,
|
||||
oauth: NonNullable<ClaudeCodeMcpServer["oauth"]>
|
||||
): McpOAuthProvider {
|
||||
const existing = this.authProviders.get(serverUrl)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
|
||||
const provider = new McpOAuthProvider({
|
||||
serverUrl,
|
||||
clientId: oauth.clientId,
|
||||
scopes: oauth.scopes,
|
||||
})
|
||||
this.authProviders.set(serverUrl, provider)
|
||||
return provider
|
||||
}
|
||||
|
||||
private registerProcessCleanup(): void {
|
||||
if (this.cleanupRegistered) return
|
||||
this.cleanupRegistered = true
|
||||
@@ -204,7 +229,30 @@ export class SkillMcpManager {
|
||||
// Build request init with headers if provided
|
||||
const requestInit: RequestInit = {}
|
||||
if (config.headers && Object.keys(config.headers).length > 0) {
|
||||
requestInit.headers = config.headers
|
||||
requestInit.headers = { ...config.headers }
|
||||
}
|
||||
|
||||
let authProvider: McpOAuthProvider | undefined
|
||||
if (config.oauth) {
|
||||
authProvider = this.getOrCreateAuthProvider(config.url, config.oauth)
|
||||
let tokenData = authProvider.tokens()
|
||||
|
||||
const isExpired = tokenData?.expiresAt != null && tokenData.expiresAt < Math.floor(Date.now() / 1000)
|
||||
if (!tokenData || isExpired) {
|
||||
try {
|
||||
tokenData = await authProvider.login()
|
||||
} catch {
|
||||
// Login failed — proceed without auth header
|
||||
}
|
||||
}
|
||||
|
||||
if (tokenData) {
|
||||
const existingHeaders = (requestInit.headers ?? {}) as Record<string, string>
|
||||
requestInit.headers = {
|
||||
...existingHeaders,
|
||||
Authorization: `Bearer ${tokenData.accessToken}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(url, {
|
||||
@@ -460,6 +508,12 @@ export class SkillMcpManager {
|
||||
lastError = error instanceof Error ? error : new Error(String(error))
|
||||
const errorMessage = lastError.message.toLowerCase()
|
||||
|
||||
const stepUpHandled = await this.handleStepUpIfNeeded(lastError, config)
|
||||
if (stepUpHandled) {
|
||||
await this.forceReconnect(info)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!errorMessage.includes("not connected")) {
|
||||
throw lastError
|
||||
}
|
||||
@@ -470,6 +524,53 @@ export class SkillMcpManager {
|
||||
)
|
||||
}
|
||||
|
||||
await this.forceReconnect(info)
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error("Operation failed with unknown error")
|
||||
}
|
||||
|
||||
private async handleStepUpIfNeeded(
|
||||
error: Error,
|
||||
config: ClaudeCodeMcpServer
|
||||
): Promise<boolean> {
|
||||
if (!config.oauth || !config.url) {
|
||||
return false
|
||||
}
|
||||
|
||||
const statusMatch = /\b403\b/.exec(error.message)
|
||||
if (!statusMatch) {
|
||||
return false
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {}
|
||||
const wwwAuthMatch = /WWW-Authenticate:\s*(.+)/i.exec(error.message)
|
||||
if (wwwAuthMatch?.[1]) {
|
||||
headers["www-authenticate"] = wwwAuthMatch[1]
|
||||
}
|
||||
|
||||
const stepUp = isStepUpRequired(403, headers)
|
||||
if (!stepUp) {
|
||||
return false
|
||||
}
|
||||
|
||||
const currentScopes = config.oauth.scopes ?? []
|
||||
const merged = mergeScopes(currentScopes, stepUp.requiredScopes)
|
||||
config.oauth.scopes = merged
|
||||
|
||||
this.authProviders.delete(config.url)
|
||||
const provider = this.getOrCreateAuthProvider(config.url, config.oauth)
|
||||
|
||||
try {
|
||||
await provider.login()
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private async forceReconnect(info: SkillMcpClientInfo): Promise<void> {
|
||||
const key = this.getClientKey(info)
|
||||
const existing = this.clients.get(key)
|
||||
if (existing) {
|
||||
@@ -482,10 +583,6 @@ export class SkillMcpManager {
|
||||
} catch { /* transport may already be terminated */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error("Operation failed with unknown error")
|
||||
}
|
||||
|
||||
private async getOrCreateClientWithRetry(
|
||||
info: SkillMcpClientInfo,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, test, expect, mock, beforeEach } from 'bun:test'
|
||||
import type { TmuxConfig } from '../../config/schema'
|
||||
import type { WindowState, PaneAction } from './types'
|
||||
import type { ActionResult, ExecuteContext } from './action-executor'
|
||||
import type { TmuxUtilDeps } from './manager'
|
||||
|
||||
type ExecuteActionsResult = {
|
||||
success: boolean
|
||||
@@ -33,6 +34,11 @@ const mockExecuteAction = mock<(
|
||||
const mockIsInsideTmux = mock<() => boolean>(() => true)
|
||||
const mockGetCurrentPaneId = mock<() => string | undefined>(() => '%0')
|
||||
|
||||
const mockTmuxDeps: TmuxUtilDeps = {
|
||||
isInsideTmux: mockIsInsideTmux,
|
||||
getCurrentPaneId: mockGetCurrentPaneId,
|
||||
}
|
||||
|
||||
mock.module('./pane-state-querier', () => ({
|
||||
queryWindowState: mockQueryWindowState,
|
||||
paneExists: mockPaneExists,
|
||||
@@ -51,15 +57,19 @@ mock.module('./action-executor', () => ({
|
||||
executeAction: mockExecuteAction,
|
||||
}))
|
||||
|
||||
mock.module('../../shared/tmux', () => ({
|
||||
isInsideTmux: mockIsInsideTmux,
|
||||
getCurrentPaneId: mockGetCurrentPaneId,
|
||||
POLL_INTERVAL_BACKGROUND_MS: 2000,
|
||||
SESSION_TIMEOUT_MS: 600000,
|
||||
SESSION_MISSING_GRACE_MS: 6000,
|
||||
mock.module('../../shared/tmux', () => {
|
||||
const { isInsideTmux, getCurrentPaneId } = require('../../shared/tmux/tmux-utils')
|
||||
const { POLL_INTERVAL_BACKGROUND_MS, SESSION_TIMEOUT_MS, SESSION_MISSING_GRACE_MS } = require('../../shared/tmux/constants')
|
||||
return {
|
||||
isInsideTmux,
|
||||
getCurrentPaneId,
|
||||
POLL_INTERVAL_BACKGROUND_MS,
|
||||
SESSION_TIMEOUT_MS,
|
||||
SESSION_MISSING_GRACE_MS,
|
||||
SESSION_READY_POLL_INTERVAL_MS: 100,
|
||||
SESSION_READY_TIMEOUT_MS: 500,
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
const trackedSessions = new Set<string>()
|
||||
|
||||
@@ -148,7 +158,7 @@ describe('TmuxSessionManager', () => {
|
||||
}
|
||||
|
||||
//#when
|
||||
const manager = new TmuxSessionManager(ctx, config)
|
||||
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
|
||||
|
||||
//#then
|
||||
expect(manager).toBeDefined()
|
||||
@@ -168,7 +178,7 @@ describe('TmuxSessionManager', () => {
|
||||
}
|
||||
|
||||
//#when
|
||||
const manager = new TmuxSessionManager(ctx, config)
|
||||
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
|
||||
|
||||
//#then
|
||||
expect(manager).toBeDefined()
|
||||
@@ -188,7 +198,7 @@ describe('TmuxSessionManager', () => {
|
||||
}
|
||||
|
||||
//#when
|
||||
const manager = new TmuxSessionManager(ctx, config)
|
||||
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
|
||||
|
||||
//#then
|
||||
expect(manager).toBeDefined()
|
||||
@@ -210,7 +220,7 @@ describe('TmuxSessionManager', () => {
|
||||
main_pane_min_width: 80,
|
||||
agent_pane_min_width: 40,
|
||||
}
|
||||
const manager = new TmuxSessionManager(ctx, config)
|
||||
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
|
||||
const event = createSessionCreatedEvent(
|
||||
'ses_child',
|
||||
'ses_parent',
|
||||
@@ -271,7 +281,7 @@ describe('TmuxSessionManager', () => {
|
||||
main_pane_min_width: 80,
|
||||
agent_pane_min_width: 40,
|
||||
}
|
||||
const manager = new TmuxSessionManager(ctx, config)
|
||||
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
|
||||
|
||||
//#when - first agent
|
||||
await manager.onSessionCreated(
|
||||
@@ -305,7 +315,7 @@ describe('TmuxSessionManager', () => {
|
||||
main_pane_min_width: 80,
|
||||
agent_pane_min_width: 40,
|
||||
}
|
||||
const manager = new TmuxSessionManager(ctx, config)
|
||||
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
|
||||
const event = createSessionCreatedEvent('ses_root', undefined, 'Root Session')
|
||||
|
||||
//#when
|
||||
@@ -327,7 +337,7 @@ describe('TmuxSessionManager', () => {
|
||||
main_pane_min_width: 80,
|
||||
agent_pane_min_width: 40,
|
||||
}
|
||||
const manager = new TmuxSessionManager(ctx, config)
|
||||
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
|
||||
const event = createSessionCreatedEvent(
|
||||
'ses_child',
|
||||
'ses_parent',
|
||||
@@ -353,7 +363,7 @@ describe('TmuxSessionManager', () => {
|
||||
main_pane_min_width: 80,
|
||||
agent_pane_min_width: 40,
|
||||
}
|
||||
const manager = new TmuxSessionManager(ctx, config)
|
||||
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
|
||||
const event = {
|
||||
type: 'session.deleted',
|
||||
properties: {
|
||||
@@ -398,7 +408,7 @@ describe('TmuxSessionManager', () => {
|
||||
main_pane_min_width: 120,
|
||||
agent_pane_min_width: 40,
|
||||
}
|
||||
const manager = new TmuxSessionManager(ctx, config)
|
||||
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
|
||||
|
||||
//#when
|
||||
await manager.onSessionCreated(
|
||||
@@ -450,7 +460,7 @@ describe('TmuxSessionManager', () => {
|
||||
main_pane_min_width: 80,
|
||||
agent_pane_min_width: 40,
|
||||
}
|
||||
const manager = new TmuxSessionManager(ctx, config)
|
||||
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
|
||||
|
||||
await manager.onSessionCreated(
|
||||
createSessionCreatedEvent(
|
||||
@@ -487,7 +497,7 @@ describe('TmuxSessionManager', () => {
|
||||
main_pane_min_width: 80,
|
||||
agent_pane_min_width: 40,
|
||||
}
|
||||
const manager = new TmuxSessionManager(ctx, config)
|
||||
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
|
||||
|
||||
//#when
|
||||
await manager.onSessionDeleted({ sessionID: 'ses_unknown' })
|
||||
@@ -521,7 +531,7 @@ describe('TmuxSessionManager', () => {
|
||||
main_pane_min_width: 80,
|
||||
agent_pane_min_width: 40,
|
||||
}
|
||||
const manager = new TmuxSessionManager(ctx, config)
|
||||
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
|
||||
|
||||
await manager.onSessionCreated(
|
||||
createSessionCreatedEvent('ses_1', 'ses_parent', 'Task 1')
|
||||
|
||||
@@ -2,8 +2,8 @@ import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { TmuxConfig } from "../../config/schema"
|
||||
import type { TrackedSession, CapacityConfig } from "./types"
|
||||
import {
|
||||
isInsideTmux,
|
||||
getCurrentPaneId,
|
||||
isInsideTmux as defaultIsInsideTmux,
|
||||
getCurrentPaneId as defaultGetCurrentPaneId,
|
||||
POLL_INTERVAL_BACKGROUND_MS,
|
||||
SESSION_MISSING_GRACE_MS,
|
||||
SESSION_READY_POLL_INTERVAL_MS,
|
||||
@@ -21,6 +21,16 @@ interface SessionCreatedEvent {
|
||||
properties?: { info?: { id?: string; parentID?: string; title?: string } }
|
||||
}
|
||||
|
||||
export interface TmuxUtilDeps {
|
||||
isInsideTmux: () => boolean
|
||||
getCurrentPaneId: () => string | undefined
|
||||
}
|
||||
|
||||
const defaultTmuxDeps: TmuxUtilDeps = {
|
||||
isInsideTmux: defaultIsInsideTmux,
|
||||
getCurrentPaneId: defaultGetCurrentPaneId,
|
||||
}
|
||||
|
||||
const SESSION_TIMEOUT_MS = 10 * 60 * 1000
|
||||
|
||||
/**
|
||||
@@ -43,13 +53,15 @@ export class TmuxSessionManager {
|
||||
private sessions = new Map<string, TrackedSession>()
|
||||
private pendingSessions = new Set<string>()
|
||||
private pollInterval?: ReturnType<typeof setInterval>
|
||||
private deps: TmuxUtilDeps
|
||||
|
||||
constructor(ctx: PluginInput, tmuxConfig: TmuxConfig) {
|
||||
constructor(ctx: PluginInput, tmuxConfig: TmuxConfig, deps: TmuxUtilDeps = defaultTmuxDeps) {
|
||||
this.client = ctx.client
|
||||
this.tmuxConfig = tmuxConfig
|
||||
this.deps = deps
|
||||
const defaultPort = process.env.OPENCODE_PORT ?? "4096"
|
||||
this.serverUrl = ctx.serverUrl?.toString() ?? `http://localhost:${defaultPort}`
|
||||
this.sourcePaneId = getCurrentPaneId()
|
||||
this.sourcePaneId = deps.getCurrentPaneId()
|
||||
|
||||
log("[tmux-session-manager] initialized", {
|
||||
configEnabled: this.tmuxConfig.enabled,
|
||||
@@ -60,7 +72,7 @@ export class TmuxSessionManager {
|
||||
}
|
||||
|
||||
private isEnabled(): boolean {
|
||||
return this.tmuxConfig.enabled && isInsideTmux()
|
||||
return this.tmuxConfig.enabled && this.deps.isInsideTmux()
|
||||
}
|
||||
|
||||
private getCapacityConfig(): CapacityConfig {
|
||||
@@ -113,7 +125,7 @@ export class TmuxSessionManager {
|
||||
log("[tmux-session-manager] onSessionCreated called", {
|
||||
enabled,
|
||||
tmuxConfigEnabled: this.tmuxConfig.enabled,
|
||||
isInsideTmux: isInsideTmux(),
|
||||
isInsideTmux: this.deps.isInsideTmux(),
|
||||
eventType: event.type,
|
||||
infoId: event.properties?.info?.id,
|
||||
infoParentID: event.properties?.info?.parentID,
|
||||
|
||||
@@ -66,6 +66,20 @@ describe("atlas hook", () => {
|
||||
})
|
||||
|
||||
describe("tool.execute.after handler", () => {
|
||||
test("should handle undefined output gracefully (issue #1035)", async () => {
|
||||
// #given - hook and undefined output (e.g., from /review command)
|
||||
const hook = createAtlasHook(createMockPluginInput())
|
||||
|
||||
// #when - calling with undefined output
|
||||
const result = await hook["tool.execute.after"](
|
||||
{ tool: "delegate_task", sessionID: "session-123" },
|
||||
undefined as unknown as { title: string; output: string; metadata: Record<string, unknown> }
|
||||
)
|
||||
|
||||
// #then - returns undefined without throwing
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
test("should ignore non-delegate_task tools", async () => {
|
||||
// #given - hook and non-delegate_task tool
|
||||
const hook = createAtlasHook(createMockPluginInput())
|
||||
@@ -396,9 +410,9 @@ describe("atlas hook", () => {
|
||||
)
|
||||
|
||||
// #then
|
||||
expect(output.output).toContain("DELEGATION REQUIRED")
|
||||
expect(output.output).toContain("ORCHESTRATOR, not an IMPLEMENTER")
|
||||
expect(output.output).toContain("delegate_task")
|
||||
expect(output.output).toContain("delegate_task")
|
||||
})
|
||||
|
||||
test("should append delegation reminder when orchestrator edits outside .sisyphus/", async () => {
|
||||
@@ -417,7 +431,7 @@ describe("atlas hook", () => {
|
||||
)
|
||||
|
||||
// #then
|
||||
expect(output.output).toContain("DELEGATION REQUIRED")
|
||||
expect(output.output).toContain("ORCHESTRATOR, not an IMPLEMENTER")
|
||||
})
|
||||
|
||||
test("should NOT append reminder when orchestrator writes inside .sisyphus/", async () => {
|
||||
@@ -438,7 +452,7 @@ describe("atlas hook", () => {
|
||||
|
||||
// #then
|
||||
expect(output.output).toBe(originalOutput)
|
||||
expect(output.output).not.toContain("DELEGATION REQUIRED")
|
||||
expect(output.output).not.toContain("ORCHESTRATOR, not an IMPLEMENTER")
|
||||
})
|
||||
|
||||
test("should NOT append reminder when non-orchestrator writes outside .sisyphus/", async () => {
|
||||
@@ -462,7 +476,7 @@ describe("atlas hook", () => {
|
||||
|
||||
// #then
|
||||
expect(output.output).toBe(originalOutput)
|
||||
expect(output.output).not.toContain("DELEGATION REQUIRED")
|
||||
expect(output.output).not.toContain("ORCHESTRATOR, not an IMPLEMENTER")
|
||||
|
||||
cleanupMessageStorage(nonOrchestratorSession)
|
||||
})
|
||||
@@ -526,7 +540,7 @@ describe("atlas hook", () => {
|
||||
|
||||
// #then
|
||||
expect(output.output).toBe(originalOutput)
|
||||
expect(output.output).not.toContain("DELEGATION REQUIRED")
|
||||
expect(output.output).not.toContain("ORCHESTRATOR, not an IMPLEMENTER")
|
||||
})
|
||||
|
||||
test("should NOT append reminder when orchestrator writes inside .sisyphus with mixed separators", async () => {
|
||||
@@ -547,7 +561,7 @@ describe("atlas hook", () => {
|
||||
|
||||
// #then
|
||||
expect(output.output).toBe(originalOutput)
|
||||
expect(output.output).not.toContain("DELEGATION REQUIRED")
|
||||
expect(output.output).not.toContain("ORCHESTRATOR, not an IMPLEMENTER")
|
||||
})
|
||||
|
||||
test("should NOT append reminder for absolute Windows path inside .sisyphus\\", async () => {
|
||||
@@ -568,7 +582,7 @@ describe("atlas hook", () => {
|
||||
|
||||
// #then
|
||||
expect(output.output).toBe(originalOutput)
|
||||
expect(output.output).not.toContain("DELEGATION REQUIRED")
|
||||
expect(output.output).not.toContain("ORCHESTRATOR, not an IMPLEMENTER")
|
||||
})
|
||||
|
||||
test("should append reminder for Windows path outside .sisyphus\\", async () => {
|
||||
@@ -587,7 +601,7 @@ describe("atlas hook", () => {
|
||||
)
|
||||
|
||||
// #then
|
||||
expect(output.output).toContain("DELEGATION REQUIRED")
|
||||
expect(output.output).toContain("ORCHESTRATOR, not an IMPLEMENTER")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -636,7 +650,7 @@ describe("atlas hook", () => {
|
||||
expect(mockInput._promptMock).toHaveBeenCalled()
|
||||
const callArgs = mockInput._promptMock.mock.calls[0][0]
|
||||
expect(callArgs.path.id).toBe(MAIN_SESSION_ID)
|
||||
expect(callArgs.body.parts[0].text).toContain("BOULDER CONTINUATION")
|
||||
expect(callArgs.body.parts[0].text).toContain("incomplete tasks")
|
||||
expect(callArgs.body.parts[0].text).toContain("2 remaining")
|
||||
})
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user