host

package
v1.10.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 16, 2026 License: MIT Imports: 32 Imported by: 0

Documentation

Overview

Package host implements the docsgpt-cli host daemon and pairing flow.

Index

Constants

View Source
const LaunchdLabel = "com.arc53.docsgpt-cli-host"

LaunchdLabel is the launchd job label for the host daemon. Doubles as the plist basename (Label + ".plist").

View Source
const ServiceUnitName = "docsgpt-cli-host"

ServiceUnitName is the systemd unit name (no extension) for the host daemon.

View Source
const WindowsTaskName = "docsgpt-cli-host"

WindowsTaskName is the Task Scheduler task name for the host daemon, mirroring ServiceUnitName (systemd) and LaunchdLabel (launchd).

Variables

View Source
var ErrAuthRejected = errors.New("auth rejected")

ErrAuthRejected is the sentinel PollOnce / RunSSE return on 401. Callers (RunPolling) escalate this to ErrRevoked once they have seen at least one successful poll, or immediately when the very first poll is rejected (which indicates a bad token from the start).

View Source
var ErrRevoked = errors.New("device session revoked")

ErrRevoked is returned by RunPolling / RunSSE when the server has revoked the device session. cmd/host.go translates this into a non-zero exit so the daemon does not poll forever against a dead token.

View Source
var ErrUnsupportedOS = errors.New(
	"install-service supports linux (systemd), macOS (launchd), and " +
		"Windows (Task Scheduler) only; run `docsgpt-cli host` manually " +
		"on this platform",
)

ErrUnsupportedOS is returned by service operations on platforms without a supported service backend (anything other than Linux/systemd, macOS/launchd, or Windows/Task Scheduler).

Functions

func CanonicalPayload

func CanonicalPayload(method, path, ts string, body []byte) string

CanonicalPayload builds the string that gets signed / verified: "METHOD PATH TIMESTAMP SHA256_HEX(BODY)". The body hash binds the request body into the signature so a captured signature can't be replayed with a tampered body inside the timestamp window. For a request with no body (GET) this is the SHA-256 of the empty string.

KEEP IN SYNC with the backend verifier (application/api/devices/auth.py `_canonical_payload`). The hex encoding and single-space separators must match byte-for-byte.

func ClearLocalState added in v1.7.0

func ClearLocalState() ([]string, error)

ClearLocalState deletes host.yml, host.key and the spool without contacting the server and returns the names of what it removed.

func CurrentSystemUser

func CurrentSystemUser() (string, error)

CurrentSystemUser returns the OS user the CLI is running as. Used as a sensible default for `--system --user`.

func DaemonReload

func DaemonReload(mode ServiceMode) error

DaemonReload runs `systemctl [--user] daemon-reload`.

func DeleteWindowsTask

func DeleteWindowsTask() error

DeleteWindowsTask removes the scheduled task registration.

func DetachConsole

func DetachConsole()

DetachConsole is a no-op outside Windows; systemd and launchd services have no console window to shed.

func DisableNow

func DisableNow(mode ServiceMode) error

DisableNow runs `systemctl [--user] disable --now <unit>`.

func EnableNow

func EnableNow(mode ServiceMode) error

EnableNow runs `systemctl [--user] enable --now <unit>`.

func EndWindowsTask

func EndWindowsTask() error

EndWindowsTask stops a running instance of the task. Failing is normal when no instance is running.

func EnterServiceMode

func EnterServiceMode(logFile string) error

EnterServiceMode reroutes the daemon's output for running under a service manager with no terminal attached (the Windows scheduled task): stdout and stderr append to `logFile`, styling is disabled so the log stays free of ANSI escapes, and any attached console window is dropped. Callers must invoke it before printing anything.

func HasLocalState added in v1.7.0

func HasLocalState() bool

HasLocalState reports whether host.yml, host.key or the spool exists.

func HostConfigPath

func HostConfigPath() string

HostConfigPath returns the absolute path to host.yml.

func HumanDuration added in v1.7.0

func HumanDuration(d time.Duration) string

HumanDuration renders a Duration as 14s, 2m, 1h, etc. Trades precision for legibility — these are status lines, not metrics.

func InstallService added in v1.7.0

func InstallService(systemFlag bool, runUserFlag string) error

InstallService installs the daemon with the platform's service backend (systemd on Linux, launchd on macOS, Task Scheduler on Windows). Zero flags let the mode resolver pick system-vs-user from root. Flag-specific guards such as "--system requires root" are the caller's.

func IsDarwin

func IsDarwin() bool

IsDarwin reports whether the current platform is macOS.

func IsLinux

func IsLinux() bool

IsLinux reports whether the current platform is Linux.

func IsWindows

func IsWindows() bool

IsWindows reports whether the current platform is Windows.

func JournalArgs

func JournalArgs(mode ServiceMode) []string

JournalArgs returns the args prefix to inspect logs via journalctl.

func LaunchdBootout

func LaunchdBootout(mode ServiceMode, plistPath string) error

LaunchdBootout unloads the plist from launchd, trying the modern `launchctl bootout <domain> <plist>` first and falling back to the legacy `launchctl unload -w <plist>`.

func LaunchdBootstrap

func LaunchdBootstrap(mode ServiceMode, plistPath string) error

LaunchdBootstrap loads the plist into launchd. It tries the modern `launchctl bootstrap <domain> <plist>` first and, if that fails, falls back to the legacy `launchctl load -w <plist>`. Returns the error from the fallback (or nil) so the caller can surface manual steps when both fail.

func LaunchdBootstrapCmd

func LaunchdBootstrapCmd(mode ServiceMode, plistPath string) string

LaunchdBootstrapCmd returns the manual bootstrap command string, shown to the user when the automatic load fails.

func LaunchdDomainTarget

func LaunchdDomainTarget(mode ServiceMode) string

LaunchdDomainTarget returns the launchctl domain target for the given mode: "gui/<uid>" for a LaunchAgent, "system" for a LaunchDaemon.

func LaunchdLogPath

func LaunchdLogPath(mode ServiceMode, runUser string) (string, error)

LaunchdLogPath returns the log file path written into the plist. Both modes log to the runtime user's ~/.docsgpt/host.log so the file is owned by, and readable to, the user the daemon runs as.

func LaunchdPlistPath

func LaunchdPlistPath(mode ServiceMode, runUser string) (string, error)

LaunchdPlistPath returns the absolute plist path for the given mode. LaunchAgent goes under the runtime user's ~/Library/LaunchAgents; LaunchDaemon under /Library/LaunchDaemons.

func LaunchdServiceTarget

func LaunchdServiceTarget(mode ServiceMode) string

LaunchdServiceTarget returns the launchctl service target ("<domain>/<label>") used by `launchctl print`.

func LogStamp

func LogStamp(t time.Time) string

LogStamp returns the `[hh:mm:ss]` prefix used for daemon log lines.

func RegisterWindowsTask

func RegisterWindowsTask(xmlPath string) error

RegisterWindowsTask creates (or replaces) the scheduled task from the XML definition at `xmlPath`.

func RenderLaunchdPlist

func RenderLaunchdPlist(mode ServiceMode, exec, runUser, logPath string) string

RenderLaunchdPlist returns the plist XML for the given mode.

`exec` is the absolute path to the docsgpt-cli binary; `logPath` is the StandardOut/StandardError destination. For ServiceModeSystem, `runUser` (when non-empty) is emitted as a UserName key so the daemon does not run as root; it is ignored for ServiceModeUser (LaunchAgents run as the user).

KeepAlive is {SuccessfulExit=false}: restart on a non-zero exit (crash), but not on a clean exit (a revoke exits 0), so a revoked device does not restart-loop.

func RenderServiceUnit

func RenderServiceUnit(mode ServiceMode, exec, systemUser string) string

RenderServiceUnit returns the systemd unit file content for the given mode.

`exec` is the absolute path to the docsgpt-cli binary. For ServiceModeSystem, `systemUser` is the OS user the service should run as and must be non-empty; for ServiceModeUser it is ignored.

func RenderWindowsTaskXML

func RenderWindowsTaskXML(execPath, runUser string) string

RenderWindowsTaskXML returns the Task Scheduler task definition for the host daemon: run at logon as `runUser`, restart on failure, no run-time limit. The action invokes `exec host --service` so the daemon drops its console window and appends to the host log file instead of a terminal.

Two settings depart from Task Scheduler defaults on purpose: ExecutionTimeLimit PT0S (the default PT72H would kill the daemon after three days) and the battery settings (a laptop host should keep running unplugged). RestartOnFailure mirrors systemd Restart=on-failure; a revoke exits 0, which Task Scheduler does not treat as a failure, so a revoked device stays stopped.

func ResolveExecutable

func ResolveExecutable() (string, error)

ResolveExecutable returns the symlink-evaluated absolute path to the currently-running CLI binary. The unit file should not reference a volatile symlink such as a freshly-built /tmp path.

func RevokeFromServer

func RevokeFromServer(cfg HostConfig) error

RevokeFromServer hits DELETE /api/devices/{id} using the stored token.

func RunDaemon added in v1.7.0

func RunDaemon(opts DaemonOptions) error

RunDaemon loads host.yml and runs the long-lived poll/stream loop until SIGINT/SIGTERM (nil) or a revoke (ErrRevoked, after printing why; the caller exits 0 so service managers do not restart a revoked device).

func ServiceInstallSupported

func ServiceInstallSupported() bool

ServiceInstallSupported reports whether install-service has a service backend for the current platform (systemd on Linux, launchd on macOS, Task Scheduler on Windows).

func ServiceUnitPath

func ServiceUnitPath(mode ServiceMode) (string, error)

ServiceUnitPath returns the absolute filesystem location of the unit file.

func ShowStartupBanner

func ShowStartupBanner(cfg HostConfig)

ShowStartupBanner prints the connection banner on daemon start. The approval_mode line is fetched live (server is the source of truth, and the user can change it in the UI at any time); on any failure the banner degrades to a no-value line rather than printing a stale local value.

func SpoolDir added in v1.9.0

func SpoolDir() string

SpoolDir holds the reports of commands not yet delivered to the server (one journal per invocation), so a restart does not lose them.

func StartWindowsTask

func StartWindowsTask() error

StartWindowsTask starts the scheduled task immediately (the logon trigger only fires on the next logon).

func SystemctlArgs

func SystemctlArgs(mode ServiceMode) []string

SystemctlArgs returns the args prefix to invoke systemctl for the given mode.

func UninstallService added in v1.7.0

func UninstallService() error

UninstallService removes whichever service InstallService set up.

func WindowsTaskInstalled

func WindowsTaskInstalled() bool

WindowsTaskInstalled reports whether the scheduled task is registered.

func WindowsTaskRegisterCmd

func WindowsTaskRegisterCmd(xmlPath string) string

WindowsTaskRegisterCmd returns the manual registration command string, shown to the user when the automatic `schtasks /Create` fails.

func WindowsTaskXMLPath

func WindowsTaskXMLPath() string

WindowsTaskXMLPath returns where the rendered task definition lives (~/.docsgpt/docsgpt-cli-host.task.xml). The file stays on disk after install so a failed `schtasks /Create` can be retried by hand.

func WriteLaunchdPlist

func WriteLaunchdPlist(path, content string, mode ServiceMode) error

WriteLaunchdPlist writes the plist at `path` with 0644 permissions, creating parent directories (0755 for daemons, 0700 for the per-user agent dir under ~). It also ensures the log file's parent directory exists.

func WriteUnitFile

func WriteUnitFile(path, content string, mode ServiceMode) error

WriteUnitFile writes the unit file at `path` with 0644 permissions, creating any missing parent directories with mode 0755 (system) or 0700 (user, under ~).

func WriteWindowsTaskXML

func WriteWindowsTaskXML(path, content string) error

WriteWindowsTaskXML writes the task definition at `path`, creating the parent directory (0700 — it is ~/.docsgpt) when missing.

Types

type Baton

type Baton struct {
	// contains filtered or unexported fields
}

Baton is the single-flight guard between Polling and Streaming goroutines.

func (*Baton) LastActivity

func (b *Baton) LastActivity() time.Time

LastActivity returns the zero value before the first successful poll.

func (*Baton) SessionID

func (b *Baton) SessionID() string

SessionID returns the currently-active SSE session id (empty when polling).

func (*Baton) SetSessionID

func (b *Baton) SetSessionID(id string)

SetSessionID stores the session id for the active SSE.

func (*Baton) State

func (b *Baton) State() State

State returns the current state under lock.

func (*Baton) TouchActivity

func (b *Baton) TouchActivity()

TouchActivity records that the daemon successfully reached the server.

func (*Baton) Transition

func (b *Baton) Transition(from, to State) bool

Transition atomically swaps state if `from` matches. Returns true on success, false if a different state is currently active.

type DaemonOptions added in v1.7.0

type DaemonOptions struct {
	PollInterval string // overrides host.yml's poll_interval when set
	ServiceMode  bool   // log to the host log file instead of the terminal
	Version      string
	AutoUpdate   string // update.ModeOn / ModeNotify / ModeOff
}

DaemonOptions configures RunDaemon.

type DeviceMe

type DeviceMe struct {
	ID           string `json:"id"`
	Name         string `json:"name"`
	Hostname     string `json:"hostname"`
	OS           string `json:"os"`
	Status       string `json:"status"`
	ApprovalMode string `json:"approval_mode"`
	Description  string `json:"description"`
	PairedAt     string `json:"paired_at"`
	LastSeenAt   string `json:"last_seen_at"`
}

DeviceMe is the shape of GET /api/devices/me — the live, server-side view of this device. The server is the source of truth for approval_mode.

func FetchDeviceMe

func FetchDeviceMe(cfg HostConfig, timeout time.Duration) (d *DeviceMe, unauthorized bool, err error)

FetchDeviceMe queries the live server-side view of this device. A nil device with unauthorized=false means the server was unreachable; unauthorized=true means the device has been revoked. `timeout` bounds the request — callers that must not block (e.g. the startup banner) pass a short one.

type HostConfig

type HostConfig struct {
	DeviceID     string `yaml:"device_id"`
	SessionToken string `yaml:"session_token"`
	BaseURL      string `yaml:"base_url"`
	PollInterval string `yaml:"poll_interval"`
	ApprovalMode string `yaml:"approval_mode"`
	LogFile      string `yaml:"log_file"`
}

HostConfig is the persistent state at ~/.docsgpt/host.yml.

func DefaultHostConfig

func DefaultHostConfig() HostConfig

DefaultHostConfig returns the canonical defaults for a fresh host.yml.

func LoadHostConfig

func LoadHostConfig() (HostConfig, error)

LoadHostConfig reads host.yml; returns the defaults + os.IsNotExist if missing.

func (*HostConfig) PollIntervalDuration

func (c *HostConfig) PollIntervalDuration() time.Duration

PollIntervalDuration converts the textual poll_interval into a time.Duration.

func (*HostConfig) Save

func (c *HostConfig) Save() error

Save persists the config to disk with 0600 permissions.

type HostKey

type HostKey struct {
	Private ed25519.PrivateKey
	Public  ed25519.PublicKey
}

HostKey holds the Ed25519 keypair used to sign requests to the server.

func LoadOrCreateKey

func LoadOrCreateKey() (*HostKey, error)

LoadOrCreateKey returns the persisted key, generating + persisting one on first use. The private key is stored alongside the public key, base64- encoded, one per line: `priv\npub\n`. File mode is 0600.

func (*HostKey) Fingerprint

func (k *HostKey) Fingerprint() string

Fingerprint returns the hex SHA-256 of the public key bytes. The server stores the same hex digest in devices.machine_pubkey_fingerprint.

func (*HostKey) PublicKeyB64

func (k *HostKey) PublicKeyB64() string

PublicKeyB64 returns the base64-encoded public key for pairing payloads.

func (*HostKey) SignRequest

func (k *HostKey) SignRequest(method, path string, body []byte) (string, string)

SignRequest signs the canonical payload (including a hash of `body`) and returns (base64-signature, unix timestamp string). Pass nil/empty `body` for requests without one (e.g. GET).

type Invocation

type Invocation struct {
	InvocationID   string                 `json:"invocation_id"`
	Action         string                 `json:"action"`
	Params         map[string]interface{} `json:"params"`
	ApprovalMode   string                 `json:"approval_mode"`
	IssuedAt       string                 `json:"issued_at"`
	ConversationID string                 `json:"conversation_id,omitempty"`
	AgentID        string                 `json:"agent_id,omitempty"`
}

Invocation envelope received on SSE.

type Jobs added in v1.9.0

type Jobs struct {
	// contains filtered or unexported fields
}

Jobs runs the daemon's invocations and delivers their reports. A command runs in its own goroutine under no session, so it outlives the SSE session that delivered it; its ack, output and exit code go through an outbox.

func (*Jobs) Cancel added in v1.9.0

func (j *Jobs) Cancel(id string)

Cancel stops a running invocation. One that is unknown or already finished is ignored: its report stands.

func (*Jobs) Idle added in v1.9.0

func (j *Jobs) Idle() bool

Idle reports whether no command runs and every report was delivered, so the daemon may restart.

func (*Jobs) Kick added in v1.9.0

func (j *Jobs) Kick()

Kick makes every outbox waiting to retry try again now (the daemon is back online).

func (*Jobs) Recover added in v1.9.0

func (j *Jobs) Recover() int

Recover delivers the reports a previous daemon left in the spool and returns how many. A command that was still running then cannot be reattached: it is reported as interrupted. Reports for another pairing, or past their give-up time, are deleted.

func (*Jobs) Shutdown added in v1.9.0

func (j *Jobs) Shutdown(force context.Context)

Shutdown stops accepting commands, stops the running ones (reported as host_shutdown) and waits up to the flush time for reports to go out; what is left stays in the spool for the next start. When force closes, commands are killed at once and nothing more is waited for.

func (*Jobs) Start added in v1.9.0

func (j *Jobs) Start(inv Invocation)

Start runs an invocation in the background. Called from the SSE loop, so a cancel that arrives later on the same stream always finds it.

type PairRequest

type PairRequest struct {
	UserCode      string `json:"user_code"`
	Hostname      string `json:"hostname"`
	OS            string `json:"os"`
	Arch          string `json:"arch"`
	CLIVersion    string `json:"cli_version"`
	MachinePubkey string `json:"machine_pubkey"`
}

PairRequest is the JSON body sent to /api/devices/pairings/redeem.

type PairResponse

type PairResponse struct {
	DeviceID     string `json:"device_id"`
	SessionToken string `json:"session_token"`
	Name         string `json:"name"`
}

PairResponse mirrors the redeem response on success.

func Pair

func Pair(ctx context.Context, baseURL, userCode, cliVersion string) (*PairResponse, error)

Pair performs a single redeem call against the server, persisting the result to host.yml on success. Returns the redeemed pairing response.

type PollResponse

type PollResponse struct {
	SessionTicket string `json:"session_ticket"`
	SessionURL    string `json:"session_url"`
	ExpiresIn     int    `json:"expires_in"`
}

PollResponse is the JSON body returned on poll work-queued (HTTP 200).

type ServiceMode

type ServiceMode int

ServiceMode is user (~/.config/systemd/user) or system (/etc/systemd/system).

const (
	ServiceModeUser ServiceMode = iota
	ServiceModeSystem
)

func DetectInstalledLaunchdMode

func DetectInstalledLaunchdMode() (mode ServiceMode, plistPath string, found bool)

DetectInstalledLaunchdMode looks for an existing plist and reports whether it is a LaunchDaemon (system) or LaunchAgent (user). Returns found=false when neither exists. Mirrors DetectInstalledMode for systemd.

func DetectInstalledMode

func DetectInstalledMode() (mode ServiceMode, found bool)

DetectInstalledMode looks for an existing unit file and reports whether it lives in the user-scope or system-scope path. Returns `found=false` when neither file exists.

func ResolveLaunchdMode

func ResolveLaunchdMode(isRoot, systemFlag bool, userFlag, sudoUser string) (mode ServiceMode, runUser, note string)

ResolveLaunchdMode decides the launchd install mode and runtime user, parallel to ResolveServiceMode but with launchd terminology in the note. Non-root installs a LaunchAgent (runs as the invoking user, on login); root (or explicit --system) installs a LaunchDaemon (runs at boot).

For a LaunchDaemon with no explicit --user, it prefers $SUDO_USER and falls back to root (with a warning). `note` is a possibly-empty, possibly-multi-line message explaining an automatic choice.

func ResolveServiceMode

func ResolveServiceMode(isRoot, systemFlag bool, userFlag, sudoUser string) (mode ServiceMode, runUser, note string)

ResolveServiceMode decides the install mode and runtime user for install-service from the invocation context. Precedence: an explicit --system flag is respected; otherwise root defaults to system mode and non-root to user mode (the daemon then runs as the invoking user).

In system mode with no explicit --user, it prefers $SUDO_USER (the human who sudo'd in) and falls back to root. `note` is a one-line, possibly empty, message for the user explaining an automatic choice.

func (ServiceMode) String

func (m ServiceMode) String() string

type State

type State int

State enumerates the daemon states.

const (
	StatePolling State = iota
	StateStreaming
	StateRestarting // held by an update so no session opens
)

func (State) String

func (s State) String() string

type Transport

type Transport struct {
	Cfg          HostConfig
	Key          *HostKey
	Version      string
	Baton        *Baton
	Client       *http.Client
	OnInvocation func(inv Invocation)
	OnCancel     func(invocationID string) // `event: cancel`
	OnReconnect  func()                    // a poll succeeded after failing
}

Transport carries the cross-cutting state needed by both the polling and SSE loops: config, identity, baton, an HTTP client, and event handlers.

func NewTransport

func NewTransport(cfg HostConfig, key *HostKey, version string) *Transport

NewTransport wires the standard collaborators together.

func (*Transport) PollOnce

func (t *Transport) PollOnce(ctx context.Context) (*PollResponse, bool, error)

PollOnce sends a single `GET /api/devices/poll` request and returns the (ticket, queued?) tuple. `queued == false` is the 202-no-work response.

func (*Transport) RunPolling

func (t *Transport) RunPolling(ctx context.Context, fastUntil time.Time) (*PollResponse, error)

RunPolling runs the polling loop until `ctx` is cancelled or a session ticket arrives. On ticket, swaps the baton to Streaming and returns the ticket so the caller can upgrade. Returns ErrRevoked when the server rejects the session token with 401 — immediately if no prior poll has succeeded (the token was bad from the start), or after `authRejectThreshold` consecutive 401s after at least one success (the server has revoked the device since the daemon started).

func (*Transport) RunSSE

func (t *Transport) RunSSE(ctx context.Context, sessionID string, lastEventID string) error

RunSSE opens the SSE stream for `sessionID` and dispatches events to `t.OnInvocation` and `t.OnCancel` until the server closes or `ctx` cancels. Other event names are ignored. Returns ErrRevoked on a 401 (device revoked while a session was being negotiated) or when an `event: revoke` arrives on the open stream.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL