diff --git a/AGENTS.md b/AGENTS.md index 7ce7658..81abd66 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,7 +30,7 @@ is `skillhook`. User docs: `README.md`, `docs/`, `llms.txt`. | `src/events.ts` | The in-process event bus (`Events`, `EventMap`): the queue publishes `job.*`, the scheduler `schedule.*`, the registry `skill.changed`, `serve` `server.*`; `GET /events` and `GET /jobs//events` stream it (SSE, `openEventStream` in `src/server.ts`). The cloud link will subscribe to the same bus. | | `src/progress.ts`, `src/answer.ts`, `src/mcp-job.ts`, `src/commands/job.ts`, `src/reporting.ts` | The job API for the running agent and the human loop. `progress.ts` is the file model in the job directory (`progress.jsonl`, `progress.json`, `question.json`, `answer.json`) that the queue watches; `reporting.ts` normalizes what a report says for people (a job title, a one-line headline, typed links, choices) the same way for every front end; `mcp-job.ts` serves it as the per-run MCP server (`skillhook mcp --job`, injected by the runners) and `commands/job.ts` as `skillhook job progress\|ask\|outcome\|note\|context`; `answer.ts` (leaf, like `manual.ts`) delivers a person's answer live or as a `trigger: resume` job that reopens the session. | | `src/runners/` | `claude.ts`, `codex.ts`, `shell.ts`: build argv, parse output; `env.ts` is the env allow-list (`baseRunEnv` is also what probes run with); `failure.ts` classifies a failed run (`failure.kind`, from the CLIs' captured lines) and holds the `fallback` / `retry` schemas. | -| `src/cloud/` | The Skillhook Cloud side of this machine (`control.ts`: the commands that act on it, with the config keys the cloud may never change; `seal.ts`: X25519 + AES-GCM sealing for secrets). `protocol.ts` is the wire protocol as pure zod (no `node:` imports; exported as `@meterapp/skillhook/protocol`, the cloud repo imports it), with the vocabulary repeated as literals and a drift test; `config.ts` holds the URL rules, the kill switch and `commandAllowed`; `link.ts` is the sync loop `serve` runs (idle until `cloud.enabled`), `outbox.ts` the spool and ledgers in `jobs/.cloud/`, `redact.ts` what is removed before anything leaves, `commands.ts` the command dispatcher, `ingress.ts` hosted deliveries replayed to the local server, `pair.ts` / `src/commands/cloud.ts` pairing, `report.ts` a person's problem report (`cloud report`, MCP `cloud_report_issue`) with the machine's diagnostics, `api.ts` the client for the cloud's public API with an organisation API key (`cloud login|machines|jobs|job`), `tools.ts` the cloud's tool catalogue (`GET /api/v1/tools`) and its calls, mapped to CLI arguments (`cloud tools`, `cloud `, `cloud overview`), `bridge.ts` the same catalogue as the `skillhook-cloud` MCP server (`skillhook mcp --cloud`), `remote-secret.ts` a secret generated on another machine and sealed to a one-time key pair here (`cloud secret`, MCP `generate_secret`). The cloud owns that catalogue: a tool it adds needs no change here, so never hand-write a cloud tool in skillhook. Tests talk to `src/test-support/fake-cloud.ts`, never to a real cloud (and `fake-server.ts` stands in for a running server). | +| `src/cloud/` | The Skillhook Cloud side of this machine (`control.ts`: the commands that act on it, with the config keys the cloud may never change; `seal.ts`: X25519 + AES-GCM sealing for secrets). `protocol.ts` is the wire protocol as pure zod (no `node:` imports; exported as `@meterapp/skillhook/protocol`, the cloud repo imports it), with the vocabulary repeated as literals and a drift test; `config.ts` holds the URL rules, the kill switch and `commandAllowed`; `link.ts` is the sync loop `serve` runs (idle until `cloud.enabled`), `outbox.ts` the spool and ledgers in `jobs/.cloud/`, `redact.ts` what is removed before anything leaves, `commands.ts` the command dispatcher, `ingress.ts` hosted deliveries replayed to the local server, `pair.ts` / `src/commands/cloud.ts` pairing, `report.ts` a person's problem report (`cloud report`, MCP `cloud_report_issue`) with the machine's diagnostics, `api.ts` the client for the cloud's public API with an organisation API key (`cloud login|machines|jobs|job`), `login.ts` signing in with the browser to get one (RFC 8628's device flow: `cloud login`, and `skillhook_cloud_setup` of `mcp --cloud`), `tools.ts` the cloud's tool catalogue (`GET /api/v1/tools`) and its calls, mapped to CLI arguments (`cloud tools`, `cloud `, `cloud overview`), `bridge.ts` the same catalogue as the `skillhook-cloud` MCP server (`skillhook mcp --cloud`), `remote-secret.ts` a secret generated on another machine and sealed to a one-time key pair here (`cloud secret`, MCP `generate_secret`). The cloud owns that catalogue: a tool it adds needs no change here, so never hand-write a cloud tool in skillhook. Tests talk to `src/test-support/fake-cloud.ts`, never to a real cloud (and `fake-server.ts` stands in for a running server). | | `src/stats.ts` | Pure aggregation over job records and delivery records (`computeStats`) and `collectStats` over the store and the log: `GET /stats`, `skillhook stats`, MCP `get_stats`. New numbers go here with a unit test on synthetic records. | | `src/readiness.ts` | Is a runner installed and logged in (`checkReadiness`, `ReadinessCache`): the queue's pre-flight before every job, `GET /runners`, `skillhook runners`, `runners.changed`. A not-ready runner fails the job fast or hands it to a `fallback:` runner; a failed run may be retried or handed over only before the agent produced anything. | | `src/ops.ts` | Shared operations (create skill, run locally, sign+send, resolve URLs). CLI and MCP both call this; do not duplicate logic in either. | @@ -62,7 +62,7 @@ Runtime state lives outside the repo in `~/.skillhook` (`SKILLHOOK_HOME`): - **Every CLI command supports `--json`** and returns non-zero on failure. Register new commands in `COMMANDS` (with the usage text) and `HELP` in `src/commands/main.ts`, then in the README table. `--help` / `-h` never reaches a command: `main` prints the usage from `COMMANDS` instead, so a command never checks for it; a new subcommand gets a line in the `--help` test of `src/cli.test.ts`. - **Third-party facts** (Granola, Sentry, GitHub, Tailscale) are stated in `docs/` and the examples with the exact header names; change them only with a source. - **Tests are hermetic**: `tempHome()` from `src/test-support/helpers.ts`, fake runners, ephemeral ports. Never touch `~/.skillhook`, the real `claude`/`codex`, `launchctl` or `tailscale` from a test. Never reach the real npm registry either: point `SKILLHOOK_NPM_REGISTRY` at a local `node:http` server or set `SKILLHOOK_NO_UPDATE_CHECK=1`. Nor Skillhook Cloud: `cloud.url` defaults to the live service (`DEFAULT_CLOUD_URL`), so a test gives a `FakeCloud` URL (`SKILLHOOK_CLOUD_URL`, `cloud.url`, `--url`), or answers what is sent to the default itself (a `fetchImpl`, or a stubbed `fetch` that hands it to a `FakeCloud`). -- **Outbound requests are opt-in and enumerated.** By default the CLI phones home at most once an hour, and only for updates (`src/update.ts`: the registry's `latest` dist-tag, cached 1 h, from a detached process so no command waits, never in CI or when `SKILLHOOK_NO_UPDATE_CHECK` / `update_check: false` say so); with `auto_update` (the default) that process also installs a newer version of skillhook itself, from the same registry, with the package manager that installed it, and nothing else. The one other outbound connection is the Skillhook Cloud link (`src/cloud/link.ts`), and only after `skillhook cloud connect` wrote `cloud.enabled` and `SKILLHOOK_CLOUD_TOKEN`: it talks to `cloud.url` over HTTPS only, sends only what `docs/cloud.md` lists (redacted, scrubbed of every `.env` value), obeys `cloud.mode` and the local allow/deny lists (which the cloud cannot change), and stops on `cloud.enabled: false`, `SKILLHOOK_NO_CLOUD=1` or `cloud disconnect`. Never enable it by default, from `init` or from a job. Besides the link, the person-invoked cloud commands send one request each, only when run, only to the machine's cloud URL (`cloud.url` or `SKILLHOOK_CLOUD_URL`, HTTPS only): `cloud connect` / `disconnect` (pairing, revocation), `cloud report` and the MCP tool `cloud_report_issue` (`src/cloud/report.ts`: the person's text plus the diagnostics `docs/cloud.md` lists, scrubbed like the link's uploads, with the machine token), and `cloud login|overview|machines|jobs|job|tools||secret` and the tools of `skillhook mcp --cloud` (`src/cloud/api.ts`, `tools.ts`, `bridge.ts`, `remote-secret.ts`: requests with the person's organisation API key `SKILLHOOK_CLOUD_API_KEY`, never with the machine token, only to the cloud the key was checked against at login (`SKILLHOOK_CLOUD_API_URL`), each one a command the person ran or a tool their agent called; `skillhook mcp --cloud` also reads the catalogue once when it starts; skillhook's own tools do not move them for an agent: the local MCP refuses `cloud.*` settings and `SKILLHOOK_CLOUD_*` secrets, and no skill's `secret_env` may name skillhook's own credentials; an agent that runs commands acts with the person's rights all the same); the last two groups refuse under `SKILLHOOK_NO_CLOUD=1`. Do not add other outbound requests the user did not ask for, and never install anything automatically but skillhook's own update. +- **Outbound requests are opt-in and enumerated.** By default the CLI phones home at most once an hour, and only for updates (`src/update.ts`: the registry's `latest` dist-tag, cached 1 h, from a detached process so no command waits, never in CI or when `SKILLHOOK_NO_UPDATE_CHECK` / `update_check: false` say so); with `auto_update` (the default) that process also installs a newer version of skillhook itself, from the same registry, with the package manager that installed it, and nothing else. The one other outbound connection is the Skillhook Cloud link (`src/cloud/link.ts`), and only after `skillhook cloud connect` wrote `cloud.enabled` and `SKILLHOOK_CLOUD_TOKEN`: it talks to `cloud.url` over HTTPS only, sends only what `docs/cloud.md` lists (redacted, scrubbed of every `.env` value), obeys `cloud.mode` and the local allow/deny lists (which the cloud cannot change), and stops on `cloud.enabled: false`, `SKILLHOOK_NO_CLOUD=1` or `cloud disconnect`. Never enable it by default, from `init` or from a job. Besides the link, the person-invoked cloud commands send one request each, only when run, only to the machine's cloud URL (`cloud.url` or `SKILLHOOK_CLOUD_URL`, HTTPS only): `cloud connect` / `disconnect` (pairing, revocation), `cloud report` and the MCP tool `cloud_report_issue` (`src/cloud/report.ts`: the person's text plus the diagnostics `docs/cloud.md` lists, scrubbed like the link's uploads, with the machine token), and `cloud login|overview|machines|jobs|job|tools||secret` and the tools of `skillhook mcp --cloud` (`src/cloud/api.ts`, `tools.ts`, `bridge.ts`, `remote-secret.ts`: requests with the person's organisation API key `SKILLHOOK_CLOUD_API_KEY`, never with the machine token, only to the cloud the key was checked against at login (`SKILLHOOK_CLOUD_API_URL`), each one a command the person ran or a tool their agent called; `cloud login` without `--key`, and `skillhook_cloud_setup` without a working key, sign in with the browser instead (`src/cloud/login.ts`): `POST /api/auth/device/code` with this computer's name, then `POST /api/auth/device/token` with the device code every few seconds until a person approves it on the cloud, ten minutes at most, and the page opened with the system's opener (argv, no shell; not over SSH or without a display), the key it gets kept like a pasted one; `skillhook mcp --cloud` also reads the catalogue once when it starts; skillhook's own tools do not move them for an agent: the local MCP refuses `cloud.*` settings and `SKILLHOOK_CLOUD_*` secrets, and no skill's `secret_env` may name skillhook's own credentials; an agent that runs commands acts with the person's rights all the same); the last two groups refuse under `SKILLHOOK_NO_CLOUD=1`. Do not add other outbound requests the user did not ask for, and never install anything automatically but skillhook's own update. ## Checks diff --git a/CHANGELOG.md b/CHANGELOG.md index 4643817..bc30d2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,21 @@ All notable changes to skillhook, newest first. The format follows [Keep a Chang ## Unreleased +- `skillhook cloud login` signs in with the browser: it shows a code, opens the cloud's sign-in page, and the person + approves it there (choosing the organisation, where they are an admin or owner, and the access: read, run or + admin); the cloud makes a new organisation API key for this computer, + named `skillhook CLI on ` under Settings → API keys, and hands it to the waiting CLI once. Nobody copies a + key out of the dashboard and into a terminal any more. `--no-browser` only prints the link; the link and the code go + to stderr, so `--json` still answers on stdout. `--key shc_…`, `--key -` (stdin) and `--key` alone (asked for at a + terminal) keep a key made on the dashboard, as before; in CI, where nobody can approve, login says to use them. + Needs a Skillhook Cloud with browser sign-in (`/api/auth/device/*`); an older one says to use `--key`. +- `skillhook_cloud_setup` of `skillhook mcp --cloud` does the same for an agent: without a working key it opens the + sign-in page in the person's browser and returns the link and the code to show them, keeps the key once they + approved and loads the cloud's tools; `wait_seconds` waits for the approval. The key never passes through the + conversation, and the sign-in goes only to the cloud the key would. +- The messages that sent people to Settings → API keys to make a key first (no key, a refused key, a missing scope) + say to run `skillhook cloud login` instead. + ## 0.9.1 (2026-10-07) - `skillhook init` names pairing with Skillhook Cloud (https://skillhook.dev) among its next steps, and the `cloud link` diff --git a/README.md b/README.md index 868b4f4..9931a2c 100644 --- a/README.md +++ b/README.md @@ -361,7 +361,7 @@ Agents reading this repository should start with [`AGENTS.md`](AGENTS.md) (layou | `skillhook mcp [--print-config]` · `mcp --job` | MCP server over stdio; `--print-config` prints client configuration; `--job` serves one run's job API (the runners start it). | | `skillhook cloud connect --code XXXX-XXXX [--control] [--url U]` · `cloud status` · `cloud disconnect [--keep-token]` | Pair this machine with [Skillhook Cloud](https://skillhook.dev) (opt-in, outbound only; observe mode unless `--control`): webhooks, jobs, health and stats of every machine in one place, hosted webhook URLs. See [Skillhook Cloud](#skillhook-cloud) and [docs/cloud.md](docs/cloud.md). | | `skillhook cloud report "" [--body T\|--body-file F\|--body -] [--kind bug\|question\|feature\|other] [--severity low\|normal\|high\|urgent] [--job ID] [--delivery ID] [--skill S] [--email E] [--no-diagnostics] [--dry-run]` | Report a problem to the Skillhook team from a paired machine, with the diagnostics it already has (versions, link state, runner readiness, failing checks; scrubbed of every `.env` value, never payloads or logs; `--dry-run` shows the JSON). See [docs/cloud.md](docs/cloud.md#reporting-a-problem). | -| `skillhook cloud login [--url U] [--key shc_…\|-]` · `cloud logout` | Keep an organisation API key (Settings → API keys on the dashboard; asked for at a terminal) in `.env` as `SKILLHOOK_CLOUD_API_KEY`, never printed, never the machine token; the cloud is Skillhook Cloud (`https://skillhook.dev`) unless `--url` names another deployment. | +| `skillhook cloud login [--url U] [--no-browser]` · `cloud login --key [shc_…\|-]` · `cloud logout` | Sign in with the browser: approve the code it shows on the cloud, and the organisation API key the cloud makes for this computer is kept in `.env` as `SKILLHOOK_CLOUD_API_KEY`, never printed, never the machine token (`--key` keeps one from Settings → API keys instead); the cloud is Skillhook Cloud (`https://skillhook.dev`) unless `--url` names another deployment. | | `skillhook cloud overview` · `cloud machines` · `cloud jobs [--machine M] [--skill S] [--status ST] [--outcome O] [--waiting] [--limit N] [--before C]` · `cloud job <id>` | With the key: what needs a person across the organisation (waiting agents, alerts, failing checks, failures, the day's numbers); its machines and jobs. | | `skillhook cloud tools [tool]` · `cloud <tool> [args] [--param value]…` · `cloud secret <machine> <skill\|NAME> [--force]` | Every tool the cloud offers the key (the dashboard's views and actions: answer agents, replay, run and test skills, stats, alerts, hosted URLs, skills, machines…), run by name; a skill's secret generated on a machine and opened only here. See [docs/cloud.md](docs/cloud.md#the-whole-organisation-with-an-api-key). | | `skillhook config show\|get <key>\|set <key> <value>\|unset <key>\|reload\|path` | Read and edit `skillhook.json`; `set`/`unset` tell the running server, which applies every key but `host` and `port` live. | diff --git a/docs/cloud.md b/docs/cloud.md index d79d838..44a485f 100644 --- a/docs/cloud.md +++ b/docs/cloud.md @@ -76,14 +76,16 @@ It needs a paired machine (`cloud.enabled` and `SKILLHOOK_CLOUD_TOKEN`); on one Everything the dashboard shows and does for an organisation (every machine's jobs, deliveries, alerts, health and stats; answering agents, replaying, running and testing skills; skills, secrets, hosted URLs and machines) is a tool of -the cloud, and skillhook offers each one in a terminal and to an agent. They use an organisation API key (`shc_…`; an -admin creates one on the dashboard under Settings → API keys), never the machine token: a paired machine cannot read the -rest of its organisation, only someone holding a key can. Its scope decides what it can do: `fleet:read` looks, +the cloud, and skillhook offers each one in a terminal and to an agent. They use an organisation API key (`shc_…`, which +`skillhook cloud login` gets by signing in with the browser, or one an admin made on the dashboard under Settings → API +keys), never the machine token: a paired machine cannot read the rest of its organisation, only someone holding a key +can. Its scope decides what it can do: `fleet:read` looks, `fleet:run` also answers agents, runs, tests, replays and cancels, `fleet:admin` also changes skills, configuration, hosted URLs, machines and settings and generates secrets. ```bash -skillhook cloud login # asks for the key at a terminal (or --key shc_…, --key - for stdin); --url for another deployment +skillhook cloud login # signs in with the browser: approve the code, the new key is kept here; --url for another deployment +skillhook cloud login --key # keeps a key from Settings → API keys instead (asked for at a terminal; --key - reads stdin) skillhook cloud overview # what needs a person: waiting agents, alerts, failing checks, failures, the day's numbers skillhook cloud tools # every tool this key has (and which a wider key would add) skillhook cloud tools answer_job # one tool's parameters @@ -98,9 +100,22 @@ skillhook cloud job 20260929T101500Z-a1b2c3 skillhook cloud logout # forget the key here; revoke it on the dashboard to end it ``` +**Signing in.** `skillhook cloud login` signs in with the browser, as RFC 8628 has devices do: it starts a sign-in at +the cloud (`POST /api/auth/device/code`, sending only the name this computer gives itself, `skillhook CLI on +<hostname>`, which becomes the key's name on the dashboard), prints the page and a code, and opens the page in the +browser with the system's opener (`open`, `xdg-open`; argv, never a shell; not over SSH, not on Linux without a display, +never with `--no-browser`). The person signs in there, checks that the page shows the same code, chooses the +organisation (one where they are an admin or owner: only they make keys) and the access (read, run or admin), and +approves or cancels. Meanwhile the CLI asks `POST /api/auth/device/token` with the device code every few seconds (five +seconds more after a `slow_down`, longer while the cloud cannot answer) for at most ten minutes; its first request after +the approval gets a new organisation API key, made by the cloud at that moment and handed over once. The link and the +code go to stderr, so `--json` keeps stdout for the result. A cloud from before browser sign-in says so, and `--key` +works there; in CI (`CI` set) there is nobody to approve, so it asks for `--key -` or the environment instead. + `login` checks the key with `GET /api/v1/me` against the cloud `--url` names (else the one logged in to before, else this machine's cloud: `SKILLHOOK_CLOUD_URL` or `cloud.url`, which is Skillhook Cloud itself, `https://skillhook.dev`, -unless it names another; when those two differ it asks for `--url` rather than send a key to the wrong one) and keeps +unless it names another; when those two differ it asks for `--url` rather than send a key to the wrong one or sign in to +it) and keeps both in `.env`, as `SKILLHOOK_CLOUD_API_KEY` and `SKILLHOOK_CLOUD_API_URL` (mode 600, the key never printed); `logout` removes them. From then on the key goes to that cloud and nowhere else: a `cloud.url` changed later moves neither the key nor, since login never touches it, this @@ -132,8 +147,10 @@ same wherever they stand: such a text goes after an equals sign, `--answer=--hel **The MCP server.** `skillhook mcp --cloud` serves the same tools to an agent (the skillhook plugin registers it as `skillhook-cloud`, next to this machine's own `skillhook` server): the catalogue is read when it starts, each call is forwarded with the key, and the cloud validates, authorises and audits it as that key. Without a key it offers one tool, -`skillhook_cloud_setup`, which says what is missing and loads the tools once the person logged in; an agent never -handles the key. See [mcp.md](mcp.md#skillhook-cloud-skillhook-mcp---cloud). +`skillhook_cloud_setup`, which says what is missing and, without a working key, signs in with the browser like `cloud +login`: it opens the person's browser on this computer and gives the agent the link and the code to show them, and once +they approved, the key is kept in `.env` and the tools load. An agent never handles the key, and cannot choose the cloud +it signs in to (the key's, else the machine's). See [mcp.md](mcp.md#skillhook-cloud-skillhook-mcp---cloud). **Secrets.** `skillhook cloud secret <machine> <skill|NAME> [--force]` (and the tool `generate_secret`) has the machine generate a skill's secret and seal it to a key pair made for this one request: `POST /api/v1/machines/<m>/secrets` with @@ -149,7 +166,7 @@ The fixed views: | Command | Request | |---|---| -| `cloud login` | `GET /api/v1/me` (the organisation, the key's name and scopes) | +| `cloud login` | `POST /api/auth/device/code`, then `POST /api/auth/device/token` every few seconds until the person approved the code (at most ten minutes), then `GET /api/v1/me` for the new key; with `--key`, `GET /api/v1/me` (the organisation, the key's name and scopes) | | `cloud overview` | `POST /api/v1/tools/describe_cloud` | | `cloud tools [tool]` | `GET /api/v1/tools` | | `cloud machines` | `GET /api/v1/machines`: name, status, mode, skillhook version, last seen | diff --git a/docs/mcp.md b/docs/mcp.md index 7c8a2f6..579525a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -135,9 +135,9 @@ claude mcp add skillhook-cloud -- skillhook mcp --cloud codex mcp add skillhook-cloud -- skillhook mcp --cloud ``` -It needs an organisation API key, which the person keeps here once with `skillhook cloud login` (with `--url` for a -deployment other than Skillhook Cloud itself, `https://skillhook.dev`; -[cloud.md](cloud.md#the-whole-organisation-with-an-api-key)). When it starts it reads the cloud's catalogue of tools +It needs an organisation API key, which `skillhook cloud login` gets once by signing in with the browser (with `--url` +for a deployment other than Skillhook Cloud itself, `https://skillhook.dev`; +[cloud.md](cloud.md#the-whole-organisation-with-an-api-key)), or the setup tool below for the agent. When it starts it reads the cloud's catalogue of tools (`GET /api/v1/tools`, waiting at most 8 s) and offers each one the key's scope allows, with the cloud's own name, description and input schema; a call goes to `POST /api/v1/tools/<name>` with the key (only to the cloud the key was checked against at login), and the cloud validates, authorises and audits it. So the tools are the cloud's as of the @@ -153,9 +153,15 @@ One tool is local: `generate_secret {machine, skill, force?}` (admin keys) has t once; the cloud only forwards the sealed value. A catalogue tool of that name never replaces it. Without a key (or while the cloud cannot be reached, or under `SKILLHOOK_NO_CLOUD=1`) the server offers only -`skillhook_cloud_setup`: it says what is missing, and once the person logged in a call loads the cloud's tools (a -`tools/list_changed` notification; reconnect the server if the client ignores it). Logging in stays with the person in -a terminal, so a key never passes through a conversation. +`skillhook_cloud_setup {wait_seconds?}`. It says what is missing; when a sign-in would fix it (no key, or one the cloud +refused) it signs in with the browser like `skillhook cloud login`: it opens the sign-in page in the person's browser on +this computer and returns at once with the link, the code they check on it and whether the browser opened, for the +agent to show them. The person approves on the cloud; the key goes into `.env` and the cloud's tools load (a +`tools/list_changed` notification; reconnect the server if the client ignores it). Calling it again reports the same +sign-in (`wait_seconds`, up to 120, waits for the approval), then `connected: true`; after a cancelled or expired one it +starts another. The key never passes through the conversation, and the sign-in goes only to the cloud the key would +(the one kept with it, else the machine's). A key in the server's environment (`SKILLHOOK_CLOUD_API_KEY`) wins over a +kept one, so then it says to fix that instead. ## The hosted MCP server of Skillhook Cloud diff --git a/docs/security.md b/docs/security.md index 2760ddf..2d298f6 100644 --- a/docs/security.md +++ b/docs/security.md @@ -43,7 +43,7 @@ Secrets the dashboard asks for are generated here and sent only sealed to the re The kill switches: `cloud.enabled: false`, `SKILLHOOK_NO_CLOUD=1` in the server's environment, `skillhook cloud disconnect` (which also revokes the token and removes the machine's key). Treat the machine token like the admin token: it identifies the machine to the cloud, and whoever holds it can read what the link uploads. -An organisation API key kept with `skillhook cloud login` (`SKILLHOOK_CLOUD_API_KEY` in `.env`, mode 600) reads the whole organisation with the key's scopes; keep a `fleet:read` key on a machine, not an admin one, and revoke it on the dashboard when the machine should stop reading. The machine token is never used for those reads, so pairing alone gives a machine no view of the rest of the organisation. The key is kept with the cloud it was checked against (`SKILLHOOK_CLOUD_API_URL`) and goes nowhere else. skillhook's own credentials (`SKILLHOOK_CLOUD_*`, `SKILLHOOK_ADMIN_TOKEN`) are never a skill's `secret_env`, the local MCP server's configuration and secret tools refuse the `SKILLHOOK_CLOUD_*` ones (the admin token stays theirs to generate on this machine), and the machine never generates or sets any of them for the cloud; an agent that can run commands on the machine is not held back by any of that, since it acts with your account's rights. +An organisation API key kept with `skillhook cloud login` (`SKILLHOOK_CLOUD_API_KEY` in `.env`, mode 600) reads the whole organisation with the key's scopes; give a machine read access, not admin, and revoke its key on the dashboard when the machine should stop reading. Signing in with the browser sends the cloud only this computer's name and the device code it got; the key is made only when a person signed in to the cloud as an admin or owner of the organisation approves the code this computer shows, and it is handed to the waiting process once, never printed. The `skillhook_cloud_setup` tool of `skillhook mcp --cloud` can start that sign-in for an agent, which sees the link and the code, never the key, and cannot pick the cloud; approving it is still the person's decision, so approve only a code your own terminal or agent just showed you. The machine token is never used for those reads, so pairing alone gives a machine no view of the rest of the organisation. The key is kept with the cloud it was checked against (`SKILLHOOK_CLOUD_API_URL`) and goes nowhere else. skillhook's own credentials (`SKILLHOOK_CLOUD_*`, `SKILLHOOK_ADMIN_TOKEN`) are never a skill's `secret_env`, the local MCP server's configuration and secret tools refuse the `SKILLHOOK_CLOUD_*` ones (the admin token stays theirs to generate on this machine), and the machine never generates or sets any of them for the cloud; an agent that can run commands on the machine is not held back by any of that, since it acts with your account's rights. ## Authentication schemes diff --git a/llms.txt b/llms.txt index 34a801b..e5c93fb 100644 --- a/llms.txt +++ b/llms.txt @@ -43,7 +43,7 @@ - Runner readiness, failure kinds, fallback: before a job spawns its runner is checked (installed, logged in or API key; `claude auth status` / `codex login status` with the job environment, cached `health.readiness_cache_seconds`): `skillhook runners [--refresh] [--local]`, `GET /runners`, MCP `get_runners`, event `runners.changed`. A not-ready runner fails the job at once (`failure.kind: auth|not_found`, no process) unless the skill's `fallback: { runners: [codex], on: [not_ready] }` (or `defaults.fallback`) names a ready runner: then `runner` is the fallback, `runner_requested` the original, `runner_reason` says why. Every `failed`/`timed_out` job has `failure: {kind: auth|usage_limit|rate_limit|budget|max_turns|not_found|timeout|crash|unknown, code?, retryable, message?}` classified from the CLI output; `jobs list --failure K`, `GET /jobs?failure=`, MCP `list_jobs {failure}`. `fallback.on` may add `auth|usage_limit|rate_limit|crash` and `retry: {attempts: 1-3, on?: [kinds], backoff_seconds?}` repeats a run that failed before the agent produced anything (`attempts[]` on the job); idempotent skills only. - Stats: `skillhook stats [--since 24h|7d|2w|ISO] [--until ISO] [--skill S]`, `GET /stats?since&until&skill`, MCP `get_stats`: `{window, jobs: {total, finished, queued, running, by_status, by_outcome, by_trigger, by_runner, by_failure_kind, success_rate, completion_rate, duration_ms {count,p50,p95,avg,max}, queue_wait_ms, cost_usd, tokens {input, output, cached_input}, waiting_for_human}, deliveries: {total, by_outcome, by_http_status, accepted_rate, last_received_at}, skills: {<name>: {jobs, by_status, by_outcome, success_rate, cost_usd, tokens, duration_ms, deliveries, last_job}}, generated_at}`; read from the job directories and the delivery log (newest 5000 without a window). - Health: `skillhook health [--quick] [--refresh] [--no-network] [--local]`, `GET /health/checks?deep=0|1&network=0|1&refresh=1` (admin, cached `health.cache_seconds`), `GET /doctor`, MCP `get_health {deep, refresh, network}`: the doctor's checks grouped (`system`, `skillhook`, `runners`, `tools`, `skills`, `exposure`; each check `{name, status, detail, hint?, group, data?}`) plus deep probes of the CLIs with the job environment: `claude` / `codex` version and login, one `claude mcp <name>` check per MCP server (connected / needs authentication / failed), `claude mcp config` diagnostics, `claude plugins`, `codex mcp <name>`, `codex doctor`, `disk`, and each skill's last run and missing `env:` names. Event `health.changed {report, changed}` when a check changes status. Config `health.cache_seconds` (60), `health.probe_timeout_seconds` (20). -- Skillhook Cloud (https://skillhook.dev, docs at https://skillhook.dev/docs; opt-in link, outbound HTTPS only): `skillhook cloud connect --code XXXX-XXXX [--control] [--url U] [--token T] [--force]` (the cloud is `https://skillhook.dev`, Skillhook Cloud itself, unless `--url`, `SKILLHOOK_CLOUD_URL` or `cloud.url` names another deployment) stores `SKILLHOOK_CLOUD_TOKEN` in `.env` and `cloud.{url,machine_id,mode,enabled}` in skillhook.json; `cloud status`, `cloud disconnect [--keep-token]`. `serve` then syncs (`POST /api/agent/sync`, long-poll ≤ 25 s): redacted events scrubbed of `.env` values (deliveries with bodies ≤ 256 KiB when `cloud.upload_payloads`, job records without command lines, progress, schedules, config and health changes), snapshots, deep health reports; read commands run in any mode; control commands (`skill.run`, `skill.test`, `delivery.replay`, `job.replay`, `job.cancel`, `job.answer`, `config.patch` except host/port/trust_proxy/runners/env_passthrough/projects/cloud, `schedule.run`, `update.install`, `service.restart` after the ack, `skill.put`, `skill.delete` to `jobs/.removed-skills/`, `secret.generate` sealed to `recipient_key`) need `cloud.mode: control` or `cloud.allow_commands`; `secret.set` (sealed to the machine key `SKILLHOOK_CLOUD_PRIVATE_KEY`) needs an allow entry; control mode amounts to shell access; `job.watch` streams `job.output` events, `job.artifact` uploads large artifacts in 1 MiB chunks; hosted-ingress deliveries are replayed to the local server (signature checked locally, `via: "ingress"`, `ingress_id` on the delivery record) and acknowledged. Spool in `jobs/.cloud/`. Kill switches: `cloud.enabled: false`, `SKILLHOOK_NO_CLOUD=1`, `cloud disconnect`. `/health` (admin) `cloud`; doctor/health check `cloud link`. Settings `cloud.*` (`mode` observe|control, `allow_commands`, `deny_commands`, `upload_payloads`, `upload_artifacts`, `ingress`, intervals, `outbox_max_events`); protocol `@meterapp/skillhook/protocol`; `SKILLHOOK_CLOUD_*` never reaches a run. `skillhook cloud report "<title>" [--body T|--body-file F|--body -] [--kind bug|question|feature|other] [--severity low|normal|high|urgent] [--job ID] [--delivery ID] [--skill S] [--email E] [--no-diagnostics] [--dry-run]` (MCP `cloud_report_issue`): a person on a paired machine reports a problem to the Skillhook team, `POST /api/agent/issues` with the machine token (`IssueReportRequest` → `IssueReportResponse` `{issue_id, number, url, acknowledged}`, body ≤ 64 KiB; a `report_id` per report, the same on the retries of network errors, timeouts and 5xx, so the cloud files it once); diagnostics unless `--no-diagnostics`: versions, OS, arch, `cloud.mode`, link state, `{runner, ready}`, health summary and the failing/warning checks, scrubbed of `.env` values like the title and body; never payloads, logs, prompts or job output. `skillhook cloud login --key shc_…|-` (checked with `GET /api/v1/me`, kept in `.env` as `SKILLHOOK_CLOUD_API_KEY`, which the environment overrides), `cloud logout`, `cloud machines`, `cloud jobs [--machine M] [--skill S] [--status ST] [--outcome O] [--waiting] [--limit N] [--before C]`, `cloud job <id>`: the organisation's fleet through the cloud's public API with an organisation API key, never the machine token, and only to the key's cloud (`cloud login [--url U]` keeps the key with the cloud it was checked against, `SKILLHOOK_CLOUD_API_URL` in `.env`, and the key goes nowhere else; without `--url` that is the machine's cloud, `https://skillhook.dev` unless `SKILLHOOK_CLOUD_URL` or `cloud.url` names another; login never touches `cloud.url`; at a terminal it asks for the key without echo; the default cloud for a later login is the one kept, and when it differs from the machine's (Skillhook Cloud's own included), login asks for `--url`; the same key from the environment goes there too; a 0.6 key without one goes to the machine's cloud; the local MCP refuses `cloud.*` in `update_config` and `SKILLHOOK_CLOUD_*` in `set_secret`/`generate_secret`, and a skill's `secret_env` may not be `SKILLHOOK_ADMIN_TOKEN` or `SKILLHOOK_CLOUD_*` (nor will the machine generate or set those for the cloud), which is no boundary against an agent that runs commands); tables, or the API's JSON with `--json`. `skillhook cloud overview` (the tool `describe_cloud`: waiting agents, open alerts, failing checks, failures and rejections of 24 h, the day's numbers, next steps), `cloud tools [tool]` (the cloud's catalogue, `GET /api/v1/tools`: every tool with its JSON Schema and scope) and `cloud <tool> [args] [--param value]` (any tool by name, `POST /api/v1/tools/<tool>`: required parameters as arguments, typed flags after the name (before it only skillhook's own options), JSON as literal/@file/-, `--param-file`, `--input`; skillhook's own `--json`, `--help`/`-h`, `--version`/`-v`, `--dir` are never a value, so such a text is `--answer=--help`; output stripped of control and bidi characters, `\u` escapes in `--json`) do everything the dashboard does (answer_job, replay_job, run_skill, test_skill, save_skill, get_job_artifact, get_stats, list_alerts, enable_hosted_url, send_command, …) as far as the key's scope allows (fleet:read, fleet:run, fleet:admin). `cloud secret <machine> <skill|NAME> [--force]`: the machine generates a skill's secret (only a skill's) sealed to a one-time key pair here (`POST /api/v1/machines/<m>/secrets`, `POST /api/v1/commands/<id>/claim`), opened only here. `skillhook mcp --cloud` (the plugin's `skillhook-cloud` server) offers the same catalogue as MCP tools, plus `generate_secret`; without a key only `skillhook_cloud_setup`. Report and API-key commands and tools send only when run or called, only to the cloud URL over HTTPS, and refuse under `SKILLHOOK_NO_CLOUD=1`. +- Skillhook Cloud (https://skillhook.dev, docs at https://skillhook.dev/docs; opt-in link, outbound HTTPS only): `skillhook cloud connect --code XXXX-XXXX [--control] [--url U] [--token T] [--force]` (the cloud is `https://skillhook.dev`, Skillhook Cloud itself, unless `--url`, `SKILLHOOK_CLOUD_URL` or `cloud.url` names another deployment) stores `SKILLHOOK_CLOUD_TOKEN` in `.env` and `cloud.{url,machine_id,mode,enabled}` in skillhook.json; `cloud status`, `cloud disconnect [--keep-token]`. `serve` then syncs (`POST /api/agent/sync`, long-poll ≤ 25 s): redacted events scrubbed of `.env` values (deliveries with bodies ≤ 256 KiB when `cloud.upload_payloads`, job records without command lines, progress, schedules, config and health changes), snapshots, deep health reports; read commands run in any mode; control commands (`skill.run`, `skill.test`, `delivery.replay`, `job.replay`, `job.cancel`, `job.answer`, `config.patch` except host/port/trust_proxy/runners/env_passthrough/projects/cloud, `schedule.run`, `update.install`, `service.restart` after the ack, `skill.put`, `skill.delete` to `jobs/.removed-skills/`, `secret.generate` sealed to `recipient_key`) need `cloud.mode: control` or `cloud.allow_commands`; `secret.set` (sealed to the machine key `SKILLHOOK_CLOUD_PRIVATE_KEY`) needs an allow entry; control mode amounts to shell access; `job.watch` streams `job.output` events, `job.artifact` uploads large artifacts in 1 MiB chunks; hosted-ingress deliveries are replayed to the local server (signature checked locally, `via: "ingress"`, `ingress_id` on the delivery record) and acknowledged. Spool in `jobs/.cloud/`. Kill switches: `cloud.enabled: false`, `SKILLHOOK_NO_CLOUD=1`, `cloud disconnect`. `/health` (admin) `cloud`; doctor/health check `cloud link`. Settings `cloud.*` (`mode` observe|control, `allow_commands`, `deny_commands`, `upload_payloads`, `upload_artifacts`, `ingress`, intervals, `outbox_max_events`); protocol `@meterapp/skillhook/protocol`; `SKILLHOOK_CLOUD_*` never reaches a run. `skillhook cloud report "<title>" [--body T|--body-file F|--body -] [--kind bug|question|feature|other] [--severity low|normal|high|urgent] [--job ID] [--delivery ID] [--skill S] [--email E] [--no-diagnostics] [--dry-run]` (MCP `cloud_report_issue`): a person on a paired machine reports a problem to the Skillhook team, `POST /api/agent/issues` with the machine token (`IssueReportRequest` → `IssueReportResponse` `{issue_id, number, url, acknowledged}`, body ≤ 64 KiB; a `report_id` per report, the same on the retries of network errors, timeouts and 5xx, so the cloud files it once); diagnostics unless `--no-diagnostics`: versions, OS, arch, `cloud.mode`, link state, `{runner, ready}`, health summary and the failing/warning checks, scrubbed of `.env` values like the title and body; never payloads, logs, prompts or job output. `skillhook cloud login [--no-browser]` (signs in with the browser, RFC 8628: `POST /api/auth/device/code` with this computer's name, the link and a code on stderr and the page opened in the browser, the person approves the code on the cloud as an admin or owner of the organisation choosing the access, `POST /api/auth/device/token` every few seconds until the cloud hands over the new key once; ten minutes at most; refused in CI) or `cloud login --key shc_…|-` (a key made under Settings → API keys), either checked with `GET /api/v1/me` and kept in `.env` as `SKILLHOOK_CLOUD_API_KEY`, which the environment overrides, `cloud logout`, `cloud machines`, `cloud jobs [--machine M] [--skill S] [--status ST] [--outcome O] [--waiting] [--limit N] [--before C]`, `cloud job <id>`: the organisation's fleet through the cloud's public API with an organisation API key, never the machine token, and only to the key's cloud (`cloud login [--url U]` keeps the key with the cloud it was checked against, `SKILLHOOK_CLOUD_API_URL` in `.env`, and the key goes nowhere else; without `--url` that is the machine's cloud, `https://skillhook.dev` unless `SKILLHOOK_CLOUD_URL` or `cloud.url` names another; login never touches `cloud.url`; `--key` alone asks for the key at a terminal without echo; the default cloud for a later login is the one kept, and when it differs from the machine's (Skillhook Cloud's own included), login asks for `--url`; the same key from the environment goes there too; a 0.6 key without one goes to the machine's cloud; `skillhook_cloud_setup` of `skillhook mcp --cloud` signs in with the browser the same way for an agent, which gets the link and the code, never the key; the local MCP refuses `cloud.*` in `update_config` and `SKILLHOOK_CLOUD_*` in `set_secret`/`generate_secret`, and a skill's `secret_env` may not be `SKILLHOOK_ADMIN_TOKEN` or `SKILLHOOK_CLOUD_*` (nor will the machine generate or set those for the cloud), which is no boundary against an agent that runs commands); tables, or the API's JSON with `--json`. `skillhook cloud overview` (the tool `describe_cloud`: waiting agents, open alerts, failing checks, failures and rejections of 24 h, the day's numbers, next steps), `cloud tools [tool]` (the cloud's catalogue, `GET /api/v1/tools`: every tool with its JSON Schema and scope) and `cloud <tool> [args] [--param value]` (any tool by name, `POST /api/v1/tools/<tool>`: required parameters as arguments, typed flags after the name (before it only skillhook's own options), JSON as literal/@file/-, `--param-file`, `--input`; skillhook's own `--json`, `--help`/`-h`, `--version`/`-v`, `--dir` are never a value, so such a text is `--answer=--help`; output stripped of control and bidi characters, `\u` escapes in `--json`) do everything the dashboard does (answer_job, replay_job, run_skill, test_skill, save_skill, get_job_artifact, get_stats, list_alerts, enable_hosted_url, send_command, …) as far as the key's scope allows (fleet:read, fleet:run, fleet:admin). `cloud secret <machine> <skill|NAME> [--force]`: the machine generates a skill's secret (only a skill's) sealed to a one-time key pair here (`POST /api/v1/machines/<m>/secrets`, `POST /api/v1/commands/<id>/claim`), opened only here. `skillhook mcp --cloud` (the plugin's `skillhook-cloud` server) offers the same catalogue as MCP tools, plus `generate_secret`; without a key only `skillhook_cloud_setup`. Report and API-key commands and tools send only when run or called, only to the cloud URL over HTTPS, and refuse under `SKILLHOOK_NO_CLOUD=1`. - Config: `skillhook.json` (schema in `schema/skillhook.schema.json`); `skillhook config show|get|set|unset|reload|path`. The running server holds one live config: `set`/`unset`, `PATCH /config {set: {"dotted.key": v}, unset: [..]}`, `POST /config/reload`, MCP `update_config` re-read the file at once (a hand edit is noticed within 5 s); every key but `host`/`port` applies live, those two are `pending_restart` (`GET /config`, MCP `get_config`); `400 config_invalid` / `config_key_not_allowed` write nothing; event `config.changed`. Control: `POST /control/restart {force?, wait_seconds?}` / MCP `restart_server` (service-run servers only, `409 not_a_service`), `GET /service`, `GET /logs?lines=`, `POST /update {install?}` / MCP `check_update` (never restarts itself). Every CLI command accepts `--json` and `--dir`; exit code 0 ok, 1 error, 2 usage. - Updates: skillhook updates itself. At most once an hour a background check (cached in `<home>/update-check.json`) finds a newer version and, with `auto_update` (default on), installs it with the package manager that installed skillhook (npm/pnpm/bun/yarn/Volta); the next command runs it and the service restarts itself onto it once idle. `skillhook update` asks npm now, `skillhook update --install` upgrades now and updates the plugin in Claude Code and Codex. `"auto_update": false` only reports new versions (after interactive commands, in `doctor` and in the server log); `SKILLHOOK_NO_UPDATE_CHECK=1`, `CI`, or `"update_check": false` disable the check. Releases: https://github.com/MeterApp/skillhook/releases. - Install: `npm install -g @meterapp/skillhook` (the command is `skillhook`; `npx @meterapp/skillhook <command>` for one-off use). The unscoped `skillhook` package is the old 0.1.0 name: `npm uninstall -g skillhook` before installing, then `skillhook service install` again if the service ran from it. diff --git a/skills/skillhook-cloud/SKILL.md b/skills/skillhook-cloud/SKILL.md index 50b64ee..30e2c1a 100644 --- a/skills/skillhook-cloud/SKILL.md +++ b/skills/skillhook-cloud/SKILL.md @@ -14,17 +14,19 @@ this computer's own skillhook. ## 1. Connect If the `skillhook-cloud` tools include `describe_cloud`, you are connected. If the only tool is `skillhook_cloud_setup`, -call it: it says what is missing. Logging in is for the person, in a terminal, once: +call it: it says what is missing and, when there is no working key, signs in with the browser. It opens the sign-in +page in the person's browser on this computer and returns the link and a code: tell them to check that the page shows +that code and approve it (they choose the organisation and the access there; it needs an admin or owner of that +organisation), and give them the link when `browser_opened` is false. Then call it again with `wait_seconds: 60`; the +tools appear once they approved. In a terminal the same is: ```bash -skillhook cloud login # asks for an organisation API key, keeps it in ~/.skillhook/.env +skillhook cloud login # opens the browser; the person approves the code, the key is kept in ~/.skillhook/.env ``` That is Skillhook Cloud at https://skillhook.dev; only a person on another deployment adds `--url https://…` (the -setup tool's message names it when this machine uses one). The key comes from the dashboard: Settings → API keys -(admins create them). Never ask the person to paste a key into the -conversation, and never put one in a command line, a file or a commit. Then call `skillhook_cloud_setup` again (or -reconnect the MCP server) and the tools appear. +setup tool's message names it when this machine uses one). The key never passes through the conversation: never ask +the person to paste one, and never put one in a command line, a file or a commit. The key's scope decides which tools exist: `fleet:read` looks, `fleet:run` also answers agents, runs, tests, replays and cancels, `fleet:admin` also changes skills, configuration, hosted URLs, machines and settings and generates secrets. diff --git a/src/cli.test.ts b/src/cli.test.ts index 9b2827e..44fbb0b 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -778,8 +778,10 @@ describe("cli", () => { const none = io(env); expect(await main(["cloud", "machines", ...at, "--json"], none.cli)).toBe(1); expect(String(none.json().error)).toContain("skillhook cloud login"); + // --key without one, and no terminal to ask at: nothing to log in with (without --key it would sign in with the browser). const usage = io(env); - expect(await main(["cloud", "login", ...at], usage.cli)).toBe(2); + expect(await main(["cloud", "login", "--key", ...at], usage.cli)).toBe(2); + expect(usage.err()).toContain("--key needs the organisation API key"); const machineToken = io(env); expect(await main(["cloud", "login", "--key", fake.token, ...at], machineToken.cli)).toBe(2); expect(machineToken.err()).toContain("not an organisation API key"); @@ -790,7 +792,7 @@ describe("cli", () => { expect(fake.apiRequests).toEqual([]); const wrong = io(env); expect(await main(["cloud", "login", "--key", "shc_placeholder-unknown-key", ...at, "--json"], wrong.cli)).toBe(1); - expect(String(wrong.json().error)).toMatch(/refused the API key \(invalid_key: The API key is unknown, revoked or expired\. \(request req_\d+\)\)\. Log in with a valid one: skillhook cloud login/); + expect(String(wrong.json().error)).toMatch(/refused the API key \(invalid_key: The API key is unknown, revoked or expired\. \(request req_\d+\)\)\. Log in again: skillhook cloud login --url http:\/\/127\.0\.0\.1:\d+$/); expect(readFileSync(home.envFile, "utf8")).not.toContain("SKILLHOOK_CLOUD_API_KEY"); // A key kept by skillhook 0.6 has no cloud of its own: it goes to the machine's, and status says so. @@ -891,6 +893,83 @@ describe("cli", () => { } }); + it("signs in with the browser: the link and the code shown, the browser opened, the key the cloud hands over kept and never printed", async () => { + const { FakeCloud } = await import("./test-support/fake-cloud.js"); + const fake = await FakeCloud.start(); + const home = tempHome("skillhook-cli-browser-login-"); + const at = ["--dir", home.home]; + try { + const env = { SKILLHOOK_CLOUD_URL: fake.url, SKILLHOOK_NO_UPDATE_CHECK: "1" }; + fake.apiScopes = ["fleet:run"]; + // Waiting, asked to slow down, the cloud in trouble for a moment, waiting again; then approved. + fake.signInScript.push("pending", "slow_down", "500", "pending"); + const opened: string[] = []; + const waits: number[] = []; + const login = io(env); + login.cli.openUrl = (url) => opened.push(url) > 0; + login.cli.sleep = async (ms) => void waits.push(ms); + expect(await main(["cloud", "login", ...at], login.cli)).toBe(0); + const page = `${fake.url}/activate?user_code=WXYZ-2345`; + expect(opened).toEqual([page]); + expect(login.err()).toContain(`Sign in to ${fake.url} to log this computer in:\n ${page}\n\nCheck that the page shows this code: WXYZ-2345\n\nOpened your browser (--no-browser to skip). Waiting for your approval (the code expires in 10 minutes; Ctrl-C stops)…`); + expect(waits).toEqual([1_000, 1_000, 6_000, 12_000, 12_000]); + expect(login.out()).toContain(`Logged in to ${fake.url} as Fake Org: key "laptop" (fleet:run), kept in ${home.envFile} with its cloud (SKILLHOOK_CLOUD_API_KEY, SKILLHOOK_CLOUD_API_URL).`); + for (const output of [login.out(), login.err()]) expect(output).not.toContain(fake.apiKey); + expect(readFileSync(home.envFile, "utf8")).toContain(`SKILLHOOK_CLOUD_API_KEY=${fake.apiKey}\nSKILLHOOK_CLOUD_API_URL=${fake.url}`); + expect(statSync(home.envFile).mode & 0o777).toBe(0o600); + // Only the name this computer gives itself went with the sign-in: no key, no token. The key was checked with /me. + expect(fake.signIns).toEqual([expect.objectContaining({ clientName: expect.stringMatching(/^skillhook CLI on \S+/), authorization: undefined, polls: 5, claimed: true })]); + expect(fake.apiRequests).toEqual([{ method: "GET", path: "/api/v1/me", authorization: `Bearer ${fake.apiKey}` }]); + + // --no-browser prints the link and opens nothing; --json keeps stdout for the answer. + const quiet = io(env); + quiet.cli.openUrl = (url) => opened.push(url) > 0; + quiet.cli.sleep = async () => {}; + expect(await main(["cloud", "login", "--no-browser", ...at, "--json"], quiet.cli)).toBe(0); + expect(opened).toHaveLength(1); + expect(quiet.err()).toContain("Open the link in a browser where you can sign in."); + expect(quiet.json()).toEqual({ ok: true, method: "browser", url: fake.url, organisation: { id: "org_fake", slug: "fake", name: "Fake Org" }, key: { id: "key_1", name: "laptop", scopes: ["fleet:run"] }, role: "member", env_file: home.envFile }); + // Nothing opens where it was not asked for: without openUrl (an embedder, these tests) the link is the way. + const embedded = io(env); + embedded.cli.sleep = async () => {}; + expect(await main(["cloud", "login", ...at], embedded.cli)).toBe(0); + expect(embedded.err()).toContain("Open the link in a browser where you can sign in."); + + // Cancelled in the browser, or never approved: the command fails and nothing is kept. + writeEnv(home, {}); + for (const [answer, message] of [["deny", "The sign-in was not approved: The sign-in was cancelled in the browser."], ["expire", "The code expired before it was approved. Run skillhook cloud login again."]] as const) { + fake.signInScript.push(answer); + const refused = io(env); + refused.cli.sleep = async () => {}; + expect(await main(["cloud", "login", ...at, "--json"], refused.cli)).toBe(1); + expect(refused.json()).toEqual({ ok: false, error: message }); + } + expect(readFileSync(home.envFile, "utf8")).not.toContain("SKILLHOOK_CLOUD_API_KEY"); + + // A cloud from before browser sign-in says how to log in with a key instead. + fake.signInMode = "missing"; + const old = io(env); + expect(await main(["cloud", "login", ...at, "--json"], old.cli)).toBe(1); + expect(old.json().error).toBe(`${fake.url} does not offer signing in with the browser. Log in with a key from its dashboard (Settings → API keys) instead: skillhook cloud login --url ${fake.url} --key`); + fake.signInMode = "ok"; + + // Nobody to approve in CI, nothing sent under the kill switch, nothing in plain http: no sign-in starts. + const started = fake.signIns.length; + const ci = io({ ...env, CI: "true" }); + expect(await main(["cloud", "login", ...at], ci.cli)).toBe(2); + expect(ci.err()).toContain("Signing in with the browser needs a person at it. In CI, give the key: --key - with it on stdin"); + const killed = io({ ...env, SKILLHOOK_NO_CLOUD: "1" }); + expect(await main(["cloud", "login", ...at], killed.cli)).toBe(1); + expect(killed.err()).toContain("SKILLHOOK_NO_CLOUD is set"); + const insecure = io({ ...env, SKILLHOOK_CLOUD_URL: "http://cloud.example.invalid" }); + expect(await main(["cloud", "login", ...at], insecure.cli)).toBe(1); + expect(insecure.err()).toContain("must use https"); + expect(fake.signIns).toHaveLength(started); + } finally { + await fake.close(); + } + }); + it("logs in to Skillhook Cloud itself on a machine that names no cloud, and keeps the key with it", async () => { const { FakeCloud } = await import("./test-support/fake-cloud.js"); const fake = await FakeCloud.start(); @@ -902,7 +981,7 @@ describe("cli", () => { // Without a key: the command that keeps one, nothing to fill in. const none = io(env); expect(await main(["cloud", "overview", ...at, "--json"], none.cli)).toBe(1); - expect(none.json().error).toBe("No organisation API key. Create one on https://skillhook.dev (Settings → API keys), then: skillhook cloud login (or set SKILLHOOK_CLOUD_API_KEY)"); + expect(none.json().error).toBe("No organisation API key. Sign in with the browser: skillhook cloud login (or set SKILLHOOK_CLOUD_API_KEY)"); const login = io(env); expect(await main(["cloud", "login", "--key", fake.apiKey, ...at], login.cli)).toBe(0); expect(login.out()).toContain(`Logged in to https://skillhook.dev as Fake Org: key "laptop" (fleet:read), kept in ${home.envFile} with its cloud`); @@ -915,7 +994,7 @@ describe("cli", () => { expect(await main(["cloud", "machines", ...at], io(env).cli)).toBe(0); const ambiguous = io(env); expect(await main(["cloud", "login", "--key", fake.apiKey, ...at], ambiguous.cli)).toBe(2); - expect(ambiguous.err()).toContain(`The key kept here was for https://skillhook.dev, and this machine's cloud is ${fake.url}: name the one this key belongs to`); + expect(ambiguous.err()).toContain(`The key kept here was for https://skillhook.dev, and this machine's cloud is ${fake.url}: name the one to log in to`); expect(production.requests).toEqual(["GET https://skillhook.dev/api/v1/me", "POST https://skillhook.dev/api/v1/tools/describe_cloud", "GET https://skillhook.dev/api/v1/machines"]); expect(fake.requests).toEqual([]); } finally { @@ -956,13 +1035,18 @@ describe("cli", () => { const ambiguous = io(env); const before = fake.apiRequests.length; expect(await main(["cloud", "login", "--key", fake.apiKey, ...at], ambiguous.cli)).toBe(2); - expect(ambiguous.err()).toContain(`The key kept here was for ${fake.url}, and this machine's cloud is https://other-cloud.example.invalid: name the one this key belongs to, skillhook cloud login --url https://…`); + expect(ambiguous.err()).toContain(`The key kept here was for ${fake.url}, and this machine's cloud is https://other-cloud.example.invalid: name the one to log in to, skillhook cloud login --url https://…`); expect(fake.apiRequests.length).toBe(before); - // Asked before the key is: at a terminal, nobody types a key only to be told which cloud is missing. + // Asked before the key is: at a terminal, nobody types a key only to be told which cloud is missing; nor is a browser + // sign-in started for one of the two. const asked = io(env); asked.cli.isTTY = true; - expect(await main(["cloud", "login", ...at], asked.cli)).toBe(2); - expect(asked.err()).toContain("name the one this key belongs to"); + expect(await main(["cloud", "login", "--key", ...at], asked.cli)).toBe(2); + expect(asked.err()).toContain("name the one to log in to"); + const browser = io(env); + expect(await main(["cloud", "login", ...at], browser.cli)).toBe(2); + expect(browser.err()).toContain("name the one to log in to"); + expect(fake.signIns).toEqual([]); // A blank --url is no cloud at all, not "no --url". const blank = io(env); expect(await main(["cloud", "login", "--url", " ", "--key", fake.apiKey, ...at], blank.cli)).toBe(2); @@ -972,7 +1056,7 @@ describe("cli", () => { writeConfigFile(home, {}); const unnamed = io(env); expect(await main(["cloud", "login", "--key", fake.apiKey, ...at], unnamed.cli)).toBe(2); - expect(unnamed.err()).toContain(`The key kept here was for ${fake.url}, and this machine's cloud is https://skillhook.dev: name the one this key belongs to`); + expect(unnamed.err()).toContain(`The key kept here was for ${fake.url}, and this machine's cloud is https://skillhook.dev: name the one to log in to`); expect(fake.apiRequests.length).toBe(before); // With one cloud to go by (the key's is the machine's), that is the one. writeConfigFile(home, { cloud: { url: fake.url } }); @@ -1242,7 +1326,7 @@ describe("cli", () => { runners: [[], ["--refresh", "--local"]], stats: [[], ["--since", "7d"]], config: [[], ["show"], ["get", "port"], ["set", "port", "9999"], ["unset", "runners"], ["reload"], ["path"]], - cloud: [[], ["connect", "--code", "ABCD-EFGH", "--control"], ["disconnect"], ["status"], ["report", "Broken", "--body", "details"], ["login", "--key", "shc_placeholder-other-key", "--url", "https://cloud.example.invalid"], ["logout"], ["overview"], ["machines"], ["jobs", "--waiting"], ["job", job], ["tools"], ["tools", "answer_job"], ["answer_job", job, "yes"], ["save-skill", "mac-mini", "hello", "--content-file", "SKILL.md"], ["secret", "mac-mini", "hello", "--force"]], + cloud: [[], ["connect", "--code", "ABCD-EFGH", "--control"], ["disconnect"], ["status"], ["report", "Broken", "--body", "details"], ["login", "--key", "shc_placeholder-other-key", "--url", "https://cloud.example.invalid"], ["login", "--no-browser"], ["logout"], ["overview"], ["machines"], ["jobs", "--waiting"], ["job", job], ["tools"], ["tools", "answer_job"], ["answer_job", job, "yes"], ["save-skill", "mac-mini", "hello", "--content-file", "SKILL.md"], ["secret", "mac-mini", "hello", "--force"]], mcp: [[], ["--print-config"], ["--cloud"], ["--job", job]], update: [[], ["--install"], ["--refresh"]], link: [[], [unlinked]], diff --git a/src/cloud/api.ts b/src/cloud/api.ts index ecfc61c..d8b0834 100644 --- a/src/cloud/api.ts +++ b/src/cloud/api.ts @@ -5,7 +5,7 @@ // HTTPS, only when a person runs one of these commands or their agent calls one of those tools. The answers are the // cloud's own records, parsed loosely: a newer cloud may say more. import { z } from "zod"; -import { readEnvFile } from "../env.js"; +import { ensureSecretFileMode, readEnvFile, upsertEnvVar } from "../env.js"; import type { Paths } from "../paths.js"; import { CLOUD_API_KEY_ENV, CLOUD_API_URL_ENV, cloudDisabledByEnv, DEFAULT_CLOUD_URL, InsecureCloudUrlError, isSecureCloudUrl, resolveCloudUrl } from "./config.js"; import { CloudHttpError, cloudRequest } from "./http.js"; @@ -85,6 +85,16 @@ export function storedApiCredentials(paths: Paths, env: NodeJS.ProcessEnv): ApiC return { key: fromEnvironment, url: env[CLOUD_API_URL_ENV]?.trim() || (fromEnvironment === kept.key ? kept.url : undefined) }; } +/** + * Keeps a key in `.env` (mode 600) with the cloud it was checked against, as `skillhook cloud login` does (and the + * `skillhook_cloud_setup` tool after a browser sign-in); it goes to that cloud and nowhere else from then on. + */ +export function keepApiKey(paths: Paths, key: string, url: string): void { + upsertEnvVar(paths.envFile, CLOUD_API_KEY_ENV, key); + upsertEnvVar(paths.envFile, CLOUD_API_URL_ENV, url); + ensureSecretFileMode(paths.envFile); +} + export interface FleetClient { /** The cloud the requests go to. */ url: string; @@ -104,7 +114,7 @@ export function fleetClient(env: NodeJS.ProcessEnv, cloud: { url?: string }, cre const { key } = credentials; if (cloudDisabledByEnv(env)) throw new CloudApiError("SKILLHOOK_NO_CLOUD is set: nothing goes to Skillhook Cloud from this environment. Unset it to use the fleet.", undefined, "disabled"); const url = resolveCloudUrl(env, cloud, options.url ?? credentials.url); - if (!key) throw new CloudApiError(`No organisation API key. Create one on ${url} (Settings → API keys), then: ${loginCommand(url)} (or set ${CLOUD_API_KEY_ENV})`, undefined, "no_key"); + if (!key) throw new CloudApiError(`No organisation API key. Sign in with the browser: ${loginCommand(url)} (or set ${CLOUD_API_KEY_ENV})`, undefined, "no_key"); if (!API_KEY_RE.test(key)) throw new CloudApiError(`${CLOUD_API_KEY_ENV} does not hold an organisation API key (shc_ followed by letters, digits, - and _); run: skillhook cloud login`, undefined, "invalid_key"); if (!isSecureCloudUrl(url, env)) throw new CloudApiError(new InsecureCloudUrlError(url).message, undefined, "insecure_url"); const send = async <T,>(method: "GET" | "POST", path: string, schema: z.ZodType<T>, body: unknown, timeoutMs: number) => { @@ -135,10 +145,10 @@ export function loginCommand(url: string): string { export function describeApiFailure(error: CloudHttpError, url: string, method: "GET" | "POST" = "GET"): string { const said = `${error.code}: ${error.message}${error.requestId ? ` (request ${error.requestId})` : ""}`; if (error.status === 0) return `Could not reach ${url} (${error.message}). If the cloud moved: skillhook cloud login --url https://<its address>`; - if (error.status === 401) return `${url} refused the API key (${said}). Log in with a valid one: skillhook cloud login --url ${url} --key - (keys: Settings → API keys on that cloud's dashboard)`; + if (error.status === 401) return `${url} refused the API key (${said}). Log in again: ${loginCommand(url)}`; // A read needs fleet:read; a tool the cloud refused names the scope it needs (and what the key has). - if (error.status === 403 && method === "GET") return `The API key is not allowed to read this (${said}); it needs the fleet:read scope. Create a key with it under Settings → API keys, then: skillhook cloud login --key -`; - if (error.status === 403) return `The API key is not allowed to do this (${said}). Reading needs the fleet:read scope, acting fleet:run, changing fleet:admin: create a key with the one it needs under Settings → API keys, then: skillhook cloud login`; + if (error.status === 403 && method === "GET") return `The API key is not allowed to read this (${said}); it needs the fleet:read scope. Log in again with read access: ${loginCommand(url)}`; + if (error.status === 403) return `The API key is not allowed to do this (${said}). Reading needs the fleet:read scope, acting fleet:run, changing fleet:admin: log in again and choose the access it needs, ${loginCommand(url)}`; return `${url}: ${said}`; } diff --git a/src/cloud/bridge.test.ts b/src/cloud/bridge.test.ts index ce7f2b3..239b89b 100644 --- a/src/cloud/bridge.test.ts +++ b/src/cloud/bridge.test.ts @@ -1,9 +1,26 @@ +import { readFileSync } from "node:fs"; import { afterEach, describe, expect, it } from "vitest"; import { FakeCloud } from "../test-support/fake-cloud.js"; import { tempHome, writeConfigFile, writeEnv } from "../test-support/helpers.js"; import { connectMcp, type McpTestClient } from "../test-support/mcp-client.js"; import { buildCloudMcpServer, connectCloud } from "./bridge.js"; +/** Polls of a browser sign-in that wait until the test allows them, so the test decides when the person approved. */ +function gatedPolls() { + let budget = 0; + let wake: (() => void) | undefined; + return { + async sleep() { + while (budget === 0) await new Promise<void>((resolve) => (wake = resolve)); + budget--; + }, + allow(n: number) { + budget += n; + wake?.(); + }, + }; +} + const clients: McpTestClient[] = []; const clouds: FakeCloud[] = []; afterEach(async () => { @@ -113,51 +130,116 @@ describe("skillhook mcp --cloud", () => { expect(fake.toolCalls.map((call) => call.name)).not.toContain("generate_secret"); }); - it("without a key, offers only the setup tool, which loads the cloud's tools once the person logged in", async () => { - const { fake, paths, client } = await setup({ loggedIn: false }); + it("without a key, signs in with the browser: the link and the code for the person, then the cloud's tools", async () => { + const fake = await FakeCloud.start(); + clouds.push(fake); + const paths = tempHome("skillhook-cloud-mcp-"); + writeConfigFile(paths, { cloud: { url: fake.url } }); + const env = { SKILLHOOK_NO_UPDATE_CHECK: "1" }; + const opened: string[] = []; + const polls = gatedPolls(); + const client = await connectMcp(buildCloudMcpServer(paths, env, await connectCloud(paths, env), { openUrl: (url) => opened.push(url) > 0, sleep: polls.sleep })); + clients.push(client); expect(await client.tools()).toEqual(["skillhook_cloud_setup"]); const instructions = String((client.init.result as { instructions: string }).instructions); expect(instructions).toContain("Skillhook Cloud is not connected on this computer: No organisation API key."); - expect(instructions).toContain(`then: skillhook cloud login --url ${fake.url} (or set SKILLHOOK_CLOUD_API_KEY)`); - expect(instructions).toContain("Never ask them to paste the key into this conversation"); + expect(instructions).toContain("To connect, call skillhook_cloud_setup: it signs in with the browser"); + expect(instructions).toContain("Never ask the person to paste a key into this conversation"); - const still = await client.call("skillhook_cloud_setup"); - expect(still.isError).toBe(true); - expect(still.text).toContain("No organisation API key"); - expect(fake.apiRequests).toEqual([]); + // The first call starts the sign-in, opens the page on this computer and returns at once with what to show. + const page = `${fake.url}/activate?user_code=WXYZ-2345`; + const started = await client.call("skillhook_cloud_setup"); + expect(started).toMatchObject({ isError: false, data: { connected: false, signing_in: true, cloud: fake.url, url: page, code: "WXYZ-2345", browser_opened: true, expires_in_seconds: 600 } }); + expect(started.text).toContain(`The sign-in page is open in the person's browser on this computer: ${page}\nIt must show the code WXYZ-2345`); + expect(opened).toEqual([page]); + // Asked again before the person approved: the same sign-in, not another one. + const again = await client.call("skillhook_cloud_setup"); + expect(again.data).toMatchObject({ signing_in: true, code: "WXYZ-2345" }); + expect(fake.signIns).toHaveLength(1); + expect(fake.signIns[0]).toMatchObject({ clientName: expect.stringMatching(/^skillhook CLI on /), authorization: undefined, polls: 0 }); - writeEnv(paths, { SKILLHOOK_CLOUD_API_KEY: fake.apiKey }); - const connected = await client.call("skillhook_cloud_setup"); + // Approved after one more pending poll: the key is kept and the tools load; wait_seconds waits for it. + fake.signInScript.push("pending"); + polls.allow(2); + const connected = await client.call("skillhook_cloud_setup", { wait_seconds: 30 }); expect(connected).toMatchObject({ isError: false, data: { connected: true, organisation: { name: "Fake Org" }, tools: 6 } }); + expect(fake.signIns[0]).toMatchObject({ polls: 2, claimed: true }); expect(await client.tools()).toEqual(["describe_cloud", "get_delivery", "get_job", "list_jobs", "list_skills", "report_issue"]); expect((await client.call("describe_cloud")).data).toMatchObject({ organisation: { name: "Fake Org" } }); + expect(readFileSync(paths.envFile, "utf8")).toContain(`SKILLHOOK_CLOUD_API_KEY=${fake.apiKey}\nSKILLHOOK_CLOUD_API_URL=${fake.url}`); + for (const result of [started, again, connected]) expect(result.text).not.toContain(fake.apiKey); }); - it("without a key on a machine that names no cloud, gives the person the command for Skillhook Cloud itself", async () => { + it("starts again after a sign-in the person cancelled, and leaves a key from the environment to whoever set it", async () => { + const fake = await FakeCloud.start(); + clouds.push(fake); + const paths = tempHome("skillhook-cloud-mcp-"); + writeConfigFile(paths, { cloud: { url: fake.url } }); + const polls = gatedPolls(); + const client = await connectMcp(buildCloudMcpServer(paths, {}, await connectCloud(paths, {}), { sleep: polls.sleep })); + clients.push(client); + const started = await client.call("skillhook_cloud_setup"); + expect(started.data).toMatchObject({ signing_in: true, browser_opened: false }); + expect(started.text).toContain(`Give the person this link, to open in a browser where they can sign in: ${fake.url}/activate?user_code=WXYZ-2345`); + fake.signInScript.push("deny"); + polls.allow(1); + const denied = await client.call("skillhook_cloud_setup", { wait_seconds: 30 }); + expect(denied).toMatchObject({ isError: true }); + expect(denied.text).toContain("The sign-in was not approved: The sign-in was cancelled in the browser.\nCall skillhook_cloud_setup again to start a new one."); + const fresh = await client.call("skillhook_cloud_setup"); + expect(fresh.data).toMatchObject({ signing_in: true }); + expect(fake.signIns).toHaveLength(2); + + // A refused key in this server's environment wins over anything a sign-in would keep: say so, start nothing. + const env = { SKILLHOOK_CLOUD_API_KEY: "shc_placeholder-revoked-key-0123456789", SKILLHOOK_CLOUD_URL: fake.url }; + const stuck = await connectMcp(buildCloudMcpServer(paths, env, await connectCloud(paths, env), { sleep: polls.sleep })); + clients.push(stuck); + const refused = await stuck.call("skillhook_cloud_setup"); + expect(refused).toMatchObject({ isError: true }); + expect(refused.text).toContain("refused the API key"); + expect(refused.text).toContain("SKILLHOOK_CLOUD_API_KEY is set in this MCP server's environment and wins over a key kept by signing in"); + expect(fake.signIns).toHaveLength(2); + }); + + it("without a key on a machine that names no cloud, signs in to Skillhook Cloud itself", async () => { const paths = tempHome("skillhook-cloud-mcp-"); const env = { SKILLHOOK_NO_UPDATE_CHECK: "1" }; const connection = await connectCloud(paths, env); - expect(connection).toEqual({ problem: "No organisation API key. Create one on https://skillhook.dev (Settings → API keys), then: skillhook cloud login (or set SKILLHOOK_CLOUD_API_KEY)" }); + expect(connection).toEqual({ problem: "No organisation API key. Sign in with the browser: skillhook cloud login (or set SKILLHOOK_CLOUD_API_KEY)", code: "no_key" }); const client = await connectMcp(buildCloudMcpServer(paths, env, connection)); clients.push(client); const instructions = String((client.init.result as { instructions: string }).instructions); - expect(instructions).toContain("The person logs in once, in a terminal: `skillhook cloud login` (with `--url https://…` for a Skillhook Cloud other than https://skillhook.dev)"); + expect(instructions).toContain("In a terminal, `skillhook cloud login` does the same (with `--url https://…` for a Skillhook Cloud other than https://skillhook.dev)"); expect(instructions).not.toContain("https://<"); - const listed = ((await client.request("tools/list")).result as { tools: { name: string; description: string }[] }).tools; - expect(listed).toEqual([expect.objectContaining({ name: "skillhook_cloud_setup", description: expect.stringContaining("`skillhook cloud login`") })]); + const listed = ((await client.request("tools/list")).result as { tools: { name: string; description: string; annotations: { readOnlyHint: boolean } }[] }).tools; + expect(listed).toEqual([expect.objectContaining({ name: "skillhook_cloud_setup", description: expect.stringContaining("signs in with the browser"), annotations: expect.objectContaining({ readOnlyHint: false }) })]); }); it("says what is wrong when the cloud cannot be used: the kill switch, plain http, an older cloud", async () => { const paths = tempHome("skillhook-cloud-mcp-"); writeEnv(paths, { SKILLHOOK_CLOUD_API_KEY: "shc_placeholder-organisation-key-0123456789abc" }); - expect(await connectCloud(paths, { SKILLHOOK_NO_CLOUD: "1" })).toEqual({ problem: expect.stringContaining("SKILLHOOK_NO_CLOUD is set") }); + const killed = await connectCloud(paths, { SKILLHOOK_NO_CLOUD: "1" }); + expect(killed).toEqual({ problem: expect.stringContaining("SKILLHOOK_NO_CLOUD is set"), code: "disabled" }); + // Signing in would not help there: the setup tool says what is wrong and starts nothing. + const client = await connectMcp(buildCloudMcpServer(paths, { SKILLHOOK_NO_CLOUD: "1" }, killed)); + clients.push(client); + const setupCall = await client.call("skillhook_cloud_setup"); + expect(setupCall).toMatchObject({ isError: true }); + expect(setupCall.text).toContain("SKILLHOOK_NO_CLOUD is set"); writeConfigFile(paths, { cloud: { url: "http://cloud.example.invalid" } }); - expect(await connectCloud(paths, {})).toEqual({ problem: expect.stringContaining("must use https") }); + expect(await connectCloud(paths, {})).toEqual({ problem: expect.stringContaining("must use https"), code: "insecure_url" }); + // Without a key the problem is the key, but the sign-in that would fix it goes over https only too. + writeEnv(paths, {}); + const plain = await connectMcp(buildCloudMcpServer(paths, {}, await connectCloud(paths, {}), { openUrl: () => true })); + clients.push(plain); + const refused = await plain.call("skillhook_cloud_setup"); + expect(refused).toMatchObject({ isError: true }); + expect(refused.text).toContain("the cloud URL must use https (got http://cloud.example.invalid)"); const fake = await FakeCloud.start(); clouds.push(fake); fake.catalogMode = "missing"; writeConfigFile(paths, { cloud: { url: fake.url } }); writeEnv(paths, { SKILLHOOK_CLOUD_API_KEY: fake.apiKey }); - expect(await connectCloud(paths, {})).toEqual({ problem: expect.stringContaining("has no tool catalogue") }); + expect(await connectCloud(paths, {})).toMatchObject({ problem: expect.stringContaining("has no tool catalogue") }); }); }); diff --git a/src/cloud/bridge.ts b/src/cloud/bridge.ts index 97c96e2..73bf631 100644 --- a/src/cloud/bridge.ts +++ b/src/cloud/bridge.ts @@ -4,15 +4,19 @@ // `POST /api/v1/tools/<name>`, where the cloud validates, authorises and audits it as that key. One more, generate_secret, // needs a key pair only a local process can hold (src/cloud/remote-secret.ts). Without a key, or while the cloud cannot // be reached, the server offers one tool, skillhook_cloud_setup, which says what is missing and loads the tools once it -// is fixed. Logging in stays with the person, in a terminal: an agent never handles the key. +// is fixed. Without a key (or with one the cloud refused) it signs in with the browser as `skillhook cloud login` does +// (src/cloud/login.ts): it opens the person's browser on this computer and gives the agent the link and the code to +// show; the person approves on the cloud, the key goes into .env, and the tools load. The agent never handles the key, +// and it cannot choose the cloud: the sign-in goes where the key would (the one kept with it, else the machine's). import { fromJsonSchema, McpServer, type JsonSchemaType, type JsonSchemaValidator, type jsonSchemaValidator } from "@modelcontextprotocol/server"; import { z } from "zod"; import { loadConfig } from "../config.js"; import type { Paths } from "../paths.js"; import { errorMessage } from "../util.js"; import { VERSION } from "../version.js"; -import { CloudApiError, fleetClient, storedApiCredentials, type FleetClient } from "./api.js"; -import { DEFAULT_CLOUD_URL } from "./config.js"; +import { API_KEY_RE, CloudApiError, fleetClient, keepApiKey, storedApiCredentials, type FleetClient } from "./api.js"; +import { CLOUD_API_KEY_ENV, DEFAULT_CLOUD_URL, resolveCloudUrl } from "./config.js"; +import { browserLogin, openBrowser, type SignInPrompt } from "./login.js"; import { generateRemoteSecret, secretNameFor } from "./remote-secret.js"; import { callTool, fetchCatalog, type Catalog } from "./tools.js"; @@ -38,9 +42,17 @@ const ANNOTATIONS: Record<string, { readOnlyHint: boolean; destructiveHint?: boo /** Tools this server makes itself: a catalogue tool of the same name never replaces them (generate_secret must stay sealed end to end). */ const LOCAL_TOOLS = new Set(["generate_secret", "skillhook_cloud_setup"]); -const LOGIN_HINT = `The person logs in once, in a terminal: \`skillhook cloud login\` (with \`--url https://…\` for a Skillhook Cloud other than ${DEFAULT_CLOUD_URL}), pasting an organisation API key from that cloud's dashboard (Settings → API keys; fleet:read to look, fleet:run to also answer agents and run skills, fleet:admin to also change skills and settings). Never ask them to paste the key into this conversation.`; +const LOGIN_HINT = `To connect, call skillhook_cloud_setup: it signs in with the browser, opening the person's browser on this computer, and returns the link and a code to show them; they approve it on Skillhook Cloud (as an admin or owner of the organisation, choosing the access: read to look, run to also answer agents and run skills, admin to also change skills and settings), the key goes straight into skillhook's .env and the tools load. In a terminal, \`skillhook cloud login\` does the same (with \`--url https://…\` for a Skillhook Cloud other than ${DEFAULT_CLOUD_URL}). Never ask the person to paste a key into this conversation.`; -export type CloudConnection = { client: FleetClient; catalog: Catalog } | { problem: string }; +/** `code` and `status`: the CloudApiError's, so the setup tool knows whether signing in would help. */ +export type CloudConnection = { client: FleetClient; catalog: Catalog } | { problem: string; code?: string; status?: number }; + +export interface CloudMcpOptions { + /** Opens the sign-in page in the person's browser (`serveCloudMcp`); without it the agent only gets the link to show. */ + openUrl?: (url: string) => boolean; + /** How a sign-in waits between polls (tests pass their own). */ + sleep?: (ms: number) => Promise<void>; +} /** How long the server waits for the catalogue when it starts: an MCP client waits for the server meanwhile. */ const CATALOG_TIMEOUT_MS = 8_000; @@ -51,10 +63,15 @@ export async function connectCloud(paths: Paths, env: NodeJS.ProcessEnv): Promis const client = fleetClient(env, loadConfig(paths).cloud, storedApiCredentials(paths, env)); return { client, catalog: await fetchCatalog(client, { timeoutMs: CATALOG_TIMEOUT_MS }) }; } catch (error) { - return { problem: errorMessage(error) }; + return { problem: errorMessage(error), ...(error instanceof CloudApiError ? { ...(error.code ? { code: error.code } : {}), ...(error.status !== undefined ? { status: error.status } : {}) } : {}) }; } } +/** A sign-in fixes a missing or malformed key, and one the cloud refused; not the kill switch, plain http or a cloud out of reach. */ +function signInHelps(problem: { code?: string; status?: number }): boolean { + return problem.code === "no_key" || problem.code === "invalid_key" || problem.status === 401; +} + function instructionsFor(catalog: Catalog): string { const key = catalog.key; return [ @@ -71,11 +88,11 @@ function generatesSecrets(catalog: Catalog): boolean { } /** The server for one connection: the catalogue's tools when connected, else the setup tool that connects. */ -export function buildCloudMcpServer(paths: Paths, env: NodeJS.ProcessEnv, connection: CloudConnection): McpServer { +export function buildCloudMcpServer(paths: Paths, env: NodeJS.ProcessEnv, connection: CloudConnection, options: CloudMcpOptions = {}): McpServer { const connected = "catalog" in connection; const server = new McpServer( { name: "skillhook-cloud", version: VERSION }, - { capabilities: { tools: {} }, instructions: connected ? instructionsFor(connection.catalog) : `Skillhook Cloud is not connected on this computer: ${connection.problem}\nskillhook_cloud_setup checks again and loads the cloud's tools once it works. ${LOGIN_HINT}` }, + { capabilities: { tools: {} }, instructions: connected ? instructionsFor(connection.catalog) : `Skillhook Cloud is not connected on this computer: ${connection.problem}\n${LOGIN_HINT}` }, ); const install = (client: FleetClient, catalog: Catalog) => { @@ -119,15 +136,94 @@ export function buildCloudMcpServer(paths: Paths, env: NodeJS.ProcessEnv, connec install(connection.client, connection.catalog); return server; } + const connectedResult = (connection: { client: FleetClient; catalog: Catalog }) => + ok({ connected: true, organisation: connection.catalog.organisation ?? null, key: connection.catalog.key ?? null, tools: connection.catalog.tools.filter((tool) => tool.allowed !== false).length }, `Connected to ${connection.client.url}: the Skillhook Cloud tools are listed now (describe_cloud first). If your client does not show them, reconnect this MCP server.`); + + // One browser sign-in at a time. When the person approves, the key is kept and the tools load at once; the setup tool + // stays until it is called again, to say so (an agent checking on the sign-in should not find it gone). + type SignIn = { url: string; prompt?: SignInPrompt; opened: boolean; state: "waiting" | "connected" | "failed"; connection?: { client: FleetClient; catalog: Catalog }; failure?: string; done: Promise<void> }; + let signIn: SignIn | undefined; + const waitFor = async (current: SignIn, seconds: number | undefined) => { + if (current.state !== "waiting" || !seconds) return; + let timer: NodeJS.Timeout | undefined; + await Promise.race([current.done, new Promise<void>((resolve) => (timer = setTimeout(resolve, seconds * 1000)))]); + clearTimeout(timer); + }; + const startSignIn = async (): Promise<SignIn> => { + const url = resolveCloudUrl(env, loadConfig(paths).cloud, storedApiCredentials(paths, env).url); + let prompted!: () => void; + const shown = new Promise<void>((resolve) => (prompted = resolve)); + const current: SignIn = { url, opened: false, state: "waiting", done: Promise.resolve() }; + current.done = browserLogin({ + url, + env, + sleep: options.sleep, + onPrompt: (prompt) => { + current.prompt = prompt; + current.opened = Boolean(options.openUrl?.(prompt.url)); + prompted(); + }, + }) + .then(async ({ key }) => { + if (!API_KEY_RE.test(key)) throw new CloudApiError(`${url} handed over something that is not an organisation API key; nothing was kept`); + keepApiKey(paths, key, url); + const again = await connectCloud(paths, env); + if ("problem" in again) throw new CloudApiError(again.problem); + install(again.client, again.catalog); + current.connection = again; + current.state = "connected"; + }) + .catch((error: unknown) => { + current.failure = errorMessage(error); + current.state = "failed"; + }) + .finally(() => prompted()); + signIn = current; + await shown; + return current; + }; + const report = (current: SignIn): ToolResult => { + if (current.state === "connected" && current.connection) { + setup.remove(); + return connectedResult(current.connection); + } + if (current.state === "failed" || !current.prompt) { + signIn = undefined; + return fail(new CloudApiError(`${current.failure ?? "The sign-in did not start."}\nCall skillhook_cloud_setup again to start a new one.`)); + } + const { prompt } = current; + const minutes = Math.max(1, Math.round(prompt.expiresInSeconds / 60)); + return ok( + { connected: false, signing_in: true, cloud: current.url, url: prompt.url, code: prompt.code, browser_opened: current.opened, expires_in_seconds: prompt.expiresInSeconds }, + `Signing in to ${current.url}. ${current.opened ? "The sign-in page is open in the person's browser on this computer" : "Give the person this link, to open in a browser where they can sign in"}: ${prompt.url}\nIt must show the code ${prompt.code}; they check that and approve (an admin or owner of the organisation, choosing the access). The code expires in ${minutes} minutes, and the key goes straight into skillhook's .env, never through this conversation. Call skillhook_cloud_setup again once they approved (wait_seconds: 60 waits for it); the Skillhook Cloud tools load as soon as they do.`, + ); + }; + const setup = server.registerTool( "skillhook_cloud_setup", - { title: "Connect Skillhook Cloud", description: `Why the Skillhook Cloud tools are missing on this computer, and a new attempt to load them (call it again after the person logged in). ${LOGIN_HINT}`, inputSchema: z.object({}), annotations: { readOnlyHint: true, openWorldHint: true } }, - async () => { + { + title: "Connect Skillhook Cloud", + description: `Connects the Skillhook Cloud tools on this computer: says what is missing, signs in with the browser when there is no working key (it opens the person's browser and returns the link and a code to show them), and loads the tools once it works. Call it again to see whether the person approved, or with wait_seconds to wait for that. ${LOGIN_HINT}`, + inputSchema: z.object({ wait_seconds: z.number().int().min(0).max(120).optional().describe("Wait up to this long for the person to approve a sign-in that is under way") }), + annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true }, + }, + async ({ wait_seconds }) => { + if (signIn) { + await waitFor(signIn, wait_seconds); + return report(signIn); + } + // Logged in some other way meanwhile: a terminal, the environment. const again = await connectCloud(paths, env); - if ("problem" in again) return fail(new CloudApiError(`${again.problem}\n${LOGIN_HINT}`)); - setup.remove(); - install(again.client, again.catalog); - return ok({ connected: true, organisation: again.catalog.organisation ?? null, key: again.catalog.key ?? null, tools: again.catalog.tools.filter((tool) => tool.allowed !== false).length }, `Connected to ${again.client.url}: the Skillhook Cloud tools are listed now (describe_cloud first). If your client does not show them, reconnect this MCP server.`); + if (!("problem" in again)) { + setup.remove(); + install(again.client, again.catalog); + return connectedResult(again); + } + if (!signInHelps(again)) return fail(new CloudApiError(`${again.problem}\n${LOGIN_HINT}`)); + if (env[CLOUD_API_KEY_ENV]?.trim()) return fail(new CloudApiError(`${again.problem}\n${CLOUD_API_KEY_ENV} is set in this MCP server's environment and wins over a key kept by signing in: fix it there, or remove it and call skillhook_cloud_setup again.`)); + const current = await startSignIn(); + await waitFor(current, wait_seconds); + return report(current); }, ); return server; @@ -137,5 +233,5 @@ export async function serveCloudMcp(paths: Paths, env: NodeJS.ProcessEnv = proce const connection = await connectCloud(paths, env); if ("problem" in connection) console.error(`[skillhook mcp --cloud] not connected: ${connection.problem}`); const { serveStdio } = await import("@modelcontextprotocol/server/stdio"); - serveStdio(() => buildCloudMcpServer(paths, env, connection), { onerror: (error) => console.error(`[skillhook mcp --cloud] ${error.message}`) }); + serveStdio(() => buildCloudMcpServer(paths, env, connection, { openUrl: (url) => openBrowser(url, env) }), { onerror: (error) => console.error(`[skillhook mcp --cloud] ${error.message}`) }); } diff --git a/src/cloud/config.ts b/src/cloud/config.ts index 038db6b..bc82bcc 100644 --- a/src/cloud/config.ts +++ b/src/cloud/config.ts @@ -66,7 +66,8 @@ export function cloudDisabledByEnv(env: NodeJS.ProcessEnv = process.env): boolea return truthy(env.SKILLHOOK_NO_CLOUD); } -function truthy(value: string | undefined): boolean { +/** Set, and not "", "0" or "false": how the switches in the environment read (`SKILLHOOK_NO_CLOUD`, `CI`). */ +export function truthy(value: string | undefined): boolean { return value !== undefined && value !== "" && value !== "0" && value.toLowerCase() !== "false"; } diff --git a/src/cloud/http.test.ts b/src/cloud/http.test.ts index a46eb29..1919624 100644 --- a/src/cloud/http.test.ts +++ b/src/cloud/http.test.ts @@ -90,8 +90,8 @@ describe("fleetClient", () => { it("refuses without a key, with what looks like no key, under the kill switch and to plain http", () => { // Without a key, the command that keeps one: --url only for a cloud other than Skillhook Cloud's own. - expect(refusal(() => fleetClient({}, {}, { key: undefined, url: undefined }))).toMatchObject({ code: "no_key", message: "No organisation API key. Create one on https://skillhook.dev (Settings → API keys), then: skillhook cloud login (or set SKILLHOOK_CLOUD_API_KEY)" }); - expect(refusal(() => fleetClient({}, { url: "https://cloud.example" }, { key: undefined, url: undefined })).message).toContain("Create one on https://cloud.example (Settings → API keys), then: skillhook cloud login --url https://cloud.example (or set"); + expect(refusal(() => fleetClient({}, {}, { key: undefined, url: undefined }))).toMatchObject({ code: "no_key", message: "No organisation API key. Sign in with the browser: skillhook cloud login (or set SKILLHOOK_CLOUD_API_KEY)" }); + expect(refusal(() => fleetClient({}, { url: "https://cloud.example" }, { key: undefined, url: undefined })).message).toContain("Sign in with the browser: skillhook cloud login --url https://cloud.example (or set"); expect(refusal(() => fleetClient({}, {}, { key: "shm_machine-token", url: undefined }))).toMatchObject({ code: "invalid_key" }); expect(refusal(() => fleetClient({}, {}, { key: `${KEY}\nshc_placeholder-second-line`, url: undefined }))).toMatchObject({ code: "invalid_key" }); expect(refusal(() => fleetClient({ SKILLHOOK_NO_CLOUD: "1" }, {}, { key: KEY, url: undefined }))).toMatchObject({ code: "disabled" }); diff --git a/src/cloud/http.ts b/src/cloud/http.ts index dd81c25..4521bf4 100644 --- a/src/cloud/http.ts +++ b/src/cloud/http.ts @@ -1,4 +1,4 @@ -// The one HTTP client for Skillhook Cloud (the link, pairing, `cloud report` and the API-key commands): bearer token, +// The one HTTP client for Skillhook Cloud (the link, pairing, `cloud report`, `cloud login` and the API-key commands): bearer token, // protocol header, a timeout, JSON in and out. The token is never logged or included in an error message. import { VERSION } from "../version.js"; import { PROTOCOL_VERSION } from "./protocol.js"; @@ -36,7 +36,8 @@ export interface CloudResponse<T = unknown> { /** * Sends one request; non-2xx answers become `CloudHttpError` with what the body says when it is JSON: `error` / `message` - * from the agent API, `code` / `detail` / `request_id` from the public API's RFC 9457 problems. + * from the agent API, `code` / `detail` / `request_id` from the public API's RFC 9457 problems, `error` / + * `error_description` from the OAuth-style answers of a browser sign-in (`cloud login`). */ export async function cloudRequest<T = unknown>(baseUrl: string, path: string, options: CloudRequestOptions = {}): Promise<CloudResponse<T>> { // The runtime quotes a header value it refuses in its error; a token that could not be a header never gets that far. @@ -77,7 +78,7 @@ export async function cloudRequest<T = unknown>(baseUrl: string, path: string, o const retryHeader = Number(response.headers.get("retry-after")); const retryAfterMs = typeof record.retry_after_ms === "number" ? record.retry_after_ms : Number.isFinite(retryHeader) && retryHeader > 0 ? retryHeader * 1000 : undefined; const requestId = field("request_id") ?? response.headers.get("x-request-id") ?? undefined; - throw new CloudHttpError(response.status, field("error", "code") ?? `http_${response.status}`, field("message", "detail", "title") ?? `${response.status} ${response.statusText}`.trim(), retryAfterMs, typeof record.min_protocol_version === "number" ? record.min_protocol_version : undefined, requestId); + throw new CloudHttpError(response.status, field("error", "code") ?? `http_${response.status}`, field("message", "detail", "error_description", "title") ?? `${response.status} ${response.statusText}`.trim(), retryAfterMs, typeof record.min_protocol_version === "number" ? record.min_protocol_version : undefined, requestId); } return { status: response.status, body: parsed as T, headers: response.headers }; } diff --git a/src/cloud/login.ts b/src/cloud/login.ts new file mode 100644 index 0000000..f0764d3 --- /dev/null +++ b/src/cloud/login.ts @@ -0,0 +1,139 @@ +// `skillhook cloud login` signing in with the browser, and the `skillhook_cloud_setup` tool of `skillhook mcp --cloud` +// doing the same for an agent: RFC 8628's device authorization grant against Skillhook Cloud. One request starts it +// (`POST /api/auth/device/code`): a device code that only this process holds, and a user code with the page to approve +// it on (`/activate`), which is opened in the person's browser. Then `POST /api/auth/device/token` with the device code +// every few seconds until the person approved the code there (or cancelled, or ten minutes passed): the answer is a new +// organisation API key, made by the cloud at that moment and handed over once. Nothing else goes with these requests: +// no key, no machine token, only the name this computer gives itself ("skillhook CLI on <hostname>"), which becomes the +// key's name on the dashboard. The key is then checked and kept like one given with `--key` (`keepApiKey`). +import { spawn } from "node:child_process"; +import { hostname } from "node:os"; +import { z } from "zod"; +import { printable } from "../util.js"; +import { CloudApiError } from "./api.js"; +import { cloudDisabledByEnv, InsecureCloudUrlError, isSecureCloudUrl } from "./config.js"; +import { CloudHttpError, cloudRequest } from "./http.js"; + +/** `POST /api/auth/device/code` (RFC 8628, section 3.2), parsed loosely: a newer cloud may say more. */ +export const DeviceCodeSchema = z + .object({ + device_code: z.string().min(1).max(512), + user_code: z.string().min(1).max(64), + verification_uri: z.string().min(1).max(2048), + verification_uri_complete: z.string().min(1).max(2048).optional(), + expires_in: z.number().positive(), + interval: z.number().positive().optional(), + }) + .loose(); +export type DeviceCode = z.infer<typeof DeviceCodeSchema>; + +/** `POST /api/auth/device/token` once the person approved (RFC 6749, section 5.1). */ +const TokenSchema = z.object({ access_token: z.string().min(1), token_type: z.string().nullish(), scope: z.string().nullish() }).loose(); + +/** What the person needs to approve the sign-in: the page (with the code in it when the cloud gives one) and the code. */ +export interface SignInPrompt { + url: string; + code: string; + expiresInSeconds: number; +} + +export interface BrowserLoginOptions { + /** The cloud, already resolved; nothing is sent to it under `SKILLHOOK_NO_CLOUD` or in plain http (`env` says). */ + url: string; + clientName?: string; + /** Told once the sign-in started: show the page and the code, open the browser. */ + onPrompt: (prompt: SignInPrompt) => void | Promise<void>; + /** The kill switch, and plain http only where it is allowed (`SKILLHOOK_CLOUD_ALLOW_INSECURE`), for the cloud and its page. */ + env: NodeJS.ProcessEnv; + fetchImpl?: typeof fetch; + sleep?: (ms: number) => Promise<void>; + now?: () => number; +} + +/** The longest wait between two polls, after network trouble or `slow_down`. */ +const MAX_INTERVAL_MS = 30_000; + +/** "skillhook CLI on mac-mini": how the key is named on the dashboard, where the person revokes it. */ +export function defaultClientName(): string { + const host = hostname().replace(/\.local$/, "") || "this computer"; + return `skillhook CLI on ${host}`.slice(0, 80); +} + +/** + * Starts a sign-in at `options.url`, tells `onPrompt` what to show, and polls until the person decided. Resolves with the + * new key; rejects with a `CloudApiError` whose code says why not: `unsupported` (a cloud from before browser sign-in), + * `denied`, `expired`, or what the cloud answered. Network trouble, 5xx and 429 answers only make it wait longer. + */ +export async function browserLogin(options: BrowserLoginOptions): Promise<{ key: string; scope: string | null }> { + const sleep = options.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))); + const now = options.now ?? Date.now; + if (cloudDisabledByEnv(options.env)) throw new CloudApiError("SKILLHOOK_NO_CLOUD is set: nothing goes to Skillhook Cloud from this environment. Unset it to log in.", undefined, "disabled"); + if (!isSecureCloudUrl(options.url, options.env)) throw new CloudApiError(new InsecureCloudUrlError(options.url).message, undefined, "insecure_url"); + let started; + try { + started = await cloudRequest(options.url, "/api/auth/device/code", { body: { client_name: options.clientName ?? defaultClientName() }, fetchImpl: options.fetchImpl, timeoutMs: 20_000 }); + } catch (error) { + if (!(error instanceof CloudHttpError)) throw error; + if (error.status === 404 || error.status === 405) throw new CloudApiError(`${options.url} does not offer signing in with the browser. Log in with a key from its dashboard (Settings → API keys) instead: skillhook cloud login --url ${options.url} --key`, error.status, "unsupported"); + if (error.status === 0) throw new CloudApiError(`Could not reach ${options.url} (${error.message}).`, 0, error.code); + throw new CloudApiError(`${options.url} could not start a sign-in: ${error.code}: ${error.message}${error.requestId ? ` (request ${error.requestId})` : ""}`, error.status, error.code); + } + const parsed = DeviceCodeSchema.safeParse(started.body); + if (!parsed.success) throw new CloudApiError(`${options.url} answered POST /api/auth/device/code with something this version does not understand`, started.status, "unsupported"); + const device = parsed.data; + const page = device.verification_uri_complete ?? device.verification_uri; + // The page is opened in a browser: only a web address, and only one as safe as the cloud's own would have to be. + if (!isSecureCloudUrl(page, options.env) || printable(page) !== page || /\s/.test(page)) throw new CloudApiError(`${options.url} gave a sign-in page that is not an https address`, started.status, "unsupported"); + await options.onPrompt({ url: page, code: printable(device.user_code), expiresInSeconds: device.expires_in }); + + const deadline = now() + device.expires_in * 1000; + let interval = Math.max(1, device.interval ?? 5) * 1000; + while (now() < deadline) { + await sleep(interval); + let answer; + try { + answer = await cloudRequest(options.url, "/api/auth/device/token", { body: { device_code: device.device_code }, fetchImpl: options.fetchImpl, timeoutMs: 20_000 }); + } catch (error) { + if (!(error instanceof CloudHttpError)) throw error; + if (error.code === "authorization_pending") continue; + if (error.code === "slow_down") { + interval = Math.min(interval + 5_000, MAX_INTERVAL_MS); + continue; + } + if (error.code === "access_denied") throw new CloudApiError(`The sign-in was not approved: ${error.message}`, error.status, "denied"); + if (error.code === "expired_token") throw new CloudApiError("The code expired before it was approved. Run skillhook cloud login again.", error.status, "expired"); + if (error.code === "invalid_grant") throw new CloudApiError("The cloud knows this sign-in no longer (it was used already). Run skillhook cloud login again.", error.status, "expired"); + if (error.status === 0 || error.status === 429 || error.status >= 500) { + interval = Math.min(interval * 2, MAX_INTERVAL_MS); + continue; + } + throw new CloudApiError(`${options.url} refused the sign-in: ${error.code}: ${error.message}`, error.status, error.code); + } + const token = TokenSchema.safeParse(answer.body); + if (!token.success) throw new CloudApiError(`${options.url} answered the sign-in with something this version does not understand`, answer.status, "unsupported"); + return { key: token.data.access_token.trim(), scope: token.data.scope ?? null }; + } + throw new CloudApiError("The code expired before it was approved. Run skillhook cloud login again.", undefined, "expired"); +} + +/** + * Opens `url` in the person's browser without waiting for it (argv, never a shell). Not over SSH, where it would open on + * the far machine's screen or nowhere, nor on Linux without a display: false then, and the caller's printed link is the + * way. + */ +export function openBrowser(url: string, env: NodeJS.ProcessEnv = process.env, platform: NodeJS.Platform = process.platform, run: typeof spawn = spawn): boolean { + if (!/^https?:\/\/[^\s]+$/i.test(url)) return false; + if (env.SSH_CONNECTION || env.SSH_TTY) return false; + if (platform !== "darwin" && platform !== "win32" && !env.DISPLAY && !env.WAYLAND_DISPLAY) return false; + const [command, args] = platform === "darwin" ? ["open", [url]] : platform === "win32" ? ["rundll32", ["url.dll,FileProtocolHandler", url]] : ["xdg-open", [url]]; + try { + const child = run(command, args, { detached: true, stdio: "ignore" }); + child.on("error", () => { + // No opener on this system: the link is printed anyway. + }); + child.unref(); + return true; + } catch { + return false; + } +} diff --git a/src/commands/cloud.ts b/src/commands/cloud.ts index c96b1d9..4c6c730 100644 --- a/src/commands/cloud.ts +++ b/src/commands/cloud.ts @@ -1,7 +1,8 @@ import { readFileSync } from "node:fs"; import { adminRequest, findRunningServer, readServerState } from "../client.js"; -import { API_KEY_RE, CloudApiError, fleetClient, JobDetailSchema, JobListSchema, machineNames, MachineListSchema, MeSchema, storedApiCredentials, type FleetClient, type FleetJob } from "../cloud/api.js"; -import { assertSecureCloudUrl, CLOUD_API_KEY_ENV, CLOUD_API_URL_ENV, CLOUD_PRIVATE_KEY_ENV, CLOUD_TOKEN_ENV, cloudDisabledByEnv, resolveCloudUrl, trimTrailingSlashes } from "../cloud/config.js"; +import { API_KEY_RE, CloudApiError, fleetClient, JobDetailSchema, JobListSchema, keepApiKey, loginCommand, machineNames, MachineListSchema, MeSchema, storedApiCredentials, type FleetClient, type FleetJob, type Me } from "../cloud/api.js"; +import { assertSecureCloudUrl, CLOUD_API_KEY_ENV, CLOUD_API_URL_ENV, CLOUD_PRIVATE_KEY_ENV, CLOUD_TOKEN_ENV, cloudDisabledByEnv, resolveCloudUrl, trimTrailingSlashes, truthy } from "../cloud/config.js"; +import { browserLogin } from "../cloud/login.js"; import { CloudHttpError } from "../cloud/http.js"; import { cloudStatus, disconnectCloud, machineInfo, pairMachine, writeLinkCredentials } from "../cloud/pair.js"; import { ISSUE_KINDS, ISSUE_SEVERITIES, JOB_OUTCOMES, JOB_STATUSES, type IssueKind, type IssueSeverity } from "../cloud/protocol.js"; @@ -9,8 +10,8 @@ import { generateRemoteSecret, secretNameFor } from "../cloud/remote-secret.js"; import { buildIssueReport, IssueReportError, reportIssue, type IssueReportInput } from "../cloud/report.js"; import { machineKeyPair, publicKeyOf, SealError } from "../cloud/seal.js"; import { afterOwnOptions, callTool, fetchCatalog, renderResult, toolInput, ToolInputError, toolName, toolUsage, type Catalog } from "../cloud/tools.js"; -import { ensureSecretFileMode, readEnvFile, removeEnvVar, upsertEnvVar } from "../env.js"; -import { printable } from "../util.js"; +import { readEnvFile, removeEnvVar } from "../env.js"; +import { errorMessage, printable } from "../util.js"; import { bool, CommandError, formatDuration, num, promptHidden, relativeTime, str, table, UsageError, type Ctx } from "./shared.js"; export const CLOUD_USAGE = `Usage: @@ -19,7 +20,8 @@ export const CLOUD_USAGE = `Usage: skillhook cloud disconnect [--keep-token] stop the link, forget the pairing, revoke the token skillhook cloud status skillhook cloud report "<title>" [--body TEXT|--body-file PATH|--body -] [--kind ${ISSUE_KINDS.join("|")}] [--severity ${ISSUE_SEVERITIES.join("|")}] [--job ID] [--delivery ID] [--skill NAME] [--email ADDRESS] [--no-diagnostics] [--dry-run] report a problem to the Skillhook team from this paired machine - skillhook cloud login [--url URL] [--key shc_…|-] check an organisation API key (Settings → API keys) and keep it in .env with its cloud; asks for it at a terminal + skillhook cloud login [--url URL] [--no-browser] sign in with the browser: approve the code on the cloud's page, and the organisation API key it makes is kept in .env with its cloud + skillhook cloud login --key [shc_…|-] [--url URL] keep a key from Settings → API keys instead (--key alone asks for it at a terminal, - reads stdin) skillhook cloud logout forget that key With the API key, the whole organisation (every command below reads or acts through Skillhook Cloud): @@ -148,27 +150,21 @@ async function cloudSubcommand(ctx: Ctx): Promise<number> { // The cloud named here (else the one logged in to before, else the machine's) is the one the key is checked against, // kept with it, and the only one it is ever sent to: changing cloud.url later moves neither the key nor this // machine's link. With two to choose from the key could be for either, and it must not be sent to the other; a - // machine that names no cloud has Skillhook Cloud's own, which is one of the two like any other. + // machine that names no cloud has Skillhook Cloud's own, which is one of the two like any other. Signing in with + // the browser asks the same: the person approves on that cloud, and the key it makes goes to that cloud only. const before = env[CLOUD_API_URL_ENV]?.trim() || readEnvFile(ctx.paths.envFile)[CLOUD_API_URL_ENV] || undefined; const machineCloud = resolveCloudUrl(env, config.cloud); - if (!url && before && trimTrailingSlashes(before) !== machineCloud) throw new UsageError(`The key kept here was for ${before}, and this machine's cloud is ${machineCloud}: name the one this key belongs to, skillhook cloud login --url https://…`, CLOUD_USAGE); + if (!url && before && trimTrailingSlashes(before) !== machineCloud) throw new UsageError(`The key kept here was for ${before}, and this machine's cloud is ${machineCloud}: name the one to log in to, skillhook cloud login --url https://…`, CLOUD_USAGE); + if (ctx.flags.key === undefined || ctx.flags.key === false) return await browserSignIn(ctx, url ?? before); let given = str(ctx.flags, "key"); if (!given && ctx.io.isTTY) given = await promptHidden("Organisation API key (dashboard → Settings → API keys): "); - if (!given) throw new UsageError("Give the organisation API key: --key shc_…, or --key - to read it from stdin (Settings → API keys on the dashboard); at a terminal, skillhook cloud login asks for it", CLOUD_USAGE); + if (!given) throw new UsageError("--key needs the organisation API key: --key shc_…, or --key - to read it from stdin (Settings → API keys on the dashboard); at a terminal, --key alone asks for it. Without --key, skillhook cloud login signs in with the browser", CLOUD_USAGE); const key = (given === "-" ? await readStdin(ctx) : given).trim(); if (!API_KEY_RE.test(key)) throw new UsageError("That is not an organisation API key: those are shc_ followed by letters, digits, - and _ (Settings → API keys on the dashboard)", CLOUD_USAGE); const client = fleetClient(env, config.cloud, { key, url: undefined }, { url: url ?? before }); const { data: me } = await client.get("/me", MeSchema); - upsertEnvVar(ctx.paths.envFile, CLOUD_API_KEY_ENV, key); - upsertEnvVar(ctx.paths.envFile, CLOUD_API_URL_ENV, client.url); - ensureSecretFileMode(ctx.paths.envFile); - const fromEnvironment = env[CLOUD_API_KEY_ENV]?.trim(); - const lines = [ - `Logged in to ${client.url} as ${me.organisation.name}: key "${me.key.name}" (${me.key.scopes.join(", ") || "no scopes"}), kept in ${ctx.paths.envFile} with its cloud (${CLOUD_API_KEY_ENV}, ${CLOUD_API_URL_ENV}).`, - ...(fromEnvironment && fromEnvironment !== key ? [`${CLOUD_API_KEY_ENV} is also set in this environment, and wins over .env.`] : []), - "Next: skillhook cloud overview (what needs attention), skillhook cloud tools (everything this key can do); agents get the same as the skillhook-cloud MCP server (skillhook mcp --cloud).", - ]; - ctx.print(printable(lines.join("\n")), { ok: true, url: client.url, organisation: me.organisation, key: me.key, role: me.role, env_file: ctx.paths.envFile }); + keepApiKey(ctx.paths, key, client.url); + ctx.print(printable(loggedIn(ctx, client.url, key, me).join("\n")), { ok: true, method: "key", url: client.url, organisation: me.organisation, key: me.key, role: me.role, env_file: ctx.paths.envFile }); return 0; } case "logout": { @@ -253,6 +249,50 @@ async function cloudSubcommand(ctx: Ctx): Promise<number> { } } +/** What a login says: whose key it kept and where, and what to run next. */ +function loggedIn(ctx: Ctx, url: string, key: string, me: Me): string[] { + const fromEnvironment = ctx.io.env[CLOUD_API_KEY_ENV]?.trim(); + return [ + `Logged in to ${url} as ${me.organisation.name}: key "${me.key.name}" (${me.key.scopes.join(", ") || "no scopes"}), kept in ${ctx.paths.envFile} with its cloud (${CLOUD_API_KEY_ENV}, ${CLOUD_API_URL_ENV}).`, + ...(fromEnvironment && fromEnvironment !== key ? [`${CLOUD_API_KEY_ENV} is also set in this environment, and wins over .env.`] : []), + "Next: skillhook cloud overview (what needs attention), skillhook cloud tools (everything this key can do); agents get the same as the skillhook-cloud MCP server (skillhook mcp --cloud).", + ]; +} + +/** + * `skillhook cloud login` without --key: signing in with the browser (src/cloud/login.ts). The link and the code go to + * stderr, so --json keeps stdout for the answer. The key the cloud hands over is new and that cloud's own, so it is kept + * at once, then read back with /me for what to say: a key lost to a failed read would be one nobody has. + */ +async function browserSignIn(ctx: Ctx, override: string | undefined): Promise<number> { + const env = ctx.io.env; + const config = ctx.config(); + if (truthy(env.CI)) throw new UsageError("Signing in with the browser needs a person at it. In CI, give the key: --key - with it on stdin, or SKILLHOOK_CLOUD_API_KEY (and SKILLHOOK_CLOUD_API_URL) in the environment", CLOUD_USAGE); + const url = resolveCloudUrl(env, config.cloud, override); + const { key, scope } = await browserLogin({ + url, + env, + sleep: ctx.io.sleep, + onPrompt: (prompt) => { + const opened = ctx.flags.browser !== false && Boolean(ctx.io.openUrl?.(prompt.url)); + const minutes = Math.max(1, Math.round(prompt.expiresInSeconds / 60)); + ctx.warn(printable(["", `Sign in to ${url} to log this computer in:`, ` ${prompt.url}`, "", `Check that the page shows this code: ${prompt.code}`, "", `${opened ? "Opened your browser (--no-browser to skip)." : "Open the link in a browser where you can sign in."} Waiting for your approval (the code expires in ${minutes} minutes; Ctrl-C stops)…`].join("\n"))); + }, + }); + if (!API_KEY_RE.test(key)) throw new CommandError(`${url} handed over something that is not an organisation API key; nothing was kept`); + keepApiKey(ctx.paths, key, url); + let me: Me; + try { + ({ data: me } = await fleetClient(env, config.cloud, { key, url }).get("/me", MeSchema)); + } catch (error) { + const problem = errorMessage(error); + ctx.print(printable(`Logged in to ${url}: the new key (${scope ?? "?"}) is kept in ${ctx.paths.envFile} with its cloud, but reading its details failed: ${problem}\nskillhook cloud status shows it; skillhook cloud overview uses it.`), { ok: true, method: "browser", url, scope, env_file: ctx.paths.envFile, warning: problem }); + return 0; + } + ctx.print(printable(loggedIn(ctx, url, key, me).join("\n")), { ok: true, method: "browser", url, organisation: me.organisation, key: me.key, role: me.role, env_file: ctx.paths.envFile }); + return 0; +} + /** A client with the organisation API key kept here (or in the environment), for the cloud it was checked against. */ function apiClient(ctx: Ctx): FleetClient { return fleetClient(ctx.io.env, ctx.config().cloud, storedApiCredentials(ctx.paths, ctx.io.env)); @@ -292,7 +332,7 @@ async function callCloudTool(ctx: Ctx, given: string, at: number, ended: boolean if (error instanceof ToolInputError) throw new UsageError(error.message, toolUsage(tool)); throw error; } - if (tool.allowed === false) throw new CommandError(`${tool.name} needs a key with the ${tool.scope ?? "?"} scope; this one has ${catalog.key?.scopes?.join(", ") || "?"}. Create one under Settings → API keys, then: skillhook cloud login`); + if (tool.allowed === false) throw new CommandError(`${tool.name} needs a key with the ${tool.scope ?? "?"} scope; this one has ${catalog.key?.scopes?.join(", ") || "?"}. Log in again and choose that access: ${loginCommand(client.url)}`); const result = await callTool(client, tool.name, input); ctx.print(renderResult(result), result); return 0; diff --git a/src/commands/main.ts b/src/commands/main.ts index 482a4bd..7f2d2e1 100644 --- a/src/commands/main.ts +++ b/src/commands/main.ts @@ -1,3 +1,4 @@ +import { openBrowser } from "../cloud/login.js"; import { ConfigError } from "../config.js"; import { SkillError } from "../skills.js"; import { errorMessage, jsonForTerminal, printable } from "../util.js"; @@ -74,7 +75,8 @@ Agents cloud connect --code XXXX-XXXX [--control] | disconnect | status Pair this machine with Skillhook Cloud (opt-in; https://skillhook.dev, docs/cloud.md) cloud report "<title>" [--body T|--body-file F|--body -] [--kind K] [--severity S] [--job ID] [--email E] [--no-diagnostics] [--dry-run] Report a problem to the Skillhook team from a paired machine, with its diagnostics (scrubbed) - cloud login [--url U] [--key shc_…|-] | logout Keep an organisation API key (asked for at a terminal; never the machine token) + cloud login [--url U] [--no-browser] | logout Sign in with the browser: the organisation API key it makes is kept here (never the machine token) + cloud login --key [shc_…|-] Keep a key from Settings → API keys instead (--key alone asks for it at a terminal) cloud overview | machines | jobs [--waiting] … | job <id> What needs a person across the organisation; its machines and jobs cloud tools [tool] | <tool> [args] [--param value]… Everything the dashboard shows and does, by name (answer_job, run_skill, get_stats, …) cloud secret <machine> <skill|NAME> [--force] A skill's secret generated on a machine, opened only here @@ -179,6 +181,7 @@ export function defaultIO(): CliIO { stderr: (text) => process.stderr.write(text), env: process.env, isTTY: Boolean(process.stdin.isTTY), + openUrl: (url) => openBrowser(url, process.env), }; } diff --git a/src/commands/shared.ts b/src/commands/shared.ts index 475235e..0e71b33 100644 --- a/src/commands/shared.ts +++ b/src/commands/shared.ts @@ -15,6 +15,10 @@ export interface CliIO { env: NodeJS.ProcessEnv; isTTY: boolean; stdin?: () => Promise<string>; + /** Opens a link in the person's browser (`cloud login`); false when it could not. Without it nothing is ever opened. */ + openUrl?: (url: string) => boolean; + /** How the CLI waits between polls (`cloud login`); tests pass a quick one. */ + sleep?: (ms: number) => Promise<void>; } export class UsageError extends Error { diff --git a/src/mcp.ts b/src/mcp.ts index 1a472b0..8243931 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -39,7 +39,7 @@ Jobs are directories under <home>/jobs/<id> with payload.json, prompt.md, stdout Every webhook the server received, including rejected, filtered and duplicate ones, is in the delivery log: list_deliveries and get_delivery show what arrived and why it did not run; replay_delivery (or replay_job) runs it again through the skill as it is now. While it runs, an agent reports progress and can ask a person a question through the job API (the job_* tools of \`skillhook mcp --job\`, or \`skillhook job …\`); such jobs show \`progress\`, \`question\` and \`answer\`. list_jobs with waiting: true lists what waits for a person (an open question, or a finished job with outcome needs_human); answer_job delivers the answer to the waiting agent, or starts a new job (trigger \`resume\`) that continues the agent's session with it. get_health, get_runners and get_stats answer whether the CLIs, their MCP servers and the skills are healthy and how the jobs went; get_config / update_config / restart_server change the running server. cloud_status tells whether the machine is paired with Skillhook Cloud and whether an organisation API key is kept here; pairing itself is only done by the person in a terminal (\`skillhook cloud connect --code …\`), never by an agent. cloud_report_issue sends a problem report to the Skillhook team from a paired machine, when the person asks for one. -These tools are this machine's own skillhook. The whole organisation (every machine's jobs, deliveries, alerts, health and stats, answering agents, running skills, hosted URLs: what the Skillhook Cloud dashboard shows and does) is the separate skillhook-cloud MCP server (\`skillhook mcp --cloud\`, part of the skillhook plugin), once the person ran \`skillhook cloud login\`.`; +These tools are this machine's own skillhook. The whole organisation (every machine's jobs, deliveries, alerts, health and stats, answering agents, running skills, hosted URLs: what the Skillhook Cloud dashboard shows and does) is the separate skillhook-cloud MCP server (\`skillhook mcp --cloud\`, part of the skillhook plugin), once signed in: its skillhook_cloud_setup tool signs in with the browser (the person approves a code there), as \`skillhook cloud login\` does in a terminal.`; type ToolResult = { content: { type: "text"; text: string }[]; structuredContent?: Record<string, unknown>; isError?: boolean }; diff --git a/src/test-support/fake-cloud.ts b/src/test-support/fake-cloud.ts index f66626f..14ace71 100644 --- a/src/test-support/fake-cloud.ts +++ b/src/test-support/fake-cloud.ts @@ -3,7 +3,7 @@ // protocol schemas and keeps what it received for assertions. The public API (`/api/v1`): answers the reads of the // API-key commands for one organisation API key, a small tool catalogue (`/tools`, with the scope each tool needs and // what `apiScopes` allows) and its calls, and a secret sealed to the requester's key, with RFC 9457 problems like the -// real one. +// real one. Browser sign-ins (`/api/auth/device/*`, RFC 8628): each poll answered from a script, then `apiKey` once. import { createServer, type IncomingMessage, type Server } from "node:http"; import { CommandResultSchema, IssueReportRequestSchema, PairRequestSchema, SyncRequestSchema, type Command, type CommandResult, type Hints, type IngressAck, type IngressItem, type IssueReportRequest, type PairRequest, type SyncRequest } from "../cloud/protocol.js"; import { sealForRecipient } from "../cloud/seal.js"; @@ -92,6 +92,12 @@ export class FakeCloud { apiScopes: Scope[] = ["fleet:read"]; /** `missing` answers `/tools` like a cloud from before the catalogue (404). */ catalogMode: "ok" | "missing" = "ok"; + /** Browser sign-ins started (`POST /api/auth/device/code`): the name the CLI gave and the bearer it sent (none). */ + readonly signIns: { deviceCode: string; userCode: string; clientName: unknown; authorization: string | undefined; polls: number; claimed: boolean }[] = []; + /** How the next polls are answered, one each, before the sign-in is approved: then `apiKey` with `apiScopes`. */ + readonly signInScript: ("pending" | "slow_down" | "500" | "deny" | "expire")[] = []; + /** `missing` answers like a cloud from before browser sign-in (404). */ + signInMode: "ok" | "missing" = "ok"; /** Every tool call: its name and input. */ readonly toolCalls: { name: string; input: Record<string, unknown> }[] = []; /** Secret requests (`POST /machines/<m>/secrets`): their body; each is sealed on the second claim, then `exists`. */ @@ -232,6 +238,7 @@ export class FakeCloud { } return this.reply(res, 200, this.fileIssue(parsed.data)); } + if (url.pathname.startsWith("/api/auth/device/")) return await this.handleSignIn(req, res, url); if (url.pathname.startsWith("/api/v1/")) return await this.handleApi(req, res, url); if (req.method === "POST" && url.pathname === "/api/agent/sync") { this.authHeaders.push(req.headers.authorization); @@ -309,6 +316,41 @@ export class FakeCloud { return answer; } + /** RFC 8628: the codes, then each poll as the script says, then the key once and `invalid_grant` after that. */ + private async handleSignIn(req: IncomingMessage, res: import("node:http").ServerResponse, url: URL): Promise<void> { + if (this.signInMode === "missing" || req.method !== "POST") return this.reply(res, 404, "<!doctype html><title>404" as unknown as Record); + const body = (await readJson(req)) as Record; + const oauth = (error: string, description: string, status = 400) => this.reply(res, status, { error, error_description: description, request_id: `req_signin_${this.signIns.length}` }); + if (url.pathname === "/api/auth/device/code") { + const n = this.signIns.length + 1; + const signIn = { deviceCode: `placeholder-device-code-${n}-0123456789abcdef`, userCode: "WXYZ-2345", clientName: body.client_name, authorization: req.headers.authorization, polls: 0, claimed: false }; + this.signIns.push(signIn); + this.notify(); + return this.reply(res, 200, { device_code: signIn.deviceCode, user_code: signIn.userCode, verification_uri: `${this.url}/activate`, verification_uri_complete: `${this.url}/activate?user_code=${signIn.userCode}`, expires_in: 600, interval: 1 }); + } + if (url.pathname !== "/api/auth/device/token") return this.reply(res, 404, { ok: false, error: "not_found", message: "no such route" }); + const signIn = this.signIns.find((s) => s.deviceCode === body.device_code); + if (!signIn) return oauth("invalid_grant", "No sign-in waits for this device code."); + signIn.polls++; + this.notify(); + if (signIn.claimed) return oauth("invalid_grant", "No sign-in waits for this device code: it was already used, or never started."); + switch (this.signInScript.shift()) { + case "pending": + return oauth("authorization_pending", "Waiting for the sign-in to be approved in the browser."); + case "slow_down": + return oauth("slow_down", "Polling too often from this address; wait five seconds longer between polls."); + case "500": + return this.reply(res, 500, { ok: false, error: "server_error", message: "try again" }); + case "deny": + return oauth("access_denied", "The sign-in was cancelled in the browser."); + case "expire": + return oauth("expired_token", "The sign-in expired before it was approved; start a new one."); + default: + signIn.claimed = true; + return this.reply(res, 200, { access_token: this.apiKey, token_type: "Bearer", scope: this.apiScopes.join(" ") }); + } + } + /** RFC 9457 problem details, as the public API answers errors. */ private problem(res: import("node:http").ServerResponse, status: number, code: string, detail: string, headers: Record = {}): void { this.reply(res, status, { type: `${this.url}/docs/api#${code}`, title: code.replaceAll("_", " "), status, code, detail, request_id: `req_${this.apiRequests.length}` }, { "content-type": "application/problem+json", ...headers });