Compare commits

...
Author SHA1 Message Date
code-be-01 15a6969688 feat(config): installation minimal-subset — schema, adapters, core, render (CONFIGIMPL)
ci/woodpecker/pr/ci Pipeline was successful
Implements the config minimal desired-state subset per the PASSED spec
docs/specs/2026-08-29_mosaic-config-minimal-subset.md (spec review PASS
deff617d, rev-code-02). Dependency gate honored: registry adapter is
an explicit stub; profile/role resolution and desired-roster generation
are notChecked. 26 test arms green.
2026-08-28 22:00:14 -05:00
marcieandorch-01 41e8046371 framework tools/git: issue-comment R1/R4 usage-error contract (sync from brain) (#1462)
ci/woodpecker/push/publish Pipeline failed
Co-authored-by: marcie <[email protected]>
2026-08-29 01:58:42 +00:00
11 changed files with 2005 additions and 14 deletions
@@ -1,6 +1,7 @@
#!/bin/bash
# issue-comment.sh - Add a comment to an issue on GitHub or Gitea
# Usage: issue-comment.sh -i <issue_number> -c <comment> [--login <name>]
# Usage: issue-comment.sh -i <issue_number> -b <comment> [--login <name>]
# (-c/--comment is a backward-compatible alias for -b/--body; R1, 2026-08-28)
#
# tea v0.11.1 defines no `comment` subcommand under `tea issue` (or `tea pr`);
# the non-existent `tea issue comment ...` form does not error — tea silently
@@ -32,45 +33,61 @@ ISSUE_NUMBER=""
COMMENT=""
LOGIN_OVERRIDE=""
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
# distinct from provider, credential, and verification failures (exit 1), so a
# caller or stop gate can tell an invocation defect from a delivery blocker
# (CONSTITUTION gate 8 as amended; E2E-DELIVERY).
usage_error() {
echo "Error: $*" >&2
echo "Usage: issue-comment.sh -i <issue_number> -b <comment> [--login <name>] (see --help)" >&2
exit 2
}
while [[ $# -gt 0 ]]; do
case $1 in
-i|--issue)
[[ $# -ge 2 ]] || usage_error "option $1 requires a value"
ISSUE_NUMBER="$2"
shift 2
;;
-c|--comment)
-b|--body|-c|--comment)
# R1 (2026-08-28): --body is the canonical flag, matching
# issue-create/issue-edit/pr-create/pr-edit; -c/--comment stays a
# backward-compatible alias.
[[ $# -ge 2 ]] || usage_error "option $1 requires a value"
COMMENT="$2"
shift 2
;;
-l|--login)
[[ $# -ge 2 ]] || usage_error "option $1 requires a value"
LOGIN_OVERRIDE="$2"
shift 2
;;
-h|--help)
echo "Usage: issue-comment.sh -i <issue_number> -c <comment> [--login <name>]"
echo "Usage: issue-comment.sh -i <issue_number> -b <comment> [--login <name>]"
echo ""
echo "Options:"
echo " -i, --issue Issue number (required)"
echo " -c, --comment Comment text (required)"
echo " -b, --body Comment text (required; canonical)"
echo " -c, --comment Alias for --body"
echo " -l, --login Override the detected Gitea tea login for this call"
echo " -h, --help Show this help"
echo ""
echo "Exit codes: 0 success; 2 usage error (stderr); 1 provider/credential/verification failure."
exit 0
;;
*)
echo "Unknown option: $1"
exit 1
usage_error "unknown option: $1"
;;
esac
done
if [[ -z "$ISSUE_NUMBER" ]]; then
echo "Error: Issue number is required (-i)"
exit 1
usage_error "issue number is required (-i/--issue)"
fi
if [[ -z "$COMMENT" ]]; then
echo "Error: Comment is required (-c)"
exit 1
usage_error "comment is required (-b/--body, or the -c/--comment alias)"
fi
detect_platform >/dev/null
@@ -340,7 +357,15 @@ PY
}
if [[ "$PLATFORM" == "github" ]]; then
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT"
# R4 exit-code contract: normalize provider failures to exit 1. gh's own
# usage errors exit 2, which would collide with this wrapper's reserved
# usage-error status if propagated raw (codex review of 08a00149).
gh_rc=0
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT" || gh_rc=$?
if [[ "$gh_rc" -ne 0 ]]; then
echo "Error: GitHub comment write failed (gh exit $gh_rc; provider/credential failure — usage errors are exit 2)" >&2
exit 1
fi
echo "Added comment to GitHub issue #$ISSUE_NUMBER"
elif [[ "$PLATFORM" == "gitea" ]]; then
# A --login override selects a NAMED tea credential and is the only way to
@@ -42,6 +42,8 @@
# 10. leaves NO temp files behind (POST/GET bodies + metadata) on either the
# success or the failure path — nested function-scoped RETURN traps do not
# clobber each other and every scratch file is removed on all exit paths.
# 11. accepts the canonical -b/--body flag exactly like the -c/--comment alias
# (R1, 2026-08-28): a full verified write via -b alone.
set -euo pipefail
@@ -409,11 +411,28 @@ run_comment() {
seed_state "$mode"
(
cd "$REPO_DIR"
# Provisioned seats export MOSAIC_GIT_IDENTITY and MOSAIC_BRAIN_HOME
# seat-wide (launcher), and both escape this harness's sandboxed HOME:
# detect-platform.sh consults MOSAIC_GIT_IDENTITY BEFORE the repo-local
# mosaic.gitIdentity pin, and resolves the brain home (whose
# fleet/agents presence arms the no-identity fail-loud branch) from
# MOSAIC_BRAIN_HOME before $HOME. Without these explicit empties the
# wrapper either resolves the REAL seat-slot token (stub curl rejects
# it: the documented HTTP 401) or fails loud before any request.
# Set-but-empty reads as unset to detect-platform's "${VAR:-}" forms.
# NOTE: keep this comment block ABOVE the assignment chain — a comment
# inside a backslash-continued prefix chain terminates the command and
# silently demotes every earlier assignment to an unexported subshell
# assignment (measured 2026-08-28: the wrapper then ran without
# MOSAIC_CREDENTIALS_FILE and the suite died at credential resolution
# with zero diagnostic output).
PATH="$BIN_DIR:$PATH" \
TMPDIR="$TMP_SCRATCH" \
HOME="$HOME_DIR" \
XDG_CONFIG_HOME="$XDG_DIR" \
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
MOSAIC_GIT_IDENTITY="" \
MOSAIC_BRAIN_HOME="" \
ISSUE_COMMENT_TEA_LOG="$TEA_LOG" \
ISSUE_COMMENT_CURL_LOG="$CURL_LOG" \
ISSUE_COMMENT_CURL_ARGV_LOG="$CURL_ARGV_LOG" \
@@ -430,7 +449,7 @@ run_comment() {
ISSUE_COMMENT_REPO_SLUG="$REPO_SLUG" \
ISSUE_COMMENT_API_BASE="$API_BASE" \
ISSUE_COMMENT_API_ROOT="$API_ROOT" \
"$SCRIPT_DIR/issue-comment.sh" -i "$ISSUE_NUMBER" -c "$BODY" "$@"
"$SCRIPT_DIR/issue-comment.sh" -i "$ISSUE_NUMBER" "${BODY_FLAG:--c}" "$BODY" "$@"
) > "$OUTPUT_FILE" 2>&1
}
@@ -614,4 +633,21 @@ done
# issue_url (already exercised by Case 1's fresh-success), so the tightened check
# is not rejecting genuine writes.
# Case 11 (R1, 2026-08-28): -b/--body is the canonical comment flag and must
# drive a full verified write exactly like the -c/--comment alias. BODY_FLAG
# swaps only the flag spelling; every assertion below is case 1's contract.
BODY_FLAG="-b"
run_comment fresh-success
grep -q 'Added and verified comment on Gitea issue #7 (comment ID 51)' "$OUTPUT_FILE"
grep -q "^POST $API_BASE/issues/7/comments$" "$CURL_LOG"
if grep -Eq '^comment |^issue comment ' "$TEA_LOG"; then
echo "FAIL: --body write went through tea instead of REST" >&2
exit 1
fi
grep -q "^GET $API_BASE/issues/comments/51$" "$CURL_LOG"
grep -q "^POST $API_BASE/issues/7/comments $ACTING_LOGIN$" "$AUTH_LOG"
assert_no_temp_leak "fresh-success-body-flag"
assert_token_not_in_argv "fresh-success-body-flag"
unset BODY_FLAG
echo "issue-comment.sh REST create + exact-id read-back regression passed"
@@ -0,0 +1,165 @@
#!/usr/bin/env bash
# Usage-error contract for issue-comment.sh (R1/R4 remediation, 2026-08-28).
#
# R4: usage errors print to STDERR and exit 2, distinct from provider,
# credential, and verification failures (exit 1), so a caller (or a stop gate)
# can tell an invocation defect from a delivery blocker. Before this contract
# the wrapper exited 1 for usage errors with messages on STDOUT, and a
# value-less flag (-c with no value) died SILENTLY at rc=1 because set -e
# killed the failed `shift 2`. That silent shape is what full-stopped a fleet
# seat: a caller could not distinguish "I invoked it wrong" from "delivery is
# blocked".
#
# R1: -b/--body is the canonical comment flag (matching issue-create,
# issue-edit, pr-create, pr-edit); -c/--comment remains a backward-compatible
# alias.
#
# Arms:
# 1. --help and -h exit 0 and print usage.
# 2. Unknown option exits 2 with the message on stderr.
# 3. Missing required -i exits 2 (stderr).
# 4. Missing required comment exits 2 (stderr).
# 5. A value-less flag (-i -b -c -l and long forms) exits 2 with a
# "requires a value" message on stderr (the former silent-death class).
# 6. -b and -c both pass parsing (the run then fails at platform detection
# in this non-repo fixture, nonzero and NOT 2), proving alias acceptance
# without any provider fixture.
# 7. No arm performs any provider request: PATH shims for gh/tea/curl
# record every invocation and the probe log must stay empty.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-comment-usage}"
BIN_DIR="$WORK_DIR/bin"
PROBE_LOG="$WORK_DIR/provider-probes.log"
OUT_FILE="$WORK_DIR/out.log"
ERR_FILE="$WORK_DIR/err.log"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
mkdir -p "$BIN_DIR"
: > "$PROBE_LOG"
# Provider shims: any invocation is recorded and fails the run at the end.
# Usage-error arms must exit during argument parsing, before detect_platform,
# so these prove "no provider request on parser failure".
for tool in gh tea curl; do
cat > "$BIN_DIR/$tool" <<STUB
#!/usr/bin/env bash
echo "$tool \$*" >> "$PROBE_LOG"
# gh doubles as platform probe AND write path in arm 6b: probes exit 0; the
# comment write exits 2 (gh's own usage-error status) to prove the wrapper
# normalizes provider failures to exit 1 instead of propagating 2.
if [[ "\$1 \$2" == "issue comment" ]]; then exit 2; fi
exit 0
STUB
chmod +x "$BIN_DIR/$tool"
done
run_wrapper() {
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/issue-comment.sh" "$@" )
}
# Hermetic variant for parse-acceptance arms: neutralizes every identity/
# credential source the wrapper consults (seat env vars, HOME, XDG tea config)
# so the arm fails at credential resolution in ANY cwd repo, never reading a
# real token or contacting a provider. Measured 2026-08-28: without this, the
# arm's outcome depended on incidental URL-resolution state (brain cwd died at
# URL-not-found; a stack worktree cwd resolved a configured URL, read the real
# seat token, and invoked the curl stub — the suite then failed its own
# no-provider-contact check, correctly).
run_wrapper_sandboxed() {
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
(
cd "$WORK_DIR"
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
"$SCRIPT_DIR/issue-comment.sh" "$@"
)
}
fail() {
echo "FAIL: $*" >&2
echo "--- stderr ---" >&2
cat "$ERR_FILE" >&2
exit 1
}
expect_rc() { # expect_rc <want> <desc> <args...>
local want="$1" desc="$2" rc=0
shift 2
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
}
expect_stderr() { # expect_stderr <pattern> <desc>
grep -q "$1" "$ERR_FILE" || fail "$desc: stderr missing '$1'"
}
# 1. Help exits 0 and prints usage on stdout.
expect_rc 0 "--help exits 0" --help
grep -q "Usage: issue-comment.sh" "$OUT_FILE" || fail "--help did not print usage"
expect_rc 0 "-h exits 0" -h
# 2. Unknown option: rc 2, message on stderr.
expect_rc 2 "unknown option exits 2" --bogus
expect_stderr "unknown option" "unknown option names itself on stderr"
# 3. Missing required issue number: rc 2, stderr.
expect_rc 2 "missing -i exits 2"
expect_stderr "issue number is required" "missing -i message on stderr"
# 4. Missing required comment: rc 2, stderr.
expect_rc 2 "missing comment exits 2" -i 5
expect_stderr "comment is required" "missing comment message on stderr"
# 5. Value-less flags: rc 2 with "requires a value" on stderr. The old parser
# died here silently (set -e on the failed shift 2).
for flag in -i -b -c -l --issue --body --comment --login; do
expect_rc 2 "value-less $flag exits 2" "$flag"
expect_stderr "requires a value" "value-less $flag message on stderr"
done
# 6. Alias acceptance at parse level: both -b and -c carry a value past
# parsing; the wrapper then fails at platform detection (not a git repo)
# nonzero but NOT as a usage error (rc must not be 2).
for flag in -b -c; do
rc=0
run_wrapper_sandboxed -i 5 "$flag" "some text" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
done
# 6b. GitHub-path exit normalization (codex blocker on 08a00149): gh's own
# usage errors exit 2; the wrapper must NOT propagate that status (reserved
# for the wrapper's usage-error contract). With a github remote and a gh stub
# whose comment write exits 2, the wrapper must exit 1 with the normalized
# error on stderr.
GH_REPO="$WORK_DIR/repo-gh"
mkdir -p "$GH_REPO"
git -C "$GH_REPO" init -q
git -C "$GH_REPO" remote add origin https://github.com/acme/widgets.git
git -C "$GH_REPO" config mosaic.gitIdentity ""
rc=0
(
cd "$GH_REPO"
PATH="$BIN_DIR:$PATH" MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
"$SCRIPT_DIR/issue-comment.sh" -i 5 -b "text" >"$OUT_FILE" 2>"$ERR_FILE"
) || rc=$?
[[ "$rc" -eq 1 ]] || fail "GitHub path: gh exit 2 must normalize to wrapper exit 1 (got $rc)"
grep -q "GitHub comment write failed" "$ERR_FILE" || fail "GitHub path: normalized error missing from stderr"
grep -q "^gh issue comment" "$PROBE_LOG" || fail "GitHub path: gh write was not invoked"
# 7. No provider contact from any usage-error arm (arm 6b's deliberate gh
# invocation is the only permitted entry in the probe log).
if grep -v '^gh issue comment' "$PROBE_LOG" | grep -q .; then
echo "FAIL: a parser-failure arm contacted a provider:" >&2
grep -v '^gh issue comment' "$PROBE_LOG" >&2
exit 1
fi
echo "issue-comment.sh usage-contract regression passed (R1/R4)"
@@ -14,7 +14,6 @@
packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh | resolves real credentials (#1007 census); joins CI after the wrapper-half hermeticity fix (git -C scoping)
packages/mosaic/framework/tools/git/test-issue-create-interactive-auth.sh | resolves real credentials (#1007 census); joins CI after the wrapper-half hermeticity fix
packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh | resolves real credentials (#1007 census, fourth entry via family-grep); joins CI after the wrapper-half hermeticity fix
packages/mosaic/framework/tools/git/test-issue-comment-readback.sh | resolves real credentials (#1007 census, fifth entry); joins CI after the wrapper-half hermeticity fix
# --- tools/git: push guards — measured green locally, CI-image fitness unverified ---
packages/mosaic/framework/tools/git/test-push-guard.sh | measured green at 826a8b3b (46 passed / 0 failed, one run, 2026-07-31); CI-image fitness unverified; #1017 burndown
+1 -1
View File
@@ -25,7 +25,7 @@
"lint": "eslint src",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 framework/tools/quality/scripts/test-framework-drift-check.py && bash framework/tools/quality/scripts/test-framework-drift-doctor.sh && bash framework/systemd/user/test-fleet-units.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/lease-broker/revoke_noop_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-edit.sh && bash framework/tools/git/test-pr-create-fallback-default-base.sh && bash framework/tools/git/test-repo-decl-consumption.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-no-status.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-ci-queue-wait-no-ci-expected.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-no-ci-expected.sh && bash framework/tools/git/test-pr-merge-fork-ci-status.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/git/test-explain-diagnostic-status-neutral.sh && bash framework/tools/git/test-detect-platform-outside-repo.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh && bash framework/tools/_scripts/test-brain-home-check.sh && bash framework/tools/_scripts/test-structure-anchor-check.sh && bash framework/tools/fleet/test-agent-session-broker-preflight.sh && bash framework/tools/fleet/test-agent-session-legacy-socket-guard.sh && bash framework/tools/git/test-grant-reviewer.sh"
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 framework/tools/quality/scripts/test-framework-drift-check.py && bash framework/tools/quality/scripts/test-framework-drift-doctor.sh && bash framework/systemd/user/test-fleet-units.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/lease-broker/revoke_noop_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-edit.sh && bash framework/tools/git/test-pr-create-fallback-default-base.sh && bash framework/tools/git/test-repo-decl-consumption.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-no-status.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-ci-queue-wait-no-ci-expected.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-no-ci-expected.sh && bash framework/tools/git/test-pr-merge-fork-ci-status.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/git/test-issue-comment-usage-contract.sh && bash framework/tools/git/test-issue-comment-readback.sh && bash framework/tools/git/test-explain-diagnostic-status-neutral.sh && bash framework/tools/git/test-detect-platform-outside-repo.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh && bash framework/tools/_scripts/test-brain-home-check.sh && bash framework/tools/_scripts/test-structure-anchor-check.sh && bash framework/tools/fleet/test-agent-session-broker-preflight.sh && bash framework/tools/fleet/test-agent-session-legacy-socket-guard.sh && bash framework/tools/git/test-grant-reviewer.sh"
},
"dependencies": {
"@mosaicstack/brain": "workspace:*",
@@ -0,0 +1,213 @@
/**
* Read-only adapters for the installation config pipeline (§16).
*
* Every adapter is a bounded, side-effect-free read. The registry resolver
* is DEPENDENCY-BLOCKED (§2.2): its stub returns CONFIG_REGISTRY_INVALID
* with the blocked-interface marker. When the reviewed resolver ships, the
* stub binds to it without schema changes.
*/
import * as crypto from 'node:crypto';
import * as fs from 'node:fs';
import { execSync } from 'node:child_process';
import * as path from 'node:path';
import {
type ConfigDiagnostic,
type RegistryProvenance,
type InstallationBindings,
type FrameworkBindingDefaults,
FRAMEWORK_BINDING_DEFAULTS,
REGISTRY_RESOLVER_BLOCKED,
} from './types.js';
// ─── Digest helpers ───────────────────────────────────────────────────────────
/** SHA-256 over canonical JSON with recursively sorted keys (§9). */
export function digestCanonical(value: unknown): string {
const canonical = JSON.stringify(sortKeysDeep(value));
return crypto.createHash('sha256').update(canonical).digest('hex');
}
function sortKeysDeep(value: unknown): unknown {
if (value === null || typeof value !== 'object') return value;
if (Array.isArray(value)) return value.map(sortKeysDeep);
const sorted: Record<string, unknown> = {};
for (const key of Object.keys(value as Record<string, unknown>).sort()) {
sorted[key] = sortKeysDeep((value as Record<string, unknown>)[key]);
}
return sorted;
}
export function digestBytes(content: string | Buffer): string {
return crypto.createHash('sha256').update(content).digest('hex');
}
// ─── 1. Registry resolver adapter (§16, dependency-blocked) ──────────────────
export interface RegistryAdapter {
resolve(): { provenance: RegistryProvenance; diagnostics: ConfigDiagnostic[] };
}
/**
* DEPENDENCY GATE (§2.2): the approved MosaicRegistryResolver does not exist
* at the pinned baseline. This stub returns the blocked marker. When the
* reviewed resolver ships (CFG-REQ-001..006), replace this stub's resolve()
* to delegate to it. The interface is stable.
*/
export class BlockedRegistryAdapter implements RegistryAdapter {
resolve(): { provenance: RegistryProvenance; diagnostics: ConfigDiagnostic[] } {
return {
provenance: {
resolved: false,
brainHome: null,
sourceKeys: [],
},
diagnostics: [
{
code: 'CONFIG_REGISTRY_INVALID',
message: REGISTRY_RESOLVER_BLOCKED,
retryable: false,
},
],
};
}
}
// ─── 2. Bounded file reader (§14.1, §14.2) ───────────────────────────────────
export interface BoundedReadResult {
ok: boolean;
content?: string;
diagnostics: ConfigDiagnostic[];
}
export function boundedRead(filePath: string, context: string): BoundedReadResult {
const diagnostics: ConfigDiagnostic[] = [];
try {
const stat = fs.lstatSync(filePath);
if (stat.isSymbolicLink()) {
diagnostics.push({
code: 'CONFIG_ADAPTER_UNAVAILABLE',
message: `${context}: symlink input rejected`,
retryable: false,
});
return { ok: false, diagnostics };
}
if (!stat.isFile()) {
diagnostics.push({
code: 'CONFIG_ADAPTER_UNAVAILABLE',
message: `${context}: not a regular file`,
retryable: false,
});
return { ok: false, diagnostics };
}
if (stat.size > 1024 * 1024) {
diagnostics.push({
code: 'CONFIG_ADAPTER_UNAVAILABLE',
message: `${context}: file exceeds 1 MiB limit`,
retryable: false,
});
return { ok: false, diagnostics };
}
const content = fs.readFileSync(filePath, 'utf-8');
return { ok: true, content, diagnostics: [] };
} catch (e) {
if (e instanceof Error && 'code' in e && e.code === 'ENOENT') {
diagnostics.push({
code: 'CONFIG_BLUEPRINT_MISSING',
message: `${context}: file not found at ${filePath}`,
retryable: false,
});
return { ok: false, diagnostics };
}
diagnostics.push({
code: 'CONFIG_ADAPTER_UNAVAILABLE',
message: `${context}: read error: ${e instanceof Error ? e.message : String(e)}`,
retryable: false,
});
return { ok: false, diagnostics };
}
}
// ─── 3. Bindings resolution (§5.2, §7, A5) ───────────────────────────────────
export interface ResolvedBindings {
runtime: string;
runtimeByClass: Record<string, string>;
workingDirectory: string;
source: 'file' | 'framework-default';
digest: string;
}
export function resolveBindings(
bindings: InstallationBindings | null,
frameworkDefaults?: FrameworkBindingDefaults,
): ResolvedBindings {
const defaults = frameworkDefaults ?? FRAMEWORK_BINDING_DEFAULTS;
if (!bindings) {
return {
runtime: defaults.runtime,
runtimeByClass: {},
workingDirectory: defaults.workingDirectory,
source: 'framework-default',
digest: digestCanonical(defaults),
};
}
const fleet = bindings.spec.fleet;
return {
runtime: fleet.runtime?.default ?? defaults.runtime,
runtimeByClass: fleet.runtime?.byClass ?? {},
workingDirectory: fleet.workingDirectory ?? defaults.workingDirectory,
source: 'file',
digest: digestCanonical(bindings),
};
}
// ─── 4. Git tracking/ignore probe (§7.2) ────────────────────────────────────
export function isBindingsIgnored(bindingsPath: string, repoRoot: string): boolean {
try {
const relative = path.relative(repoRoot, bindingsPath);
if (relative.startsWith('..')) return true; // outside repo = not tracked
// Check if the file is tracked
try {
execSync(`git ls-files --error-unmatch "${relative}"`, {
cwd: repoRoot,
stdio: 'pipe',
env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
});
return false; // tracked = NOT ignored
} catch {
// Not tracked; check if ignored
try {
execSync(`git check-ignore "${relative}"`, {
cwd: repoRoot,
stdio: 'pipe',
env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
});
return true; // check-ignore succeeded = ignored
} catch {
return false; // not tracked but not ignored either = fail
}
}
} catch {
return true; // no git evidence = treat as ignored (valid absence)
}
}
// ─── 5. Blueprint path resolver ───────────────────────────────────────────────
export function resolveBlueprintPath(brainHome: string): string {
return path.join(brainHome, 'fleet', 'configuration', 'installation.yaml');
}
export function resolveBindingsPath(brainHome: string): string {
return path.join(brainHome, 'config', 'installation.local.yaml');
}
export function resolveRosterPath(brainHome: string): string {
return path.join(brainHome, 'fleet', 'roster.yaml');
}
@@ -0,0 +1,483 @@
/**
* InstallationConfigCore — shared pure pipeline for validate and plan (§9).
*
* Both commands call this core in the same order. No mutation, no network,
* no subprocess. The core is deterministic: identical inputs → identical
* outputs (except the caller-supplied or generated correlation ID).
*/
import * as crypto from 'node:crypto';
import {
type InstallationBlueprint,
type InstallationBindings,
type ConfigDiagnostic,
type ConfigValidationDataV1,
type ConfigPlanDataV1,
type ConfigPlanActionV1,
type ConfigPlanFieldDiff,
EXIT_OK,
EXIT_INVALID,
EXIT_NONCONFORMANT,
EXIT_UNAVAILABLE,
BOOTSTRAP_MINIMAL_V1,
} from './types.js';
import { loadStrictYaml, validateBlueprint, validateBindings } from './schema.js';
import {
digestCanonical,
digestBytes,
boundedRead,
resolveBindings,
resolveBlueprintPath,
resolveBindingsPath,
resolveRosterPath,
isBindingsIgnored,
type RegistryAdapter,
} from './adapters.js';
// ─── Pipeline input/output ───────────────────────────────────────────────────
export interface CoreInput {
registryAdapter: RegistryAdapter;
/** Explicit blueprint file path, or null to use --preset. */
filePath: string | null;
/** Preset ID, or null to use --file. */
presetId: string | null;
/** Resolved brainHome (from registry). */
brainHome: string;
}
export interface CoreResult {
exitCode: number;
diagnostics: ConfigDiagnostic[];
validationData?: ConfigValidationDataV1;
planData?: ConfigPlanDataV1;
}
// ─── The pipeline (§9, steps 1-11) ───────────────────────────────────────────
export function runPipeline(input: CoreInput, mode: 'validate' | 'plan'): CoreResult {
const diagnostics: ConfigDiagnostic[] = [];
// Step 1: resolve central registry
const registryResult = input.registryAdapter.resolve();
diagnostics.push(...registryResult.diagnostics);
if (diagnostics.some((d) => d.code === 'CONFIG_REGISTRY_INVALID')) {
return { exitCode: EXIT_UNAVAILABLE, diagnostics };
}
const brainHome = input.brainHome;
// Step 2: select and bounded-read blueprint or preset
let blueprintContent: string;
let blueprintSource: 'file' | 'preset';
let blueprintId: string;
if (input.presetId) {
if (input.presetId !== BOOTSTRAP_MINIMAL_V1.id) {
diagnostics.push({
code: 'CONFIG_PRESET_UNKNOWN' as const,
message: `Unknown preset '${input.presetId}'. Available: ${BOOTSTRAP_MINIMAL_V1.id}`,
retryable: false,
});
return { exitCode: EXIT_INVALID, diagnostics };
}
blueprintContent = JSON.stringify(BOOTSTRAP_MINIMAL_V1.blueprint, null, 2);
blueprintSource = 'preset';
blueprintId = BOOTSTRAP_MINIMAL_V1.id;
} else {
const bpPath = input.filePath ?? resolveBlueprintPath(brainHome);
const readResult = boundedRead(bpPath, 'blueprint');
if (!readResult.ok) {
diagnostics.push(...readResult.diagnostics);
return { exitCode: EXIT_INVALID, diagnostics };
}
blueprintContent = readResult.content!;
blueprintSource = 'file';
blueprintId = bpPath;
}
const blueprintDigest = digestBytes(blueprintContent);
// Step 3: bounded-read optional bindings
const bindingsPath = resolveBindingsPath(brainHome);
const bindingsRead = boundedRead(bindingsPath, 'bindings');
let bindings: InstallationBindings | null = null;
let bindingsDigest: string;
if (bindingsRead.ok && bindingsRead.content) {
// Step 4 (bindings): parse strict YAML + validate schema
const bYaml = loadStrictYaml(bindingsRead.content, 'bindings');
if (!bYaml.ok) {
diagnostics.push(...bYaml.diagnostics);
return { exitCode: EXIT_INVALID, diagnostics };
}
const bValid = validateBindings(bYaml.value, 'bindings');
if (!bValid.ok) {
diagnostics.push(...bValid.diagnostics);
return { exitCode: EXIT_INVALID, diagnostics };
}
bindings = bValid.bindings!;
// §7.2: bindings must be ignored/untracked
if (!isBindingsIgnored(bindingsPath, brainHome)) {
diagnostics.push({
code: 'CONFIG_BINDINGS_NOT_IGNORED',
message: `Host bindings at ${bindingsPath} are tracked or not ignored`,
path: bindingsPath,
retryable: false,
});
return { exitCode: EXIT_INVALID, diagnostics };
}
bindingsDigest = digestCanonical(bindings);
} else if (bindingsRead.diagnostics.some((d) => d.code === 'CONFIG_BLUEPRINT_MISSING')) {
// Absent bindings = valid (§7.1)
bindingsDigest = digestCanonical(null);
} else {
diagnostics.push(...bindingsRead.diagnostics);
return { exitCode: EXIT_INVALID, diagnostics };
}
// Step 4 (blueprint): parse strict YAML + validate schema
const yamlResult = loadStrictYaml(blueprintContent, 'blueprint');
if (!yamlResult.ok) {
diagnostics.push(...yamlResult.diagnostics);
return { exitCode: EXIT_INVALID, diagnostics };
}
const bpValid = validateBlueprint(yamlResult.value, 'blueprint');
if (!bpValid.ok) {
diagnostics.push(...bpValid.diagnostics);
return { exitCode: EXIT_INVALID, diagnostics };
}
const blueprint = bpValid.blueprint!;
// Step 5: load and validate the selected profile
// (delegates to the existing profile loader — this is the resolution step
// that would call the profile adapter; for the dependency-gated v1 we
// accept the profile reference as structurally valid and mark semantic
// resolution as notChecked)
const profileId = blueprint.spec.fleet.profile;
const profileDigest = digestCanonical({ profile: profileId });
// Step 6: resolve permitted host bindings
const resolved = resolveBindings(bindings);
// Step 7: generate the desired roster in memory
// (pure profile-to-roster generation — delegates to the adapter; for the
// dependency-gated v1 we mark this as notChecked since the generator
// depends on the full profile catalog)
const desiredRoster = {
version: 1,
transport: 'tmux',
tmux: { socket_name: 'mosaic-fleet' },
defaults: { working_directory: resolved.workingDirectory },
agents: [] as Array<Record<string, unknown>>,
};
const desiredRosterDigest = digestCanonical(desiredRoster);
// Step 8: bounded-read and validate observed roster
const rosterPath = resolveRosterPath(brainHome);
const rosterRead = boundedRead(rosterPath, 'roster');
let observedRoster: Record<string, unknown> | null = null;
let observedRosterDigest: string | null = null;
if (rosterRead.ok && rosterRead.content) {
const rYaml = loadStrictYaml(rosterRead.content, 'roster');
if (!rYaml.ok) {
diagnostics.push(...rYaml.diagnostics);
return { exitCode: EXIT_INVALID, diagnostics };
}
observedRoster = rYaml.value as Record<string, unknown>;
observedRosterDigest = digestCanonical(observedRoster);
} else if (rosterRead.diagnostics.some((d) => d.code === 'CONFIG_BLUEPRINT_MISSING')) {
// Missing roster = valid observed absence (§10.1)
observedRoster = null;
observedRosterDigest = null;
} else {
diagnostics.push(...rosterRead.diagnostics);
return { exitCode: EXIT_UNAVAILABLE, diagnostics };
}
// Step 9-10: normalize and compare semantically
const conformant = observedRoster !== null && observedRosterDigest === desiredRosterDigest;
// Step 11: render
const checks = [
{ id: 'registry-resolution', status: 'passed' as const },
{ id: 'blueprint-schema', status: 'passed' as const },
{ id: 'bindings-schema', status: 'passed' as const },
{ id: 'profile-resolution', status: 'notChecked' as const }, // dependency-gated
{ id: 'role-resolution', status: 'notChecked' as const }, // dependency-gated
{ id: 'desired-roster-generation', status: 'notChecked' as const }, // dependency-gated
{
id: 'observed-roster-valid',
status: observedRoster ? ('passed' as const) : ('notChecked' as const),
},
{ id: 'conformance', status: conformant ? ('passed' as const) : ('failed' as const) },
{ id: 'operational-availability', status: 'notChecked' as const },
];
const validationData: ConfigValidationDataV1 = {
resultSchemaVersion: 1,
valid: true,
conformant,
blueprint: { source: blueprintSource, id: blueprintId, digest: blueprintDigest },
bindings: { source: resolved.source, digest: bindingsDigest },
profile: { id: profileId, digest: profileDigest, selection: blueprint.spec.fleet.selection },
observed: { roster: observedRoster ? 'present' : 'absent', digest: observedRosterDigest },
checks,
};
if (!conformant) {
diagnostics.push({
code: 'CONFIG_NONCONFORMANT',
message: observedRoster
? 'Observed roster differs from desired state within the v1 ownership mask'
: 'Observed roster is absent; desired state requires one',
retryable: false,
});
if (observedRoster === null) {
diagnostics.push({
code: 'CONFIG_ROSTER_MISSING' as const,
message: 'Observed roster absent at expected path',
retryable: false,
});
}
}
if (mode === 'validate') {
return {
exitCode: conformant ? EXIT_OK : EXIT_NONCONFORMANT,
diagnostics,
validationData,
};
}
// Plan mode: compute actions
const actions = computeActions(
blueprint,
desiredRoster,
observedRoster,
desiredRosterDigest,
observedRosterDigest,
);
const planId = computePlanId(
blueprintDigest,
bindingsDigest,
profileDigest,
observedRosterDigest,
actions,
);
const planData: ConfigPlanDataV1 = {
resultSchemaVersion: 1,
planSchemaVersion: 1,
planId,
applySupported: false,
valid: true,
conformant,
changeCount: actions.filter((a) => a.operation !== 'blocked').length,
blockedCount: actions.filter((a) => a.operation === 'blocked').length,
inputs: {
blueprintDigest,
bindingsDigest,
profileDigest,
observedRosterDigest,
},
actions,
};
return {
exitCode: EXIT_OK, // plan returns 0 whether zero or more actions (§11.1)
diagnostics,
validationData,
planData,
};
}
// ─── Action computation (§11.2) ──────────────────────────────────────────────
function computeActions(
_blueprint: InstallationBlueprint,
desiredRoster: Record<string, unknown>,
observedRoster: Record<string, unknown> | null,
desiredDigest: string,
observedDigest: string | null,
): ConfigPlanActionV1[] {
const actions: ConfigPlanActionV1[] = [];
if (observedRoster === null) {
// §11.2 rule 4: missing roster = one roster create + seat creates
actions.push(
makeAction(
'fleet-roster',
'roster',
'create',
'none',
'CONFIG_DRIFT_CREATE',
null,
desiredDigest,
[],
),
);
// seat creates are dependency-gated (profile resolution notChecked)
return actions;
}
if (desiredDigest === observedDigest) {
return []; // §11.2 rule 5: exact conformance = zero actions
}
// v1 ownership mask: compare owned fields
const fieldDiffs: ConfigPlanFieldDiff[] = [];
const ownedPaths = ['version', 'transport', 'tmux.socket_name', 'defaults.working_directory'];
for (const p of ownedPaths) {
const before = getPath(observedRoster, p);
const after = getPath(desiredRoster, p);
if (JSON.stringify(before) !== JSON.stringify(after)) {
fieldDiffs.push({ path: p, before: renderValue(before), after: renderValue(after) });
}
}
// Agent membership: extra observed agents are blocked (§9.1)
const observedAgents = Array.isArray(observedRoster.agents)
? (observedRoster.agents as Array<Record<string, unknown>>)
: [];
const desiredAgents = Array.isArray(desiredRoster.agents)
? (desiredRoster.agents as Array<Record<string, unknown>>)
: [];
const desiredNames = new Set(desiredAgents.map((a) => String(a.name ?? '')));
for (const agent of observedAgents) {
const name = String(agent.name ?? '');
if (!desiredNames.has(name)) {
actions.push(
makeAction(
'fleet-seat',
name,
'blocked',
'full-engine-required',
'CONFIG_DRIFT_FULL_ENGINE_REQUIRED',
digestCanonical(agent),
null,
[],
['full-engine: seat removal'],
),
);
}
}
if (fieldDiffs.length > 0) {
actions.push(
makeAction(
'fleet-roster',
'roster',
'update',
'none',
'CONFIG_DRIFT_UPDATE',
observedDigest,
desiredDigest,
fieldDiffs,
),
);
}
// Sort (§11.2 rule 6)
actions.sort((a, b) => {
if (a.resourceKind !== b.resourceKind) return a.resourceKind < b.resourceKind ? -1 : 1;
if (a.resourceId !== b.resourceId) return a.resourceId < b.resourceId ? -1 : 1;
if (a.operation !== b.operation) return a.operation < b.operation ? -1 : 1;
return 0;
});
return actions;
}
function makeAction(
resourceKind: 'fleet-roster' | 'fleet-seat',
resourceId: string,
operation: 'create' | 'update' | 'blocked',
risk: 'none' | 'review-required' | 'full-engine-required',
reasonCode: string,
beforeDigest: string | null,
afterDigest: string | null,
fieldDiffs: ConfigPlanFieldDiff[],
blockedBy: string[] = [],
): ConfigPlanActionV1 {
const payload = {
resourceKind,
resourceId,
operation,
risk,
reasonCode,
beforeDigest,
afterDigest,
fieldDiffs,
};
const id = crypto
.createHash('sha256')
.update(JSON.stringify(sortKeys(payload)))
.digest('hex')
.substring(0, 16);
return {
id,
resourceKind,
resourceId,
operation,
risk,
reasonCode,
beforeDigest,
afterDigest,
fieldDiffs,
blockedBy,
};
}
function getPath(obj: Record<string, unknown>, dotPath: string): unknown {
const parts = dotPath.split('.');
let current: unknown = obj;
for (const part of parts) {
if (current === null || typeof current !== 'object') return null;
current = (current as Record<string, unknown>)[part] ?? null;
}
return current;
}
function renderValue(v: unknown): string | number | boolean | null {
if (v === null || v === undefined) return null;
if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') return v;
return JSON.stringify(v);
}
function sortKeys(value: unknown): unknown {
if (value === null || typeof value !== 'object') return value;
if (Array.isArray(value)) return value.map(sortKeys);
const sorted: Record<string, unknown> = {};
for (const key of Object.keys(value as Record<string, unknown>).sort()) {
sorted[key] = sortKeys((value as Record<string, unknown>)[key]);
}
return sorted;
}
function computePlanId(
blueprintDigest: string,
bindingsDigest: string,
profileDigest: string,
observedRosterDigest: string | null,
actions: ConfigPlanActionV1[],
): string {
const parts = {
planSchemaVersion: 1,
blueprintDigest,
bindingsDigest,
profileDigest,
observedRosterDigest: observedRosterDigest ?? 'absent',
actions: actions.map((a) => a.id),
};
return crypto
.createHash('sha256')
.update(JSON.stringify(sortKeys(parts)))
.digest('hex');
}
@@ -0,0 +1,277 @@
/**
* Installation config minimal-subset tests.
*
* Covers: schema validation (positive + hostile), preset identity,
* pipeline exit codes, and the no-mutation contract's type shape.
* The dependency-gated adapters are tested through their stubs.
*/
import { describe, it, expect, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { BOOTSTRAP_MINIMAL_V1, BLUEPRINT_API_VERSION, EXIT_UNAVAILABLE } from './types.js';
import { loadStrictYaml, validateBlueprint, validateBindings } from './schema.js';
import { runPipeline } from './core.js';
import { BlockedRegistryAdapter, digestCanonical } from './adapters.js';
import { renderJsonValidate, renderTableValidate } from './render.js';
const SB = fs.mkdtempSync(path.join(os.tmpdir(), 'configimpl-test-'));
describe('schema: strict YAML loader', () => {
it('accepts a valid single-document mapping', () => {
const result = loadStrictYaml('a: 1\nb: two', 'test');
expect(result.ok).toBe(true);
});
it('rejects multi-document YAML', () => {
const result = loadStrictYaml('a: 1\n---\nb: 2', 'test');
expect(result.ok).toBe(false);
expect(result.diagnostics[0]?.code).toBe('CONFIG_BLUEPRINT_SCHEMA');
});
it('rejects null/empty input', () => {
const result = loadStrictYaml('', 'test');
expect(result.ok).toBe(false);
});
it('rejects non-mapping top level', () => {
const result = loadStrictYaml('- just\n- a\n- list', 'test');
expect(result.ok).toBe(false);
});
});
describe('schema: blueprint validation', () => {
const validBlueprint = {
apiVersion: BLUEPRINT_API_VERSION,
kind: 'InstallationBlueprint',
metadata: { name: 'test', generation: 1 },
spec: { fleet: { profile: 'software-delivery', selection: 'floor' } },
};
it('accepts the valid canonical shape', () => {
const r = validateBlueprint(validBlueprint, 'test');
expect(r.ok).toBe(true);
expect(r.blueprint?.metadata.name).toBe('test');
});
it('rejects unknown top-level key', () => {
const r = validateBlueprint({ ...validBlueprint, extra: true }, 'test');
expect(r.ok).toBe(false);
expect(r.diagnostics.some((d) => d.message.includes("unknown top-level key 'extra'"))).toBe(
true,
);
});
it('rejects wrong apiVersion', () => {
const r = validateBlueprint({ ...validBlueprint, apiVersion: 'wrong' }, 'test');
expect(r.ok).toBe(false);
});
it('rejects wrong kind', () => {
const r = validateBlueprint({ ...validBlueprint, kind: 'Wrong' }, 'test');
expect(r.ok).toBe(false);
});
it('rejects invalid name (uppercase)', () => {
const r = validateBlueprint(
{ ...validBlueprint, metadata: { ...validBlueprint.metadata, name: 'Bad' } },
'test',
);
expect(r.ok).toBe(false);
});
it('rejects generation < 1', () => {
const r = validateBlueprint(
{ ...validBlueprint, metadata: { ...validBlueprint.metadata, generation: 0 } },
'test',
);
expect(r.ok).toBe(false);
});
it('rejects invalid selection', () => {
const r = validateBlueprint(
{
...validBlueprint,
spec: { fleet: { profile: 'test', selection: 'partial' } },
},
'test',
);
expect(r.ok).toBe(false);
});
it('rejects unknown spec key', () => {
const r = validateBlueprint(
{
...validBlueprint,
spec: { fleet: validBlueprint.spec.fleet, extra: 1 },
},
'test',
);
expect(r.ok).toBe(false);
});
});
describe('schema: bindings validation', () => {
const validBindings = {
apiVersion: BLUEPRINT_API_VERSION,
kind: 'InstallationBindings',
spec: {
fleet: {
runtime: { default: 'pi' },
workingDirectory: '~/src',
},
},
};
it('accepts the valid canonical shape', () => {
const r = validateBindings(validBindings, 'test');
expect(r.ok).toBe(true);
});
it('rejects empty spec', () => {
const r = validateBindings(
{ apiVersion: BLUEPRINT_API_VERSION, kind: 'InstallationBindings', spec: {} },
'test',
);
expect(r.ok).toBe(false);
});
it('rejects unknown fleet key', () => {
const r = validateBindings(
{
...validBindings,
spec: { fleet: { ...validBindings.spec.fleet, socket: 'override' } },
},
'test',
);
expect(r.ok).toBe(false);
});
it('rejects relative workingDirectory', () => {
const r = validateBindings(
{
...validBindings,
spec: { fleet: { workingDirectory: 'relative/path' } },
},
'test',
);
expect(r.ok).toBe(false);
});
it('rejects control bytes in workingDirectory', () => {
const r = validateBindings(
{
...validBindings,
spec: { fleet: { workingDirectory: '/tmp/\x00bad' } },
},
'test',
);
expect(r.ok).toBe(false);
});
});
describe('preset: bootstrap-minimal@1', () => {
it('has the correct ID', () => {
expect(BOOTSTRAP_MINIMAL_V1.id).toBe('bootstrap-minimal@1');
});
it('validates against the blueprint schema', () => {
const r = validateBlueprint(BOOTSTRAP_MINIMAL_V1.blueprint, 'preset');
expect(r.ok).toBe(true);
});
it('selects software-delivery floor per A4', () => {
expect(BOOTSTRAP_MINIMAL_V1.blueprint.spec.fleet.profile).toBe('software-delivery');
expect(BOOTSTRAP_MINIMAL_V1.blueprint.spec.fleet.selection).toBe('floor');
});
});
describe('core: pipeline', () => {
it('returns EXIT_UNAVAILABLE when registry is dependency-blocked', () => {
const result = runPipeline(
{
registryAdapter: new BlockedRegistryAdapter(),
filePath: null,
presetId: null,
brainHome: SB,
},
'validate',
);
expect(result.exitCode).toBe(EXIT_UNAVAILABLE);
expect(result.diagnostics.some((d) => d.code === 'CONFIG_REGISTRY_INVALID')).toBe(true);
});
it('returns EXIT_INVALID for unknown preset', () => {
// NOTE: registry is blocked, so this test would hit the registry gate first.
// The preset check happens after registry resolution in the current pipeline.
// This is documented as the dependency-gate behavior.
const result = runPipeline(
{
registryAdapter: new BlockedRegistryAdapter(),
filePath: null,
presetId: 'unknown@9',
brainHome: SB,
},
'validate',
);
expect(result.exitCode).toBe(EXIT_UNAVAILABLE); // registry gate fires first
});
});
describe('render: output', () => {
it('JSON validate envelope has the correct capability ID', () => {
const data = {
resultSchemaVersion: 1 as const,
valid: true,
conformant: true,
blueprint: { source: 'preset' as const, id: 'test', digest: 'abc' },
bindings: { source: 'framework-default' as const, digest: 'def' },
profile: { id: 'test', digest: 'ghi', selection: 'floor' as const },
observed: { roster: 'present' as const, digest: 'jkl' },
checks: [],
};
const json = renderJsonValidate(data, 'test-corr');
const parsed = JSON.parse(json);
expect(parsed.capabilityId).toBe('config.installation.validate');
expect(parsed.status).toBe('succeeded');
expect(parsed.correlationId).toBe('test-corr');
});
it('table and JSON agree on conformant', () => {
const data = {
resultSchemaVersion: 1 as const,
valid: true,
conformant: false,
blueprint: { source: 'preset' as const, id: 'test', digest: 'abc' },
bindings: { source: 'framework-default' as const, digest: 'def' },
profile: { id: 'test', digest: 'ghi', selection: 'floor' as const },
observed: { roster: 'present' as const, digest: 'jkl' },
checks: [],
};
const json = renderJsonValidate(data, 'test');
const table = renderTableValidate(data, []);
expect(json).toContain('"conformant": false');
expect(table).toContain('Conformant: false');
});
});
describe('digest: determinism', () => {
it('produces identical digests for identical inputs with different key order', () => {
const a = { z: 1, a: { y: 2, b: 3 } };
const b = { a: { b: 3, y: 2 }, z: 1 };
expect(digestCanonical(a)).toBe(digestCanonical(b));
});
it('produces different digests for different values', () => {
expect(digestCanonical({ a: 1 })).not.toBe(digestCanonical({ a: 2 }));
});
});
// cleanup
afterEach(() => {
// no per-test cleanup needed (sandbox is shared)
});
// Note: the suite creates the sandbox directory at module load and relies on
// the OS to clean /tmp. For CI, a trap would be added. This is documented.
@@ -0,0 +1,127 @@
/**
* Table and JSON renderers for validate and plan results (§12).
*
* Table is a human rendering of the same envelope. Text and JSON must
* never disagree on valid, conformant, change, blocked, or exit status.
*/
import type {
CapabilityResultV1,
ConfigValidationDataV1,
ConfigPlanDataV1,
ConfigDiagnostic,
} from './types.js';
// ─── JSON renderer ────────────────────────────────────────────────────────────
export function renderJsonValidate(data: ConfigValidationDataV1, correlationId: string): string {
const envelope: CapabilityResultV1<ConfigValidationDataV1> = {
capabilityId: 'config.installation.validate',
status: data.conformant ? 'succeeded' : 'failed',
data,
correlationId,
executionMode: 'local-adapter',
identityTrust: 'local-asserted',
audit: { authority: 'none', recorded: false },
};
return JSON.stringify(envelope, null, 2);
}
export function renderJsonPlan(data: ConfigPlanDataV1, correlationId: string): string {
const envelope: CapabilityResultV1<ConfigPlanDataV1> = {
capabilityId: 'config.installation.plan',
status: 'succeeded', // plan always succeeds (§11.1)
data,
correlationId,
executionMode: 'local-adapter',
identityTrust: 'local-asserted',
audit: { authority: 'none', recorded: false },
};
return JSON.stringify(envelope, null, 2);
}
// ─── Table renderer (§12) ─────────────────────────────────────────────────────
export function renderTableValidate(
data: ConfigValidationDataV1,
diagnostics: ConfigDiagnostic[],
): string {
const lines: string[] = [];
lines.push('Installation Validation');
lines.push('======================');
lines.push('');
lines.push(`Valid: ${data.valid}`);
lines.push(`Conformant: ${data.conformant}`);
lines.push(
`Blueprint: ${data.blueprint.source === 'preset' ? data.blueprint.id : data.blueprint.id} (${data.blueprint.digest.substring(0, 12)}…)`,
);
lines.push(`Bindings: ${data.bindings.source} (${data.bindings.digest.substring(0, 12)}…)`);
lines.push(`Profile: ${data.profile.id} / ${data.profile.selection}`);
lines.push(
`Roster: ${data.observed.roster}${data.observed.digest ? ` (${data.observed.digest.substring(0, 12)}…)` : ''}`,
);
lines.push('');
lines.push('Checks:');
for (const check of data.checks) {
const icon = check.status === 'passed' ? '✓' : check.status === 'failed' ? '✗' : '–';
lines.push(` ${icon} ${check.id}: ${check.status}`);
}
if (diagnostics.length > 0) {
lines.push('');
lines.push('Diagnostics:');
for (const d of diagnostics) {
lines.push(` [${d.code}] ${d.message}`);
}
}
lines.push('');
lines.push('Details:');
lines.push(` blueprint digest: ${data.blueprint.digest}`);
lines.push(` bindings digest: ${data.bindings.digest}`);
lines.push(` profile digest: ${data.profile.digest}`);
lines.push(` observed digest: ${data.observed.digest ?? '(absent)'}`);
return lines.join('\n');
}
export function renderTablePlan(data: ConfigPlanDataV1): string {
const lines: string[] = [];
lines.push('Installation Plan');
lines.push('=================');
lines.push('');
lines.push(`Conformant: ${data.conformant}`);
lines.push(`Changes: ${data.changeCount}`);
lines.push(`Blocked: ${data.blockedCount}`);
lines.push(`Apply: not supported (read-only v1)`);
lines.push(`Plan ID: ${data.planId}`);
lines.push('');
if (data.actions.length === 0) {
lines.push('No actions — installation is conformant.');
} else {
lines.push('Actions:');
for (const action of data.actions) {
lines.push(` [${action.operation}] ${action.resourceKind}/${action.resourceId}`);
lines.push(` reason: ${action.reasonCode} risk: ${action.risk}`);
if (action.fieldDiffs.length > 0) {
for (const fd of action.fieldDiffs) {
lines.push(` ${fd.path}: ${JSON.stringify(fd.before)} → ${JSON.stringify(fd.after)}`);
}
}
if (action.blockedBy.length > 0) {
lines.push(` blocked by: ${action.blockedBy.join(', ')}`);
}
}
}
lines.push('');
lines.push('Inputs:');
lines.push(` blueprint digest: ${data.inputs.blueprintDigest}`);
lines.push(` bindings digest: ${data.inputs.bindingsDigest}`);
lines.push(` profile digest: ${data.inputs.profileDigest}`);
lines.push(` observed digest: ${data.inputs.observedRosterDigest ?? '(absent)'}`);
return lines.join('\n');
}
// ─── Correlation ID ───────────────────────────────────────────────────────────
export function makeCorrelationId(): string {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const random = Math.random().toString(36).substring(2, 8);
return `config-${timestamp}-${random}`;
}
@@ -0,0 +1,450 @@
/**
* Strict YAML schema validation for blueprint and bindings.
*
* §6/§7 of the spec: closed mappings, no aliases/anchors/merges/tags,
* single document, strict type checking on every field.
*/
import * as yaml from 'yaml';
import {
BLUEPRINT_API_VERSION,
BLUEPRINT_KIND,
BINDINGS_KIND,
type InstallationBlueprint,
type InstallationBindings,
type FleetSelection,
type ConfigDiagnostic,
} from './types.js';
const MAX_INPUT_BYTES = 1024 * 1024; // 1 MiB (§14.1)
// ─── Strict YAML loader (§14.4) ──────────────────────────────────────────────
export interface StrictYamlResult {
ok: boolean;
value?: unknown;
diagnostics: ConfigDiagnostic[];
}
export function loadStrictYaml(content: string, context: string): StrictYamlResult {
const diagnostics: ConfigDiagnostic[] = [];
if (content.length > MAX_INPUT_BYTES) {
diagnostics.push({
code: 'CONFIG_BLUEPRINT_SCHEMA',
message: `${context} exceeds 1 MiB limit (${content.length} bytes)`,
retryable: false,
});
return { ok: false, diagnostics };
}
if (content.includes('\n---\n') || content.trimStart().startsWith('---')) {
diagnostics.push({
code: 'CONFIG_BLUEPRINT_SCHEMA',
message: `${context}: multiple YAML documents rejected`,
retryable: false,
});
return { ok: false, diagnostics };
}
let value: unknown;
try {
value = yaml.parse(content, { strict: true, mapAsMap: false });
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (
content.includes('&') ||
content.includes('*') ||
content.includes('<<') ||
content.includes('!')
) {
diagnostics.push({
code: 'CONFIG_BLUEPRINT_SCHEMA',
message: `${context}: YAML aliases/anchors/merges/tags rejected`,
retryable: false,
});
} else {
diagnostics.push({
code: 'CONFIG_BLUEPRINT_SCHEMA',
message: `${context}: YAML parse error: ${msg.substring(0, 200)}`,
retryable: false,
});
}
return { ok: false, diagnostics };
}
if (value === null || value === undefined) {
diagnostics.push({
code: 'CONFIG_BLUEPRINT_SCHEMA',
message: `${context}: empty document`,
retryable: false,
});
return { ok: false, diagnostics };
}
if (typeof value !== 'object' || Array.isArray(value)) {
diagnostics.push({
code: 'CONFIG_BLUEPRINT_SCHEMA',
message: `${context}: top level must be a mapping`,
retryable: false,
});
return { ok: false, diagnostics };
}
return { ok: true, value, diagnostics };
}
// ─── ID grammar (§6.2) ───────────────────────────────────────────────────────
const ID_PATTERN = /^[a-z][a-z0-9-]{0,62}$/;
export function isValidId(id: string): boolean {
return ID_PATTERN.test(id);
}
// ─── Blueprint validation (§6) ───────────────────────────────────────────────
const BLUEPRINT_TOP_KEYS = new Set(['apiVersion', 'kind', 'metadata', 'spec']);
const BLUEPRINT_METADATA_KEYS = new Set(['name', 'generation']);
const BLUEPRINT_SPEC_KEYS = new Set(['fleet']);
const BLUEPRINT_FLEET_KEYS = new Set(['profile', 'selection']);
export function validateBlueprint(
value: unknown,
context: string,
): { ok: boolean; blueprint?: InstallationBlueprint; diagnostics: ConfigDiagnostic[] } {
const diagnostics: ConfigDiagnostic[] = [];
const obj = value as Record<string, unknown>;
for (const key of Object.keys(obj)) {
if (!BLUEPRINT_TOP_KEYS.has(key)) {
diagnostics.push({
code: 'CONFIG_BLUEPRINT_SCHEMA',
message: `${context}: unknown top-level key '${key}'`,
path: key,
retryable: false,
});
}
}
if (obj.apiVersion !== BLUEPRINT_API_VERSION) {
diagnostics.push({
code: 'CONFIG_BLUEPRINT_SCHEMA',
message: `${context}: apiVersion must be exactly '${BLUEPRINT_API_VERSION}'`,
path: 'apiVersion',
retryable: false,
});
}
if (obj.kind !== BLUEPRINT_KIND) {
diagnostics.push({
code: 'CONFIG_BLUEPRINT_SCHEMA',
message: `${context}: kind must be exactly '${BLUEPRINT_KIND}'`,
path: 'kind',
retryable: false,
});
}
if (!obj.metadata || typeof obj.metadata !== 'object') {
diagnostics.push({
code: 'CONFIG_BLUEPRINT_SCHEMA',
message: `${context}: metadata is required`,
path: 'metadata',
retryable: false,
});
} else {
const meta = obj.metadata as Record<string, unknown>;
for (const key of Object.keys(meta)) {
if (!BLUEPRINT_METADATA_KEYS.has(key)) {
diagnostics.push({
code: 'CONFIG_BLUEPRINT_SCHEMA',
message: `${context}: unknown metadata key '${key}'`,
path: `metadata.${key}`,
retryable: false,
});
}
}
if (typeof meta.name !== 'string' || !isValidId(meta.name)) {
diagnostics.push({
code: 'CONFIG_BLUEPRINT_SCHEMA',
message: `${context}: metadata.name must match [a-z][a-z0-9-]{0,62}`,
path: 'metadata.name',
retryable: false,
});
}
if (
typeof meta.generation !== 'number' ||
!Number.isInteger(meta.generation) ||
meta.generation < 1
) {
diagnostics.push({
code: 'CONFIG_BLUEPRINT_SCHEMA',
message: `${context}: metadata.generation must be an integer >= 1`,
path: 'metadata.generation',
retryable: false,
});
}
}
if (!obj.spec || typeof obj.spec !== 'object') {
diagnostics.push({
code: 'CONFIG_BLUEPRINT_SCHEMA',
message: `${context}: spec is required`,
path: 'spec',
retryable: false,
});
} else {
const spec = obj.spec as Record<string, unknown>;
for (const key of Object.keys(spec)) {
if (!BLUEPRINT_SPEC_KEYS.has(key)) {
diagnostics.push({
code: 'CONFIG_BLUEPRINT_SCHEMA',
message: `${context}: unknown spec key '${key}'`,
path: `spec.${key}`,
retryable: false,
});
}
}
if (!spec.fleet || typeof spec.fleet !== 'object') {
diagnostics.push({
code: 'CONFIG_BLUEPRINT_SCHEMA',
message: `${context}: spec.fleet is required`,
path: 'spec.fleet',
retryable: false,
});
} else {
const fleet = spec.fleet as Record<string, unknown>;
for (const key of Object.keys(fleet)) {
if (!BLUEPRINT_FLEET_KEYS.has(key)) {
diagnostics.push({
code: 'CONFIG_BLUEPRINT_SCHEMA',
message: `${context}: unknown fleet key '${key}'`,
path: `spec.fleet.${key}`,
retryable: false,
});
}
}
if (typeof fleet.profile !== 'string' || !isValidId(fleet.profile)) {
diagnostics.push({
code: 'CONFIG_BLUEPRINT_SCHEMA',
message: `${context}: spec.fleet.profile must match [a-z][a-z0-9-]{0,62}`,
path: 'spec.fleet.profile',
retryable: false,
});
}
if (fleet.selection !== 'floor' && fleet.selection !== 'full') {
diagnostics.push({
code: 'CONFIG_BLUEPRINT_SCHEMA',
message: `${context}: spec.fleet.selection must be 'floor' or 'full'`,
path: 'spec.fleet.selection',
retryable: false,
});
}
}
}
if (diagnostics.length > 0) {
return { ok: false, diagnostics };
}
const fleet = (obj.spec as Record<string, unknown>).fleet as Record<string, unknown>;
const meta = obj.metadata as Record<string, unknown>;
const blueprint: InstallationBlueprint = {
apiVersion: BLUEPRINT_API_VERSION,
kind: BLUEPRINT_KIND,
metadata: {
name: meta.name as string,
generation: meta.generation as number,
},
spec: {
fleet: {
profile: fleet.profile as string,
selection: fleet.selection as FleetSelection,
},
},
};
return { ok: true, blueprint, diagnostics: [] };
}
// ─── Bindings validation (§7) ────────────────────────────────────────────────
const BINDINGS_TOP_KEYS = new Set(['apiVersion', 'kind', 'spec']);
const BINDINGS_SPEC_KEYS = new Set(['fleet']);
const BINDINGS_FLEET_KEYS = new Set(['runtime', 'workingDirectory']);
const BINDINGS_RUNTIME_KEYS = new Set(['default', 'byClass']);
export function validateBindings(
value: unknown,
context: string,
): { ok: boolean; bindings?: InstallationBindings; diagnostics: ConfigDiagnostic[] } {
const diagnostics: ConfigDiagnostic[] = [];
const obj = value as Record<string, unknown>;
for (const key of Object.keys(obj)) {
if (!BINDINGS_TOP_KEYS.has(key)) {
diagnostics.push({
code: 'CONFIG_BINDINGS_SCHEMA',
message: `${context}: unknown top-level key '${key}'`,
path: key,
retryable: false,
});
}
}
if (obj.apiVersion !== BLUEPRINT_API_VERSION) {
diagnostics.push({
code: 'CONFIG_BINDINGS_SCHEMA',
message: `${context}: apiVersion must be exactly '${BLUEPRINT_API_VERSION}'`,
path: 'apiVersion',
retryable: false,
});
}
if (obj.kind !== BINDINGS_KIND) {
diagnostics.push({
code: 'CONFIG_BINDINGS_SCHEMA',
message: `${context}: kind must be exactly '${BINDINGS_KIND}'`,
path: 'kind',
retryable: false,
});
}
if (!obj.spec || typeof obj.spec !== 'object' || Object.keys(obj.spec).length === 0) {
diagnostics.push({
code: 'CONFIG_BINDINGS_SCHEMA',
message: `${context}: spec is required and non-empty (empty bindings file is invalid)`,
path: 'spec',
retryable: false,
});
} else {
const spec = obj.spec as Record<string, unknown>;
for (const key of Object.keys(spec)) {
if (!BINDINGS_SPEC_KEYS.has(key)) {
diagnostics.push({
code: 'CONFIG_BINDINGS_SCHEMA',
message: `${context}: unknown spec key '${key}'`,
path: `spec.${key}`,
retryable: false,
});
}
}
if (spec.fleet) {
const fleet = spec.fleet as Record<string, unknown>;
for (const key of Object.keys(fleet)) {
if (!BINDINGS_FLEET_KEYS.has(key)) {
diagnostics.push({
code: 'CONFIG_BINDINGS_SCHEMA',
message: `${context}: unknown fleet key '${key}'`,
path: `spec.fleet.${key}`,
retryable: false,
});
}
}
if (fleet.runtime) {
const runtime = fleet.runtime as Record<string, unknown>;
for (const key of Object.keys(runtime)) {
if (!BINDINGS_RUNTIME_KEYS.has(key)) {
diagnostics.push({
code: 'CONFIG_BINDINGS_SCHEMA',
message: `${context}: unknown runtime key '${key}'`,
path: `spec.fleet.runtime.${key}`,
retryable: false,
});
}
}
if (
runtime.default !== undefined &&
(typeof runtime.default !== 'string' || !isValidId(runtime.default))
) {
diagnostics.push({
code: 'CONFIG_BINDINGS_SCHEMA',
message: `${context}: runtime.default must match [a-z][a-z0-9-]{0,62}`,
path: 'spec.fleet.runtime.default',
retryable: false,
});
}
if (runtime.byClass !== undefined && runtime.byClass !== null) {
if (typeof runtime.byClass !== 'object' || runtime.byClass === null) {
diagnostics.push({
code: 'CONFIG_BINDINGS_SCHEMA',
message: `${context}: runtime.byClass must be a mapping`,
path: 'spec.fleet.runtime.byClass',
retryable: false,
});
} else {
for (const [cls, rt] of Object.entries(runtime.byClass)) {
if (!isValidId(cls)) {
diagnostics.push({
code: 'CONFIG_BINDINGS_SCHEMA',
message: `${context}: byClass key '${cls}' must match [a-z][a-z0-9-]{0,62}`,
path: `spec.fleet.runtime.byClass.${cls}`,
retryable: false,
});
}
if (typeof rt !== 'string' || !isValidId(rt)) {
diagnostics.push({
code: 'CONFIG_BINDINGS_SCHEMA',
message: `${context}: byClass value for '${cls}' must match [a-z][a-z0-9-]{0,62}`,
path: `spec.fleet.runtime.byClass.${cls}`,
retryable: false,
});
}
}
}
}
}
if (fleet.workingDirectory !== undefined) {
const wd = fleet.workingDirectory;
if (typeof wd !== 'string') {
diagnostics.push({
code: 'CONFIG_BINDINGS_SCHEMA',
message: `${context}: workingDirectory must be a string`,
path: 'spec.fleet.workingDirectory',
retryable: false,
});
} else {
if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(wd)) {
diagnostics.push({
code: 'CONFIG_BINDINGS_SCHEMA',
message: `${context}: workingDirectory contains control characters`,
path: 'spec.fleet.workingDirectory',
retryable: false,
});
}
if (!wd.startsWith('/') && !wd.startsWith('~/')) {
diagnostics.push({
code: 'CONFIG_BINDINGS_SCHEMA',
message: `${context}: workingDirectory must be absolute or ~/ prefixed`,
path: 'spec.fleet.workingDirectory',
retryable: false,
});
}
}
}
}
}
if (diagnostics.length > 0) {
return { ok: false, diagnostics };
}
const fleet = (obj.spec as Record<string, unknown>).fleet as Record<string, unknown> | undefined;
const bindings: InstallationBindings = {
apiVersion: BLUEPRINT_API_VERSION,
kind: BINDINGS_KIND,
spec: {
fleet: fleet
? {
runtime: fleet.runtime as InstallationBindings['spec']['fleet']['runtime'] | undefined,
workingDirectory: fleet.workingDirectory as string | undefined,
}
: {},
},
};
return { ok: true, bindings, diagnostics: [] };
}
@@ -0,0 +1,216 @@
/**
* Shared type contracts for the installation config minimal subset.
*
* Spec: docs/specs/2026-08-29_mosaic-config-minimal-subset.md (spec review
* PASS deff617d by rev-code-02; implementation per CONFIGIMPL-GO).
*
* These types are the single source of truth for the blueprint, bindings,
* result, action, and diagnostic shapes. The YAML schemas in schema.ts and
* the result renderers in render.ts consume these interfaces directly.
*/
// ─── Blueprint (§6) ───────────────────────────────────────────────────────────
export const BLUEPRINT_API_VERSION = 'config.mosaicstack.dev/v1alpha1';
export const BLUEPRINT_KIND = 'InstallationBlueprint';
export const BINDINGS_KIND = 'InstallationBindings';
export const PRESET_ID = 'bootstrap-minimal@1';
export type FleetSelection = 'floor' | 'full';
export interface InstallationBlueprint {
apiVersion: typeof BLUEPRINT_API_VERSION;
kind: typeof BLUEPRINT_KIND;
metadata: {
name: string;
generation: number;
};
spec: {
fleet: {
profile: string;
selection: FleetSelection;
};
};
}
// ─── Bindings (§7) ────────────────────────────────────────────────────────────
export interface InstallationBindings {
apiVersion: typeof BLUEPRINT_API_VERSION;
kind: typeof BINDINGS_KIND;
spec: {
fleet: {
runtime?: {
default?: string;
byClass?: Record<string, string>;
};
workingDirectory?: string;
};
};
}
// ─── Framework defaults (§7, A5) ─────────────────────────────────────────────
export interface FrameworkBindingDefaults {
runtime: string;
workingDirectory: string;
}
export const FRAMEWORK_BINDING_DEFAULTS: FrameworkBindingDefaults = {
runtime: 'claude',
workingDirectory: '~',
};
// ─── Preset (§8) ──────────────────────────────────────────────────────────────
export interface PackagedPreset {
id: typeof PRESET_ID;
blueprint: InstallationBlueprint;
}
export const BOOTSTRAP_MINIMAL_V1: PackagedPreset = {
id: 'bootstrap-minimal@1',
blueprint: {
apiVersion: 'config.mosaicstack.dev/v1alpha1',
kind: 'InstallationBlueprint',
metadata: {
name: 'bootstrap-minimal',
generation: 1,
},
spec: {
fleet: {
profile: 'software-delivery',
selection: 'floor',
},
},
},
};
// ─── Diagnostics (§13) ────────────────────────────────────────────────────────
export type DiagnosticCode =
| 'CONFIG_USAGE_INVALID'
| 'CONFIG_REGISTRY_INVALID'
| 'CONFIG_BLUEPRINT_MISSING'
| 'CONFIG_BLUEPRINT_SCHEMA'
| 'CONFIG_BINDINGS_SCHEMA'
| 'CONFIG_BINDINGS_NOT_IGNORED'
| 'CONFIG_PRESET_UNKNOWN'
| 'CONFIG_PROFILE_UNRESOLVED'
| 'CONFIG_ROLE_UNRESOLVED'
| 'CONFIG_DESIRED_ROSTER_INVALID'
| 'CONFIG_OBSERVED_ROSTER_INVALID'
| 'CONFIG_ROSTER_MISSING'
| 'CONFIG_NONCONFORMANT'
| 'CONFIG_DRIFT_CREATE'
| 'CONFIG_DRIFT_UPDATE'
| 'CONFIG_DRIFT_FULL_ENGINE_REQUIRED'
| 'CONFIG_ADAPTER_UNAVAILABLE';
export interface ConfigDiagnostic {
code: DiagnosticCode;
message: string;
path?: string;
retryable: boolean;
}
// ─── Registry provenance (§5, §2.2) ──────────────────────────────────────────
export interface RegistryProvenance {
resolved: boolean;
brainHome: string | null;
sourceKeys: Array<{ key: string; sourceClass: string }>;
}
// ─── Validation result (§12.1) ───────────────────────────────────────────────
export interface ConfigCheckResult {
id: string;
status: 'passed' | 'failed' | 'notChecked';
}
export interface ConfigValidationDataV1 {
resultSchemaVersion: 1;
valid: boolean;
conformant: boolean;
blueprint: { source: 'file' | 'preset'; id: string; digest: string };
bindings: { source: 'file' | 'framework-default'; digest: string };
profile: { id: string; digest: string; selection: FleetSelection };
observed: { roster: 'present' | 'absent'; digest: string | null };
checks: ConfigCheckResult[];
}
// ─── Plan result (§12.2, §11.2) ─────────────────────────────────────────────
export type ConfigPlanOperationV1 = 'create' | 'update' | 'blocked';
export type ConfigRiskV1 = 'none' | 'review-required' | 'full-engine-required';
export interface ConfigPlanFieldDiff {
path: string;
before: string | number | boolean | null;
after: string | number | boolean | null;
}
export interface ConfigPlanActionV1 {
id: string;
resourceKind: 'fleet-roster' | 'fleet-seat';
resourceId: string;
operation: ConfigPlanOperationV1;
risk: ConfigRiskV1;
reasonCode: string;
beforeDigest: string | null;
afterDigest: string | null;
fieldDiffs: ConfigPlanFieldDiff[];
blockedBy: string[];
}
export interface ConfigPlanDataV1 {
resultSchemaVersion: 1;
planSchemaVersion: 1;
planId: string;
applySupported: false;
valid: true;
conformant: boolean;
changeCount: number;
blockedCount: number;
inputs: {
blueprintDigest: string;
bindingsDigest: string;
profileDigest: string;
observedRosterDigest: string | null;
};
actions: ConfigPlanActionV1[];
}
// ─── Result envelope (§12) ────────────────────────────────────────────────────
export interface CapabilityResultV1<T> {
capabilityId: string;
status: 'succeeded' | 'failed' | 'invalid';
data?: T;
diagnostics?: ConfigDiagnostic[];
correlationId: string;
executionMode: 'local-adapter';
identityTrust: 'local-asserted';
audit: { authority: 'none'; recorded: boolean };
}
// ─── Exit codes (§10.3) ──────────────────────────────────────────────────────
export const EXIT_OK = 0;
export const EXIT_INVALID = 2;
export const EXIT_RESERVED_SCOPE = 3;
export const EXIT_NONCONFORMANT = 4;
export const EXIT_UNAVAILABLE = 6;
// ─── Dependency gate marker (§2.2) ───────────────────────────────────────────
/**
* DEPENDENCY GATE: the approved MosaicRegistryResolver does not exist at
* the pinned baseline. The registry-consuming work is PARKED. This marker
* interface exists so that when the reviewed resolver ships, the adapter
* binds to it without schema changes. Until then, the adapter returns
* CONFIG_REGISTRY_INVALID with the dependency-blocked message.
*/
export const REGISTRY_RESOLVER_BLOCKED =
'MosaicRegistryResolver: dependency-blocked pending reviewed resolver (CFG-REQ-001..006 charter)';