Skip to content

Add hyp remote mint for CI enrollment tokens - #969

Merged
platypii merged 5 commits into
masterfrom
feat/ci-mint-token
Aug 20, 2026
Merged

Add hyp remote mint for CI enrollment tokens#969
platypii merged 5 commits into
masterfrom
feat/ci-mint-token

Conversation

@platypii

@platypiiplatypii commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Adds hyp remote mint [name] [--label <label>] [--expires-days <n>]: mints a long-lived CI enrollment token from the user's logged-in OIDC session and prints it once, for storing in CI secrets. Design is LLP 0298 (in this PR): CI runs enroll with the existing hyp join path and all share one gateway; each run exchanges the token for its own short-lived JWT, so the shared secret never rotates.

The CI recipe is existing commands only: hyp join <url> <token> --no-daemon, hyp daemon run --foreground &, and hyp sync --yes as the teardown flush.

Server prerequisite (hypaware-server): POST /v1/identity/mint and accepting the minted token on bootstrap against an existing gateway with multiple concurrent JWTs. Until that ships, the command reports HTTP 404 with a clear message.

Deferred, filed as #968: capturing the CI run id into rows for per-run attribution.

Tests: 9 new tests in test/core/remote-mint-command.test.js; npm test otherwise green except the pre-existing top-level-help pin failure in command-dispatch.test.js (fails on master too); typecheck clean.

@platypiiplatypii added the neutral:adopt Foreign PR adopted into neutral's reconcile scope label Aug 20, 2026
@philcunliffephilcunliffe added the neutral:adopted Adoption completion record: merged while carrying neutral:adopt (LLP 0031) label Aug 20, 2026
…I reference
Two review findings on `hyp remote mint`:
- `--expires-days` was declared `{ type: 'string' }` and then re-validated by
hand in `runRemoteMint` with `Number()` + `Number.isInteger`, duplicating the
coercion the arg codec already does for `integer` + `minimum`. LLP 0293 makes
the schema entry the whole surface of a command, so the bound and the 365-day
default now live there. Side effect: a bad value is refused at the gate and
prints the usage line, like every other argument error.
- `docs/CLI_REFERENCE.md` enumerates every other `hyp remote *` subcommand and
had no entry for the new one. Added it, with the LLP 0298 D4 CI recipe.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@philcunliffe

philcunliffe commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Review of c8458dea - hyp remote mint

Verdict: sound design, four findings fixed. The command wires into the existing per-target credential machinery rather than inventing a parallel path, and LLP 0298's load-bearing claims check out against the corpus. Two of the findings were real defects that would have surfaced only in CI or only for an unlucky user; two were contract/documentation gaps. One note deferred with a reason.

Findings

1. [high] The printed CI recipe pastes the registered target URL into hyp join. FIXED.
src/core/cli/remote_commands.js:1120 printed hyp join ${entry.url} .... But a target need not be a bare origin: deriveMcpEndpoint carries explicit back-compat for targets registered as <base>/v1/mcp (LLP 0084 D2), and docs/CLI_REFERENCE.md:1207 shows exactly that shape as its hyp remote add example. For such a user the failure is silent locally and only appears in CI: minting succeeds, because deriveIdentityBase (src/core/remote/credentials.js:73-79) reduces to .origin; but hyp join stores its url argument verbatim as the central sink url (src/core/commands/central.js:100), and central's IdentityClient resolves the suffix relative to it (hypaware-core/plugins-workspace/central/src/identity_client.js:138,379), giving https://host/v1/mcp/v1/identity/bootstrap - a 404 on every run.
Fix: the recipe now prints new URL(entry.url).origin (the same reduction the mint request already performs), with a regression test that mints against a /v1/mcp-registered target and asserts the recipe carries the base.

2. [medium] A 401 that survives the refresh reports only "session expired". FIXED.
remote_commands.js:1070-1074 routed every surviving 401 through describeAuthRejection, which for a refreshable oidc session unconditionally reports expiry (src/core/remote/credentials.js:617-620). This repo's own reports plane documents that hypaware-server answers 401, not 403, to a live session lacking a scope, and report_commands.js:522-533 special-cases it for exactly that reason (LLP 0155 #write-401). So the 403 branch at remote_commands.js:1086 never fires for the "not permitted to mint" case: a user who lacks the permission burns a forced refresh of a one-time-use refresh token, is told the session expired, re-logs in successfully, and gets the identical message forever.
Fix: a refreshable session's surviving 401 now names both causes, mirroring the report-write wording. Static tokens and env overrides still fall through to describeAuthRejection unchanged (existing test still green).

3. [medium] --expires-days bypassed the one argument-validation contract. FIXED.
src/core/cli/command_args.js:180 declared 'expires-days': { type: 'string' } and remote_commands.js:1003-1008 then re-implemented the coercion by hand. The codec already does exactly this for { type: 'integer', minimum: 1 } (src/core/cli/verb_codec.js:380-388), down to the identical wording, and applyDefaults already applies a declared default. LLP 0293 #usage-agreement makes the schema entry the whole surface of one command; hand-rolling it put the 365-day default in two places (runner plus the help text at core_commands.js:702) and skipped the usage: line every other argument error prints.
Fix: { type: 'integer', minimum: 1, default: 365 }; the runner reads the coerced number. The test now covers soon, 0, -5, 1.5 and asserts the usage line.

4. [medium] A new visible command was missing from the CLI reference. FIXED.
docs/CLI_REFERENCE.md states it "documents the visible commands shipped with HypAware" and enumerates remote add / login / list / remove; remote mint was absent, and nothing pins that file against the registry, so it drifts silently. LLP 0298 D4 #recipe also calls the CI recipe "documented" while nothing user-facing carried it.
Fix: added a hyp remote mint section with the flags and the setup/teardown recipe.

Deferred (no change made)

5. [low] The secret shares stdout with human prose.
remote_commands.js:1139-1144 writes the token, the "store it in your CI secrets now" line, and the four-line recipe all to ctx.stdout, so the natural automation - hyp remote mint > ci.token, or piping into gh secret set - stores five lines of banner around the secret, and there is no token-only mode. The advisory lines would be better on stderr, as the first-sync consent block in the same file already does (~line 289). Left alone: it is a UX decision the author's tests deliberately pin, and LLP 0298 D3 only says "prints the token once". Worth a follow-up, ideally alongside a --json shape.

Checked and correct

  • Every command and flag the recipe names exists: hyp join <url> [token] ... [--no-daemon] (core_commands.js:341), hyp daemon run --foreground (core_commands.js:600), hyp sync [instance] [--yes] (core_commands.js:635).
  • LLP 0298 D1's exemption claim holds. The first-sync hold marker is written only on the enrolling-login path (remote_commands.js:275), never by hyp join, consistent with LLP 0101 #which - so the CI teardown is an ordinary --yes sync, not the "--yes refuses while a hold is live" case LLP 0101 #no-release forbids.
  • @ref targets resolve: LLP 0298#mint exists (D3), LLP 0062#bare-remote exists as an <a id> anchor.
  • readConfiguredRemotes layers BUILTIN_REMOTES underneath the user's query.remotes (remote_commands.js:1239), so bare hyp remote mint resolves the shipped default rather than reporting "unknown remote target 'hyperparam'".
  • attachWithRefresh / describeRefreshError usage mirrors reportsRequest, including deliberately passing the pre-refresh resolved. Boot profile and group registration match the other remote subcommands.
  • Response handling is defensive in the right places: 404/403 are named before the body is read, an unreadable body falls through to the shape check, a 200 carrying no token is an error rather than a blank print, and unconsumed bodies on the error paths are harmless because bin/hypaware.js:79 exits the process.
  • Style: no semicolons, no em dashes in any changed file.

Local checks

npm test: 5024 pass / 1 fail. The single failure is test/core/hyparquet-floor-pin.test.js:167, caused by this sandbox's shared node_modules carrying a nested hypgrep/node_modules/hyparquet@1.27.1 below the LLP 0222 floor. It reproduces at the pre-PR SHA and this PR touches no dependency. npm run typecheck: clean.

One stale note in the PR body: it reports a "pre-existing top-level-help pin failure in command-dispatch.test.js" that "fails on master too". That test passes here at every SHA in this round, so the caveat looks out of date.

Fixes pushed as ea645304 and b4c9f525 on feat/ci-mint-token. CI on the PR is the authority.

testand others added 2 commits August 20, 2026 16:27
…1 names both causes
Two more review findings on `hyp remote mint`:
- The printed recipe pasted the registered target URL into `hyp join`. A target
may legitimately be registered as `<base>/v1/mcp` (LLP 0084 D2, and the shape
docs/CLI_REFERENCE.md shows for `hyp remote add`), and `hyp join` stores its
url argument verbatim as the central sink url, which central's IdentityClient
then resolves `/v1/identity/bootstrap` against. Such a user would see minting
succeed locally (deriveIdentityBase already reduces to the origin) and then
every CI run 404 on bootstrap. The recipe now prints the origin.
- A 401 that survived the one-shot refresh reported only "session expired". This
server answers 401, not 403, to a live session lacking a scope (LLP 0155
#write-401), so the 403 branch never fires for that case and a user who is not
permitted to mint gets sent round the re-login loop forever. A refreshable
session's surviving 401 now names both causes, as report writes already do.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…stops putting the CI secret in argv
Three findings from the round-2 review of b4c9f52.
The printed expiry read `expires_at` only when it was a string, but the
identity plane sends a Unix epoch-second (`/mint` is the sibling of
`/token`), so a real server's expiry was dropped from a token that is shown
exactly once. It now reuses `expiryTimestamp`, which is exported for that,
and an unreadable value drops the detail rather than the token.
The printed recipe passed a 365-day, non-rotating, fleet-shared secret to
`hyp join` as an argv positional, which `hyp join`'s own help and
docs/CLI_REFERENCE.md both warn against: on a runner that is `ps` and any
`set -x` trace. It now pipes the token on stdin, the path `runJoin` already
reads on a non-TTY.
The token also shared stdout with six lines of narration, so
`TOKEN=$(hyp remote mint)` captured the banner too; re-minting after that
fails creates a second gateway row (LLP 0298 D2). stdout is now the token
alone and every advisory line moved to stderr, as the first-sync consent
block in the same file already does.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
Contributor

Review round 2 of b4c9f525 - hyp remote mint

Verdict: the four round-1 fixes all landed and hold up. Three further findings, all fixed; one noted and deliberately not fixed. Round 1 read the command against the credential machinery; this round read it against the wire it talks to and the shell it prints into, which is where the remaining defects were. Fixes pushed as 2d38d94e.

Round-1 fixes verified present at b4c9f525

Not taken on trust - each was diffed in the tree, not inferred from a green suite:

  • The recipe prints new URL(entry.url).origin (remote_commands.js:1024), pinned by the /v1/mcp regression test.
  • A surviving 401 on a refreshable session names both expiry and missing permission (remote_commands.js:1085-1092), wording matched against report_commands.js:525-533.
  • 'expires-days': { type: 'integer', minimum: 1, default: 365 } (command_args.js:184) with the runner reading the coerced number and no second check.
  • docs/CLI_REFERENCE.md carries a hyp remote mint section.

Findings

1. [medium] The printed expiry was dropped for every real server response. FIXED.
remote_commands.js:1136 read expires_at only when typeof === 'string'. But the identity plane's wire value is a Unix epoch-second, not an ISO string - expiryTimestamp at src/core/remote/identity_client.js:260-284 exists precisely to normalize it ("The wire value is a Unix epoch-second (the JWT exp)"), and the branch's own /token stub in the new test file uses expires_at: 32503680000. LLP 0298 D3 calls /mint "the sibling of /token and /refresh", so it will send the sibling shape. Verified against the branch: a reply of { token, gateway_id, expires_at: 1789000000 } exited 0 and printed minted CI token for 'prod' (gateway gw) with the expiry silently gone. The token is shown once, so the user could never learn when their CI credential dies.
Fix: expiryTimestamp is now exported and reused rather than re-deciding the shape here. Because this is display only and the line below it carries the secret, an unreadable value drops the detail instead of throwing. Two tests: an epoch-second renders, an unparseable value still prints the token.

2. [medium] The recipe put a long-lived shared secret in argv. FIXED.
remote_commands.js:1142 printed hyp join <base> "$HYP_CI_TOKEN" --no-daemon. This contradicts three places in the repo that all say the same thing: docs/CLI_REFERENCE.md:851-852 ("Prefer --token-file or standard input. A positional token can appear in shell history and process listings"), hyp join's registered help (core_commands.js:342), and runJoin's docstring (src/core/commands/central.js:37-41). The warning is more load-bearing here than for the MDM case it was written for: this token is 365-day, never-rotating, and shared by every run of the pipeline (LLP 0298 D2), and a CI runner is exactly where set -x traces and a readable ps table live. Nothing about the recipe needed argv: runJoin already reads a non-TTY stdin at central.js:80-85.
Fix: the recipe is now printf '%s' "$HYP_CI_TOKEN" | hyp join <base> --no-daemon, in the printed output and in docs/CLI_REFERENCE.md, with a test asserting the pipe form and asserting the argv form is absent.

3. [low] The token shared stdout with six lines of narration. FIXED.
remote_commands.js:1138-1144 wrote the summary, the token, the warning and the three recipe lines all to ctx.stdout. Round 1 saw this and deferred it as a UX nit; round 2 found the concrete failure it causes. TOKEN=$(hyp remote mint prod) and hyp remote mint prod > ci.token are the natural ways to move a printed secret, and both capture the banner. The stored value is then an invalid token, hyp join fails later in CI, and since the token is never re-shown the only recovery is to mint again - which per LLP 0298 D2 creates a second gateway row, the exact outcome D2 exists to prevent. That is a data consequence, not a formatting preference.
Fix: stdout is the token and nothing else; every advisory line moved to stderr, following the first-sync consent block in the same file (remote_commands.js:~289), which already prints guidance to stderr for the same reason. The first test now asserts stdout === "ci-tok-1\n" exactly.

Noted, deliberately not fixed

4. [low] --expires-days has a minimum but no maximum.
command_args.js:184 declares { type: 'integer', minimum: 1, default: 365 }, and Number.isInteger accepts exponent notation, so --expires-days 1e30 passes the gate and serializes as {"expires_days":1e+30} - an effectively immortal shared secret, and a value a strict server integer parser will reject with an opaque 4xx instead of the usage line the schema promises.
Left alone on purpose. VerbInputProperty (hypaware-plugin-kernel-types.d.ts:1647-1656) has no maximum, so fixing it in the schema means extending the published plugin kernel contract, and choosing the cap is a design decision LLP 0298 D3 did not settle: it says the default is 365 days "overridable by flag" and names no ceiling, while the server is the authority on expiry policy anyway. Widening a kernel type and inventing a bound inside a review round is the wrong place for both. Worth a follow-up that adds maximum to the codec alongside whatever cap the server settles on.

Checked and correct

  • hyp sync --yes in the teardown is not blocked by the LLP 0101 first-sync hold: the marker is written only on the attended enrolling-login fork (remote_commands.js:814, "hyp join and re-logins write nothing"), so LLP 0298 D1's exemption claim holds.
  • The resolve to attachWithRefresh to 401/403/404 ladder mirrors reportsRequest faithfully - no missing await, inverted condition, or null-deref. isRefreshable(resolved) deliberately tests the pre-refresh credential, as the reports path does.
  • --label "" forwards an empty label harmlessly; extra positionals are refused with usage and exit 2; bare hyp remote mint resolves the built-in hyperparam default via readConfiguredRemotes (remote_commands.js:1257).
  • No fetch timeout, consistent with report_commands.js, and unconsumed error bodies are harmless because bin/hypaware.js:79 exits the process.
  • Flag naming is kebab-case throughout CORE_COMMAND_ARGS, matching remote login's token-file; resolveFlag resolves it.
  • @ref anchors resolve: {#mint} at llp/0298-...:54, <a id="bare-remote"> at llp/0062-...:43.
  • Registration matches its siblings (no category/audience, inheriting the remote group).
  • Style: no semicolons, no em dashes in any changed file.

Local checks

At 2d38d94e: npm test 5027 pass / 1 fail, the one failure being test/core/hyparquet-floor-pin.test.js:167, a known sandbox artifact (a nested hypgrep/node_modules/hyparquet@1.27.1 below the LLP 0222 floor) that reproduces at every SHA and is untouched by this PR. npm run typecheck clean. The new test/core/remote-mint-command.test.js is 14/14. GitHub CI was green on b4c9f525 across test (22/24), typecheck (22/24), LLP, cross-branch-numbers and duplicate-numbers; CI on 2d38d94e is the authority for the pushed fixes.

Two stale notes, neither blocking: the PR body still reports a "pre-existing top-level-help pin failure in command-dispatch.test.js" that does not reproduce at any SHA in this round (33/33 pass); and round 1's rationale cited docs/CLI_REFERENCE.md:1207 as showing a /v1/mcp-shaped remote add example, but that line actually reads https://hyp.example.com/mcp, which deriveMcpEndpoint does not back-compat (it honors only paths ending /v1/mcp), so that documented example yields .../mcp/v1/mcp. Pre-existing and out of scope here, but it is a real docs bug worth a separate fix - and the round-1 conclusion it was cited for is correct regardless, since a /v1/mcp target is independently supported.

@philcunliffe

Copy link
Copy Markdown
Contributor

Triage at head 2d38d94e after the review-round cap: every finding still open is non-blocking, so this PR is clear to ship as-is and the open items are deferred to #972.

Verified at the current head before deciding:

  • All three round-2 fixes are present in the tree: the expiry is normalized via expiryTimestamp (src/core/cli/remote_commands.js, readExpiry), the printed recipe pipes the token on stdin instead of argv, and stdout carries the token alone with every advisory line on stderr. test/core/remote-mint-command.test.js is 14/14 locally.
  • Remaining open items, all preference/deferred-design, none a production risk in this PR's shipped behavior:
    1. --expires-days lacks a maximum (src/core/cli/command_args.js:184): fixing it means extending the published kernel contract and choosing a cap the server has not settled; the default path is safe and the server is the expiry authority.
    2. Stale caveat in the PR body about a command-dispatch.test.js failure that no longer reproduces.
    3. A pre-existing docs/CLI_REFERENCE.md:1207 example bug that predates this PR.

All three are enumerated with evidence in #972.

@philcunliffephilcunliffe added the neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030) label Aug 20, 2026
@platypii
platypii added this pull request to the merge queueAug 20, 2026
Merged via the queue into master with commit fc3d08dAug 20, 2026
8 checks passed
@platypii
platypii deleted the feat/ci-mint-token branch August 20, 2026 18:29
@platypiiplatypii mentioned this pull request Aug 21, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

neutral:adoptForeign PR adopted into neutral's reconcile scopeneutral:adoptedAdoption completion record: merged while carrying neutral:adopt (LLP 0031)neutral:approvedneutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@platypii@philcunliffe