fix(sdk): support command paths containing spaces in scheduler script tasks (#1794) - #1802
Conversation
… tasks
LocalPollingProvider split task.ref on whitespace with no quote handling:
const argv = entry.task.ref.trim().split(/\s+/);
On Windows the default Node install is C:\Program Files\nodejs\node.exe, so
the command became "C:\Program" and every script task failed with ENOENT.
This is the modal Windows configuration, not an edge case — it breaks for
anyone who did not install Node somewhere unusual.
Confirmed on this machine: process.execPath is literally
C:\Program Files\nodejs\node.exe, and a full run on unmodified dev fails
FAIL test/scheduler.test.ts > LocalPollingProvider > should execute script tasks
FAIL ... > rich error capture on script failure > captures the exit code on a non-zero exit
FAIL ... > rich error capture on script failure > captures stderr written by the child
FAIL ... > rich error capture on script failure > does not block the event loop while a script runs
all four of which pass after this change.
Parsing is now quote-aware, and an unquoted command path is resolved by
widening across spaces, longest match first — the same way Windows
CreateProcess resolves an unquoted path. A ref whose first token already
resolves on disk is used unchanged, so every previously-working ref keeps its
exact behaviour, including bare PATH commands such as `node` that are not
files at all.
One backward-compatibility trap found while building this, caught by the
existing suite going red rather than by reading: naive shell-style tokenizing
strips the inner quotes from `node -e console.log('hi')`, turning the string
literal into an identifier so the child dies with ReferenceError. Quotes now
group only when they open at a token boundary; a quote mid-token is a literal
and is passed through untouched. Pinned by a direct unit test.
TaskConfig gains `argv?: string[]` as the unambiguous form — when present,
`ref` is the executable verbatim and is never parsed.
Spawn failures are also no longer silent. execFile reports them with a string
code (ENOENT, EACCES) and no stdout or stderr, which fell through every branch
and produced `code: undefined, stderr: ''` — telling an operator nothing about
what failed to spawn. TaskResult gains `spawnError?: string`, and the message
now names the ref.
`shell: false` is retained, so the injection-safety property is unchanged.
Tests build their fixture at <tmp>/Program Files/nodejs/node.exe from a real
copy of the running Node binary, so the red reproduces the actual modal
failure rather than a synthetic string containing a space. A guard test
asserts the fixture path really does contain a space, so the suite cannot
silently become vacuous.
Pre-fix red for the new tests: 5 failed | 1 passed (the 1 being that guard).
Post-fix: 81 passed.
Closes #1794
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 70370e36-33b0-4786-bd72-4cf15518daa6
🟡 Impact Analysis — PR #1802Risk tier: 🟡 MEDIUM 📊 Summary
🎯 Risk Factors
📦 Modules Affectedroot (1 file)
squad-sdk (1 file)
tests (1 file)
This report is generated automatically for every PR. See #733 for details. |
🛫 PR Readiness Check
PR Scope: 📦🔧 Mixed (product + infrastructure)
|
| Status | Check | Details |
|---|---|---|
| ❌ | Single commit | 2 commits — consider squashing before review |
| ✅ | Not in draft | Ready for review |
| ❌ | Branch up to date | dev is 9 commit(s) ahead — rebase recommended |
| ❌ | Copilot review | No Copilot review yet — it may still be processing |
| ✅ | Changeset present | Changeset file found |
| ✅ | Scope clean | No .squad/ or docs/proposals/ files |
| ✅ | No merge conflicts | No merge conflicts |
| ❌ | Copilot threads resolved | 6 unresolved Copilot thread(s) — fix and resolve before merging |
| ✅ | CI passing | All checks passing |
Files Changed (3 files, +313 −4)
| File | +/− |
|---|---|
.changeset/scheduler-quoted-task-ref.md |
+32 −0 |
packages/squad-sdk/src/runtime/scheduler.ts |
+127 −3 |
test/scheduler.test.ts |
+154 −1 |
Total: +313 −4
This check runs automatically on every push. Fix any ❌ items and push again.
See CONTRIBUTING.md and PR Requirements for details.
There was a problem hiding this comment.
Pull request overview
This PR fixes SDK scheduler execution for command paths and arguments containing spaces, especially on Windows.
Changes:
- Adds quote-aware parsing, path resolution, explicit
argvsupport, and spawn diagnostics. - Adds regression tests and an SDK patch changeset.
Show a summary per file
| File | Summary |
|---|---|
test/scheduler.test.ts |
Adds regression coverage for spaced paths, quoting, arguments, and spawn failures. |
packages/squad-sdk/src/runtime/scheduler.ts |
Implements the scheduler changes. Five moderate findings and one nit remain regarding path resolution, error classification, argv validation, verbatim refs, and JSDoc placement. |
.changeset/scheduler-quoted-task-ref.md |
Documents the SDK patch release. |
Review details
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Suppressed comments (4)
packages/squad-sdk/src/runtime/scheduler.ts:500
- Tokenization collapses all whitespace and candidate reconstruction always joins with one ASCII space. Therefore an unquoted executable path containing repeated spaces, such as
C:\Program Files\node.exe, can never match the existing file and falls back to the first token. Preserve the raw separator positions (or explicitly require quoting) so the advertised unquoted path support does not depend on exactly one space between components.
for (let i = tokens.length; i >= 2; i--) {
const candidate = tokens.slice(0, i).join(' ');
if (exists(candidate)) {
packages/squad-sdk/src/runtime/scheduler.ts:607
tokenizeTaskRefcan reject an unterminated quote, butvalidateManifeststill calls onlyvalidateTaskRef, which does not perform that syntax check. As a result,parseScheduleaccepts a malformed ref and the error is deferred until the task happens to execute. Invoke the tokenizer during ref validation (without resolving the executable) so invalid schedule files fail at load time.
const { tokens, firstQuoted } = tokenizeTaskRef(entry.task.ref);
({ command, args } = resolveScriptCommand(tokens, firstQuoted));
packages/squad-sdk/src/runtime/scheduler.ts:639
- This puts the entire
task.ref, including every command-line argument, into an error thatscheduleRunpersists in.schedule-state.jsonand prints. A ref can contain a token or password argument, so a spawn failure can leak it into durable state and logs; use the resolvedcommand(or redact arguments) here rather than recording the full ref.
result.error = `${e.code}: failed to spawn '${entry.task.ref}' — ${e.message}`;
packages/squad-sdk/src/runtime/scheduler.ts:500
- For any command whose first token is not an existing file, this performs a synchronous
existsSyncfor every token—including every argument—on every scheduled invocation.task.refhas no length/token bound, so a long inline script or malformed ref can block the scheduler event loop for an unbounded number of filesystem calls, undermining the non-blocking execution goal. Limit probing to path-like prefixes or use a targeted/async resolution strategy.
for (let i = tokens.length; i >= 2; i--) {
const candidate = tokens.slice(0, i).join(' ');
if (exists(candidate)) {
- Files reviewed: 3/3 changed files
- Comments generated: 6
- Review effort level: Lite
| if (exists(first)) { | ||
| return { command: first, args: tokens.slice(1) }; | ||
| } |
| if (typeof e.code === 'string') { | ||
| result.spawnError = e.code; | ||
| result.error = `${e.code}: failed to spawn '${entry.task.ref}' — ${e.message}`; | ||
| } |
| if (entry.task.argv) { | ||
| command = entry.task.ref.trim(); | ||
| args = entry.task.argv; |
| * can cause issues even without shell interpretation. | ||
| * The structural protection comes from execFileSync (shell: false). | ||
| */ | ||
| /** |
| let command: string; | ||
| let args: string[]; | ||
| if (entry.task.argv) { | ||
| command = entry.task.ref.trim(); |
| for (let i = tokens.length; i >= 2; i--) { | ||
| const candidate = tokens.slice(0, i).join(' '); | ||
| if (exists(candidate)) { | ||
| return { command: candidate, args: tokens.slice(i) }; |
#1802 adds four things to the squad-sdk public surface, not just a behaviour fix: two new exported functions (tokenizeTaskRef, resolveScriptCommand) and two new public interface fields (TaskConfig.argv, TaskResult.spawnError). The changeset itself tells consumers to prefer argv for spaced paths, so it is documenting new API while claiming a patch. Additive exported API is minor under semver, and it matches this repo's own precedent: squad-sdk 0.10.0 files "Add WorkItemTypeInfo interface", "Add getAvailableWorkItemTypes()" and "Add SkillScriptLoader/applySkillHandlers" all as Minor Changes. The one patch precedent on dev (max-reasoning-effort) adds a single string literal to an existing union - strictly smaller. Worth noting the failure shape: changelog-gate was already GREEN. The fragment existed and named the right package, so a presence check passed while the scoping was wrong. A gate that checks presence cannot check correctness - this would have shipped new exports inside a patch and misled consumers pinning semver ranges. Same class as the rest of tonight's findings: we asserted on the reporter, not the artifact. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 70370e36-33b0-4786-bd72-4cf15518daa6
Closes #1794.
The defect
LocalPollingProvider.executesplittask.refon whitespace with no quote handling:On Windows the default Node install is
C:\Program Files\nodejs\node.exe, socommandbecameC:\Programand every script task failed withENOENT. This is the modal Windows configuration, not an exotic edge case — it breaks for anyone who did not install Node somewhere unusual.It was also documented in the test suite as a constraint rather than a bug. Two existing tests carry the comment:
// Space-free script — the scheduler tokenizes `task.ref` by whitespace.Same shape as #1796: correctly observed, worked around, never fixed.
Red, reproducing the actual modal failure
process.execPathon the test machine is literallyC:\Program Files\nodejs\node.exe. A full suite run on unmodifieddevfails four pre-existing scheduler tests for exactly this reason:All four pass after this change. This PR removes
test/scheduler.test.tsfrom the Windows failing set entirely (#1797 baseline: 9 files → 8).The new tests build their fixture at
<tmp>/Program Files/nodejs/node.exefrom a real copy of the running Node binary, so the red is the actual spawn failure and not a synthetic string with a space in it. Pre-fix:The 1 pass is deliberate:
the fixture path really does contain a space (guards against a vacuous test). A space-free fixture passes identically before and after and proves nothing, so the suite asserts it cannot silently become vacuous.The fix
nodewhich are not files at all. Only when that fails do we widen across spaces, longest match first — the same way WindowsCreateProcessresolves an unquoted path.TaskConfig.argv?: string[]as the unambiguous form:refis the executable verbatim, never parsed.TaskResult.spawnError?: string.execFilereports spawn failures with a string code (ENOENT,EACCES) and no stdout/stderr, which fell through every branch and producedcode: undefined, stderr: ''. That is the silent-success class again — a failure that reports nothing. The message now names the ref.shell: falseretained, so the injection-safety property is unchanged.A backward-compat trap this nearly shipped
Naive shell-style tokenizing strips the inner quotes from
node -e console.log('hi'), turning the string literal into an identifier so the child dies withReferenceError. I found this because the existing suite went red, not by reading it — my first tokenizer brokeshould execute script tasksand two others.Quotes now group only when they open at a token boundary; a quote mid-token is a literal. Pinned directly:
Verification
test/scheduler.test.tspost-fixnpm run buildgit diff --cached --statgit diff --cached --diff-filter=D --name-only.changeset/scheduler-quoted-task-ref.md(patch,@bradygaster/squad-sdk)Running the full suite three times produced three different failing sets: 9 files (the #1797 baseline), 8 (with this change), 7 (control, change stashed).
template-sync,consumer-imports,promote-insider-tagandpatch-esm-importsmove in and out between runs. All pass in isolation.So the Windows failure count is not stable run-to-run, which weakens the single-number rule in #1797.
template-sync's instability is specifically the #1796 race — fixed in #1798, unfixed on this branch. Reported separately; flagging here so the count change from this PR (9 → 8) is not read as the whole story.