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>
5 files changed,
+207
−0
changelog/2026-07-22-gif-search-keyless.md+4 −0envcmd.go+140 −0envcmd_test.go+59 −0main.go+3 −0subcommands.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"}, |