Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 67 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,9 @@ needs **no local clone** of any repository, and `ProjectConfig` has no `workspac
- **`packages/cloud-api`** — the Railway-deployed control plane. Fastify + Postgres.
Stores config, the agent registry, and run history; sends Slack notifications.
- **`packages/cloud-dashboard`** — the React app over the cloud user API. Vite +
React 19, built on `@16-bits-design/ui`. Named as a sibling of `cloud-api` because
React 19, built on `@16-bits-design/ui`. Organization pages render behind a
**second-level rail** (`components/OrgRail.tsx`, the design's `showOrgRail`),
mounted by `AppShell` only on those paths. Named as a sibling of `cloud-api` because
both are hosted; the runner also serves an API, so an unqualified `api` would be
ambiguous. Deployed to Railway as its own service; see `docs/dashboard.md`.

Expand Down Expand Up @@ -100,7 +102,7 @@ harmless where a loss is not.

| File | Purpose |
|---|---|
| `config.json` | Cloud credentials, Hermes profiles and keys, secrets (v2 schema) |
| `config.json` | Cloud credentials, Hermes profiles and keys, fallback tracker secrets (v2 schema) |
| `routes.json` | Last known good routes; the offline fallback, and the whole route table when no cloud is configured |
| `running.json` | Pid and port of the running runner |
| `sentinel0.db` | SQLite — runs, run events, dispatch ledger, observations, mirror outbox |
Expand Down Expand Up @@ -150,7 +152,7 @@ Two invariants the dispatcher enforces:
agent was reached releases the claim so a fix can run.

**Two GitHub identities, and only one of them is Sentinel0's.** The *runner's* own
`gh` does all the polling, labelling and commenting; each *agent* authenticates as
**token** does all the polling, labelling and commenting; each *agent* authenticates as
itself inside its Hermes profile, and Sentinel0 passes it no token — `createRun` sends
prompt, instructions, session and model, nothing else. `HermesProfileConfig.githubLogin`
is therefore the operator's *declaration* of which account a profile uses, verified by
Expand All @@ -168,6 +170,55 @@ nothing will retry it and the person who pressed the button is owed the reason.
records `routeId: 'manual'` and `triggerType: 'manual'`; inventing a plausible route id
would be worse than saying there was not one.

### Tracker credentials (`packages/common/src/github.ts`, `orchestrator/src/integrations/`)

Sentinel0 **does not shell out to `gh`**. It did, and the machine it runs on stopped
being able to install it; the six calls it actually made are plain REST, so
`GitHubRestClient` makes them over `fetch`. It lives in `common` rather than the
orchestrator because the cloud API needs the same three calls to answer the
dashboard's repository and label 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 roughly
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, and both would fail silently:

- `GET /repos/{o}/{r}/issues` **includes pull requests**; `gh issue list` did not.
Unfiltered, every open PR also raises a `ticket` trigger.
- `gh`'s `reviewRequests` merged users and teams; REST splits them into
`requested_reviewers` and `requested_teams`. Reading only the first makes a route
targeting a review-owning team silently never match.

`getPullRequestDiff` is **gone**, not ported. It had no callers after the Hermes
pivot: fetching a diff is the agent's job under the boundary, and re-adding it here
would cross that boundary in the wrong direction. What Sentinel0 still needs from
GitHub is five calls — list issues, list pulls, ensure/add/remove labels, and post a
comment. Labels are the non-negotiable half: `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 are cloud-owned, and the runner still calls GitHub itself.** The cloud
is a *credential broker*, not a data proxy: `GET /v1/runner/integrations` hands the
runner its tokens, and the runner polls `api.github.com` directly. Proxying the data
too would have made trigger collection stop whenever Railway did, which is precisely
what `routes.json`'s offline cache exists to prevent.

`IntegrationStore` holds them **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. An operator who wants to survive a restart mid-outage sets
`GITHUB_TOKEN` (or `LINEAR_API_KEY`), which is the fallback, not an override: the
cloud wins 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.

### Route catalog (`packages/common/src/route-catalog.ts`)

The supported cases are declared once, as complete routes, and served from
Expand Down Expand Up @@ -210,6 +261,19 @@ curl, sending no preflight, works perfectly.

Migrations are plain `.sql` files applied in filename order, one transaction each.

`SENTINEL0_SECRET_KEY` is **required** once any integration is stored: 32 random
bytes, base64 or hex, encrypting tracker credentials with AES-256-GCM. `crypto.ts`
throws rather than deriving a key from nothing, because a deployment that quietly
encrypted every token under a guessable constant would look exactly like a working
one. `GET /v1/runner/integrations` is the only endpoint that returns a plaintext
secret, and is runner-scoped for precisely that reason — the `snt_usr_` key a browser
holds is rejected before the handler runs.

`/v1/integrations/slack` stays a static route and so still wins over the parametric
`/v1/integrations/:provider`. Slack is deliberately not in the `integrations` table:
it authenticates nothing and is never handed to a runner, so folding it in would have
meant a column that is null for one provider and required for the others.

`DASHBOARD_URL` is optional and only used to deep-link a run from Slack. Without it the
needs-approval message still names the `sentinel0 approve` command — a notification that
says a decision is required and no way to make one is the failure this replaced.
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ Full walkthrough: **[docs/getting-started.md](./docs/getting-started.md)**
npm install -g sentinel0

sentinel0 init # cloud key, Hermes profiles — each key is probed as you enter it
sentinel0 preflight # Node, Hermes, cloud, gh auth
sentinel0 preflight # Node, Hermes, cloud, tracker credentials
sentinel0 start
sentinel0 runner install # survive reboots (launchd)
```
Expand Down Expand Up @@ -115,7 +115,7 @@ sentinel0 run --agent product --prompt "Reply with the word ready."
- **Node.js >= 22.5** — the CLI re-executes itself under a compatible interpreter if
the active one cannot load `node:sqlite`
- **Postgres**, for the control plane
- `gh`, authenticated, if any project pulls from GitHub
- A GitHub token, added under Settings → Integrations, if any project pulls from GitHub

## Repository layout

Expand Down
88 changes: 88 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,86 @@ predating the heartbeat still reads as alive on the strength of its long poll al

---

## Integrations

The tracker credentials the organization authenticates with. One per provider, plus
an optional override per project.

```http
GET /v1/integrations
PUT /v1/integrations/:provider { "token": "github_pat_…", "projectId": null }
DELETE /v1/integrations/:provider?projectId=acme%2Fplatform
```

`:provider` is `github` or `linear`. `projectId` null — or absent — is the
organization default; naming a project stores an override for that project alone.
**A project override wins over the organization default**, so one token can cover
everything while a project that needs a different account carries its own.

`PUT` **verifies the token against the provider before storing it**, and returns the
account it authenticated as:

```json
{ "ok": true, "accountLogin": "sentinel0-bot", "scopes": [] }
```

A credential saved unverified fails later, on a poll cycle, as a 401 in a log nobody
is reading — while whoever pasted it has already left the screen believing it worked.
A rejected token comes back as a `400` naming the provider's own complaint.

`GET` never returns a token. It reports a prefix, the account, when it was added and
whether its last use failed:

```json
{
"integrations": [
{
"provider": "github",
"projectId": null,
"tokenPrefix": "github_pat_1",
"accountLogin": "sentinel0-bot",
"scopes": [],
"createdAt": "2026-09-01T10:12:04.221Z",
"updatedAt": "2026-09-01T10:12:04.221Z",
"lastVerifiedAt": "2026-09-01T10:12:04.221Z",
"lastError": null
}
]
}
```

Tokens are encrypted at rest with AES-256-GCM under `SENTINEL0_SECRET_KEY`. That
variable is **required** before any credential can be stored; see
[deploy-cloud.md](./deploy-cloud.md).

### What GitHub scopes are needed

A fine-grained PAT with **Issues: read and write** and **Pull requests: read**.
Nothing more. Sentinel0 never pushes code, opens pull requests or leaves reviews —
the agents do all of that under their own accounts inside Hermes. The *write* half of
Issues is not optional: it is what sets the `sentinel0:in-progress` marker, and
without the marker every route re-fires on every cycle.

`scopes` is empty for a fine-grained token, because GitHub sends no scope header for
one. Empty means "not reported", never "no permissions".

### Reading GitHub on the dashboard's behalf

```http
GET /v1/integrations/github/repositories
GET /v1/integrations/github/labels?repo=acme/platform
```

Both call GitHub with the stored credential and return only names. They exist because
a browser must never hold a token that grants writing to someone's repositories, so
the API asks on its behalf — which is what lets *Add project* offer a repository
picker and the repository's real labels instead of a free-form JSON field.

Both answer `409` when no credential is stored, which the dashboard renders as the
prerequisite it is rather than as an error.

---

## Slack

```http
Expand Down Expand Up @@ -579,6 +659,7 @@ POST /v1/runner/hello register; resets started_at
POST /v1/runner/heartbeat periodic health, once per poll cycle
PUT /v1/runner/inventory publish discovered agents
GET /v1/runner/projects pull ticket sources to watch
GET /v1/runner/integrations pull tracker credentials, decrypted
GET /v1/runner/routes pull enabled routes
GET /v1/runner/commands?cursor=&wait=&runner= long poll, up to 30s
POST /v1/runner/commands/ack { cursor, runner }
Expand All @@ -587,6 +668,13 @@ PATCH /v1/runner/runs/:id mirror a status change
POST /v1/runner/runs/:id/events mirror log events
```

`GET /v1/runner/integrations` is the only endpoint anywhere that returns a plaintext
secret, and it is runner-scoped for exactly that reason: a `snt_usr_` key — the kind a
browser holds — is rejected before the handler runs. The runner keeps what it receives
in memory only and calls `api.github.com` itself, so the cloud brokers the credential
rather than proxying the data. Trigger collection therefore survives a cloud outage,
the same way cached routes do.

`GET /v1/runner/commands` is held open until something arrives or the window closes.
An empty array is the normal, healthy result — not an error. This is how a runner
behind NAT receives work without any inbound connection.
Expand Down
22 changes: 22 additions & 0 deletions docs/dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,28 @@ be saved into a state the runner would reject.
- **No renaming an organization.** Its name is set by `org-cli.js` at creation and
there is no endpoint to change it.

## The organization rail

Organization pages — Settings, Integrations, Projects, Access keys, Runners — render
behind a second-level nav between the sidebar and the page. It is the design's
`showOrgRail`, and it exists because 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 among them in the primary sidebar.

`components/OrgRail.tsx` owns both the entries and `isOrgPath`, which `AppShell` uses
to mount it. Each entry carries a hint under its label, because these pages are
visited rarely enough that "Settings" and "Projects" do not by themselves say which
one holds the thing you came for.

Adding a page to the group means adding one entry to `ORG_RAIL` and one `<Route>`;
the rail's visibility follows from the entry rather than from a second list that
could disagree with it.

The old Settings screen carried the organization, Slack, **and** the runner table.
Slack moved to Integrations, beside the tracker credentials it belongs with, and the
runners to a page of their own — it was the only table on a page of forms, and "is
the Mac Mini still there" is asked far more often than anything else that page held.

## Design system

The UI is built on [`@16-bits-design/ui`](https://github.com/maxigimenez/16-bits-design),
Expand Down
17 changes: 16 additions & 1 deletion docs/deploy-cloud.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,22 @@ In the service's Variables tab, reference the database:
DATABASE_URL = ${{Postgres.DATABASE_URL}}
```

That is the only required variable. Optional ones:
`SENTINEL0_SECRET_KEY` is required as well, before any tracker credential can be
stored. Thirty-two random bytes, base64 or hex:

```bash
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
```

It encrypts GitHub and Linear tokens at rest with AES-256-GCM. The API refuses to
start storing credentials without it 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.

**Losing or rotating this key makes every stored credential unreadable.** They are not
recoverable; re-add them under Settings → Integrations.

Optional variables:

| Variable | Default | Purpose |
|---|---|---|
Expand Down
2 changes: 1 addition & 1 deletion docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ profile: a role, a GitHub login (for PR-review routes), and an avatar image URL
in Slack).

```bash
sentinel0 preflight # Node, Hermes profiles, cloud, gh auth
sentinel0 preflight # Node, Hermes profiles, cloud, tracker credentials
sentinel0 start
sentinel0 status
```
Expand Down
13 changes: 9 additions & 4 deletions docs/routes.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,14 +95,19 @@ rejects it on a `ticket` route, which could never fire.

There are two, and Sentinel0 only owns one of them.

- **The runner's `gh`.** Every poll, every label, every comment Sentinel0 writes goes
through whatever `gh auth login` was run on the runner's machine. This account needs
write access to the repositories you watch, or the `sentinel0:in-progress` marker
cannot be set and routes will fire twice.
- **Sentinel0's own token,** stored under **Settings → Integrations** in the
dashboard. Every poll, every label and every comment Sentinel0 writes goes through
it. A fine-grained PAT with **Issues: read and write** and **Pull requests: read**
is enough — Sentinel0 never pushes code. The write half is not optional: without it
the `sentinel0:in-progress` marker cannot be set and routes fire on every cycle.
- **The agent's own `gh`,** inside its Hermes profile. This is who opens the pull
request and leaves the review. Sentinel0 passes Hermes **no** GitHub token — the
agent's credentials are Hermes' business entirely.

Sentinel0 stopped shelling out to `gh` in favour of the REST API, so the runner's
machine needs no GitHub CLI and no `gh auth login`. The agents still use `gh` inside
their own profiles, which is Hermes' concern rather than Sentinel0's.

`githubLogin` in `~/.sentinel0/config.json` is your *declaration* of which account the
second one is. Nothing verifies it. Set it wrong and login-targeted routes silently
never match; leave it unset and `sentinel0 preflight` says so. It cannot be set from
Expand Down
38 changes: 29 additions & 9 deletions packages/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,18 +237,38 @@ export async function runInit(context: CliContext): Promise<void> {
return
}

// ── Secrets ──────────────────────────────────────────────
/*
* ── Secrets ──────────────────────────────────────────────
*
* Tracker credentials belong in the cloud now, under Settings →
* Integrations: one place for the whole organization, encrypted at rest, and
* the only way the dashboard can offer a repository picker. What stays here
* is a *fallback*, for the two cases the cloud cannot serve -- a runner with
* no control plane, and a runner restarting while the cloud is unreachable.
*
* So this asks once, and only if the operator says they want one.
*/
const secrets = { ...existing.secrets }
const needsLinear = assertNotCancel(
await p.confirm({ message: 'Will any project pull from Linear?', initialValue: false })
const wantsLocalTokens = assertNotCancel(
await p.confirm({
message: 'Store tracker credentials on this machine as a fallback?',
initialValue: false,
})
)
if (needsLinear && !secrets.LINEAR_API_KEY) {
secrets.LINEAR_API_KEY = assertNotCancel(
await p.password({
message: 'Linear API key',
validate: (value) => (value?.trim() ? undefined : 'Required.'),
})
if (wantsLocalTokens) {
const githubToken = assertNotCancel(
await p.password({ message: 'GitHub token (blank to skip)' })
)
if (githubToken?.trim()) {
secrets.GITHUB_TOKEN = githubToken.trim()
}

const linearKey = assertNotCancel(
await p.password({ message: 'Linear API key (blank to skip)' })
)
if (linearKey?.trim()) {
secrets.LINEAR_API_KEY = linearKey.trim()
}
}

const config: StoredConfig = {
Expand Down
Loading
Loading