Signal External Service Integration¶
How the amplihack signal onboarding flow talks to the outside world —
signal-cli, the local JSON-RPC daemon, and the Azure Linux (azlin / az)
fleet — and how those integrations are made testable, resumable, and
secure.
This is the developer- and operator-facing companion to the two user guides:
- Signal Onboarding — the
setup/distributeCLI. - Signal Channel — the runtime channel, config schema, and trust boundary.
Read this document when you need to understand the integration architecture, extend a service adapter, write tests against the external boundaries, or audit the security invariants (issues #971, #972).
Contents¶
- Overview
- The seam boundary
SignalCliInvokerLinkSessionClockVmLister- Dependency-injection helpers
- VM discovery: azlin → az fallback
- Fleet distribution core (
--all-vms) - Resumable on-disk state
- Per-VM isolation
- PARTIAL rollouts
amplihack-remotepublic re-exports- Security invariants
- Exit-code taxonomy
- Configuration and environment
- Testing against the boundaries
- See also
Overview¶
Signal onboarding drives four external effects:
| Effect | Backing tool | Where |
|---|---|---|
| Locate the CLI | signal-cli on PATH |
host + each VM |
| Device link | signal-cli link (device-link URI + QR) |
host + each VM |
| Daemon readiness poll | wall-clock pacing | host + each VM |
| Fleet discovery | azlin list → az vm list fallback |
operator workstation |
Every one of these is reached through an injectable trait seam rather than a
direct process/cloud call. Production code wires in real implementations; tests
construct fakes and drive them through the same &dyn Trait boundary the
production wrappers use — with zero cloud dependency, no real signal-cli,
and no device-link secret ever produced.
The seams live in
crates/amplihack-cli/src/commands/signal/seams.rs
and are compiled only under the signal cargo feature.
run_setup(args) run_distribute(args)
│ wires real impls │ wires real impls
▼ ▼
run_setup_with(&SignalCliBin, run_distribute_with(&AzVmLister, args)
&CliLinkSession, │
&SystemClock, args) ▼
│ discover_vms ── azlin ──▶ az fallback
▼ │
detect ▶ link ▶ daemon ▶ config per-VM distribute core (resumable, isolated)
The public entry points run_setup / run_distribute keep their exact
signatures (they are the stable CLI surface). The injectable _with variants
carry the seams and are what tests target.
The seam boundary¶
All four traits are object-safe (&dyn Trait) so a single core can be driven by
either the real adapter or a fake. Errors always surface — no seam silently
degrades a failure into an empty/success result.
SignalCliInvoker¶
Locates the signal-cli binary.
pub trait SignalCliInvoker {
/// Return the path to a usable `signal-cli`, or a `SignalCli` error
/// carrying install guidance. Never silently succeeds without a binary.
fn detect(&self) -> Result<PathBuf, SignalOpError>;
}
/// Production adapter: inspects `PATH` via the existing detection logic.
pub struct SignalCliBin;
A discovery failure returns SignalOpError::SignalCli(..) → exit 4 with a
message telling the operator how to install signal-cli.
LinkSession¶
Runs the interactive signal-cli link device-link handshake.
pub trait LinkSession {
/// Link a device (named `device_name`, or a default) and return the
/// linked account in E.164 form (e.g. "+15551234567").
fn link(&self, signal_cli: &Path, device_name: Option<&str>)
-> Result<String, SignalOpError>;
}
/// Production adapter over `signal-cli link`.
pub struct CliLinkSession;
Security (R9). The device-link URI (
sgnl://linkdevice…, legacytsdevice:…) is a bearer secret. Implementations MUST print it to stderr only and MUST NOT persist or log it anywhere. Only the resulting E.164 account flows onward into config/state. Test fakes must never emit a real URI. See Security invariants.
Clock¶
Abstracts the blocking pace of the daemon-readiness poll so tests run instantly and deterministically. Deliberately minimal (ruthless simplicity):
pub trait Clock {
/// Block for `dur`. The real clock sleeps; fakes typically no-op and
/// record the requested duration/call-count.
fn sleep(&self, dur: Duration);
}
/// Production adapter backed by `std::thread::sleep`.
pub struct SystemClock;
A FakeClock used in tests returns immediately from sleep, making the
wait_for_daemon loop finish without real waiting.
VmLister¶
Enumerates VM names in an operator resource group for fleet distribution.
pub trait VmLister {
/// List VM names in `resource_group`. A discovery failure **propagates**
/// (no silent empty fallback): it must surface, never masquerade as
/// "no VMs".
fn list_vms(&self, resource_group: &str) -> anyhow::Result<Vec<String>>;
}
/// Production adapter: validates the resource group, then shells out to
/// `az vm list --resource-group <rg> --output json` (explicit argv, no shell).
pub struct AzVmLister;
AzVmLister validates the resource-group name before constructing the
command, invokes az with an explicit argv array (never sh -c), and reuses
the pure vm_names_from_az_vm_list_json extractor so the parse rule cannot
drift.
Dependency-injection helpers¶
Two private helpers hold the orchestration logic; the public functions are thin wrappers that inject the real adapters.
// Stable public surface — signatures are frozen.
pub fn run_setup(args: SignalSetupArgs) -> Result<(), SignalOpError> {
run_setup_with(&SignalCliBin, &CliLinkSession, &SystemClock, args)
}
pub fn run_distribute(args: SignalDistributeArgs) -> Result<(), SignalOpError> {
run_distribute_with(&AzVmLister, args)
}
// Injectable variants — module-private in `run.rs`; they carry the seams.
fn run_setup_with(
cli: &dyn SignalCliInvoker,
link: &dyn LinkSession,
clock: &dyn Clock,
args: SignalSetupArgs,
) -> Result<(), SignalOpError>;
fn run_distribute_with(
lister: &dyn VmLister,
args: SignalDistributeArgs,
) -> Result<(), SignalOpError>;
The _with helpers are private to run.rs — they are not re-exported and
are not part of any test's import surface. What tests do reach is the public
seams module (compiled under --features signal), which exposes the
object-safe traits and their pure helpers:
Tests construct fakes for these traits and exercise them through the exact
&dyn Trait boundary the production wrappers pass in. The stable public surface
stays exactly run_setup / run_distribute; the seam traits are the injectable
extension point.
VM discovery: azlin → az fallback¶
Discovery prefers azlin (the Azure Linux fleet tool) and falls back to the
generic az vm list. The rule is a single pure combinator so it is trivially
testable and cannot silently swallow a total failure.
/// Non-empty azlin Ok → used as-is (fallback never runs).
/// Empty Ok OR Err → run the `az` fallback.
/// Both fail → Err (a total discovery failure surfaces; never an empty fleet).
pub fn resolve_vm_list<F>(
azlin: anyhow::Result<Vec<String>>,
az_fallback: F,
) -> anyhow::Result<Vec<String>>
where
F: FnOnce() -> anyhow::Result<Vec<String>>;
The name extractors are pure and tolerant — malformed or non-array input yields
an empty Vec (which then triggers the fallback rather than an error at the
parse layer):
pub fn vm_names_from_azlin_json(json: &str) -> Vec<String>;
pub fn vm_names_from_az_vm_list_json(json: &str) -> Vec<String>;
Untrusted input (R11). VM names returned by discovery are treated as untrusted. Before any name is used to construct a command it is re-validated with
validate::validate_vm_name. A discovered name that fails validation is rejected, not sanitized-and-run.
Fleet distribution core (--all-vms)¶
signal setup --all-vms --resource-group <rg> and signal distribute
--resource-group <rg> route through the same shared distribute core. The
--all-vms flag is an alias that runs host onboarding first, then hands off to
run_distribute with the equivalent arguments — there is one rollout
implementation, not two.
# These are equivalent:
amplihack signal setup --all-vms --resource-group my-rg
amplihack signal distribute --resource-group my-rg
Resumable on-disk state¶
The rollout records per-VM progress so an interrupted run resumes instead of
restarting. State lives in a JSON file written with owner-only 0600
permissions (it is secrets-adjacent) using an atomic temp-write + fsync +
rename.
/// On-disk schema version. A file claiming a HIGHER version is refused
/// (upgrade amplihack) — forward-compat is never assumed.
pub const SCHEMA_VERSION: u32;
pub enum VmStatus {
Pending, // never attempted (or reset)
Linking, // device linking in progress
Linked, // device linked, daemon not yet confirmed
DaemonRunning, // daemon up, config not yet written
ConfigWritten, // fully onboarded — the single TERMINAL success state
Failed, // reason recorded; retried on resume
}
pub struct DistributeState { /* version + per-VM records */ }
impl DistributeState {
pub fn resumable_targets(&self, all: &[String]) -> Vec<String>; // non-terminal
pub fn succeeded_count(&self) -> usize;
pub fn failed_count(&self) -> usize;
pub fn save(&self, path: &Path) -> anyhow::Result<()>; // atomic, 0600
pub fn load(path: &Path) -> anyhow::Result<Self>; // refuses newer schema
}
Key rules:
ConfigWrittenis the only terminal-success state. A resumed run skips VMs atConfigWrittenand retries every other status (Pending,Linking,Linked,DaemonRunning,Failed).--forcere-attempts VMs already atConfigWritten.- Loading a state file whose
versionexceedsSCHEMA_VERSIONis refused with an explicit error — never best-effort parsed.
Per-VM isolation¶
Each VM is onboarded independently. A failure on one VM records that VM as
Failed (with a reason) and moves on; it never corrupts a sibling's record and
never aborts the remaining rollout. State is persisted after each VM transition
so a crash mid-fleet loses at most the in-flight VM.
PARTIAL rollouts¶
Whenever one or more target VMs end Failed, the run finishes as a
PARTIAL outcome:
succeeded— count of VMs that reachedConfigWrittenthis run.total— count of VMs targeted this run.failures—Vec<(vm_name, reason)>for each failed VM.
Exit 5 is the distinct signal for "at least one VM failed" so automation can
branch on it (retry the failures, alert, etc.). Any run with a non-empty
failures set returns Partial — including the all-failed case, where
succeeded is 0 (run_distribute_with returns Partial whenever
failures.is_empty() is false). A rollout where every VM succeeds exits 0.
amplihack-remote public re-exports¶
Idle-based device linking and fleet discovery reuse building blocks from
amplihack-remote. Step 8b promotes the required helpers to the crate's public
API (see
crates/amplihack-remote/src/lib.rs):
// Idle-watchdog re-exported from amplihack-utils.
pub use amplihack_utils::idle_watchdog;
// Azlin fleet-listing parsers (JSON + human-readable text forms).
pub use azlin_parse::{parse_azlin_list_json, parse_azlin_list_text};
parse_azlin_list_json / parse_azlin_list_text¶
Pure parsers over azlin list output. Both return Vec<VM> (the crate-public
amplihack_remote::VM). Malformed or non-array input yields an empty Vec — a
parse failure is never an error at this layer, matching the discovery
combinator's contract.
use amplihack_remote::{parse_azlin_list_json, parse_azlin_list_text};
let vms = parse_azlin_list_json(azlin_json_output); // [] on malformed input
Shell / session safety¶
Remote command construction is fail-closed: session identifiers are
validated before interpolation and invalid values are rejected rather than
lossy-sanitized into a different command target. The remote executor keeps the
validator internal (amplihack_remote does not re-export a "quote-like"
transform), so callers cannot mistake a destructive stripper for a safe quoter.
The primary defense remains explicit argv arrays with no sh -c wherever an
external process is spawned directly, plus validate-before-construct for the
small remote script fragments that must be sent to azlin connect.
idle_watchdog¶
Re-exported from amplihack-utils for the idle-based linking flow. Reachable as
amplihack_remote::idle_watchdog.
Security invariants¶
The integration layer holds these invariants; each is covered by tests.
| # | Invariant | Enforcement |
|---|---|---|
| Device-link URI is secret | sgnl://, tsdevice: printed to stderr only, never persisted/logged |
LinkSession returns E.164 only; state/config files audited for URI substrings |
| Only the account persists | Written config/state contains the E.164 account, never a URI | invariant test greps every written file |
| No shell | External calls use explicit argv arrays where possible; remote script fragments validate inputs before interpolation | argv-only adapters; fail-closed session validation |
| Validate before construct | VM name, resource group, account, device name, endpoint/port validated first | validate::* allowlists |
| Discovery is untrusted | VM names from azlin/az re-validated before use |
validate_vm_name on every discovered name |
| Secrets-adjacent files are private | Config + rollout state written 0600 (atomic temp + fsync + rename) |
fsutil::write_private |
| Loopback-only daemon | Daemon endpoint must bind loopback; routable/wildcard refused | single validate_loopback_endpoint choke-point → exit 6 |
| No forward-compat guessing | State file with a newer schema version is refused | DistributeState::load |
| Failures surface | No seam degrades an error into empty/success | traits return Result; resolve_vm_list Err on total failure |
Exit-code taxonomy¶
SignalOpError maps to stable process exit codes so tooling can branch on
outcomes:
| Variant | Exit | Meaning |
|---|---|---|
Usage |
2 |
Bad flags / arguments |
Unsupported |
3 |
Feature disabled, or unsupported identity mode |
SignalCli |
4 |
signal-cli missing / failed |
Partial |
5 |
Fleet rollout partially succeeded (some VMs failed) |
Daemon |
6 |
Daemon start / loopback-endpoint failure |
Link |
7 |
Device-linking failure |
| (success) | 0 |
Host onboarded, or every VM reached ConfigWritten |
Configuration and environment¶
The integration honors the same knobs as the CLI:
| Setting | Source | Notes |
|---|---|---|
| Daemon endpoint | --endpoint / --port / AMPLIHACK_SIGNAL_ENDPOINT |
--port > --endpoint > env; default 127.0.0.1:7583; loopback-only |
| Device name | --device-name |
default amplihack-<hostname> |
| Resource group | --resource-group |
required for distribute / --all-vms |
| Explicit VM list | --vms (comma-separated) |
bypasses azlin/az discovery |
| Rollout concurrency | --concurrency |
Accepted but not honored for interactive device-linking: each VM emits a distinct QR a human must scan, so onboarding is always sequential. A value >1 is announced-and-ignored (never silently dropped), reserved for future non-interactive rollout phases |
| Force | --force |
rewrite config / retry terminal-success VMs; never re-links an already linked device |
Discovery uses the Azure toolchain's own auth (azlin / az); no credentials
are handled in-process.
Testing against the boundaries¶
Because every external effect is a seam, each boundary is testable with fakes
and no real services. The seam-injection contract constructs fakes and drives
them through the same &dyn Trait boundary production uses. Sketch:
#![cfg(feature = "signal")]
use amplihack_cli::commands::signal::error::SignalOpError;
use amplihack_cli::commands::signal::seams::{Clock, LinkSession, SignalCliInvoker};
struct FakeSignalCli(PathBuf);
impl SignalCliInvoker for FakeSignalCli {
fn detect(&self) -> Result<PathBuf, SignalOpError> { Ok(self.0.clone()) }
}
struct FakeLinkSession { account: String }
impl LinkSession for FakeLinkSession {
// Returns an E.164 account and NEVER emits a device-link URI.
fn link(&self, _cli: &Path, _name: Option<&str>) -> Result<String, SignalOpError> {
Ok(self.account.clone())
}
}
struct FakeClock; // sleep() is a no-op → deterministic, instant poll
impl Clock for FakeClock { fn sleep(&self, _d: Duration) {} }
// Inject through the object-safe boundary and assert the contract:
let seam: &dyn SignalCliInvoker = &FakeSignalCli("/usr/bin/signal-cli".into());
assert_eq!(seam.detect().unwrap(), PathBuf::from("/usr/bin/signal-cli"));
let link: &dyn LinkSession = &FakeLinkSession { account: "+15551234567".into() };
let account = link.link(Path::new("/usr/bin/signal-cli"), Some("amplihack-host"))?;
assert_eq!(account, "+15551234567"); // E.164 only — no "sgnl://" / "tsdevice:"
The
run_setup_with/run_distribute_withorchestrators are module-private, so integration tests cannot call them directly. They validate the seam contract (injectability, object-safety, error surfacing, no-URI); the private wrappers are covered by the in-crate host/fleet paths.
Contract tests that freeze this boundary:
crates/amplihack-cli/tests/signal_seams_injection.rs— DI ofFakeSignalCli/FakeLinkSession/FakeClockthrough&dyn; asserts a detection failure surfacesSignalCli(exit 4) and that linking returns E.164 only (no device-link URI).crates/amplihack-remote/tests/azlin_parse_public_api.rs— parsers callable as public API; malformed → emptyVec; valid input extracts VM names.
Run the Signal suites:
# CLI-side seams + contracts (feature-gated).
cargo test -p amplihack-cli --features signal --test 'signal_*'
# Remote-side public-API contracts.
cargo test -p amplihack-remote --test azlin_parse_public_api
See also¶
- Signal Onboarding — the
setup/distributeCLI and fleet workflow. - Signal Channel — runtime channel, config schema, security model, per-session wiring.
examples/signal-config.toml— annotated config the onboarding flow writes.- Related issues: #921 (onboarding command), #922 (device linking), #923 (fleet distribution), #924 (idempotent daemon + config), #971 (external service integration), #972 (TDD contract tests).