eleven / messenger Public

server: `lchat env set` β€” amend instance envs in place (+ keyless-gif changelog) (#1272)

The fetch-service flip needs LCHAT_FETCH_URL added to every instance env,
and no operator-safe mechanism existed: /data is unreadable to operators on
a hardened box, and lchat-env-install replaces files wholesale β€” right for
vault-sourced service envs, fatal for instance envs (the admin token must
survive). `lchat env set <host>|--all KEY=VALUE…` does the read-modify-
write as root via the already-allowlisted binary: upserts only the named
keys, preserves everything else + ownership, prints KEY NAMES ONLY (values
are tokens; transcripts are public), restarts running instances, and lets
hibernating ones pick the file up on next wake. --all excludes canaries
(they belong to their sessions) and services (vault-note contract).
A missing env file errors β€” a typo'd host must not mint an empty env.

Changelog: the keyless GIF search entry, announced when the fleet flip
makes it real for members.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell e34f337261ae0fd5dc0eb7dd8858387769151598 parent d403e02
5 files changed, +207 −0
  • changelog/2026-07-22-gif-search-keyless.md +4 −0
  • envcmd.go +140 −0
  • envcmd_test.go +59 −0
  • main.go +3 −0
  • subcommands.go +1 −0
diff --git a/changelog/2026-07-22-gif-search-keyless.md b/changelog/2026-07-22-gif-search-keyless.md
new file mode 100644
index 0000000..6a9d7de
--- /dev/null
+++ b/changelog/2026-07-22-gif-search-keyless.md
@@ -0,0 +1,4 @@
+πŸ€– GIF search now works straight away β€” no more being asked for a Giphy API
+key the first time someone types /gif. Searches go through Eleven's own
+privacy service, so Giphy never sees who you are, and the GIF you pick is
+still encrypted before it's posted, like every attachment.
diff --git a/envcmd.go b/envcmd.go
new file mode 100644
index 0000000..b6b5641
--- /dev/null
+++ b/envcmd.go
@@ -0,0 +1,140 @@
+// `lchat env set` β€” amend an instance's /data/<host>.env in place, the
+// operator-safe way. On a hardened box operators can't read or edit /data
+// directly, and lchat-env-install replaces a file wholesale (right for
+// service envs sourced from a vault note; wrong for instance envs, whose
+// admin token must survive). This verb does the read-modify-write as root
+// via the allowlisted binary, prints KEY NAMES ONLY (env values are tokens β€”
+// they must never reach a transcript), and restarts what it changed.
+//
+// Born for the fetch-service flip (LCHAT_FETCH_URL=… across the fleet,
+// docs/fetch.md Β§ The client flip) but deliberately generic.
+package main
+
+import (
+ "flag"
+ "fmt"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strings"
+)
+
+var envKeyRe = regexp.MustCompile(`^[A-Z][A-Z0-9_]*$`)
+
+// runEnvCmd dispatches `lchat env <command>`, mirroring runLinksCmd's shape.
+func runEnvCmd(args []string) {
+ if len(args) < 1 {
+ die(`usage: lchat env set <host>|--all KEY=VALUE [KEY=VALUE…]
+
+ set upsert KEY=VALUE lines in /data/<host>.env and restart the instance
+ (--all: every registry instance except canaries; hibernating
+ instances get the file now and pick it up on their next wake)`)
+ }
+ switch args[0] {
+ case "set":
+ runEnvSet(args[1:])
+ default:
+ die("env: unknown command %q", args[0])
+ }
+}
+
+func runEnvSet(args []string) {
+ fs := flag.NewFlagSet("env set", flag.ExitOnError)
+ registryPath := fs.String("registry", envOr("LCHAT_REGISTRY", "/data/registry.db"), "router registry SQLite path")
+ all := fs.Bool("all", false, "apply to every instance in the registry (canaries excluded β€” they belong to their sessions)")
+ _ = fs.Parse(reorderFlagsFirst(fs, args))
+
+ host := ""
+ pairs := map[string]string{}
+ for _, a := range fs.Args() {
+ if k, v, ok := strings.Cut(a, "="); ok {
+ if !envKeyRe.MatchString(k) {
+ die("env set: %q is not an env KEY (A-Z, digits, underscores)", k)
+ }
+ pairs[k] = v
+ continue
+ }
+ if host != "" {
+ die("env set: one host (or --all), then KEY=VALUE pairs β€” got both %q and %q", host, a)
+ }
+ host = strings.ToLower(strings.TrimSpace(a))
+ }
+ if len(pairs) == 0 {
+ die("env set: no KEY=VALUE pairs given")
+ }
+ if (*all && host != "") || (!*all && host == "") {
+ die("env set: name one host, or --all β€” not both, not neither")
+ }
+
+ keys := make([]string, 0, len(pairs))
+ for k := range pairs {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+
+ var hosts []string
+ if *all {
+ reg, err := openRegistry(*registryPath)
+ if err != nil {
+ die("env set: open registry: %v", err)
+ }
+ routes, err := reg.list()
+ if err != nil {
+ die("env set: %v", err)
+ }
+ for _, rt := range routes {
+ // Services keep their own env contracts (vault-note wholesale via
+ // lchat-env-install); canaries belong to their sessions.
+ if rt.Kind != "instance" || rt.Canary {
+ continue
+ }
+ hosts = append(hosts, rt.Host)
+ }
+ sort.Strings(hosts)
+ } else {
+ if !validHost(host) {
+ die("env set: invalid host %q", host)
+ }
+ hosts = []string{host}
+ }
+
+ for _, h := range hosts {
+ if err := envSetOne(h, pairs); err != nil {
+ // --all keeps going: one broken instance must not strand the rest
+ // of a fleet rollout half-applied with no report of who's left.
+ fmt.Printf(" %s: FAILED: %v\n", h, err)
+ continue
+ }
+ unit := "lchat@" + h
+ switch {
+ case unitActive(unit):
+ if err := systemctl("restart", unit); err != nil {
+ fmt.Printf(" %s: env updated (%s) but restart failed: %v\n", h, strings.Join(keys, ", "), err)
+ continue
+ }
+ fmt.Printf(" %s: set %s (restarted)\n", h, strings.Join(keys, ", "))
+ default:
+ // Hibernating (or simply stopped): the activator layers the env
+ // file over each spawn, so the change rides the next wake.
+ fmt.Printf(" %s: set %s (not running β€” applies on next start/wake)\n", h, strings.Join(keys, ", "))
+ }
+ }
+}
+
+// envSetOne upserts pairs into /data/<host>.env, preserving everything else
+// (notably LCHAT_ADMIN_TOKEN β€” the whole reason this isn't a wholesale write)
+// and the lchat service user's ownership.
+func envSetOne(host string, pairs map[string]string) error {
+ path := filepath.Join(instanceDataDir, host+".env")
+ env, err := readEnvFile(path)
+ if err != nil {
+ return err
+ }
+ for k, v := range pairs {
+ env[k] = v
+ }
+ if err := writeEnvFile(path, env); err != nil {
+ return err
+ }
+ return matchOwner(path, instanceDataDir)
+}
diff --git a/envcmd_test.go b/envcmd_test.go
new file mode 100644
index 0000000..b6cf925
--- /dev/null
+++ b/envcmd_test.go
@@ -0,0 +1,59 @@
+package main
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// The whole reason `env set` exists: an upsert must preserve what it doesn't
+// name β€” above all the admin token, which wholesale env replacement loses.
+func TestEnvSetOnePreservesExistingKeys(t *testing.T) {
+ dir := t.TempDir()
+ old := instanceDataDir
+ instanceDataDir = dir
+ t.Cleanup(func() { instanceDataDir = old })
+
+ path := filepath.Join(dir, "x.example.env")
+ if err := os.WriteFile(path,
+ []byte("LCHAT_ADMIN_TOKEN=keepme\nLCHAT_PUSH_RELAY_TOKEN=alsokeep\nLCHAT_FETCH_URL=https://old.example\n"),
+ 0o600); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := envSetOne("x.example", map[string]string{
+ "LCHAT_FETCH_URL": "https://fetch.11ch.at", // overwrite
+ "LCHAT_NEW_FLAG": "1", // append
+ }); err != nil {
+ t.Fatalf("envSetOne: %v", err)
+ }
+
+ got, err := readEnvFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := map[string]string{
+ "LCHAT_ADMIN_TOKEN": "keepme",
+ "LCHAT_PUSH_RELAY_TOKEN": "alsokeep",
+ "LCHAT_FETCH_URL": "https://fetch.11ch.at",
+ "LCHAT_NEW_FLAG": "1",
+ }
+ for k, v := range want {
+ if got[k] != v {
+ t.Fatalf("%s = %q, want %q (full: %v)", k, got[k], v, got)
+ }
+ }
+ if len(got) != len(want) {
+ t.Fatalf("unexpected keys: %v", got)
+ }
+ // A missing env file is an error, never a silent create β€” `env set` amends
+ // real instances; a typo'd host must not mint an empty env.
+ if err := envSetOne("nosuch.example", map[string]string{"A": "b"}); err == nil {
+ t.Fatal("envSetOne on a missing env file should fail")
+ }
+ if _, err := os.Stat(filepath.Join(dir, "nosuch.example.env")); !os.IsNotExist(err) {
+ t.Fatal("envSetOne must not create an env file for a typo'd host")
+ }
+ _ = strings.TrimSpace("")
+}
diff --git a/main.go b/main.go
index a3137ae..311ce98 100644
--- a/main.go
+++ b/main.go
@@ -208,6 +208,9 @@ func main() {
case "instances":
runInstances(os.Args[2:])
return
+ case "env":
+ runEnvCmd(os.Args[2:])
+ return
case "hibernate":
runHibernate(os.Args[2:])
return
diff --git a/subcommands.go b/subcommands.go
index 214865a..ddcf047 100644
--- a/subcommands.go
+++ b/subcommands.go
@@ -62,6 +62,7 @@ var subcommands = []subcommand{
// Looking after what's already running.
{"instances", "list every instance and service on this box"},
+ {"env", "amend an instance's env in place (`env set <host>|--all KEY=VALUE`)"},
{"remove", "remove an instance (deliberately leaves /data behind)"},
{"reset", "archive a canary's data (canary-flag gated)"},
{"canary", "set/clear an instance's throwaway-canary flag"},