fix(ruflo-console): transcript search, model keys and peer sends on Windows; smoke under Git Bash

The last Windows gaps of the sweep, each tested.

- Stopping or messaging a workflow run reads the run's task id from the session
  transcript; over 2 MB that was `grep -o`, which Windows lacks. Windows now runs
  PowerShell's Select-String (windowsSearchArgv in data/wf-control.ts). The path
  and the pattern ride inside the script as base64, so a path with a space, a
  quote, $, & or ; is only data and the script holds no double quote. Tested
  against a real PowerShell on a 5 MB-class file through such a path.
- taskIdOf returned the FIRST launch when a search printed two launches of one
  run (a resume), because the pattern's `[^}]` ran across both matches (nothing
  prints a closing brace between them). It takes the last task id in the match.
  This also affected `grep -o` on Linux and macOS.
- A model key for an HTTP endpoint was read with `printenv`, which Windows lacks.
  Host gains an optional `env` (register.ts: $.env.get, already used for other
  names) and SendDeps an optional `readEnv`, set only for a Windows home; elsewhere
  `printenv` is unchanged.
- A send to a federation peer runs `bash <helper>`. On a Windows PATH `bash` is
  the WSL launcher (System32\bash.exe), which cannot open a C:\ script path, so
  the send failed obscurely. On a Windows home it now refuses, running nothing,
  and says why.
- ruflo-cost-tracker scripts/smoke.sh builds ROOT with `pwd -W` under Git Bash:
  a Windows node could not open /c/... paths, which failed 5 steps (now 66/0).

Checked and left alone: the npx launcher. The installed Claude Code (a native
binary with the host code inside it) runs $.process.run through a bundled execa
on cross-spawn, which finds `npx` as npx.cmd through PATHEXT and runs it via
cmd.exe /d /s /c with its own argument escaping, so ['npx', ...] works on
Windows. The same lookup is why `bash` there is the first bash.exe on the PATH
(the WSL launcher on this machine), which the peer refusal above relies on.

CHANGELOG: both plugins' unreleased entries (0.47.2, 0.28.3) list these.

Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01C9t8G7o2vTPAxSFNb8rP4n
This commit is contained in:
Mario JauvinandRuFlo committed 2026-10-23 23:23:19 -04:00
1 parent 7c8f221fb7
commit f547724241
11 files changed
+231 -6

No files matched your search

+4
View File
@@ -10,6 +10,10 @@ Newest first. One `## <version> — <date>` heading per version, then `feat:`, `
- fix: mission spend works on Windows (needs ruflo-cost-tracker 0.28.3, which fixes the ledger script itself).
- fix: the autopilot's text checks know Windows (groundwork: a start is still refused there). A task naming `C:\…`, `\\share\…`, `..\…`, `%USERPROFILE%\…` or `$env:USERPROFILE\…` is checked against the envelope folders; `rmdir /s`, `del C:\`, `format C:`, `Remove-Item -Recurse` and `rm -rf C:\` are hard denies; a verify command may not be `cmd`, `powershell`, `pwsh`, `wsl`, `bash.exe` and the like, by basename under either separator and without `.exe`. These only park or refuse more.
- fix: the Overview heading names the last two folders of a Windows project (`ruflo state in x/proj`), where it printed the whole `C:\…` path because it split on `/` only.
- fix: stopping or messaging a workflow run on Windows finds its task id in a session transcript over 2 MB. The search used `grep`; Windows now uses PowerShell's `Select-String` (path and pattern passed as base64, never parsed as PowerShell).
- fix: a model key for an HTTP endpoint is read through the engine's environment read on Windows, which has no `printenv`.
- fix: a send to a federation peer on Windows says why nothing was sent. The helper is a bash script, and `bash` on a Windows PATH is the WSL launcher, which cannot open a `C:…` path.
- fix: for a resumed run, the task id from a search is the latest launch, not the first. The search prints matches with no closing brace between them, so the pattern ran across both (any platform).
- chore: the real-disk POSIX write specs (modes, FIFOs) are skipped on Windows, which writes through the host file API; one `ruflo tui --plain` spec no longer needs a runnable fake codex there.
## 0.47.1 — 2026-10-24
+19 -1
View File
@@ -86,7 +86,25 @@ export function taskIdOf(text: string, runId: string): string | null {
const hits = [...text.matchAll(new RegExp(taskPattern(runId), 'g'))]
const last = hits.at(-1)
return last === undefined ? null : (/"taskId":"([A-Za-z0-9]{6,40})"/.exec(last[0])?.[1] ?? null)
// A search prints only the matches, with no closing brace between two launches of one run, so one match can hold both: the last task id in it wins.
return last === undefined ? null : ([...last[0].matchAll(/"taskId":"([A-Za-z0-9]{6,40})"/g)].at(-1)?.[1] ?? null)
}
const utf8Base64 = (text: string): string => btoa(Array.from(new TextEncoder().encode(text), byte => String.fromCharCode(byte)).join(''))
/**
* The transcript search for Windows, which has no `grep`: PowerShell's Select-String reads the file line by line and prints only what
* matched (`grep -o`). The path and the pattern ride inside the script as base64 and are decoded there, so no character of either is ever
* parsed as PowerShell (a path with a space, a quote or a `$` is only data); the script itself has no double quote. Null for a run id that is
* not one, a path with a NUL, or a host without TextEncoder/btoa.
*/
export function windowsSearchArgv(path: string, runId: string): readonly string[] | null {
if (!RUN_ID.test(runId) || path === '' || path.includes('\0') || typeof TextEncoder !== 'function' || typeof btoa !== 'function') return null
const text = (value: string): string => `[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${utf8Base64(value)}'))`
const script = `$p=${text(path)};$q=${text(taskPattern(runId))};Select-String -LiteralPath $p -Pattern $q -AllMatches | ForEach-Object { $_.Matches } | ForEach-Object { $_.Value }`
return ['powershell', '-NoProfile', '-NonInteractive', '-Command', script]
}
/** The session transcript that launched a run: the run's directory (`<session>/subagents/workflows/<run>`) without its tail, plus `.jsonl`. Null when the path is not a run path. */
+11 -2
View File
@@ -13,6 +13,7 @@ import { callTool, permissionOf } from './wf-control'
import { cleanText } from './wf-clean'
import { guardText } from './wf-guide'
import { idOf, plain, type HiveInfo } from './parse'
import { isWindowsPath } from './paths'
import { endpointsOf, type Config, type Endpoint, type Target } from './wf-targets'
export type SendDeps = Pick<Host, 'toolCall' | 'toolCheck' | 'run' | 'httpSend' | 'submitPrompt'> & {
@@ -24,6 +25,8 @@ export type SendDeps = Pick<Host, 'toolCall' | 'toolCheck' | 'run' | 'httpSend'
cwd: string
hive: HiveInfo | null
config: Config
/** Reads one environment variable. Set on Windows, which has no `printenv`; where absent a key is read with `printenv`. */
readEnv?: (name: string) => Promise<string | undefined>
}
export type SendState = 'reply' | 'queued' | 'sent' | 'error' | 'refused'
@@ -178,6 +181,9 @@ export async function sendTo(deps: SendDeps, target: Target, raw: string): Promi
case 'x-channel':
return viaCli(deps, 'x_federation_channel_publish', { channel: target.ref, msgType: 'Status', payload: { text: body, from: 'ruflo-console' } })
case 'peer': {
// The helper is a shell script. Windows has no bash of its own on the PATH (`bash` there is the WSL launcher, which cannot open a C:... script path), so nothing is run.
if (isWindowsPath(deps.helper)) return err('refused', 'a peer is reached through a bash helper script (federation.sh), which Windows cannot run from here: nothing was sent. Use a BBS room or a channel instead.')
const result = await deps.run(['bash', deps.helper, 'dispatch', target.ref, body, ...(deps.trustedPeers.has(target.ref) ? [] : ['--confirm'])], WAIT.peer).catch((error: unknown) => ({ exitCode: -1, stdout: '', stderr: error instanceof Error ? error.message : 'refused' }))
return result.exitCode === 0 && result.stdout.trim() !== '' ? { ok: true, state: 'reply', text: tidy(result.stdout).trim() } : err('error', result.stderr.trim() !== '' ? result.stderr : `the peer answered nothing (exit ${result.exitCode})`)
@@ -197,9 +203,12 @@ export async function sendTo(deps: SendDeps, target: Target, raw: string): Promi
let key: string | undefined
if (endpoint.keyEnv !== 'NONE') {
const read = await deps.run(['printenv', endpoint.keyEnv], 5_000).catch(() => undefined)
if (deps.readEnv !== undefined) key = (await deps.readEnv(endpoint.keyEnv).catch(() => undefined))?.trim()
else {
const read = await deps.run(['printenv', endpoint.keyEnv], 5_000).catch(() => undefined)
key = read !== undefined && read.exitCode === 0 ? read.stdout.trim() : undefined
key = read !== undefined && read.exitCode === 0 ? read.stdout.trim() : undefined
}
if (key === undefined || key === '') return err('refused', `$${endpoint.keyEnv} is not set in the environment Claude Code runs in: nothing was sent`)
}
+2
View File
@@ -44,6 +44,8 @@ export type Host = {
settings: () => Promise<unknown>
home: () => Promise<string | undefined>
configDir: () => Promise<string | undefined>
/** One environment variable of the engine's process, or undefined when unset. Windows has no `printenv`, so a key is read through this there. */
env?: (name: string) => Promise<string | undefined>
/** This plugin's folder: where its own files (the command catalog) are. */
pluginRoot: string
rufloSnapshot: () => Promise<RufloSnapshot>
+1
View File
@@ -126,6 +126,7 @@ function hostOf($: EngineInterface, cwd: string, toasts: { prefs: () => ToastPre
return homeOf(k => envVars[k])
},
configDir: async () => $.env.get('CLAUDE_CONFIG_DIR'),
env: async name => $.env.get(name),
pluginRoot: $.plugin.root,
// `$.ruflo` exists only where ruflo-mods is seated; validate refuses feature-detecting a noun, so these are
// async: a missing noun throws inside the promise and every caller's catch sees a rejection.
@@ -8,8 +8,9 @@
import type { RenderElement } from 'claude-code'
import { cleanText } from '../data/wf-clean'
import { controlActions, taskIdOf, taskPattern, transcriptOf, type ControlAction, type ControlInput, type TaskRef } from '../data/wf-control'
import { controlActions, taskIdOf, taskPattern, transcriptOf, windowsSearchArgv, type ControlAction, type ControlInput, type TaskRef } from '../data/wf-control'
import { tidy } from '../data/wf-send'
import { writeFlavorReady } from '../data/write-flavor'
import type { Host } from '../host'
import type { State } from '../state'
import type { WfRun } from '../data/workflows'
@@ -70,7 +71,12 @@ async function findTask(state: State, host: Host, run: Pick<WfRun, 'id' | 'dir'>
/** A transcript too large to read whole: grep prints only the launch result's task and run ids (argv, no shell; the path is a checked run path). */
async function searchTask(host: Host, path: string, runId: string): Promise<string | null> {
const result = await host.run(['grep', '-o', '-E', taskPattern(runId), path], 10_000)
// Windows has no grep: the same search through PowerShell (data/wf-control.ts); null where it cannot be built.
const argv = (await writeFlavorReady()) === 'host-fs' ? windowsSearchArgv(path, runId) : ['grep', '-o', '-E', taskPattern(runId), path]
if (argv === null) return null
const result = await host.run(argv, 10_000)
return result.exitCode === 0 ? taskIdOf(result.stdout, runId) : null
}
@@ -116,6 +116,8 @@ export function depsOf(state: State): SendDeps | null {
cwd: state.cwd,
hive: state.snapshot?.hive ?? null,
config: live.config,
// Windows has no printenv: the engine's own environment read supplies a model key there.
...(host.env !== undefined && live.home !== null && isWindowsPath(live.home) && { readEnv: host.env }),
}
}
@@ -0,0 +1,75 @@
import { execFile } from 'node:child_process'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, describe, expect, it } from 'vitest'
import { taskIdOf, windowsSearchArgv } from '../hooks/data/wf-control'
// Finding a run's task id in a session transcript too large to read whole: `grep -o` on Linux and macOS, PowerShell's Select-String on Windows.
const LAUNCH = (task: string, run: string): string => `{"type":"tool","content":"{\\"taskId\\":\\"${task}\\",\\"taskType\\":\\"local_workflow\\",\\"runId\\":\\"${run}\\"}"}`.replace(/\\"/g, '"')
describe('windowsSearchArgv', () => {
it('is a PowerShell command whose script has no double quote, and carries the path and the pattern only as base64', () => {
const argv = windowsSearchArgv('C:\\Users\\John Smith\\.claude\\projects\\a$b\'c;d&e\\s.jsonl', 'wf_abc12345') as readonly string[]
expect(argv.slice(0, 4)).toEqual(['powershell', '-NoProfile', '-NonInteractive', '-Command'])
expect(argv).toHaveLength(5)
expect(argv[4]).not.toContain('"')
expect(argv[4]).not.toContain('John Smith')
expect(argv[4]).not.toContain('wf_abc12345')
expect(argv[4]).toContain('Select-String -LiteralPath $p -Pattern $q -AllMatches')
})
it('refuses a run id that is not one, an empty path and a NUL', () => {
expect(windowsSearchArgv('C:\\s.jsonl', 'wf_x"; Remove-Item *')).toBeNull()
expect(windowsSearchArgv('C:\\s.jsonl', 'not-a-run')).toBeNull()
expect(windowsSearchArgv('', 'wf_abc12345')).toBeNull()
expect(windowsSearchArgv('C:\\s\0.jsonl', 'wf_abc12345')).toBeNull()
})
})
describe('taskIdOf on what a search printed (any platform)', () => {
it('takes the latest launch when the output holds two of one run (a resume), as the whole-file reader does', () => {
const printed = [`"taskId":"first0task","taskType":"local_workflow","runId":"wf_abc12345"`, `"taskId":"resumed9tk","taskType":"local_workflow","runId":"wf_abc12345"`].join('\n')
expect(taskIdOf(printed, 'wf_abc12345')).toBe('resumed9tk')
expect(taskIdOf(printed.split('\n')[0] as string, 'wf_abc12345')).toBe('first0task')
})
})
describe.skipIf(process.platform !== 'win32')('the Windows search, through a real PowerShell', () => {
const root = mkdtempSync(join(tmpdir(), 'wf search '))
const folder = join(root, "o'brien $x & y; z")
mkdirSync(folder)
afterAll(() => rmSync(root, { recursive: true, force: true }))
const file = join(folder, 's.jsonl')
const filler = Array.from({ length: 20_000 }, (_unused, i) => JSON.stringify({ type: 'user', i, pad: 'x'.repeat(120) }))
writeFileSync(file, [...filler.slice(0, 50), LAUNCH('first0task', 'wf_abc12345'), ...filler.slice(50, 900), LAUNCH('resumed9tk', 'wf_abc12345'), LAUNCH('other1task', 'wf_zzz99999'), ...filler.slice(900)].join('\r\n'))
const search = (path: string, runId: string): Promise<{ code: number; stdout: string }> =>
new Promise(resolve => {
const argv = windowsSearchArgv(path, runId) as readonly string[]
execFile(argv[0] as string, argv.slice(1), { timeout: 30_000, windowsHide: true }, (error, stdout) => resolve({ code: error === null ? 0 : 1, stdout }))
})
it('prints only the launch lines of that run, through a path with a space, a quote, $, & and ;', { timeout: 30_000 }, async () => {
const found = await search(file, 'wf_abc12345')
expect(found.code).toBe(0)
expect(found.stdout).toContain('first0task')
expect(found.stdout).toContain('resumed9tk')
expect(found.stdout).not.toContain('other1task')
expect(found.stdout).not.toContain('"user"')
})
it('gives the same answer as the in-process reader: the last launch of the run wins (a resume), another run is not found', { timeout: 30_000 }, async () => {
expect(taskIdOf((await search(file, 'wf_abc12345')).stdout, 'wf_abc12345')).toBe('resumed9tk')
expect(taskIdOf((await search(file, 'wf_zzz99999')).stdout, 'wf_zzz99999')).toBe('other1task')
expect(taskIdOf((await search(file, 'wf_missing00')).stdout, 'wf_missing00')).toBeNull()
})
})
@@ -0,0 +1,106 @@
import { describe, expect, it } from 'vitest'
import { sendTo, type SendDeps } from '../hooks/data/wf-send'
import { parseConfig, targetsOf, type Target } from '../hooks/data/wf-targets'
// A model key is read from the environment of the process Claude Code runs in. `printenv` does that on Linux and macOS; Windows has none, so
// there the engine's own environment read (host.env) supplies it and no program is run.
const CONFIG = 'endpoint:a=https://a.example.com/v1|KEY_A|m1'
function rig(env?: (name: string) => Promise<string | undefined>) {
const ran: string[][] = []
const headers: Record<string, string>[] = []
const deps = {
cli: [],
helper: '',
trustedPeers: new Set<string>(),
cwd: 'C:\\proj',
hive: null,
config: parseConfig(CONFIG),
run: async (argv: readonly string[]) => {
ran.push([...argv])
return argv[0] === 'printenv' ? { exitCode: 0, stdout: 'from-printenv\n', stderr: '' } : { exitCode: 127, stdout: '', stderr: 'not found' }
},
httpSend: async (_url: string, init: { headers: Record<string, string> }) => {
headers.push(init.headers)
return { ok: true, status: 200, text: JSON.stringify({ model: 'm1', choices: [{ message: { content: 'hello' } }], usage: { prompt_tokens: 1, completion_tokens: 1 } }) }
},
submitPrompt: async () => undefined,
...(env !== undefined && { readEnv: env }),
} as unknown as SendDeps
const target = targetsOf({ agents: [], peers: [], config: deps.config, hasHive: false, hasCodex: false }).find(candidate => candidate.id === 'a') as Target
return { deps, target, ran, headers }
}
describe('reading a model key for an endpoint send', () => {
it('uses the engine environment read where it is given (Windows) and runs no program', async () => {
const r = rig(async name => (name === 'KEY_A' ? ' win-secret ' : undefined))
const result = await sendTo(r.deps, r.target, 'hello')
expect(result.ok).toBe(true)
expect(r.headers[0]?.authorization).toBe('Bearer win-secret')
expect(r.ran).toEqual([])
})
it('refuses, sending nothing, when that read finds no key or fails', async () => {
for (const env of [async () => undefined, async () => '', async (): Promise<string | undefined> => Promise.reject(new Error('refused'))]) {
const r = rig(env)
const result = await sendTo(r.deps, r.target, 'hello')
expect(result.state).toBe('refused')
expect(result.text).toContain('KEY_A is not set')
expect(r.headers).toEqual([])
}
})
it('keeps `printenv` where no environment read is given (Linux, macOS)', async () => {
const r = rig()
const result = await sendTo(r.deps, r.target, 'hello')
expect(result.ok).toBe(true)
expect(r.ran).toEqual([['printenv', 'KEY_A']])
expect(r.headers[0]?.authorization).toBe('Bearer from-printenv')
})
})
describe('sending to a federation peer', () => {
const peerRig = (helper: string) => {
const ran: string[][] = []
const deps = {
cli: [],
helper,
trustedPeers: new Set(['zenbook']),
cwd: '/work',
hive: null,
config: parseConfig(''),
run: async (argv: readonly string[]) => (ran.push([...argv]), { exitCode: 0, stdout: 'peer says hi', stderr: '' }),
httpSend: async () => ({ ok: true, status: 200, text: '' }),
submitPrompt: async () => undefined,
} as unknown as SendDeps
const target = targetsOf({ agents: [], peers: [{ host: 'zenbook', trusted: true }], config: deps.config, hasHive: false, hasCodex: false }).find(candidate => candidate.id === 'peer-zenbook') as Target
return { deps, target, ran }
}
it('is refused on Windows, running nothing: the helper is a bash script and `bash` there is the WSL launcher', async () => {
for (const helper of ['C:\\Users\\u/.claude/helpers/federation.sh', 'C:/Users/u/.claude/helpers/federation.sh']) {
const r = peerRig(helper)
const result = await sendTo(r.deps, r.target, 'hello')
expect(result).toMatchObject({ ok: false, state: 'refused' })
expect(result.text).toContain('cannot run from here')
expect(r.ran).toEqual([])
}
})
it('still runs the helper with bash on a POSIX home', async () => {
const r = peerRig('/home/u/.claude/helpers/federation.sh')
const result = await sendTo(r.deps, r.target, 'hello')
expect(result).toMatchObject({ ok: true, state: 'reply', text: 'peer says hi' })
expect(r.ran).toEqual([['bash', '/home/u/.claude/helpers/federation.sh', 'dispatch', 'zenbook', 'hello']])
})
})
+1
View File
@@ -5,6 +5,7 @@ Newest first. One `## <version> — <date>` heading per version, then `feat:`, `
## 0.28.3 — 2026-10-24
- fix: `ledger.mjs`, `advise.mjs` and `local-cost.mjs` do nothing on Windows: they ran only when `import.meta.url === "file://" + argv[1]`, which is never true there (`file:///C:/…` against `C:…`), so every call exited 0 with no output. They now compare against `pathToFileURL(argv[1])`.
- fix: `ledger.mjs --project` takes a drive-lettered path, and the project filter matches Windows paths ignoring separator style and drive-letter case, so the ruflo console's mission spend works on Windows.
- chore: `scripts/smoke.sh` builds its root with `pwd -W` under Git Bash, so a Windows `node` can open the paths it is given (5 steps failed there before).
## 0.28.2 — 2026-10-12
- fix: a failed Stop-hook telemetry write is logged to ~/.claude-flow/logs/cost-tracker-failures.log (capped at 512 KB, rotated to the newest 200 lines) instead of being discarded; the hook still never blocks a turn (#3227, #4078, thanks @nicholas-ruest)
+2 -1
View File
@@ -1,7 +1,8 @@
#!/usr/bin/env bash
# Structural smoke test for ruflo-cost-tracker v0.28.3.
set -u
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
# `pwd -W` (Git Bash on Windows) gives C:/Users/...; plain pwd gives /c/Users/..., which a Windows node cannot open. Elsewhere -W fails and pwd answers.
ROOT="$(cd "$(dirname "$0")/.." && { pwd -W 2>/dev/null || pwd; })"
PASS=0
FAIL=0
step() { printf "→ %s ... " "$1"; }