Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 42 additions & 3 deletions src/dispatcher.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,10 @@
// 2. Body path (superpowers model): output the skill's markdown body
// (frontmatter stripped). LLM-interpreted orientation/instructions.

import { readFileSync, readSync, existsSync, readdirSync, statSync } from "node:fs";
import { readFileSync, readSync, existsSync, readdirSync, statSync, accessSync, constants } from "node:fs";
import { spawnSync } from "node:child_process";
import { isatty } from "node:tty";
import { join, resolve, dirname, sep } from "node:path";
import { join, resolve, dirname, sep, delimiter } from "node:path";
import { fileURLToPath } from "node:url";

const DEFAULT_TIMEOUT = 60;
Expand Down Expand Up @@ -200,6 +200,40 @@ function findProjectRoot() {

// ── Script resolution (ported from spec-kit _resolve_event_command_argv) ─

// Resolve a runnable launcher by scanning PATH (mirrors Python's
// shutil.which). Returns the absolute path of the first `names` entry that
// both exists and is executable, or null when none is found — so the ps
// variant can degrade to "no argv" (clean "unresolvable, falling back")
// instead of fabricating a bare "pwsh" that spawnSync would fail to exec
// with a confusing ENOENT (spec-kit #4340).
function findLauncher(names) {
const pathEnv = process.env.PATH || "";
if (!pathEnv) return null;
const dirs = pathEnv.split(delimiter).filter(Boolean);
// On Windows, PATHEXT lists the executable suffixes to probe; on POSIX a
// script name carries no extension.
const exts = process.platform === "win32"
? (process.env.PATHEXT || ".EXE;.CMD;.BAT").split(delimiter).filter(Boolean)
: [""];
for (const name of names) {
for (const dir of dirs) {
for (const ext of exts) {
const candidate = join(dir, name + ext);
if (!existsSync(candidate)) continue;
try {
// X_OK is a no-op on Windows (Node never enforces the execute bit
// there); on POSIX it rejects files present but not executable.
accessSync(candidate, constants.X_OK);
return candidate;
} catch {
// exists but not executable — keep scanning
}
}
}
}
return null;
}

function resolveScriptArgv(scriptsField, skillDir) {
// scripts: is either a YAML-style string ("sh: scripts/boot.sh\nps: ...")
// already parsed by our frontmatter parser into an object, or a raw string.
Expand Down Expand Up @@ -229,7 +263,12 @@ function resolveScriptArgv(scriptsField, skillDir) {
return [process.execPath || "python3", scriptPath, ...rest];
}
if (variant === "ps") {
return ["pwsh", "-File", scriptPath, ...rest];
// Probe for a real launcher (pwsh, then powershell). When neither is on
// PATH, return null like every other unresolvable branch — NOT a bare
// "pwsh" argv, which would make spawnSync raise ENOENT (spec-kit #4340).
const launcher = findLauncher(["pwsh", "powershell"]);
if (!launcher) return null;
return [launcher, "-File", scriptPath, ...rest];
}
// sh: direct on POSIX; bash launcher on Windows.
if (process.platform === "win32") {
Expand Down
54 changes: 54 additions & 0 deletions tests/events.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -736,6 +736,60 @@ echo "SCRIPT_OUTPUT: constitution loaded"`,
}
});

it("ps variant with no pwsh/powershell on PATH → degrades to 'no argv' (fail-open), not ENOENT (spec-kit #4340)", () => {
const projectRoot = createTestProject(false);
try {
const skillsDir = join(projectRoot, ".agents", "skills");
mkdirSync(join(skillsDir, "ps-skill"), { recursive: true });
writeFileSync(
join(skillsDir, "ps-skill", "SKILL.md"),
`---
name: ps-skill
description: ps-only skill
scripts:
ps: scripts/boot.ps1
---

# ps-skill

Body injected when script unresolvable.`,
"utf-8",
);
mkdirSync(join(skillsDir, "ps-skill", "scripts"), { recursive: true });
writeFileSync(join(skillsDir, "ps-skill", "scripts", "boot.ps1"), "exit 0\n", "utf-8");

installDispatcher(projectRoot);
const dispatcher = join(projectRoot, DISPATCHER_REL);

// Scrub PATH so neither pwsh nor powershell can be resolved, regardless
// of what the host machine has installed. Use process.execPath (absolute)
// for node itself so the parent spawnSync doesn't need PATH to launch it;
// the child only needs PATH for its own launcher probe, which must find
// nothing here.
const result = spawnSync(process.execPath, [dispatcher, "session_start", "ps-skill", skillsDir, "10"], {
encoding: "utf-8",
cwd: projectRoot,
env: { ...process.env, PATH: "" },
});

assert.equal(result.status, 0, `dispatcher exited ${result.status}: ${result.stderr}`);
assert.ok(
result.stderr.includes("unresolvable"),
`clean "unresolvable, falling back" logged, got: ${result.stderr}`,
);
assert.ok(
!result.stderr.includes("ENOENT") && !/script error/i.test(result.stderr),
`no confusing pwsh ENOENT, got: ${result.stderr}`,
);
assert.ok(
result.stdout.includes("Body injected when script unresolvable"),
"degraded to body injection instead of crashing spawn",
);
} finally {
rmSync(projectRoot, { recursive: true, force: true });
}
});

it("fail-open: missing skill logs and exits 0", () => {
const projectRoot = createTestProject(true);
try {
Expand Down