Uh oh!
There was an error while loading. Please reload this page.
hyp remote login: auto-provision the central sink (LLP 0063) - #237
Conversation
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) }) |
There was a problem hiding this comment.
[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) |
There was a problem hiding this comment.
[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).
| // 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') |
There was a problem hiding this comment.
[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) |
There was a problem hiding this comment.
[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) { |
There was a problem hiding this comment.
[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 = { |
There was a problem hiding this comment.
[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) |
There was a problem hiding this comment.
[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.
| // 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 }) |
There was a problem hiding this comment.
[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.
| * 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 |
There was a problem hiding this comment.
[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. |
There was a problem hiding this comment.
[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
commented
Jul 5, 2026
Independent review — |
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.
Uh oh!
There was an error while loading. Please reload this page.
Implements the client half of LLP 0063 — an enrolling
hyp remote loginnow forwards logs from one command. When the server mints a gateway credential and no@hypaware/centralsink targets it, login provisions one, seeds the login-minted identity into it, and finishes exactly the wayhyp joindoes.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'srunDaemonInstall(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-daemonflags, and theprovisioned … forwarding to …message replacing the old dead-end note plus the interimrun '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
<origin>/mcp, but the forward sink builds ingest URLs by joining/v1/ingest/...ontoconfig.url— so the sink is provisioned at the origin, not the query target.validateCentralConfigrequires anidentityobject (runs in the daemon's apply path). The block carriesidentity: {}(empty — the seededidentity.jsonis the credential); omitting it would have made the daemon silently reject the sink.Follow-ups (named in the LLP, not in this PR)
hyp remote logoutand the README connection-levels table.@refannotations on D2–D5. Lint, typecheck, and the full suite (1817 pass / 0 fail) are green.