From 8cb0953ff4eba12df1d01a45ef5d06c3438da3b1 Mon Sep 17 00:00:00 2001 From: jkrandom-sudo Date: Thu, 30 Jul 2026 15:13:47 +0800 Subject: [PATCH] fix: strip opencode-run argv quotes before /loop fallback matching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up for #19: `opencode run "/loop 5m"` stores the user message with literal outer quotes (argv elements containing spaces are re-quoted), so the fallback regex /^\/loop.../ never matched real run-mode text and every deterministic guard was still bypassed end-to-end — the unit tests only simulated unquoted parts. - reuse stripOuterQuotes (now exported) before matching, mirroring handleUserCommand - scan remaining text parts instead of bailing on the first non-match - regression tests for double- and single-quoted command text and for /loop appearing in a later text part (12/12 run-mode tests, 176/176 overall, tsc clean) - verified end-to-end against a real `opencode run` with the built plugin: quoted cron input is now rejected deterministically and no task is created; a quoted valid interval creates the fixed task Co-Authored-By: Claude Opus 4.6 --- src/index.ts | 10 +++-- src/scheduler.ts | 2 +- tests/run-mode.test.mjs | 83 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 4 deletions(-) diff --git a/src/index.ts b/src/index.ts index 301b4b0..1a0ec76 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,7 +27,7 @@ import type { Plugin, Hooks, PluginModule } from "@opencode-ai/plugin" import type { Part } from "@opencode-ai/sdk" import { LoopStore } from "./store.js" import { InstanceLock } from "./instance-lock.js" -import { Scheduler } from "./scheduler.js" +import { Scheduler, stripOuterQuotes } from "./scheduler.js" import { CronParser } from "./cron-parser.js" import { Jitter } from "./jitter.js" import { buildLoopTools } from "./tools/loop-tools.js" @@ -211,10 +211,14 @@ export const LoopPlugin: Plugin = async (ctx) => { // deterministic parser. Parts already consumed by // command.execute.before are synthetic/ignored and skipped, so the // TUI path is never handled twice. + // + // Note: opencode run re-quotes argv elements that contain spaces, so + // the stored text is often `"/loop 5m"` (with literal quotes) — strip + // outer quotes before matching, mirroring handleUserCommand. for (const part of output?.parts ?? []) { if (part.type !== "text" || part.synthetic || part.ignored) continue - const match = /^\/loop(?:\s+([\s\S]*))?$/.exec(part.text.trim()) - if (!match) return + const match = /^\/loop(?:\s+([\s\S]*))?$/.exec(stripOuterQuotes(part.text)) + if (!match) continue await runLoopCommand(match[1] ?? "", input.sessionID, output.parts) return } diff --git a/src/scheduler.ts b/src/scheduler.ts index 2cd0079..677b193 100644 --- a/src/scheduler.ts +++ b/src/scheduler.ts @@ -100,7 +100,7 @@ function parseFlagPrefix(text: string): { } /** Strip one layer of matching surrounding quotes (B10). */ -function stripOuterQuotes(text: string): string { +export function stripOuterQuotes(text: string): string { const t = text.trim() if (t.length >= 2) { const first = t[0] diff --git a/tests/run-mode.test.mjs b/tests/run-mode.test.mjs index b074f07..ee7f10e 100644 --- a/tests/run-mode.test.mjs +++ b/tests/run-mode.test.mjs @@ -200,3 +200,86 @@ test("chat.message without output argument still tracks the active session", asy rmSync(dir, { recursive: true, force: true }) } }) + +test("run mode: opencode run quotes argv with spaces — quoted text is still intercepted", async () => { + // Real-world part.text from `opencode run "/loop 5m"` is "\"/loop 5m\"" + // (opencode re-quotes argv elements containing spaces). Regression test + // for the quote-stripping fix: without it the regex never matches and + // every guard is bypassed end-to-end despite unit tests passing. + const dir = mkdtempSync(join(tmpdir(), "loop-run-")) + try { + const hooks = await makeHooks(dir) + + const bare = textMessage('"/loop 5m"') + await hooks["chat.message"]({ sessionID: "sRun" }, bare) + assert.ok( + bare.parts[0].text.includes('Missing prompt after interval "5m"'), + `expected missing-prompt failure for quoted input, got: ${bare.parts[0].text.slice(0, 120)}` + ) + assert.equal(taskCount(dir), 0, "no task created from quoted bare interval") + + const cron = textMessage('"/loop */5 * * * * check something"') + await hooks["chat.message"]({ sessionID: "sRun" }, cron) + assert.ok( + cron.parts[0].text.includes("Cron expressions are not supported"), + `expected cron rejection for quoted input, got: ${cron.parts[0].text.slice(0, 120)}` + ) + assert.equal(taskCount(dir), 0, "no task created from quoted cron") + + const valid = textMessage('"/loop 1m ping the server"') + await hooks["chat.message"]({ sessionID: "sRun" }, valid) + assert.equal(taskCount(dir), 1, "quoted valid command creates the task") + const task = JSON.parse(readFileSync(tasksFile(dir), "utf-8")).tasks[0] + assert.equal(task.mode, "fixed") + assert.equal(task.prompt, "ping the server") + + await hooks.dispose() + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) + +test("run mode: single-quoted command text is also intercepted", async () => { + const dir = mkdtempSync(join(tmpdir(), "loop-run-")) + try { + const hooks = await makeHooks(dir) + const out = textMessage("'/loop 5m'") + await hooks["chat.message"]({ sessionID: "sRun" }, out) + assert.ok( + out.parts[0].text.includes('Missing prompt after interval "5m"'), + `expected missing-prompt failure for single-quoted input, got: ${out.parts[0].text.slice(0, 120)}` + ) + assert.equal(taskCount(dir), 0) + await hooks.dispose() + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) + +test("run mode: /loop in a later text part is still found (continue, not return)", async () => { + const dir = mkdtempSync(join(tmpdir(), "loop-run-")) + const hooks = await makeHooks(dir) + try { + const out = { + message: { id: "m9", sessionID: "sRun", role: "user", time: { created: Date.now() } }, + parts: [ + { id: "p1", sessionID: "sRun", messageID: "m9", type: "text", text: "preface" }, + { id: "p2", sessionID: "sRun", messageID: "m9", type: "text", text: "/loop 5m" }, + ], + } + await hooks["chat.message"]({ sessionID: "sRun" }, out) + // consumeLoopCommand replaces the FIRST text part with the result and + // marks the rest ignored — interception happened if the error text + // landed in parts[0] and the command part was consumed. + assert.ok( + out.parts[0].text.includes('Missing prompt after interval "5m"'), + `expected interception of later part, got: ${out.parts[0].text.slice(0, 120)}` + ) + assert.equal(out.parts[0].synthetic, true) + assert.equal(out.parts[1].ignored, true) + assert.equal(taskCount(dir), 0) + } finally { + await hooks.dispose() + rmSync(dir, { recursive: true, force: true }) + } +})