Navigace
ELOWEN · Dokumentace

Poslední aktualizace: 20. října 2026

Procházet dokumentaci · Elowen CLI pairing and enrollment

Tento článek zatím není přeložen. Je dostupný v angličtině.

Developer reference

Elowen CLI pairing and enrollment

A computer running the Elowen CLI gets its own personal API token, minted by UsersRepository.apiTokens.createCliDevice (packages/db/src/users/apiTokens.ts). The token row is the device: its cli_target_id, metadata and last-seen columns live beside the token, /auth/api-tokens never lists it, and revoking the token removes the device. Two ways lead to that token, both owned by core in src/api/routes/cliDevices.ts (registerCliDeviceRoutes):

  • Enrollment ticket. In Account > Computers the browser's system picks the installer (browserOs, from navigator.userAgentData.platform or navigator.userAgent; only an unknown system, such as a phone, offers the Windows or macOS and Linux choice), and Copy install command issues a ticket and copies the sh or PowerShell command that carries it. That one command installs the client, pairs the computer and starts the background service.
  • Device code. elowen login on a headless or manually installed client prints a link; the user approves the computer in the browser. The server package's own CLI asks for account sign-in instead when neither ELOWEN_URL nor ELOWEN_DAEMON_URL is set; the installed client runs under standalone and always pairs a computer.

Both answer only when the instance has a canonical public web URL: the one trustedPublicWebUrl(webBaseUrl()) derives from install metadata in src/daemon/brainCore.ts, passed to the routes as ServerDeps.publicWebUrl and to plugins as ctx.publicWebUrl(). The request Host header never builds a link. Without that URL the routes that hand out a link, a command or an installer answer 503 verification_unavailable.

Local account sign-in calls UsersRepository.apiTokens.create with no expiry and no device target. It creates no computer Project or Computers entry; the token is visible and revocable in Account > API tokens, and it keeps ordinary API-token permissions, limits and TOTP revocation, not browser-session authority. The CLI's mode choice grants no server authority either: an anonymous code request still needs explicit browser approval of its recorded kind, which the approval page displays and the decision body cannot change. No URL hostname, port or request Host is trusted as a local-authentication exemption.

Who may call what

Neither the CLI nor the installer has an account credential, so their routes are public paths in isPublic (src/api/auth.ts, exact method and path). The CLI's own routes authenticate by a secret in the JSON body; the two installer downloads carry none. The web BFF forwards a request without cookie or Origin tokenless and forwards only content-negotiation headers and the declared CLI client capabilities, which is why no ticket or code ever travels in a URL or header.

Every decision for an account (approve, deny, issue a ticket, list devices) requires a human in a real browser login (credentialScope 'full'). A personal API token, a turn's ElowenApi agent credential and an impersonation session all get 403 browser_session_required, so no credential can enroll another.

Enrollment tickets

src/remoteCli/enrollment.ts (CliEnrollments) keeps the tickets:

  • 32 random bytes, base64url (256 bits), bound to the account id that issued it.
  • At most one live, unredeemed ticket per account. A second POST /cli/enrollments returns HTTP 409 { error: 'cli_enrollment_active', expiresAt, remainingMs }.
  • GET /cli/enrollments returns { enrollment: { expiresAt, remainingMs } | null } for the signed-in account, with private, no-store. It never returns a ticket, command or cancellation id.
  • A successful mint returns { id, expiresAt, remainingMs, command }. The browser copies once and shows a disabled countdown, restored from the status read after F5. It samples remainingMs against performance.now(), rather than subtracting the browser wall clock from the server's expiresAt. Status is re-read on mount and every 10 seconds, releasing the lock after redemption or daemon restart. Changing the installer does not release it. The accessible button name gives the local availability time without announcing every second.
  • If the clipboard refuses the write, DELETE /cli/enrollments/:id cancels only that account's exact mint before the copy-failed toast and another attempt. The UUID is a cancellation handle, not a download or redemption credential; an old handle cannot cancel a newer mint. Failed cancellation remains an error and the next status read preserves the live lock.
  • Valid for 10 minutes. Until it is consumed it authorizes the two download routes, so the installer can fetch the archive and the client can fetch the signed descriptor of its own version.
  • Single use: POST /cli/enroll checks the live holder and account, reads canonical retry policy, then synchronously rechecks/consumes the ticket before minting, so concurrent redemptions mint at most one device. It answers strict {token,targetId,policy} (cliDeviceCredentialSchema in src/shared/cliDevices.ts). Unreadable policy returns typed 503 settings_unreadable without consuming the ticket or minting a credential. The token belongs to the issuing account; if that account was deleted meanwhile the ticket answers like a spent one.
  • Unknown, used and expired tickets answer the same 410 expired.
  • Only in daemon memory, with an unref'd expiry timer. A daemon restart forgets every ticket; the user copies a new command.
  • Never logged. The lifecycle logs (CLI enrollment issued for account #<id>, CLI enrollment redeemed as API token <id> for account #<id>) carry account and token ids only.

Device codes

src/remoteCli/pairing.ts (CliPairings) keeps the pairing requests, ported from the removed remote-execution plugin. A request has a 32-byte base64url deviceCode that only the CLI holds and an 8-character upper-case hex userCode the browser shows. It records the required kind ('account' or 'computer'), displayed in the approval card, and lives 10 minutes. Approval uses that stored kind, never a new choice in the decision body: create for account sign-in, createCliDevice for a computer. The first successful token poll hands out the token and deletes the record, so a later poll reads expired. Polling reads the canonical policy before calling the synchronous one-shot CliPairings.poll(deviceCode, policy). An approved response is {status:'approved',kind:'computer',token,targetId,policy} for pairing or {status:'approved',kind:'account',token} for account sign-in; unavailable policy returns typed 503 without losing the held credential, and simultaneous polls cannot receive it twice. This policy uses the same isRetryPolicy validator and committed projection as /connection-policy, not enrollment-specific defaults. A failed mint denies the request, answers 500 pairing_approval_failed and logs the problem code cli.pairing_failed. Requests live in daemon memory and are lost on restart, and neither code nor token is logged.

Installer

POST /cli/enrollments takes {installer: 'sh' | 'powershell'} (required, CLI_INSTALLERS in src/remoteCli/installScript.ts) and answers the command for that installer from installCommand. Both installers are rendered by src/remoteCli/installScript.ts, served as public paths and carry no secret: the public web URL is baked in, checked to be canonical and quoted for its shell, and the ticket travels only in the environment and then in a request body. In both the script body is one unit closed on its last line, so a download cut short runs nothing. Verifying the release, redeeming the ticket and installing the service all happen in the client's own client-install, never in the script.

sh (Linux and macOS)

GET /cli/install.sh serves renderInstallScript. The copied command is

{ curl -fsSL 'https://instance.example/api/cli/install.sh' || echo 'exit 1'; } | ELOWEN_ENROLLMENT_TICKET='<ticket>' sh

The ticket is an environment assignment of the sh that runs the script, not an argument: every local user can read a process's arguments, only its owner can read its environment. A failed download feeds exit 1 to that sh, so the command fails instead of running an empty script with status 0. The script body is one function called on its last line.

The script is POSIX sh with set -eu, asks nothing and parses no JSON. In order it:

  1. fails without ELOWEN_ENROLLMENT_TICKET;
  2. maps uname -s to linux or darwin and uname -m to x64 or arm64 (anything else is named and refused). A Windows shell (MINGW*, MSYS*, CYGWIN*, for example Git Bash) is sent to the Windows command in Account > Computers; WSL reports Linux and installs the Linux client;
  3. requires curl and tar;
  4. creates a mktemp -d directory that an exit trap removes;
  5. posts {ticket, platform, arch} to /api/cli/enroll/archive, building the body with printf and piping it to curl so the ticket stays out of the process list, and tells 404 (no release for this platform yet), 410 (expired or used command) and any other status apart;
  6. unpacks the archive (top directory package/) and runs ELOWEN_URL='<url>' ELOWEN_ENROLLMENT_TICKET="$ticket" "$tmp/package/bin/elowen" client-install --archive "$tmp/client.tgz", exiting with its status after the cleanup trap.

PowerShell (Windows)

GET /cli/install.ps1 serves renderPowerShellInstallScript for Windows PowerShell 5.1 and PowerShell 7. The copied command is

$env:ELOWEN_ENROLLMENT_TICKET='<ticket>'; try { irm -UseBasicParsing 'https://instance.example/api/cli/install.ps1' | iex } finally { $env:ELOWEN_ENROLLMENT_TICKET=$null }

Both values are PowerShell verbatim strings with ' doubled. irm fails on an HTTP error, so iex never runs an error body, and the finally removes the ticket from the window when the download or the parse fails before the script could. iex parses the whole text before it runs any of it, and the body is one & { ... } script block whose closing brace is the last line, so a truncated download is a parse error. The script runs in the user's own window: it never calls exit (that would close the window), every failure throws Elowen CLI install failed: ..., and its preferences live in the block's own scope. It is ASCII only, because Windows PowerShell 5.1 decodes a body without a byte order mark by the system code page. In order it:

  1. copies $env:ELOWEN_ENROLLMENT_TICKET into a local variable and removes it from the session environment, so nothing else started from the window inherits it;
  2. refuses a Windows build below 19045 ([Environment]::OSVersion.Version.Build): Elowen CLI needs Windows 10 22H2 or Windows 11. Nothing is created or downloaded before this check;
  3. fails without the ticket, pointing to Account > Computers;
  4. maps the machine's PROCESSOR_ARCHITECTURE from HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Environment (AMD64 to x64, ARM64 to arm64) and refuses anything else. The machine value names the native processor even in an emulated PowerShell; RuntimeInformation.OSArchitecture is $null in Windows PowerShell 5.1;
  5. creates a randomly named directory under [IO.Path]::GetTempPath() that a finally removes (a removal failure is reported without replacing the outcome);
  6. posts {ticket, platform: 'win32', arch} with Invoke-WebRequest -UseBasicParsing -OutFile to /api/cli/enroll/archive; the HTTP status comes from the exception's response in both PowerShell versions, and 404 and 410 get the same messages as in install.sh;
  7. unpacks the archive with %SystemRoot%\System32\tar.exe -xzf;
  8. sets ELOWEN_URL and ELOWEN_ENROLLMENT_TICKET only around & "$tmp\package\runtime\node.exe" "$tmp\package\dist\cli\clientBin.js" client-install --archive $archive, removes both in a finally, and throws on a non-zero exit code;
  9. adds the entries of the machine and user Path registry values that the window's $env:Path lacks, so elowen works in the same window at once while the session keeps its own entries.

Downloads

Both enrollment downloads reuse clientReleaseResponse (src/licence/clientReleaseCache.ts) with the licence handler deps, the same path as the authenticated /licence/client/:platform/:arch/:file route: the daemon checks the instance licence, authenticates to the publisher and serves size and hash verified bytes from its private cache. fetchClientRelease throws ClientReleaseHttpError with the publisher's status; a 404 becomes 404 notFound on every route, so a missing release is an answer and not a 500. /cli/enroll/archive resolves latest through that same helper to the running daemon's core version. It downloads that exact client archive; a missing release for that version returns 404 even when the publisher has a newer client. First installation and the paired client's update feed share this authority.

Routes

Daemon paths; the browser and the CLI reach them as <origin>/api/<path>.

Method, path Auth Body or query Answer
POST /cli/device/code public {deviceName, kind:'account'|'computer'} {deviceCode, userCode, verificationUriComplete, expiresAt}, link <publicWebUrl>/account?cat=elowenCli&code=<userCode>
POST /cli/device/token public {deviceCode} {status:'pending'|'denied'|'expired'}, once {status:'approved',kind:'account',token} or {status:'approved',kind:'computer',token,targetId,policy}
GET /cli/device?userCode= browser {userCode, deviceName, kind, expiresAt}; 410 expired; 409 device_code_already_decided
POST /cli/device/approve, /cli/device/deny browser {userCode} {status:'approved'} or {status:'denied'}; same 410 and 409
POST /cli/enrollments browser {installer: 'sh'|'powershell'} {id, expiresAt, remainingMs, command}; 409 cli_enrollment_active; 400 without a known installer
GET /cli/enrollments browser {enrollment: {expiresAt, remainingMs} | null} of the signed-in account
DELETE /cli/enrollments/:id browser {ok: true}; cancels only the caller's exact mint, a stale id cancels nothing
GET /cli/install.sh public the sh installer, text/plain; charset=utf-8, cache-control: no-store
GET /cli/install.ps1 public the PowerShell installer, same headers
POST /cli/enroll/archive ticket {ticket, platform, arch} client archive matching the running daemon version; 410 expired; 404 notFound
POST /cli/enroll/release ticket {ticket, platform, arch, version} signed descriptor of that version; 410; 404
POST /cli/enroll ticket {ticket, deviceName} consumes the ticket: {token, targetId, policy}; 410
GET /cli/devices/:targetId/stats browser from, to (YYYY-MM-DD UTC days) {from, to, totals, days} of turns, errors, tokens, cost, commands and edited files; 400 for a bad window; 404 for a foreign or unknown computer

deviceName follows the API token name rule (1 to 80 characters after trimming). expiresAt values are epoch milliseconds. Computer facts, connection state and platform metadata are served through GET /projects under ProjectView.computer (see paired computers). Revoking a device is DELETE /auth/api-tokens/:id.

Tests: tests/api/cliDevices.test.ts drives the routes through the real app; tests/remoteCli/installScript.test.ts runs the rendered sh script with sh against a local HTTP server and PATH shims, and pins the PowerShell script by a snapshot and its safety properties (ASCII, no exit, one block, the ticket only through Env:, the build check before any download), because no PowerShell runs on the build host; the real run is checked on a Windows machine.