@huynle/hub 0.6.1 → 0.7.2
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/CHANGELOG.md +870 -0
- package/Dockerfile +33 -0
- package/README.md +1007 -66
- package/package.json +47 -2
- package/packages/claude-code/package.json +12 -0
- package/packages/claude-code/src/bridge.ts +79 -0
- package/packages/claude-code/src/daemon.ts +339 -0
- package/packages/claude-code/src/hook-input.ts +44 -0
- package/packages/claude-code/src/ids.ts +27 -0
- package/packages/claude-code/src/index.ts +18 -0
- package/packages/claude-code/src/launcher.ts +265 -0
- package/packages/claude-code/src/main.ts +127 -0
- package/packages/claude-code/src/mcp.ts +109 -0
- package/packages/claude-code/src/paths.ts +34 -0
- package/packages/claude-code/src/plugin.ts +99 -0
- package/packages/claude-code/src/references.ts +51 -0
- package/packages/claude-code/src/sessions.ts +600 -0
- package/packages/claude-code/src/tools.ts +66 -0
- package/packages/claude-code/src/wire.ts +71 -0
- package/packages/claude-code/src/wrapper.ts +156 -0
- package/packages/cli/dist/hub-cli.js +21647 -0
- package/packages/cli/package.json +11 -0
- package/packages/cli/src/acting.ts +70 -0
- package/packages/cli/src/args.ts +91 -0
- package/packages/cli/src/bootstrap/adapters/archive-stream.ts +276 -0
- package/packages/cli/src/bootstrap/adapters/claude/README.md +63 -0
- package/packages/cli/src/bootstrap/adapters/claude/adapter.ts +250 -0
- package/packages/cli/src/bootstrap/adapters/claude/index.ts +42 -0
- package/packages/cli/src/bootstrap/adapters/claude/node.ts +99 -0
- package/packages/cli/src/bootstrap/adapters/claude/openpgp.ts +221 -0
- package/packages/cli/src/bootstrap/adapters/claude/ports.ts +44 -0
- package/packages/cli/src/bootstrap/adapters/claude/source.ts +160 -0
- package/packages/cli/src/bootstrap/adapters/claude/verify.ts +165 -0
- package/packages/cli/src/bootstrap/adapters/codex/archive.ts +103 -0
- package/packages/cli/src/bootstrap/adapters/codex/index.ts +60 -0
- package/packages/cli/src/bootstrap/adapters/codex/plan.ts +129 -0
- package/packages/cli/src/bootstrap/adapters/codex/sigstore.ts +298 -0
- package/packages/cli/src/bootstrap/adapters/codex/source.ts +102 -0
- package/packages/cli/src/bootstrap/adapters/codex/verify.ts +298 -0
- package/packages/cli/src/bootstrap/adapters/inflate.ts +433 -0
- package/packages/cli/src/bootstrap/adapters/node-services/control.ts +392 -0
- package/packages/cli/src/bootstrap/adapters/node-services/index.ts +23 -0
- package/packages/cli/src/bootstrap/adapters/node-services/launch.ts +223 -0
- package/packages/cli/src/bootstrap/adapters/node-services/plan.ts +222 -0
- package/packages/cli/src/bootstrap/adapters/node-services/render.ts +183 -0
- package/packages/cli/src/bootstrap/adapters/node-services/validate.ts +197 -0
- package/packages/cli/src/bootstrap/adapters/opencode/README.md +204 -0
- package/packages/cli/src/bootstrap/adapters/opencode/apply.ts +205 -0
- package/packages/cli/src/bootstrap/adapters/opencode/archive.ts +91 -0
- package/packages/cli/src/bootstrap/adapters/opencode/config.ts +212 -0
- package/packages/cli/src/bootstrap/adapters/opencode/index.ts +81 -0
- package/packages/cli/src/bootstrap/adapters/opencode/plan.ts +266 -0
- package/packages/cli/src/bootstrap/adapters/opencode/verify.ts +208 -0
- package/packages/cli/src/bootstrap/adapters/readiness.ts +19 -0
- package/packages/cli/src/bootstrap/ansible/index.ts +2 -0
- package/packages/cli/src/bootstrap/ansible/render.ts +279 -0
- package/packages/cli/src/bootstrap/contracts/actions.ts +470 -0
- package/packages/cli/src/bootstrap/contracts/artifact.ts +323 -0
- package/packages/cli/src/bootstrap/contracts/canonical.ts +54 -0
- package/packages/cli/src/bootstrap/contracts/credentials.ts +108 -0
- package/packages/cli/src/bootstrap/contracts/discovery.ts +215 -0
- package/packages/cli/src/bootstrap/contracts/envelope.ts +143 -0
- package/packages/cli/src/bootstrap/contracts/exec.ts +74 -0
- package/packages/cli/src/bootstrap/contracts/harness.ts +202 -0
- package/packages/cli/src/bootstrap/contracts/index.ts +28 -0
- package/packages/cli/src/bootstrap/contracts/journal.ts +198 -0
- package/packages/cli/src/bootstrap/contracts/paths.ts +91 -0
- package/packages/cli/src/bootstrap/contracts/plan.ts +155 -0
- package/packages/cli/src/bootstrap/contracts/platform.ts +62 -0
- package/packages/cli/src/bootstrap/contracts/preview.ts +190 -0
- package/packages/cli/src/bootstrap/contracts/profile.ts +105 -0
- package/packages/cli/src/bootstrap/contracts/secrets.ts +52 -0
- package/packages/cli/src/bootstrap/contracts/validate.ts +212 -0
- package/packages/cli/src/bootstrap/discovery/discover.ts +146 -0
- package/packages/cli/src/bootstrap/discovery/executables.ts +134 -0
- package/packages/cli/src/bootstrap/discovery/host.ts +96 -0
- package/packages/cli/src/bootstrap/discovery/index.ts +6 -0
- package/packages/cli/src/bootstrap/discovery/node-probe.ts +97 -0
- package/packages/cli/src/bootstrap/discovery/probe.ts +41 -0
- package/packages/cli/src/bootstrap/discovery/util.ts +13 -0
- package/packages/cli/src/bootstrap/enrollment/enroll.ts +226 -0
- package/packages/cli/src/bootstrap/enrollment/index.ts +7 -0
- package/packages/cli/src/bootstrap/enrollment/policy.ts +54 -0
- package/packages/cli/src/bootstrap/enrollment/result.ts +63 -0
- package/packages/cli/src/bootstrap/enrollment/status.ts +119 -0
- package/packages/cli/src/bootstrap/enrollment/store.ts +173 -0
- package/packages/cli/src/bootstrap/preview/index.ts +5 -0
- package/packages/cli/src/bootstrap/preview/links.ts +31 -0
- package/packages/cli/src/bootstrap/preview/plan.ts +242 -0
- package/packages/cli/src/bootstrap/preview/preview.ts +104 -0
- package/packages/cli/src/bootstrap/preview/render.ts +30 -0
- package/packages/cli/src/bootstrap/profiles/audit.ts +87 -0
- package/packages/cli/src/bootstrap/profiles/catalog.ts +49 -0
- package/packages/cli/src/bootstrap/profiles/desired.ts +96 -0
- package/packages/cli/src/bootstrap/profiles/drift.ts +286 -0
- package/packages/cli/src/bootstrap/profiles/history.ts +263 -0
- package/packages/cli/src/bootstrap/profiles/index.ts +11 -0
- package/packages/cli/src/bootstrap/profiles/upgrade.ts +276 -0
- package/packages/cli/src/bootstrap/profiles/versions.ts +50 -0
- package/packages/cli/src/bootstrap/release/README.md +115 -0
- package/packages/cli/src/bootstrap/release/assets.ts +39 -0
- package/packages/cli/src/bootstrap/release/cli.ts +332 -0
- package/packages/cli/src/bootstrap/release/harness-pins.json +94 -0
- package/packages/cli/src/bootstrap/release/manifest.ts +118 -0
- package/packages/cli/src/bootstrap/release/pins.ts +192 -0
- package/packages/cli/src/bootstrap/release/text-assets.d.ts +9 -0
- package/packages/cli/src/bootstrap/release/trust-root.json +13 -0
- package/packages/cli/src/bootstrap/release/trust-root.ts +39 -0
- package/packages/cli/src/bootstrap/release/verify.ts +88 -0
- package/packages/cli/src/bootstrap/transaction/builtin.ts +312 -0
- package/packages/cli/src/bootstrap/transaction/engine.ts +994 -0
- package/packages/cli/src/bootstrap/transaction/executor.ts +112 -0
- package/packages/cli/src/bootstrap/transaction/fs.ts +194 -0
- package/packages/cli/src/bootstrap/transaction/index.ts +12 -0
- package/packages/cli/src/bootstrap/transaction/json-merge.ts +403 -0
- package/packages/cli/src/bootstrap/transaction/lock.ts +132 -0
- package/packages/cli/src/bootstrap/transaction/resolve.ts +48 -0
- package/packages/cli/src/bootstrap/transaction/result.ts +60 -0
- package/packages/cli/src/bootstrap/transaction/state.ts +88 -0
- package/packages/cli/src/bootstrap/transaction/store.ts +189 -0
- package/packages/cli/src/bootstrap/transaction/stream.ts +254 -0
- package/packages/cli/src/cli.ts +814 -0
- package/packages/cli/src/credentials.ts +75 -0
- package/packages/cli/src/decisions.ts +106 -0
- package/packages/cli/src/format.ts +185 -0
- package/packages/cli/src/grants.ts +105 -0
- package/packages/cli/src/http.ts +86 -0
- package/packages/cli/src/launches.ts +157 -0
- package/packages/cli/src/login.ts +160 -0
- package/packages/cli/src/main.ts +63 -0
- package/packages/cli/src/mcp.ts +879 -0
- package/packages/cli/src/messaging.ts +108 -0
- package/packages/cli/src/node.ts +279 -0
- package/packages/cli/src/openshell.ts +166 -0
- package/packages/cli/src/setup/commands.ts +809 -0
- package/packages/cli/src/setup/contributors.ts +171 -0
- package/packages/cli/src/setup/dispatch.ts +74 -0
- package/packages/cli/src/setup/launch-driver.ts +109 -0
- package/packages/cli/src/setup/runtime.ts +456 -0
- package/packages/cli/src/setup/spoke-probe.ts +120 -0
- package/packages/cli/src/setup/state.ts +244 -0
- package/packages/cli/src/setup/user-bin.ts +39 -0
- package/packages/cli/src/spoke-services.ts +109 -0
- package/packages/cli/src/watch.ts +28 -0
- package/packages/codex/package.json +12 -0
- package/packages/codex/src/app-server.ts +44 -0
- package/packages/codex/src/ids.ts +28 -0
- package/packages/codex/src/index.ts +11 -0
- package/packages/codex/src/jsonrpc.ts +168 -0
- package/packages/codex/src/main.ts +123 -0
- package/packages/codex/src/paths.ts +65 -0
- package/packages/codex/src/sessions.ts +578 -0
- package/packages/codex/src/spoke.ts +252 -0
- package/packages/codex/src/stdio.ts +101 -0
- package/packages/codex/src/websocket.ts +206 -0
- package/packages/codex/src/wrapper.ts +104 -0
- package/packages/node/package.json +12 -0
- package/packages/node/src/config.ts +184 -0
- package/packages/node/src/connection.ts +283 -0
- package/packages/node/src/daemon.ts +281 -0
- package/packages/node/src/git.ts +107 -0
- package/packages/node/src/harnesses.ts +95 -0
- package/packages/node/src/host.ts +319 -0
- package/packages/node/src/index.ts +17 -0
- package/packages/node/src/main.ts +11 -0
- package/packages/node/src/openshell.ts +73 -0
- package/packages/node/src/run.ts +106 -0
- package/packages/preset/package.json +12 -0
- package/packages/preset/presets/aider.json +8 -0
- package/packages/preset/presets/goose.json +8 -0
- package/packages/preset/src/index.ts +2 -0
- package/packages/preset/src/main.ts +195 -0
- package/packages/preset/src/presets.ts +87 -0
- package/packages/preset/src/spoke.ts +251 -0
- package/packages/preset/src/tmux.ts +160 -0
- package/packages/protocol/package.json +12 -0
- package/packages/protocol/src/harness-events.ts +207 -0
- package/packages/protocol/src/index.ts +1215 -0
- package/packages/protocol/src/launch.ts +172 -0
- package/packages/protocol/src/node.ts +409 -0
- package/packages/protocol/src/preset.ts +144 -0
- package/packages/protocol/src/webhook.ts +62 -0
- package/packages/protocol/src/wire.ts +54 -0
- package/packages/runtime-openshell/image/Dockerfile +36 -0
- package/packages/runtime-openshell/image/README.md +78 -0
- package/packages/runtime-openshell/image/build.ts +203 -0
- package/packages/runtime-openshell/image/entry.sh +163 -0
- package/packages/runtime-openshell/image/image.ts +119 -0
- package/packages/runtime-openshell/image/start.sh +24 -0
- package/packages/runtime-openshell/package.json +12 -0
- package/packages/runtime-openshell/src/cli.ts +584 -0
- package/packages/runtime-openshell/src/index.ts +6 -0
- package/packages/runtime-openshell/src/policy.ts +155 -0
- package/packages/runtime-openshell/src/profiles.ts +181 -0
- package/packages/runtime-openshell/src/runtime.ts +475 -0
- package/packages/runtime-sdk/package.json +12 -0
- package/packages/runtime-sdk/src/index.ts +87 -0
- package/packages/sdk/package.json +12 -0
- package/packages/sdk/src/address.ts +25 -0
- package/packages/sdk/src/client.ts +553 -0
- package/packages/sdk/src/envelope.ts +108 -0
- package/packages/sdk/src/errors.ts +107 -0
- package/packages/sdk/src/index.ts +10 -0
- package/packages/sdk/src/sse.ts +70 -0
- package/packages/sdk/src/types.ts +517 -0
- package/packages/sdk/src/webhook.ts +102 -0
- package/packages/spoke-sdk/package.json +12 -0
- package/packages/spoke-sdk/src/activity.ts +3 -0
- package/packages/spoke-sdk/src/connection.ts +295 -0
- package/packages/spoke-sdk/src/delivery.ts +119 -0
- package/packages/spoke-sdk/src/enroll.ts +101 -0
- package/packages/spoke-sdk/src/harness-hello.ts +79 -0
- package/packages/spoke-sdk/src/harness-spoke.ts +263 -0
- package/packages/spoke-sdk/src/index.ts +12 -0
- package/packages/spoke-sdk/src/prompt-refs.ts +154 -0
- package/packages/spoke-sdk/src/prompt-source.ts +8 -0
- package/packages/spoke-sdk/src/session-ids.ts +49 -0
- package/packages/spoke-sdk/src/session-tokens.ts +78 -0
- package/scripts/hub-claude-code.service +34 -0
- package/scripts/hub-codex-spoke.service +34 -0
- package/scripts/hub-node.service +35 -0
- package/scripts/io.github.huynle.hub-claude-code.plist +53 -0
- package/scripts/io.github.huynle.hub-codex-spoke.plist +53 -0
- package/scripts/io.github.huynle.hub-node.plist +52 -0
- package/src/central/actions.ts +233 -0
- package/src/central/agent/acting-routes.ts +133 -0
- package/src/central/agent/acting-switch.ts +93 -0
- package/src/central/agent/acting.ts +599 -0
- package/src/central/agent/actor.ts +193 -0
- package/src/central/agent/app-tokens.ts +152 -0
- package/src/central/agent/archive-inactive.ts +66 -0
- package/src/central/agent/audit.ts +185 -0
- package/src/central/agent/decisions.ts +530 -0
- package/src/central/agent/delivery.ts +678 -0
- package/src/central/agent/directory-routes.ts +328 -0
- package/src/central/agent/directory.ts +473 -0
- package/src/central/agent/envelope.ts +51 -0
- package/src/central/agent/errors.ts +74 -0
- package/src/central/agent/escalation.ts +219 -0
- package/src/central/agent/event-cursor.ts +49 -0
- package/src/central/agent/event-writers.ts +181 -0
- package/src/central/agent/events-routes.ts +148 -0
- package/src/central/agent/events.ts +277 -0
- package/src/central/agent/grant-requests.ts +221 -0
- package/src/central/agent/grant-routes.ts +70 -0
- package/src/central/agent/grants.ts +235 -0
- package/src/central/agent/launch-engine.ts +1025 -0
- package/src/central/agent/launch-routes.ts +252 -0
- package/src/central/agent/launch-spec.ts +289 -0
- package/src/central/agent/launch-store.ts +264 -0
- package/src/central/agent/launch-tokens.ts +206 -0
- package/src/central/agent/message-limits.ts +123 -0
- package/src/central/agent/message-routes.ts +385 -0
- package/src/central/agent/messages.ts +442 -0
- package/src/central/agent/names.ts +86 -0
- package/src/central/agent/placement.ts +419 -0
- package/src/central/agent/policy.ts +378 -0
- package/src/central/agent/read-routes.ts +385 -0
- package/src/central/agent/routes.ts +281 -0
- package/src/central/agent/session-tokens.ts +227 -0
- package/src/central/agent/versioned.ts +26 -0
- package/src/central/agent/watch.ts +112 -0
- package/src/central/agent/webhook-routes.ts +86 -0
- package/src/central/agent/webhooks.ts +493 -0
- package/src/central/agent-data.ts +177 -0
- package/src/central/auth.ts +350 -121
- package/src/central/backend/file-state.ts +240 -0
- package/src/central/backend/index.ts +116 -0
- package/src/central/backend/memory-bus.ts +93 -0
- package/src/central/backend/memory-catalog.ts +90 -0
- package/src/central/backend/memory-counters.ts +102 -0
- package/src/central/backend/memory-events.ts +45 -0
- package/src/central/backend/memory-kv.ts +38 -0
- package/src/central/backend/memory-state.ts +77 -0
- package/src/central/backend/postgres-migrate.ts +100 -0
- package/src/central/backend/postgres-state.ts +197 -0
- package/src/central/backend/types.ts +246 -0
- package/src/central/backend/valkey-bus.ts +425 -0
- package/src/central/backend/valkey-catalog.ts +190 -0
- package/src/central/backend/valkey-client.ts +36 -0
- package/src/central/backend/valkey-counters.ts +38 -0
- package/src/central/backend/valkey-events.ts +168 -0
- package/src/central/backend/valkey-kv.ts +54 -0
- package/src/central/backend/valkey-locks.ts +92 -0
- package/src/central/cluster/directory.ts +141 -0
- package/src/central/cluster/feed.ts +84 -0
- package/src/central/cluster/leader.ts +232 -0
- package/src/central/cluster/live.ts +151 -0
- package/src/central/cluster/recommend.ts +59 -0
- package/src/central/cluster/rpc.ts +150 -0
- package/src/central/cluster/view.ts +232 -0
- package/src/central/config.ts +197 -3
- package/src/central/done.ts +96 -0
- package/src/central/events-log.ts +100 -0
- package/src/central/http.ts +1149 -170
- package/src/central/import.ts +318 -0
- package/src/central/main.ts +211 -12
- package/src/central/mcp-http.ts +116 -0
- package/src/central/nodes.ts +314 -0
- package/src/central/oidc.ts +34 -14
- package/src/central/push-target.ts +118 -18
- package/src/central/push.ts +256 -82
- package/src/central/registry.ts +902 -92
- package/src/central/say-stream.ts +128 -0
- package/src/central/session-token-push.ts +233 -0
- package/src/central/speech/openai.ts +21 -14
- package/src/central/speech/openrouter.ts +27 -10
- package/src/central/speech/provider.ts +54 -0
- package/src/central/speech/service.ts +22 -7
- package/src/central/spoke-ws.ts +522 -44
- package/src/central/store-shared.ts +518 -0
- package/src/central/store.ts +625 -189
- package/src/central/token-keys.ts +165 -0
- package/src/config.ts +118 -7
- package/src/index.ts +195 -43
- package/src/protocol.ts +3 -650
- package/src/rpc.ts +27 -2
- package/src/spoke/client.ts +3 -272
- package/src/spoke/conversation.ts +16 -12
- package/src/spoke/deliver.ts +75 -0
- package/src/spoke/enroll.ts +2 -79
- package/src/spoke/env-inject.ts +160 -0
- package/src/spoke/hub-rpc.ts +115 -0
- package/src/spoke/hub-tool.ts +409 -0
- package/src/spoke/launch-source.ts +22 -0
- package/src/spoke/launchers.ts +174 -0
- package/src/spoke/locations.ts +3 -2
- package/src/spoke/node-roots.ts +61 -0
- package/src/spoke/prompt-refs.ts +116 -0
- package/src/spoke/session-tokens.ts +2 -0
- package/src/spoke/spoke.ts +465 -28
- package/src/spoke/taskboard.ts +243 -0
- package/src/spoke/timeline.ts +184 -6
- package/src/spoke/tracker.ts +79 -4
- package/src/status.ts +203 -10
- package/src/tui-commands.ts +13 -6
- package/src/tui-messaging.ts +93 -0
- package/src/tui.ts +15 -0
- package/web/dist/apple-touch-icon.png +0 -0
- package/web/dist/assets/highlight-y4rJE6Tt.js +5 -0
- package/web/dist/assets/index-DWnMhXk9.js +20 -0
- package/web/dist/assets/index-DmUXEnw9.css +1 -0
- package/web/dist/assets/ort-wasm-simd-threaded-CxTQ5xH-.wasm +0 -0
- package/web/dist/assets/silero-worker-C8-8vLVS.js +11 -0
- package/web/dist/assets/silero_vad-Dt9-bTDn.onnx +0 -0
- package/web/dist/favicon.ico +0 -0
- package/web/dist/icon-192.png +0 -0
- package/web/dist/icon-512.png +0 -0
- package/web/dist/icon-maskable-512.png +0 -0
- package/web/dist/icon.svg +5 -5
- package/web/dist/index.html +17 -4
- package/web/dist/manifest.webmanifest +2 -2
- package/web/dist/sw.js +79 -15
- package/web/dist/assets/index-DClBvfLL.css +0 -1
- package/web/dist/assets/index-Djo_yF0a.js +0 -12
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// SetupPreview: what `hubctl setup preview` outputs. It contains the plan, its digest and preview token, the
|
|
2
|
+
// blockers that stop apply, and the upstream evidence already checked for pinned harness downloads.
|
|
3
|
+
//
|
|
4
|
+
// The only blocker in v1 is a manual edit. When a config the plan would change is a symlink (for example
|
|
5
|
+
// ~/.config/opencode pointing into a dotfiles repository), hubctl refuses to write through it. Instead it emits the
|
|
6
|
+
// exact, structured, secret-free edit for the user to make in the repository the link points to. The edit
|
|
7
|
+
// replaces its action: the plan itself never contains an action that writes through the link. Apply refuses while
|
|
8
|
+
// any blocker remains (manual_edit_required, exit 10). Once the user has made the edit, a new preview sees the
|
|
9
|
+
// changed config, so its digest differs and that plan no longer carries the refused action.
|
|
10
|
+
//
|
|
11
|
+
// v1.1: a second reason, unmergeable-format: a config hubctl cannot edit losslessly (TOML, or a file it cannot
|
|
12
|
+
// parse) gets the same exact, structured edit, for the file itself (no link involved), instead of a bare conflict.
|
|
13
|
+
import { checkConfigSets, checkPrecondition, CONFIG_OWNERS, findExecKeys, type Action, type ConfigFormat, type ConfigOwner, type ConfigSet, type Precondition } from "./actions.ts"
|
|
14
|
+
import { EVIDENCE_KINDS, HARNESS_ORDER, type EvidenceKind, type HarnessID } from "./harness.ts"
|
|
15
|
+
import { checkPathRef, type PathRef } from "./paths.ts"
|
|
16
|
+
import { planDigest, validatePreviewToken, validateSetupPlan, type PreviewToken, type SetupPlan } from "./plan.ts"
|
|
17
|
+
import { findSecrets } from "./secrets.ts"
|
|
18
|
+
import { absPath, Checker, digest, fail, header, int, isRecord, list, ok, oneOf, pointer, shape, text, type Result } from "./validate.ts"
|
|
19
|
+
|
|
20
|
+
export type ManualChange =
|
|
21
|
+
| { readonly op: "merge"; readonly set: readonly ConfigSet[] }
|
|
22
|
+
/** Register this hubctl's `mcp` subcommand under `name` (the command is implied, as in the action). */
|
|
23
|
+
| { readonly op: "register-mcp-server"; readonly harness: HarnessID; readonly name: string }
|
|
24
|
+
| { readonly op: "write-file"; readonly asset: string; readonly sha256: string; readonly mode: number }
|
|
25
|
+
|
|
26
|
+
export const MANUAL_EDIT_REASONS = ["symlinked-config", "unmergeable-format"] as const
|
|
27
|
+
export type ManualEditReason = (typeof MANUAL_EDIT_REASONS)[number]
|
|
28
|
+
|
|
29
|
+
export interface ManualEdit {
|
|
30
|
+
readonly kind: "manual-edit"
|
|
31
|
+
readonly reason: ManualEditReason
|
|
32
|
+
/** The plan action it replaces. */
|
|
33
|
+
readonly actionID: string
|
|
34
|
+
readonly path: PathRef
|
|
35
|
+
/** The symlink found between the root and the target (symlinked-config only). */
|
|
36
|
+
readonly linkPath?: string
|
|
37
|
+
/** Where the edit belongs (the file the link resolves to). */
|
|
38
|
+
readonly resolvedPath: string
|
|
39
|
+
readonly format?: ConfigFormat
|
|
40
|
+
readonly owner?: ConfigOwner
|
|
41
|
+
/** What the file looked like when the edit was computed. */
|
|
42
|
+
readonly precondition?: Precondition
|
|
43
|
+
readonly change: ManualChange
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Everything that stops apply. */
|
|
47
|
+
export type PlanBlocker = ManualEdit
|
|
48
|
+
|
|
49
|
+
const ACTION_ID = /^[a-z0-9][a-z0-9-]{0,63}$/
|
|
50
|
+
|
|
51
|
+
function checkManualEdit(c: Checker, path: string, value: unknown): void {
|
|
52
|
+
for (const p of findExecKeys(value, path)) c.add(p, "exec_field", "a manual edit never carries a command")
|
|
53
|
+
const r = shape(c, path, value, ["kind", "reason", "actionID", "path", "resolvedPath", "change"], ["linkPath", "format", "owner", "precondition"])
|
|
54
|
+
if (!r) return
|
|
55
|
+
const at = (key: string) => pointer(path, key)
|
|
56
|
+
if (r.kind !== "manual-edit") c.add(at("kind"), "invalid_value", "expected manual-edit")
|
|
57
|
+
oneOf(c, at("reason"), r.reason, MANUAL_EDIT_REASONS)
|
|
58
|
+
text(c, at("actionID"), r.actionID, 64, ACTION_ID)
|
|
59
|
+
checkPathRef(c, at("path"), r.path)
|
|
60
|
+
if (r.reason === "symlinked-config" && r.linkPath === undefined) c.add(at("linkPath"), "missing_field", "a symlinked config names its link")
|
|
61
|
+
if (r.reason === "unmergeable-format" && r.linkPath !== undefined) c.add(at("linkPath"), "inconsistent", "an unmergeable config is edited in place; no link is involved")
|
|
62
|
+
if (r.linkPath !== undefined) absPath(c, at("linkPath"), r.linkPath)
|
|
63
|
+
absPath(c, at("resolvedPath"), r.resolvedPath)
|
|
64
|
+
if (r.format !== undefined) oneOf(c, at("format"), r.format, ["json", "jsonc", "toml"] as const)
|
|
65
|
+
if (r.owner !== undefined) oneOf(c, at("owner"), r.owner, CONFIG_OWNERS)
|
|
66
|
+
if (r.precondition !== undefined) checkPrecondition(c, at("precondition"), r.precondition)
|
|
67
|
+
const change = isRecord(r.change) ? r.change : undefined
|
|
68
|
+
if (!change) return void c.add(at("change"), "invalid_value", "expected a change")
|
|
69
|
+
const cp = at("change")
|
|
70
|
+
if (!oneOf(c, pointer(cp, "op"), change.op, ["merge", "register-mcp-server", "write-file"] as const)) return
|
|
71
|
+
if (change.op === "merge") {
|
|
72
|
+
shape(c, cp, change, ["op", "set"])
|
|
73
|
+
checkConfigSets(c, pointer(cp, "set"), change.set)
|
|
74
|
+
} else if (change.op === "register-mcp-server") {
|
|
75
|
+
shape(c, cp, change, ["op", "harness", "name"])
|
|
76
|
+
oneOf(c, pointer(cp, "harness"), change.harness, HARNESS_ORDER)
|
|
77
|
+
text(c, pointer(cp, "name"), change.name, 32, /^[a-z0-9][a-z0-9-]{0,31}$/)
|
|
78
|
+
} else {
|
|
79
|
+
shape(c, cp, change, ["op", "asset", "sha256", "mode"])
|
|
80
|
+
text(c, pointer(cp, "asset"), change.asset, 128, /^[a-z0-9][a-z0-9._-]*(?:\/[a-z0-9][a-z0-9._-]*)*$/)
|
|
81
|
+
digest(c, pointer(cp, "sha256"), change.sha256)
|
|
82
|
+
int(c, pointer(cp, "mode"), change.mode, 0, 0o7777)
|
|
83
|
+
}
|
|
84
|
+
for (const p of findSecrets(value, path)) c.add(p, "secret", "a manual edit never carries credentials")
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function validateManualEdit(value: unknown): Result<ManualEdit> {
|
|
88
|
+
const c = new Checker()
|
|
89
|
+
checkManualEdit(c, "", value)
|
|
90
|
+
return c.result(value)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The manual edit that replaces `action` when its target is behind a symlink; undefined if the action writes no config or file. */
|
|
94
|
+
export function manualEditFor(action: Action, link: { readonly linkPath: string; readonly resolvedPath: string }): ManualEdit | undefined {
|
|
95
|
+
const base = { kind: "manual-edit", reason: "symlinked-config", actionID: action.id, linkPath: link.linkPath, resolvedPath: link.resolvedPath } as const
|
|
96
|
+
switch (action.kind) {
|
|
97
|
+
case "merge-config":
|
|
98
|
+
return { ...base, path: action.path, format: action.format, owner: action.owner, precondition: action.precondition, change: { op: "merge", set: action.set } }
|
|
99
|
+
case "register-mcp-server":
|
|
100
|
+
return { ...base, path: action.path, precondition: action.precondition, change: { op: "register-mcp-server", harness: action.harness, name: action.name } }
|
|
101
|
+
case "write-file":
|
|
102
|
+
return { ...base, path: action.path, change: { op: "write-file", asset: action.asset, sha256: action.sha256, mode: action.mode } }
|
|
103
|
+
default:
|
|
104
|
+
return undefined
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* v1.1: the manual edit that replaces a config action hubctl cannot apply losslessly (TOML, or a file it cannot
|
|
110
|
+
* parse), for the file at `resolvedPath` itself; undefined if the action edits no config.
|
|
111
|
+
*/
|
|
112
|
+
export function unmergeableEditFor(action: Action, resolvedPath: string): ManualEdit | undefined {
|
|
113
|
+
const base = { kind: "manual-edit", reason: "unmergeable-format", actionID: action.id, resolvedPath } as const
|
|
114
|
+
switch (action.kind) {
|
|
115
|
+
case "merge-config":
|
|
116
|
+
return { ...base, path: action.path, format: action.format, owner: action.owner, precondition: action.precondition, change: { op: "merge", set: action.set } }
|
|
117
|
+
case "register-mcp-server":
|
|
118
|
+
return { ...base, path: action.path, precondition: action.precondition, change: { op: "register-mcp-server", harness: action.harness, name: action.name } }
|
|
119
|
+
default:
|
|
120
|
+
return undefined
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** An upstream evidence check reported in the preview; "deferred" checks run on the downloaded bytes at apply. */
|
|
125
|
+
export interface PreviewEvidence {
|
|
126
|
+
readonly actionID: string
|
|
127
|
+
readonly kind: EvidenceKind
|
|
128
|
+
readonly status: "verified" | "unavailable" | "deferred" | "failed"
|
|
129
|
+
readonly detail?: string
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export const SETUP_PREVIEW_SCHEMA = "hub.setup-preview"
|
|
133
|
+
|
|
134
|
+
export interface SetupPreview {
|
|
135
|
+
readonly schema: typeof SETUP_PREVIEW_SCHEMA
|
|
136
|
+
readonly version: 1
|
|
137
|
+
readonly plan: SetupPlan
|
|
138
|
+
readonly planDigest: string
|
|
139
|
+
readonly token: PreviewToken
|
|
140
|
+
readonly blockers: readonly PlanBlocker[]
|
|
141
|
+
readonly evidence: readonly PreviewEvidence[]
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function validateSetupPreview(value: unknown): Result<SetupPreview> {
|
|
145
|
+
const c = new Checker()
|
|
146
|
+
const r = shape(c, "", value, ["schema", "version", "plan", "planDigest", "token", "blockers", "evidence"])
|
|
147
|
+
if (!r) return c.result(value)
|
|
148
|
+
header(c, "", r, SETUP_PREVIEW_SCHEMA, 1)
|
|
149
|
+
const plan = validateSetupPlan(r.plan)
|
|
150
|
+
c.merge("/plan", plan)
|
|
151
|
+
digest(c, "/planDigest", r.planDigest)
|
|
152
|
+
const token = validatePreviewToken(r.token)
|
|
153
|
+
c.merge("/token", token)
|
|
154
|
+
const actions = plan.ok ? plan.value.actions : []
|
|
155
|
+
if (plan.ok) {
|
|
156
|
+
const d = planDigest(plan.value)
|
|
157
|
+
if (r.planDigest !== d) c.add("/planDigest", "inconsistent", "planDigest is not the digest of the plan")
|
|
158
|
+
if (token.ok && (token.value.planDigest !== d || token.value.hostFingerprint !== plan.value.hostFingerprint || token.value.snapshotDigest !== plan.value.snapshotDigest))
|
|
159
|
+
c.add("/token", "inconsistent", "the token is not for this plan")
|
|
160
|
+
}
|
|
161
|
+
list(c, "/blockers", r.blockers, 256, (p, b) => {
|
|
162
|
+
checkManualEdit(c, p, b)
|
|
163
|
+
// A manual edit replaces its action: the plan never keeps an action that would write through the symlink.
|
|
164
|
+
if (plan.ok && isRecord(b) && actions.some((a) => a.id === b.actionID)) c.add(pointer(p, "actionID"), "inconsistent", "the refused action is still in the plan")
|
|
165
|
+
})
|
|
166
|
+
list(c, "/evidence", r.evidence, 256, (p, e) => {
|
|
167
|
+
const ev = shape(c, p, e, ["actionID", "kind", "status"], ["detail"])
|
|
168
|
+
if (!ev) return
|
|
169
|
+
text(c, pointer(p, "actionID"), ev.actionID, 64, ACTION_ID)
|
|
170
|
+
oneOf(c, pointer(p, "kind"), ev.kind, EVIDENCE_KINDS)
|
|
171
|
+
oneOf(c, pointer(p, "status"), ev.status, ["verified", "unavailable", "deferred", "failed"] as const)
|
|
172
|
+
if (ev.detail !== undefined) text(c, pointer(p, "detail"), ev.detail)
|
|
173
|
+
const action = actions.find((a) => a.id === ev.actionID)
|
|
174
|
+
if (plan.ok && (action?.kind !== "install-harness" || !action.evidence.includes(ev.kind as EvidenceKind))) c.add(p, "inconsistent", "the evidence names no planned check")
|
|
175
|
+
})
|
|
176
|
+
// The preview token is not a credential (it grants nothing; apply re-plans and compares), so only its key is exempt.
|
|
177
|
+
for (const p of findSecrets(value, "", new Set(["/token"]))) c.add(p, "secret", "previews never carry credentials")
|
|
178
|
+
return c.result(value)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Whether apply may proceed past the preview's own findings (revalidatePreview still runs at apply). */
|
|
182
|
+
export function previewApplicable(preview: SetupPreview): Result<SetupPreview> {
|
|
183
|
+
if (preview.blockers.length > 0) {
|
|
184
|
+
const reasons = [...new Set(preview.blockers.map((b) => (b.reason === "unmergeable-format" ? "a format hubctl cannot edit losslessly" : "symlinked config")))].join(", ")
|
|
185
|
+
return fail("/blockers", "manual_edit_required", `${preview.blockers.length} change(s) must be made by hand (${reasons}); make them and preview again`)
|
|
186
|
+
}
|
|
187
|
+
const failed = preview.evidence.find((e) => e.status === "failed")
|
|
188
|
+
if (failed) return fail("/evidence", "evidence_failed", `${failed.kind} failed for ${failed.actionID}`)
|
|
189
|
+
return ok(preview)
|
|
190
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Setup profiles: how much of Hub a machine gets.
|
|
2
|
+
// session-only the harness integration only (follow and steer sessions); no services, no launches.
|
|
3
|
+
// launch-node plus the `hub node` service, so the hub can launch bounded sessions on this machine.
|
|
4
|
+
// full-harness plus every chosen harness's spoke service (and opt-in OpenShell checks).
|
|
5
|
+
// Profiles are versioned documents; an older document is upgraded by the migration hook, one version at a time,
|
|
6
|
+
// before it is validated. Escalation is never automatic and services are never activated without confirmation.
|
|
7
|
+
//
|
|
8
|
+
// v1.1 (profile version 2): `revision` records the catalog revision a profile was made from (profile rebases use
|
|
9
|
+
// it), and `enrollment` says whether the plan enrolls the machine ("inline", the default) or leaves it to a separate
|
|
10
|
+
// `hubctl machine enroll` ("separate", which the Ansible path always uses), so both paths plan the same digest.
|
|
11
|
+
import { SERVICE_IDS, type ServiceID } from "./exec.ts"
|
|
12
|
+
import { HARNESS_ORDER, RUNTIME_IDS, type HarnessID, type RuntimeID } from "./harness.ts"
|
|
13
|
+
import { bool, Checker, fail, header, int, isRecord, oneOf, shape, subset, type Result } from "./validate.ts"
|
|
14
|
+
|
|
15
|
+
export const PROFILE_SCHEMA = "hub.bootstrap-profile"
|
|
16
|
+
export const PROFILE_VERSION = 2
|
|
17
|
+
/** Profile versions a plan may record: plans made by a v1.0 hubctl (profile version 1) still validate and resume. */
|
|
18
|
+
export const PLAN_PROFILE_VERSIONS = [1, 2] as const
|
|
19
|
+
export type PlanProfileVersion = (typeof PLAN_PROFILE_VERSIONS)[number]
|
|
20
|
+
/** v1.1: inline: the plan enrolls the machine (enroll-machine). separate: enrollment is its own step; the plan never enrolls. */
|
|
21
|
+
export const ENROLLMENT_MODES = ["inline", "separate"] as const
|
|
22
|
+
export type EnrollmentMode = (typeof ENROLLMENT_MODES)[number]
|
|
23
|
+
export const PROFILE_IDS = ["session-only", "launch-node", "full-harness"] as const
|
|
24
|
+
export type ProfileID = (typeof PROFILE_IDS)[number]
|
|
25
|
+
|
|
26
|
+
export interface Profile {
|
|
27
|
+
readonly schema: typeof PROFILE_SCHEMA
|
|
28
|
+
readonly version: typeof PROFILE_VERSION
|
|
29
|
+
readonly id: ProfileID
|
|
30
|
+
/** In HARNESS_ORDER. */
|
|
31
|
+
readonly harnesses: readonly HarnessID[]
|
|
32
|
+
readonly runtimes: readonly RuntimeID[]
|
|
33
|
+
readonly services: readonly ServiceID[]
|
|
34
|
+
readonly launch: { readonly enabled: boolean; readonly maxDurationS?: number }
|
|
35
|
+
/** "explicit": the user may approve elevated actions at apply time; "never": the plan stays unprivileged. */
|
|
36
|
+
readonly escalation: "never" | "explicit"
|
|
37
|
+
/** v1.1: the built-in catalog revision this profile was made from (absent: unknown, e.g. written by hand). */
|
|
38
|
+
readonly revision?: number
|
|
39
|
+
/** v1.1: whether the plan enrolls the machine (default "inline"). */
|
|
40
|
+
readonly enrollment?: EnrollmentMode
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The enrollment mode a profile plans with. */
|
|
44
|
+
export const enrollmentModeOf = (profile: Pick<Profile, "enrollment">): EnrollmentMode => profile.enrollment ?? "inline"
|
|
45
|
+
|
|
46
|
+
/** The service each harness's spoke runs as, when it has one (OpenCode's spoke lives in its plugin). */
|
|
47
|
+
export const HARNESS_SERVICE: Readonly<Partial<Record<HarnessID, ServiceID>>> = Object.freeze({ "claude-code": "claude-code", codex: "codex-spoke" })
|
|
48
|
+
|
|
49
|
+
export function validateProfile(value: unknown): Result<Profile> {
|
|
50
|
+
const c = new Checker()
|
|
51
|
+
const r = shape(c, "", value, ["schema", "version", "id", "harnesses", "runtimes", "services", "launch", "escalation"], ["revision", "enrollment"])
|
|
52
|
+
if (!r) return c.result(value)
|
|
53
|
+
header(c, "", r, PROFILE_SCHEMA, PROFILE_VERSION)
|
|
54
|
+
if (r.revision !== undefined) int(c, "/revision", r.revision, 1, 1_000_000)
|
|
55
|
+
if (r.enrollment !== undefined) oneOf(c, "/enrollment", r.enrollment, ENROLLMENT_MODES)
|
|
56
|
+
oneOf(c, "/id", r.id, PROFILE_IDS)
|
|
57
|
+
oneOf(c, "/escalation", r.escalation, ["never", "explicit"] as const)
|
|
58
|
+
const harnesses = subset(c, "/harnesses", r.harnesses, HARNESS_ORDER) ? r.harnesses : []
|
|
59
|
+
const services = subset(c, "/services", r.services, SERVICE_IDS) ? r.services : []
|
|
60
|
+
subset(c, "/runtimes", r.runtimes, RUNTIME_IDS)
|
|
61
|
+
if (Array.isArray(r.harnesses) && r.harnesses.length === 0) c.add("/harnesses", "invalid_value", "a profile sets up at least one harness")
|
|
62
|
+
const order = harnesses.map((h) => HARNESS_ORDER.indexOf(h))
|
|
63
|
+
if (order.some((v, i) => i > 0 && v < order[i - 1]!)) c.add("/harnesses", "inconsistent", `harnesses go in the order ${HARNESS_ORDER.join(", ")}`)
|
|
64
|
+
const launch = shape(c, "/launch", r.launch, ["enabled"], ["maxDurationS"])
|
|
65
|
+
if (launch) {
|
|
66
|
+
bool(c, "/launch/enabled", launch.enabled)
|
|
67
|
+
if (launch.maxDurationS !== undefined) int(c, "/launch/maxDurationS", launch.maxDurationS, 1, 86_400)
|
|
68
|
+
if (launch.enabled === true && launch.maxDurationS === undefined) c.add("/launch/maxDurationS", "inconsistent", "launches are always bounded")
|
|
69
|
+
if (launch.enabled === true && !services.includes("node")) c.add("/launch", "inconsistent", "launching needs the node service")
|
|
70
|
+
}
|
|
71
|
+
if (r.id === "session-only" && services.length > 0) c.add("/services", "inconsistent", "session-only installs no services")
|
|
72
|
+
if (r.id === "session-only" && launch?.enabled === true) c.add("/launch", "inconsistent", "session-only never launches")
|
|
73
|
+
if ((r.id === "launch-node" || r.id === "full-harness") && !services.includes("node")) c.add("/services", "inconsistent", `${String(r.id)} needs the node service`)
|
|
74
|
+
for (const [harness, service] of Object.entries(HARNESS_SERVICE))
|
|
75
|
+
if (services.includes(service!) && !harnesses.includes(harness as HarnessID)) c.add("/services", "inconsistent", `the ${service} service needs the ${harness} harness`)
|
|
76
|
+
return c.result(value)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** One step: a document of version N (the key) to version N+1. */
|
|
80
|
+
export type ProfileMigration = (raw: Record<string, unknown>) => unknown
|
|
81
|
+
export type ProfileMigrations = Readonly<Record<number, ProfileMigration>>
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Registered migrations to the current version. 1 → 2 (v1.1): the new fields are optional and mean what a version-1
|
|
85
|
+
* document meant (catalog revision unknown, enrollment inline), so only the version changes.
|
|
86
|
+
*/
|
|
87
|
+
export const PROFILE_MIGRATIONS: ProfileMigrations = Object.freeze({
|
|
88
|
+
1: (raw: Record<string, unknown>) => ({ ...raw, version: 2 }),
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
/** Upgrades an older profile document step by step, then validates it. */
|
|
92
|
+
export function migrateProfile(raw: unknown, migrations: ProfileMigrations = PROFILE_MIGRATIONS): Result<Profile> {
|
|
93
|
+
let doc = raw
|
|
94
|
+
for (;;) {
|
|
95
|
+
if (!isRecord(doc) || !Number.isInteger(doc.version)) return fail("/version", "invalid_value", "a profile needs an integer version")
|
|
96
|
+
const version = doc.version as number
|
|
97
|
+
if (version > PROFILE_VERSION) return fail("/version", "unsupported_version", `profile version ${version} is newer than this hubctl understands (${PROFILE_VERSION}); upgrade hubctl`)
|
|
98
|
+
if (version === PROFILE_VERSION) return validateProfile(doc)
|
|
99
|
+
const step = migrations[version]
|
|
100
|
+
if (!step) return fail("/version", "unmigratable", `no migration from profile version ${version}`)
|
|
101
|
+
const next = step(doc)
|
|
102
|
+
if (!isRecord(next) || !Number.isInteger(next.version) || (next.version as number) <= version) return fail("/version", "unmigratable", `the migration from version ${version} did not advance the version`)
|
|
103
|
+
doc = next
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Secret detection for everything the bootstrap writes outside the credential store: plans, previews, journals,
|
|
2
|
+
// discovery snapshots, status output, logs and generated Ansible. A document that findSecrets flags is refused,
|
|
3
|
+
// not redacted: the only place a machine token may live is the credential store (credentials.ts).
|
|
4
|
+
import { isRecord, pointer } from "./validate.ts"
|
|
5
|
+
|
|
6
|
+
/** Key names (case-insensitive, "-" and "_" ignored) that mean the value is a credential. */
|
|
7
|
+
const SECRET_KEY_PARTS = ["token", "secret", "password", "passwd", "passphrase", "apikey", "privatekey", "credential", "authorization", "cookie", "devicecode"] as const
|
|
8
|
+
|
|
9
|
+
/** Value shapes of known credentials (anywhere in a string, at a word start): hub app (hat_), session (hst1.), launch (hlt1) and machine test tokens, common vendor keys, PEM keys, bearer headers. */
|
|
10
|
+
const B = "(?:^|[^A-Za-z0-9_])"
|
|
11
|
+
const SECRET_VALUES: readonly RegExp[] = [
|
|
12
|
+
new RegExp(`${B}hat_[A-Za-z0-9_-]{8,}`),
|
|
13
|
+
new RegExp(`${B}hst1\\.[A-Za-z0-9_-]`),
|
|
14
|
+
new RegExp(`${B}hlt1[A-Za-z0-9._-]{8,}`),
|
|
15
|
+
new RegExp(`${B}mt_[A-Za-z0-9_-]+`),
|
|
16
|
+
new RegExp(`${B}sk-[A-Za-z0-9_-]{8,}`),
|
|
17
|
+
new RegExp(`${B}(?:ghp|gho|ghs|ghu)_[A-Za-z0-9]{8,}`),
|
|
18
|
+
new RegExp(`${B}github_pat_`),
|
|
19
|
+
new RegExp(`${B}xox[abprs]-`),
|
|
20
|
+
/-----BEGIN [A-Z ]*PRIVATE KEY-----/,
|
|
21
|
+
/\bBearer\s+\S/i,
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
export function isSecretKey(key: string): boolean {
|
|
25
|
+
const k = key.toLowerCase().replaceAll(/[-_]/g, "")
|
|
26
|
+
return SECRET_KEY_PARTS.some((part) => k === part || k.endsWith(part) || k.startsWith(part))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const isSecretValue = (value: string): boolean => SECRET_VALUES.some((re) => re.test(value))
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* JSON pointers of every credential-named key (with a value) and every credential-shaped string in `value`.
|
|
33
|
+
* `allowKeys` exempts exact pointers whose key name only looks like a credential (a preview's `/token`); their
|
|
34
|
+
* values are still scanned.
|
|
35
|
+
*/
|
|
36
|
+
export function findSecrets(value: unknown, base = "", allowKeys: ReadonlySet<string> = new Set()): string[] {
|
|
37
|
+
const found: string[] = []
|
|
38
|
+
const walk = (v: unknown, path: string) => {
|
|
39
|
+
if (typeof v === "string") {
|
|
40
|
+
if (isSecretValue(v)) found.push(path)
|
|
41
|
+
} else if (Array.isArray(v)) v.forEach((item, i) => walk(item, pointer(path, i)))
|
|
42
|
+
else if (isRecord(v))
|
|
43
|
+
for (const [key, item] of Object.entries(v)) {
|
|
44
|
+
if (item === undefined) continue
|
|
45
|
+
const p = pointer(path, key)
|
|
46
|
+
if (isSecretKey(key) && !allowKeys.has(p)) found.push(p)
|
|
47
|
+
else walk(item, p)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
walk(value, base)
|
|
51
|
+
return found
|
|
52
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// A small, dependency-free validator kit for the bootstrap contracts. Every document crossing a trust boundary (a
|
|
2
|
+
// discovery snapshot, a plan read back from disk, a preview token, a manifest downloaded from a release, a journal
|
|
3
|
+
// left by a crashed apply) is parsed as `unknown` and checked here; nothing is cast blindly. Checks collect every
|
|
4
|
+
// issue (with a JSON pointer) instead of stopping at the first, so a report shows the whole problem.
|
|
5
|
+
|
|
6
|
+
/** Machine-readable reasons a check fails. Tests and callers match on `code`, never on `message`. */
|
|
7
|
+
export type IssueCode =
|
|
8
|
+
| "invalid_value"
|
|
9
|
+
| "missing_field"
|
|
10
|
+
| "unknown_field"
|
|
11
|
+
| "unsupported_version"
|
|
12
|
+
| "inconsistent"
|
|
13
|
+
| "duplicate"
|
|
14
|
+
| "invalid_path"
|
|
15
|
+
| "unmapped_root"
|
|
16
|
+
| "symlink"
|
|
17
|
+
| "exec_field"
|
|
18
|
+
| "secret"
|
|
19
|
+
| "unsupported_platform"
|
|
20
|
+
| "wrong_platform"
|
|
21
|
+
| "integrity_mismatch"
|
|
22
|
+
| "signature_invalid"
|
|
23
|
+
| "downgrade_rejected"
|
|
24
|
+
| "below_security_floor"
|
|
25
|
+
| "trust_root_rollback"
|
|
26
|
+
| "unmigratable"
|
|
27
|
+
| "preview_foreign"
|
|
28
|
+
| "preview_expired"
|
|
29
|
+
| "preview_not_yet_valid"
|
|
30
|
+
| "preview_replayed"
|
|
31
|
+
| "preview_stale"
|
|
32
|
+
| "unpinned_artifact"
|
|
33
|
+
| "evidence_failed"
|
|
34
|
+
| "manual_edit_required"
|
|
35
|
+
/** v1.1: an action needs escalation the profile never allows (was `inconsistent`). */
|
|
36
|
+
| "escalation_required"
|
|
37
|
+
/** v1.1: the profile installs services, but the host has no user service manager. */
|
|
38
|
+
| "no_service_manager"
|
|
39
|
+
|
|
40
|
+
export interface Issue {
|
|
41
|
+
/** JSON pointer of the offending value ("" for the document itself). */
|
|
42
|
+
readonly path: string
|
|
43
|
+
readonly code: IssueCode
|
|
44
|
+
readonly message: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type Result<T> = { readonly ok: true; readonly value: T } | { readonly ok: false; readonly issues: readonly Issue[] }
|
|
48
|
+
|
|
49
|
+
export const ok = <T>(value: T): Result<T> => ({ ok: true, value })
|
|
50
|
+
export const fail = <T = never>(path: string, code: IssueCode, message: string): Result<T> => ({ ok: false, issues: [{ path, code, message }] })
|
|
51
|
+
|
|
52
|
+
/** Collects issues while a document is walked. */
|
|
53
|
+
export class Checker {
|
|
54
|
+
readonly issues: Issue[] = []
|
|
55
|
+
|
|
56
|
+
add(path: string, code: IssueCode, message: string): void {
|
|
57
|
+
this.issues.push({ path, code, message })
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
merge(prefix: string, result: Result<unknown>): void {
|
|
61
|
+
if (!result.ok) for (const issue of result.issues) this.issues.push({ ...issue, path: `${prefix}${issue.path}` })
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
result<T>(value: unknown): Result<T> {
|
|
65
|
+
return this.issues.length === 0 ? ok(value as T) : { ok: false, issues: [...this.issues] }
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export type Json = null | boolean | number | string | readonly Json[] | { readonly [key: string]: Json }
|
|
70
|
+
|
|
71
|
+
export const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
|
|
72
|
+
|
|
73
|
+
/** JSON pointer segment escaping (RFC 6901). */
|
|
74
|
+
export const pointer = (base: string, key: string | number): string => `${base}/${String(key).replaceAll("~", "~0").replaceAll("/", "~1")}`
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Checks that `value` is an object whose keys are all in `allowed` (keys holding `undefined` count as absent) and
|
|
78
|
+
* that every key in `required` is present. Returns the object, or undefined (with issues added) when not an object.
|
|
79
|
+
*/
|
|
80
|
+
export function shape(c: Checker, path: string, value: unknown, required: readonly string[], optional: readonly string[] = []): Record<string, unknown> | undefined {
|
|
81
|
+
if (!isRecord(value)) {
|
|
82
|
+
c.add(path, "invalid_value", "expected an object")
|
|
83
|
+
return undefined
|
|
84
|
+
}
|
|
85
|
+
const allowed = new Set([...required, ...optional])
|
|
86
|
+
for (const [key, v] of Object.entries(value)) if (v !== undefined && !allowed.has(key)) c.add(pointer(path, key), "unknown_field", `unknown field ${key}`)
|
|
87
|
+
for (const key of required) if (value[key] === undefined) c.add(pointer(path, key), "missing_field", `missing ${key}`)
|
|
88
|
+
return value
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** schema + version header shared by every versioned document. */
|
|
92
|
+
export function header(c: Checker, path: string, r: Record<string, unknown>, schema: string, version: number): void {
|
|
93
|
+
if (r.schema !== schema) c.add(pointer(path, "schema"), "invalid_value", `expected schema ${schema}`)
|
|
94
|
+
if (!Number.isInteger(r.version)) c.add(pointer(path, "version"), "invalid_value", "version must be an integer")
|
|
95
|
+
else if (r.version !== version) c.add(pointer(path, "version"), "unsupported_version", `version ${String(r.version)} is not supported (expected ${version})`)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function oneOf<T extends string>(c: Checker, path: string, value: unknown, options: readonly T[]): value is T {
|
|
99
|
+
if (typeof value === "string" && (options as readonly string[]).includes(value)) return true
|
|
100
|
+
if (value !== undefined) c.add(path, "invalid_value", `expected one of ${options.join(", ")}`)
|
|
101
|
+
return false
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function bool(c: Checker, path: string, value: unknown): value is boolean {
|
|
105
|
+
if (typeof value === "boolean") return true
|
|
106
|
+
if (value !== undefined) c.add(path, "invalid_value", "expected true or false")
|
|
107
|
+
return false
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function int(c: Checker, path: string, value: unknown, min: number, max: number): value is number {
|
|
111
|
+
if (Number.isInteger(value) && (value as number) >= min && (value as number) <= max) return true
|
|
112
|
+
if (value !== undefined) c.add(path, "invalid_value", `expected an integer in ${min}..${max}`)
|
|
113
|
+
return false
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** A timestamp: integer milliseconds since the epoch. */
|
|
117
|
+
export const instant = (c: Checker, path: string, value: unknown): value is number => int(c, path, value, 0, 8.64e15)
|
|
118
|
+
|
|
119
|
+
// eslint-disable-next-line no-control-regex
|
|
120
|
+
const CONTROL = /[\u0000-\u001f\u007f]/
|
|
121
|
+
|
|
122
|
+
/** A one-line printable string of bounded length (no control characters, so it cannot fake output lines). */
|
|
123
|
+
export function text(c: Checker, path: string, value: unknown, max = 200, pattern?: RegExp): value is string {
|
|
124
|
+
if (typeof value === "string" && value.length >= 1 && value.length <= max && !CONTROL.test(value) && (!pattern || pattern.test(value))) return true
|
|
125
|
+
if (value !== undefined) c.add(path, "invalid_value", pattern ? `expected a string matching ${pattern}` : `expected a one-line string of 1..${max} characters`)
|
|
126
|
+
return false
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export const DIGEST = /^sha256:[0-9a-f]{64}$/
|
|
130
|
+
export const digest = (c: Checker, path: string, value: unknown): value is string => text(c, path, value, 71, DIGEST)
|
|
131
|
+
|
|
132
|
+
export const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/
|
|
133
|
+
export const semver = (c: Checker, path: string, value: unknown): value is string => text(c, path, value, 64, SEMVER)
|
|
134
|
+
|
|
135
|
+
/** An absolute, normalized POSIX path: no empty, "." or ".." segments, no trailing slash, no control characters. */
|
|
136
|
+
export function isAbsolutePath(value: unknown): value is string {
|
|
137
|
+
if (typeof value !== "string" || !value.startsWith("/") || value.length > 4096 || CONTROL.test(value) || value.includes("\\")) return false
|
|
138
|
+
if (value === "/") return true
|
|
139
|
+
return value
|
|
140
|
+
.slice(1)
|
|
141
|
+
.split("/")
|
|
142
|
+
.every((s) => s !== "" && s !== "." && s !== "..")
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function absPath(c: Checker, path: string, value: unknown): value is string {
|
|
146
|
+
if (isAbsolutePath(value)) return true
|
|
147
|
+
if (value !== undefined) c.add(path, "invalid_path", "expected an absolute, normalized path")
|
|
148
|
+
return false
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** An array whose items are checked by `item`; `unique` reports repeated keys as duplicates. */
|
|
152
|
+
export function list(c: Checker, path: string, value: unknown, max: number, item: (p: string, v: unknown) => void, unique?: (v: unknown) => string | undefined): value is unknown[] {
|
|
153
|
+
if (!Array.isArray(value) || value.length > max) {
|
|
154
|
+
if (value !== undefined) c.add(path, "invalid_value", `expected a list of at most ${max} items`)
|
|
155
|
+
return false
|
|
156
|
+
}
|
|
157
|
+
const seen = new Set<string>()
|
|
158
|
+
value.forEach((v, i) => {
|
|
159
|
+
item(pointer(path, i), v)
|
|
160
|
+
const key = unique?.(v)
|
|
161
|
+
if (key === undefined) return
|
|
162
|
+
if (seen.has(key)) c.add(pointer(path, i), "duplicate", `duplicate ${key}`)
|
|
163
|
+
seen.add(key)
|
|
164
|
+
})
|
|
165
|
+
return true
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** A list of distinct strings drawn from `options`. */
|
|
169
|
+
export function subset<T extends string>(c: Checker, path: string, value: unknown, options: readonly T[]): value is T[] {
|
|
170
|
+
return list(c, path, value, options.length, (p, v) => oneOf(c, p, v, options), (v) => (typeof v === "string" ? v : undefined))
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** A plain file name (no directories). */
|
|
174
|
+
export const FILE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/
|
|
175
|
+
|
|
176
|
+
/** An https URL without credentials (download sources: never plain http, not even for loopback). */
|
|
177
|
+
export function httpsURL(c: Checker, path: string, value: unknown): value is string {
|
|
178
|
+
if (typeof value === "string" && value.length <= 2048) {
|
|
179
|
+
try {
|
|
180
|
+
const url = new URL(value)
|
|
181
|
+
if (url.protocol === "https:" && !url.username && !url.password) return true
|
|
182
|
+
} catch {}
|
|
183
|
+
}
|
|
184
|
+
if (value !== undefined) c.add(path, "invalid_value", "expected an https URL without credentials")
|
|
185
|
+
return false
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]"])
|
|
189
|
+
|
|
190
|
+
/** v1.1: whether `value` is a plain-http URL of a non-loopback host, without credentials (an insecure hub). */
|
|
191
|
+
export function isInsecureHubURL(value: unknown): value is string {
|
|
192
|
+
if (typeof value !== "string" || value.length > 2048) return false
|
|
193
|
+
try {
|
|
194
|
+
const url = new URL(value)
|
|
195
|
+
return url.protocol === "http:" && !LOOPBACK_HOSTS.has(url.hostname) && !url.username && !url.password
|
|
196
|
+
} catch {
|
|
197
|
+
return false
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** http(s) URL; plain http only for loopback hubs (a local hub on 127.0.0.1). */
|
|
202
|
+
export function hubURL(c: Checker, path: string, value: unknown): value is string {
|
|
203
|
+
if (typeof value === "string" && value.length <= 2048) {
|
|
204
|
+
try {
|
|
205
|
+
const url = new URL(value)
|
|
206
|
+
const loopback = LOOPBACK_HOSTS.has(url.hostname)
|
|
207
|
+
if ((url.protocol === "https:" || (url.protocol === "http:" && loopback)) && !url.username && !url.password) return true
|
|
208
|
+
} catch {}
|
|
209
|
+
}
|
|
210
|
+
if (value !== undefined) c.add(path, "invalid_value", "expected an https URL (http only for a loopback hub), without credentials")
|
|
211
|
+
return false
|
|
212
|
+
}
|