Skip to content

feat(config): config.env is written by a command, not by the agent (ADR 0017) - #632

Open
nevamind-agent wants to merge 9 commits into
mainfrom
feat/config-env-command
Open

feat(config): config.env is written by a command, not by the agent (ADR 0017)#632
nevamind-agent wants to merge 9 commits into
mainfrom
feat/config-env-command

Conversation

@nevamind-agent

Copy link
Copy Markdown
Contributor

📝 Pull Request Summary

Implements ADR 0017 end to end: ~/.memu/config.env is now written by two commands — <binary> init and <binary> config — instead of by the agent, from prose, during the install. The ADR itself is included, along with the guide changes that drive the new verbs and the one open issue the last commit closes.


✅ What does this PR do?

Four commits, in dependency order:

  1. feat(config): write config.env from init and config, not from prose — the writer (memu.config_file), the two verbs (memu.hosts.config_cmd), and the guards. Logic only; nothing invokes it yet.
  2. fix(config): init never rejects on modeinit is the first command on the install path and has no --force to hand back, so a refusal there strands every step after it. It warns instead.
  3. feat(config): name the verbs in SKILL.md and all seven INSTALL.md §1.2 sections — the guide side of the same change.
  4. feat(events): move the install-start event to init, rename the guide's own event — closes ADR 0017's "where the install-start event belongs" open issue.

Two verbs over one writer. init is the inferring front door named in SKILL.md — the surface memU cannot refresh from the server, so its contract is one optional flag and is meant to hold for years. config is the explicit one driven by INSTALL.md, which is server-refreshable (ADR 0013) and can grow flags at the server's pace. config show is the read side, and is a name rather than a bare config: a verb that printed while a bare init wrote is the asymmetry agents get wrong.

The guard is state-based, and reads the file. A mode change is refused only when the current mode has something to lose — MEMU_DB set, or a stored cloud key. Guarding on the declaration alone would refuse the most ordinary first install; reading the state through env.env() would be wrong in the other direction, since an exported credential in the calling shell must not make a file that declares nothing look like a configured install.

The install funnel gains its true first step. cli_install_started now fires from init (SKILL.md Step 2) rather than from docs install (Step 3), and is emitted after the config write — init mints MEMU_CLIENT_ID into that same file, so an envelope built first would find no id, persist a second one, and be overwritten. The old emission is renamed to install_guide_opened and is otherwise untouched.


🤔 Why is this change needed?

~/.memu/config.env is shared by every host on the machine, carries a plaintext credential, and holds an invariant a guide can only state in words: record and inject must agree on the backend — and in local mode on the DSN and embedding space too — or retrieval silently returns nothing (ADR 0009's opening argument).

Until now INSTALL.md §1.2 told the agent which keys to write and the agent wrote them, typically with a heredoc or echo >>. Field behaviour matched the exposure: agents rewrite the file rather than merge into it, taking another host's settings and the user's comments with them. The guide already spends a paragraph on "repair the connection, never the identity" and enforces it with nothing but emphasis.

This is the same argument that produced install-instruction: memU owns the text, so memU writes it. config.env is a strictly harder file — merge semantics, a secret, a cross-host invariant — and had no such command.

Secondarily, the install-start event fired before the memU Cloud key existed, so a first install reported anonymously. init holds the key and writes it before reporting, which makes attribution unconditional.


🔍 Type of Change

  • Bug fix
  • New feature
  • Documentation update
  • Refactor / cleanup
  • Other — one breaking change on the analytics wire (see below)

✅ PR Quality Checklist

  • PR title follows an allowed format
  • Changes are limited in scope and easy to review — four commits, each self-contained and readable on its own
  • Documentation updated where applicable — ADR 0017 added, ADR 0016 §4 amended, SKILL.md and all seven INSTALL.md guides updated
  • Breaking change, documented rather than absent — see below
  • Related discussion linked — ADR 0017 supersedes the prose procedure in ADR 0009/0012's guides

The breaking change

cli_install_started changes subject. From this release it means init ran; history below the cutover means the install guide was printed. Nothing errors at the boundary — context.client_version is the discriminator, the same treatment the memory_update rename got in ADR 0016.

install_guide_opened is a new name for the old event, so nothing that was observable stopped being observable; the funnel gained a step rather than trading one. Ingest needs no schema change — both carry empty properties.

Deploy ordering ⚠️

The guides in this PR must not be published server-side ahead of the release carrying the code. docs install prefers the server's copy (ADR 0013), so a guide naming config would reach machines whose binary has no such subcommand.


📌 Optional

  • Edge cases considered — see tests/test_config_cmd.py (406 lines: merge semantics, both guards, --force, legacy files declaring no mode, shell-exported values that must not be read) and the funnel tests in tests/test_events.py
  • Follow-up tasks mentioned — ADR 0017's remaining open issues: the credential lands in argv and memU mines transcripts; nothing validates the key until doctor; local mode's flag surface will keep growing

431 passed, 1 skipped; ruff and mypy clean.

evan-ak and others added 4 commits August 10, 2026 05:23
…se (ADR 0017)

`~/.memu/config.env` is shared by every host on the machine, carries a plaintext
credential, and holds an invariant a guide can only state in words — record and
inject must agree on the backend, or retrieval silently returns nothing. Until
now the agent wrote it by hand from `INSTALL.md` §1.2, and field behaviour
matched the exposure: agents rewrite the file rather than merge into it, taking
another host's settings with them.

Two verbs over one writer. `init` is the inferring front door named in
`SKILL.md` — the surface memU cannot refresh from the server, so its contract is
one optional flag. `config` is the explicit one driven by `INSTALL.md`, which is
server-refreshable and can grow flags at the server's pace. `config show` is the
read side, and is a name rather than a bare `config`: a verb that printed while
bare `init` wrote is the asymmetry agents get wrong.

The guard is state-based, and reads the *file*. Guarding on declaration alone
breaks the most ordinary first install — bare `init` lands `local`, the guide
then asks the question, the user says cloud, and the flip is refused as if it
were a real one. So a mode change is refused only when the current mode has
something to lose: `MEMU_DB` set, or a stored cloud key. Reading that state
through `env.env()` would be equally wrong in the other direction, since it
resolves the shell first: an exported credential must not make a file that
declares nothing look like a configured install. A file with no
`MEMU_MEMORY_MODE` at all counts as local, not as vacuous — that is the
population most likely to hold a large legacy store.

`client_instance_id()` moves onto the same writer. `init` now persists
`MEMU_CLIENT_ID` too, and read-modify-write plus `os.replace` would silently
discard the append it used to do; its fail-open `except` and its post-write
re-read survive the move.

This commit is the logic only — no guide names either verb yet, so nothing
invokes them. `SKILL.md` and the seven `INSTALL.md` §1.2 sections follow
separately, and must not be published server-side ahead of the release carrying
this: `docs install` prefers the server's copy, so a guide naming `config` would
reach machines whose binary has no such subcommand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…wn event

`cli_install_started` fired from `docs install`, because printing the guide was
the first act on the install path that proved `memu-cli` resolves. ADR 0017 put
`init` one step earlier — `SKILL.md` Step 2 — and left where the event belongs
as an open issue. It belongs there: `init` proves the same thing sooner and
additionally holds the two facts the event most wants, the memU Cloud key and
`MEMU_CLIENT_ID`.

So the start moves, and it is emitted *after* the write rather than beside the
client id. `client_instance_id()` reads `MEMU_CLIENT_ID` from the resolved
environment, and the id `_client_id` generates is not there until `_write` has
persisted it and reloaded — report first and the event mints a second id,
persists it, and is overwritten, leaving the machine's first event reporting
under an id its own config no longer contains. The alternative of an extra
`_write` early to bank the id buys no durability, since nothing between the two
points does I/O or can raise, and costs a second read-modify-write and a `config`
row printed twice. In the `--cloud-api-key` branch the report sits below the
orphaned-store warning: it blocks on a POST, and the warning is the line a user
has to read.

The same ordering is what attaches the key to the header, so the first envelope
of an install is attributable rather than anonymous — the attribution ADR 0017
was written for, now unconditional instead of dependent on the user having
configured cloud before Step 3.

The old emission is renamed, not deleted. `docs install` still records
`install_guide_opened` and still flushes there; what it observes is unchanged and
worth a row, but it is the funnel's second step now, and a second name ending in
`_started` beside the real one is the pair a consumer sums by accident. Both legs
stay code-observed, so `started >= opened >= succeeded` holds structurally
against a prose-driven `report install` that undercounts.

Consumers must update: `cli_install_started` means `init` ran from this release
on, and history below the cutover means the guide was printed.
`context.client_version` is the discriminator, as it was for the
`memory_update` change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@evan-ak
evan-ak force-pushed the feat/config-env-command branch from d636fba to eb21f33 Compare August 9, 2026 20:24
`memu.events` and `memu.hosts.templates` reach a memU server with `urllib`
rather than the `httpx` the rest of the package uses, so both verify against
whatever trust store OpenSSL was pointed at. A python.org framework build whose
bundled `Install Certificates.command` was never run is pointed at nothing —
neither `cafile` nor `capath` — and every HTTPS call raises
CERTIFICATE_VERIFY_FAILED. On two fail-open paths that is not an error anyone
sees: `_post` reads it as RETRY forever, so `report flush` prints `delivered 0
event(s)` while `.sending` files accumulate until `MAX_SPOOL_BYTES` starts
dropping, and `_get` reads it as "the server is unreachable", so the machine
silently keeps its embedded templates and never says so. `httpx` carries
`certifi` and is unaffected, which is why `retrieve` keeps working on exactly
the machine where delivery reports zero — the divergence that made this take an
afternoon to find, with nothing in the client's output and nothing in the
backend's log, because the request never left the host.

A fallback, and deliberately not a pin. Handing `certifi` to these two sites
unconditionally would fix that machine and break the opposite one: a corporate
root CA installed OS-wide is in the system store and is not in `certifi`, so
every user behind a TLS-inspecting proxy would begin failing exactly as
silently. So the system store still wins wherever one exists, and `certifi`
only fills a vacuum.

Moving both sites to `httpx` was the other candidate and is rejected on the same
ground. Its stated obstacle is already gone — `events` imports `memu.config_file`,
which imports `memu/__init__`, which imports `MemoryService` and therefore
`httpx`, in every process that can reach either call site, and `httpx.post` is
sync — but it verifies against `certifi` too, so it carries the identical
regression while additionally re-opening redirect, timeout and proxy semantics on
two paths whose whole contract is that they never raise. One HTTP client in the
codebase is worth having; it is a refactor to make on its own merits, not one to
smuggle in behind a bug fix, which is precisely how that regression would ship
unnoticed. `_post`'s docstring still cites the async stack as the reason for
`urllib` and is now wrong about it; correcting that is left to the change that
settles the question.

`urlopen_kwargs` returns `{}` rather than a context on a healthy machine, so the
call site passes no `context` argument at all. That makes an install that works
today unchanged by construction instead of unchanged by inspection, and it is
why no existing `urlopen` stub in the suite needed its signature touched. The
answer is cached because building the fallback parses `certifi`'s whole PEM
bundle and one `flush` may POST `MAX_FLUSH_POSTS` times, and every failure
returns `None` — including `certifi` being absent, since it arrives as an httpx
dependency rather than one memU declares. This must not become the reason a
fail-open path stops being one.

Verified on the interpreter that was broken, with no `SSL_CERT_FILE`: TLS now
completes and a spooled event flushes `(1, 0)` where it previously returned
`(0, 0)` and retained its file. `tests/test_trust.py` covers both directions,
because the fix has two ways to be wrong — a healthy store must be left alone,
and the vacuum must be filled with `CERT_REQUIRED` and `check_hostname` intact,
a fallback that disabled verification being worse than the bug it closes.

Refs #633
Two events were left to a later flush on the argument that one would come. In
both cases the run that most needs reporting is the one where it doesn't.

`memory_search_failed` was spool-only, deliberately, on the reasoning that a
store the hook cannot reach fails `retrieve` on *every* turn — so that is when a
per-turn blocking POST is least affordable. The reasoning held for the cost and
inverted the value. The same unreachable store breaks `prepare` and `commit`, so
the later flush this event waited for is the machinery its own failure
implicates: a machine whose retrieval is broken reported least, which is the
silent-failure mode ADR 0016 §5 names as its whole motivation. It now delivers
like the success leg.

That cost is accepted, not avoided, and the ADR says so in those words: a fully
broken store adds up to `_TIMEOUT_SECONDS` to each turn until it is fixed. What
makes it payable is that it is a constant — bounded by the timeout rather than by
how far behind the spool has fallen, and the same one the success leg already
pays. What does not follow is a flush. `host_cli.run` still exempts `retrieve`
from the error handler's, because a drain there costs up to `MAX_FLUSH_POSTS`
requests per turn, and that exemption is untouched: the `cli_error` behind a
failed retrieve is still spooled. One POST versus all of them remains the
distinction the whole design rests on.

`cli_install_succeeded` was spooled on the argument that a completed install goes
on to bridge and will flush there. That assumes the thing the event attests to.
An install whose scheduled task never registered still reaches `INSTALL.md`'s
last step and still reports completing, and it is exactly the run that never
flushes — so the funnel lost its terminal row on the machines whose funnel
matters most. `init` and `docs install` already flush for this reason; leaving
one step of three behind also gave the completion a delivery latency unlike its
own siblings', for nothing a consumer could use. Its `--help` now says "delivers
immediately", as `uninstall`'s already did.

Three tests move with it. `test_a_failing_retrieve_never_posts_from_the_per_turn
_hook` is rewritten rather than patched — its name, docstring and assertion were
the old decision — and now pins the half that did not change alongside the half
that did. The install-funnel test expects three posted names and an empty spool.
`test_report_verbs_exist_on_every_host` gains the `_Posted` stub it never needed
before: without it `report install` now resolves the fixture's `example.invalid`
for real, retries, and `_retain` writes the events back to the rotated
`.sending` file, where `_spooled()` does not look — so the test read 0 events
that were never lost, only moved. It also stops touching the network.

The `deliver=True` tripwire stays file-granular and now says why: what it guards
is which command may spend a POST per turn, not how many branches of that
command do. ADR 0016 §2 and §4 carry both changes as amendments with the
superseded reasoning kept, since both arguments are ones a later reader would
otherwise make again. `flush()`'s docstring listed its callers and had already
fallen three behind — `init`, `docs install`, `report error` — and is corrected
along with the two.

439 unit tests pass; not yet exercised against a live endpoint.
@MrXnneHang

MrXnneHang commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

佬,先 blocking 一下。 windows 端有不少阻塞点,我补点跨平台的东西。

进度在这里 track (这次复核的点都 resolve 了):

第五个点建议 --db 的路径不用 Unix 的而改用跨平台友好的 ~/,这里涉及 host 较多,要不要改,怎么改未定,我就只作为一个 non-blocking suggestion,没有直接应用修改。

patch PR 在这:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants