fix: reject disabled agents in CLI validation instead of silently proceeding (#3061)

resolveEntityKey() and checkEntity() checked manifest.agents[input] directly,
bypassing the disabled filter in agentKeys(). This let users run `spawn cursor
<cloud>` even though cursor is disabled, wasting time provisioning a VM for an
agent that can't route through OpenRouter. Now both functions check the disabled
flag and show the disabled_reason to the user.

Also removes stale cursor references from spawn skill templates injected into
child VMs.

Agent: code-health

Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
A 2026-03-27 03:22:18 -07:00 committed by GitHub
parent 1cfa9ca1a7
commit db77121414
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 86 additions and 6 deletions

View file

@ -1,6 +1,6 @@
{
"name": "@openrouter/spawn",
"version": "0.27.3",
"version": "0.27.4",
"type": "module",
"bin": {
"spawn": "cli.js"

View file

@ -1,7 +1,7 @@
import type { Manifest } from "../manifest";
import { beforeEach, describe, expect, it } from "bun:test";
import { checkEntity } from "../commands/index.js";
import { checkEntity, resolveAgentKey } from "../commands/index.js";
/**
* Tests for checkEntity (commands/shared.ts).
@ -383,6 +383,76 @@ describe("checkEntity", () => {
});
});
// ── Disabled agents ─────────────────────────────────────────────────────
describe("disabled agents", () => {
let disabledManifest: Manifest;
beforeEach(() => {
disabledManifest = {
agents: {
claude: {
name: "Claude Code",
description: "AI coding assistant",
url: "https://claude.ai",
install: "npm install -g claude",
launch: "claude",
env: {
ANTHROPIC_API_KEY: "test",
},
},
cursor: {
name: "Cursor CLI",
description: "AI coding agent",
url: "https://cursor.com",
install: "curl https://cursor.com/install | bash",
launch: "agent",
env: {},
disabled: true,
disabled_reason: "Cursor CLI uses a proprietary protocol.",
},
},
clouds: {
sprite: {
name: "Sprite",
description: "Lightweight VMs",
price: "test",
url: "https://sprite.sh",
type: "vm",
auth: "SPRITE_TOKEN",
provision_method: "api",
exec_method: "ssh",
interactive_method: "ssh",
},
},
matrix: {
"sprite/claude": "implemented",
"sprite/cursor": "implemented",
},
};
});
it("checkEntity returns false for a disabled agent", () => {
expect(checkEntity(disabledManifest, "cursor", "agent")).toBe(false);
});
it("checkEntity returns true for an enabled agent in the same manifest", () => {
expect(checkEntity(disabledManifest, "claude", "agent")).toBe(true);
});
it("resolveAgentKey returns null for a disabled agent", () => {
expect(resolveAgentKey(disabledManifest, "cursor")).toBeNull();
});
it("resolveAgentKey resolves an enabled agent normally", () => {
expect(resolveAgentKey(disabledManifest, "claude")).toBe("claude");
});
it("checkEntity still works for clouds even when agents are disabled", () => {
expect(checkEntity(disabledManifest, "sprite", "cloud")).toBe(true);
});
});
// ── Manifest with overlapping key names ────────────────────────────────
describe("manifest with overlapping patterns", () => {

View file

@ -162,6 +162,9 @@ export function findClosestKeyByNameOrKey(
function resolveEntityKey(manifest: Manifest, input: string, kind: "agent" | "cloud"): string | null {
const collection = getEntityCollection(manifest, kind);
if (collection[input]) {
if (kind === "agent" && manifest.agents[input].disabled) {
return null;
}
return input;
}
const keys = getEntityKeys(manifest, kind);
@ -285,6 +288,13 @@ export function checkEntity(manifest: Manifest, value: string, kind: "agent" | "
const def = ENTITY_DEFS[kind];
const collection = getEntityCollection(manifest, kind);
if (collection[value]) {
if (kind === "agent" && manifest.agents[value].disabled) {
p.log.error(`${pc.bold(manifest.agents[value].name)} is temporarily disabled.`);
if (manifest.agents[value].disabled_reason) {
p.log.info(manifest.agents[value].disabled_reason);
}
return false;
}
return true;
}

View file

@ -193,7 +193,7 @@ async function setupCursorConfig(runner: CloudRunner, _apiKey: string): Promise<
'spawn <agent> <cloud> --headless --output json --prompt "task description"',
"```",
"",
"## Agents: claude, codex, openclaw, zeroclaw, opencode, kilocode, hermes, junie, cursor",
"## Agents: claude, codex, openclaw, zeroclaw, opencode, kilocode, hermes, junie",
"## Clouds: hetzner, digitalocean, aws, gcp, sprite",
"",
"The command returns JSON with connection details. Use this to delegate subtasks",

View file

@ -30,7 +30,7 @@ You have the \`spawn\` CLI installed. Use it to provision cloud VMs with AI agen
spawn <agent> <cloud> --headless --output json --prompt "task description"
\`\`\`
## Agents: claude, cursor, codex, openclaw, zeroclaw, opencode, kilocode, hermes, junie
## Agents: claude, codex, openclaw, zeroclaw, opencode, kilocode, hermes, junie
## Clouds: hetzner, digitalocean, aws, gcp, sprite
## Example
@ -51,7 +51,7 @@ ssh -o StrictHostKeyChecking=accept-new root@<ip_address> '~/.local/bin/claude -
\`\`\`
### Known gotchas do NOT waste time rediscovering these:
- Agent binaries are at \`~/.local/bin/\`, NOT on the default PATH. Always use the full path: \`~/.local/bin/claude\`, \`~/.local/bin/cursor\`, etc.
- Agent binaries are at \`~/.local/bin/\`, NOT on the default PATH. Always use the full path: \`~/.local/bin/claude\`, \`~/.local/bin/codex\`, etc.
- Claude Code blocks \`--dangerously-skip-permissions\` as root. Use \`--print\` for one-shot prompts instead — it works as root and returns the response directly.
- Do NOT try to create a non-root user to work around root restrictions it requires copying config files (\`.claude.json\`, \`.claude/settings.json\`, env vars) and is fragile. Just use \`--print\`.
- For long-running tasks, use \`spawn delete --name <name> --yes\` to clean up when done.
@ -76,7 +76,7 @@ const HERMES_SNIPPET = `
You have the \`spawn\` CLI for creating child cloud VMs with AI agents.
Use \`spawn <agent> <cloud> --headless --output json --prompt "task"\` to delegate work.
Available agents: claude, cursor, codex, openclaw, zeroclaw, opencode, kilocode, hermes, junie.
Available agents: claude, codex, openclaw, zeroclaw, opencode, kilocode, hermes, junie.
Cloud credentials are pre-configured. Run \`spawn list --json\` to see children.
Note: --headless only provisions the VM. To run a prompt, SSH in: \`ssh root@<ip> '~/.local/bin/<agent> --print "prompt"'\`.
`;