Repository navigation
feat: replace gh with the REST API, and hold tracker credentials in the cloud - #47
Merged
Merged
Conversation
…he cloud
`gh` is no longer installable on the machine the runner runs on, which took
GitHub polling, labelling and commenting with it. Nothing about that was
load-bearing: `gh` lived in exactly one file, and the six calls Sentinel0
actually made are plain REST.
`GitHubRestClient` makes them over `fetch`. It lives in `packages/common`
rather than the orchestrator because the cloud API needs three of the same
calls to answer the dashboard's pickers, and two clients would be two places
that know GitHub's field names. The token arrives through a **provider
function**, never a constructor string: a PAT is a constant and would not need
one, but a GitHub App installation token expires hourly, and the provider is
the shape that serves both -- moving to an App changes how a token is obtained
and nothing else in the file.
Two REST behaviours are not `gh` behaviours, and both fail silently:
`GET /repos/{o}/{r}/issues` returns pull requests as well as issues, so
unfiltered every open PR would also raise a `ticket` trigger; and `gh`'s
`reviewRequests` merged users and teams where REST splits them into
`requested_reviewers` and `requested_teams`, so reading only the first makes a
route targeting a review-owning team silently never match. Both are pinned by
tests.
`getPullRequestDiff` is deleted rather than ported. It has had no callers since
the Hermes pivot -- fetching a diff is the agent's job under that boundary --
so porting it would have re-crossed the boundary in the wrong direction. What
remains is five calls: list issues, list pulls, ensure/add/remove labels, and
post a comment. Labels are the half that cannot be given up, because
`sentinel0:in-progress` is the loop guard and a runner that cannot write labels
re-fires every route on every cycle. The comment exists for `postFailure`, the
one case an agent structurally cannot report on its own behalf.
Credentials move from each machine to the organization, encrypted at rest with
AES-256-GCM under `SENTINEL0_SECRET_KEY`. `crypto.ts` throws rather than
deriving a key from nothing: a deployment that quietly encrypted every token
under a guessable constant would look exactly like a working one.
The cloud is a **credential broker, not a data proxy**. `GET
/v1/runner/integrations` hands the runner its tokens and the runner still calls
`api.github.com` itself, so trigger collection survives a cloud outage the same
way cached routes do -- proxying the data too would have made polling stop
whenever Railway did. That endpoint is the only one anywhere returning a
plaintext secret, and is runner-scoped for exactly that reason: the `snt_usr_`
key a browser holds is rejected before the handler runs.
`IntegrationStore` keeps tokens in memory only. Routes and projects cache to
`~/.sentinel0/` so an outage cannot stop dispatch and the same argument applies
here, but a decrypted PAT in a file is a worse trade than a runner that needs
its control plane once at boot. `GITHUB_TOKEN` and `LINEAR_API_KEY` remain as
an explicit fallback for the two cases the cloud cannot serve -- no control
plane at all, and a restart mid-outage -- and the cloud outranks them whenever
it has an answer, or a rotated credential would lose to a stale local one. A
failed refresh keeps what was already loaded, for the same reason.
Precedence is project override, then organization default, resolved by
`resolveIntegration` in `common` so the cloud and the dashboard cannot disagree
about it. `PUT /v1/integrations/:provider` verifies a token against its
provider before storing it and returns the account it authenticated as: a
credential saved unverified fails hours later as a 401 in a log nobody reads,
while whoever pasted it has already left the screen believing it worked.
Storing the token in the cloud is also what lets the dashboard stop asking
people to type things it could look up. *Add project* offered a free-form JSON
textarea hinted "use {}", which asked an operator to know the filter schema,
spell a repository slug correctly, and guess which labels a repository has --
three mistakes discovered hours later as an empty poll. It now offers the
repositories the credential can reach and the chosen repository's real labels,
read through the API because a browser must never hold a token that grants
writing to someone's repositories. The JSON escape hatch stays, behind a
disclosure.
The organization pages gain the design's second-level rail (`showOrgRail`).
That group had outgrown a flat list: with Integrations added there are five
destinations, each a settings surface rather than a view of work, and promoting
all five into the primary sidebar would have buried Runs and Routes among them.
Settings splits three ways -- Slack moves to Integrations beside the
credentials it belongs with, and the runner table to a page of its own, having
been the only table on a page of forms.
`end` on a rail entry is derived rather than hand-set. On all of them the rail
goes blank on a child page like `/projects/new`; on none of them Settings stays
lit across every page nested under it. Only entries another entry sits under
need it, which is a fact about the list rather than a flag to remember.
Also: `errorMessage` moves out of `index.ts` now that a second module needs it,
and `HostExecutor` is left in place but is no longer constructed anywhere --
the runner genuinely executes nothing now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RbctixjBYjPL823X6CEMd3
This was referenced Sep 7, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
ghis no longer installable on the machine the runner runs on, which took GitHub polling, labelling and commenting with it. Nothing about that was load-bearing:ghlived in exactly one file, and the six calls Sentinel0 actually made are plain REST.The
ghremovalGitHubRestClient(packages/common/src/github.ts) makes them overfetch. It sits incommonrather than the orchestrator because cloud-api needs three of the same calls to answer the dashboard's pickers, and two clients would be two places that know GitHub's field names.The token arrives through a provider function, never a constructor string. A PAT is a constant and would not need one; a GitHub App installation token expires hourly, and the provider is the shape that serves both — moving to an App changes how a token is obtained and nothing else in the file.
Two REST behaviours that are not
ghbehaviours, both of which fail silently, both pinned by tests:ghGET /issuestickettriggerreviewRequestsarrayrequested_reviewers+requested_teams— reading one makes a team-targeted route never matchgetPullRequestDiffis deleted, not ported. It has had no callers since the Hermes pivot — fetching a diff is the agent's job under that boundary — so porting it would have re-crossed the boundary in the wrong direction.What remains is five calls: list issues, list pulls, ensure/add/remove labels, post a comment. Labels are the half that cannot be given up:
sentinel0:in-progressis the loop guard, and a runner that cannot write labels re-fires every route on every cycle. The comment exists forpostFailure, the one case an agent structurally cannot report on its own behalf.A fine-grained PAT with Issues: read and write and Pull requests: read covers all of it. No
Contents, nothing that could push code.Credentials
Cloud as broker, not proxy.
GET /v1/runner/integrationshands the runner its tokens; the runner still callsapi.github.comitself, so trigger collection survives a cloud outage the same way cached routes do. Proxying the data too would have made polling stop whenever Railway did.SENTINEL0_SECRET_KEY.crypto.tsthrows rather than deriving a key from nothing — a deployment that quietly encrypted every token under a guessable constant would look exactly like a working one.snt_usr_key is rejected before the handler runs.IntegrationStorekeeps tokens in memory only. A decrypted PAT in~/.sentinel0/is a worse trade than a runner that needs its control plane once at boot.GITHUB_TOKEN/LINEAR_API_KEYstay as an explicit fallback the cloud always outranks.resolveIntegrationincommonso the cloud and the dashboard cannot disagree.PUT /v1/integrations/:providerverifies the token before storing it and returns the account it authenticated as. Saved unverified, a bad credential fails hours later as a 401 in a log nobody reads.The dashboard
Storing the token in the cloud is what lets Add project stop asking people to type things it could look up. The free-form JSON textarea hinted
use {}asked an operator to know the filter schema, spell a slug correctly, and guess a repository's labels — three mistakes discovered hours later as an empty poll. It now offers the repositories the credential can reach and that repository's real labels. The JSON escape hatch stays, behind a disclosure.Organization pages gain the design's second-level rail (
showOrgRailinsentinel0 App Noir.dc.html). That group had outgrown a flat list: five destinations, each a settings surface rather than a view of work, would have buried Runs and Routes in the primary sidebar. Settings splits three ways — Slack moves to Integrations beside the credentials it belongs with, and the runner table to its own page, having been the only table on a page of forms.Driving it in a browser found one bug worth naming:
endon every railNavLinkleft the rail blank on/projects/new;endon none of them leaves Settings lit across everything nested under it. It is now derived from the list — only entries another entry sits under need it — with a test for each half.Deploying this
Two steps before it works, both in
docs/deploy-cloud.md:SENTINEL0_SECRET_KEYon the Railway API service — 32 random bytes, base64 or hex:007_integrations.sql.Verification
pnpm build,pnpm lintandpnpm testall pass — 526 tests, 43 of them new. Both new screens were driven in a browser against fixture data.Known gaps
ghis untouched.route-catalog.tsstill tells agents to rungh pr view/gh pr diff/gh pr reviewinside their own Hermes profiles, under their own identities. Sentinel0's token cannot help them. If the machine's constraint applies to Hermes too, those routes still degrade — that is a separate change.HostExecutoris now dead. Nothing in the monorepo constructs it. Left in place rather than removing a subpath export from a package the CLI bundles; worth deciding separately.test/fixtures/integrations.jsonis hand-written, with no Postgres available to record from. Marked in the fixtures README as the second documented exception alongsideruns_run_2.json.🤖 Generated with Claude Code
https://claude.ai/code/session_01RbctixjBYjPL823X6CEMd3