mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
feat(daemon): add Kilo CLI (ACP) (#480)
* docs(readme): refresh contributors wall * docs(readme): refresh contributors wall * feat: kilo cli * fix: use default model option for kilo * chore: add agent_diff id unique test * chore: add deepseek to docs
This commit is contained in:
parent
6380c48a48
commit
ec7dafc007
4 changed files with 391 additions and 170 deletions
14
README.md
14
README.md
|
|
@ -1,6 +1,6 @@
|
|||
# Open Design
|
||||
|
||||
> **The open-source alternative to [Claude Design][cd].** Local-first, web-deployable, BYOK at every layer — **13 coding-agent CLIs** auto-detected on your `PATH` (Claude Code, Codex, Devin for Terminal, Cursor Agent, Gemini CLI, OpenCode, Qwen, GitHub Copilot CLI, Hermes, Kimi, Pi, Kiro, Mistral Vibe) become the design engine, driven by **31 composable Skills** and **72 brand-grade Design Systems**. No CLI? An OpenAI-compatible BYOK proxy is the same loop minus the spawn.
|
||||
> **The open-source alternative to [Claude Design][cd].** Local-first, web-deployable, BYOK at every layer — **15 coding-agent CLIs** auto-detected on your `PATH` (Claude Code, Codex, Devin for Terminal, Cursor Agent, Gemini CLI, OpenCode, Qwen, GitHub Copilot CLI, Hermes, Kimi, Pi, Kiro, Kilo, Mistral Vibe, DeepSeek TUI) become the design engine, driven by **31 composable Skills** and **72 brand-grade Design Systems**. No CLI? An OpenAI-compatible BYOK proxy is the same loop minus the spawn.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/banner.png" alt="Open Design — editorial cover: design with the agent on your laptop" width="100%" />
|
||||
|
|
@ -19,7 +19,7 @@
|
|||
<p align="center">
|
||||
<a href="https://github.com/nexu-io/open-design/releases/latest"><img alt="Latest release" src="https://img.shields.io/github/v/release/nexu-io/open-design?style=flat-square&color=blueviolet&label=release&include_prereleases" /></a>
|
||||
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/license-Apache%202.0-blue.svg?style=flat-square" /></a>
|
||||
<a href="#supported-coding-agents"><img alt="Agents" src="https://img.shields.io/badge/agents-12%20CLIs%20%2B%20BYOK%20proxy-black?style=flat-square" /></a>
|
||||
<a href="#supported-coding-agents"><img alt="Agents" src="https://img.shields.io/badge/agents-15%20CLIs%20%2B%20BYOK%20proxy-black?style=flat-square" /></a>
|
||||
<a href="#design-systems"><img alt="Design systems" src="https://img.shields.io/badge/design%20systems-72-orange?style=flat-square" /></a>
|
||||
<a href="#skills"><img alt="Skills" src="https://img.shields.io/badge/skills-31-teal?style=flat-square" /></a>
|
||||
<a href="QUICKSTART.md"><img alt="Quickstart" src="https://img.shields.io/badge/quickstart-3%20commands-green?style=flat-square" /></a>
|
||||
|
|
@ -50,7 +50,7 @@ OD stands on four open-source shoulders:
|
|||
|
||||
| | What you get |
|
||||
|---|---|
|
||||
| **Coding-agent CLIs (13)** | Claude Code · Codex CLI · Devin for Terminal · Cursor Agent · Gemini CLI · OpenCode · Qwen Code · GitHub Copilot CLI · Hermes (ACP) · Kimi CLI (ACP) · Pi (RPC) · Kiro CLI (ACP) · Mistral Vibe CLI (ACP) — auto-detected on `PATH`, swap with one click |
|
||||
| **Coding-agent CLIs (15)** | Claude Code · Codex CLI · Devin for Terminal · Cursor Agent · Gemini CLI · OpenCode · Qwen Code · GitHub Copilot CLI · Hermes (ACP) · Kimi CLI (ACP) · Pi (RPC) · Kiro CLI (ACP) · Kilo (ACP) · Mistral Vibe CLI (ACP) · DeepSeek TUI — auto-detected on `PATH`, swap with one click |
|
||||
| **BYOK fallback** | Protocol-specific API proxy at `/api/proxy/{anthropic,openai,azure,google}/stream` — paste `baseUrl` + `apiKey` + `model`, choose Anthropic / OpenAI / Azure OpenAI / Google Gemini, and the daemon normalizes SSE back to the same chat stream. Internal-IP/SSRF blocked at the daemon edge. |
|
||||
| **Design systems built-in** | **129** — 2 hand-authored starters + 70 product systems (Linear, Stripe, Vercel, Airbnb, Tesla, Notion, Anthropic, Apple, Cursor, Supabase, Figma, Xiaohongshu, …) from [`awesome-design-md`][acd2], plus 57 design skills from [`awesome-design-skills`][ads] added directly under `design-systems/` |
|
||||
| **Skills built-in** | **31** — 27 in `prototype` mode (web-prototype, saas-landing, dashboard, mobile-app, gamified-app, social-carousel, magazine-poster, dating-web, sprite-animation, motion-frames, critique, tweaks, wireframe-sketch, pm-spec, eng-runbook, finance-report, hr-onboarding, invoice, kanban-board, team-okrs, …) + 4 in `deck` mode (`guizang-ppt` · `simple-deck` · `replit-deck` · `weekly-update`). Grouped in the picker by `scenario`: design / marketing / operation / engineering / product / finance / hr / sale / personal. |
|
||||
|
|
@ -218,7 +218,7 @@ Adding a skill takes one folder. Read [`docs/skills-protocol.md`](docs/skills-pr
|
|||
|
||||
### 1 · We don't ship an agent. Yours is good enough.
|
||||
|
||||
The daemon scans your `PATH` for [`claude`](https://docs.anthropic.com/en/docs/claude-code), [`codex`](https://github.com/openai/codex), `devin`, [`cursor-agent`](https://www.cursor.com/cli), [`gemini`](https://github.com/google-gemini/gemini-cli), [`opencode`](https://opencode.ai/), [`qwen`](https://github.com/QwenLM/qwen-code), [`copilot`](https://github.com/features/copilot/cli), `hermes`, `kimi`, [`pi`](https://github.com/mariozechner/pi-ai), [`kiro-cli`](https://kiro.dev), and [`vibe-acp`](https://github.com/mistralai/mistral-vibe) on startup. Whichever ones it finds become candidate design engines — driven over stdio with one adapter per CLI, swappable from the model picker. Inspired by [`multica`](https://github.com/multica-ai/multica) and [`cc-switch`](https://github.com/farion1231/cc-switch). No CLI installed? The API mode is the same pipeline minus the spawn — choose Anthropic, OpenAI-compatible, Azure OpenAI, or Google Gemini and the daemon forwards normalized SSE chunks back, with loopback / link-local / RFC1918 destinations rejected at the edge.
|
||||
The daemon scans your `PATH` for [`claude`](https://docs.anthropic.com/en/docs/claude-code), [`codex`](https://github.com/openai/codex), `devin`, [`cursor-agent`](https://www.cursor.com/cli), [`gemini`](https://github.com/google-gemini/gemini-cli), [`opencode`](https://opencode.ai/), [`qwen`](https://github.com/QwenLM/qwen-code), [`copilot`](https://github.com/features/copilot/cli), `hermes`, `kimi`, [`pi`](https://github.com/mariozechner/pi-ai), [`kiro-cli`](https://kiro.dev), `kilo`, [`vibe-acp`](https://github.com/mistralai/mistral-vibe), and `deepseek` on startup. Whichever ones it finds become candidate design engines — driven over stdio with one adapter per CLI, swappable from the model picker. Inspired by [`multica`](https://github.com/multica-ai/multica) and [`cc-switch`](https://github.com/farion1231/cc-switch). No CLI installed? The API mode is the same pipeline minus the spawn — choose Anthropic, OpenAI-compatible, Azure OpenAI, or Google Gemini and the daemon forwards normalized SSE chunks back, with loopback / link-local / RFC1918 destinations rejected at the edge.
|
||||
|
||||
### 2 · Skills are files, not plugins.
|
||||
|
||||
|
|
@ -280,7 +280,7 @@ Every layer is composable. Every layer is a file you can edit. Read [`apps/web/s
|
|||
▼
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ claude · codex · devin (ACP) · gemini · opencode · cursor-agent │
|
||||
│ qwen · copilot · hermes (ACP) · kimi (ACP) · pi (RPC) · kiro (ACP) · vibe (ACP) │
|
||||
│ qwen · copilot · hermes (ACP) · kimi (ACP) · pi (RPC) · kiro (ACP) · kilo (ACP) · vibe (ACP) · deepseek │
|
||||
│ reads SKILL.md + DESIGN.md, writes artifacts to disk │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
|
@ -289,7 +289,7 @@ Every layer is composable. Every layer is a file you can edit. Read [`apps/web/s
|
|||
|---|---|
|
||||
| Frontend | Next.js 16 App Router + React 18 + TypeScript, Vercel-deployable |
|
||||
| Daemon | Node 24 · Express · SSE streaming · `better-sqlite3`; tables: `projects` · `conversations` · `messages` · `tabs` · `templates` |
|
||||
| Agent transport | `child_process.spawn`; typed-event parsers for `claude-stream-json` (Claude Code), `copilot-stream-json` (Copilot), `json-event-stream` per-CLI parsers (Codex / Gemini / OpenCode / Cursor Agent), `acp-json-rpc` (Devin / Hermes / Kimi / Kiro / Mistral Vibe via Agent Client Protocol), `pi-rpc` (Pi via stdio JSON-RPC), `plain` (Qwen Code) |
|
||||
| Agent transport | `child_process.spawn`; typed-event parsers for `claude-stream-json` (Claude Code), `copilot-stream-json` (Copilot), `json-event-stream` per-CLI parsers (Codex / Gemini / OpenCode / Cursor Agent), `acp-json-rpc` (Devin / Hermes / Kimi / Kiro / Kilo / Mistral Vibe via Agent Client Protocol), `pi-rpc` (Pi via stdio JSON-RPC), `plain` (Qwen Code / DeepSeek TUI) |
|
||||
| BYOK proxy | `POST /api/proxy/{anthropic,openai,azure,google}/stream` → provider-specific upstream APIs, normalized `delta/end/error` SSE; rejects loopback / link-local / RFC1918 hosts at the daemon edge |
|
||||
| Storage | Plain files in `.od/projects/<id>/` + SQLite at `.od/app.sqlite` + credentials at `.od/media-config.json` (gitignored, auto-created). `OD_DATA_DIR=<dir>` relocates all daemon data (used for test isolation and read-only-install setups); `OD_MEDIA_CONFIG_DIR=<dir>` further narrows the override to just `media-config.json` for setups that want to keep API keys outside the data dir |
|
||||
| Preview | Sandboxed iframe via `srcdoc` + per-skill `<artifact>` parser ([`apps/web/src/artifacts/parser.ts`](apps/web/src/artifacts/parser.ts)) |
|
||||
|
|
@ -646,7 +646,9 @@ Auto-detected from `PATH` on daemon boot. No config required. Streaming dispatch
|
|||
| [Hermes](https://github.com/eqlabs/hermes) | `hermes` | `acp-json-rpc` (Agent Client Protocol) | `hermes acp --accept-hooks` |
|
||||
| Kimi CLI | `kimi` | `acp-json-rpc` | `kimi acp` |
|
||||
| [Kiro CLI](https://kiro.dev) | `kiro-cli` | `acp-json-rpc` | `kiro-cli acp` |
|
||||
| Kilo | `kilo` | `acp-json-rpc` | `kilo acp` |
|
||||
| [Mistral Vibe CLI](https://github.com/mistralai/mistral-vibe) | `vibe-acp` | `acp-json-rpc` | `vibe-acp` |
|
||||
| DeepSeek TUI | `deepseek` | `plain` (raw stdout chunks) | `deepseek exec --auto [--model …] <prompt>` (prompt as positional arg) |
|
||||
| [Pi](https://github.com/mariozechner/pi-ai) | `pi` | `pi-rpc` (stdio JSON-RPC) | `pi --mode rpc --no-session [--model …] [--thinking …]` (prompt sent as RPC `prompt` command) |
|
||||
| **Multi-provider BYOK** | n/a | SSE normalization | `POST /api/proxy/{provider}/stream` → Anthropic / OpenAI-compatible / Azure OpenAI / Gemini; SSRF-guarded against loopback / link-local / RFC1918 |
|
||||
|
||||
|
|
|
|||
|
|
@ -156,12 +156,7 @@ export const AGENT_DEFS = [
|
|||
// cursor/qwen entries below.
|
||||
buildArgs: (_prompt, _imagePaths, extraAllowedDirs = [], options = {}) => {
|
||||
const caps = agentCapabilities.get('claude') || {};
|
||||
const args = [
|
||||
'-p',
|
||||
'--output-format',
|
||||
'stream-json',
|
||||
'--verbose',
|
||||
];
|
||||
const args = ['-p', '--output-format', 'stream-json', '--verbose'];
|
||||
// `--include-partial-messages` lands richer streaming events but only
|
||||
// exists in newer Claude Code builds. Older installs reject it with
|
||||
// "unknown option" and exit 1, killing the chat. Gate on the probe.
|
||||
|
|
@ -213,7 +208,13 @@ export const AGENT_DEFS = [
|
|||
// `error: unexpected argument '-' found` and the agent exits with
|
||||
// code 2 before any prompt is read (see issue #237). The pipe alone
|
||||
// is sufficient for stdin delivery.
|
||||
buildArgs: (_prompt, _imagePaths, _extra, options = {}, runtimeContext = {}) => {
|
||||
buildArgs: (
|
||||
_prompt,
|
||||
_imagePaths,
|
||||
_extra,
|
||||
options = {},
|
||||
runtimeContext = {},
|
||||
) => {
|
||||
const args = [
|
||||
'exec',
|
||||
'--json',
|
||||
|
|
@ -252,7 +253,13 @@ export const AGENT_DEFS = [
|
|||
fetchModels: async (resolvedBin) =>
|
||||
detectAcpModels({
|
||||
bin: resolvedBin,
|
||||
args: ['--permission-mode', 'dangerous', '--respect-workspace-trust', 'false', 'acp'],
|
||||
args: [
|
||||
'--permission-mode',
|
||||
'dangerous',
|
||||
'--respect-workspace-trust',
|
||||
'false',
|
||||
'acp',
|
||||
],
|
||||
timeoutMs: 15_000,
|
||||
defaultModelOption: DEFAULT_MODEL_OPTION,
|
||||
}),
|
||||
|
|
@ -270,7 +277,13 @@ export const AGENT_DEFS = [
|
|||
{ id: 'gpt', label: 'gpt' },
|
||||
{ id: 'gemini', label: 'gemini' },
|
||||
],
|
||||
buildArgs: () => ['--permission-mode', 'dangerous', '--respect-workspace-trust', 'false', 'acp'],
|
||||
buildArgs: () => [
|
||||
'--permission-mode',
|
||||
'dangerous',
|
||||
'--respect-workspace-trust',
|
||||
'false',
|
||||
'acp',
|
||||
],
|
||||
streamFormat: 'acp-json-rpc',
|
||||
},
|
||||
{
|
||||
|
|
@ -315,14 +328,22 @@ export const AGENT_DEFS = [
|
|||
},
|
||||
fallbackModels: [
|
||||
DEFAULT_MODEL_OPTION,
|
||||
{ id: 'anthropic/claude-sonnet-4-5', label: 'anthropic/claude-sonnet-4-5' },
|
||||
{
|
||||
id: 'anthropic/claude-sonnet-4-5',
|
||||
label: 'anthropic/claude-sonnet-4-5',
|
||||
},
|
||||
{ id: 'openai/gpt-5', label: 'openai/gpt-5' },
|
||||
{ id: 'google/gemini-2.5-pro', label: 'google/gemini-2.5-pro' },
|
||||
],
|
||||
// Prompt delivered via stdin (`opencode run -`) to avoid Windows
|
||||
// `spawn ENAMETOOLONG` while preserving OpenCode's structured stream.
|
||||
buildArgs: (_prompt, _imagePaths, _extra, options = {}) => {
|
||||
const args = ['run', '--format', 'json', '--dangerously-skip-permissions'];
|
||||
const args = [
|
||||
'run',
|
||||
'--format',
|
||||
'json',
|
||||
'--dangerously-skip-permissions',
|
||||
];
|
||||
if (options.model && options.model !== 'default') {
|
||||
args.push('--model', options.model);
|
||||
}
|
||||
|
|
@ -406,9 +427,22 @@ export const AGENT_DEFS = [
|
|||
// Passing it makes the CLI treat the dash as the literal user prompt,
|
||||
// which then surfaces as "your message only contains '-'". Keep stdin
|
||||
// piped for prompt delivery, but do not append a fake prompt arg.
|
||||
buildArgs: (_prompt, _imagePaths, _extra, options = {}, runtimeContext = {}) => {
|
||||
buildArgs: (
|
||||
_prompt,
|
||||
_imagePaths,
|
||||
_extra,
|
||||
options = {},
|
||||
runtimeContext = {},
|
||||
) => {
|
||||
const args = [];
|
||||
args.push('--print', '--output-format', 'stream-json', '--stream-partial-output', '--force', '--trust');
|
||||
args.push(
|
||||
'--print',
|
||||
'--output-format',
|
||||
'stream-json',
|
||||
'--stream-partial-output',
|
||||
'--force',
|
||||
'--trust',
|
||||
);
|
||||
if (runtimeContext.cwd) {
|
||||
args.push('--workspace', runtimeContext.cwd);
|
||||
}
|
||||
|
|
@ -523,7 +557,10 @@ export const AGENT_DEFS = [
|
|||
// `pi --list-models` fails or times out.
|
||||
fallbackModels: [
|
||||
DEFAULT_MODEL_OPTION,
|
||||
{ id: 'anthropic/claude-sonnet-4-5', label: 'Claude Sonnet 4.5 (anthropic)' },
|
||||
{
|
||||
id: 'anthropic/claude-sonnet-4-5',
|
||||
label: 'Claude Sonnet 4.5 (anthropic)',
|
||||
},
|
||||
{ id: 'anthropic/claude-opus-4-5', label: 'Claude Opus 4.5 (anthropic)' },
|
||||
{ id: 'openai/gpt-5', label: 'GPT-5 (openai)' },
|
||||
{ id: 'openai/o4-mini', label: 'o4-mini (openai)' },
|
||||
|
|
@ -543,7 +580,13 @@ export const AGENT_DEFS = [
|
|||
// pi's RPC mode drives the entire conversation over stdio JSON-RPC.
|
||||
// The daemon sends a `prompt` command and pi streams back typed events.
|
||||
// No prompt in argv — avoids ENAMETOOLONG and keeps the protocol clean.
|
||||
buildArgs: (_prompt, _imagePaths, _extra, options = {}, runtimeContext = {}) => {
|
||||
buildArgs: (
|
||||
_prompt,
|
||||
_imagePaths,
|
||||
_extra,
|
||||
options = {},
|
||||
runtimeContext = {},
|
||||
) => {
|
||||
const args = ['--mode', 'rpc', '--no-session'];
|
||||
if (options.model && options.model !== 'default') {
|
||||
// pi --model accepts patterns ("sonnet", "anthropic/claude-sonnet-4-5",
|
||||
|
|
@ -574,9 +617,23 @@ export const AGENT_DEFS = [
|
|||
timeoutMs: 15_000,
|
||||
defaultModelOption: DEFAULT_MODEL_OPTION,
|
||||
}),
|
||||
fallbackModels: [
|
||||
DEFAULT_MODEL_OPTION,
|
||||
],
|
||||
fallbackModels: [DEFAULT_MODEL_OPTION],
|
||||
buildArgs: () => ['acp'],
|
||||
streamFormat: 'acp-json-rpc',
|
||||
},
|
||||
{
|
||||
id: 'kilo',
|
||||
name: 'Kilo',
|
||||
bin: 'kilo',
|
||||
versionArgs: ['--version'],
|
||||
fetchModels: async (resolvedBin) =>
|
||||
detectAcpModels({
|
||||
bin: resolvedBin,
|
||||
args: ['acp'],
|
||||
timeoutMs: 15_000,
|
||||
defaultModelOption: DEFAULT_MODEL_OPTION,
|
||||
}),
|
||||
fallbackModels: [DEFAULT_MODEL_OPTION],
|
||||
buildArgs: () => ['acp'],
|
||||
streamFormat: 'acp-json-rpc',
|
||||
},
|
||||
|
|
@ -592,9 +649,7 @@ export const AGENT_DEFS = [
|
|||
timeoutMs: 15_000,
|
||||
defaultModelOption: DEFAULT_MODEL_OPTION,
|
||||
}),
|
||||
fallbackModels: [
|
||||
DEFAULT_MODEL_OPTION,
|
||||
],
|
||||
fallbackModels: [DEFAULT_MODEL_OPTION],
|
||||
buildArgs: () => [],
|
||||
streamFormat: 'acp-json-rpc',
|
||||
},
|
||||
|
|
@ -694,10 +749,18 @@ function userToolchainDirs() {
|
|||
path.join(home, '.asdf', 'shims'),
|
||||
path.join(home, 'Library', 'pnpm'),
|
||||
path.join(home, '.cargo', 'bin'),
|
||||
...(process.platform !== 'win32' && !homeOverride ? ['/opt/homebrew/bin', '/usr/local/bin'] : []),
|
||||
...existingDirsUnder(path.join(home, '.local', 'share', 'mise', 'installs', 'node'), ['bin']),
|
||||
...(process.platform !== 'win32' && !homeOverride
|
||||
? ['/opt/homebrew/bin', '/usr/local/bin']
|
||||
: []),
|
||||
...existingDirsUnder(
|
||||
path.join(home, '.local', 'share', 'mise', 'installs', 'node'),
|
||||
['bin'],
|
||||
),
|
||||
...existingDirsUnder(path.join(home, '.nvm', 'versions', 'node'), ['bin']),
|
||||
...existingDirsUnder(path.join(home, '.local', 'share', 'fnm', 'node-versions'), ['installation', 'bin']),
|
||||
...existingDirsUnder(
|
||||
path.join(home, '.local', 'share', 'fnm', 'node-versions'),
|
||||
['installation', 'bin'],
|
||||
),
|
||||
];
|
||||
return cachedToolchainDirs;
|
||||
}
|
||||
|
|
@ -741,7 +804,10 @@ export function resolveOnPath(bin) {
|
|||
// when no candidate is on PATH.
|
||||
export function resolveAgentExecutable(def) {
|
||||
if (!def?.bin) return null;
|
||||
const candidates = [def.bin, ...(Array.isArray(def.fallbackBins) ? def.fallbackBins : [])];
|
||||
const candidates = [
|
||||
def.bin,
|
||||
...(Array.isArray(def.fallbackBins) ? def.fallbackBins : []),
|
||||
];
|
||||
for (const bin of candidates) {
|
||||
const resolved = resolveOnPath(bin);
|
||||
if (resolved) return resolved;
|
||||
|
|
@ -790,7 +856,9 @@ async function probe(def) {
|
|||
}
|
||||
let version = null;
|
||||
try {
|
||||
const { stdout } = await execFileP(resolved, def.versionArgs, { timeout: 3000 });
|
||||
const { stdout } = await execFileP(resolved, def.versionArgs, {
|
||||
timeout: 3000,
|
||||
});
|
||||
version = stdout.trim().split('\n')[0];
|
||||
} catch {
|
||||
// binary exists but --version failed; still mark available
|
||||
|
|
@ -845,7 +913,6 @@ function stripFns(def) {
|
|||
return rest;
|
||||
}
|
||||
|
||||
|
||||
export async function detectAgents() {
|
||||
const results = await Promise.all(AGENT_DEFS.map(probe));
|
||||
// Refresh the validation cache from whatever we just surfaced to the UI
|
||||
|
|
@ -872,7 +939,10 @@ export function getAgentDef(id) {
|
|||
// without spinning up the HTTP server or a real spawn.
|
||||
export function checkPromptArgvBudget(def, composed) {
|
||||
if (!def || typeof def.maxPromptArgBytes !== 'number') return null;
|
||||
const bytes = Buffer.byteLength(typeof composed === 'string' ? composed : '', 'utf8');
|
||||
const bytes = Buffer.byteLength(
|
||||
typeof composed === 'string' ? composed : '',
|
||||
'utf8',
|
||||
);
|
||||
if (bytes <= def.maxPromptArgBytes) return null;
|
||||
return {
|
||||
code: 'AGENT_PROMPT_TOO_LARGE',
|
||||
|
|
@ -975,14 +1045,16 @@ const WINDOWS_CREATE_PROCESS_HEADROOM = 256;
|
|||
// would run on Windows.
|
||||
export function checkWindowsCmdShimCommandLineBudget(def, resolvedBin, args) {
|
||||
if (!def || typeof def.maxPromptArgBytes !== 'number') return null;
|
||||
if (typeof resolvedBin !== 'string' || !/\.(bat|cmd)$/i.test(resolvedBin)) return null;
|
||||
if (typeof resolvedBin !== 'string' || !/\.(bat|cmd)$/i.test(resolvedBin))
|
||||
return null;
|
||||
const argList = Array.isArray(args) ? args : [];
|
||||
const inner = [resolvedBin, ...argList].map(quoteForWindowsCmdShim).join(' ');
|
||||
// `cmd.exe /d /s /c "<inner>"` — same shape as buildCmdShimInvocation
|
||||
// in packages/platform; the leading 'cmd.exe ' + '/d /s /c ' framing
|
||||
// plus the two outer quote chars rounds out the full command line.
|
||||
const commandLineLength = 'cmd.exe /d /s /c '.length + inner.length + 2;
|
||||
const safeLimit = WINDOWS_CREATE_PROCESS_LIMIT - WINDOWS_CREATE_PROCESS_HEADROOM;
|
||||
const safeLimit =
|
||||
WINDOWS_CREATE_PROCESS_LIMIT - WINDOWS_CREATE_PROCESS_HEADROOM;
|
||||
if (commandLineLength <= safeLimit) return null;
|
||||
return {
|
||||
code: 'AGENT_PROMPT_TOO_LARGE',
|
||||
|
|
@ -1044,13 +1116,16 @@ export function checkWindowsDirectExeCommandLineBudget(def, resolvedBin, args) {
|
|||
// CreateProcess. On POSIX hosts, `execvp` accepts each argv entry as a
|
||||
// separate buffer — there's no command-line concatenation step that
|
||||
// could expand past a kernel cap, so we have nothing to guard.
|
||||
if (process.platform !== 'win32' && !looksLikeWindowsPath(resolvedBin)) return null;
|
||||
if (process.platform !== 'win32' && !looksLikeWindowsPath(resolvedBin))
|
||||
return null;
|
||||
const argList = Array.isArray(args) ? args : [];
|
||||
// `[command, ...args].map(quote).join(' ')` is the exact shape libuv
|
||||
// builds before handing it to CreateProcess.
|
||||
const commandLineLength =
|
||||
[resolvedBin, ...argList].map(quoteForWindowsDirectExe).join(' ').length;
|
||||
const safeLimit = WINDOWS_CREATE_PROCESS_LIMIT - WINDOWS_CREATE_PROCESS_HEADROOM;
|
||||
const commandLineLength = [resolvedBin, ...argList]
|
||||
.map(quoteForWindowsDirectExe)
|
||||
.join(' ').length;
|
||||
const safeLimit =
|
||||
WINDOWS_CREATE_PROCESS_LIMIT - WINDOWS_CREATE_PROCESS_HEADROOM;
|
||||
if (commandLineLength <= safeLimit) return null;
|
||||
return {
|
||||
code: 'AGENT_PROMPT_TOO_LARGE',
|
||||
|
|
@ -1106,7 +1181,9 @@ export function rememberLiveModels(agentId, models) {
|
|||
if (!Array.isArray(models)) return;
|
||||
liveModelCache.set(
|
||||
agentId,
|
||||
new Set(models.map((m) => m && m.id).filter((id) => typeof id === 'string')),
|
||||
new Set(
|
||||
models.map((m) => m && m.id).filter((id) => typeof id === 'string'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,13 @@
|
|||
// @ts-nocheck
|
||||
import { afterEach, test } from 'vitest';
|
||||
import assert from 'node:assert/strict';
|
||||
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import {
|
||||
chmodSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
|
|
@ -17,6 +23,7 @@ const codex = AGENT_DEFS.find((agent) => agent.id === 'codex');
|
|||
const copilot = AGENT_DEFS.find((agent) => agent.id === 'copilot');
|
||||
const cursorAgent = AGENT_DEFS.find((agent) => agent.id === 'cursor-agent');
|
||||
const kiro = AGENT_DEFS.find((agent) => agent.id === 'kiro');
|
||||
const kilo = AGENT_DEFS.find((agent) => agent.id === 'kilo');
|
||||
const vibe = AGENT_DEFS.find((agent) => agent.id === 'vibe');
|
||||
const claude = AGENT_DEFS.find((agent) => agent.id === 'claude');
|
||||
const devin = AGENT_DEFS.find((agent) => agent.id === 'devin');
|
||||
|
|
@ -46,6 +53,12 @@ afterEach(() => {
|
|||
}
|
||||
});
|
||||
|
||||
test('AGENT_DEFS ids are unique', () => {
|
||||
const ids = AGENT_DEFS.map((a) => a.id);
|
||||
const dupes = ids.filter((id, i) => ids.indexOf(id) !== i);
|
||||
assert.deepEqual(dupes, [], `duplicate agent ids: ${JSON.stringify(dupes)}`);
|
||||
});
|
||||
|
||||
test('codex args disable plugins when OD_CODEX_DISABLE_PLUGINS is 1', () => {
|
||||
process.env.OD_CODEX_DISABLE_PLUGINS = '1';
|
||||
|
||||
|
|
@ -107,19 +120,43 @@ test('codex args do not include the literal `-` stdin sentinel (regression of #2
|
|||
const baseArgs = codex.buildArgs('', [], [], {}, { cwd: '/tmp/od-project' });
|
||||
assert.equal(baseArgs.includes('-'), false);
|
||||
|
||||
const withModel = codex.buildArgs('', [], [], { model: 'gpt-5-codex' }, { cwd: '/tmp/od-project' });
|
||||
const withModel = codex.buildArgs(
|
||||
'',
|
||||
[],
|
||||
[],
|
||||
{ model: 'gpt-5-codex' },
|
||||
{ cwd: '/tmp/od-project' },
|
||||
);
|
||||
assert.equal(withModel.includes('-'), false);
|
||||
|
||||
const withReasoning = codex.buildArgs('', [], [], { reasoning: 'high' }, { cwd: '/tmp/od-project' });
|
||||
const withReasoning = codex.buildArgs(
|
||||
'',
|
||||
[],
|
||||
[],
|
||||
{ reasoning: 'high' },
|
||||
{ cwd: '/tmp/od-project' },
|
||||
);
|
||||
assert.equal(withReasoning.includes('-'), false);
|
||||
|
||||
process.env.OD_CODEX_DISABLE_PLUGINS = '1';
|
||||
const withDisablePlugins = codex.buildArgs('', [], [], {}, { cwd: '/tmp/od-project' });
|
||||
const withDisablePlugins = codex.buildArgs(
|
||||
'',
|
||||
[],
|
||||
[],
|
||||
{},
|
||||
{ cwd: '/tmp/od-project' },
|
||||
);
|
||||
assert.equal(withDisablePlugins.includes('-'), false);
|
||||
});
|
||||
|
||||
test('cursor-agent args deliver prompts via stdin without passing a literal dash prompt', () => {
|
||||
const args = cursorAgent.buildArgs('', [], [], {}, { cwd: '/tmp/od-project' });
|
||||
const args = cursorAgent.buildArgs(
|
||||
'',
|
||||
[],
|
||||
[],
|
||||
{},
|
||||
{ cwd: '/tmp/od-project' },
|
||||
);
|
||||
|
||||
assert.deepEqual(args, [
|
||||
'--print',
|
||||
|
|
@ -179,7 +216,12 @@ test('copilot args keep `-p <prompt>` at the front when model and extra dirs are
|
|||
|
||||
test('copilot drops empty / non-string entries from extraAllowedDirs without breaking the `-p <prompt>` lead', () => {
|
||||
const prompt = 'design a landing page';
|
||||
const args = copilot.buildArgs(prompt, [], ['', null, '/tmp/od-skills', undefined], {});
|
||||
const args = copilot.buildArgs(
|
||||
prompt,
|
||||
[],
|
||||
['', null, '/tmp/od-skills', undefined],
|
||||
{},
|
||||
);
|
||||
assert.equal(args[0], '-p');
|
||||
assert.equal(args[1], prompt);
|
||||
// Only the one valid path survives.
|
||||
|
|
@ -231,13 +273,31 @@ test('gemini args preserve custom model selection', () => {
|
|||
test('kiro fetchModels falls back to fallbackModels when detection fails', async () => {
|
||||
// fetchModels rejects when the binary doesn't exist; the daemon's
|
||||
// probe() catches this and uses fallbackModels instead.
|
||||
const result = await kiro.fetchModels('/nonexistent/kiro-cli').catch(() => null);
|
||||
const result = await kiro
|
||||
.fetchModels('/nonexistent/kiro-cli')
|
||||
.catch(() => null);
|
||||
|
||||
assert.equal(result, null);
|
||||
assert.ok(Array.isArray(kiro.fallbackModels));
|
||||
assert.equal(kiro.fallbackModels[0].id, 'default');
|
||||
});
|
||||
|
||||
test('kilo args use acp subcommand for json-rpc streaming', () => {
|
||||
const args = kilo.buildArgs('', [], [], {});
|
||||
|
||||
assert.deepEqual(args, ['acp']);
|
||||
assert.equal(kilo.streamFormat, 'acp-json-rpc');
|
||||
});
|
||||
|
||||
test('kilo fetchModels falls back to fallbackModels when detection fails', async () => {
|
||||
const result = await kilo.fetchModels('/nonexistent/kilo').catch(() => null);
|
||||
|
||||
assert.equal(result, null);
|
||||
assert.ok(Array.isArray(kilo.fallbackModels));
|
||||
assert.equal(kilo.fallbackModels[0].id, 'default');
|
||||
assert.equal(kilo.fallbackModels.length, 1);
|
||||
});
|
||||
|
||||
// ---- reasoning-effort clamp ------------------------------------------------
|
||||
// Drives clampCodexReasoning through the public buildArgs surface so the
|
||||
// helper stays non-exported. The wire-level `-c model_reasoning_effort="..."`
|
||||
|
|
@ -248,31 +308,37 @@ test('codex buildArgs clamps reasoning effort per model', () => {
|
|||
// [model, reasoning, expected wire-level effort]
|
||||
// gpt-5.5 family (and unknown / 'default' which we treat as 5.5):
|
||||
// minimal -> low, others pass through.
|
||||
[undefined, 'minimal', 'low'],
|
||||
['default', 'minimal', 'low'],
|
||||
['gpt-5.2', 'minimal', 'low'],
|
||||
['gpt-5.3', 'minimal', 'low'],
|
||||
['gpt-5.4', 'minimal', 'low'],
|
||||
['gpt-5.5', 'minimal', 'low'],
|
||||
['gpt-5.5', 'low', 'low'],
|
||||
['gpt-5.5', 'medium', 'medium'],
|
||||
['gpt-5.5', 'high', 'high'],
|
||||
['vendor/gpt-5.5-foo', 'minimal', 'low'], // path-style id
|
||||
[undefined, 'minimal', 'low'],
|
||||
['default', 'minimal', 'low'],
|
||||
['gpt-5.2', 'minimal', 'low'],
|
||||
['gpt-5.3', 'minimal', 'low'],
|
||||
['gpt-5.4', 'minimal', 'low'],
|
||||
['gpt-5.5', 'minimal', 'low'],
|
||||
['gpt-5.5', 'low', 'low'],
|
||||
['gpt-5.5', 'medium', 'medium'],
|
||||
['gpt-5.5', 'high', 'high'],
|
||||
['vendor/gpt-5.5-foo', 'minimal', 'low'], // path-style id
|
||||
// gpt-5.1: xhigh isn't supported, others pass through.
|
||||
['gpt-5.1', 'xhigh', 'high'],
|
||||
['gpt-5.1', 'high', 'high'],
|
||||
['gpt-5.1', 'xhigh', 'high'],
|
||||
['gpt-5.1', 'high', 'high'],
|
||||
// gpt-5.1-codex-mini: caps at medium / high only.
|
||||
['gpt-5.1-codex-mini', 'minimal', 'medium'],
|
||||
['gpt-5.1-codex-mini', 'low', 'medium'],
|
||||
['gpt-5.1-codex-mini', 'medium', 'medium'],
|
||||
['gpt-5.1-codex-mini', 'high', 'high'],
|
||||
['gpt-5.1-codex-mini', 'xhigh', 'high'],
|
||||
['gpt-5.1-codex-mini', 'low', 'medium'],
|
||||
['gpt-5.1-codex-mini', 'medium', 'medium'],
|
||||
['gpt-5.1-codex-mini', 'high', 'high'],
|
||||
['gpt-5.1-codex-mini', 'xhigh', 'high'],
|
||||
// Unknown / future families: pass through; let the API surface its error
|
||||
// as the signal a new rule belongs in clampCodexReasoning.
|
||||
['gpt-6', 'minimal', 'minimal'],
|
||||
['gpt-6', 'minimal', 'minimal'],
|
||||
];
|
||||
for (const [model, reasoning, expected] of cases) {
|
||||
const args = codex.buildArgs('', [], [], { model, reasoning }, { cwd: '/tmp/od-project' });
|
||||
const args = codex.buildArgs(
|
||||
'',
|
||||
[],
|
||||
[],
|
||||
{ model, reasoning },
|
||||
{ cwd: '/tmp/od-project' },
|
||||
);
|
||||
assert.ok(
|
||||
args.includes(`model_reasoning_effort="${expected}"`),
|
||||
`(model=${model ?? '<none>'}, reasoning=${reasoning}) → expected ${expected}; args=${JSON.stringify(args)}`,
|
||||
|
|
@ -281,10 +347,18 @@ test('codex buildArgs clamps reasoning effort per model', () => {
|
|||
});
|
||||
|
||||
test('codex buildArgs omits model_reasoning_effort when reasoning is "default"', () => {
|
||||
const args = codex.buildArgs('', [], [], { reasoning: 'default' }, { cwd: '/tmp/od-project' });
|
||||
const args = codex.buildArgs(
|
||||
'',
|
||||
[],
|
||||
[],
|
||||
{ reasoning: 'default' },
|
||||
{ cwd: '/tmp/od-project' },
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
args.some((a) => typeof a === 'string' && a.startsWith('model_reasoning_effort=')),
|
||||
args.some(
|
||||
(a) => typeof a === 'string' && a.startsWith('model_reasoning_effort='),
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
|
@ -298,10 +372,20 @@ test('claude flags promptViaStdin and never embeds the prompt in argv', () => {
|
|||
assert.equal(claude.promptViaStdin, true);
|
||||
|
||||
const longPrompt = 'x'.repeat(200_000);
|
||||
const args = claude.buildArgs(longPrompt, [], [], {}, { cwd: '/tmp/od-project' });
|
||||
const args = claude.buildArgs(
|
||||
longPrompt,
|
||||
[],
|
||||
[],
|
||||
{},
|
||||
{ cwd: '/tmp/od-project' },
|
||||
);
|
||||
|
||||
assert.ok(Array.isArray(args), 'claude.buildArgs must return argv');
|
||||
assert.equal(args.includes(longPrompt), false, 'prompt must not appear in argv');
|
||||
assert.equal(
|
||||
args.includes(longPrompt),
|
||||
false,
|
||||
'prompt must not appear in argv',
|
||||
);
|
||||
for (const arg of args) {
|
||||
assert.ok(
|
||||
typeof arg === 'string' && arg.length < 1000,
|
||||
|
|
@ -339,96 +423,120 @@ test('claude entry declares openclaude as a fallback bin (issue #235)', () => {
|
|||
// every platform and is what catches regressions in the AGENT_DEF.
|
||||
const fsTest = process.platform === 'win32' ? test.skip : test;
|
||||
|
||||
fsTest('resolveAgentExecutable prefers def.bin over fallbackBins when bin is on PATH', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'od-agents-resolve-'));
|
||||
try {
|
||||
writeFileSync(join(dir, 'claude'), '');
|
||||
writeFileSync(join(dir, 'openclaude'), '');
|
||||
chmodSync(join(dir, 'claude'), 0o755);
|
||||
chmodSync(join(dir, 'openclaude'), 0o755);
|
||||
process.env.OD_AGENT_HOME = dir;
|
||||
process.env.PATH = dir;
|
||||
fsTest(
|
||||
'resolveAgentExecutable prefers def.bin over fallbackBins when bin is on PATH',
|
||||
() => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'od-agents-resolve-'));
|
||||
try {
|
||||
writeFileSync(join(dir, 'claude'), '');
|
||||
writeFileSync(join(dir, 'openclaude'), '');
|
||||
chmodSync(join(dir, 'claude'), 0o755);
|
||||
chmodSync(join(dir, 'openclaude'), 0o755);
|
||||
process.env.OD_AGENT_HOME = dir;
|
||||
process.env.PATH = dir;
|
||||
|
||||
const resolved = resolveAgentExecutable({
|
||||
bin: 'claude',
|
||||
fallbackBins: ['openclaude'],
|
||||
});
|
||||
assert.equal(resolved, join(dir, 'claude'));
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
const resolved = resolveAgentExecutable({
|
||||
bin: 'claude',
|
||||
fallbackBins: ['openclaude'],
|
||||
});
|
||||
assert.equal(resolved, join(dir, 'claude'));
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
fsTest('resolveAgentExecutable falls back through fallbackBins when def.bin is missing', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'od-agents-resolve-'));
|
||||
try {
|
||||
// Only `openclaude` is installed (Claude Code fork-only setup).
|
||||
writeFileSync(join(dir, 'openclaude'), '');
|
||||
chmodSync(join(dir, 'openclaude'), 0o755);
|
||||
process.env.OD_AGENT_HOME = dir;
|
||||
process.env.PATH = dir;
|
||||
fsTest(
|
||||
'resolveAgentExecutable falls back through fallbackBins when def.bin is missing',
|
||||
() => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'od-agents-resolve-'));
|
||||
try {
|
||||
// Only `openclaude` is installed (Claude Code fork-only setup).
|
||||
writeFileSync(join(dir, 'openclaude'), '');
|
||||
chmodSync(join(dir, 'openclaude'), 0o755);
|
||||
process.env.OD_AGENT_HOME = dir;
|
||||
process.env.PATH = dir;
|
||||
|
||||
const resolved = resolveAgentExecutable({
|
||||
bin: 'claude',
|
||||
fallbackBins: ['openclaude'],
|
||||
});
|
||||
assert.equal(resolved, join(dir, 'openclaude'));
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
const resolved = resolveAgentExecutable({
|
||||
bin: 'claude',
|
||||
fallbackBins: ['openclaude'],
|
||||
});
|
||||
assert.equal(resolved, join(dir, 'openclaude'));
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
fsTest('resolveAgentExecutable returns null when neither def.bin nor any fallback is on PATH', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'od-agents-resolve-'));
|
||||
try {
|
||||
process.env.OD_AGENT_HOME = dir;
|
||||
process.env.PATH = dir;
|
||||
fsTest(
|
||||
'resolveAgentExecutable returns null when neither def.bin nor any fallback is on PATH',
|
||||
() => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'od-agents-resolve-'));
|
||||
try {
|
||||
process.env.OD_AGENT_HOME = dir;
|
||||
process.env.PATH = dir;
|
||||
|
||||
const resolved = resolveAgentExecutable({
|
||||
bin: 'claude',
|
||||
fallbackBins: ['openclaude'],
|
||||
});
|
||||
assert.equal(resolved, null);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
const resolved = resolveAgentExecutable({
|
||||
bin: 'claude',
|
||||
fallbackBins: ['openclaude'],
|
||||
});
|
||||
assert.equal(resolved, null);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
fsTest('resolveAgentExecutable searches mise node bins when PATH is minimal', () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'od-agents-home-'));
|
||||
try {
|
||||
const dir = join(home, '.local', 'share', 'mise', 'installs', 'node', '24.14.1', 'bin');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, 'codex'), '');
|
||||
chmodSync(join(dir, 'codex'), 0o755);
|
||||
process.env.OD_AGENT_HOME = home;
|
||||
process.env.PATH = '/usr/bin:/bin';
|
||||
fsTest(
|
||||
'resolveAgentExecutable searches mise node bins when PATH is minimal',
|
||||
() => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'od-agents-home-'));
|
||||
try {
|
||||
const dir = join(
|
||||
home,
|
||||
'.local',
|
||||
'share',
|
||||
'mise',
|
||||
'installs',
|
||||
'node',
|
||||
'24.14.1',
|
||||
'bin',
|
||||
);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, 'codex'), '');
|
||||
chmodSync(join(dir, 'codex'), 0o755);
|
||||
process.env.OD_AGENT_HOME = home;
|
||||
process.env.PATH = '/usr/bin:/bin';
|
||||
|
||||
const resolved = resolveAgentExecutable({
|
||||
bin: 'codex',
|
||||
});
|
||||
assert.equal(resolved, join(dir, 'codex'));
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
const resolved = resolveAgentExecutable({
|
||||
bin: 'codex',
|
||||
});
|
||||
assert.equal(resolved, join(dir, 'codex'));
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
fsTest('resolveAgentExecutable still resolves agents without a fallbackBins field', () => {
|
||||
// Guard against a regression that would require every AGENT_DEF to
|
||||
// declare fallbackBins. Most agents (codex / gemini / opencode / ...)
|
||||
// only have a single binary name and must keep working unchanged.
|
||||
const dir = mkdtempSync(join(tmpdir(), 'od-agents-resolve-'));
|
||||
try {
|
||||
writeFileSync(join(dir, 'codex'), '');
|
||||
chmodSync(join(dir, 'codex'), 0o755);
|
||||
process.env.PATH = dir;
|
||||
fsTest(
|
||||
'resolveAgentExecutable still resolves agents without a fallbackBins field',
|
||||
() => {
|
||||
// Guard against a regression that would require every AGENT_DEF to
|
||||
// declare fallbackBins. Most agents (codex / gemini / opencode / ...)
|
||||
// only have a single binary name and must keep working unchanged.
|
||||
const dir = mkdtempSync(join(tmpdir(), 'od-agents-resolve-'));
|
||||
try {
|
||||
writeFileSync(join(dir, 'codex'), '');
|
||||
chmodSync(join(dir, 'codex'), 0o755);
|
||||
process.env.PATH = dir;
|
||||
|
||||
const resolved = resolveAgentExecutable({ bin: 'codex' });
|
||||
assert.equal(resolved, join(dir, 'codex'));
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
const resolved = resolveAgentExecutable({ bin: 'codex' });
|
||||
assert.equal(resolved, join(dir, 'codex'));
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// DeepSeek TUI's exec subcommand requires the prompt as a positional
|
||||
// argument (no `-` stdin sentinel; clap declares `prompt: String` as a
|
||||
|
|
@ -514,7 +622,9 @@ test('checkPromptArgvBudget flags oversized DeepSeek prompts and lets short prom
|
|||
// A multi-byte UTF-8 prompt (e.g. CJK characters) is measured in
|
||||
// bytes, not code points — pin that so a 3-byte-per-char prompt
|
||||
// can't sneak past a code-point-based regression of the helper.
|
||||
const cjkOversized = '汉'.repeat(Math.ceil(deepseek.maxPromptArgBytes / 3) + 1);
|
||||
const cjkOversized = '汉'.repeat(
|
||||
Math.ceil(deepseek.maxPromptArgBytes / 3) + 1,
|
||||
);
|
||||
const cjkFlagged = checkPromptArgvBudget(deepseek, cjkOversized);
|
||||
assert.ok(cjkFlagged, 'byte-counted UTF-8 prompts must also trip the guard');
|
||||
assert.equal(cjkFlagged.code, 'AGENT_PROMPT_TOO_LARGE');
|
||||
|
|
@ -560,7 +670,11 @@ test('checkWindowsCmdShimCommandLineBudget flags quote-heavy prompts that expand
|
|||
// Use a realistic npm-style Windows install path so the resolved-bin
|
||||
// contribution mirrors a real user's environment.
|
||||
const resolvedBin = 'C:\\Users\\Tester\\AppData\\Roaming\\npm\\deepseek.cmd';
|
||||
const flagged = checkWindowsCmdShimCommandLineBudget(deepseek, resolvedBin, args);
|
||||
const flagged = checkWindowsCmdShimCommandLineBudget(
|
||||
deepseek,
|
||||
resolvedBin,
|
||||
args,
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
flagged,
|
||||
|
|
@ -602,7 +716,11 @@ test('checkWindowsCmdShimCommandLineBudget is a no-op for non-.cmd resolutions',
|
|||
// than overlapping with the direct-exe guard's contract.
|
||||
const args = deepseek.buildArgs('x'.repeat(20_000), [], [], {});
|
||||
assert.equal(
|
||||
checkWindowsCmdShimCommandLineBudget(deepseek, '/usr/local/bin/deepseek', args),
|
||||
checkWindowsCmdShimCommandLineBudget(
|
||||
deepseek,
|
||||
'/usr/local/bin/deepseek',
|
||||
args,
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
|
|
@ -644,7 +762,11 @@ test('checkWindowsCmdShimCommandLineBudget projects the %var% escape into the co
|
|||
|
||||
const args = deepseek.buildArgs(prompt, [], [], {});
|
||||
const resolvedBin = 'C:\\Users\\Tester\\AppData\\Roaming\\npm\\deepseek.cmd';
|
||||
const flagged = checkWindowsCmdShimCommandLineBudget(deepseek, resolvedBin, args);
|
||||
const flagged = checkWindowsCmdShimCommandLineBudget(
|
||||
deepseek,
|
||||
resolvedBin,
|
||||
args,
|
||||
);
|
||||
// The prompt is short enough that the cmd-shim budget should still pass —
|
||||
// the test isn't about an oversized prompt; it's about the *content* of
|
||||
// the projected line. A null result here means the escape is in place
|
||||
|
|
@ -656,10 +778,7 @@ test('checkWindowsCmdShimCommandLineBudget no-ops when resolvedBin is null or ad
|
|||
// Bin resolution failed but the run continued long enough to reach
|
||||
// this guard — must be a no-op so the existing AGENT_UNAVAILABLE path
|
||||
// still fires from server.ts.
|
||||
assert.equal(
|
||||
checkWindowsCmdShimCommandLineBudget(deepseek, null, []),
|
||||
null,
|
||||
);
|
||||
assert.equal(checkWindowsCmdShimCommandLineBudget(deepseek, null, []), null);
|
||||
// Stdin-delivered adapters never declare `maxPromptArgBytes` — the
|
||||
// guard must skip them even when handed a `.cmd` path.
|
||||
assert.equal(
|
||||
|
|
@ -699,7 +818,11 @@ test('checkWindowsDirectExeCommandLineBudget flags quote-heavy prompts on a dire
|
|||
// (path has spaces so the resolved-bin contribution itself gets
|
||||
// wrapped in `"…"`, which mirrors what libuv would do on Windows).
|
||||
const resolvedBin = 'C:\\Program Files\\DeepSeek\\deepseek.exe';
|
||||
const flagged = checkWindowsDirectExeCommandLineBudget(deepseek, resolvedBin, args);
|
||||
const flagged = checkWindowsDirectExeCommandLineBudget(
|
||||
deepseek,
|
||||
resolvedBin,
|
||||
args,
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
flagged,
|
||||
|
|
@ -735,7 +858,12 @@ test('checkWindowsDirectExeCommandLineBudget no-ops on .cmd / .bat resolutions a
|
|||
// The cmd-shim guard owns `.bat` / `.cmd` — the direct-exe guard must
|
||||
// skip them so an oversized prompt on a `.cmd` install doesn't trip
|
||||
// both guards (and double-emit an SSE error).
|
||||
const args = deepseek.buildArgs('"'.repeat(deepseek.maxPromptArgBytes - 100), [], [], {});
|
||||
const args = deepseek.buildArgs(
|
||||
'"'.repeat(deepseek.maxPromptArgBytes - 100),
|
||||
[],
|
||||
[],
|
||||
{},
|
||||
);
|
||||
assert.equal(
|
||||
checkWindowsDirectExeCommandLineBudget(
|
||||
deepseek,
|
||||
|
|
@ -757,11 +885,19 @@ test('checkWindowsDirectExeCommandLineBudget no-ops on .cmd / .bat resolutions a
|
|||
// concatenation to bust. The pre-buildArgs `checkPromptArgvBudget` is
|
||||
// the one responsible for catching oversized argv on those hosts.
|
||||
assert.equal(
|
||||
checkWindowsDirectExeCommandLineBudget(deepseek, '/usr/local/bin/deepseek', args),
|
||||
checkWindowsDirectExeCommandLineBudget(
|
||||
deepseek,
|
||||
'/usr/local/bin/deepseek',
|
||||
args,
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
checkWindowsDirectExeCommandLineBudget(deepseek, '/home/dev/.cargo/bin/deepseek', args),
|
||||
checkWindowsDirectExeCommandLineBudget(
|
||||
deepseek,
|
||||
'/home/dev/.cargo/bin/deepseek',
|
||||
args,
|
||||
),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
|
@ -774,10 +910,7 @@ test('checkWindowsDirectExeCommandLineBudget no-ops when resolvedBin is null/emp
|
|||
checkWindowsDirectExeCommandLineBudget(deepseek, null, []),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
checkWindowsDirectExeCommandLineBudget(deepseek, '', []),
|
||||
null,
|
||||
);
|
||||
assert.equal(checkWindowsDirectExeCommandLineBudget(deepseek, '', []), null);
|
||||
// Stdin-delivered adapters never declare `maxPromptArgBytes` — the
|
||||
// guard must skip them even when handed a Windows `.exe` path.
|
||||
assert.equal(
|
||||
|
|
@ -799,10 +932,16 @@ test('cmd-shim and direct-exe guards are mutually exclusive on a single resoluti
|
|||
|
||||
const cmdPath = 'C:\\Users\\Tester\\AppData\\Roaming\\npm\\deepseek.cmd';
|
||||
assert.ok(checkWindowsCmdShimCommandLineBudget(deepseek, cmdPath, args));
|
||||
assert.equal(checkWindowsDirectExeCommandLineBudget(deepseek, cmdPath, args), null);
|
||||
assert.equal(
|
||||
checkWindowsDirectExeCommandLineBudget(deepseek, cmdPath, args),
|
||||
null,
|
||||
);
|
||||
|
||||
const exePath = 'C:\\Program Files\\DeepSeek\\deepseek.exe';
|
||||
assert.equal(checkWindowsCmdShimCommandLineBudget(deepseek, exePath, args), null);
|
||||
assert.equal(
|
||||
checkWindowsCmdShimCommandLineBudget(deepseek, exePath, args),
|
||||
null,
|
||||
);
|
||||
assert.ok(checkWindowsDirectExeCommandLineBudget(deepseek, exePath, args));
|
||||
});
|
||||
|
||||
|
|
@ -831,7 +970,9 @@ test('vibe args use empty array for acp-json-rpc streaming', () => {
|
|||
test('vibe fetchModels falls back to fallbackModels when detection fails', async () => {
|
||||
// fetchModels rejects when the binary doesn't exist; the daemon's
|
||||
// probe() catches this and uses fallbackModels instead.
|
||||
const result = await vibe.fetchModels('/nonexistent/vibe-acp').catch(() => null);
|
||||
const result = await vibe
|
||||
.fetchModels('/nonexistent/vibe-acp')
|
||||
.catch(() => null);
|
||||
|
||||
assert.equal(result, null);
|
||||
assert.ok(Array.isArray(vibe.fallbackModels));
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@ If both signals agree, detection is confident. If only one signal fires, we mark
|
|||
| **openclaw** | `openclaw` | `~/.openclaw/` | 〜 | 〜 | 〜 | P2 |
|
||||
| **copilot** | `copilot` | `~/.copilot/` | ❌ | ✅ (`edit` tool) | ✅ (`--output-format json` JSONL) | P2 |
|
||||
| **kiro** | `kiro-cli` | `~/.kiro/` | ❌ | ✅ | ✅ (`acp-json-rpc`) | P2 |
|
||||
| **kilo** | `kilo` | — | ❌ | ✅ | ✅ (`acp-json-rpc`) | P2 |
|
||||
| **vibe** | `vibe-acp` | `~/.vibe/` | ❌ | ✅ | ✅ (`acp-json-rpc`) | P2 |
|
||||
| **deepseek** | `deepseek` | `~/.deepseek/` | `~/.deepseek/skills/` | ❌ (prompt-injected) | ✅ | ✅ (plain text) | P2 |
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue