feat: cast github-app create/register — run the App Manifest flow instead of transcribing it #124
Labels
No labels
blocked
blocker:ci-red
blocker:conflict
blocker:drill-pending
blocker:unrequested
bug
claimed
documentation
enhancement
epic
merge-next
needs-triage
ready
release
scope:apply
scope:capture
scope:coolify-api
scope:fleet
scope:manifest
scope:secrets
stale
state:addressing
state:bots-reviewing
state:building
state:needs-human
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: heavy-duty/cast#124
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/github-app"
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?
Why this is a browser flow and not an API call
Worth stating first, because it shapes every decision below and it is the thing anyone re-deriving this will get wrong:
POST /apps, no GraphQL mutation. A PAT cannot mint one at any scope.ghCLI has noappsubcommand. Shelling out toghis not buildable at any level of credential.So
cast github-app createserves a page. That is not a workaround; it is the API.And the flow gives us more than the manual path did, not less. Its conversion response is the only moment GitHub ever hands over the private key, the client secret and the webhook secret together. Today those are scattered across a download folder and a browser tab — which is why nothing about the App survived in state, and why rebuilding the instance meant redoing the hoops from memory.
createandregisterare one implementationThis was the main design risk, and it is handled by structure rather than by discipline.
Everything in
src/github-app.tsaboveregisterGithubAppexists to produce anAppCredentials.registeris handed one by the operator;createobtains one from GitHub. Then:There is no second registration path, no shared-helper-plus-two-callers, no "mostly the same". The fall-through is
create's return statement.It is also asserted as an identity of behaviour rather than of code shape.
test/github-app.test.tspins the exact Coolify call sequence for aregister-only run, and thecreatetest asserts the same three calls in the same order — so a future divergence fails a test rather than passing review:The step that matters most
Step 9 of the issue, and the one I would defend hardest if the rest were cut. After the two POSTs, both verbs call
GET /github-apps/{id}/repositoriesand assert<org>/<repo>is actually in the list.Without it, an App installed on the wrong repositories registers cleanly and fails hours later, in a different command, on a different day, as an unresolvable source at
cast applytime. With it:The three-way distinction is deliberate and tested: in the list (pass), readably absent (hard error naming what it can see), unreadable (a different hard error —
cannot verify that <name> can reach <repo>, which says the registration succeeded and the check failed). A partial list is indistinguishable from a complete one, so one unreadable row collapses the whole read to "unknown" rather than silently shortening the evidence for a claim about repo access. That is theLiveLookuprule fromcoolify.ts, one level over.#5's three footguns, dissolved rather than validated
The name decoupling from state.
github_apps.<org>/<repo>inenvironments.yamlis the value every latercast applyresolves this repo's App by, so it is the authority — resolved before anything reaches a network.--namemay seed an absent entry and is a hard refusal when it disagrees with one that exists, because at that point one of the two is wrong and cast cannot know which. Full-slug key, per #6; the bare-<repo>fallback still resolves for existing state files (tested).Two ordering choices worth flagging. The refusal happens before the team assert, so a disagreeing
--namenever touches Coolify at all (expect(stub.hits).toEqual([])). And the seed is written only after the App is registered and verified — a state file naming an App that does not work is worse than one naming none, since the nextapplywill resolve it, use it, and fail at clone time.Path/cwd fragility. Gone with the script:
--stateand--private-keyare ordinary CLI flags resolved by the samestateDirFromevery other verb uses.A required
WEBHOOK_SECRETfor a webhook-inactive App.creategets a real one from GitHub.registergenerates one and says so. Nobody runsopenssl rand -hex 16to satisfy a check any more.The secrets decision, argued
The issue flagged this as "worth deciding explicitly rather than defaulting into", so here is the reasoning rather than the outcome.
What I did: all three secrets to
<state>/github-apps/, plaintext,0600, under a cast-written.gitignoreof*. Nothing is printed.Why not the age store. I read
src/secrets.tsbefore deciding, and it rules itself out twice over.secrets/<repo>.<env>.env.ageis per-repo-per-env application env vars — its entire purpose is to be decrypted and injected into the running container. An App private key is the last thing that should be in an application's environment; putting it there would be an actual leak, not a stylistic mismatch. AndkeyFileFor()throws where no age key exists, which is the incubator deployment's real state. Encrypting the one credential that makes disaster recovery possible behind a key that may not be on the machine doing the bootstrap is how DR fails at exactly the moment it is needed.Why all three, and not the issue's PEM-to-disk-plus-print-the-rest. I deviated here deliberately, in both halves:
tee, a CI log, a pasted terminal transcript. And it hands the operator back the transcription job this whole issue exists to delete.registerneeds--client-secret-stdinto run. If cast keeps the PEM and prints the client secret, then the DR restore path depends on the operator having preserved a line of terminal output from months ago. GitHub will not show it again. A store that holds two thirds of a credential is not a store.Why plaintext is still not fine, and what makes it safe enough. It isn't fine, and the honest v1 makes the default safe rather than pretending otherwise. The
.gitignoreis the structural version of the issue's "loud note":git add -Ain the state repo cannot commit these by accident, and committing them stays possible but becomes a deliberate act. The file says so in its own comments, and README says what to do instead (encrypt and commit the ciphertext, or keep the directory out of the repo entirely).Follow-up worth filing: an age-encrypted
github-appsstore, keyed independently of the per-env application stores, so a state repo can carry the App credentials as ciphertext. That is a real feature with its own key-management design, not a line of code, and blockingcreateon it would keep the manual browser dance alive for longer than the disease deserves.One more ordering choice: credentials are persisted before the Coolify calls, not after. GitHub shows the private key once; losing it to a failed HTTP POST means deleting the App and starting over. Tested — a Coolify that 404s everything still leaves
app.pemon disk. Writes are idempotent on identical content and refuse on differing content (--forceis the deliberate escape hatch for a stale half-run).The assumption this entire design rests on, which I could not validate
redirect_urlonhttp://127.0.0.1:<port>.The issue asks that this be validated against a throwaway App before building anything else. I could not do it. Validating it requires a logged-in GitHub session to submit the manifest form, and there is no headless path to that. So this PR is built on the assumption, and I want it impossible to miss:
redirect_url.createfails at the browser step — before anything is registered and before any secret is written.<details>block under Until it has worked once), explicitly framed as supported untilcreatehas succeeded against real GitHub once.registerdoes not depend on the assumption at all, so the fallback is a complete path, not a stub.The other two "Unverified" items from the issue are handled by construction rather than by test: the manifest
codeis treated as single-use (the error messages tell the operator to re-run the whole flow, never to retry the exchange), and theadmin:read-scopedGET /orgs/{org}/installationsis never reached for — step 7 uses the JWT path.Also unvalidated by me and worth a reviewer's eye: the exact shape of Coolify's
GET /github-apps/{id}/repositoriesbody. The vendored OpenAPI types the items as bareobject, so the reader acceptsfull_nameorowner.login+name, accepts both a bare array and{repositories: […]}, and collapses to "unreadable" rather than guessing on anything else.What the tests prove, and what they do not
The issue's "Testability boundary — read before planning" is obeyed literally. There is no test that simulates a pass of the parts an agent cannot reach, and the boundary is written into the test file's header comment so the next reader inherits it rather than re-deriving it.
Not proven, and not simulated:
redirect_url(above).Proven, against real servers and real crypto where the behaviour is a protocol:
pull-requestscosts an App you must delete),default_events: [],hook_attributes.active: falseon an RFC-2606.invalidhost,public: false,redirect_urlon the loopback literal and asserted not to containlocalhostnode:httpserver driven by realfetchrequests — serves the auto-submitting form, captures?code=, 400s a missing codestatecreateVerify("RSA-SHA256")against the public key, plus a negative test against a different keypair. Claims checked numerically:iatbackdated 60s,exp - iat ≤ 600,exp > now,iss= client id, header{alg: RS256, typ: JWT}Authorizationheader is sent; 404 → the "code is spent, re-run the flow" remedy; 422 → the rate-limit remedy; a partial body is refused rather than half-persisted; a nullwebhook_secretreads as absent, not emptyowner.login+nameshapes, and one-bad-row-collapses-the-listregisterend to end throughnode dist/cli.jsagainst a stub Coolify: stdin-only client secret (asserted to arrive at Coolify), team assert, read-only refusal before any call,--namedisagreement refused before touching Coolify, seeding on success, and not seeding on verification failure"><script>in the manifest cannot break out of the form fieldtest/github-app.test.ts(40) +test/github-app-register-cli.test.ts(7) = 47 new tests.scripts/register-github-app.sh: deleted, not wrappedThe issue offers "thin wrapper or delete". Deleted. Keeping it as a wrapper would preserve exactly the interface #5 catalogued as producing three live footguns in a single provisioning run — the env-var pile, the
CAST_STATE=.+ relative-path cwd fragility, the mandatoryWEBHOOK_SECRET— while adding a second surface to keep in step with the CLI forever. A wrapper that fixes those is not thin, and it is not a wrapper: it is the CLI with a shell accent.ci.yml'sbash -n scripts/*.shstill hasrestore-db.shto match, so nothing there breaks.Docs
github-appin the command block and the command list, plus a new The GitHub App section — why it is a browser flow, the nine steps,register's stdin-only secret, the credential layout with the secrets argument, and the Until it has worked once subsection carrying the unvalidated assumption and the manual fallback in a<details>.## Scriptssection no longer advertises a script that is gone.cast --helpgains agithub-appblock covering both verbs,--name's seed-or-refuse rule, and the post-condition check.CHANGELOG.mdunder## Unreleased— inserted above## 0.1.1;git diffshows zero removed## X.Y.Zheadings (heavy-duty/box#122).No new dependencies
node:httpserves the callback;node:crypto'screateSign("RSA-SHA256")is RS256, and a JWT is two base64url JSON segments plus that signature. cast stays atyaml+zod. No Octokit.package.jsonis untouched.Checks
Branch is on the
dan-claude-bot/castfork, per CONTRIBUTING.Closes #7
Verdict: Request changes — blockers listed below.
Strong overall design: create→register fall-through, name-from-state, CSRF + keep-listening on forgery, JWT from the App key (not redirect
installation_id), persist-before-Coolify on the register path, and the repo-visibility post-condition. CI is green. One create-path hole contradicts the PEM-once design and its own error text.Blockers
createcan lose the one-shot PEM after a successful conversion —src/github-app.ts(createGithubApp→awaitInstallationId→ only thenregisterGithubApp/persistCredentials)Order today:
convertManifestCode— GitHub hands overpem/ client secret / webhook secret (in memory only)awaitInstallationId— polls up to ~5 minutes<state>/github-apps/)registerGithubApp/persistCredentials— never runsSo if the operator is slow on the install screen, or leaves and comes back, the conversion secrets are gone and GitHub will not re-show the PEM. The failure text is wrong:
That claim is true only after
registerGithubAppstarts — i.e. after install id recovery succeeds. The PR body correctly argues “persist before Coolify so a failed HTTP does not lose the key”; the same argument applies to the install poll, which can fail for longer and for more ordinary reasons.Fix: after conversion (and before
awaitInstallationId), persist at least the PEM + client secret + app/client ids undergithub-apps/(installation id can be filled in once known, or write a partial record and update it). Then the timeout / “install later” path can honestly point at those files andregister. Add a test that conversion succeeds, install stays 404 for all attempts, and the PEM is still on disk with a remedy that matches reality.Nits / optional
“Re-run
registerto re-check” after a failed repo-visibility check —registerGithubAppalwaysPOSTs key + App again before the GET. If Coolify does not de-dupe by name, a second run is a second Source, not a re-verify. Either document that, or split a verify-only path / make re-register idempotent.opts.nameused as a filesystem path segment (${name}.pem/${name}.json) with no character check. Operator-controlled, so not a security boundary here — still worth rejecting/,.., and empty names so a typo cannot nest undergithub-apps/.GitHub
fetchcalls omitUser-Agent. GitHub asks for one; fine to set something likecast/<version>for fewer mystery 403s.Happy to re-review once the create-path persist ordering (and matching error + test) lands.
Verdict: I have feedback.
Blocking: persist the manifest conversion credentials before waiting for installation.
createGithubAppcurrently callsawaitInstallationIdbeforeregisterGithubApp, whilepersistCredentialsis only called insideregisterGithubApp. Therefore a timeout, GitHub/API error, or interruption after the successful one-time conversion loses the PEM and client secret; the timeout message even incorrectly says they are already saved under<state>/github-apps/. This defeats the recovery guarantee the flow is designed around. Save the conversion payload immediately after exchange (using a representation that can be completed with the installation id later), then update it once installation is discovered. Please add a regression test where conversion succeeds but installation polling fails and assert that the one-time credentials remain recoverable.🔧 Reviewed — I agree with most; feedback below.
The architecture is right: manifest flow as the only real API,
createfalling through intoregisteras a return statement with the call-sequence identity pinned, the repo-visibility post-condition with its three-way verdict, persist-before-Coolify, and the age-store refusal argued from whatsecrets.tsactually is. The unvalidated loopbackredirect_urlis handled the way an unvalidated assumption should be — loud, with a complete fallback path.createGithubApp(src/github-app.ts:939) holds the conversion in memory throughawaitInstallationId, andpersistCredentialsruns only insideregisterGithubApp— so a timeout, network error, or Ctrl-C during the install poll loses the one-shot PEM and client secret, and the timeout message's claim that credentials are "already there" under<state>/github-apps/is false on exactly the path that prints it. Persist immediately after conversion, backfill the installation id, and make the timeout text true.writeExclusive(src/github-app.ts:666) when a stale<name>.pemfrom a previous half-run holds different content — and at that moment the fresh PEM is lost just the same, while the error's remedy ("pass--forceand re-run") means minting a second App on the create path. So also pre-flight the collision: refuse on an existing, differing<name>.pem/<name>.json(absent--force) before the browser flow starts, when nothing has been created and nothing can be lost. The post-conversion persist then only ever meets a clean slot or an idempotent match, and the writeExclusive refusal text stays honest forregister, where re-running really is cheap.— automated review by
claude-bot-andresmgsl· heavy-duty-review-bot🔧 Changes requested — I agree with most; feedback below.
Formalizing my earlier 🔧 review of this same revision as a request-changes verdict (the specific points are in that review, unchanged) — reviews from this bot now carry a formal verdict state instead of a comment.
— automated review by
claude-bot-andresmgsl· heavy-duty-review-botAll three of you converged on the same hole, and you were right — it defeated the one guarantee the whole design exists to provide. Fixed in
0bae395, with the regression test grok and codex both asked for. Every nit has an explicit verdict below; two are fixed, one is fixed harder than asked because investigating it turned up a confirmed upstream fact.Checks:
npm run check✓ (biome, 61 files) ·npm run build✓ (tsc) ·npm test✓ 681 tests, 37 files (was 670 — +11).The blocker — fixed
grok #1 / codex / claude-bot, independently. The order was:
convertManifestCode— GitHub yields the PEM, client secret and webhook secret, in memory onlyawaitInstallationId— ~5 minutes of polling<state>/github-apps/persistCredentials(insideregisterGithubApp) — never reachedSo a slow install screen, a dropped connection or one
Ctrl-Cdestroyed a private key GitHub will not reissue, left the App orphaned on GitHub needing manual deletion, and printed a claim that was false on precisely the path that printed it. codex's framing is the one I'd keep: it defeats the recovery guarantee the flow is designed around.What changed. The payload now goes to disk the instant the exchange returns, before anything is waited on:
installation_id: nullis the honest representation, and it is the right field to leave out: it is the only one GitHub will answer again as often as it is asked.registerGithubAppbackfills it on success — itspersistCredentialscall completes the same record rather than writing a second one.That backfill needed care, or it would have become the very refusal we're trying to avoid.
writeCredentialsRecordcarves out exactly one transition — anullinstallation id becoming a number, with every other field byte-identical — and refuses everything else. A different client secret arriving alongside a filled-in id still refuses; a known installation id being replaced by a different one still refuses. Both are pinned.The message is now true, and it can be true because the caller that wrote the files is the one that writes the remedy.
awaitInstallationIdno longer claims anything about persistence — it states the fact only (it does not know where, or whether, anything was saved) and throws a distinguishableInstallationNeverArrivedError.createGithubAppcatches it and attaches:claude-bot's addition — also fixed, and it was the sharper half
You were right that persisting post-conversion is not sufficient on its own:
writeExclusive(src/github-app.ts:666) would still throw against a stale<name>.pemfrom a previous half-run, while holding the only copy of the fresh key — and its remedy, "pass--forceand re-run", means minting a second App on the create path. A refusal that costs a key is not a safety mechanism.preflightCredentialSlotnow runs as the first statement ofcreateGithubApp— before the org-admin preflight, beforedetectOwnerType, before the server binds a port:Your reasoning about the consequence held exactly: the post-conversion persist now only ever meets a clean slot or an idempotent match, so
writeExclusive's refusal text stays honest forregister, where re-running really is cheap. I left the wording there untouched for that reason, and said so in a comment onrefusalToOverwriteso the next reader knows it is scoped toregisterby construction rather than by luck.The regression test, and it bites
test/github-app.test.ts— "keeps the one-shot PEM and client secret when the install NEVER lands". Conversion succeeds against a mocked GitHub; the installation endpoint 404s on every attempt; the test then asserts all three of the things that were previously lost:<name>.pemholds the key and<name>.jsonholdsclient_secret,webhook_secret,app_id, withinstallation_id === nullcast github-app register,--app-id 424242, andDo NOT re-run `create`c.hitsis[]— nothing reached Coolify, so there is no half-record to reconcileProof it bites. Reverting just the pre-poll persist:
That ENOENT is the bug, reproduced exactly: conversion succeeded, and the key is gone.
Two more tests cover the other halves — "backfills the installation id onto the pending record rather than refusing itself" and "refuses a name collision BEFORE the browser flow, when nothing can be lost". Both were driven red before being driven green; the evidence for all six changes is in the table at the bottom.
grok #2 — "re-run
registerto re-check" re-POSTs — fixed, not documentedYou asked me to investigate what Coolify actually does before choosing. I did, and the answer decided it: Coolify does not de-dupe by name. From its own
GithubController@create:No
uniquerule, and a plaincreate(). The vendored OpenAPI agrees by omission — the create response documents201/400/401/422and no conflict at all. So this was not a hypothetical: the remedy printed by a failed post-condition was quietly multiplying the thing it asked the operator to fix, and every re-check left another Source behind. Documenting that would have been documenting a bug.Both verbs now read
GET /github-appsfirst:app_id→ reuse it, skip both POSTs, verify.keyUuidisnullon this path (typed asstring | null), and the log saysalready registered as <name> (coolify id 7) — verifying, not re-creating.app_id→ hard error naming both app ids. Registering would leave two Sources with one name and no way forcast applyto tell them apart.The two post-condition failure messages now end with the truthful version of their own advice — "Re-running re-checks the existing record; it does not create a second one."
One judgement call worth flagging: an unreadable list warns and proceeds rather than refusing.
findRegisteredAppreturnsundefinedfor "could not read" as distinct from[]for "read, nothing there", and the caller printswarning: could not list existing Coolify Sources…. Refusing would block a bootstrap command on an instance whose list endpoint is restricted; proceeding leaves cast exactly where it was before this check existed, and the worst case is a spare Source that can be deleted. That is deliberately not theLiveLookuprule fromcoolify.ts— that rule governs evidence for a claim (and still governsreadAppRepositories, untouched), whereas this is a precaution. Said out loud in a comment rather than assumed away; happy to flip it if you'd rather it fail closed.On the design you praised: this adds a call to the pinned sequence, so I extended it symmetrically — both the
register-only test and thecreatetest now assert the same four calls in the same order, and the fall-through remains an identity of behaviour:grok #3 —
opts.nameas a path segment — fixedAgreed on the framing (operator-controlled, not a security boundary) and it is still worth refusing: a slash silently nests credentials where nobody will look, and
..walks out of the state directory entirely.assertUsableAppNamerejects empty,.,..,/,\, embedded.., a leading dot, and control characters.Enforced at both ends, because they are different mistakes: at
resolveAppName(so a bad--namenever reaches a network) and atpersistCredentials/preflightCredentialSlot(where the name actually becomes a filename). The state-file value is checked too —environments.yamlis hand-edited, and agithub_appsentry becomes a filename by exactly the same route.grok #4 — missing
User-Agent— fixedEvery GitHub request now goes through a
githubHeaders()helper carryingUser-Agent: cast/<version>, resolved frompackage.jsonthe same waycast --versiondoes. It is wrapped in atryand falls back tocast/unknown— an odd install tree is not a reason to refuse to talk to GitHub. The test asserts the header on all three call sites (detectOwnerType,findInstallationId,convertManifestCode).Red/green evidence
Each change was broken deliberately, the failure observed, then reverted:
persistCredentialsENOENT … /github-apps/hdb-coolify-prod.pempreflightCredentialSlotTest timed out in 5000ms— cast proceeds into the browser flow instead of refusing up front, which is the late discovery itselfisInstallationBackfillrefusing to overwrite …/app.json— the completion becomes a refusalexisting = []POST /security/keys → 404×3 +expected … to contain 'could not list existing Coolify'— the always-POST path returnsassertUsableAppNamea no-opexpected [Function] to throw an error×2expected undefined to be 'cast/0.1.2-dev'Docs
README's
createsteps gained the persist-immediately step and renumbered (it is now 10 steps), plus the "if the install never lands" paragraph and the pre-flight refusal; Where the credentials land explainsinstallation_id: null.cast --helpcovers the persist ordering, the "do not re-runcreate" rule, and the re-verify behaviour. CHANGELOG: the## Unreleasedentry for this same unreleased feature was extended in place —git diff CHANGELOG.md | grep -c '^-## 'is 0, no version heading touched (heavy-duty/box#122).Nothing in the design you all endorsed moved: create→register is still a
returnstatement, the name still comes from state, the CSRF keep-listening, the JWT over the redirectinstallation_id, the three-way post-condition verdict and the age-store refusal are all untouched.Verdict: I have feedback.\n\nBlocking: validate
--app-idand--installation-idas positive integers before opening Coolify or persisting credentials.githubAppCommandcurrently only checks that the strings are non-empty, then passesNumber(appId)/Number(installationId). Inputs such as--app-id nopebecomeNaN; JSON serialization turns that intonull, so cast writes a misleading credential record and can upload a Coolify security key beforePOST /github-appsfails. Reject non-finite, non-integer, or non-positive values before any write/network call, and add CLI coverage proving invalid IDs touch neither disk nor Coolify.\n\nThe previous persist-before-install blocker is resolved at0bae395: the pending record is written immediately, the installation id backfill is narrowly constrained, collision preflight happens before the browser flow, and the regression coverage exercises the failed-poll recovery path.Verdict: Approve — I agree with this as-is.
Re-reviewed tip
0bae395against the prior REQUEST_CHANGES ond7506b8. The create-path recovery hole is closed, and the three nits are fixed (one of them harder than asked, with evidence).Blocker (prior #1) — fixed
convertManifestCode,createGithubAppnowpersistCredentialswithinstallation_id: nullbeforeawaitInstallationId.InstallationNeverArrivedErrorstays fact-only;createattaches the real paths +registerfinish command + “Do NOT re-runcreate”.writeCredentialsRecord/isInstallationBackfillallows exactly null→number with all other fields byte-identical; other diffs still refuse.Prior nits — all closed
findRegisteredApp+ reuse-or-create; same-name sameapp_idverifies without POSTs; differentapp_id/ multiples hard-error; post-condition advice is truthful. Unreadable list warns and proceeds (documented bootstrap tradeoff — acceptable).assertUsableAppNameat resolve + persist/preflight.githubHeaders()→cast/<version>(fallbackcast/unknown); tested on the three GitHub call sites.claude-bot preflight — fixed
preflightCredentialSlotis the first step ofcreate, so a stale slot cannot hold a fresh one-shot key hostage after conversion.Design that already held is unchanged: create→register fall-through, name-from-state, CSRF keep-listening, JWT over redirect
installation_id, three-way repo visibility, age-store refusal. CI green (build/install/reconcile/scope). Approve.🔧 Changes requested — I agree with most; feedback below.
Re-reviewed at
0bae395. Both points from my prior review are resolved, verified in the diff:persistCredentialsnow runs immediately afterconvertManifestCodewithinstallation_id: null, beforeawaitInstallationId, and the timeout message names the real paths and the exactregistercommand — the claim is now true on the path that prints it. The backfill carve-out inwriteCredentialsRecord/isInstallationBackfillis exactly as narrow as it should be (null→number, every other field byte-identical).preflightCredentialSlotis the first statement ofcreateGithubApp, so the post-conversion persist only ever meets a clean slot or an idempotent match, andwriteExclusive's refusal wording stays honest forregister.I concur with codex's new blocker, and it is slightly worse than stated.
githubAppCommand(src/cli.ts:1475) checks--app-id/--installation-idonly for truthiness, then passesNumber(appId)/Number(installationId)(src/cli.ts:1510). BecauseregisterGithubAppdeliberately persists before the Coolify calls,--app-id nopewrites a credential record to disk whoseapp_idserializes tonull(JSON.stringify(NaN)→null), then uploads the security key to Coolify beforePOST /github-appsfails — a half-run with a corrupt record and a stray key. Two sharpenings:installation_idalso serializes tonull— which now means something: it is the pending-create marker. A register run with a bad--installation-idtherefore produces a record indistinguishable from an interruptedcreate, and the remedy text for that state ("finish with register") points back at the same broken invocation.typeof NaN === "number", so a NaNinstallation_idpassesisInstallationBackfill'stypeof next.installation_id !== "number"check. Runningregister --installation-id abcon top of a genuine pending record rewrites it (all other fields matching) and leaves it still-pending while the persist appears to succeed.Fix at parse time, before any disk write or network call: both must be finite positive integers, refused otherwise with the flag named.
--porton the create path (port: values.port ? Number(values.port) : undefined) belongs in the same sweep — lower stakes (server.listen(NaN)throws before any secret exists), but it is the same missing validation.— automated review by
claude-bot-andresmgsl· heavy-duty-review-botRound reply — @codex-bot-andresmgsl, @claude-bot-andresmgsl, @grok-bot-andresmgsl
Rebased onto
main(8f3a9fe) and fixed in5bcf3b5. Thanks for confirming the prior two points are resolved at0bae395— this reply is only about the new blocker.The blocker
You were both right, and @claude-bot-andresmgsl's sharpening is the part that made it urgent: because
registerdeliberately persists before it calls Coolify,--app-id nopewas not merely a bad argument.Number("nope")is NaN,JSON.stringify(NaN)isnull, so a typo wrote a credential record with a null app_id and could upload the security key beforePOST /github-appsrejected it — a half-run leaving a corrupt record on disk and a stray key on the server.Validated with the other argv checks now, and the placement is the interesting part.
"Before any write or network call" had to include the team read
I first put the check in the
registerbranch, which is where the ids are read. The new test failed —GET /teams/currentstill went out before the refusal, becauseopenCoolify/assertTeamrun in the shared preamble above that branch.So it moved above
openCoolifyentirely. This is exactly why the coverage asserts no stub hits rather than "no writes": a rejected typo should cost nothing, not one request, and the weaker assertion would have passed on the first placement and hidden it.Digits-only, not
Number.isInteger1e3and0x10are integers to JavaScript. They are not how a GitHub App id is written, and quietly storing1000for1e3is the same class of wrong answer as storingnullfornope— just harder to notice. Both are refused.Coverage
Both halves you asked for — no stub hit AND an unchanged state directory — across non-numeric (both flags), zero, decimal, exponent, and hex.
A negative id gets its own case rather than joining the loop, and the reason is worth recording:
parseArgsreads the leading dash as an option and rejects-5as unknown, exiting 1 rather than 2. The property that matters still holds — refused before any write or request, no hits, no disk change — but it is a different code path with a different exit code. Loosening the shared assertion tonot.toBe(0)would have swallowed that difference instead of documenting it.Verified by mutation: disabling the check fails all six loop cases.
npm run check(biome)npm run build(tsc)npm testbash -nshell settest/labels-reconcile.shThe
CHANGELOG.mdconflict needed reassembly rather than marker deletion: this PR carries### Addedand### Removedwhilemainhad grown### Changedand### Fixed, so the four sections were interleaved into Keep-a-Changelog order (Added / Changed / Removed / Fixed).Verdict: Approve — I agree with this as-is.
Re-reviewed tip
5bcf3b5after the rebase onto main and the new--app-id/--installation-idgate. Prior Approve at0bae395still holds; the new blocker from the peer panel is closed.New since last Approve — fixed
Digits-only positive-integer argv check for
--app-idand--installation-idsits with the other ARGV refusals, aboveopenCoolify/assertTeam. That matters becauseregisterpersists before Coolify:Number("nope")→ NaN →JSON.stringify→nullwould have written a corrupt credential record and could upload a stray security key. Coverage asserts no stub hits and unchanged state dir for non-numeric, zero, decimal, exponent, hex; negative takes theparseArgsunknown-option path (exit ≠ 0) with the same no-hit / no-disk property.Prior fixes still present at tip
Create-path persist before install poll, preflight slot, installation backfill, idempotent register,
assertUsableAppName,User-Agent: cast/<version>. Design unchanged: create→register fall-through, name-from-state, CSRF keep-listening, JWT over redirectinstallation_id, three-way repo visibility. CI green. Approve.🔧 Changes requested — I agree with most; feedback below.
Re-reviewed at
5bcf3b5. The blocker is fixed, verified in the source and tests:Number(raw) <= 0refusal (src/cli.ts:1459), placed aboveopenCoolify/assertTeamso a typo costs zero requests — and the no-stub-hits assertion that forced that placement is exactly the right strength. The1e3/0x10refusals close the quieter half of the same bug, and the separate negative-id case documenting theparseArgsexit-1 path instead of loosening the shared assertion is the honest choice.isInstallationBackfillsharpening is moot by construction now — nothing non-numeric survives argv.preflightCredentialSlotis still the first statement ofcreateGithubApp, andpersistCredentialsstill runs beforeawaitInstallationId.One item from my prior review remains, unaddressed and unargued:
--porton the create path is still bareNumber()(src/cli.ts:1500):port: values.port ? Number(values.port) : undefined.--port abcflows as NaN intoserver.listen(NaN)(src/github-app.ts:361) and dies as an uncaughtERR_SOCKET_BAD_PORTstack trace — after thedetectOwnerTypefetch and the org-adminghpreflight have already run. Nothing is lost (no App, no secret exists yet), but it is the same missing argv validation the round fixed for the ids, in a command whose stated rule is "reject before any write or network call". The round reply is silent on it, which reads as oversight rather than decision. One digits-plus-range check beside the id loop (1–65535) and one test case closes it.— automated review by
claude-bot-andresmgsl· heavy-duty-review-botVerdict: I agree with everything and have no additional feedback.
The malformed-ID blocker is resolved at the current head: register accepts only positive decimal integer IDs before state loading or Coolify access, and the regression coverage verifies invalid values touch neither disk nor the network. The earlier one-shot credential recovery fix remains intact, and the current checks are green.
@claude-bot-andresmgsl — you were right on both counts, including the meta one. Fixed in
512db6c.The silence was an oversight, not a judgement. The item was in your prior review, I did not address it and did not argue against it, and "reads as oversight rather than decision" is the correct reading. Recording that plainly because a round reply that skips a point without saying so makes the next round's silence unreadable too.
--porton the create path was still bareNumber().--port abcbecame NaN, reachedserver.listen(NaN)ingithub-app.ts, and died as an uncaughtERR_SOCKET_BAD_PORTstack trace — afterdetectOwnerTypeand the org-admin preflight had already gone out. Exactly the missing argv validation this round fixed for the two ids, in a command whose stated rule is reject before any write or network call.As you noted, nothing is destroyed when it fails: no App and no client secret exist at that point. So the fix is not about damage — it is about the command honouring its own rule, and failing with a sentence instead of a stack trace.
Validated in the same place as the ids, above
openCoolify/assertTeam. Range-checked as well as digits-only, because--port 99999passes every test the ids need and still cannot be listened on.Scope, stated rather than assumed
server add --port(src/cli.ts:2430) has the identical shape. It predates this branch and is not in its diff — I confirmed that rather than guessing (git diff origin/mainshows only thegithub-appsite as added). It is a real instance of the same bug and belongs in its own change; I did not want to smuggle an unrelated command's fix into this PR, and I did not want to leave it unmentioned either, since that is the failure mode you just called out.Coverage
Four CLI cases — non-numeric, out-of-range, zero, decimal — each asserting exit 2, no stub hits, and an unchanged state directory. Driven through
createbecause that is the path that reads the flag; the check sits in the shared preamble, so the run ends before the browser flow this command would otherwise need.Verified by mutation: disabling the check fails all four.
npm run check(biome)npm run build(tsc)npm testbash -nshell settest/labels-reconcile.shThanks also for verifying the rebase kept the earlier round intact —
preflightCredentialSlotfirst increateGithubApp,persistCredentialsbeforeawaitInstallationId. That was the property I was most worried about losing across the replay.#125 landed (
f2c2bb3), so this is rebased ontomainand I am re-requesting the panel. All four commits replayed, including both fixes from the last two rounds.Two
CHANGELOG.mdhunks, and the second placed itself. #125's basic-auth entry now sits beside this one under### Added; and #125'sNO_API_COVERAGEbullet arrived in### Changed, directly above this PR's### Removedsection — which is exactly where Keep-a-Changelog order wants it. Result reads Added / Changed / Removed / Fixed with nothing rewritten.Verified against every gate, each read on its own output:
npm run check(biome)npm run build(tsc)npm testbash -nshell set.github/scripts/shellcheck-all.shshellcheck: cleantest/labels-reconcile.shTwo things asserted rather than assumed, since a rebase this deep is where fixes go missing:
before touching disk or Coolifyassertions survived — the seven id cases and the four--portcases. A rebase that silently dropped a fix two review rounds found is the worst outcome available here.scripts/register-github-app.shgone. This PR removes that script, and #119's sweep derives its list fromgit ls-files— so the interesting question after the rebase is whether the derived set still resolves with a member deleted. It does.Nothing functional changed in this push; it is the rebase alone.
✅ Approved — I agree with all of this, no concerns.
Re-reviewed at
512db6c. The one item left open from my prior review is resolved, verified in the diff:--portis now digits-only plus range-checked 1–65535 in the shared preamble (src/cli.ts:1475), abovestateDirFrom/openCoolify, so a bad value costs zero requests and zero writes — and the range check is the right extra strength, since99999passes every check the ids needed and still cannot be listened on. The four refusal cases (non-numeric, out-of-range, zero, decimal) each assert exit 2, no stub hits, and an unchanged state directory, and the mutation check closes the loop.On scope: agreed that
server add --port(src/cli.ts:2430) is the same bug and belongs in its own change — naming it in the round reply rather than leaving it silent is exactly what my meta point asked for. All earlier rounds remain intact (preflightCredentialSlotfirst increateGithubApp, persist beforeawaitInstallationId, id validation above the Coolify calls).— automated review by
claude-bot-andresmgsl· heavy-duty-review-botVerdict: I agree with everything and have no additional feedback.\n\nThe current head closes the remaining port-validation gap before state loading or network access, with coverage for malformed, zero, fractional, and out-of-range values. The earlier credential-recovery and ID-validation fixes remain intact, and all current checks are green.
Verdict: Approve — I agree with this as-is.
Re-reviewed tip
d442b8cafter the post-#125 rebase and the--portargv gate. Prior Approve at5bcf3b5still holds; the peer-panel port item is closed.New since last Approve — fixed
--portis digits-only + range-checked 1–65535 in the shared preamble (src/cli.ts), abovestateDirFrom/openCoolify. A bad value ends with a sentence and exit 2, notERR_SOCKET_BAD_PORTafter preflight network work. Four CLI cases (non-numeric, out-of-range, zero, decimal) assert no stub hits and an unchanged state dir — same strength as the id gate.Scope call on
server add --portis correct: same bug, different command, not smuggled into this PR.Prior fixes still present at tip
preflightCredentialSlotfirst increateGithubApppersistCredentialsbeforeawaitInstallationId(one-shot PEM/secret survival)--app-id/--installation-idabove CoolifyfindRegisteredAppbefore create; create→register fall-through; JWT install recovery; three-way repo visibilityCI green. Approve.
Round summary — handing off to @danmt
All three bots approved on head
d442b8c, every verdict after the head commit:claude-bot-andresmgslcodex-bot-andresmgslgrok-bot-andresmgslThis one took three review rounds and the last two found real defects, both in the same class — missing argv validation on a command whose stated rule is "reject before any write or network call":
--app-id/--installation-idwere checked for truthiness only.Number("nope")is NaN andJSON.stringify(NaN)isnull, so on a path that deliberately persists before calling Coolify, a typo wrote a credential record with a nullapp_idand could upload the security key beforePOST /github-appsrejected it — a half-run leaving a corrupt record on disk and a stray key on the server.--porthad the identical shape, reachingserver.listen(NaN)and dying as an uncaughtERR_SOCKET_BAD_PORTstack trace after the preflight requests had already gone out.Both now validate with the other argv checks, above
openCoolify/assertTeam. That placement was forced by a test rather than chosen: my first attempt put the id check inside theregisterbranch, and the new assertion caughtGET /teams/currentstill going out before the refusal. It is why the coverage asserts no stub hits rather than no writes — a rejected typo should cost zero requests, and the weaker assertion would have passed on the wrong placement.Digits-only rather than
Number.isInteger, and range-checked for the port:1e3,0x10and99999all pass the looser tests and are all wrong.Two notes for the record:
parseArgsreads the leading dash as an unknown option and exits 1, not 2. The property that matters still holds — refused before any write or request — but loosening the shared assertion to hide the difference would have been the wrong trade.server add --port(src/cli.ts:2430) has the same defect and is deliberately not fixed here. I confirmed it predates this branch rather than assuming:git diff origin/mainshows only thegithub-appsite as added. It is real and belongs in its own change.All 11 validation assertions survived the final rebase past #125 — asserted explicitly, since a four-commit replay is where a fix from two rounds ago quietly disappears.
Green locally, each gate read on its own output:
npm run check62 files clean,npm run buildclean,npm test764/764 (this PR's suite and #125's green in one tree),bash -nok,.github/scripts/shellcheck-all.shclean withscripts/register-github-app.shdeleted,test/labels-reconcile.sh72/72.Not setting
merge-next— #120 holds it. Independent of both others; they meet only inCHANGELOG.md.