Skip to content

plugin-user

Placement compiled-in (in-process)
Source github.com/opencharly/plugin-user/candy/plugin-user
Version 2026.242.2255
Candy plugin-user

This plugin is listed in charly/charly.yml’s compiled_plugins:, so its providers are compiled into the charly binary and register in-process.

The reserved words this plugin serves:

  • user — verb class

The user MULTI-ROLE state-provision verb relocated into a candy. CHECK: getent passwd via the live check engine and compare uid/gid/home/shell. ACT: render an idempotent useradd. A HOST-COUPLED verb on the sdk/kit contract (CheckVerbProvider + ProvisionActor), so it is COMPILED-IN-ONLY. No matchers — direct field comparison.

The CUE schema below is the authoritative grammar for this plugin’s input. It is the same single source that generates the plugin’s Go parameter types and answers the runtime Describe RPC, so this page cannot disagree with either.

// The BUILT-IN `user` plugin's OWN CUE schema — the typed plugin_input for the `user`
// verb (a getent-passwd probe in do:assert, a useradd in do:act). It is the SINGLE
// SOURCE for this plugin's params, used two ways (the same contract the reference
// examplerunverb and core `spec` use):
//
// 1. GENERATE the Go param struct — `cue exp gengotypes` (driven by task cue:gen,
// which wraps this with `package params` + `@go(params)`) emits
// ../params/cue_types_gen.go, so the provider decodes plugin_input into a TYPED
// struct, never a hand-parsed map.
// 2. VALIDATE authored input AT RUNTIME — the builtin serves this source over the
// Describe channel (InProcTransport) exactly like an external serves it over
// gRPC; the host splices it onto the base (base ++ plugin) and validates every
// authored `user` step's plugin_input against #UserInput.
//
// SELF-CONTAINED: it references NO base def, so it compiles standalone (gengotypes +
// the load-gate compile) AND splices onto the base — the base ++ plugin splice exists
// to detect a def-name collision with the base, not to resolve base refs.
//
// `user` is DUAL-NATURED — a state-provision verb that is BOTH a CheckVerbProvider
// (RunVerb → r.runUser, the getent-passwd probe that keeps the live *Runner) AND a
// ProvisionActor (RenderProvisionScript → useradd, rendered at install emit AND at
// runtime act). `uid`/`gid`/`home`/`shell` were base #Op fields read ONLY by the `user`
// verb (the `unix_group` verb that previously shared `gid` had already left #Op and
// reproduces gid in its own #UnixGroupInput), so all four MOVE here when `user` extracts
// and leave #Op entirely. The probe asserts uid/gid/home/shell when set; the act renders
// useradd with -u/-m -d/-s (gid is not set by the current act form — it is decoded for
// the assert only).
#UserInput: {
// user — the account name `getent passwd` probes (assert) / `useradd` creates (act).
// The verb discriminator.
user: string @go(User)
// uid — optional expected numeric user id (assert) / `-u` flag (act). Tri-state pointer.
uid?: int & >=0 @go(UID,type=*int)
// gid — optional expected numeric primary group id (assert only). Tri-state pointer.
gid?: int & >=0 @go(GID,type=*int)
// home — optional expected home dir (assert) / `-m -d` flag (act).
home?: string
// shell — optional expected login shell (assert) / `-s` flag (act).
shell?: string
// groups — group names the account MUST belong to, read from `id -nG <user>`
// (primary + supplementary). ASSERT ONLY, like `gid`: the act form does not create
// memberships, because a `user:` install step that silently added a user to `wheel`
// or `docker` would be a privilege grant hiding inside an account declaration.
//
// An exact-name SET, deliberately NOT a matcher list. A matcher runs against the
// whole subject, so `contains: docker` would also match `docker-users` and
// `contains: wheel` would match `wheel-admins` — the same substring trap that made
// four `mount: opt: {contains: "subvol=/@"}` checks pass against `@home`, `@log` and
// `@pkg`. Membership is a set question, so this is a set.
groups?: [...string] @go(Groups)
// not_groups — group names the account must NOT belong to. A separate field rather
// than a negative matcher, for the same exactness reason above, and because what it
// exists for is a SECURITY posture worth stating directly: the `docker` group is
// root-equivalent (a member can `docker run -v /:/host` its way to passwordless
// root), so "the desktop user is not in docker" is an assertion a hardened image
// should be able to make about itself.
not_groups?: [...string] @go(NotGroups)
// linger — whether systemd-logind keeps a user manager running for this account
// with no login session open. Asserted via
// `loginctl show-user <u> --property=Linger` (assert) / `loginctl enable-linger`
// (act). Tri-state pointer: UNSET asserts nothing and acts on nothing, so every
// existing `user:` step is unchanged.
//
// It belongs on the ACCOUNT, not on a service. Linger is a property of the user,
// and N services owned by one account would otherwise each carry a field that
// fights the others over the same single piece of state.
//
// Without it, every `scope: user` unit dies the moment the deploy's SSH session
// ends. charly runs linger nowhere today, and four places hand-roll it with three
// different failure semantics (agentteams-controller, plugin-vm/vm.go,
// plugin-deploy-vm/lifecycle.go, and supervisord's sentinel file).
linger?: bool @go(Linger,type=*bool)
}

See also the candy reference for this candy’s install surface.