@ademu/adc-control
v0.1.1
Published
TypeScript operator client for the ADC daemon's control socket (PROTOCOL.md Part II, shipped in @ademu/adc-client) — pairing ceremony + token mint + ensureDaemon().
Readme
@ademu/adc-control
The operator-side onboarding client for the ADC daemon's control socket. PROTOCOL.md
(Part II; shipped in @ademu/adc-client's tarball, adc/PROTOCOL.md in the repository) is the
contract; this library is one consumer of it and cites it, never substitutes for it. The session
protocol (Part I) is @ademu/adc-client's territory — this package
never handles a session or an fd. Together they are the two halves of a programmatic agent
connector: this package gets a device onto Ademú (pairing ceremony + token mint); the session
client is how the agent lives there.
Zero runtime dependencies beyond @ademu/adc-client (whose /internal subpath supplies the
shared NDJSON framing), ESM, strict TypeScript, engines.node >= 20.
Install
npm i @ademu/adc-controlPulls @ademu/adc-client at the same lockstep version (the range is ^<this package's own
version>, re-pinned every release). Published on npm from a private monorepo, so the tarball is
self-sufficient: dist/, src/, README.md, LICENSE-MIT + LICENSE-APACHE. Releases are cut
by tag (clients-v<semver>, both libraries on one version) and published by CI via npm Trusted
Publishing — no stored or long-lived npm token exists anywhere. Pair it with @ademu/adc-bin when the host must not rely
on adc being on PATH: ensureDaemon({ binaryPath: resolveAdcBinaryPath() }). Release procedure
for maintainers: docs/RELEASING_NPM.md.
Authority: ambient, same-UID, operator-side ONLY
This package wields ambient operator authority — the same authority as the adc CLI: any
process running as the daemon's UID can drive it. It is for operator-side tooling (plugins
running on the owner's machine) and must NEVER be reachable by remote or untrusted input.
Authority never moves off the owner: creating a device is inert until the owner's phone scans the
QR, and the four-word comparison stays a human attestation — this package can render the words,
never confirm them on its own authority. The CLI is for humans; this package is for programs.
Hardened hosts (forward-compat). On a hardened deployment (dedicated service user,
mint-authority separation — the "3b" posture), same-UID self-provisioning WILL fail. The typed
PrivilegeError is reserved now for exactly that refusal: it is exported (write your degradation
branch today — print operator instructions and stop) but constructed nowhere in v1; it maps to
the structured code when 3b mints one.
Quickstart — the ceremony, as the plugin drives it
import { ensureDaemon, connect } from '@ademu/adc-control';
import { connect as attachSession } from '@ademu/adc-client';
await ensureDaemon(); // probe; spawn `adc daemon run` if absent
const control = await connect(); // daemon greets first; proto gated
const { device_id } = await control.createDevice({ agent_name: 'my-agent' });
// Render each snapshot in YOUR UI: show the QR (qrPayload), and the four
// safety words when they appear. The owner scans with their phone and
// confirms out-of-band; the poll resolves when the daemon reaches a
// terminal state.
const terminal = await control.pollPairing(device_id, (s) => render(s));
if (terminal.state !== 'enrolled') throw new Error(`ceremony ended ${terminal.state}`);
const { token } = await control.tokenMint({ device_id, label: 'my-plugin' });
const { session_socket_path } = await control.daemonInfo();
await control.close();
// The handoff: token + the daemon's OWN session socket path (the authority —
// never re-derive it while the daemon is reachable). The field is optional
// on the wire (pre-tier-1 daemons omit it) and the session client would
// silently fall back to ITS ladder if handed undefined — so fail loudly
// instead: a daemon that old cannot do token attach anyway.
if (session_socket_path === undefined) {
throw new Error('this daemon predates token attach — upgrade it');
}
const session = await attachSession({ token, socketPath: session_socket_path });pollPairing is deliberately thin: the CLI's own 1 s cadence, a snapshot per poll (terminal
included — dedupe in your renderer if you want to), terminal at enrolled/revoked/retired,
cancellation via AbortSignal. There is no "words" state — words availability is the words
field, orthogonal to state.
ensureDaemon() — spawn-if-needed, with two recorded divergences
Ports the CLI's semantics (adc agent add's probe → spawn → backoff-wait, the exact
[50,100,200,400,800,1600,3200] ms ladder), spawning adc from PATH detached with stdout/err
appended to <data_dir>/daemon.log (0600). The pre-spawn probe is a bare connect; every
post-spawn probe reads and validates the hello (1 s per rung), so a wedged or incompatible
listener fails loudly instead of reporting ready. Two divergences from the CLI, both deliberate:
- No config pre-flight. This library parses no TOML; a daemon that refuses to boot (missing
ADC_REST_BASE_URL/ADC_WS_URLor[server]config) surfaces asDaemonUnreachableErrornaming thedaemon.logto read. - No explicit spawn flags. The daemon is spawned as plain
adc daemon run— a config-blind client must never override the daemon's config-aware path resolution. If your config relocates the socket, setADC_SOCKET_PATH.
ensureDaemon({ binaryPath }) spawns that file instead of looking adc up on PATH — pair it with
@ademu/adc-bin's resolveAdcBinaryPath() when the host must not rely on a PATH install (the
OpenClaw plugin does). Only the command changes; the argv stays daemon run. ENOENT on the given
path is still NotInstalledError, now carrying .binaryPath and a message that names the path
instead of the install one-liner.
The auto-spawn inherits the CLI's accepted connect-probe race (two clients may both spawn; the loser fails cleanly — same-user blast radius only).
Socket path resolution
The daemon's hardened ladder, ported exactly: $ADC_SOCKET_PATH → gated $XDG_RUNTIME_DIR
(never on macOS) → $ADC_DATA_DIR verbatim → the XDG data chain, filename adc.sock. Never
probes candidates; joins verbatim. Deliberately omitted: the config-file rungs — this library
is zero-dependency and parses no TOML; pass socketPath or set ADC_SOCKET_PATH if your
daemon's socket moved via config.
Requests, timeouts, and the excluded verbs
request(op, params, { timeoutMs }) is the generic escape hatch; the eight typed wrappers
(createDevice, listDevices, deviceStatus, confirmWords, getPairingDisplay,
cancelPairing, tokenMint, daemonInfo) only name-type-delegate onto it (enforced by test).
The 30 s default timeout is hang insurance, not a protocol signal — the control socket answers
every well-formed request line (bad_request and unknown_op included). One connection per
client; no reconnect machinery — reconnect by calling connect() again.
Excluded on purpose: attach/detach (session territory — an attach hands an fd over
SCM_RIGHTS, which this package never touches), shutdown, and token_list/token_revoke.
Forward note: the plugin's future "forget Ademú" flow will need token_revoke; it is additive
when that flow ships, not before. token_mint rotation is replace: true — the type is the
literal true, so a plain mint omits the key entirely (wire parity with the daemon's serde).
Error taxonomy
| Error | When | Remedy |
| --- | --- | --- |
| NotInstalledError | adc not on PATH | the message carries the install one-liner |
| DaemonUnreachableError | spawn-wait exhausted, or the child exited | read .logPath; if config relocates the socket, set ADC_SOCKET_PATH |
| ProtoSkewError | the hello's proto ≠ 1 | upgrade one side; never guess at the wire |
| HelloTimeoutError | no hello within 10 s of connect | the peer is wedged or not the daemon |
| ControlError | an ok:false reply; branch on .code, never parse .message | reachable v1 codes: bad_request, unknown_op, unknown_device, device_not_ready, words_mismatch, not_cancellable, invalid_state, invalid_agent_name, label_exists, token_not_found, internal_error |
| ControlTimeoutError | no reply within the deadline (default 30 s) | a wedged/incompatible daemon, or an oversized line the daemon dropped |
| ConnectionClosedError | EOF/close; carries {op, id} when one was in flight | reconcile via queries, reconnect |
| LineTooLongError | outbound frame over the daemon's 1 MiB line cap (refused pre-write) | from @ademu/adc-client — shared instanceof |
| ProtocolViolationError | the peer sent what a correct daemon never sends | it may not be the daemon at all |
| PrivilegeError | reserved (hardened hosts, 3b) | degrade to printing operator instructions |
No error message ever carries token plaintext, agent names, QR payloads, or safety words — codes,
ops, ids, paths, and byte lengths only (CI-enforced by dev/privacy-audit.sh's TS scan).
Testing
npm test = strict compile + compile-time type fixtures (test/types/) + node:test suites,
including golden-fixture conformance (test/fixture.test.mjs — the control half's v1 lines,
decode and reproduce classes, unknown-tolerance; the Rust suite owns the total line count) and
the privacy-detector self-test (test/privacy-pattern.test.mjs, driving the real audit script
over a bait tree). CI: .github/workflows/js.yml (the adc/clients workspace, node 20 + current
LTS). The manual live lane is integration/live-control.sh (deliberately outside test/): a
fully scratch daemon lifecycle — spawn, ceremony-to-cancel, pre-enrollment mint — with no phone
ceremony (that belongs to the plugin slice's E2E).
