Skip to content
Open
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
5 changes: 5 additions & 0 deletions llp/0033-remote-query-attach.spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,11 @@ client now core (LLP 0034), "which server a verb talks to" is a core concern, an
the **central layer can never inject a remote target** ([LLP 0031](./0031-layered-config.decision.md#query-is-local-only)) —
a free invariant. The URL is non-secret and committable; the token is not config.

> **Extended-by: [LLP 0062](./0062-builtin-default-remote.decision.md).** The
> client now ships a built-in `hyparam` target and wires `default_remote`, so
> bare `hyp <verb> --remote` and bare `hyp remote login` resolve the central
> server with no `remote add`. User `query.remotes` still layers on top.

## Credentials

<a id="credentials"></a>The query-scoped token is **never in config**
Expand Down
62 changes: 62 additions & 0 deletions llp/0062-builtin-default-remote.decision.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# LLP 0062: Ship a built-in default remote so the central server needs no `remote add`

**Type:** Decision
**Status:** Accepted
**Systems:** CLI, Query, MCP
**Author:** Kenny / Claude
**Date:** 2026-07-02
**Related:** LLP 0033, LLP 0031

> LLP 0033 gave every install a target registry (`query.remotes`) and a
> `default_remote`, but reaching the Hyparam-hosted central server still took a
> `hyp remote add <name> <url>` on each machine, and the `default_remote` field
> was validated yet never consumed. This decision ships the central server as a
> built-in target and wires the default so `hyp <verb> --remote` (no name) and
> `hyp remote login` (no name) both resolve it. It extends LLP 0033 §targets; it
> supersedes nothing.

## Decision

### D1 — A built-in target, shipped in the client

<a id="builtin"></a>The client ships a constant registry of built-in targets
(`BUILTIN_REMOTES`), currently the single entry `hyparam →
https://hypaware.hyperparam.app`, plus `BUILTIN_DEFAULT_REMOTE = 'hyparam'`.
Target resolution reads the **effective** registry: built-ins with the user's
`query.remotes` layered on top, so a user entry of the same name repoints or
shadows a built-in (`effectiveRemotes`). The URL is non-secret and committable,
exactly as a `hyp remote add` URL is (LLP 0033 §targets); shipping it in the
public client is therefore consistent with that section, not a secrets leak.

This keeps the local-first default intact: a bare `hyp <verb>` still runs
locally. The built-in only changes what a target *name* resolves to, never
whether a plain command goes remote.

### D2 — Bare `--remote` and bare `remote login` resolve the default

<a id="bare-remote"></a>`--remote` becomes optionally-valued. A bare `--remote`
parses to an empty-string sentinel (distinct from `undefined`, which stays
"local"); the command path resolves it to `effectiveDefaultRemote(config)` — an
explicit `query.default_remote` if set, else `BUILTIN_DEFAULT_REMOTE`. A named
`--remote <name>` is unchanged. Symmetrically, `hyp remote login` with no
positional target resolves the same default, the companion of bare `--remote`
so the one-time sign-in needs no name either.

The resolver is never empty, so bare `--remote` always resolves to a target;
this is the behavior LLP 0033's schema comment already anticipated ("`--remote`
with no arg never silently resolves to nothing").

### D3 — `default_remote` may name a built-in

<a id="validation"></a>Config validation for `query.default_remote` accepts a
name defined in the user's `remotes` **or** in `BUILTIN_REMOTES`, so a config may
default to the central server without restating its URL.

## Consequences

- Onboarding drops to `hyp remote login` then `hyp <verb> --remote`; no
`remote add`, no URL to copy.
- Credentials are still per-target (`HYP_REMOTE_TOKEN_HYPARAM`, or the stored
`0600` session under the `hyparam` key), unchanged from LLP 0033 §credentials.
- Changing the central URL is a client release (it is compiled in). A user who
must override it before then adds a `query.remotes.hyparam` entry, which wins.
20 changes: 11 additions & 9 deletions src/core/cli/remote_commands.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import process from 'node:process'

import { defaultConfigPath } from '../config/schema.js'
import { readObservabilityEnv } from '../observability/env.js'
import { BUILTIN_REMOTES, effectiveDefaultRemote } from '../remote/builtin_remotes.js'
import {
deriveIdentityBase,
readCredentials,
Expand Down Expand Up @@ -122,11 +123,10 @@ export async function runRemoteLogin(argv, ctx, deps = {}) {
// The target name is the first positional. Skip the VALUE slot of a
// value-taking flag so e.g. `login --org acme` (name omitted) is not misread
// as the target 'acme'.
const name = positionals(argv, new Set(['--token-file', '--org', '--host']))[0]
if (!name) {
ctx.stderr.write('usage: hyp remote login <name> [--token-file <path>] [--org <name>] [--host <label>] [--no-browser]\n')
return 2
}
// A bare `hyp remote login` (no target) signs in to the default target: an
// explicit query.default_remote, else the shipped built-in central server.
// @ref LLP 0062#bare-remote [implements]: bare `remote login` resolves the default target, the companion of bare `--remote`
const name = positionals(argv, new Set(['--token-file', '--org', '--host']))[0] ?? effectiveDefaultRemote(ctx.config)
const forceBrowser = argv.includes('--browser')
const noBrowser = argv.includes('--no-browser')

Expand Down Expand Up @@ -509,16 +509,18 @@ function localConfigPath(ctx) {
* @returns {Promise<Record<string, { url: string }>>}
*/
async function readConfiguredRemotes(ctx) {
// Ship the built-in targets under any user-defined ones, so `remote login`
// and `remote list` see the central server even before a `remote add`; a
// user entry of the same name overrides it.
/** @type {Record<string, { url: string }>} */
const out = { ...BUILTIN_REMOTES }
const config = await readLocalConfigRaw(localConfigPath(ctx))
if (isObject(config.query) && isObject(config.query.remotes)) {
/** @type {Record<string, { url: string }>} */
const out = {}
for (const [name, entry] of Object.entries(config.query.remotes)) {
if (isObject(entry) && typeof entry.url === 'string') out[name] = { url: entry.url }
}
return out
}
return {}
return out
}

/**
Expand Down
9 changes: 6 additions & 3 deletions src/core/cli/verb_codec.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,9 +112,12 @@ export function parseControlFlags(argv) {
break
}
case '--remote': {
const v = takeVal()
if (v === undefined) return { ok: false, error: '--remote expects a target name' }
controls.remote = v
// Bare `--remote` (no value) selects the default target, resolved
// downstream against config + built-ins; `--remote <name>` names one.
// The empty-string sentinel distinguishes "default remote" from
// `undefined` ("stay local").
// @ref LLP 0062#bare-remote [implements]: bare --remote parses to the empty-string sentinel; undefined stays local
controls.remote = takeVal() ?? ''
break
}
default:
Expand Down
9 changes: 7 additions & 2 deletions src/core/cli/verb_command.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export async function runVerbCommand(verb, argv, ctx) {

/** @type {unknown} */
let result
if (ctrl.controls.remote) {
if (ctrl.controls.remote !== undefined) {
// `--refresh` is a local-cache control; the server owns its freshness,
// so combining it with `--remote` is a hard error, not a silent ignore.
// @ref LLP 0033#flag-compat [implements]: --remote with --refresh is rejected; other render flags stay valid
Expand All @@ -68,7 +68,12 @@ export async function runVerbCommand(verb, argv, ctx) {
// Lazy-loaded: the remote stack (MCP client, credential store) is only
// reached on `--remote`, so a local `hyp <verb>` never pays for it.
const { runRemoteVerb } = await import('../mcp/remote_verb.js')
const remote = await runRemoteVerb({ verb, params: parsed.params, target: ctrl.controls.remote, ctx })
const { effectiveDefaultRemote } = await import('../remote/builtin_remotes.js')
// Bare `--remote` (empty sentinel) resolves to the default target; a named
// `--remote <name>` passes straight through.
// @ref LLP 0062#bare-remote [implements]: bare --remote uses query.default_remote, else the shipped built-in default
const target = ctrl.controls.remote === '' ? effectiveDefaultRemote(ctx.config) : ctrl.controls.remote
const remote = await runRemoteVerb({ verb, params: parsed.params, target, ctx })
if (!remote.ok) {
ctx.stderr.write(`hyp ${verb.name}: ${remote.error}\n`)
return remote.exitCode ?? 1
Expand Down
17 changes: 12 additions & 5 deletions src/core/config/schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import fs from 'node:fs/promises'
import path from 'node:path'

import { Attr, getLogger, withSpan } from '../observability/index.js'
import { BUILTIN_REMOTES } from '../remote/builtin_remotes.js'

/**
* @import { BlobSinkConfigInstance, ConfigRegistry, ConfigSectionRegistration, HypAwareV2Config, JsonObject, PluginConfigInstance, PluginName, QueryCacheConfig, QueryCacheMaintenanceConfig, QueryConfig, RequestSinkConfigInstance, SinkConfigInstance, SinkInstanceConfig, ValidationError, ValidationResult } from '../../../collectivus-plugin-kernel-types.js'
Expand Down Expand Up @@ -540,14 +541,20 @@ function parseQueryConfig(obj, pointer, errors) {
}

// default_remote must name a defined target, so `--remote` with no arg
// never silently resolves to nothing.
// never silently resolves to nothing. A shipped built-in target counts as
// defined, so a config may default to the central server without also
// restating its URL under `remotes`.
if (obj.default_remote !== undefined) {
if (!isNonEmptyString(obj.default_remote)) {
const defaultName = obj.default_remote
const defined =
(result.remotes && Object.prototype.hasOwnProperty.call(result.remotes, defaultName)) ||
Object.prototype.hasOwnProperty.call(BUILTIN_REMOTES, defaultName)
if (!isNonEmptyString(defaultName)) {
errors.push({ pointer: `${pointer}/default_remote`, message: 'query.default_remote must be a non-empty string' })
} else if (!result.remotes || !Object.prototype.hasOwnProperty.call(result.remotes, obj.default_remote)) {
errors.push({ pointer: `${pointer}/default_remote`, message: `query.default_remote '${obj.default_remote}' is not a defined remote target` })
} else if (!defined) {
errors.push({ pointer: `${pointer}/default_remote`, message: `query.default_remote '${defaultName}' is not a defined remote target` })
} else {
result.default_remote = obj.default_remote
result.default_remote = defaultName
}
}

Expand Down
5 changes: 4 additions & 1 deletion src/core/mcp/remote_verb.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// @ts-check

import { readObservabilityEnv } from '../observability/env.js'
import { effectiveRemotes } from '../remote/builtin_remotes.js'
import { attachWithRefresh, deriveIdentityBase, describeAuthRejection, resolveAccessJwt } from '../remote/credentials.js'
import { describeRefreshError, NO_FETCH_MESSAGE } from '../remote/identity_client.js'
import { createHttpMcpClient, isAuthStatus } from './client.js'
Expand All @@ -24,7 +25,9 @@ import { createHttpMcpClient, isAuthStatus } from './client.js'
* @ref LLP 0033#two-truncations [implements]: server cap surfaced here as its own line; client cannot lift it
*/
export async function runRemoteVerb({ verb, params, target, ctx }) {
const remotes = ctx.config?.query?.remotes ?? {}
// Built-in targets (the shipped central server) layered under the user's
// own `query.remotes`, so `--remote hyparam` works with no `remote add`.
const remotes = effectiveRemotes(ctx.config)
const entry = remotes[target]
if (!entry) {
return {
Expand Down
51 changes: 51 additions & 0 deletions src/core/remote/builtin_remotes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// @ts-check

/**
* @import { HypAwareV2Config, QueryRemoteTarget } from '../../../collectivus-plugin-kernel-types.js'
*/

/**
* Targets shipped preconfigured with the client, so an operator can attach to
* the Hyparam-hosted central server with just `hyp remote login` +
* `hyp <verb> --remote`, no `hyp remote add` first. A user's own
* `query.remotes` entry of the same name wins (see effectiveRemotes), so this
* is a default, not a lock. The URL is non-secret and committable, exactly as
* a `hyp remote add` URL is (LLP 0033 Targets).
*
* @ref LLP 0062#builtin [implements]: a built-in target the client ships, layered under user query.remotes, so the central server needs no local `remote add`
* @type {Record<string, QueryRemoteTarget>}
*/
export const BUILTIN_REMOTES = {
hyparam: { url: 'https://hypaware.hyperparam.app' },
}

/**
* Name of the shipped default target, used by bare `--remote` (and bare
* `hyp remote login`) when the local config sets no `query.default_remote`.
*/
export const BUILTIN_DEFAULT_REMOTE = 'hyparam'

/**
* The effective target registry: shipped built-ins with the user's
* `query.remotes` layered on top, so a user entry of the same name repoints
* (or shadows) a built-in.
*
* @param {HypAwareV2Config | undefined} config
* @returns {Record<string, QueryRemoteTarget>}
*/
export function effectiveRemotes(config) {
return { ...BUILTIN_REMOTES, ...(config?.query?.remotes ?? {}) }
}

/**
* The effective default target name: an explicit `query.default_remote` wins,
* otherwise the shipped built-in. Never empty, so bare `--remote` always
* resolves to a target.
*
* @param {HypAwareV2Config | undefined} config
* @returns {string}
*/
export function effectiveDefaultRemote(config) {
const configured = config?.query?.default_remote
return typeof configured === 'string' && configured ? configured : BUILTIN_DEFAULT_REMOTE
}
23 changes: 15 additions & 8 deletions test/core/remote-login-command.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -424,16 +424,23 @@ test('--browser overrides a piped stdin token and takes the browser flow', async
assert.equal(called, true)
})

test('a missing target name (only flags) is a usage error, not a flag value misread as the name', async () => {
test('a missing target name resolves the default (built-in) target; a value flag is not misread as the name', async () => {
const hypHome = await tmpHome()
const { ctx, err } = await makeCtx({ hypHome })
let called = false
const login = /** @type {any} */ (async () => { called = true; return {} })
// `--org acme` with no positional name must not be read as target 'acme'.
const { ctx, out } = await makeCtx({ hypHome })
/** @type {any} */
let seen = null
const login = /** @type {any} */ (async (opts) => {
seen = opts
return { refreshToken: 'rt', accessJwt: 'jwt', expiresAt: '2999-01-01T00:00:00Z', org: 'acme' }
})
// `--org acme` with no positional name resolves the shipped default target
// (the central server), and is never read as target 'acme'.
const code = await runRemoteLogin(['--org', 'acme'], ctx, { login })
assert.equal(code, 2)
assert.equal(called, false)
assert.match(err.join(''), /usage: hyp remote login <name>/)
assert.equal(code, 0)
assert.ok(seen)
assert.match(seen.identityBase, /hypaware\.hyperparam\.app/)
assert.equal(seen.org, 'acme')
assert.match(out.join(''), /logged in to 'hyparam' as org 'acme'/)
})

test('--org as the last arg with no value is a usage error', async () => {
Expand Down
9 changes: 9 additions & 0 deletions test/core/verb-codec.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,15 @@ test('control flags: strip render/transport flags, keep the verb tail in rest',
assert.deepEqual(p.rest, ['conv-1', '--depth', '2'])
})

test('control flags: bare --remote selects the default target (empty sentinel)', () => {
// No value, at end of argv: the empty-string sentinel means "default target".
assert.equal(okCtrl(parseControlFlags(['SELECT 1', '--remote'])).controls.remote, '')
// A following flag is not consumed as the target name: still the default.
assert.equal(okCtrl(parseControlFlags(['SELECT 1', '--remote', '--format', 'json'])).controls.remote, '')
// An explicit name still passes through unchanged.
assert.equal(okCtrl(parseControlFlags(['SELECT 1', '--remote', 'prod'])).controls.remote, 'prod')
})

test('control flags: --refresh sets refreshExplicit (for the --remote conflict check)', () => {
assert.equal(okCtrl(parseControlFlags(['x', '--refresh', 'always'])).controls.refreshExplicit, true)
assert.equal(okCtrl(parseControlFlags(['x'])).controls.refreshExplicit, false)
Expand Down
28 changes: 28 additions & 0 deletions test/core/verb-remote.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,34 @@ test('server-cap truncation is surfaced as its own stderr line', async (t) => {
assert.match(err.join(''), /remote: showing first 1 rows \(server cap rows:10000\)/)
})

test('bare --remote routes to the shipped default target (central server)', async (t) => {
const original = globalThis.fetch
t.after(() => { globalThis.fetch = original })
/** @type {string[]} */ const urls = []
globalThis.fetch = /** @type {any} */ (async (/** @type {string} */ url, /** @type {any} */ init) => {
urls.push(String(url))
const req = JSON.parse(init.body)
const json = (/** @type {any} */ obj, status = 200, ct = 'application/json') => ({
ok: status >= 200 && status < 300,
status,
headers: { get: (/** @type {string} */ k) => k.toLowerCase() === 'content-type' ? ct : (k.toLowerCase() === 'mcp-session-id' ? 'sess-1' : null) },
text: async () => JSON.stringify(obj),
})
if (req.method === 'initialize') return json({ jsonrpc: '2.0', id: req.id, result: { protocolVersion: '2025-06-18', serverInfo: { name: 'srv' } } })
if (req.method === 'notifications/initialized') return { ok: true, status: 202, headers: { get: () => null }, text: async () => '' }
if (req.method === 'tools/call') return json({ jsonrpc: '2.0', id: req.id, result: { structuredContent: { columns: ['n'], rows: [{ n: 1 }] }, isError: false } })
return json({ jsonrpc: '2.0', id: req.id, error: { code: -32601, message: 'no' } })
})
// No default_remote and no 'hyparam' entry in config: bare --remote falls to
// the shipped built-in, whose per-target token env is HYP_REMOTE_TOKEN_HYPARAM.
const { ctx, out } = ctxWith({ HYP_HOME: '/tmp/none', HYP_REMOTE_TOKEN_HYPARAM: 'tok' })
const code = await cmd.run(['SELECT 1', '--remote', '--format', 'json'], ctx)
assert.equal(code, 0)
assert.ok(urls.length > 0)
assert.match(urls[0], /^https:\/\/hypaware\.hyperparam\.app/)
assert.deepEqual(JSON.parse(out.join('')), [{ n: 1 }])
})

test('--remote with --refresh is a hard error (server owns its freshness)', async (t) => {
stubServer(t, { toolResult: {} })
const { ctx, err } = ctxWith({ HYP_HOME: '/tmp/none', HYP_REMOTE_TOKEN_PROD: 'tok' })
Expand Down