Skip to content

CLI doctor verdict ​

This document specifies the machine-readable verdict the alcove doctor subcommands emit under --json. It covers the document's fields, the byte encoding of the line holding them, the schema version and the compatibility rule a consumer applies before reading anything else, the overall and per-check status vocabularies, the ordered check list each mode reports, the relay check's bound and outcomes, what the verdict withholds, and the exit code each verdict maps to. It is the spec-tier complement to the operator-facing Checking a network shared folder and Checking the relay of a WebRTC exchange sections in CLI.md, which say what the three modes check and how an operator runs them; this document says what a caller may rely on. It does not cover the SMB_* inputs the checks read (see CLI.md), the CLI-wide exit-code table these codes sit within (see CLI.md), or the rendezvous semantics the checks are chosen to exercise (see FILE_SYNC.md). Intended readers are implementors of a setup launcher or supervising process, and security auditors.

The verdict is a contract, not a formatted log: a launcher keys on the check ids and the overall value rather than parsing check lines, so a check keeps its id and a consumer reads version before anything else.

The verdict document ​

--json writes the verdict as exactly one line of JSON on stdout, and nothing else reaches stdout, so a capture or a pipe holds the verdict alone. It replaces the human check lines rather than accompanying them. Without --json no verdict document is produced at all: the check lines go to stderr, and the exit code states the classification. Those lines are a rendering for a person to read rather than log records, so they have none of the [timestamp] [level] [context] prefix the CLI puts ahead of a diagnostic, and a launcher that collects and re-prints them passes on what the operator would have seen. They are written to the same destination the diagnostics are, so --log-file captures them, and --log-level admits or suppresses them exactly as it does a diagnostic.

json
{"version":1,"mode":"probe","overall":"fix_and_retry","checks":[{"id":"tcp_445","status":"ok"},{"id":"write","status":"fail","meaning":"this account can read the folder but not write to it.","action":"ask whoever administers the share for write permission on this folder."}]}
FieldTypeValueMeaning
versioninteger1Verdict schema version (see Schema version).
modestringprobe, mount or relayWhich checks produced the verdict, matching the subcommand that ran.
overallstringone of ok, fix_and_retry, fatalThe roll-up a caller branches on (see Overall verdict).
checksarray of check objectsThe mode's full ordered check list (see The check list).

Each entry of checks:

FieldTypePresenceMeaning
idstringalwaysStable identifier from the mode's fixed list.
statusstringalwaysOne of ok, warn, fail, skipped (see Check status).
meaningstringper statusWhat the outcome means.
actionstringper statusWhat the operator does about it.
urlstringrelay mode's TURN and WebSocket relay checks onlyThe relay url the check is about, as the run lists it. On a TURN check it is the TURN url the check asked, or, on a skipped check, one it did not contact. On a WebSocket relay check it is the WebSocket relay url the check asked.
transportstringrelay mode's TURN checks onlyThe transport that url names: udp, tcp or tls. On a udp url, whether it names ?transport=udp or no transport, werift retries a failed UDP allocation over TCP to the same host and port, so an ok there may have been reached over TCP.
outcomestringrelay mode's WebSocket relay checks onlyWhat the relay answered the proof a run presents: one of admitted, not_admitted, not_reached, certificate_not_verified or unexpected_message (see The relay check).
close_codeintegerrelay mode's WebSocket relay checks, not_admitted onlyThe close code the relay refused the proof with: one of 1001, 1009, 1011, 4001, 4002, 4003, 4004, 4005, 4007 or 4008 (WEBSOCKET_RELAY.md).
registrarstringrelay mode's enrollment check onlyThe registrar invitation_relay names: the one the check enrolled at, or, on a skipped check, the one it did not contact.
lapses_atstringrelay mode's enrollment check, ok onlyWhen the enrollment lapses, as the registrar stated it: a UTC timestamp, YYYY-MM-DDTHH:MM:SSZ. Omitted when the registrar states no lapse.

No other member appears on either object. An optional field that has nothing to say is omitted rather than emitted as null, so a consumer tests presence, never a null value.

The line is printable ASCII ​

Every byte of the emitted line is printable ASCII (U+0020-U+007E). A meaning or an action interpolates text this codebase did not choose: the SMB_PATH subfolder reaches the subdirectory check's meaning verbatim, SMB_SHARE reaches the authentication and share_open checks' meaning, and SMB_SERVER reaches the name_resolution check's action. The doctor validates those three variables against the C0 controls and DEL alone as far as character content goes (the server and share names are also refused a path separator and a leading -), so a C1 control, a bidi override, an astral character, or U+2028/U+2029 in any of them is accepted and reaches the document. The authentication and share_open checks also include an smbclient NT_STATUS token in a meaning; that token is bounded to NT_STATUS_[A-Z_]+ where it is extracted, so it is ASCII by construction and cannot contain such a byte -- the three SMB_* values are the reachable sources. In a relay verdict, each check's url is configuration text, and a url from invitation_relay was chosen by the partner's invitation, as the enrollment check's registrar was. Its lapses_at is the registrar's text, read only where it is a UTC timestamp, so it is ASCII by construction. Bare JSON.stringify escapes only U+0000-U+001F, the quote and the backslash, passing DEL, the whole C1 range, and U+2028/U+2029 through. The verdict therefore rides the same encoder the probe-host-key machine lines do (asciiSafeJsonLine, apps/cli/src/util/jsonLine.ts; see SFTP host-key verification, which specifies the encoder for those lines), which rewrites every UTF-16 code unit outside that range in the encoded text to the \uHHHH escape JSON already defines for it. A launcher that cannot classify a line prints it -- to a terminal, or into a log a person later reads -- and this is what makes doing so safe.

The escapes are JSON's own, so the keys, the value types, and the values a consumer parses back are exactly what a bare JSON.stringify would have produced. This is not a second escaping altitude and not a redaction: a launcher that renders a parsed meaning or action to a human escapes it once, at its own display sink, with nothing here to double (see CONTRIBUTING.md, Operator-facing escaping), and material that must not be shown at all is withheld where the verdict is composed (see What the verdict withholds).

Schema version and compatibility ​

version is a single integer, currently 1. A consumer reads it first and refuses a value it does not know rather than parsing on.

Within a version:

  • An added field is compatible; a consumer ignores members it does not know.
  • A check added to a mode appears as an additional entry in checks, so a consumer keys on the ids it knows rather than on the list's length or an entry's position.

A new version is taken for any of: removing a field or a check id, changing what an existing field or check id means, adding a value to the status or overall vocabulary, or changing which exit code a verdict maps to. Both vocabularies are therefore closed within a version, and a consumer may switch on them exhaustively.

Overall verdict ​

ValueMeaningRolled up from
okNothing found here blocks an exchange. A warn check is reported under this value: it asks something of the operator without stopping them.No check has status fail.
fix_and_retryThe checks ran and at least one returned a verdict the operator can act on before running an exchange.At least one fail, none of them a failure that made the checks unrunnable. A fail that ends the run early, leaving the checks after it skipped, still rolls up here.
fatalThe checks could not be run, so nothing was established either way -- neither that the share works nor that it does not.At least one fail that made the checks unrunnable: the smbclient binary missing from the image, or the mount directory not listable.

The two failure classes are distinguished by overall alone; which check was the unrunnable one is not itself a field. fatal outranks fix_and_retry because the two ask different things of the caller: a fatal verdict has no action to follow, since the checks that would have produced one never ran.

Check status ​

StatusHuman labelMeaningmeaningaction
okOK:The check ran and establishes what it set out to. Nothing is asked of the operator.optionalnever
warnWARN:The check ran and does not block an exchange, but has something the operator should read -- a share that works only with --lockless-rendezvous, one nearly out of space, a folder whose listing was too long to count in full.alwaysalways
failFAIL:The check ran and what it tests is not the case. Any fail moves overall to fix_and_retry or fatal.alwaysalways
skippedSKIP:The check did not establish its property, for one of four reasons the meaning states: an earlier check failed and stopped the run; the check does not apply to the inputs given; the check was attempted and could not be completed; or, in relay mode, a run of the configuration does not contact the host, so the check does not either. Every skipped record has a meaning.alwaysoptional

warn is its own status rather than an ok with an action: a consumer classifies on status alone, without inferring severity from which optional fields are present, and both renderings key off the same field -- the human label column above is the whole mapping.

The check list ​

The id sets of probe and mount are fixed and ordered. Every id below appears in every verdict that mode produces, in the order given, whatever stopped the run: a check that did not run is reported as skipped, never omitted, so a consumer can index the verdict by id without first discovering how far the run got.

probe -- the userspace checks, run over smbclient with nothing mounted:

IdWhat a passing check establishes
name_resolutionThe server name resolves from inside the container. A literal IPv4 address needs no resolution and passes on that basis.
tcp_445A TCP connection to port 445 on the server completes.
smbclient_availableThe smbclient binary is present. Its absence is the failure that makes the rest of these checks unrunnable.
authenticationThe server accepted the credentials. A server that authenticates and then refuses to list its shares is ok with a meaning, not a failure: refusing the share list to ordinary accounts is common and decides nothing.
share_openThe share opened. When a subdirectory was given, a share root that will not list is ok with a meaning -- rights to one's own folder and nothing above it is the ordinary shape of an agency grant.
subdirectoryThe folder the exchange will run in opened, and how many entries it holds. With no subdirectory configured this is the share root, counted the same way. A folder already holding more entries than the transport's directory-listing bound is a fail, since an exchange there is refused whatever else the checks find, and its action is a folder dedicated to the exchange (see TRANSPORT_BOUNDS.md). The captured output allows each entry line room for a full-length name, but a listing can still be cut short of the bound -- by lines longer than that allowance -- so a listing too long to capture whose counted entries are within the bound is a separate warn: the count is reported as a lower bound, the meaning states that the listing could not be captured in full, and the action asks the operator to confirm the folder is within the bound or use a dedicated one.
free_spaceThe free space the server reported for the share. Below 100 MB, or none at all, is a warn; a server that reports no figure, or a listing too long to capture through to the figure after its last entry, makes this skipped.
writeA file was created in that folder.
renameThat file was renamed.
deleteThe renamed file was deleted.
markerThe cross-check file was left behind for doctor mount to find. skipped when no marker or token was supplied, and when the share refused the write.

mount -- the kernel checks, run against an already-mounted directory:

IdWhat a passing check establishes
mount_readableThe directory exists and can be listed. Its failure is the failure that makes the rest of these checks unrunnable.
markerThe file doctor probe left behind is here and is this run's, so both sets of checks examined the same directory. A marker from a different run is a warn, and so is a matching marker the mount cannot delete; an absent one is a fail, since the mount and the probe are then pointing at different directories. skipped when no marker or token was supplied.
write_renameA file was written under a temporary name and renamed into place, the shape Alcove writes every message with. A write or rename that fails is a fail, and so is one of its two working names, .alcove-w.tmp and .alcove-w2.tmp, left in the directory where the run cannot remove it first. Either fail stops the run, leaving the checks after it skipped.
exclusive_createThe share refuses to create a file that already exists -- the O_EXCL refusal Alcove's rendezvous uses to decide which side goes first. Only an "already exists" error (EEXIST) on the second create counts as that refusal. A share that does not refuse it is a warn; a share where the create could not be tested at all -- the first create failed, or the second failed with any other error -- is skipped, with an action naming the same remedy. A working name left in the directory that the run cannot remove first is a fail: the check never ran, and the name has to be cleared by hand.
rename_onto_existingThe share renames a file onto an existing one, which Alcove does when two sides meet at once. A share that will not is a warn; staging the two files that the rename needs failing is skipped; and, as for exclusive_create, an unremovable working name is a fail.

The fails of exclusive_create and rename_onto_existing are the occupied-working-name case, and it is the only fail either of those checks emits. Their three names are fixed -- .alcove-x.tmp for exclusive_create, .alcove-a.tmp and .alcove-b.tmp for rename_onto_existing -- and each is removed before its check runs; a name still present after that attempt puts overall at fix_and_retry, never fatal, and unlike the write_rename case the checks after it still run.

relay -- one check per TURN url a run of the configuration lists, in the order the run lists them (selectRunRelay: the connection's own turn entries, then the invitation_relay turn urls no own entry already names, less the urls allowed_relays leaves out), preceded by the enrollment check where invitation_relay names a registrar, and followed by one check per WebSocket relay a run dials, in the order the run lists them (selectRunRelay: the connection's own websocket_relay, then the one invitation_relay names where it is another url, less the urls allowed_relays leaves out). Only turn_1, each WebSocket relay check, and an enrollment the run would make contact a host; every other check is skipped:

IdWhat a passing check establishes
enrollmentThe registrar invitation_relay names enrolled the exchange's relay key, as it does for a run before it dials when the url the run allocates on is the invitation's and is sent the minted credential, or when the run dials the invitation's WebSocket relay under the exchange id it derives (runEnrollsAtInvitationRegistrar). Present, and first, only where invitation_relay names a registrar; skipped, with nothing contacted, where a run of the configuration does not enroll there.
turn_1The relay at the check's url, the first the run lists and the only one a run allocates on, gave this host a relay candidate. werift allocates on the first TURN url a peer is given and ignores the rest (PROTOCOL.md). Present only where a run lists a TURN url.
turn_2, turn_3, ...Always skipped: a run lists the relay at the check's url but does not contact it, so the check does not either. The number is the url's 1-based position in the run's list, so the id set is as long as that list, and a consumer reads url for which relay a check is about.
websocket_1, websocket_2The WebSocket relay at the check's url, which a run dials, admitted the proof a run presents there. A run dials every WebSocket relay it lists, so each is contacted. The number is the relay's 1-based position in the run's list, and a run lists two at most. Present only where a run lists a WebSocket relay.

The relay check ​

doctor relay runs only when it is named: doctor probe and doctor mount contact no relay and no registrar.

The check contacts no host a run of the configuration does not contact, and of a run's contacts only these: the first TURN url the run lists, every WebSocket relay the run dials, and the registrar invitation_relay names only where runEnrollsAtInvitationRegistrar is true for the configuration. It discloses this machine's address, and the credential or proof a run presents, to no host a run of the configuration would not disclose them to.

  • Refusals. A configuration whose connection.channel is not webrtc, one that configures ice_provision, and one that lists no TURN url and no WebSocket relay are each a usage error (exit 64), refused before anything is contacted. So is one whose every relay url allowed_relays leaves out, TURN and WebSocket alike, and its refusal names those urls. So is one that lists a WebSocket relay and sets no role, since the proof a WebSocket relay takes names this side's role.

  • The host statement. Before its first request, the check prints the hosts it will contact:

    • the first TURN url, with the transport it names, whether it came from connection.turn or the invitation, that it is the relay a run allocates on, and that whoever runs it learns this machine's network address;
    • each WebSocket relay a run dials, whether it came from connection.websocket_relay or the invitation, that the check opens two connections to it, presents on each the proof a run presents there, and closes both once the relay answers, and that whoever runs it learns this machine's network address;
    • the registrar, where the run would enroll there, with the statement that the check enrolls this exchange's relay key there as a run does and that whoever runs it learns this machine's network address.

    It then prints each host the configuration names that it will not contact, with the reason that a run of this configuration does not contact it: every TURN url after the first, which a run does not allocate on, each relay url allowed_relays leaves out, stated as Left out by connection.allowed_relays., and the registrar where invitation_relay names one the run would not enroll at. Where invitation_relay names no registrar, the statement says the check contacts none and enrolls nothing; where the configuration has no invitation_relay block, it says nothing about one. Each host is alone on its line, with the explanation on the lines below it and, for a TURN url, its transport and its source there too, so a host longer than the display budget (DISPLAY_REDACTION.md) loses only its own tail on the statement. A check's summary names its url on one line with the rest of the summary, so a url long enough to fill that budget loses the rest of the summary there, while the verdict's url member has the url as the run lists it. It prints them to the destination the human check lines use -- stderr, or the --log-file -- with or without --json, at every --log-level, silent included. It contacts no other host: not connection.relay_registrar, no coordination server, and no STUN server.

  • The enrollment. Where the run would enroll at the registrar invitation_relay names, the check enrolls the exchange's relay key there before it asks the relays, through the call a run makes before it dials (enrollAtInvitationRelay; PROTOCOL.md): one attempt without a token, under the run's wait. Its outcome is the enrollment check: ok with the lapse the registrar states, or warn with the first sentence of the run's warning, holding the registrar's answer, as the summary. The warn has a meaning saying a run makes the same enrollment, and an action to run the check again and, if the enrollment keeps failing, to ask the partner whether the registrar their invitation names is up; it adds that a run goes on without the enrollment, and that the invitation's relay refuses the run's credential until either party enrolls the exchange there. The answer is the registrar's text, so it stays in the summary the JSON withholds. A failed enrollment does not stop the check: the relays are still asked. Where invitation_relay names a registrar the run would not enroll at, the enrollment check is skipped, with a meaning that a run of this configuration does not enroll there, because the relay it allocates on is not one the invitation names or is sent its connection.turn entry's own credential, because it proves to the WebSocket relay the invitation names under the exchange id the invitation names, because the WebSocket relay it dials is not one the invitation names, or because connection.allowed_relays leaves out the relay it names.

  • The request. For the first TURN url alone the check builds a werift peer connection as a run does, except that it gathers relay candidates only, with that url as its only ICE server, presenting what the run presents there: the entry's own username and credential, or the credential the run mints from the shared secret (relayCredentialForRun). It creates a data channel and a local offer, and waits for gathering to complete. No later TURN url is contacted.

  • The WebSocket relay request. For each WebSocket relay a run dials, the check takes the run's own dial (webSocketRelayDialsFor), so the exchange id, the role, the relay key and the proof are derived as a run derives them, and parks two sockets on it in this side's role through the run's own relay client (parkWebSocketRelay; WEBSOCKET_RELAY.md). Each socket sends the hello a run sends, and corrects an expired proof on the relay's clock as a run does. The relay sends nothing to an admitted hello until a partner arrives, and it checks the role only once the proof has verified (WEBSOCKET_RELAY.md), so the second socket it admits is refused as role taken (4006). The check reads that refusal, or paired on either socket, as the admission. It closes both sockets once it has an answer, with 1000, as a run closes a socket on which it has not sent its start, except a socket that received a frame the client does not take, which it closes with 4000, as a run abandons such a socket. Neither socket sends a start, so the check opens no relayed path and sends no exchange message.

  • The stated limit of the WebSocket relay request. The check stops at admission. It establishes that this host reached the relay and that the relay accepted the proof a run presents, and not that a relayed path between the two parties would carry the exchange: that needs the partner, which the check does not wait for. A partner already waiting at the relay in the other role is paired with one of the check's sockets, and the check's close then reads to that partner as a partner leaving the pairing, on which its relay client dials again (WEBSOCKET_RELAY.md).

  • The bound. The requests run at once, under one timer of 30 seconds, an arbitrary working value, started once the enrollment has ended. A gathering that has not completed when it expires is reported as not reached, and the peer connection is closed, ending the gather. A WebSocket relay that has not answered when it expires is reported as not reached, and its sockets are closed. The certificate check of a WebSocket relay runs under the same timer as well as its own budget, the signaling certificate check's (WEBRTC_TRANSPORT.md), so the check takes no longer than the one bound. A gather that throws rather than ending ends the check with an error and no verdict, and so does a WebSocket relay request that the run's relay client ends with a failure of its own, such as a proof it could not build or a socket it could not dial, rather than with an answer from the relay. A close from the relay, and a message from it that the run's relay client does not take, are that relay's own outcome, and every other check is reported beside it.

  • The outcomes. The first url is reached when a relay candidate is among this side's candidates (the same reading of the ICE statistics a run's failure diagnosis makes). Its check, turn_1, is ok when it is reached, and fail otherwise, with no relay candidate gathered in the summary and the remedy a run's relay failure has in CLI.md as the action. Where a run also dials a WebSocket relay, that action adds that each WebSocket relay line below shows whether the exchange can still go that way. werift reports no per-server error from the peer connection, so a relay that refused the allocation, one that never answered, and one whose certificate did not verify are one outcome, and the meaning says the reason is not reported. A relay that has not enrolled the exchange's relay key refuses the minted credential, so where the enrollment failed and no run on either side has enrolled the key, the invitation's url is not reached on any network, and its check's meaning names the failed enrollment as the likely cause. Every later url's check is skipped, with a meaning that a run of this configuration does not contact it, since a run allocates on the first url alone, and an action to list it first and run the check again if it should be the run's relay. The human rendering prints that shared meaning and action once, under the last of them.

  • The WebSocket relay outcomes. Each WebSocket relay's check is ok with outcome admitted where the relay admitted the proof. It is fail otherwise, with one of four outcomes, each with its own meaning and action:

    • not_admitted, where the relay refused the hello with a close code WEBSOCKET_RELAY.md lists for a hello it refuses, held in close_code. A first 4005 on a socket is not an answer, since the relay client makes the proof again at once on the relay's clock. The action follows the code:
      • For 4003, run the check again and ask the partner whether the registrar their invitation names is up, where a run enrolls at that registrar on this relay's account (webSocketRelayEnrollsAtInvitationRegistrar, decided by the url as runEnrollsAtInvitationRegistrar is); ask the partner to enroll the exchange's relay key at their relay's registrar with their relay-owner token, which this side does not hold, where the relay is the invitation's WebSocket relay dialed under the exchange id the invitation names (webSocketRelayIsPartnersUnderInvitationExchangeId, decided by the url likewise); and otherwise enroll the exchange's relay key with alcove enroll-relay. Where the enrollment failed and a run enrolls there on this relay's account, the 4003 meaning names the failed enrollment as the likely cause.
      • For 4004, register the current key with alcove enroll-relay --replace-relay-key.
      • For a 4005 after the relay client made the proof again on the relay's clock, the meaning says this computer's clock is not the cause, and the action is to run the check again and, if the relay refuses the proof the same way, check the relay and the exchange's enrollment at its registrar, as the relay client's own failure says. A 4005 whose reason is not a second the relay client reads is the answer at once, since no proof can be made on the relay's clock; for it the action is to correct this computer's clock first.
      • For 4007, check the network path to the relay, including any proxy.
    • unexpected_message, where the relay answered the proof with a message the run's relay client does not take at that point, so the url may not name an Alcove WebSocket relay. The action is to check that the url names one.
    • certificate_not_verified, where a socket closed before it opened and the certificate check a run makes of a failed signaling socket (WEBRTC_TRANSPORT.md) found that the relay's TLS certificate did not verify on this host. The action names the remedy where the network intercepts TLS: add its certificate authority to this machine's trust store, or name a file holding it in NODE_EXTRA_CA_CERTS.
    • not_reached, for anything else: a socket that closed before the relay answered the proof, a certificate that verified or could not be checked, or no answer within the bound. Where Node's environment proxying is on, the certificate is not checked, and the meaning and action say so.
  • Rollup. As Overall verdict states, a fail makes overall fix_and_retry, and a warn or skipped leaves it ok. Only turn_1 and the WebSocket relay checks can fail, so they alone decide overall. When any relay the check asks is not reached, not admitted, or does not answer as an Alcove WebSocket relay, overall is fix_and_retry, even though a run may still succeed over another relay the configuration lists. A failed enrollment is a warn and leaves overall ok: the enrollment serves the relays a run uses, and their own checks have the consequence. The check reports no fatal.

What the verdict withholds ​

The record set behind both renderings has three fields the JSON document does not: the check's summary headline, a detail excerpt of the tool's own output behind a failure, and the marker for a failure that stopped the run.

In relay mode the summary is composed from the check's own id, status, url, transport, registrar, lapses_at, outcome and close_code and the check's 30-second bound, by core's relayCheckHeadline (packages/core/src/relayCheck.ts). The one exception is a warn enrollment, whose summary also holds the registrar's answer. A consumer that composes a headline from the verdict with that function states the line the CLI prints, less that answer. The host statement is core's too (relayCheckPlan and relayHostStatement), so a consumer can state the hosts a check of a configuration contacts before it runs one. Neither rendering, nor the host statement, holds the credential or the proof a run presents, or anything else derived from the shared secret, such as the relay key or an exchange id the run derives.

detail is server-controlled text -- smbclient can answer with a whole share listing -- so a consumer receives the classified meaning and action rather than raw bytes to re-render. In the human rendering, where it is shown, a private-key strip runs over the whole excerpt first, before it is bounded. A key block arrives in its canonical multi-line form, so a strip applied to the bounded lines instead would match its BEGIN line alone and render the body verbatim, and this rendering's sink does not pass through the logger that would otherwise catch it (see DISPLAY_REDACTION.md). The stripped excerpt is then bounded to the first 2,000 characters and the first 24 non-blank lines, with a trailing ... (output truncated) line when either bound cut it -- measured against the stripped text, so a replacement that shortened the excerpt without either bound dropping anything does not report a cut that did not happen. The operator is asked to send that rendering on to whoever is helping them, so it is capped rather than sprayed. The subdirectory check reports a folder's entry count for the same reason and never its filenames: those are the operator's own names on their own share. For the same reason the excerpt behind a failure drops every directory-listing entry line from the tool's output, so a listing that failed partway -- one the wait cut short included -- leaves only smbclient's own messages in it.

Exit codes ​

The exit code is set from overall, identically with and without --json, so a caller that does not parse the verdict still receives the classification:

overallCodeName
ok0success
fix_and_retry78EX_CONFIG
fatal69EX_UNAVAILABLE

The two nonzero values follow BSD sysexits, as the rest of the CLI does: fix_and_retry is a configuration the operator changes, and fatal is something the doctor depends on not being available (the smbclient binary, or the mounted directory itself).

Every code above is below 125, which Docker reserves for its own failure to start a container, so a caller running the doctor in a container can tell "Docker could not run it" from any verdict the command reaches.

A usage error -- a missing or malformed input, a bad flag, or a configuration doctor relay refuses -- is not in that set: it exits 64 (EX_USAGE) and prints no verdict on either stream, because the checks never ran. A caller that sees 64 has established nothing about the share.

A local fault that stops doctor probe before it can compose a verdict -- the temporary directory or the credentials file in it cannot be created or restricted to its owner, or a working file cannot be written locally -- also prints no verdict on either stream, and exits 69: something the checks depend on was not available, the same class a fatal verdict reports. A caller that sees 69 with nothing on stdout treats it as fatal.

64 is also what an image predating this command answers to doctor probe: option validation does not reject the two words as positionals, so they reach the zero-setup default command, which reads the first of them as a server URL and refuses it. No exit code separates that from a doctor rejecting an input, and a published image cannot be changed after the fact. So a caller that may meet an older image establishes the command's presence before it runs any check -- doctor --help answers that without reading an SMB_* value or touching the share -- rather than reading a 64 as a verdict about its own inputs.

Cleanup limits ​

Probe cleanup is attempted, never guaranteed. Every working file a run creates is named alcove-probe-<suffix>.tmp or alcove-probe-<suffix>.tmp.renamed, the suffix being the run's SMB_TOKEN or a random value, and on every handled exit path one masked delete, del alcove-probe-<suffix>.tmp*, is issued for whichever of them may still be on the share. Its outcome is not re-verified: a share that refuses deletes or a transport that dies mid-run leaves the file in place. The next probe run deletes alcove-probe-*.tmp* before its own staged test, which is the designed safety check for such residue.

An interrupt (SIGINT or SIGTERM) is a handled exit path, bounded at 8 seconds from the signal to its re-raise. The run prints one line to stderr, cleaning up, up to 8 s; press Ctrl-C again to skip, stops the smbclient command in flight (SIGTERM, then SIGKILL 2 seconds later), issues the masked delete under a 3-second timeout, removes its local credentials directory, and re-raises the signal. Whatever is still running when the bound expires is abandoned; the credentials directory is removed before the re-raise in every case. A second interrupt abandons the cleanup at once, likewise removing the credentials directory before it re-raises.

A SIGKILL cannot be handled, so one sent during the cleanup leaves the credentials file on disk in an alcove-doctor-* directory under the temporary directory, and may leave probe files on the share. A supervisor's stop timeout must be at least the 8-second bound.

The marker file is the single persistent artifact: the probe leaves it behind, and doctor mount consumes it on a matching cross-check.