@kontextmind/kxm 0.7.51 → 0.7.52
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +43 -8
- package/docs/configuration.md +1 -1
- package/docs/operations.md +195 -19
- package/package.json +1 -1
- package/plugins/kxm/.claude-plugin/plugin.json +1 -1
- package/plugins/kxm/dist/cli.js +100 -8
- package/plugins/kxm/dist/extension.js +4 -3
- package/plugins/kxm/dist/mcp-server.js +1 -1
- package/plugins/kxm/package.json +1 -1
- package/plugins/kxm/src/cli/hub.ts +84 -5
- package/plugins/kxm/src/hub-binding.ts +22 -0
- package/plugins/kxm/src/hub-env.ts +28 -0
- package/plugins/kxm/src/mcp-server.ts +1 -1
- package/plugins/kxm/src/session-work.ts +9 -3
package/CHANGELOG.md
CHANGED
|
@@ -6,17 +6,52 @@ All notable user-facing changes are documented here. The project follows [Semant
|
|
|
6
6
|
|
|
7
7
|
### Added
|
|
8
8
|
|
|
9
|
-
- **
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
`
|
|
16
|
-
|
|
9
|
+
- **Per-tenant hosting operations, and a backup that covers the whole state set.**
|
|
10
|
+
`docs/operations.md` gained a *Per-tenant hosted deployment* section (one tenant = one box
|
|
11
|
+
= one hub; loopback-only hub and supervisor; the tenant's proxy owns TLS and the browser
|
|
12
|
+
session; a reverse-proxy contract states what must hold without shipping generated proxy
|
|
13
|
+
config) and a rewritten *Backup and restore* that enumerates tenant state **by root**,
|
|
14
|
+
because the roots are not interchangeable and two of them are easy to mistake for one:
|
|
15
|
+
`$R` the fixed checkout `.kxm` tree (project, model, role, route, price and provenance
|
|
16
|
+
definition, environment, goals, tasks, memory, improvement candidates, skills — none of
|
|
17
|
+
which follow any workspace override); `$D` the workspace directories (config, logs,
|
|
18
|
+
assets, state — moved by `--workspace`/`KXM_WORKSPACE_DIR`, each individually
|
|
19
|
+
overridable, and `--workspace` ignores those overrides); `$W` the workspace state
|
|
20
|
+
directory (hub database, worker routing and recovery manifests, Pi sessions); `$S`
|
|
21
|
+
host-local machine state (`KXM_STATE_HOME`, absolute or rejected: Runtime `registry.db`
|
|
22
|
+
holding the supervisor claim as a registry row, per-project `run-events.db` **and its
|
|
23
|
+
full-filename `.run-prompts.json` sidecar**, repository bindings, `update.yaml`, hub
|
|
24
|
+
binding and credential records); `$C` user configuration; and `$T` federated telemetry,
|
|
25
|
+
resolved independently of `$C` and written by an exporter that currently has no
|
|
26
|
+
production caller. It separates recovery-critical manifests from Pi model histories whose
|
|
27
|
+
backup is an existing policy **choice**, marks what is disposable (PID and claim files,
|
|
28
|
+
`session-brief.json`, the re-generable supervisor token), applies WAL consistency to
|
|
29
|
+
every SQLite store, records each override as part of the backup, and notes that a bound
|
|
30
|
+
member repository's own `.kxm/repo/` files live on that member's filesystem — so copying
|
|
31
|
+
the binding JSON alone is not a restore path for an external member. The previous recipe
|
|
32
|
+
stopped the hub and copied `kxm.db`, which is a hub-only backup: a restore can pass every
|
|
33
|
+
hub check and still lose run history, the prompts that explain it, the project definition,
|
|
34
|
+
and the bindings that make the box reproducible.
|
|
17
35
|
|
|
18
36
|
### Changed
|
|
19
37
|
|
|
38
|
+
- **`kxm hub bind` no longer stores a remote URL it cannot authenticate to.** A remote
|
|
39
|
+
binding is a deliberate network decision, so it is now refused when no credential resolves
|
|
40
|
+
(explicit `KXM_AUTH_TOKEN`, or the persisted hub record's admin or project tokens) —
|
|
41
|
+
mirroring the rule the hub already applies to its own listener, which refuses to bind
|
|
42
|
+
beyond loopback without a token. The refusal carries `nextAction` and the same hint string
|
|
43
|
+
in both the JSON payload and the prose line, so `--json` consumers are not left with a bare
|
|
44
|
+
code and no way forward; a stored-but-unusable binding otherwise reads later like a
|
|
45
|
+
network fault and gets debugged as one.
|
|
46
|
+
- **`kxm hub view` and the session brief label the binding `loopback` or `remote`.**
|
|
47
|
+
"Attached across a network" and "attached on this box" looked identical before, and only
|
|
48
|
+
one of them puts a bearer on a wire. `localhost`, `127.0.0.1`, `::1` and `*.localhost` are
|
|
49
|
+
loopback; `0.0.0.0`, LAN addresses and host names are remote. The **bind** guard is
|
|
50
|
+
scoped to remote URLs — a damaged host record must not cost a local operator their start,
|
|
51
|
+
and the first cut of the guard did exactly that. That is a statement about `hub bind`
|
|
52
|
+
only: other client paths resolve credentials whatever the scope, so loopback commands can
|
|
53
|
+
still fail on a malformed record.
|
|
54
|
+
|
|
20
55
|
- **Naming sweep:** the retired `vnext` naming is gone from file and folder names,
|
|
21
56
|
symbols, constants, schema `$id` segments, and error codes (`vnext_*` is now
|
|
22
57
|
`initialization_failed`, `initialization_io_failed`, `wait_failed`); package and
|
package/docs/configuration.md
CHANGED
|
@@ -296,7 +296,7 @@ The current hub command groups are `agent`, `session`, `workflow`, `gate`, `hub`
|
|
|
296
296
|
| `kxm gate github watch` | Poll required GitHub checks and post the existing signed signal |
|
|
297
297
|
| `kxm init` | Create or validate a project; configuration remains project-owned and no package dogfood templates are copied |
|
|
298
298
|
| `kxm hub view` | Check `/health` and `/ready` |
|
|
299
|
-
| `kxm hub bind <url>` | Bind this machine to a running hub |
|
|
299
|
+
| `kxm hub bind <url>` | Bind this machine to a running hub. A **remote** URL is refused unless a credential resolves (`KXM_AUTH_TOKEN`, or the persisted hub record); `kxm hub view` reports the binding as `loopback` or `remote` |
|
|
300
300
|
| `kxm hub unbind` | Remove this machine's hub binding |
|
|
301
301
|
| `kxm update --check` / `kxm update --kxm` | Check or apply a kxm operator package update from an npm-global install only. Other install kinds (source checkout, Pi git, Claude marketplace, npm-local, unknown) refuse `--kxm` and skip auto-apply. Source checkouts neither fetch nor nag. Default source is GitHub release tarballs; the release asset `kxm-<v>.tgz` must carry a sha256 digest or install fails closed. `source: npm` is for after the public package exists. Optional per-user `update.yaml` (`kxm.update.v1`, `auto` boolean) under the host state root (`KXM_STATE_HOME` / `%LOCALAPPDATA%\KXM` / macOS Application Support / XDG state) enables auto-apply on `kxm update`. A project `.kxm/update.yaml` is ignored with a warning. Notice also prints on `kxm hub start` (not from source) and on the session widget from cache |
|
|
302
302
|
| `kxm dash` | Open the read-only SSE observer dashboard; non-TTY output is one ANSI-free snapshot |
|
package/docs/operations.md
CHANGED
|
@@ -144,28 +144,204 @@ Recommended alerts:
|
|
|
144
144
|
- waiting-run count or workflow wait timeouts rise beyond the expected external-system latency.
|
|
145
145
|
- quorum degradation approvals occur outside a declared incident or change window.
|
|
146
146
|
|
|
147
|
-
##
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
147
|
+
## Per-tenant hosted deployment
|
|
148
|
+
|
|
149
|
+
One tenant is one machine: one hub process, one Runtime supervisor, one SQLite state set.
|
|
150
|
+
Tenancy is the box, not a table — the hub has no tenant column and no user accounts, and
|
|
151
|
+
`kxm hub bind` still means *this machine's client attaches to that hub URL*. The portal
|
|
152
|
+
(kontextmind/kxmd-portal) is the multi-tenant, multi-user surface; browsers never talk to
|
|
153
|
+
the hub.
|
|
154
|
+
|
|
155
|
+
Topology on the tenant box:
|
|
156
|
+
|
|
157
|
+
```text
|
|
158
|
+
browser ──HTTPS──▶ reverse proxy + Authentik ──▶ portal (users, sessions, tenant directory)
|
|
159
|
+
│
|
|
160
|
+
│ server-side, loopback
|
|
161
|
+
▼
|
|
162
|
+
hub 127.0.0.1:7331 · Runtime supervisor (loopback)
|
|
163
|
+
```
|
|
163
164
|
|
|
164
|
-
|
|
165
|
+
1. **Service account, not root.** Run the hub and Runtime under a dedicated unprivileged
|
|
166
|
+
account. Workflow session isolation is a routing and cross-run safety mechanism, not a
|
|
167
|
+
sandbox against a hostile same-OS process (see
|
|
168
|
+
[architecture.md](architecture.md)); a model with shell access can reach anything its
|
|
169
|
+
own account can reach, so untrusted workers need separate accounts or containers.
|
|
170
|
+
2. **Stable paths, declared explicitly** rather than inherited from a home directory:
|
|
171
|
+
`KXM_WORKSPACE_DIR`, `KXM_STATE_DIR`, `KXM_DATA_PATH`, `KXM_LOG_PATH`, and
|
|
172
|
+
`KXM_STATE_HOME` for the machine-level hub credential and binding records. Pin
|
|
173
|
+
`KXM_HOST=127.0.0.1`.
|
|
174
|
+
3. **Loopback listeners only.** The hub and the supervisor expose no public port; nothing
|
|
175
|
+
is load-balanced across hubs. The hub keeps one writer per database.
|
|
176
|
+
4. **One project, the slim workflow.** `kxm init` the workspace, then start the hub with
|
|
177
|
+
`kxm hub start` and confirm with `kxm hub view` — the status line reports the binding as
|
|
178
|
+
`loopback` or `remote`, so an operator can see which side of the trust line they are on
|
|
179
|
+
without reading files. `kxm hub bind` refuses a **remote** URL when the machine has no
|
|
180
|
+
credential to authenticate with, because a stored-but-unusable URL later reads as a
|
|
181
|
+
network fault and gets debugged as one.
|
|
182
|
+
5. **Restart recovery is the existing one:** the PID claim file, dead-claim reclaim and
|
|
183
|
+
graceful `SIGTERM` shutdown described under *PID claims and restart recovery*. Do not
|
|
184
|
+
add a second service manager for the hub; use the tenant's existing one.
|
|
185
|
+
|
|
186
|
+
### Reverse-proxy contract
|
|
187
|
+
|
|
188
|
+
The tenant's proxy owns TLS and the browser session. KXM ships no proxy configuration,
|
|
189
|
+
because a generated config reads as authoritative while one missing directive silently
|
|
190
|
+
re-opens header forgery. What must hold, whatever the stack:
|
|
191
|
+
|
|
192
|
+
- The hub and supervisor ports are **not** reachable from outside the box.
|
|
193
|
+
- The proxy **strips** client-supplied identity, tenant, agent, caller and `Authorization`
|
|
194
|
+
headers before injecting its own validated values. The hub must never see a
|
|
195
|
+
browser-forged `x-kxm-agent-id`, `x-kxm-caller-id` or bearer.
|
|
196
|
+
- The proxy **never injects the hub admin token on a user's behalf**. That flattens every
|
|
197
|
+
authenticated user in the tenant to hub admin and destroys attribution.
|
|
198
|
+
- Machine credentials stay server-side in the portal process. A browser must not hold,
|
|
199
|
+
echo, or be redirected with a hub bearer.
|
|
200
|
+
- `kxm hub bind` on a remote hub URL therefore requires an explicit credential, and a
|
|
201
|
+
refusal names the fix instead of only the failure.
|
|
202
|
+
|
|
203
|
+
Example (illustrative shape — not generated config, not tested by this repository's CI):
|
|
204
|
+
Authentik's embedded proxy answers a forward-auth subrequest per request; the tenant proxy
|
|
205
|
+
`proxy_cache_bypass`/`auth_request`-style gate allows only the portal's routes and keeps
|
|
206
|
+
`/v1/*` and the supervisor off the public interface entirely.
|
|
165
207
|
|
|
166
|
-
|
|
208
|
+
## Backup and restore
|
|
167
209
|
|
|
168
|
-
|
|
210
|
+
> **This section covers the whole tenant state set, on purpose.** A recipe that copies only
|
|
211
|
+
> `.kxm/state/kxm.db` is a hub-only backup: it silently omits the Runtime registry,
|
|
212
|
+
> per-project event stores, prompt sidecars, bindings and configuration, so a restore that
|
|
213
|
+
> passes every hub check can still lose run history. Verify with a real restore before first
|
|
214
|
+
> hosted use, not after an incident.
|
|
215
|
+
|
|
216
|
+
SQLite runs in WAL mode, so a consistent copy requires a stopped service (or a SQLite-aware
|
|
217
|
+
online tool). Stop the hub and the Runtime supervisor first.
|
|
218
|
+
|
|
219
|
+
**What a tenant backup contains.** Six roots — one fixed to the checkout, one for the
|
|
220
|
+
workspace directories, and four more that can each sit anywhere — and confusing them is how
|
|
221
|
+
a backup goes missing while looking complete:
|
|
222
|
+
|
|
223
|
+
- **`$S`** — host-local machine state: `$KXM_STATE_HOME` **when set**, and it must be an
|
|
224
|
+
absolute path — a relative value is **rejected** with `local_state_root_not_absolute`, not
|
|
225
|
+
redirected. When unset, the default is `~/.local/state/kxm` on Linux (honouring
|
|
226
|
+
`XDG_STATE_HOME`), `~/Library/Application Support/KXM` on macOS, or
|
|
227
|
+
`%LOCALAPPDATA%\KXM` on Windows. The silent case to know about is a relative
|
|
228
|
+
`XDG_STATE_HOME`/`LOCALAPPDATA` **base**: that falls back to the default without error,
|
|
229
|
+
so a backup path derived from it can quietly point somewhere else.
|
|
230
|
+
- **`$R`** — the checkout root. Everything below it is **fixed to the repository and does
|
|
231
|
+
not follow any workspace override**: `$R/.kxm/project.yaml`, `$R/.kxm/config.yaml`,
|
|
232
|
+
`$R/.kxm/agents/`, `$R/.kxm/workflows/`, `$R/.kxm/gates.yaml`, `$R/.kxm/roles/`,
|
|
233
|
+
`$R/.kxm/role-hosts.yaml` (or `.json`), `$R/.kxm/producers.yaml`, `$R/.kxm/roster.yaml`,
|
|
234
|
+
`$R/.kxm/routes.yaml`, `$R/.kxm/prices.yaml`, `$R/.kxm/repo/`,
|
|
235
|
+
`$R/.kxm/template-provenance.yaml`, plus the durable work and learning records
|
|
236
|
+
`$R/.kxm/goals/`, `$R/.kxm/tasks/`, `$R/.kxm/memory/` (with `memory/candidates/`) and
|
|
237
|
+
`$R/.kxm/skills/`. Conflating these with the next root is how a backup omits the project
|
|
238
|
+
definition while believing it copied the project.
|
|
239
|
+
- **`$D`** — the **workspace directories**, resolved from `--workspace` or
|
|
240
|
+
`KXM_WORKSPACE_DIR`, else `$R/.kxm`, relative to `KXM_WORKDIR`/cwd:
|
|
241
|
+
`$D/config`, `$D/logs`, `$D/assets`, `$D/state`. `--workspace` **derives all four** and
|
|
242
|
+
ignores the per-directory variables; otherwise `KXM_CONFIG_DIR`, `KXM_LOGS_DIR`,
|
|
243
|
+
`KXM_ASSETS_DIR` and `KXM_STATE_DIR` override each one independently, and
|
|
244
|
+
`KXM_DATA_PATH`/`KXM_LOG_PATH` move two files again inside that. `$D` therefore **defaults to `$R/.kxm`**, and the two
|
|
245
|
+
move together only when the *workspace* is relocated: `KXM_WORKSPACE_DIR` (or
|
|
246
|
+
`--workspace`) moves `$D` and every default beneath it, while `KXM_CONFIG_DIR`,
|
|
247
|
+
`KXM_LOGS_DIR`, `KXM_ASSETS_DIR`, `KXM_STATE_DIR`, `KXM_DATA_PATH` and `KXM_LOG_PATH`
|
|
248
|
+
move **their own target and nothing else** — `KXM_STATE_DIR=/srv/state` alone leaves `$D`
|
|
249
|
+
at `$R/.kxm` and shifts only `$W`. A backup that assumes one shared location starts
|
|
250
|
+
omitting the other in exactly that case, which is why every row below is labelled as a
|
|
251
|
+
default.
|
|
252
|
+
- **`$W`** — the workspace *state* directory: `KXM_STATE_DIR` when set, else `$D/state`
|
|
253
|
+
(and `--workspace` derives it, ignoring that variable). It holds the
|
|
254
|
+
hub database, worker routing/recovery manifests and Pi sessions.
|
|
255
|
+
- **`$C`** — user configuration: `KXM_USER_CONFIG_DIR`, else `~/.config/kxm`.
|
|
256
|
+
- **`$T`** — federated telemetry output: an explicit global directory joined with
|
|
257
|
+
**`telemetry/`**, else `$XDG_CONFIG_HOME/kxm/telemetry`, else `~/.config/kxm/telemetry`.
|
|
258
|
+
It is built from `XDG_CONFIG_HOME`/`HOME`, **not** from `KXM_USER_CONFIG_DIR`, so `$T` can
|
|
259
|
+
land outside `$C`; and it is a *different file* from local accounting in `$D/logs`.
|
|
260
|
+
|
|
261
|
+
| Path | Contents | Loss means |
|
|
262
|
+
|---|---|---|
|
|
263
|
+
| `$W/kxm.db` (+ `-wal`, `-shm`, or `KXM_DATA_PATH`) | hub store: agents, messages, workflow runs, checkpoints, gate evidence | hub history and delivery state |
|
|
264
|
+
| `$S/runtime/registry.db` | Runtime registry, including the **supervisor identity and claim row** | which projects this Runtime knows; the claim is a registry row — there is no `supervisor.json` |
|
|
265
|
+
| `$S/runtime/projects/<projectKey>/run-events.db` (+ `-wal`/`-shm`) | event-sourced run state, commands, drives, receipts, gate evidence, intake, coordinators, pause control | run history and every receipt that proves it |
|
|
266
|
+
| `$S/runtime/projects/<projectKey>/run-events.db.run-prompts.json` | prompt text; the sidecar name appends to the **full** database filename | the prompts that explain the runs — restoring databases without sidecars is a partial restore |
|
|
267
|
+
| `$S/projects/<control-root-hash>/repository-bindings.json` | host-local member repository paths | member bindings are host state, outside the project tree |
|
|
268
|
+
| `$S/update.yaml` | release/update configuration consumed by the updater | the box reverts to defaults on the next update path |
|
|
269
|
+
| `$W/pi-sessions/<workerKey>/{default,runs/<runId>}/` | Pi model histories | **optional by existing policy** (see *Workflow-specific Pi sessions*): never a system of record — decide and record, do not silently widen scope |
|
|
270
|
+
| `$W/worker-session-binding-<workerKey>.json` (+ `.corrupt-*`), `worker-context-*.json`, `worker-recovery-*.json` | routing and recovery manifests | not optional: these are what make worker routing resumable after a restart |
|
|
271
|
+
| `$R/.kxm/…` project definition: `project.yaml`, `config.yaml`, `agents/`, `models/`, `workflows/`, `gates.yaml`, `roles/`, `role-hosts.yaml` (or `.json`), `producers.yaml`, `roster.yaml`, `routes.yaml`, `prices.yaml`, `repo/`, `project/env.yaml`, `template-provenance.yaml` | project, role, route, price and provenance definition | the tenant stops being reproducible — and a restore without `roster.yaml`/`routes.yaml`/`prices.yaml` comes back with **different admission and cost behaviour** while reporting itself healthy |
|
|
272
|
+
| `$R/.kxm/goals/`, `tasks/`, `memory/` (with `memory/candidates/`), `skills/` (candidate/promoted/rejected, history, patches) | durable work and learning records | open goals/tasks and approved memory disappear |
|
|
273
|
+
| `$R/.kxm/candidates/` — improvement candidate JSON and their diffs, **default only**: `kxm improve report --out-dir` relocates this directory outside every root listed here | the improvement queue itself | proposed fixes nobody was told about |
|
|
274
|
+
| each bound member repository's own `$memberRepo/.kxm/repo/repo.yaml` and `.kxm/repo/env.yaml` | member repository definition and environment | for **externally bound** members, the binding JSON alone is not enough — these files live on the member's own filesystem and need their own backup or an explicit, checked reconstruction prerequisite |
|
|
275
|
+
| `$D/assets/` (default; `KXM_ASSETS_DIR` relocates it) — retrospectives, improvements, artifacts, evidence | exported evidence | provenance and the ability to audit a past decision |
|
|
276
|
+
| `$D/logs/` (default; `KXM_LOGS_DIR` relocates the directory and `KXM_LOG_PATH` the hub log) and `$D/logs/telemetry.jsonl` | operator logs and **local** usage accounting — the spend numbers routing reports read | no local accounting to reconcile against |
|
|
277
|
+
| `KXM_WORKER_LOG_PATH` / `KXM_AGENT_LOG_PATH` targets (defaulting under `$D/logs`) | per-worker lifecycle and raw Pi output | worker diagnostics; **separate overrides, not local accounting** |
|
|
278
|
+
| `$T/model-metrics.jsonl` | **federated** metrics only. Absent almost everywhere: the exporter exists and `telemetry.federated` defaults to `true` in the shipped config, but **no hub or CLI path calls it today**, so absence is the normal state rather than evidence someone opted out. A different file from local accounting, which is `$D/logs/telemetry.jsonl` | cross-machine reporting continuity, and a privacy boundary worth naming: federated records are separate, with `anonymize` defaulting to `true` |
|
|
279
|
+
| `$S/hub-binding.json`, `$S/hub-env.json`, `$C/session.token` | host hub URL, credentials, local session token | a re-bind and a token rotation. **Secrets:** prefer regeneration to shipping them off-box, and never commit them |
|
|
280
|
+
| `$C` global roles/workflows/host configuration | user-level defaults | operator conventions |
|
|
281
|
+
|
|
282
|
+
**Overrides are part of the backup record.** `KXM_WORKSPACE_DIR` (and the `--workspace`
|
|
283
|
+
flag, which additionally **ignores** the per-directory variables) moves every `$D`
|
|
284
|
+
**default** at once; a directory with its own override stays where that variable points.
|
|
285
|
+
Neither moves `$R`, so the fixed project tree must still be backed up from the checkout even
|
|
286
|
+
when the workspace was relocated elsewhere — and copying the whole checkout is what protects
|
|
287
|
+
`$R`'s default locations, which is why an enumerated-paths backup should be re-checked
|
|
288
|
+
against this table whenever a loader grows a file. `KXM_STATE_HOME` moves `$S`
|
|
289
|
+
only if absolute. An explicit telemetry directory is likewise joined with `telemetry/`,
|
|
290
|
+
not used verbatim.
|
|
291
|
+
`KXM_DATA_PATH`, `KXM_STATE_DIR`, `KXM_CONFIG_DIR`, `KXM_ASSETS_DIR`, `KXM_LOGS_DIR`,
|
|
292
|
+
`KXM_LOG_PATH`, `KXM_WORKER_LOG_PATH`, `KXM_AGENT_LOG_PATH` or an explicit telemetry
|
|
293
|
+
directory each relocate one more thing. Record every override **with** the backup, or a
|
|
294
|
+
restore lands somewhere the running service will not look.
|
|
295
|
+
|
|
296
|
+
**Disposable, not backup material:** `session-brief.json`, `update-check.json`,
|
|
297
|
+
`*.error`, PID/claim files such as `hub.pid` and `worker-<key>.pid`, and
|
|
298
|
+
`supervisor.token` (host-local secret, re-generated on start). WAL-consistent copying or
|
|
299
|
+
`VACUUM INTO` applies to **every** SQLite file above, not only the hub database.
|
|
300
|
+
|
|
301
|
+
**Back up (stopped-state recipe):**
|
|
302
|
+
|
|
303
|
+
1. Stop **both** services, confirm they are down, and **keep them down until the copy
|
|
304
|
+
finishes**. `kxm hub stop` covers the hub and its worker PID claims; the Runtime
|
|
305
|
+
supervisor is a **separate** process owning `registry.db` and the project event stores,
|
|
306
|
+
stopped by `kxm runtime stop` — and that call acknowledges shutdown *initiated*, not
|
|
307
|
+
databases closed. So: verify neither reports live, then suspend whatever would start them
|
|
308
|
+
again — the service manager's auto-restart, hub autostart on login, and any client that
|
|
309
|
+
would reconnect and begin new work (a bound CLI, MCP server or Pi worker restarting a
|
|
310
|
+
supervisor on demand). A manager that respawns the hub halfway through a copy produces a
|
|
311
|
+
backup that is internally inconsistent across files, which is precisely the failure mode
|
|
312
|
+
this recipe is otherwise careful about. Only then copy.
|
|
313
|
+
2. Copy the whole set above as one tree — **every root**, `$R`, `$D`, `$W`, `$S`, `$C` and
|
|
314
|
+
`$T` — or take `VACUUM INTO` snapshots per database. **Snapshots replace the database
|
|
315
|
+
copies, not the file copy**: configuration, repository bindings, prompt sidecars,
|
|
316
|
+
routing manifests and update configuration are not databases, so a snapshot-only backup
|
|
317
|
+
reproduces exactly the failure this section exists to remove. The hub's own backup path already writes a hashed manifest and records
|
|
318
|
+
a schema version ceiling; keep that manifest with the files.
|
|
319
|
+
3. Record the package version, configuration revision and schema versions beside the copy.
|
|
320
|
+
A restore that cannot state which release produced it is not a restore path.
|
|
321
|
+
4. Keep at least one rotation, and bound retention explicitly — run events and prompt
|
|
322
|
+
sidecars grow, and unbounded retention is how a tenant box fills up.
|
|
323
|
+
|
|
324
|
+
**Restore:**
|
|
325
|
+
|
|
326
|
+
1. Stop the services. Move the current state aside rather than overwriting it.
|
|
327
|
+
2. Place each file back at its recorded path under the right root — `KXM_DATA_PATH`, the
|
|
328
|
+
Runtime registry and each project event store **with its sidecar**, repository bindings
|
|
329
|
+
under `$S/projects/…`, config, and `$S/runtime/registry.db` so the supervisor claim
|
|
330
|
+
returns with it.
|
|
331
|
+
3. Start the hub and confirm `/ready`, then `kxm hub view` — including that the reported
|
|
332
|
+
binding scope is what the environment actually is.
|
|
333
|
+
4. Read back a run and its drive receipt, and confirm prompt text is present. Restoring
|
|
334
|
+
databases without their sidecars leaves runs whose prompts are gone; that is a partial
|
|
335
|
+
restore, not a success.
|
|
336
|
+
|
|
337
|
+
The runtime refuses a database whose schema version is newer than it supports, so
|
|
338
|
+
restore order is: matching-or-newer release, then data. Online backups need a
|
|
339
|
+
SQLite-aware tool or a consistent snapshot of each database with its `-wal` and `-shm`;
|
|
340
|
+
a plain copy of a live `kxm.db` can omit committed WAL data.
|
|
341
|
+
|
|
342
|
+
Test restoration periodically. Routine unattended recovery (automated discovery of every
|
|
343
|
+
Runtime store plus sidecars) is deliberately **not** claimed here: it is a tracked
|
|
344
|
+
post-MVP item, and today this procedure is executed stopped and by hand.
|
|
169
345
|
|
|
170
346
|
## Upgrade and rollback
|
|
171
347
|
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
|
|
3
3
|
"name": "kxm",
|
|
4
4
|
"displayName": "KXM",
|
|
5
|
-
"version": "0.7.
|
|
5
|
+
"version": "0.7.52",
|
|
6
6
|
"description": "Headless multi-agent orchestration, durable workflows, and a live operator dashboard for Pi and Claude Code",
|
|
7
7
|
"author": {
|
|
8
8
|
"name": "KontextMind",
|
package/plugins/kxm/dist/cli.js
CHANGED
|
@@ -19657,6 +19657,21 @@ function readHubEnvRecord(env = process.env) {
|
|
|
19657
19657
|
...record.projectTokens !== void 0 ? { projectTokens: record.projectTokens } : {}
|
|
19658
19658
|
};
|
|
19659
19659
|
}
|
|
19660
|
+
function hasClientHubCredential(env = process.env, project) {
|
|
19661
|
+
if (env.KXM_AUTH_TOKEN?.trim()) return true;
|
|
19662
|
+
let record;
|
|
19663
|
+
try {
|
|
19664
|
+
record = readHubEnvRecord(env);
|
|
19665
|
+
} catch (error) {
|
|
19666
|
+
throw new HubEnvError(
|
|
19667
|
+
`${error instanceof Error ? error.message : String(error)}; refusing to guess a credential \u2014 repair or remove ${hubEnvFile(env)}`
|
|
19668
|
+
);
|
|
19669
|
+
}
|
|
19670
|
+
if (record?.authToken?.trim()) return true;
|
|
19671
|
+
const tokens = record?.projectTokens ?? {};
|
|
19672
|
+
if (project !== void 0) return typeof tokens[project] === "string" && tokens[project].trim().length > 0;
|
|
19673
|
+
return Object.values(tokens).some((token) => typeof token === "string" && token.trim().length > 0);
|
|
19674
|
+
}
|
|
19660
19675
|
function resolveClientHubAuthToken(env, project) {
|
|
19661
19676
|
const envToken = env.KXM_AUTH_TOKEN?.trim();
|
|
19662
19677
|
if (envToken) return envToken;
|
|
@@ -26098,6 +26113,17 @@ function validateHubUrl(raw) {
|
|
|
26098
26113
|
}
|
|
26099
26114
|
return parsed.href.replace(/\/$/, "");
|
|
26100
26115
|
}
|
|
26116
|
+
function hubBindingScope(url) {
|
|
26117
|
+
let host;
|
|
26118
|
+
try {
|
|
26119
|
+
host = new URL(url).hostname.toLowerCase();
|
|
26120
|
+
} catch {
|
|
26121
|
+
return "remote";
|
|
26122
|
+
}
|
|
26123
|
+
if (host === "localhost" || host === "::1" || host === "[::1]" || host.endsWith(".localhost")) return "loopback";
|
|
26124
|
+
const v4 = /^127\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$/.exec(host);
|
|
26125
|
+
return v4 && [v4[1], v4[2], v4[3]].every((part) => Number(part) <= 255) ? "loopback" : "remote";
|
|
26126
|
+
}
|
|
26101
26127
|
function isIsoTimestamp(value) {
|
|
26102
26128
|
if (Number.isNaN(Date.parse(value))) return false;
|
|
26103
26129
|
return value === new Date(value).toISOString();
|
|
@@ -45212,9 +45238,10 @@ var MAX_SESSION_BRIEF_PLANS = 5;
|
|
|
45212
45238
|
var SESSION_BRIEF_SCHEMA = "kxm.session-brief.v1";
|
|
45213
45239
|
var DEFAULT_SESSION_BRIEF_STALE_SECONDS = 5;
|
|
45214
45240
|
function hubPrefix(hub) {
|
|
45215
|
-
|
|
45216
|
-
if (hub?.state === "
|
|
45217
|
-
if (hub?.state === "
|
|
45241
|
+
const suffix = hub?.scope === "remote" ? "/remote" : "";
|
|
45242
|
+
if (hub?.state === "on" || hub?.state === void 0 && hub?.online === true) return `kxm hub:on${suffix}`;
|
|
45243
|
+
if (hub?.state === "off" || hub?.state === void 0 && hub?.online === false) return `kxm hub:off${suffix}`;
|
|
45244
|
+
if (hub?.state === "unknown") return `kxm hub:unknown${suffix}`;
|
|
45218
45245
|
return "kxm";
|
|
45219
45246
|
}
|
|
45220
45247
|
function truncate(value, width) {
|
|
@@ -45848,8 +45875,21 @@ async function refreshKxmUpdateNotice(runtime, config) {
|
|
|
45848
45875
|
async function cmdStatus(runtime) {
|
|
45849
45876
|
const health = await hubGet(`${runtime.serverUrl}/health`, runtime.fetchImpl);
|
|
45850
45877
|
const ready = await hubGet(`${runtime.serverUrl}/ready`, runtime.fetchImpl);
|
|
45851
|
-
const
|
|
45852
|
-
|
|
45878
|
+
const effectiveScope = hubBindingScope(runtime.serverUrl);
|
|
45879
|
+
const overridden = Boolean(runtime.boundHubUrl && runtime.boundHubUrl !== runtime.serverUrl);
|
|
45880
|
+
const payload = {
|
|
45881
|
+
ok: health.ok && ready.ok,
|
|
45882
|
+
command: "hub view",
|
|
45883
|
+
target: { url: runtime.serverUrl, scope: effectiveScope, ...overridden ? { source: "env" } : {} },
|
|
45884
|
+
health: health.body,
|
|
45885
|
+
ready: ready.body
|
|
45886
|
+
};
|
|
45887
|
+
print(
|
|
45888
|
+
runtime.io,
|
|
45889
|
+
runtime.json,
|
|
45890
|
+
payload,
|
|
45891
|
+
`hub health=${health.ok} ready=${ready.ok} \xB7 ${effectiveScope} hub${overridden ? " (KXM_SERVER_URL)" : ""}`
|
|
45892
|
+
);
|
|
45853
45893
|
return payload.ok ? 0 : 1;
|
|
45854
45894
|
}
|
|
45855
45895
|
async function cmdDash(runtime, options = {}) {
|
|
@@ -45934,6 +45974,7 @@ function formatHubBindHealth(health) {
|
|
|
45934
45974
|
if (health === "off") return "health=off (nothing answered; run kxm hub start)";
|
|
45935
45975
|
return "health=unknown (no reply within 300 ms)";
|
|
45936
45976
|
}
|
|
45977
|
+
var HUB_BIND_UNAUTHENTICATED_HINT = "export KXM_AUTH_TOKEN (or point KXM_STATE_HOME at the hub-env record that already holds one), then re-run; the hub itself requires a token beyond loopback";
|
|
45937
45978
|
async function cmdHubBind(runtime, rawUrl) {
|
|
45938
45979
|
let url;
|
|
45939
45980
|
try {
|
|
@@ -45950,14 +45991,64 @@ async function cmdHubBind(runtime, rawUrl) {
|
|
|
45950
45991
|
}
|
|
45951
45992
|
throw error;
|
|
45952
45993
|
}
|
|
45994
|
+
const scope = hubBindingScope(url);
|
|
45995
|
+
if (scope === "remote") {
|
|
45996
|
+
const bindProject = defaultProjectName(runtime.dirs.workdir, runtime.env) || "project";
|
|
45997
|
+
let credentialReady = false;
|
|
45998
|
+
try {
|
|
45999
|
+
credentialReady = hasClientHubCredential(runtime.env, bindProject);
|
|
46000
|
+
} catch (error) {
|
|
46001
|
+
print(
|
|
46002
|
+
runtime.io,
|
|
46003
|
+
runtime.json,
|
|
46004
|
+
{
|
|
46005
|
+
ok: false,
|
|
46006
|
+
command: "hub bind",
|
|
46007
|
+
error: "hub_credential_unreadable",
|
|
46008
|
+
url,
|
|
46009
|
+
scope,
|
|
46010
|
+
nextAction: "repair_hub_env_record",
|
|
46011
|
+
hint: `${error instanceof Error ? error.message : String(error)}; no binding was written`
|
|
46012
|
+
},
|
|
46013
|
+
`cannot read the hub credential: ${error instanceof Error ? error.message : String(error)}`
|
|
46014
|
+
);
|
|
46015
|
+
return 2;
|
|
46016
|
+
}
|
|
46017
|
+
if (!credentialReady) {
|
|
46018
|
+
print(
|
|
46019
|
+
runtime.io,
|
|
46020
|
+
runtime.json,
|
|
46021
|
+
{
|
|
46022
|
+
ok: false,
|
|
46023
|
+
command: "hub bind",
|
|
46024
|
+
error: "hub_bind_unauthenticated",
|
|
46025
|
+
url,
|
|
46026
|
+
scope,
|
|
46027
|
+
project: bindProject,
|
|
46028
|
+
// The hint belongs in the payload, not only the prose line: under --json the
|
|
46029
|
+
// prose is suppressed, and a refusal that names no next step gets debugged by
|
|
46030
|
+
// reading source.
|
|
46031
|
+
nextAction: "export_kxm_auth_token",
|
|
46032
|
+
hint: `${HUB_BIND_UNAUTHENTICATED_HINT} (needs a token for project ${bindProject})`
|
|
46033
|
+
},
|
|
46034
|
+
`refusing to bind remote hub ${url} with no credential for project ${bindProject}; ${HUB_BIND_UNAUTHENTICATED_HINT}`
|
|
46035
|
+
);
|
|
46036
|
+
return 2;
|
|
46037
|
+
}
|
|
46038
|
+
}
|
|
45953
46039
|
const file = hubBindingFile(runtime.env);
|
|
45954
46040
|
if (runtime.dryRun) {
|
|
45955
|
-
print(runtime.io, runtime.json, { ok: true, command: "hub bind", dryRun: true, url, file }, `would bind hub ${url}`);
|
|
46041
|
+
print(runtime.io, runtime.json, { ok: true, command: "hub bind", dryRun: true, url, scope, file }, `would bind hub ${url} (${scope})`);
|
|
45956
46042
|
return 0;
|
|
45957
46043
|
}
|
|
45958
46044
|
writeHubBinding({ schema: HUB_BINDING_SCHEMA, url, boundAt: (/* @__PURE__ */ new Date()).toISOString() }, runtime.env);
|
|
45959
46045
|
const { health, probeMs } = await probeHubHealth(url, runtime.fetchImpl);
|
|
45960
|
-
print(
|
|
46046
|
+
print(
|
|
46047
|
+
runtime.io,
|
|
46048
|
+
runtime.json,
|
|
46049
|
+
{ ok: true, command: "hub bind", url, scope, file, health, probeMs },
|
|
46050
|
+
`bound hub ${url} \xB7 ${scope} \xB7 ${formatHubBindHealth(health)}${scope === "remote" ? " \xB7 token leaves this machine" : ""}`
|
|
46051
|
+
);
|
|
45961
46052
|
return 0;
|
|
45962
46053
|
}
|
|
45963
46054
|
async function cmdHubUnbind(runtime) {
|
|
@@ -46271,7 +46362,8 @@ async function cmdSessionBrief(runtime, options = {}) {
|
|
|
46271
46362
|
state: health,
|
|
46272
46363
|
evidence: health === "unknown" ? "timeout" : "probed",
|
|
46273
46364
|
online: health === "on",
|
|
46274
|
-
url: targetUrl
|
|
46365
|
+
url: targetUrl,
|
|
46366
|
+
scope: hubBindingScope(targetUrl)
|
|
46275
46367
|
};
|
|
46276
46368
|
} else {
|
|
46277
46369
|
hub = { state: "off", evidence: "unconfigured", online: false };
|
|
@@ -36775,9 +36775,10 @@ var MAX_SESSION_BRIEF_PLANS = 5;
|
|
|
36775
36775
|
var SESSION_BRIEF_SCHEMA = "kxm.session-brief.v1";
|
|
36776
36776
|
var DEFAULT_SESSION_BRIEF_STALE_SECONDS = 5;
|
|
36777
36777
|
function hubPrefix(hub) {
|
|
36778
|
-
|
|
36779
|
-
if (hub?.state === "
|
|
36780
|
-
if (hub?.state === "
|
|
36778
|
+
const suffix = hub?.scope === "remote" ? "/remote" : "";
|
|
36779
|
+
if (hub?.state === "on" || hub?.state === void 0 && hub?.online === true) return `kxm hub:on${suffix}`;
|
|
36780
|
+
if (hub?.state === "off" || hub?.state === void 0 && hub?.online === false) return `kxm hub:off${suffix}`;
|
|
36781
|
+
if (hub?.state === "unknown") return `kxm hub:unknown${suffix}`;
|
|
36781
36782
|
return "kxm";
|
|
36782
36783
|
}
|
|
36783
36784
|
function truncate(value, width) {
|
|
@@ -17121,7 +17121,7 @@ async function deliverInboxNotification(messageId, delivered, notify) {
|
|
|
17121
17121
|
}
|
|
17122
17122
|
|
|
17123
17123
|
// plugins/kxm/src/mcp-server.ts
|
|
17124
|
-
var VERSION = "0.7.
|
|
17124
|
+
var VERSION = "0.7.52";
|
|
17125
17125
|
var inbox = /* @__PURE__ */ new Map();
|
|
17126
17126
|
var notifiedInbox = /* @__PURE__ */ new Set();
|
|
17127
17127
|
var meshClient;
|
package/plugins/kxm/package.json
CHANGED
|
@@ -3,7 +3,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync
|
|
|
3
3
|
import { join, resolve } from "node:path";
|
|
4
4
|
import { redactSecrets } from "../redact.ts";
|
|
5
5
|
import { defaultProjectName } from "../project-name.ts";
|
|
6
|
-
import { resolveClientHubAuthToken } from "../hub-env.ts";
|
|
6
|
+
import { hasClientHubCredential, resolveClientHubAuthToken } from "../hub-env.ts";
|
|
7
7
|
import { agentWorker, type Worker } from "../envelope.ts";
|
|
8
8
|
import { MESH_TUI_PANELS, runMeshTui, type MeshTuiPanel } from "../tui.ts";
|
|
9
9
|
import { formatSessionBriefText, loadSessionBriefAsync, type SessionHubStatus } from "../session-work.ts";
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
HubBindingError,
|
|
13
13
|
hubBindingFile,
|
|
14
14
|
probeHubHealth,
|
|
15
|
+
hubBindingScope,
|
|
15
16
|
readHubBinding,
|
|
16
17
|
removeHubBinding,
|
|
17
18
|
validateHubUrl,
|
|
@@ -104,8 +105,22 @@ export async function refreshKxmUpdateNotice(runtime: Runtime, config?: KxmUpdat
|
|
|
104
105
|
export async function cmdStatus(runtime: Runtime): Promise<number> {
|
|
105
106
|
const health = await hubGet(`${runtime.serverUrl}/health`, runtime.fetchImpl);
|
|
106
107
|
const ready = await hubGet(`${runtime.serverUrl}/ready`, runtime.fetchImpl);
|
|
107
|
-
|
|
108
|
-
|
|
108
|
+
// Scope on the status line deliberately: "attached across a network" and "attached on
|
|
109
|
+
// this box" are otherwise indistinguishable, and only one of them ships a token.
|
|
110
|
+
// Scope is a property of the URL actually contacted, not of whichever file the
|
|
111
|
+
// binding came from: KXM_SERVER_URL overrides the binding, and labelling the binding
|
|
112
|
+
// while probing an override would report "loopback" about a remote request.
|
|
113
|
+
const effectiveScope = hubBindingScope(runtime.serverUrl);
|
|
114
|
+
const overridden = Boolean(runtime.boundHubUrl && runtime.boundHubUrl !== runtime.serverUrl);
|
|
115
|
+
const payload = {
|
|
116
|
+
ok: health.ok && ready.ok,
|
|
117
|
+
command: "hub view",
|
|
118
|
+
target: { url: runtime.serverUrl, scope: effectiveScope, ...(overridden ? { source: "env" } : {}) },
|
|
119
|
+
health: health.body,
|
|
120
|
+
ready: ready.body,
|
|
121
|
+
};
|
|
122
|
+
print(runtime.io, runtime.json, payload,
|
|
123
|
+
`hub health=${health.ok} ready=${ready.ok} · ${effectiveScope} hub${overridden ? " (KXM_SERVER_URL)" : ""}`);
|
|
109
124
|
return payload.ok ? 0 : 1;
|
|
110
125
|
}
|
|
111
126
|
|
|
@@ -191,6 +206,10 @@ export function formatHubBindHealth(health: HubHealth): string {
|
|
|
191
206
|
return "health=unknown (no reply within 300 ms)";
|
|
192
207
|
}
|
|
193
208
|
|
|
209
|
+
/** Kept in one place so the JSON payload and the prose line cannot drift apart. */
|
|
210
|
+
const HUB_BIND_UNAUTHENTICATED_HINT =
|
|
211
|
+
"export KXM_AUTH_TOKEN (or point KXM_STATE_HOME at the hub-env record that already holds one), then re-run; the hub itself requires a token beyond loopback";
|
|
212
|
+
|
|
194
213
|
export async function cmdHubBind(runtime: Runtime, rawUrl: string): Promise<number> {
|
|
195
214
|
let url: string;
|
|
196
215
|
try {
|
|
@@ -207,14 +226,73 @@ export async function cmdHubBind(runtime: Runtime, rawUrl: string): Promise<numb
|
|
|
207
226
|
}
|
|
208
227
|
throw error;
|
|
209
228
|
}
|
|
229
|
+
const scope = hubBindingScope(url);
|
|
230
|
+
// Scope-scoped on purpose, and only for **this command**. A remote binding puts a
|
|
231
|
+
// bearer on a network path, so it is refused when nothing can authenticate it; a
|
|
232
|
+
// stored-but-unusable URL otherwise reads later like a network fault and gets debugged
|
|
233
|
+
// as one. Loopback is not consulted here because a loopback URL puts nothing on a wire —
|
|
234
|
+
// not because credentials are never resolved locally: other client paths still call
|
|
235
|
+
// `resolveClientHubAuthToken` regardless of scope, so a damaged host record can still
|
|
236
|
+
// fail `kxm peer list` on loopback. Narrowing the claim is the point; the first version
|
|
237
|
+
// of this guard checked the record *before* the scope and refused loopback binds that had
|
|
238
|
+
// always worked, which is a regression against behaviour predating this slice.
|
|
239
|
+
if (scope === "remote") {
|
|
240
|
+
// The project that will actually authenticate: a record holding only another
|
|
241
|
+
// project's token cannot authorise this one.
|
|
242
|
+
const bindProject = defaultProjectName(runtime.dirs.workdir, runtime.env) || "project";
|
|
243
|
+
let credentialReady = false;
|
|
244
|
+
try {
|
|
245
|
+
credentialReady = hasClientHubCredential(runtime.env, bindProject);
|
|
246
|
+
} catch (error) {
|
|
247
|
+
// A malformed record is a readable configuration failure, not an uncaught throw
|
|
248
|
+
// past a user-facing entry point.
|
|
249
|
+
print(
|
|
250
|
+
runtime.io,
|
|
251
|
+
runtime.json,
|
|
252
|
+
{
|
|
253
|
+
ok: false,
|
|
254
|
+
command: "hub bind",
|
|
255
|
+
error: "hub_credential_unreadable",
|
|
256
|
+
url,
|
|
257
|
+
scope,
|
|
258
|
+
nextAction: "repair_hub_env_record",
|
|
259
|
+
hint: `${error instanceof Error ? error.message : String(error)}; no binding was written`,
|
|
260
|
+
},
|
|
261
|
+
`cannot read the hub credential: ${error instanceof Error ? error.message : String(error)}`,
|
|
262
|
+
);
|
|
263
|
+
return 2;
|
|
264
|
+
}
|
|
265
|
+
if (!credentialReady) {
|
|
266
|
+
print(
|
|
267
|
+
runtime.io,
|
|
268
|
+
runtime.json,
|
|
269
|
+
{
|
|
270
|
+
ok: false,
|
|
271
|
+
command: "hub bind",
|
|
272
|
+
error: "hub_bind_unauthenticated",
|
|
273
|
+
url,
|
|
274
|
+
scope,
|
|
275
|
+
project: bindProject,
|
|
276
|
+
// The hint belongs in the payload, not only the prose line: under --json the
|
|
277
|
+
// prose is suppressed, and a refusal that names no next step gets debugged by
|
|
278
|
+
// reading source.
|
|
279
|
+
nextAction: "export_kxm_auth_token",
|
|
280
|
+
hint: `${HUB_BIND_UNAUTHENTICATED_HINT} (needs a token for project ${bindProject})`,
|
|
281
|
+
},
|
|
282
|
+
`refusing to bind remote hub ${url} with no credential for project ${bindProject}; ${HUB_BIND_UNAUTHENTICATED_HINT}`,
|
|
283
|
+
);
|
|
284
|
+
return 2;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
210
287
|
const file = hubBindingFile(runtime.env);
|
|
211
288
|
if (runtime.dryRun) {
|
|
212
|
-
print(runtime.io, runtime.json, { ok: true, command: "hub bind", dryRun: true, url, file }, `would bind hub ${url}`);
|
|
289
|
+
print(runtime.io, runtime.json, { ok: true, command: "hub bind", dryRun: true, url, scope, file }, `would bind hub ${url} (${scope})`);
|
|
213
290
|
return 0;
|
|
214
291
|
}
|
|
215
292
|
writeHubBinding({ schema: HUB_BINDING_SCHEMA, url, boundAt: new Date().toISOString() }, runtime.env);
|
|
216
293
|
const { health, probeMs } = await probeHubHealth(url, runtime.fetchImpl);
|
|
217
|
-
print(runtime.io, runtime.json, { ok: true, command: "hub bind", url, file, health, probeMs },
|
|
294
|
+
print(runtime.io, runtime.json, { ok: true, command: "hub bind", url, scope, file, health, probeMs },
|
|
295
|
+
`bound hub ${url} · ${scope} · ${formatHubBindHealth(health)}${scope === "remote" ? " · token leaves this machine" : ""}`);
|
|
218
296
|
return 0;
|
|
219
297
|
}
|
|
220
298
|
|
|
@@ -540,6 +618,7 @@ export async function cmdSessionBrief(runtime: Runtime, options: { status?: bool
|
|
|
540
618
|
evidence: health === "unknown" ? "timeout" : "probed",
|
|
541
619
|
online: health === "on",
|
|
542
620
|
url: targetUrl,
|
|
621
|
+
scope: hubBindingScope(targetUrl),
|
|
543
622
|
};
|
|
544
623
|
} else {
|
|
545
624
|
hub = { state: "off", evidence: "unconfigured", online: false };
|
|
@@ -7,6 +7,15 @@ export const HUB_HEALTH_PROBE_MS = 300;
|
|
|
7
7
|
|
|
8
8
|
export type HubHealth = "on" | "off" | "unknown";
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Where a bound hub sits relative to this machine — a trust question, not a cosmetic one.
|
|
12
|
+
* A loopback URL never puts a bearer on a network; a remote URL means the operator chose
|
|
13
|
+
* to. `kxm hub bind` therefore refuses a remote URL it cannot authenticate, mirroring the
|
|
14
|
+
* rule the hub applies to its own listener ("KXM_AUTH_TOKEN is required when binding
|
|
15
|
+
* beyond localhost").
|
|
16
|
+
*/
|
|
17
|
+
export type HubBindingScope = "loopback" | "remote";
|
|
18
|
+
|
|
10
19
|
export interface HubBindingRecord {
|
|
11
20
|
schema: typeof HUB_BINDING_SCHEMA;
|
|
12
21
|
url: string;
|
|
@@ -62,6 +71,19 @@ export function validateHubUrl(raw: string): string {
|
|
|
62
71
|
return parsed.href.replace(/\/$/, "");
|
|
63
72
|
}
|
|
64
73
|
|
|
74
|
+
/** Loopback literals only; `0.0.0.0`, a LAN address or a hostname are all remote. */
|
|
75
|
+
export function hubBindingScope(url: string): HubBindingScope {
|
|
76
|
+
let host: string;
|
|
77
|
+
try {
|
|
78
|
+
host = new URL(url).hostname.toLowerCase();
|
|
79
|
+
} catch {
|
|
80
|
+
return "remote";
|
|
81
|
+
}
|
|
82
|
+
if (host === "localhost" || host === "::1" || host === "[::1]" || host.endsWith(".localhost")) return "loopback";
|
|
83
|
+
const v4 = /^127\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$/.exec(host);
|
|
84
|
+
return v4 && [v4[1], v4[2], v4[3]].every((part) => Number(part) <= 255) ? "loopback" : "remote";
|
|
85
|
+
}
|
|
86
|
+
|
|
65
87
|
function isIsoTimestamp(value: string): boolean {
|
|
66
88
|
if (Number.isNaN(Date.parse(value))) return false;
|
|
67
89
|
return value === new Date(value).toISOString();
|
|
@@ -200,6 +200,34 @@ export function resolveHubCredentials(options: ResolveHubCredentialsOptions = {}
|
|
|
200
200
|
* matches the credential precedence `kxm hub start` announces, so a hub
|
|
201
201
|
* started fresh (generated token persisted) accepts authenticated client
|
|
202
202
|
* commands without the operator exporting the token. */
|
|
203
|
+
/**
|
|
204
|
+
* Can this machine authenticate to a hub at all, following the same precedence one-shot
|
|
205
|
+
* clients use: explicit `KXM_AUTH_TOKEN`, else the persisted record's admin token, else a
|
|
206
|
+
* persisted **project** token. Read-only on purpose — a refusal has to tell the operator
|
|
207
|
+
* to configure something, not reveal that the tool quietly configured it for them.
|
|
208
|
+
*
|
|
209
|
+
* Pass `project` when the caller knows which project will authenticate. A record holding
|
|
210
|
+
* only another project's token cannot authorise this one, and a guard that counts it as a
|
|
211
|
+
* credential stores a binding that will fail exactly like the one it prevented.
|
|
212
|
+
*/
|
|
213
|
+
export function hasClientHubCredential(env: NodeJS.ProcessEnv = process.env, project?: string): boolean {
|
|
214
|
+
if (env.KXM_AUTH_TOKEN?.trim()) return true;
|
|
215
|
+
let record: HubEnvRecord | undefined;
|
|
216
|
+
try {
|
|
217
|
+
record = readHubEnvRecord(env);
|
|
218
|
+
} catch (error) {
|
|
219
|
+
// A malformed or invalid record is a configuration failure, not "no credential".
|
|
220
|
+
// Letting it surface as an uncaught throw would print neither JSON nor prose.
|
|
221
|
+
throw new HubEnvError(
|
|
222
|
+
`${error instanceof Error ? error.message : String(error)}; refusing to guess a credential — repair or remove ${hubEnvFile(env)}`,
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
if (record?.authToken?.trim()) return true;
|
|
226
|
+
const tokens = record?.projectTokens ?? {};
|
|
227
|
+
if (project !== undefined) return typeof tokens[project] === "string" && tokens[project].trim().length > 0;
|
|
228
|
+
return Object.values(tokens).some((token) => typeof token === "string" && token.trim().length > 0);
|
|
229
|
+
}
|
|
230
|
+
|
|
203
231
|
export function resolveClientHubAuthToken(env: NodeJS.ProcessEnv, project: string): string | undefined {
|
|
204
232
|
const envToken = env.KXM_AUTH_TOKEN?.trim();
|
|
205
233
|
if (envToken) return envToken;
|
|
@@ -8,7 +8,7 @@ import { AGENT_COMMANDS_MAP, enforceToolPolicy, getMcpTools, reconcileInbox } fr
|
|
|
8
8
|
import { deliverInboxNotification } from "./inbox.ts";
|
|
9
9
|
import type { HubEvent, MessageRecord } from "./protocol.ts";
|
|
10
10
|
|
|
11
|
-
const VERSION = "0.7.
|
|
11
|
+
const VERSION = "0.7.52";
|
|
12
12
|
const inbox = new Map<string, MessageRecord>();
|
|
13
13
|
const notifiedInbox = new Set<string>();
|
|
14
14
|
let meshClient: HubClient | undefined;
|
|
@@ -42,6 +42,8 @@ export interface SessionHubStatus {
|
|
|
42
42
|
evidence: "probed" | "bound" | "cached" | "unconfigured" | "process" | "timeout";
|
|
43
43
|
online?: boolean;
|
|
44
44
|
url?: string;
|
|
45
|
+
/** Loopback or remote, so a brief never reads the same on both. */
|
|
46
|
+
scope?: "loopback" | "remote";
|
|
45
47
|
}
|
|
46
48
|
|
|
47
49
|
export interface SessionShipStatus {
|
|
@@ -65,9 +67,13 @@ export interface SessionBrief {
|
|
|
65
67
|
}
|
|
66
68
|
|
|
67
69
|
function hubPrefix(hub?: Partial<SessionHubStatus>): string {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
70
|
+
// A remote hub carries a bearer over a network; a loopback one does not. The JSON
|
|
71
|
+
// said so while every text surface printed the same bytes for both, which is the
|
|
72
|
+
// distinction an operator actually reads.
|
|
73
|
+
const suffix = hub?.scope === "remote" ? "/remote" : "";
|
|
74
|
+
if (hub?.state === "on" || (hub?.state === undefined && hub?.online === true)) return `kxm hub:on${suffix}`;
|
|
75
|
+
if (hub?.state === "off" || (hub?.state === undefined && hub?.online === false)) return `kxm hub:off${suffix}`;
|
|
76
|
+
if (hub?.state === "unknown") return `kxm hub:unknown${suffix}`;
|
|
71
77
|
return "kxm";
|
|
72
78
|
}
|
|
73
79
|
|