Skip to content

feat: replace gh with the REST API, and hold tracker credentials in the cloud - #47

Merged
maxigimenez merged 1 commit into
mainfrom
feat/tracker-credentials
Sep 7, 2026
Merged

maxigimenez merged 1 commit into
mainfrom
feat/tracker-credentials

Conversation

@maxigimenez

Copy link
Copy Markdown
Owner

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.

The gh removal

GitHubRestClient (packages/common/src/github.ts) makes them over fetch. It sits in common rather 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 gh behaviours, both of which fail silently, both pinned by tests:

gh REST
GET /issues issues only also returns pull requests — unfiltered, every open PR raises a ticket trigger
review requests one reviewRequests array split into requested_reviewers + requested_teams — reading one makes a team-targeted route never match

getPullRequestDiff is 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-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.

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/integrations hands the runner its tokens; 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.

  • Encrypted at rest, 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.
  • Runner-scoped endpoint, because it is the only one anywhere returning a plaintext secret. A snt_usr_ key is rejected before the handler runs.
  • IntegrationStore keeps 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_KEY stay as an explicit fallback the cloud always outranks.
  • Precedence is project override, then organization default, resolved by resolveIntegration in common so the cloud and the dashboard cannot disagree.
  • PUT /v1/integrations/:provider verifies 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 (showOrgRail in sentinel0 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: end on every rail NavLink left the rail blank on /projects/new; end on 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:

  1. Set SENTINEL0_SECRET_KEY on the Railway API service — 32 random bytes, base64 or hex:
    node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
    
    Losing or rotating it makes stored credentials unreadable; they are re-added, not recovered.
  2. Apply migration 007_integrations.sql.

Verification

pnpm build, pnpm lint and pnpm test all pass — 526 tests, 43 of them new. Both new screens were driven in a browser against fixture data.

Known gaps

  • The agents' gh is untouched. route-catalog.ts still tells agents to run gh pr view / gh pr diff / gh pr review inside 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.
  • HostExecutor is 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.json is hand-written, with no Postgres available to record from. Marked in the fixtures README as the second documented exception alongside runs_run_2.json.

🤖 Generated with Claude Code

https://claude.ai/code/session_01RbctixjBYjPL823X6CEMd3

…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
@maxigimenez
maxigimenez merged commit 38123fb into main Sep 7, 2026
2 checks passed
@maxigimenez
maxigimenez deleted the feat/tracker-credentials branch September 7, 2026 07:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant