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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions src/core/cli/remote_commands.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
writeToken,
} from '../remote/credentials.js'
import { Attr, getLogger } from '../observability/index.js'
import { readCentralSinkOrigins, seedLoginGateway } from '../remote/gateway_seed.js'
import { readCentralEnrollment, seedLoginGateway } from '../remote/gateway_seed.js'
import { enrollCentralSink } from '../commands/central.js'
import { DURABLE_HINT } from '../commands/local_only.js'
import { formatFirstSyncDeadline, writeFirstSyncHoldMarker } from '../usage-policy/first_sync_hold.js'
Expand Down Expand Up @@ -582,7 +582,29 @@ async function runBrowserLogin(name, { org, host, noBrowser, noForward, noDaemon
// switching is 'hyp leave' then log in again, never one command.
// @ref LLP 0063#d4 [implements]: pre-auth exclusivity gate, rejecting a login to a new server while enrolled elsewhere
const targetOrigin = originOf(entry.url)
const connectedOrigins = await readCentralSinkOrigins({ stateDir, configPath: localConfigPath(ctx) })
const enrollment = await readCentralEnrollment({ stateDir, configPath: localConfigPath(ctx) })
// Fail the gate CLOSED when it cannot read its own input (#623). A central
// layer that is on disk but does not load, and one whose path this process
// cannot even resolve (a pointer that is not a slot symlink, a control
// directory it cannot list), are both enrollments by every other definition
// the codebase uses: the layer file still names another org's server, and
// repairing the pointer or the permissions brings it straight back. Reading
// either one's zero origins as "not enrolled" would let a *second* org
// enroll an already-enrolled machine - the one thing D4 exists to prevent.
// Not enrolled is a control directory with no central layer left in it, and
// that never reaches here.
// Refuse instead, and name the state we could not read rather than claiming
// the machine is not connected, which is precisely what we cannot establish.
// Rejects a same-origin re-login too: with no readable layer there is no
// origin to compare against, and 'hyp leave' tears down by path, so the
// advice is actionable either way.
if (enrollment.unreadable) {
ctx.stderr.write(`hyp remote login: this machine's central config layer (${enrollment.unreadable.configPath}) cannot be read, so its enrollment cannot be verified\n`)
ctx.stderr.write(` ${enrollment.unreadable.message}\n`)
ctx.stderr.write(" repair it, or disconnect this machine ('hyp leave'), then log in again\n")
return 2
}
const connectedOrigins = enrollment.origins
const alreadyEnrolled = targetOrigin !== null && connectedOrigins.includes(targetOrigin)
if (!alreadyEnrolled && connectedOrigins.length > 0) {
ctx.stderr.write(`hyp remote login: this machine is connected to ${connectedOrigins[0]}\n`)
Expand Down
44 changes: 37 additions & 7 deletions src/core/commands/central.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,15 @@ import path from 'node:path'
import { Attr, withSpan } from '../observability/index.js'
import { readObservabilityEnv } from '../observability/env.js'
import { defaultConfigPath } from '../config/schema.js'
import { centralSeedPath, resetCentralLayerToSeed, resolveCentralLayerPath } from '../config/apply.js'
import { centralLayerResolutionFailure, centralSeedPath, resetCentralLayerToSeed, resolveCentralLayerPath } from '../config/apply.js'
import { validateConfig } from '../config/validate.js'
import { atomicWriteJson } from '../util/fs_atomic.js'
import {
clearClientActionMarker,
readClientActionStatus,
readInstalledAssets,
} from '../config/action_reconciler.js'
import { originOf, readCentralSinkOrigins, seedLoginGateway } from '../remote/gateway_seed.js'
import { originOf, readCentralEnrollment, seedLoginGateway } from '../remote/gateway_seed.js'
import { seedClientSyncStoreIfAbsent } from '../usage-policy/client_sync.js'
import { buildClientDescriptorMap, detachClientViaCore } from './clients.js'
import { runDaemonInstall } from './daemon.js'
Expand Down Expand Up @@ -209,7 +209,17 @@ export async function enrollCentralSink({ ctx, url, gateway, noDaemon }) {
// another server), abort rather than provision a second enrollment. This is
// the non-locked flavor of D4's seed-time check: it closes the common race;
// the cross-process credentials lock (LLP 0065) is a follow-up.
const connectedOrigins = await readCentralSinkOrigins({ stateDir: stateRoot, configPath: localPath })
//
// Same fail-closed reading as the pre-auth gate (#623): a central layer that
// appeared but cannot be read - whether it does not parse or its path cannot
// even be resolved - leaves this check unable to say whether a second
// enrollment is being created, so it must abort. Throwing is the whole
// handling: nothing has been written yet, and the caller already reports a
// throw here as "signed in, but enrollment failed".
const { origins: connectedOrigins, unreadable } = await readCentralEnrollment({ stateDir: stateRoot, configPath: localPath })
if (unreadable) {
throw new Error(`the central config layer (${unreadable.configPath}) cannot be read, so this machine's enrollment cannot be verified: ${unreadable.message}`)
}
const elsewhere = connectedOrigins.find((o) => o !== targetOrigin)
if (elsewhere) return { provisioned: false, connectedElsewhere: elsewhere, daemonCode: 0 }

Expand Down Expand Up @@ -384,14 +394,22 @@ export async function runLeave(argv, ctx) {
? path.resolve(ctx.env.HYP_CONFIG)
: defaultConfigPath(obsEnv.hypHome)
const centralLayerPath = resolveCentralLayerPath({ stateRoot })
// A null path is not proof there is nothing here: resolution swallows a
// pointer it cannot follow and a control directory it cannot list. The D4
// login gate refuses in exactly those states and sends the user here, so
// `leave` has to be able to finish the teardown they were told to run;
// otherwise the fail-closed gate is a lockout (#623). The teardown below is
// already the right one - `resetCentralLayerToSeed` force-removes the
// pointer and both slots by name, no resolution required.
const unresolvableCentralLayer = centralLayerResolutionFailure({ stateRoot })
const attachMarkers = readClientActionStatus({ stateRoot }).byKind.attach ?? {}
const attachedNames = Object.keys(attachMarkers)

// Nothing to tear down: no central layer AND no org-attach residue. A leave
// that failed partway leaves its attach markers on disk, so a re-run still
// lands here with work to do and finishes it - the marker is its own
// "unfinished teardown" signal, no separate bookkeeping needed.
if (centralLayerPath === null && attachedNames.length === 0) {
if (centralLayerPath === null && unresolvableCentralLayer === null && attachedNames.length === 0) {
ctx.stdout.write('hyp leave: this machine is not connected to a central server - nothing to do\n')
// A hand-authored central sink in the LOCAL layer is not an enrollment,
// and leave never edits the local layer (#111 doctrine), but a user
Expand Down Expand Up @@ -422,9 +440,21 @@ export async function runLeave(argv, ctx) {
// 1. Central layer teardown: drop the seed, then clear the applied
// slots / pointer / apply state. With both gone the machine has no
// central layer at all: the exact inverse of join's write.
await fs.rm(centralSeedPath(stateRoot), { force: true })
resetCentralLayerToSeed(stateRoot)
ctx.stdout.write('✓ removed the central config layer\n')
//
// Counted-and-reported like every other step rather than an uncaught
// throw: the states the D4 gate now refuses on include a control
// directory this process cannot read, and that removal raises EACCES.
// Aborting there would strand the attach reversal in step 3 and leave
// the user with no way to act on the gate's advice.
try {
await fs.rm(centralSeedPath(stateRoot), { force: true })
resetCentralLayerToSeed(stateRoot)
ctx.stdout.write('✓ removed the central config layer\n')
} catch (err) {
failures += 1
const message = err instanceof Error ? err.message : String(err)
ctx.stderr.write(`✗ could not remove the central config layer: ${message}\n`)
}

// 2. Restart the service so the running daemon reboots without the
// central sink: forwarding and the config-pull loop stop here. Restart,
Expand Down
59 changes: 59 additions & 0 deletions src/core/config/apply.js
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,65 @@ export function resolveCentralLayerPath({ stateRoot }) {
return null
}

/**
* The central-layer file names {@link resolveCentralLayerPath} resolves
* *through*: the pointer it reads and the files it can name. Their presence
* is the on-disk evidence that this host has a central layer, independent of
* whether the resolution above managed to follow them.
*/
const CENTRAL_LAYER_BASENAMES = [ACTIVE_BASENAME, SEED_BASENAME, 'config.a.json', 'config.b.json']

/**
* Why {@link resolveCentralLayerPath} returned `null`, for the one caller that
* cannot treat "resolved nothing" and "could not resolve" alike: the D4
* exclusivity gate (LLP 0063), which reads `null` as *not enrolled* and would
* otherwise let a second org enroll a machine whose layer it merely failed to
* look up (#623).
*
* Resolution is lossy on purpose everywhere else. `readActiveSlot` swallows
* every `readlink` error into `null` (the pointer replaced by a regular file,
* a dangling or looping link, a target that is not a slot), and `existsSync`
* swallows `EACCES` on `config-control/` into `false`. Boot and `hyp status`
* are right to see one `null`: either way there is no layer to merge or show.
* A permission decision is not, so this reports resolution *failure*
* separately: the control directory could not be listed, or it still holds
* central-layer files that the resolution above did not manage to name.
*
* `null` means the absence is real: no control directory, or one with no
* central-layer file left in it (what `hyp leave` and `resetCentralLayerToSeed`
* produce). Nothing here reads file *contents*: a layer that resolves is the
* loader's business, not this function's.
*
* @param {{ stateRoot: string }} args
* @returns {{ configPath: string, errorKind: 'config_unreadable', message: string } | null}
* @ref LLP 0031#physical-layout [constrained-by]: same active-slot-else-seed layout, read for evidence of a layer rather than for a path to load
*/
export function centralLayerResolutionFailure({ stateRoot }) {
const controlDir = path.join(stateRoot, CONTROL_DIRNAME)
if (resolveCentralLayerPath({ stateRoot }) !== null) return null
/** @type {string[]} */
let entries
try {
entries = fs.readdirSync(controlDir)
} catch (err) {
const code = err && /** @type {NodeJS.ErrnoException} */ (err).code
// No control directory at all is the genuine never-joined host.
if (code === 'ENOENT' || code === 'ENOTDIR') return null
return {
configPath: controlDir,
errorKind: 'config_unreadable',
message: `failed to read the central config directory ${controlDir}: ${err instanceof Error ? err.message : String(err)}`,
}
}
const residue = CENTRAL_LAYER_BASENAMES.filter((name) => entries.includes(name))
if (residue.length === 0) return null
return {
configPath: controlDir,
errorKind: 'config_unreadable',
message: `${controlDir} still holds central layer state (${residue.join(', ')}) that the active-slot pointer does not resolve to a readable layer file`,
}
}

/**
* Reset the central layer to **seed-config mode**: remove the active-slot
* pointer, both A/B slot files and their etag sidecars, and the
Expand Down
68 changes: 58 additions & 10 deletions src/core/remote/gateway_seed.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import path from 'node:path'

import { Attr, getLogger } from '../observability/index.js'
import { atomicWriteJsonSync } from '../util/fs_atomic.js'
import { centralLayerResolutionFailure } from '../config/apply.js'
import { resolveLayeredConfigFromDisk } from '../runtime/boot.js'

/**
Expand All @@ -21,7 +22,7 @@ import { resolveLayeredConfigFromDisk } from '../runtime/boot.js'
* a server-pushed sink block seeds the same file a locally-configured one
* does.
*
* @import { LoginGatewayCredential, SeededGateway } from '../../../src/core/remote/types.js'
* @import { CentralEnrollment, LoginGatewayCredential, SeededGateway } from '../../../src/core/remote/types.js'
* @import { PersistedIdentity } from '../../../hypaware-core/plugins-workspace/central/src/types.js'
*/

Expand Down Expand Up @@ -89,11 +90,39 @@ export async function seedLoginGateway({ stateDir, configPath, targetUrl, gatewa
}

/**
* The URL origins that `@hypaware/central` sinks target in the **central
* config layer**: i.e. which server(s) this machine is *enrolled* to. A
* fresh, login-first box returns `[]`. Drives login's D4 exclusivity gate
* (LLP 0063): already enrolled to this origin (re-login, idempotent), enrolled
* to a different origin (reject), or not enrolled (may enroll).
* This machine's enrollment as the **central config layer** records it: the
* URL origins its `@hypaware/central` sinks target, plus whether that layer
* could be read at all. Drives login's D4 exclusivity gate (LLP 0063), which
* has *four* answers, not three: already enrolled to this origin (re-login,
* idempotent), enrolled to a different origin (reject), not enrolled (may
* enroll), and **cannot tell** (reject).
*
* That last state is the reason this returns a record instead of a bare list.
* A central layer that is on disk but does not parse is an enrollment by every
* other definition the codebase uses (`hyp leave` and the apply engine key on
* the file, not its contents), yet it yields no origins. Reporting it as `[]`
* reads as "not enrolled" and lets a second org enroll the machine: a gate
* that cannot read its own input must refuse, not permit (#623). `unreadable`
* carries the load failure so the caller can say what is actually wrong
* instead of the misleading "not connected".
*
* "Absent" is an enrollment that is *verifiably* not there, which takes two
* checks, not one. Every load failure is unreadable, `config_missing` included:
* a path that resolved but does not load is not a machine that never enrolled,
* because the seed branch `existsSync`-checks before returning the path (so an
* ENOENT there is a live race with a concurrent removal) and the active-slot
* branch resolves a pointer the apply engine only ever flips *after* writing
* its slot file (so a pointer naming a file that is gone is an applied-to
* machine whose layer was removed out of band).
*
* And a null path is *not* on its own an absence, because resolution itself can
* fail: `readActiveSlot` swallows every `readlink` error, and `existsSync`
* swallows `EACCES` on the control directory. A pointer replaced by a regular
* file, or a state directory this process cannot read, both yield null on a
* machine whose `config.a.json` still holds another org's sink verbatim.
* Reading that null as "free machine" is the same fail-open one level up, so
* `centralLayerResolutionFailure` re-checks it against the directory and this
* reports resolution failure as unreadable too.
*
* Deliberately reads the central layer, **not** the effective (local+central)
* config: a hand-authored `@hypaware/central` sink in the user-owned local
Expand All @@ -103,11 +132,14 @@ export async function seedLoginGateway({ stateDir, configPath, targetUrl, gatewa
* gate and `hyp leave` therefore agree that enrollment == the central layer.
*
* @param {{ stateDir: string, configPath: string | null }} args
* @returns {Promise<string[]>}
* @returns {Promise<CentralEnrollment>}
* @ref LLP 0063#d4 [implements]: one enrollment per machine; the central-layer sink origins are the gate, and a local sink is the user's own, not an enrollment
*/
export async function readCentralSinkOrigins({ stateDir, configPath }) {
const { centralConfig } = await resolveLayeredConfigFromDisk({ stateRoot: stateDir, configPath })
export async function readCentralEnrollment({ stateDir, configPath }) {
const { centralConfig, centralLoaded } = await resolveLayeredConfigFromDisk({ stateRoot: stateDir, configPath })
const unreadable = centralLoaded && centralLoaded.ok === false
? { configPath: centralLoaded.configPath, errorKind: centralLoaded.errorKind, message: centralLoaded.message }
: centralLoaded === null ? centralLayerResolutionFailure({ stateRoot: stateDir }) : null
const sinks = centralConfig?.sinks ?? {}
const origins = new Set()
for (const entry of Object.values(sinks)) {
Expand All @@ -116,7 +148,23 @@ export async function readCentralSinkOrigins({ stateDir, configPath }) {
const origin = typeof config.url === 'string' ? originOf(config.url) : null
if (origin) origins.add(origin)
}
return [...origins]
return { origins: [...origins], unreadable }
}

/**
* {@link readCentralEnrollment} reduced to its origin list, for callers that
* are *not* making a permission decision and have their own documented answer
* for an unreadable layer (the session-start classification hook, which is
* deliberately inert on anything it cannot read: LLP 0106 #interactive).
* Anything that gates an enrollment must call `readCentralEnrollment` and
* handle `unreadable` itself.
*
* @param {{ stateDir: string, configPath: string | null }} args
* @returns {Promise<string[]>}
*/
export async function readCentralSinkOrigins({ stateDir, configPath }) {
const { origins } = await readCentralEnrollment({ stateDir, configPath })
return origins
}

/**
Expand Down
25 changes: 25 additions & 0 deletions src/core/remote/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,33 @@
* specifier so the published declaration build resolves them identically.
*/

import type { ConfigLoadErrorKind } from '../../../src/core/config/types.js'
import type { PersistedIdentity } from '../../../hypaware-core/plugins-workspace/central/src/types.js'

/**
* What the central config layer says about this machine's enrollment
* (LLP 0063 D4). Keeps "not enrolled" and "cannot tell" apart: an empty
* `origins` with a non-null `unreadable` is a layer this process could not
* read, which the D4 gate must refuse rather than read as a free machine.
* Not enrolled is a control directory with no central layer left in it.
*/
export interface CentralEnrollment {
/** URL origins the central layer's `@hypaware/central` sinks target. */
origins: string[]
/**
* Why the layer could not be read: it loaded no config (a missing file at a
* resolved path included), or its path could not be resolved at all while
* central-layer state was still on disk, in which case `configPath` is the
* control directory rather than a layer file. `null` when the layer loaded,
* or when there is verifiably no layer.
*/
unreadable: {
configPath: string
errorKind: ConfigLoadErrorKind
message: string
} | null
}

/**
* One central forward sink seeded from a login-minted gateway credential
* (LLP 0061 D5). `replaced` is the persisted identity the seed displaced,
Expand Down
Loading
Loading