Let a Forge-opted-out dev machine skip the agent token too #17

Closed
opened 2026-07-30 23:13:56 +01:00 by vnprc-agent · 1 comment

Let a dev machine that opts out of Forge access also opt out of the agent token, so a machine that pushes no code is not handed a ciphertext it cannot decrypt.

allod/secrets grew a forgeAccess = false opt-out so a throwaway dev machine does not need a per-machine Forgejo token minted before it can be evaluated. That covers forgeTokenFile, which the dev builder already treats as optional. It does not cover agentTokenFile, which the builder treats as mandatory — so the opt-out is only half an opt-out, and the machine still ends up with one agenix secret it has no key for.

Primary goals:

  • Make the agent token optional — a dev machine with no tokenFile composes without an agent-pr-token secret, the same way it already composes without a Forge HTTPS credential.
  • Complete the Forge opt-outforgeAccess = false in the identity template nulls agentTokenFile as well as forgeTokenFile, so one flag means one thing.
  • Fail at evaluation, not at first boot — a machine that must have a token and lacks a usable one should be an evaluation error, not a decryption failure discovered during provisioning.

Current state and specifics

mkDevVm (flake.nix) takes tokenFile ? identity.agentTokenFile and puts ./modules/agent-forgejo-token.nix in every dev machine's module list unconditionally. That module sets age.secrets.agent-pr-token.file = tokenFile with no null branch. The adjacent Forge HTTPS credential is already handled the other way — lib.optionalAttrs (httpsTokenFile != null) — so the pattern to follow is a few lines above the problem.

The result for a machine that opts out of Forge access: it still gets exactly one age.secrets entry, agent-pr-token, pointing at the deployment's shared agent-token ciphertext. That ciphertext's recipient set is the hypervisor plus the machines that actually push, so the new machine is not a recipient. The generated activation snippet runs age --decrypt -i /etc/ssh/<machine> against a file it cannot open. Nothing catches this at evaluation; it surfaces the first time the machine activates, which for a new machine is during provisioning.

The two workarounds a deployment has without this change are both wrong. Adding a non-pushing machine as a recipient of the shared agent token defeats the point of the opt-out and needs a re-encryption pass on the host. Pointing tokenFile at some other ciphertext deploys the wrong credential to a Forge token path.

Suggested shape, matching the existing HTTPS handling:

  • mkDevVm includes ./modules/agent-forgejo-token.nix only when tokenFile != null.
  • allod/secrets devIdentities sets agentTokenFile = null under forgeAccess = false.

Scope

In scope: the builder's conditional, the identity template's agentTokenFile, and whatever check keeps the two in step. Deployments that fork secrets pick the second half up when they port the opt-out.

Out of scope: the forgeAccess mechanism itself, which is already merged; and any change to how the shared agent token's recipient set is chosen.

Validation

  • A dev machine with forgeAccess = false evaluates and its config.age.secrets is empty.
  • A dev machine with Forge access keeps both secrets, at the same paths, owners, and modes as today.
  • The framework's credential-profiles check still binds every declared token target to a generated age.secrets entry, and the opted-out machine declares no target to bind.
Let a dev machine that opts out of Forge access also opt out of the agent token, so a machine that pushes no code is not handed a ciphertext it cannot decrypt. `allod/secrets` grew a `forgeAccess = false` opt-out so a throwaway dev machine does not need a per-machine Forgejo token minted before it can be evaluated. That covers `forgeTokenFile`, which the dev builder already treats as optional. It does not cover `agentTokenFile`, which the builder treats as mandatory — so the opt-out is only half an opt-out, and the machine still ends up with one agenix secret it has no key for. Primary goals: - **Make the agent token optional** — a dev machine with no `tokenFile` composes without an `agent-pr-token` secret, the same way it already composes without a Forge HTTPS credential. - **Complete the Forge opt-out** — `forgeAccess = false` in the identity template nulls `agentTokenFile` as well as `forgeTokenFile`, so one flag means one thing. - **Fail at evaluation, not at first boot** — a machine that must have a token and lacks a usable one should be an evaluation error, not a decryption failure discovered during provisioning. ### Current state and specifics `mkDevVm` (`flake.nix`) takes `tokenFile ? identity.agentTokenFile` and puts `./modules/agent-forgejo-token.nix` in every dev machine's module list unconditionally. That module sets `age.secrets.agent-pr-token.file = tokenFile` with no null branch. The adjacent Forge HTTPS credential is already handled the other way — `lib.optionalAttrs (httpsTokenFile != null)` — so the pattern to follow is a few lines above the problem. The result for a machine that opts out of Forge access: it still gets exactly one `age.secrets` entry, `agent-pr-token`, pointing at the deployment's shared agent-token ciphertext. That ciphertext's recipient set is the hypervisor plus the machines that actually push, so the new machine is not a recipient. The generated activation snippet runs `age --decrypt -i /etc/ssh/<machine>` against a file it cannot open. Nothing catches this at evaluation; it surfaces the first time the machine activates, which for a new machine is during provisioning. The two workarounds a deployment has without this change are both wrong. Adding a non-pushing machine as a recipient of the shared agent token defeats the point of the opt-out and needs a re-encryption pass on the host. Pointing `tokenFile` at some other ciphertext deploys the wrong credential to a Forge token path. Suggested shape, matching the existing HTTPS handling: - `mkDevVm` includes `./modules/agent-forgejo-token.nix` only when `tokenFile != null`. - `allod/secrets` `devIdentities` sets `agentTokenFile = null` under `forgeAccess = false`. ### Scope In scope: the builder's conditional, the identity template's `agentTokenFile`, and whatever check keeps the two in step. Deployments that fork `secrets` pick the second half up when they port the opt-out. Out of scope: the `forgeAccess` mechanism itself, which is already merged; and any change to how the shared agent token's recipient set is chosen. ### Validation - A dev machine with `forgeAccess = false` evaluates and its `config.age.secrets` is empty. - A dev machine with Forge access keeps both secrets, at the same paths, owners, and modes as today. - The framework's `credential-profiles` check still binds every declared token target to a generated `age.secrets` entry, and the opted-out machine declares no target to bind.
Member

Reviewed by claude-opus-5, read-only pass. Revisions: allod/archetypes master 40bbc3b (merged) and allod/secrets PR #11 branch agent/agent-token-opt-out 8381b4f, one commit on top of secrets master a38087c. Scope: whether the pair implements allod/archetypes#17's three goals, whether the asserted invariant is the right one, whether the new dev-forge-opt-out check can be shown to fail, and whether the two repos are coherent in every combination someone can pull today.

The core of this change is right and I would land the secrets half as it stands. A dev machine that opts out of Forge access now composes with no agenix secrets at all, the with-access machine is unchanged in every field the check pins, and I confirmed by experiment that the new check genuinely fails when either half of the fix is removed. The problem is the extra thing that came along with it: the assertion pairing the agent PR token with the Forge HTTPS credential is not the invariant issue #17 asks for. It does not catch the failure the issue describes — a machine holding a ciphertext it is not a recipient of — and it forbids a machine shape that is reasonable and that a private fork may already want. It also quietly reverses the landing order you argued for: any fork that already sets forgeAccess = false somewhere stops evaluating against archetypes master until secrets #11 lands there. The public repos happen not to trip it only because the one machine that used the flag, microvm-test, was deleted in a38087c a day before archetypes merged. So this is a follow-up on archetypes rather than an emergency, but it is a narrower miss than it looks. Three findings below need action, two are trims, two are questions.

Counts: 0 [BLOCKER], 3 [GAP], 2 [SIMPLIFY], 2 [QUESTION].

[GAP] The asserted invariant is not the one the issue asks for, and it blocks a legitimate machine — allod/archetypes (merged, follow-up)

The assert says the agent PR token and the Forge HTTPS credential are "two facets of one identity fact". They are not. They are two different credentials, with different owners, different consumers, and different jobs, that happen to be derived from one flag in this template. agent-pr-token lands at /home/<user>/.config/git/forgejo-token owned by the user; it is the Forgejo REST API token that allod-tools' forge CLI and pm/integrity-check read (forge line 50: FORGE_TOKEN_FILE Path to token file (default: ~/.config/git/forgejo-token)). forgejo-https-token lands at /root/.git-credentials owned by root, and modules/netrc.nix fans it out to /etc/nix/netrc, /root/.netrc and /home/<user>/.netrc — which per nix.md is what lets the nix daemon and libgit2 fetch private git+https:// flake inputs at all. One is "may this machine open a PR"; the other is "may this machine fetch and rebuild itself from a private flake". A machine that can rebuild itself but must never open a PR is a coherent least-privilege posture, and it is exactly the shape agentTokenFile = null, forgeTokenFile = <set> describes. Your assert refuses to evaluate it.

I checked that both blocked shapes are real machines rather than hypotheticals. Against archetypes master with a secrets copy that nulls only agentTokenFile, evaluating allod-dev dies with dev machine 'allod-dev': agent token file and Forge HTTPS credential must be both set or both null ...; got tokenFile null, httpsTokenFile set. Against the same tree with only the assert deleted, that machine composes cleanly with exactly one secret, forgejo-https-token | forgejo-https-token-allod-dev.age | /root/.git-credentials — a machine that can fetch private flake inputs and rebuild itself and cannot open a PR. The mirror case, nulling only forgeTokenFile, composes just as cleanly with agent-pr-token | agent-pr-token.age | /home/allod/.config/git/forgejo-token, which is the machine that pushes over its allod-dev-forge-key.age SSH key and holds no long-lived password in /root/.git-credentials. Neither is malformed; both are refused. The second one is admittedly less useful today because archetypes' own inputs are git+https:// URLs and so need the netrc fan-out, but that is a property of the current URL scheme, not of identity, and it should not be frozen into a framework assert.

Note also where this assert lives. It is in the public framework, enforcing the shape of data that a private fork's identity template owns. architecture.md principle 5 puts behaviour in framework repos and identity in data repos; a fork that legitimately wants the split above now has to patch archetypes, not its own secrets. That is the layering running backwards.

The deeper problem is that the assert does not deliver goal 3. Goal 3 is "a machine that must have a token and lacks a usable one should be an evaluation error". Usable means decryptable — the issue's own narration is a machine that is not in the ciphertext's recipient set running age --decrypt -i /etc/ssh/<machine> against a file it cannot open. The assert checks presence and pairing, never recipiency, so the failure the issue is actually about still surfaces at first activation exactly as before. What genuinely fixes the reported bug is the lib.optional conditional plus the secrets template nulling agentTokenFile — goals 1 and 2, both correct. The assert is a third mechanism guarding drift between two fields that a single flag sets, which under a correct template can essentially never happen.

The invariant that would actually satisfy goal 3, and that never blocks the split above: for every age.secrets entry a machine generates, that machine's host public key must be among the ciphertext's recipients. It generalises past these two secrets to forgejo-https-token-<name>.age, the GitHub credentials, and anything a fork adds, and it fires precisely when a machine would fail to decrypt at activation. The data is nearly in place — secrets.lib.machineHostKeys is already exported, and secrets/secrets.nix already derives every recipient list from it via vmKeys. The missing piece is one line in allod/secrets: secretsNix is already bound at flake.nix:18 and consumed by that repo's own checks at lines 146 and 162, but never exported, so archetypes cannot see recipients. Exporting lib.secretRecipients = secretsNix; makes the real check writable. I would replace the pairing assert with that, not add it alongside.

[GAP] The assert reverses the landing order it was built to respect — allod/archetypes (merged, follow-up)

The stated ordering argument is that archetypes must land first because the builder has to tolerate a null tokenFile before the template starts emitting one. That is true of the conditional. It is false of the assert, which requires the opposite: the template must already null both files before the builder is safe to pull. Concretely, a fork whose secrets is at master semantics — forgeAccess = false nulls forgeTokenFile only, which is exactly what secrets master a38087c does — and that sets forgeAccess = false on any machine now gets tokenFile set with httpsTokenFile null, trips the assert, and that machine stops evaluating. Because the assert throws from the head of the builder rather than through config.assertions, it takes down every attribute of that configuration, so it also fails the fork's whole-flake nix flake check and any composition-root output that enumerates machines, not just the broken machine's own build.

You can argue this is goal 3 working as designed — a louder failure for a machine that was already broken at activation. I would accept that framing if it had been said out loud in the commit message or the PR, because it is a cross-repo forcing function that landed on the merged side while the fix sits in an unmerged PR. The near-miss is worth seeing: 29cae7b added microvm-test = { sshKeyName = "microvm_test"; forgeAccess = false; } to identity.nix, and a38087c removed it. Had that machine survived to master, archetypes 40bbc3b would refuse to evaluate it today, on the public repos, with no merged fix available. What is left is the mechanism with no user: forgeAccess now appears in allod/secrets only as vm.forgeAccess or true in flake.nix and no machine sets it. So the concrete ask is small — before pulling archetypes master into the deployment, grep the private secrets fork for forgeAccess = false and land #11 there first if it hits.

[GAP] The token registry does not follow forgeAccess, so the first machine to actually use the opt-out trips credential-profilesallod/secrets (PR #11)

Issue #17's third validation bullet claims "the opted-out machine declares no target to bind". That is true only by the author's good manners, and only for a machine born opted out. forgejo-token-groups.json is hand-written JSON that declares forgejo-https-token-allod-dev with a { "system": "allod-dev", "deployed_path": "/root/.git-credentials" } target plus a local_auth_refresh entry for the same machine, and it is not derived from forgeAccess. In archetypes/flake.nix, profileOwnedForgejoTokenTargets keeps every target whose system is a key of machineConfigurations — an opted-out machine still is one — and then targetMatchesGeneratedAgeSecret looks for a generated age.secrets entry with a matching file and deployed_path. For an opted-out machine there is none, so credential-profiles fails with "Forgejo token registry targets do not match generated age.secrets file/path", and the local_auth_refresh arm fails alongside it for the same reason.

The trigger is the migration case, not the greenfield one. microvm-test was born with forgeAccess = false and was simply never added to the registry, so it passed; the machine that breaks is an existing one that becomes opted out — flip one boolean in identity.nix and its stale registry entry is now unbacked. The failure message names the registry and the age secret and never mentions forgeAccess, so the person who flipped the flag gets a diagnostic pointing away from what they did. One fact — does this machine have Forge access — lives in two places that nothing keeps in step, which is what architecture.md principle 8 forbids.

This predates your change; the already-merged forgeAccess work introduced it. But your change is what makes the opt-out complete enough for a fork to actually use, so it is the right moment to close it. Two options: derive each machine's registry targets from forgeAccess in flake.nix so the JSON stops carrying the fact, or add a check in allod/secrets asserting that a machine with forgeTokenFile == null has no forgejo-token-groups.json target and no local_auth_refresh entry. The good news on the agent-token side is that there is nothing to drift: agent-pr-token appears in credentials.nix but has no entry in forgejo-token-groups.json, so the half you added is registry-clean.

[SIMPLIFY] The four guard* assertions test a boolean, not the system — allod/archetypes

guardFlagsAgentOnly, guardFlagsHttpsOnly, guardAcceptsOptOut and guardAcceptsFullAccess evaluate (tokenFile == null) != (httpsTokenFile == null) on four concrete inputs and assert the four answers. That is a truth table for a one-line function, restated in the same file that defines the function, and it cannot fail for any reason that would not be visible by reading the two lines above it. agentTokenOnlyFails and httpsTokenOnlyFails already prove everything these do and prove it through the real builder. That is 23 lines with their assert messages; delete them. If [GAP] 1 is taken and the pairing assert goes too, devForgeCredentialMismatch, the eval-head assert, failsAtHead and all six fixtures go with it — about 64 of the 160 added lines, leaving a change whose load-bearing parts are the one-line conditional, the empty-age.secrets assertion, the empty-agenix-activation assertion, and the literal with-access comparison.

[SIMPLIFY] Lifting the guard out of mkDevVm buys nothing here — allod/archetypes

The comment says the helper was lifted "so the dev-forge-opt-out check can drive the guard directly and pin each sabotage fixture to this exact condition". The pinning is done by failsAtHead, which drives the builder, not the helper; the helper's only other caller is the assert itself. That is the same shape as unknownArchetypesOf, which earns its lift because profile-definition-contracts needs the guard's value to distinguish "flagged" from "thrown" — here both directions are already covered by one builder call. architecture.md principle 15 says extract on second use; this is still first use.

[QUESTION] expectedWithAccess hardcodes the template's identity — allod/archetypes

The literal capture is the right call and it is why the check is not a tautology; I want to be clear I am not asking you to derive it. But path = "/home/allod/.config/git/forgejo-token", owner = "allod", and the allod-dev machine name are template-specific, so a fork re-exporting framework checks will fail this one for a reason that has nothing to do with the contract, the same way pi-integration already does (vm-provisioning.md; allod/archetypes issue #11). Does dev-forge-opt-out belong on issue #11's list of checks that need a fork-portable form, or is the answer that framework checks are simply not fork-portable and forks filter them?

[QUESTION] No opted-out machine exists anywhere except inside one check — allod/archetypes, allod/secrets

The optedOut fixture is built inside dev-forge-opt-out and never registered in machines, so no other check ever sees an opted-out machine: credential-profiles, netrc-activation and the home-manager checks all read machineConfigurations.allod-dev, which has both credentials. That is how [GAP] 3 stayed invisible. I am not reopening the deletion of microvm-testvm-provisioning.md covers why a public example machine name is a hazard of its own, and dropping it was reasonable. The question is what replaces the coverage it would have given. The cheapest answer is inside the check you already wrote: run the opted-out fixture through the registry logic too, asserting that no forgejo-token-groups.json target and no local_auth_refresh entry names a machine whose forgeTokenFile is null. That is a handful of lines against a fixture that already exists, it closes [GAP] 3 with a sabotage-provable assertion, and it does not need a second machine anywhere. Is there a reason to prefer a real second dev machine over that?

Claims I checked rather than took on trust

The eval-head placement does what you say. lib exports only composeProfileDefinitions, composedLayerCheck and mkVmFactsmkDevVm and builders are not on the public surface — so nixosConfigurations.<name> is the only path to a dev machine's configuration and every one of them goes through the guarded head. profileData and profileSettings are merged into the builder's argument set before the call, so the assert sees the post-override tokenFile and httpsTokenFile, not the identity defaults. And the weak-head-force claim holds in the direction that matters: with the assert deleted from a scratch copy and nothing else changed, builtins.seq (mkDevVm <half-null args>) true succeeds, so the assert really was the only thing that force could fail on. The placement is also genuinely stronger than a config.assertions entry, since consumers that read config.age.secrets directly — credential-profiles does — would never trip a module-level assertion.

The check is not vacuous, and I proved both halves. Reverting ++ lib.optional (tokenFile != null) ./modules/agent-forgejo-token.nix to unconditional inclusion fails it with dev-forge-opt-out: opted-out fixture still generates age.secrets: agent-pr-token. Deleting only the eval-head assert fails it with dev-forge-opt-out: agent token without Forge HTTPS credential did not fail at evaluation, which also rules out the vacuity mode where failsAtHead returns true because the fixture is broken for an unrelated reason. The "with-access machine unchanged" arm is a genuine literal comparison, not a recomputation: expectedWithAccess is a hardcoded attrset and secretProjection normalises file through baseNameOf (toString ...) so it compares filenames rather than store hashes.

The inertness attribution is correct. I did not take the comment-only control on faith; I reproduced it without touching any repo, by evaluating archetypes master against two path: copies of the secrets branch differing only by an appended comment line in identity.nix — a line that cannot affect allod-dev. allod-dev's toplevel derivation moved from 71appaya3sbwvnhnjg0kn8dxkjx1kx8p to kzrdwxf26hm7pz3p1rdhq5hrcwvbsxxg. So any content change to secrets shifts that path and a shift is not evidence of a behaviour change, exactly as nix.md's out-path pinning entry predicts. The positive half is stronger evidence anyway: evaluating allod-dev's generated age.secrets against secrets master and against the PR branch gives byte-identical results — agent-pr-token | agent-pr-token.age | /home/allod/.config/git/forgejo-token | allod:users | 0600 and forgejo-https-token | forgejo-https-token-allod-dev.age | /root/.git-credentials | root:root | 0600 in both.

Both repo states anyone can pull today are coherent, for a slightly uncomfortable reason. No machine in allod/secrets sets forgeAccess, on master or on the branch, so the assert cannot fire either way and both combinations evaluate to the same machine. dev-forge-opt-out, credential-profiles and netrc-activation all build green on archetypes master, so the merged half disturbed nothing next to it. Worth knowing separately: archetypes master's own flake.lock still pins secrets at 3420300, which is two commits before forgeAccess existed in secrets at all, so archetypes' own nix flake check has never evaluated the flag in any form — the guard is exercised only by the fixtures inside the new check.

The opted-out machine has no dangling consumer that I can find. modules/netrc.nix already guards its agenix ordering with deps = lib.optional (config.age.secrets != {}) "agenix", and its body skips cleanly when /root/.git-credentials is absent instead of exiting the combined activation script. modules/github-credentials.nix derives independently from githubCredentialTargets and contributes nothing for this template. programs.git.settings.credential.helper = "store" in modules/home-shared.nix points at the user's own ~/.git-credentials, which neither secret populates, so it is unchanged by the opt-out. modules/agent-hooks.nix and modules/ai-agents.nix reference no token. Nothing in profiles or vm mentions tokenFile, agentTokenFile or forgejo-token. The one runtime consumer, allod-tools' forge, degrades by design — integrity-check exits 2 with no forge token. The one thing to note is that specialArgs.tokenFile = null is still threaded into every dev machine's module arguments even when opted out, so a future module taking tokenFile will receive null rather than not existing; that is fine today but is where the next file = null type error would come from.

What is right and should not be undone

The conditional is the fix, and it is the correct one: ++ lib.optional (tokenFile != null) ./modules/agent-forgejo-token.nix mirrors the lib.optionalAttrs (httpsTokenFile != null) handling a few lines above, so the two credentials are now handled the same way, which is what the issue asked for. The secrets half is a clean single change — hoisting forgeAccess into a let binding so one flag drives both files reads better than the two independent vm.forgeAccess or true tests it replaces, and the comment explaining why the agent token has to follow the flag is the kind of comment that survives.

The check design is better than most. Reusing allod-dev's real identity and definition and varying only the two token files means each passing and failing pair differs in exactly the field under test. The literal expectedWithAccess capture is the single most important decision in the whole check and it is the reason the with-access arm is not a tautology. Reading machineConfigurations.allod-dev.config.system.activationScripts.agenixInstall.text directly, so that an agenix rename throws a missing-attribute error rather than silently emptying the opted-out absence filter, is a real needle-rot defence and it is the pattern to copy. Pairing the absence proof on the opted-out machine with a positive --decrypt grep on the with-access machine's generated activation is architecture.md principle 13 done properly. Keep all of that. My argument is only with the pairing invariant that sits on top of it.

Reviewed by `claude-opus-5`, read-only pass. Revisions: `allod/archetypes` master `40bbc3b` (merged) and `allod/secrets` PR #11 branch `agent/agent-token-opt-out` `8381b4f`, one commit on top of secrets master `a38087c`. Scope: whether the pair implements `allod/archetypes#17`'s three goals, whether the asserted invariant is the right one, whether the new `dev-forge-opt-out` check can be shown to fail, and whether the two repos are coherent in every combination someone can pull today. The core of this change is right and I would land the secrets half as it stands. A dev machine that opts out of Forge access now composes with no agenix secrets at all, the with-access machine is unchanged in every field the check pins, and I confirmed by experiment that the new check genuinely fails when either half of the fix is removed. The problem is the extra thing that came along with it: the assertion pairing the agent PR token with the Forge HTTPS credential is not the invariant issue #17 asks for. It does not catch the failure the issue describes — a machine holding a ciphertext it is not a recipient of — and it forbids a machine shape that is reasonable and that a private fork may already want. It also quietly reverses the landing order you argued for: any fork that already sets `forgeAccess = false` somewhere stops evaluating against archetypes master until secrets #11 lands there. The public repos happen not to trip it only because the one machine that used the flag, `microvm-test`, was deleted in `a38087c` a day before archetypes merged. So this is a follow-up on archetypes rather than an emergency, but it is a narrower miss than it looks. Three findings below need action, two are trims, two are questions. Counts: 0 `[BLOCKER]`, 3 `[GAP]`, 2 `[SIMPLIFY]`, 2 `[QUESTION]`. ## `[GAP]` The asserted invariant is not the one the issue asks for, and it blocks a legitimate machine — `allod/archetypes` (merged, follow-up) The assert says the agent PR token and the Forge HTTPS credential are "two facets of one identity fact". They are not. They are two different credentials, with different owners, different consumers, and different jobs, that happen to be derived from one flag in *this* template. `agent-pr-token` lands at `/home/<user>/.config/git/forgejo-token` owned by the user; it is the Forgejo REST API token that `allod-tools`' `forge` CLI and `pm/integrity-check` read (`forge` line 50: `FORGE_TOKEN_FILE Path to token file (default: ~/.config/git/forgejo-token)`). `forgejo-https-token` lands at `/root/.git-credentials` owned by root, and `modules/netrc.nix` fans it out to `/etc/nix/netrc`, `/root/.netrc` and `/home/<user>/.netrc` — which per `nix.md` is what lets the nix daemon and libgit2 fetch private `git+https://` flake inputs at all. One is "may this machine open a PR"; the other is "may this machine fetch and rebuild itself from a private flake". A machine that can rebuild itself but must never open a PR is a coherent least-privilege posture, and it is exactly the shape `agentTokenFile = null, forgeTokenFile = <set>` describes. Your assert refuses to evaluate it. I checked that both blocked shapes are real machines rather than hypotheticals. Against archetypes master with a secrets copy that nulls only `agentTokenFile`, evaluating `allod-dev` dies with `dev machine 'allod-dev': agent token file and Forge HTTPS credential must be both set or both null ...; got tokenFile null, httpsTokenFile set`. Against the same tree with only the assert deleted, that machine composes cleanly with exactly one secret, `forgejo-https-token | forgejo-https-token-allod-dev.age | /root/.git-credentials` — a machine that can fetch private flake inputs and rebuild itself and cannot open a PR. The mirror case, nulling only `forgeTokenFile`, composes just as cleanly with `agent-pr-token | agent-pr-token.age | /home/allod/.config/git/forgejo-token`, which is the machine that pushes over its `allod-dev-forge-key.age` SSH key and holds no long-lived password in `/root/.git-credentials`. Neither is malformed; both are refused. The second one is admittedly less useful today because archetypes' own inputs are `git+https://` URLs and so need the netrc fan-out, but that is a property of the current URL scheme, not of identity, and it should not be frozen into a framework assert. Note also where this assert lives. It is in the public framework, enforcing the shape of data that a private fork's identity template owns. `architecture.md` principle 5 puts behaviour in framework repos and identity in data repos; a fork that legitimately wants the split above now has to patch `archetypes`, not its own secrets. That is the layering running backwards. The deeper problem is that the assert does not deliver goal 3. Goal 3 is "a machine that must have a token and lacks a *usable* one should be an evaluation error". Usable means decryptable — the issue's own narration is a machine that is not in the ciphertext's recipient set running `age --decrypt -i /etc/ssh/<machine>` against a file it cannot open. The assert checks presence and pairing, never recipiency, so the failure the issue is actually about still surfaces at first activation exactly as before. What genuinely fixes the reported bug is the `lib.optional` conditional plus the secrets template nulling `agentTokenFile` — goals 1 and 2, both correct. The assert is a third mechanism guarding drift between two fields that a single flag sets, which under a correct template can essentially never happen. The invariant that would actually satisfy goal 3, and that never blocks the split above: for every `age.secrets` entry a machine generates, that machine's host public key must be among the ciphertext's recipients. It generalises past these two secrets to `forgejo-https-token-<name>.age`, the GitHub credentials, and anything a fork adds, and it fires precisely when a machine would fail to decrypt at activation. The data is nearly in place — `secrets.lib.machineHostKeys` is already exported, and `secrets/secrets.nix` already derives every recipient list from it via `vmKeys`. The missing piece is one line in `allod/secrets`: `secretsNix` is already bound at `flake.nix:18` and consumed by that repo's own checks at lines 146 and 162, but never exported, so archetypes cannot see recipients. Exporting `lib.secretRecipients = secretsNix;` makes the real check writable. I would replace the pairing assert with that, not add it alongside. ## `[GAP]` The assert reverses the landing order it was built to respect — `allod/archetypes` (merged, follow-up) The stated ordering argument is that archetypes must land first because the builder has to tolerate a null `tokenFile` before the template starts emitting one. That is true of the conditional. It is false of the assert, which requires the opposite: the template must already null *both* files before the builder is safe to pull. Concretely, a fork whose secrets is at master semantics — `forgeAccess = false` nulls `forgeTokenFile` only, which is exactly what secrets master `a38087c` does — and that sets `forgeAccess = false` on any machine now gets `tokenFile` set with `httpsTokenFile` null, trips the assert, and that machine stops evaluating. Because the assert throws from the head of the builder rather than through `config.assertions`, it takes down every attribute of that configuration, so it also fails the fork's whole-flake `nix flake check` and any composition-root output that enumerates machines, not just the broken machine's own build. You can argue this is goal 3 working as designed — a louder failure for a machine that was already broken at activation. I would accept that framing if it had been said out loud in the commit message or the PR, because it is a cross-repo forcing function that landed on the merged side while the fix sits in an unmerged PR. The near-miss is worth seeing: `29cae7b` added `microvm-test = { sshKeyName = "microvm_test"; forgeAccess = false; }` to `identity.nix`, and `a38087c` removed it. Had that machine survived to master, archetypes `40bbc3b` would refuse to evaluate it today, on the public repos, with no merged fix available. What is left is the mechanism with no user: `forgeAccess` now appears in `allod/secrets` only as `vm.forgeAccess or true` in `flake.nix` and no machine sets it. So the concrete ask is small — before pulling archetypes master into the deployment, grep the private secrets fork for `forgeAccess = false` and land #11 there first if it hits. ## `[GAP]` The token registry does not follow `forgeAccess`, so the first machine to actually use the opt-out trips `credential-profiles` — `allod/secrets` (PR #11) Issue #17's third validation bullet claims "the opted-out machine declares no target to bind". That is true only by the author's good manners, and only for a machine born opted out. `forgejo-token-groups.json` is hand-written JSON that declares `forgejo-https-token-allod-dev` with a `{ "system": "allod-dev", "deployed_path": "/root/.git-credentials" }` target plus a `local_auth_refresh` entry for the same machine, and it is not derived from `forgeAccess`. In `archetypes/flake.nix`, `profileOwnedForgejoTokenTargets` keeps every target whose `system` is a key of `machineConfigurations` — an opted-out machine still is one — and then `targetMatchesGeneratedAgeSecret` looks for a generated `age.secrets` entry with a matching file and `deployed_path`. For an opted-out machine there is none, so `credential-profiles` fails with "Forgejo token registry targets do not match generated age.secrets file/path", and the `local_auth_refresh` arm fails alongside it for the same reason. The trigger is the migration case, not the greenfield one. `microvm-test` was born with `forgeAccess = false` and was simply never added to the registry, so it passed; the machine that breaks is an existing one that *becomes* opted out — flip one boolean in `identity.nix` and its stale registry entry is now unbacked. The failure message names the registry and the age secret and never mentions `forgeAccess`, so the person who flipped the flag gets a diagnostic pointing away from what they did. One fact — does this machine have Forge access — lives in two places that nothing keeps in step, which is what `architecture.md` principle 8 forbids. This predates your change; the already-merged `forgeAccess` work introduced it. But your change is what makes the opt-out complete enough for a fork to actually use, so it is the right moment to close it. Two options: derive each machine's registry targets from `forgeAccess` in `flake.nix` so the JSON stops carrying the fact, or add a check in `allod/secrets` asserting that a machine with `forgeTokenFile == null` has no `forgejo-token-groups.json` target and no `local_auth_refresh` entry. The good news on the agent-token side is that there is nothing to drift: `agent-pr-token` appears in `credentials.nix` but has no entry in `forgejo-token-groups.json`, so the half you added is registry-clean. ## `[SIMPLIFY]` The four `guard*` assertions test a boolean, not the system — `allod/archetypes` `guardFlagsAgentOnly`, `guardFlagsHttpsOnly`, `guardAcceptsOptOut` and `guardAcceptsFullAccess` evaluate `(tokenFile == null) != (httpsTokenFile == null)` on four concrete inputs and assert the four answers. That is a truth table for a one-line function, restated in the same file that defines the function, and it cannot fail for any reason that would not be visible by reading the two lines above it. `agentTokenOnlyFails` and `httpsTokenOnlyFails` already prove everything these do and prove it through the real builder. That is 23 lines with their assert messages; delete them. If `[GAP]` 1 is taken and the pairing assert goes too, `devForgeCredentialMismatch`, the eval-head assert, `failsAtHead` and all six fixtures go with it — about 64 of the 160 added lines, leaving a change whose load-bearing parts are the one-line conditional, the empty-`age.secrets` assertion, the empty-agenix-activation assertion, and the literal with-access comparison. ## `[SIMPLIFY]` Lifting the guard out of `mkDevVm` buys nothing here — `allod/archetypes` The comment says the helper was lifted "so the dev-forge-opt-out check can drive the guard directly and pin each sabotage fixture to this exact condition". The pinning is done by `failsAtHead`, which drives the builder, not the helper; the helper's only other caller is the assert itself. That is the same shape as `unknownArchetypesOf`, which earns its lift because `profile-definition-contracts` needs the guard's *value* to distinguish "flagged" from "thrown" — here both directions are already covered by one builder call. `architecture.md` principle 15 says extract on second use; this is still first use. ## `[QUESTION]` `expectedWithAccess` hardcodes the template's identity — `allod/archetypes` The literal capture is the right call and it is why the check is not a tautology; I want to be clear I am not asking you to derive it. But `path = "/home/allod/.config/git/forgejo-token"`, `owner = "allod"`, and the `allod-dev` machine name are template-specific, so a fork re-exporting framework checks will fail this one for a reason that has nothing to do with the contract, the same way `pi-integration` already does (`vm-provisioning.md`; `allod/archetypes` issue #11). Does `dev-forge-opt-out` belong on issue #11's list of checks that need a fork-portable form, or is the answer that framework checks are simply not fork-portable and forks filter them? ## `[QUESTION]` No opted-out machine exists anywhere except inside one check — `allod/archetypes`, `allod/secrets` The `optedOut` fixture is built inside `dev-forge-opt-out` and never registered in `machines`, so no other check ever sees an opted-out machine: `credential-profiles`, `netrc-activation` and the home-manager checks all read `machineConfigurations.allod-dev`, which has both credentials. That is how `[GAP]` 3 stayed invisible. I am not reopening the deletion of `microvm-test` — `vm-provisioning.md` covers why a public example machine name is a hazard of its own, and dropping it was reasonable. The question is what replaces the coverage it would have given. The cheapest answer is inside the check you already wrote: run the opted-out fixture through the registry logic too, asserting that no `forgejo-token-groups.json` target and no `local_auth_refresh` entry names a machine whose `forgeTokenFile` is null. That is a handful of lines against a fixture that already exists, it closes `[GAP]` 3 with a sabotage-provable assertion, and it does not need a second machine anywhere. Is there a reason to prefer a real second dev machine over that? ## Claims I checked rather than took on trust The eval-head placement does what you say. `lib` exports only `composeProfileDefinitions`, `composedLayerCheck` and `mkVmFacts` — `mkDevVm` and `builders` are not on the public surface — so `nixosConfigurations.<name>` is the only path to a dev machine's configuration and every one of them goes through the guarded head. `profileData` and `profileSettings` are merged into the builder's argument set before the call, so the assert sees the post-override `tokenFile` and `httpsTokenFile`, not the identity defaults. And the weak-head-force claim holds in the direction that matters: with the assert deleted from a scratch copy and nothing else changed, `builtins.seq (mkDevVm <half-null args>) true` succeeds, so the assert really was the only thing that force could fail on. The placement is also genuinely stronger than a `config.assertions` entry, since consumers that read `config.age.secrets` directly — `credential-profiles` does — would never trip a module-level assertion. The check is not vacuous, and I proved both halves. Reverting `++ lib.optional (tokenFile != null) ./modules/agent-forgejo-token.nix` to unconditional inclusion fails it with `dev-forge-opt-out: opted-out fixture still generates age.secrets: agent-pr-token`. Deleting only the eval-head assert fails it with `dev-forge-opt-out: agent token without Forge HTTPS credential did not fail at evaluation`, which also rules out the vacuity mode where `failsAtHead` returns true because the fixture is broken for an unrelated reason. The "with-access machine unchanged" arm is a genuine literal comparison, not a recomputation: `expectedWithAccess` is a hardcoded attrset and `secretProjection` normalises `file` through `baseNameOf (toString ...)` so it compares filenames rather than store hashes. The inertness attribution is correct. I did not take the comment-only control on faith; I reproduced it without touching any repo, by evaluating archetypes master against two `path:` copies of the secrets branch differing only by an appended comment line in `identity.nix` — a line that cannot affect `allod-dev`. `allod-dev`'s toplevel derivation moved from `71appaya3sbwvnhnjg0kn8dxkjx1kx8p` to `kzrdwxf26hm7pz3p1rdhq5hrcwvbsxxg`. So any content change to secrets shifts that path and a shift is not evidence of a behaviour change, exactly as `nix.md`'s out-path pinning entry predicts. The positive half is stronger evidence anyway: evaluating `allod-dev`'s generated `age.secrets` against secrets master and against the PR branch gives byte-identical results — `agent-pr-token | agent-pr-token.age | /home/allod/.config/git/forgejo-token | allod:users | 0600` and `forgejo-https-token | forgejo-https-token-allod-dev.age | /root/.git-credentials | root:root | 0600` in both. Both repo states anyone can pull today are coherent, for a slightly uncomfortable reason. No machine in `allod/secrets` sets `forgeAccess`, on master or on the branch, so the assert cannot fire either way and both combinations evaluate to the same machine. `dev-forge-opt-out`, `credential-profiles` and `netrc-activation` all build green on archetypes master, so the merged half disturbed nothing next to it. Worth knowing separately: archetypes master's own `flake.lock` still pins secrets at `3420300`, which is two commits *before* `forgeAccess` existed in secrets at all, so archetypes' own `nix flake check` has never evaluated the flag in any form — the guard is exercised only by the fixtures inside the new check. The opted-out machine has no dangling consumer that I can find. `modules/netrc.nix` already guards its agenix ordering with `deps = lib.optional (config.age.secrets != {}) "agenix"`, and its body skips cleanly when `/root/.git-credentials` is absent instead of exiting the combined activation script. `modules/github-credentials.nix` derives independently from `githubCredentialTargets` and contributes nothing for this template. `programs.git.settings.credential.helper = "store"` in `modules/home-shared.nix` points at the user's own `~/.git-credentials`, which neither secret populates, so it is unchanged by the opt-out. `modules/agent-hooks.nix` and `modules/ai-agents.nix` reference no token. Nothing in `profiles` or `vm` mentions `tokenFile`, `agentTokenFile` or `forgejo-token`. The one runtime consumer, `allod-tools`' `forge`, degrades by design — `integrity-check` exits 2 with `no forge token`. The one thing to note is that `specialArgs.tokenFile = null` is still threaded into every dev machine's module arguments even when opted out, so a future module taking `tokenFile` will receive null rather than not existing; that is fine today but is where the next `file = null` type error would come from. ## What is right and should not be undone The conditional is the fix, and it is the correct one: `++ lib.optional (tokenFile != null) ./modules/agent-forgejo-token.nix` mirrors the `lib.optionalAttrs (httpsTokenFile != null)` handling a few lines above, so the two credentials are now handled the same way, which is what the issue asked for. The secrets half is a clean single change — hoisting `forgeAccess` into a `let` binding so one flag drives both files reads better than the two independent `vm.forgeAccess or true` tests it replaces, and the comment explaining *why* the agent token has to follow the flag is the kind of comment that survives. The check design is better than most. Reusing `allod-dev`'s real identity and definition and varying only the two token files means each passing and failing pair differs in exactly the field under test. The literal `expectedWithAccess` capture is the single most important decision in the whole check and it is the reason the with-access arm is not a tautology. Reading `machineConfigurations.allod-dev.config.system.activationScripts.agenixInstall.text` directly, so that an agenix rename throws a missing-attribute error rather than silently emptying the opted-out absence filter, is a real needle-rot defence and it is the pattern to copy. Pairing the absence proof on the opted-out machine with a positive `--decrypt` grep on the with-access machine's generated activation is `architecture.md` principle 13 done properly. Keep all of that. My argument is only with the pairing invariant that sits on top of it.
vnprc closed this issue 2026-07-31 00:57:44 +01:00
Sign in to join this conversation.
No description provided.