Skip to content

hyp remote login: auto-provision the central sink (LLP 0063) - #237

Merged
platypii merged 2 commits into
masterfrom
hyp-enroll
Jul 5, 2026
Merged

hyp remote login: auto-provision the central sink (LLP 0063)#237
platypii merged 2 commits into
masterfrom
hyp-enroll

Conversation

@platypii

Copy link
Copy Markdown
Contributor

Implements the client half of LLP 0063 — an enrolling hyp remote login now forwards logs from one command. When the server mints a gateway credential and no @hypaware/central sink targets it, login provisions one, seeds the login-minted identity into it, and finishes exactly the way hyp join does.

What changed

  • enrollCentralSink (core_commands.js) — writes join's sink block minus the bootstrap token to the central-seed layer, inherits join's hyp join token silently ignored when a stale active config slot shadows seed.json #139 reset, seeds the identity (LLP 0061), and runs join's runDaemonInstall (D2/D5).
  • runBrowserLogin (remote_commands.js) — D4 pre-auth exclusivity gate (one server per machine), D3 pre-auth consent notice, --no-forward (query-only) and --no-daemon flags, and the provisioned … forwarding to … message replacing the old dead-end note plus the interim run 'hyp attach <client>' hint.
  • readCentralSinkOrigins (gateway_seed.js) — shared "which servers am I connected to" helper for the gate.

Two correctness points caught against the real code

  • The login target is <origin>/mcp, but the forward sink builds ingest URLs by joining /v1/ingest/... onto config.url — so the sink is provisioned at the origin, not the query target.
  • validateCentralConfigrequires an identity object (runs in the daemon's apply path). The block carries identity: {} (empty — the seeded identity.json is the credential); omitting it would have made the daemon silently reject the sink.

Follow-ups (named in the LLP, not in this PR)

  • D4 seed-time recheck is a plain re-resolve, not yet under the LLP 0065 lock.
  • The D2 attach cascade needs server LLP 0043 (org default config) to fire for login enrollees.
  • hyp remote logout and the README connection-levels table.

@ref annotations on D2–D5. Lint, typecheck, and the full suite (1817 pass / 0 fail) are green.

An enrolling `hyp remote login` now forwards logs from one command. When the
server mints a gateway credential and no `@hypaware/central` sink targets it,
login provisions one — join's sink block, minus the bootstrap token, at the
server origin — into the central-seed layer, seeds the login-minted identity
into it, and finishes with join's daemon install.
- D2/D5: enrollCentralSink writes the sink, seeds the identity, installs the
daemon (join parity). --no-daemon provisions without the service install.
- D3: pre-auth consent notice replaces the y/n prompt; --no-forward signs in
for queries only, provisioning nothing.
- D4: one server per machine; a login to a different server while enrolled is
rejected before the browser opens.
The provisioned block carries `identity: {}` (the central validator requires an
identity object; the seeded identity.json is the credential) at the server
origin, not the `/mcp` query target the sink would otherwise misuse when it
builds ingest URLs.
// switching is 'hyp leave' then log in again, never one command.
// @ref LLP 0063#d4 [implements]: pre-auth exclusivity gate — reject a login to a new server while enrolled elsewhere
const targetOrigin = safeOrigin(entry.url)
const connectedOrigins = await readCentralSinkOrigins({ stateDir, configPath: localConfigPath(ctx) })

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[correctness · CONFIRMED] A hand-authored central sink in the local layer permanently blocks login to a different server, and hyp leave cannot clear it.

readCentralSinkOrigins reads the effective (local+central) config, so a @hypaware/central sink in the user-owned local layer counts as a "connected origin" and the D4 gate rejects hyp remote login <other> with "disconnect first (hyp leave)". But runLeave defines "connected" as the central layer only and refuses to touch the local layer — it prints "not connected - nothing to do" and leaves the sink in place. The advised remedy does nothing; login stays blocked until the user hand-edits the config file.

The gate keys on the wrong notion of "connected." It should key on enrollment (the central layer), consistent with hyp leave, not on any central sink in the merged config.

await fs.mkdir(path.dirname(seedPath), { recursive: true })
const tmp = `${seedPath}.tmp.${process.pid}.${Date.now()}`
await fs.writeFile(tmp, JSON.stringify(seed, null, 2) + '\n', { mode: 0o600 })
await fs.rename(tmp, seedPath)

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[correctness · CONFIRMED] Partial-failure ordering: the seed sink is committed before its identity is seeded, leaving a broken sink + a misleading remediation.

The seed block (identity: {}, no bootstrap token) is fs.renamed and resetCentralLayerToSeedd here, beforeawait seedLoginGateway(...) at :3427 writes identity.json. If seedLoginGateway throws (EACCES/EIO/lock), the caller catches it, prints "enrollment failed", returns 1 — but the sink is already durably committed and nothing rolls it back. On the next daemon run, IdentityClient.acquire() finds no identity.json and no bootstrap_token and throws identity bootstrap failed ... Run `hyp join <central-url> <token>` — a bootstrap token the login user does not have. Seed the identity first (or roll back the seed on failure).

Comment threadsrc/core/cli/remote_commands.js Outdated
// unseeded (LLP 0063 D3) - declining enrollment, not just forwarding.
if (!session.gateway) return 0
if (noForward) {
ctx.stdout.write('note: --no-forward - signed in for queries only; this machine is not enrolled and will not forward logs\n')

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[correctness · CONFIRMED] --no-forward on an already-enrolled machine prints a false message and silently skips re-seeding.

This if (noForward) early-return fires regardless of alreadyEnrolled, before the seed() path. On a machine that already has a matching @hypaware/central sink (so it is enrolled and forwarding), hyp remote login <same> --no-forward prints "this machine is not enrolled and will not forward logs" — factually wrong; the daemon keeps forwarding — and discards the freshly minted gateway instead of refreshing the existing identity. Gate this branch on !alreadyEnrolled, and re-seed (or say nothing misleading) when already enrolled.

await fs.rename(tmp, seedPath)
// Inherit join's #139 fix: supersede a stale applied slot so the fresh
// enrollment is honored rather than silently shadowed.
resetCentralLayerToSeed(stateRoot)

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[correctness · PLAUSIBLE] resetCentralLayerToSeed here unconditionally deletes an applied central-config layer.

resetCentralLayerToSeed removes the active-slot pointer, both config.{a,b}.json slots, their etags, and state.json — not just the seed. Reaching enrollCentralSink requires connectedOrigins === [], but an applied slot can still exist while contributing zero origins: a capture-only org config with no central sink, a hand-edited slot, or a central sink whose url is present-but-unparseable (originOf → null, so both scanners skip it). In those cases this wipes the applied org config (datasets, attached clients, other plugins) down to the bare login seed until the daemon re-pulls. Consider only resetting when a stale matching slot exists.

ctx.stdout.write("nothing is captured yet - run 'hyp attach <client>' to start\n")
if (noDaemon) {
ctx.stdout.write("daemon install skipped (--no-daemon); run 'hyp daemon install' to finish enrolling\n")
} else if (result.daemonCode !== 0) {

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[correctness · PLAUSIBLE] Exit-code regression: an enrolling login now returns the daemon-install code where hyp remote login previously always returned 0.

Before this PR, session.gateway + no sink printed a note and return 0 — sign-in always succeeded. Now, on result.daemonCode !== 0, the command returns that non-zero code even though the browser sign-in, the query session, and the sink were all established. Scripts/CI that gate on hyp remote login exit 0 to mean "query session ready" break whenever the background service install fails. The exit code now conflates query-login success with enrollment success — consider keeping 0 (session established) and surfacing the daemon failure as a warning, or document the new contract.

// credential (LLP 0063 D2), so the block carries an empty identity, not a
// token.
/** @type {HypAwareV2Config} */
const seed = {

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[correctness/altitude · PLAUSIBLE] Provisioning writes the seed without the validateConfig gate runJoin runs.

runJoin builds the same block and runs buildKnownPluginsForCtx + validateConfig, refusing to write (exit 1, pointed [errorKind] message) if it is malformed or @hypaware/central is missing from the catalog. enrollCentralSink writes straight to disk, reports "provisioned … forwarding", and defers any defect to a silent daemon config-apply rejection at boot with no tie back to the login command. Route login provisioning through the same validated seed-write as join so the "join parity" claim holds and errors surface at the CLI.

await seedLoginGateway({ stateDir: stateRoot, configPath: localPath, targetUrl: url, gateway })

if (noDaemon) return { provisioned: true, daemonCode: 0 }
const daemonCode = await runDaemonInstall([], ctx)

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[correctness · PLAUSIBLE, acknowledged follow-up] No cross-process lock: concurrent same-server logins race runDaemonInstall.

Two hyp remote login <same> on a fresh box both read seeded.length === 0, both enter here, and both run runDaemonInstall, racing systemctl --user daemon-reload+restart (Linux) or launchctl bootout+bootstrap+kickstart (macOS). The async bootout racing a second bootstrap fails with "service already loaded"/"no such process"; the loser then hits daemonCode !== 0, prints "daemon install did not finish", and exits non-zero on a machine that is actually enrolled. The doc/comment already names the LLP 0065 lock as a follow-up — noting the concrete user-visible symptom.

Comment threadsrc/core/cli/core_commands.js Outdated
// Seed the login-minted gateway into the freshly written sink's identity
// (LLP 0061). `seedLoginGateway` resolves sinks from the effective config,
// which now includes this central seed, so it finds and seeds exactly it.
await seedLoginGateway({ stateDir: stateRoot, configPath: localPath, targetUrl: url, gateway })

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[correctness · PLAUSIBLE] Enroll path drops the LLP 0061 D4 "never silent about a displaced identity" note.

seedLoginGateway returns the replaced identity so callers can report what a seed overwrote; the re-seed branch in runBrowserLogin prints that note, but here the return value is discarded. If a stale identity.json lingers on a box being freshly enrolled (connectedOrigins === [] but a prior identity file remains from a partial teardown), the fresh enrollment silently overwrites it — the never-silent invariant is not re-established on the provisioning path. Surface replaced from enrollCentralSink the way the re-seed branch does.

Comment threadsrc/core/cli/core_commands.js Outdated
* server-owned org), not something the human typed, so provenance — not who
* ran the command — picks the layer.
*
* @param {{ ctx: CommandRunContext, url: string, gateway: import('../remote/types.d.ts').LoginGatewayCredential, noDaemon: boolean }} args

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[conventions · CONFIRMED] Inline import(...) type and .d.ts specifier violate AGENTS.md.

AGENTS.md (Code Style): "Never use inline import('...') types. Declare type imports at the top of the file with @import JSDoc comments, then reference the bare names." And: type-import specifiers are repo-root-anchored .js paths, not .d.ts. This line uses both an inline import('../remote/types.d.ts') and the .d.ts extension. Add LoginGatewayCredential to the file's top @import block and reference the bare name.

const login = /** @type {any} */ (async () => gatewaySession())

const code = await runRemoteLogin(['prod'], ctx, { login })
// --no-daemon keeps the test off the real launchd/systemd install.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[test-coverage] The default (non---no-daemon) enrollment path is entirely untested.

Every provisioning test passes --no-daemon to stay off the real launchd/systemd install, and enrollCentralSink calls runDaemonInstall([], ctx) directly with no injection seam. So the real user path — provision and install — plus the result.daemonCode !== 0 branch (its message and exit-code propagation, see the :432 finding) have zero coverage. A regression that reports success on a failed install, or mis-propagates the code, would ship undetected. Consider a seam for the daemon-install step so the non---no-daemon path can be exercised.

@platypii

Copy link
Copy Markdown
ContributorAuthor

Independent review — /code-review high (8 finder angles + verification)

10 findings posted inline (correctness first). Verdicts from independent verifier agents:

Confirmed correctness:

  1. Local-layer central sink permanently blocks login to a different server; hyp leave can't clear it (gate keys on effective config, but enrollment/leave key on the central layer).
  2. Write-ordering: the seed is committed before its identity is seeded → on a mid-failure, a broken sink + a misleading "run hyp join <url> <token>" message.
  3. --no-forward on an already-enrolled machine prints a false "not enrolled" message and skips re-seeding.

Plausible correctness: 4. unconditional resetCentralLayerToSeed can wipe an applied org config in edge states · 5. exit-code regression (login now returns the daemon-install code) · 6. provisioning skips the validateConfig gate join runs · 7. concurrent same-server logins race runDaemonInstall (the acknowledged LLP 0065 lock follow-up) · 8. enroll path drops the LLP 0061 "displaced identity" note.

Convention: 9. inline import(...) / .d.ts type (AGENTS.md). Coverage: 10. the default (non---no-daemon) enrollment path is untested.

Not filed as bugs (worth a design note)

  • --no-forward to a second server is rejected pre-auth. Two finders flagged this as surprising, but it's per LLP 0063 D4 ("rejected … including with --no-forward … one machine, one server"); the static-token path is the documented escape. Flagging only because it interacts badly with finding [codex] Add durable cache spool #1 (a local sink shouldn't count as enrollment).

Cleanup (below the inline cap, not blocking)

  • Three copies of the origin helper (originOf in gateway_seed.js + safeOrigin in both CLI files) all feed the D4 comparison — export one so the gate and the seed-time recheck can't diverge.
  • enrollCentralSink's seed-write duplicates runJoin's atomic 0600 write + #139 reset almost verbatim; a shared writeCentralSeed() would keep the two enrollment paths in lockstep (they've already diverged on validation and the URL protocol check).
  • enrollCentralSink's provisioned return field is never read.

Full suite / lint / typecheck are green; these are correctness/robustness and consistency issues the tests don't exercise.

Independent review (PR #237) surfaced correctness bugs in the login
auto-provision path. Fixes for the confirmed set:
- D4 gate keyed on the effective (local+central) config, so a hand-authored
central sink in the local layer counted as "enrolled" and blocked login to a
different server — but `hyp leave` won't remove a local sink, so its own
advice couldn't clear it. The gate now reads the central layer only, matching
`hyp leave`'s definition of "connected".
- enrollCentralSink committed the seed before seeding its identity; a mid-way
failure left a credential-less sink that made the daemon demand a `hyp join`
bootstrap token the login user never had. Seed the identity under the same
call and roll the seed back on failure (throw or nothing seeded).
- `--no-forward` on an already-enrolled machine printed "not enrolled and will
not forward" (false — it keeps forwarding). Report the truth per case.
- Convention: replace the inline `import('...types.d.ts')` type with a
top-of-file @import (AGENTS.md).
Adds regression tests for each and updates the D4 write-up in LLP 0063.
@platypii
platypii merged commit e9ea850 into masterJul 5, 2026
6 checks passed
@platypii
platypii deleted the hyp-enroll branch July 5, 2026 07:30
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@platypii