Let a Forge-opted-out dev machine skip the agent token too #17
Labels
No labels
bug
duplicate
enhancement
help wanted
invalid
question
wontfix
bug
duplicate
enhancement
help wanted
invalid
question
wontfix
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
allod/archetypes#17
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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/secretsgrew aforgeAccess = falseopt-out so a throwaway dev machine does not need a per-machine Forgejo token minted before it can be evaluated. That coversforgeTokenFile, which the dev builder already treats as optional. It does not coveragentTokenFile, 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:
tokenFilecomposes without anagent-pr-tokensecret, the same way it already composes without a Forge HTTPS credential.forgeAccess = falsein the identity template nullsagentTokenFileas well asforgeTokenFile, so one flag means one thing.Current state and specifics
mkDevVm(flake.nix) takestokenFile ? identity.agentTokenFileand puts./modules/agent-forgejo-token.nixin every dev machine's module list unconditionally. That module setsage.secrets.agent-pr-token.file = tokenFilewith 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.secretsentry,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 runsage --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
tokenFileat some other ciphertext deploys the wrong credential to a Forge token path.Suggested shape, matching the existing HTTPS handling:
mkDevVmincludes./modules/agent-forgejo-token.nixonly whentokenFile != null.allod/secretsdevIdentitiessetsagentTokenFile = nullunderforgeAccess = false.Scope
In scope: the builder's conditional, the identity template's
agentTokenFile, and whatever check keeps the two in step. Deployments that forksecretspick the second half up when they port the opt-out.Out of scope: the
forgeAccessmechanism itself, which is already merged; and any change to how the shared agent token's recipient set is chosen.Validation
forgeAccess = falseevaluates and itsconfig.age.secretsis empty.credential-profilescheck still binds every declared token target to a generatedage.secretsentry, and the opted-out machine declares no target to bind.Reviewed by
claude-opus-5, read-only pass. Revisions:allod/archetypesmaster40bbc3b(merged) andallod/secretsPR #11 branchagent/agent-token-opt-out8381b4f, one commit on top of secrets mastera38087c. Scope: whether the pair implementsallod/archetypes#17's three goals, whether the asserted invariant is the right one, whether the newdev-forge-opt-outcheck 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 = falsesomewhere 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 ina38087ca 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-tokenlands at/home/<user>/.config/git/forgejo-tokenowned by the user; it is the Forgejo REST API token thatallod-tools'forgeCLI andpm/integrity-checkread (forgeline 50:FORGE_TOKEN_FILE Path to token file (default: ~/.config/git/forgejo-token)).forgejo-https-tokenlands at/root/.git-credentialsowned by root, andmodules/netrc.nixfans it out to/etc/nix/netrc,/root/.netrcand/home/<user>/.netrc— which pernix.mdis what lets the nix daemon and libgit2 fetch privategit+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 shapeagentTokenFile = 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, evaluatingallod-devdies withdev 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 onlyforgeTokenFile, composes just as cleanly withagent-pr-token | agent-pr-token.age | /home/allod/.config/git/forgejo-token, which is the machine that pushes over itsallod-dev-forge-key.ageSSH 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 aregit+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.mdprinciple 5 puts behaviour in framework repos and identity in data repos; a fork that legitimately wants the split above now has to patcharchetypes, 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 thelib.optionalconditional plus the secrets template nullingagentTokenFile— 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.secretsentry a machine generates, that machine's host public key must be among the ciphertext's recipients. It generalises past these two secrets toforgejo-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.machineHostKeysis already exported, andsecrets/secrets.nixalready derives every recipient list from it viavmKeys. The missing piece is one line inallod/secrets:secretsNixis already bound atflake.nix:18and consumed by that repo's own checks at lines 146 and 162, but never exported, so archetypes cannot see recipients. Exportinglib.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
tokenFilebefore 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 = falsenullsforgeTokenFileonly, which is exactly what secrets mastera38087cdoes — and that setsforgeAccess = falseon any machine now getstokenFileset withhttpsTokenFilenull, trips the assert, and that machine stops evaluating. Because the assert throws from the head of the builder rather than throughconfig.assertions, it takes down every attribute of that configuration, so it also fails the fork's whole-flakenix flake checkand 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:
29cae7baddedmicrovm-test = { sshKeyName = "microvm_test"; forgeAccess = false; }toidentity.nix, anda38087cremoved it. Had that machine survived to master, archetypes40bbc3bwould refuse to evaluate it today, on the public repos, with no merged fix available. What is left is the mechanism with no user:forgeAccessnow appears inallod/secretsonly asvm.forgeAccess or trueinflake.nixand no machine sets it. So the concrete ask is small — before pulling archetypes master into the deployment, grep the private secrets fork forforgeAccess = falseand land #11 there first if it hits.[GAP]The token registry does not followforgeAccess, so the first machine to actually use the opt-out tripscredential-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.jsonis hand-written JSON that declaresforgejo-https-token-allod-devwith a{ "system": "allod-dev", "deployed_path": "/root/.git-credentials" }target plus alocal_auth_refreshentry for the same machine, and it is not derived fromforgeAccess. Inarchetypes/flake.nix,profileOwnedForgejoTokenTargetskeeps every target whosesystemis a key ofmachineConfigurations— an opted-out machine still is one — and thentargetMatchesGeneratedAgeSecretlooks for a generatedage.secretsentry with a matching file anddeployed_path. For an opted-out machine there is none, socredential-profilesfails with "Forgejo token registry targets do not match generated age.secrets file/path", and thelocal_auth_refresharm fails alongside it for the same reason.The trigger is the migration case, not the greenfield one.
microvm-testwas born withforgeAccess = falseand 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 inidentity.nixand its stale registry entry is now unbacked. The failure message names the registry and the age secret and never mentionsforgeAccess, 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 whatarchitecture.mdprinciple 8 forbids.This predates your change; the already-merged
forgeAccesswork 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 fromforgeAccessinflake.nixso the JSON stops carrying the fact, or add a check inallod/secretsasserting that a machine withforgeTokenFile == nullhas noforgejo-token-groups.jsontarget and nolocal_auth_refreshentry. The good news on the agent-token side is that there is nothing to drift:agent-pr-tokenappears incredentials.nixbut has no entry inforgejo-token-groups.json, so the half you added is registry-clean.[SIMPLIFY]The fourguard*assertions test a boolean, not the system —allod/archetypesguardFlagsAgentOnly,guardFlagsHttpsOnly,guardAcceptsOptOutandguardAcceptsFullAccessevaluate(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.agentTokenOnlyFailsandhttpsTokenOnlyFailsalready 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,failsAtHeadand 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.secretsassertion, the empty-agenix-activation assertion, and the literal with-access comparison.[SIMPLIFY]Lifting the guard out ofmkDevVmbuys nothing here —allod/archetypesThe 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 asunknownArchetypesOf, which earns its lift becauseprofile-definition-contractsneeds the guard's value to distinguish "flagged" from "thrown" — here both directions are already covered by one builder call.architecture.mdprinciple 15 says extract on second use; this is still first use.[QUESTION]expectedWithAccesshardcodes the template's identity —allod/archetypesThe 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 theallod-devmachine 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 waypi-integrationalready does (vm-provisioning.md;allod/archetypesissue #11). Doesdev-forge-opt-outbelong 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/secretsThe
optedOutfixture is built insidedev-forge-opt-outand never registered inmachines, so no other check ever sees an opted-out machine:credential-profiles,netrc-activationand the home-manager checks all readmachineConfigurations.allod-dev, which has both credentials. That is how[GAP]3 stayed invisible. I am not reopening the deletion ofmicrovm-test—vm-provisioning.mdcovers 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 noforgejo-token-groups.jsontarget and nolocal_auth_refreshentry names a machine whoseforgeTokenFileis 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.
libexports onlycomposeProfileDefinitions,composedLayerCheckandmkVmFacts—mkDevVmandbuildersare not on the public surface — sonixosConfigurations.<name>is the only path to a dev machine's configuration and every one of them goes through the guarded head.profileDataandprofileSettingsare merged into the builder's argument set before the call, so the assert sees the post-overridetokenFileandhttpsTokenFile, 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>) truesucceeds, so the assert really was the only thing that force could fail on. The placement is also genuinely stronger than aconfig.assertionsentry, since consumers that readconfig.age.secretsdirectly —credential-profilesdoes — 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.nixto unconditional inclusion fails it withdev-forge-opt-out: opted-out fixture still generates age.secrets: agent-pr-token. Deleting only the eval-head assert fails it withdev-forge-opt-out: agent token without Forge HTTPS credential did not fail at evaluation, which also rules out the vacuity mode wherefailsAtHeadreturns true because the fixture is broken for an unrelated reason. The "with-access machine unchanged" arm is a genuine literal comparison, not a recomputation:expectedWithAccessis a hardcoded attrset andsecretProjectionnormalisesfilethroughbaseNameOf (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 inidentity.nix— a line that cannot affectallod-dev.allod-dev's toplevel derivation moved from71appaya3sbwvnhnjg0kn8dxkjx1kx8ptokzrdwxf26hm7pz3p1rdhq5hrcwvbsxxg. So any content change to secrets shifts that path and a shift is not evidence of a behaviour change, exactly asnix.md's out-path pinning entry predicts. The positive half is stronger evidence anyway: evaluatingallod-dev's generatedage.secretsagainst 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 | 0600andforgejo-https-token | forgejo-https-token-allod-dev.age | /root/.git-credentials | root:root | 0600in both.Both repo states anyone can pull today are coherent, for a slightly uncomfortable reason. No machine in
allod/secretssetsforgeAccess, 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-profilesandnetrc-activationall build green on archetypes master, so the merged half disturbed nothing next to it. Worth knowing separately: archetypes master's ownflake.lockstill pins secrets at3420300, which is two commits beforeforgeAccessexisted in secrets at all, so archetypes' ownnix flake checkhas 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.nixalready guards its agenix ordering withdeps = lib.optional (config.age.secrets != {}) "agenix", and its body skips cleanly when/root/.git-credentialsis absent instead of exiting the combined activation script.modules/github-credentials.nixderives independently fromgithubCredentialTargetsand contributes nothing for this template.programs.git.settings.credential.helper = "store"inmodules/home-shared.nixpoints at the user's own~/.git-credentials, which neither secret populates, so it is unchanged by the opt-out.modules/agent-hooks.nixandmodules/ai-agents.nixreference no token. Nothing inprofilesorvmmentionstokenFile,agentTokenFileorforgejo-token. The one runtime consumer,allod-tools'forge, degrades by design —integrity-checkexits 2 withno forge token. The one thing to note is thatspecialArgs.tokenFile = nullis still threaded into every dev machine's module arguments even when opted out, so a future module takingtokenFilewill receive null rather than not existing; that is fine today but is where the nextfile = nulltype 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.nixmirrors thelib.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 — hoistingforgeAccessinto aletbinding so one flag drives both files reads better than the two independentvm.forgeAccess or truetests 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 literalexpectedWithAccesscapture is the single most important decision in the whole check and it is the reason the with-access arm is not a tautology. ReadingmachineConfigurations.allod-dev.config.system.activationScripts.agenixInstall.textdirectly, 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--decryptgrep on the with-access machine's generated activation isarchitecture.mdprinciple 13 done properly. Keep all of that. My argument is only with the pairing invariant that sits on top of it.