@wipcomputer/wip-ldm-os 0.4.85-alpha.2 → 0.4.85-alpha.21

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.
Files changed (37) hide show
  1. package/README.md +22 -2
  2. package/SKILL.md +8 -5
  3. package/bin/ldm.js +169 -65
  4. package/docs/universal-installer/SPEC.md +16 -3
  5. package/docs/universal-installer/TECHNICAL.md +4 -4
  6. package/lib/deploy.mjs +104 -20
  7. package/lib/detect.mjs +35 -4
  8. package/package.json +13 -2
  9. package/scripts/test-crc-agentid-tenant-boundary.mjs +80 -0
  10. package/scripts/test-crc-e2ee-key-persistence.mjs +150 -0
  11. package/scripts/test-crc-e2ee-session-route.mjs +129 -0
  12. package/scripts/test-crc-pair-login-flow.mjs +40 -0
  13. package/scripts/test-crc-pair-relink-audit-and-rotation.mjs +164 -0
  14. package/scripts/test-crc-pair-status-poll-token.mjs +73 -0
  15. package/scripts/test-install-prompt-policy.mjs +60 -0
  16. package/scripts/test-installer-skill-directory.mjs +55 -0
  17. package/scripts/test-installer-skill-dry-run-destinations.mjs +100 -0
  18. package/scripts/test-installer-target-self-update.mjs +131 -0
  19. package/scripts/test-ldm-status-timeout.mjs +80 -0
  20. package/shared/templates/install-prompt.md +20 -2
  21. package/src/hosted-mcp/README.md +15 -0
  22. package/src/hosted-mcp/app/footer.js +74 -0
  23. package/src/hosted-mcp/app/kaleidoscope-login.html +846 -0
  24. package/src/hosted-mcp/app/pair.html +165 -57
  25. package/src/hosted-mcp/app/sprites.png +0 -0
  26. package/src/hosted-mcp/codex-relay-e2ee-registry.mjs +208 -0
  27. package/src/hosted-mcp/demo/index.html +3 -7
  28. package/src/hosted-mcp/demo/login.html +318 -20
  29. package/src/hosted-mcp/deploy.sh +307 -56
  30. package/src/hosted-mcp/docs/self-host.md +268 -0
  31. package/src/hosted-mcp/nginx/codex-relay.conf +25 -0
  32. package/src/hosted-mcp/nginx/conf.d/redact-logs.conf +60 -0
  33. package/src/hosted-mcp/nginx/mcp-oauth.conf +58 -0
  34. package/src/hosted-mcp/nginx/wip.computer.conf +25 -1
  35. package/src/hosted-mcp/scripts/audit-logs.sh +205 -0
  36. package/src/hosted-mcp/scripts/verify-deploy.sh +102 -0
  37. package/src/hosted-mcp/server.mjs +963 -146
@@ -0,0 +1,60 @@
1
+ # nginx log redaction (F-007 in the VPS hosted-mcp audit).
2
+ #
3
+ # Defines a "redacted" log_format that masks bearer tokens, WS tickets,
4
+ # and ck-style API keys in the request line and the Referer header
5
+ # before they are written to access logs. Maps run at http context, so
6
+ # this file deploys to /etc/nginx/conf.d/redact-logs.conf where nginx
7
+ # loads it automatically from inside the http {} block.
8
+ #
9
+ # Source: src/hosted-mcp/nginx/conf.d/redact-logs.conf
10
+ # Deploy: /etc/nginx/conf.d/redact-logs.conf
11
+ #
12
+ # To use this format, server blocks must reference it explicitly on
13
+ # their access_log directive, e.g.
14
+ # access_log /var/log/nginx/wip.computer.access.log redacted;
15
+
16
+ # ── Query-string redaction ──────────────────────────────────────────
17
+ #
18
+ # nginx maps replace the entire value, not a substring, so any value
19
+ # containing one of these patterns gets the whole query string masked.
20
+ # That is correct here: if a request is leaking a token in the query,
21
+ # we do not want to keep the rest of the args either.
22
+
23
+ # Note: regex patterns are double-quoted because nginx's lexer treats
24
+ # bare {N,} quantifiers as config-block delimiters. Quoting forces
25
+ # nginx to treat the whole token as a regex string.
26
+
27
+ map $args $args_redacted {
28
+ "~*(?:^|&)token=" "[REDACTED token=]";
29
+ "~*(?:^|&)ticket=" "[REDACTED ticket=]";
30
+ "~*(?:^|&)api_key=" "[REDACTED api_key=]";
31
+ "~*(?:^|&)access_token=" "[REDACTED access_token=]";
32
+ "~*ck-[A-Za-z0-9_-]{6,}" "[REDACTED ck-]";
33
+ default $args;
34
+ }
35
+
36
+ # ── Referer redaction ───────────────────────────────────────────────
37
+ #
38
+ # Browsers send the previous URL as Referer, which can carry token
39
+ # values across navigations. Mask the same patterns there.
40
+
41
+ map $http_referer $http_referer_redacted {
42
+ "~*[?&]token=" "[REDACTED token in referer]";
43
+ "~*[?&]ticket=" "[REDACTED ticket in referer]";
44
+ "~*[?&]api_key=" "[REDACTED api_key in referer]";
45
+ "~*[?&]access_token=" "[REDACTED access_token in referer]";
46
+ "~*ck-[A-Za-z0-9_-]{6,}" "[REDACTED ck in referer]";
47
+ default $http_referer;
48
+ }
49
+
50
+ # ── Request line built without the raw query ────────────────────────
51
+ #
52
+ # $request expands to the full request line including the raw query.
53
+ # We rebuild it from $request_method + $uri (path only) + the redacted
54
+ # args, so the path is preserved but the query is masked when it
55
+ # contains a token-shaped value.
56
+
57
+ log_format redacted '$remote_addr - $remote_user [$time_local] '
58
+ '"$request_method $uri?$args_redacted $server_protocol" '
59
+ '$status $body_bytes_sent '
60
+ '"$http_referer_redacted" "$http_user_agent"';
@@ -37,6 +37,27 @@ location /webauthn/ {
37
37
  proxy_set_header X-Forwarded-Proto $scheme;
38
38
  }
39
39
 
40
+ # QR login API used by the canonical /login Kaleidoscope desktop-to-phone
41
+ # flow. Without these routes nginx falls through to the static homepage
42
+ # or returns 405, and the browser tries to parse HTML as JSON.
43
+ location = /api/qr-login {
44
+ proxy_pass http://127.0.0.1:18800/api/qr-login;
45
+ proxy_http_version 1.1;
46
+ proxy_set_header Host $host;
47
+ proxy_set_header X-Real-IP $remote_addr;
48
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
49
+ proxy_set_header X-Forwarded-Proto $scheme;
50
+ }
51
+
52
+ location /api/qr-login/ {
53
+ proxy_pass http://127.0.0.1:18800/api/qr-login/;
54
+ proxy_http_version 1.1;
55
+ proxy_set_header Host $host;
56
+ proxy_set_header X-Real-IP $remote_addr;
57
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
58
+ proxy_set_header X-Forwarded-Proto $scheme;
59
+ }
60
+
40
61
  # Signup and login pages
41
62
  location = /signup {
42
63
  proxy_pass http://127.0.0.1:18800/signup;
@@ -65,6 +86,43 @@ location = /login {
65
86
  proxy_set_header X-Forwarded-Proto $scheme;
66
87
  }
67
88
 
89
+ # Explicit non-primary route for the app/login.html flow. Without
90
+ # this, /login/app falls through to the catch-all `location /` and
91
+ # serves the WIP marketing static. The Node app's /login/app handler
92
+ # is what we actually want here.
93
+ location = /login/app {
94
+ proxy_pass http://127.0.0.1:18800/login/app;
95
+ proxy_http_version 1.1;
96
+ proxy_set_header Host $host;
97
+ proxy_set_header X-Real-IP $remote_addr;
98
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
99
+ proxy_set_header X-Forwarded-Proto $scheme;
100
+ }
101
+ location = /login/app/ {
102
+ proxy_pass http://127.0.0.1:18800/login/app/;
103
+ proxy_http_version 1.1;
104
+ proxy_set_header Host $host;
105
+ proxy_set_header X-Real-IP $remote_addr;
106
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
107
+ proxy_set_header X-Forwarded-Proto $scheme;
108
+ }
109
+
110
+ # Static assets served by Node from src/hosted-mcp/app/.
111
+ # /login (app/kaleidoscope-login.html) references /app/footer.js and
112
+ # /app/sprites.png. Without this proxy, those asset requests fall
113
+ # through to the catch-all `location /` and either 404 or get the
114
+ # static homepage HTML, recreating the "HTML returned where JS/PNG
115
+ # was expected" failure class. server.mjs's serveAppFile sets the
116
+ # correct Content-Type for js, png, html, css, svg, json, ico.
117
+ location /app/ {
118
+ proxy_pass http://127.0.0.1:18800/app/;
119
+ proxy_http_version 1.1;
120
+ proxy_set_header Host $host;
121
+ proxy_set_header X-Real-IP $remote_addr;
122
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
123
+ proxy_set_header X-Forwarded-Proto $scheme;
124
+ }
125
+
68
126
  # Agent approval page (server-generated)
69
127
  location = /approve {
70
128
  proxy_pass http://127.0.0.1:18800/approve;
@@ -4,7 +4,12 @@ server {
4
4
  root /var/www/wip.computer/public_html;
5
5
  index index.html index.htm;
6
6
 
7
- access_log /var/log/nginx/wip.computer.access.log;
7
+ # Log redaction: F-007 in the VPS hosted-mcp audit.
8
+ # The "redacted" format is defined in /etc/nginx/conf.d/redact-logs.conf
9
+ # (source: src/hosted-mcp/nginx/conf.d/redact-logs.conf). It masks
10
+ # token=, ticket=, api_key=, access_token=, and ck- values in the
11
+ # request line and Referer before the line is written.
12
+ access_log /var/log/nginx/wip.computer.access.log redacted;
8
13
  error_log /var/log/nginx/wip.computer.error.log;
9
14
 
10
15
  # Docs redirect to docs.wip.computer
@@ -45,6 +50,25 @@ server {
45
50
  proxy_set_header X-Forwarded-Proto $scheme;
46
51
  }
47
52
 
53
+ # Next.js internal asset surface for the Kaleidoscope app on :3001.
54
+ # /codex-remote-control/<tid> renders a Next HTML shell that
55
+ # references /_next/static/chunks/*.js and /_next/static/chunks/*.css.
56
+ # Without this proxy, those asset requests fall through to
57
+ # `location /` try_files and return the static homepage HTML, which
58
+ # makes the browser fail to hydrate (page renders blank). Same
59
+ # upstream as /codex-remote-control/. Covers /_next/static/* and
60
+ # /_next/image* paths used by the Next runtime.
61
+ location /_next/ {
62
+ proxy_pass http://127.0.0.1:3001;
63
+ proxy_http_version 1.1;
64
+ proxy_set_header Upgrade $http_upgrade;
65
+ proxy_set_header Connection "upgrade";
66
+ proxy_set_header Host $host;
67
+ proxy_set_header X-Real-IP $remote_addr;
68
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
69
+ proxy_set_header X-Forwarded-Proto $scheme;
70
+ }
71
+
48
72
  # Health check
49
73
  location /health {
50
74
  proxy_pass http://127.0.0.1:18800/health;
@@ -0,0 +1,205 @@
1
+ #!/usr/bin/env bash
2
+ # audit-logs.sh: F-007 pre-fix log audit for the VPS hosted-mcp surface.
3
+ #
4
+ # Read-only. No log mutation, no rotation, no clearing. Greps nginx and
5
+ # PM2 logs for bearer/ticket/api-key shaped values that may have leaked
6
+ # during the period when the WS upgrade accepted ?token=ck- and the
7
+ # phone app sent ck values in URLs.
8
+ #
9
+ # Output: per-source match count, plus up to N redacted sample lines per
10
+ # source so you can see which path / agent / time range is affected
11
+ # without ever printing the leaked secret in this terminal session.
12
+ #
13
+ # If any source shows a non-zero count, treat the matching ck values as
14
+ # already-leaked. Rotate them as part of the F-002 + F-005a deploy
15
+ # (see ai/product/bugs/security/2026-04-28--cc-mini--vps-hosted-mcp-audit.md).
16
+ #
17
+ # Usage on the VPS:
18
+ # bash audit-logs.sh
19
+ # bash audit-logs.sh --days 14 # widen log window for rotated logs
20
+ # bash audit-logs.sh --samples 20 # show more redacted samples
21
+ # bash audit-logs.sh --json # machine-readable output (counts only)
22
+ #
23
+ # Source: src/hosted-mcp/scripts/audit-logs.sh
24
+
25
+ set -euo pipefail
26
+
27
+ # ── Defaults ────────────────────────────────────────────────────────
28
+
29
+ DAYS=7
30
+ SAMPLES=10
31
+ JSON=0
32
+ NGINX_LOG_DIR=/var/log/nginx
33
+ PM2_APP=mcp-server
34
+
35
+ # ── Args ────────────────────────────────────────────────────────────
36
+
37
+ while [ $# -gt 0 ]; do
38
+ case "$1" in
39
+ --days) DAYS=$2; shift 2 ;;
40
+ --samples) SAMPLES=$2; shift 2 ;;
41
+ --json) JSON=1; shift 1 ;;
42
+ --nginx-log-dir) NGINX_LOG_DIR=$2; shift 2 ;;
43
+ --pm2-app) PM2_APP=$2; shift 2 ;;
44
+ -h|--help)
45
+ sed -n '2,30p' "$0"
46
+ exit 0
47
+ ;;
48
+ *) echo "Unknown arg: $1" >&2; exit 2 ;;
49
+ esac
50
+ done
51
+
52
+ # ── Patterns ────────────────────────────────────────────────────────
53
+ #
54
+ # Three families of leak we are looking for. Each is a single ERE that
55
+ # `grep -E` will accept. We match conservatively (\b, exact prefixes)
56
+ # so we do not over-flag random bytes that happen to resemble a key.
57
+
58
+ PAT_QUERY_TOKEN='[?&](token|ticket|api_key|access_token)=[^[:space:]&"]+'
59
+ PAT_CK_KEY='\bck-[A-Za-z0-9_-]{6,}\b'
60
+ PAT_AUTH_BEARER='Authorization:[[:space:]]*Bearer[[:space:]]+ck-[A-Za-z0-9_-]{6,}'
61
+
62
+ # ── Redaction (for sample output only) ──────────────────────────────
63
+ #
64
+ # Replace the matched value with a marker before we print the sample
65
+ # line. This way the audit's own output never reproduces the leaked
66
+ # secret in the operator's terminal/scrollback.
67
+
68
+ redact() {
69
+ sed -E \
70
+ -e 's/([?&](token|ticket|api_key|access_token)=)[^&" \t]+/\1[REDACTED]/g' \
71
+ -e 's/\bck-[A-Za-z0-9_-]+/[REDACTED ck-]/g'
72
+ }
73
+
74
+ # ── Source inventory ────────────────────────────────────────────────
75
+
76
+ declare -a SOURCES
77
+ declare -A SOURCE_LABEL
78
+
79
+ add_source() {
80
+ local label=$1
81
+ local path=$2
82
+ if [ -e "$path" ] || compgen -G "$path" > /dev/null; then
83
+ SOURCES+=("$path")
84
+ SOURCE_LABEL["$path"]=$label
85
+ fi
86
+ }
87
+
88
+ add_source "nginx access (current)" "${NGINX_LOG_DIR}/access.log"
89
+ add_source "nginx error (current)" "${NGINX_LOG_DIR}/error.log"
90
+ add_source "nginx wip.computer.access" "${NGINX_LOG_DIR}/wip.computer.access.log"
91
+ add_source "nginx wip.computer.error" "${NGINX_LOG_DIR}/wip.computer.error.log"
92
+
93
+ # ── Counters ────────────────────────────────────────────────────────
94
+
95
+ total_matches=0
96
+ declare -A counts
97
+
98
+ scan_one() {
99
+ local path=$1
100
+ local label=$2
101
+ local count
102
+ if [[ "$path" == *.gz ]]; then
103
+ count=$(sudo zgrep -E "$PAT_QUERY_TOKEN|$PAT_CK_KEY|$PAT_AUTH_BEARER" "$path" 2>/dev/null | wc -l | tr -d ' ')
104
+ else
105
+ count=$(sudo grep -E "$PAT_QUERY_TOKEN|$PAT_CK_KEY|$PAT_AUTH_BEARER" "$path" 2>/dev/null | wc -l | tr -d ' ')
106
+ fi
107
+ counts["$path"]=$count
108
+ total_matches=$(( total_matches + count ))
109
+ }
110
+
111
+ print_samples() {
112
+ local path=$1
113
+ local label=$2
114
+ local count=${counts["$path"]}
115
+ [ "$count" -eq 0 ] && return
116
+ echo
117
+ echo "── $label ($path)"
118
+ echo " matches: $count"
119
+ echo " sample (redacted, up to $SAMPLES lines):"
120
+ if [[ "$path" == *.gz ]]; then
121
+ sudo zgrep -E "$PAT_QUERY_TOKEN|$PAT_CK_KEY|$PAT_AUTH_BEARER" "$path" 2>/dev/null \
122
+ | head -n "$SAMPLES" | redact | sed 's/^/ /'
123
+ else
124
+ sudo grep -E "$PAT_QUERY_TOKEN|$PAT_CK_KEY|$PAT_AUTH_BEARER" "$path" 2>/dev/null \
125
+ | head -n "$SAMPLES" | redact | sed 's/^/ /'
126
+ fi
127
+ }
128
+
129
+ # ── Run nginx scan ──────────────────────────────────────────────────
130
+
131
+ if [ "$JSON" -ne 1 ]; then
132
+ echo "F-007 log audit"
133
+ echo "Days back (rotated): $DAYS"
134
+ echo "Samples per source: $SAMPLES"
135
+ echo "Nginx log dir: $NGINX_LOG_DIR"
136
+ echo
137
+ fi
138
+
139
+ # Current logs
140
+ for path in "${SOURCES[@]}"; do
141
+ scan_one "$path" "${SOURCE_LABEL[$path]}"
142
+ done
143
+
144
+ # Rotated logs within the window
145
+ shopt -s nullglob
146
+ declare -a ROTATED
147
+ for f in "${NGINX_LOG_DIR}"/*.gz "${NGINX_LOG_DIR}"/*.[0-9]*; do
148
+ if [ -f "$f" ]; then
149
+ if find "$f" -mtime "-${DAYS}" -print -quit | grep -q .; then
150
+ ROTATED+=("$f")
151
+ fi
152
+ fi
153
+ done
154
+
155
+ for path in "${ROTATED[@]}"; do
156
+ scan_one "$path" "rotated $(basename "$path")"
157
+ done
158
+
159
+ # ── PM2 ─────────────────────────────────────────────────────────────
160
+ #
161
+ # pm2 logs --nostream prints recent lines from the in-memory buffer.
162
+ # This is best-effort: it covers the current PM2 process lifetime, not
163
+ # rotated copies. PM2's persisted logs (if any) live under
164
+ # ~/.pm2/logs/<app>-out.log and ~/.pm2/logs/<app>-error.log.
165
+
166
+ PM2_OUT="$HOME/.pm2/logs/${PM2_APP}-out.log"
167
+ PM2_ERR="$HOME/.pm2/logs/${PM2_APP}-error.log"
168
+
169
+ if [ -e "$PM2_OUT" ]; then add_source "pm2 ${PM2_APP} out" "$PM2_OUT"; scan_one "$PM2_OUT" "pm2 out"; fi
170
+ if [ -e "$PM2_ERR" ]; then add_source "pm2 ${PM2_APP} error" "$PM2_ERR"; scan_one "$PM2_ERR" "pm2 error"; fi
171
+
172
+ # ── Output ──────────────────────────────────────────────────────────
173
+
174
+ if [ "$JSON" -eq 1 ]; then
175
+ printf '{"total_matches": %d, "by_source": {' "$total_matches"
176
+ first=1
177
+ for path in "${!counts[@]}"; do
178
+ [ "$first" -eq 1 ] && first=0 || printf ','
179
+ printf '"%s": %d' "$path" "${counts[$path]}"
180
+ done
181
+ printf '}}\n'
182
+ else
183
+ echo "── Summary"
184
+ for path in "${!counts[@]}"; do
185
+ label=${SOURCE_LABEL[$path]:-${path}}
186
+ printf " %-40s %s matches\n" "$label" "${counts[$path]}"
187
+ done
188
+ echo
189
+ echo "── Total matches: $total_matches"
190
+
191
+ for path in "${!counts[@]}"; do
192
+ print_samples "$path" "${SOURCE_LABEL[$path]:-${path}}"
193
+ done
194
+
195
+ echo
196
+ if [ "$total_matches" -gt 0 ]; then
197
+ echo "ACTION REQUIRED:"
198
+ echo " Treat any matched ck values as leaked. Rotate them as part of the"
199
+ echo " F-002 + F-005a deploy. After deploying redact-logs.conf, re-run"
200
+ echo " this script and confirm zero matches in the post-rotation window."
201
+ else
202
+ echo "OK: no leak signatures found in the scanned window."
203
+ echo " Re-run after every deploy that adds or moves auth surfaces."
204
+ fi
205
+ fi
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env bash
2
+ # verify-deploy.sh: Verify a hosted-mcp deploy manifest against the live
3
+ # VPS. Reads a manifest produced by deploy.sh, fetches the current
4
+ # sha256 of each declared destination on the remote, and compares.
5
+ #
6
+ # Per F-009 of the VPS hosted-mcp audit. Use after deploy.sh, after
7
+ # any apparent change to the live tree, or as part of the pre-dogfood
8
+ # smoke test.
9
+ #
10
+ # Usage:
11
+ # bash verify-deploy.sh wip.computer:/var/www/.../<timestamp>.json
12
+ # bash verify-deploy.sh /path/to/local/manifest.json --remote wip.computer
13
+ # bash verify-deploy.sh latest # use latest manifest on VPS
14
+ #
15
+ # Exits non-zero on any mismatch. Output lists each file as OK or DIFF.
16
+
17
+ set -euo pipefail
18
+
19
+ REMOTE="wip.computer"
20
+ MANIFEST_DIR="/var/www/wip.computer/deploy-manifests/hosted-mcp"
21
+ ARG=""
22
+
23
+ while [ $# -gt 0 ]; do
24
+ case "$1" in
25
+ --remote) REMOTE=$2; shift 2 ;;
26
+ -h|--help) sed -n '2,18p' "$0"; exit 0 ;;
27
+ *) ARG=$1; shift ;;
28
+ esac
29
+ done
30
+
31
+ if [ -z "$ARG" ]; then
32
+ echo "usage: bash verify-deploy.sh <manifest-path-or-latest>" >&2
33
+ exit 2
34
+ fi
35
+
36
+ # Resolve manifest path.
37
+ if [ "$ARG" = "latest" ]; then
38
+ MANIFEST_PATH=$(ssh "${REMOTE}" "ls -t ${MANIFEST_DIR}/*.json 2>/dev/null | head -1")
39
+ if [ -z "$MANIFEST_PATH" ]; then
40
+ echo "FATAL: no manifests found in ${REMOTE}:${MANIFEST_DIR}" >&2
41
+ exit 1
42
+ fi
43
+ echo "Latest manifest: ${MANIFEST_PATH}"
44
+ MANIFEST_JSON=$(ssh "${REMOTE}" "cat ${MANIFEST_PATH}")
45
+ elif [[ "$ARG" == *":"* ]]; then
46
+ # remote:path form
47
+ MANIFEST_REMOTE=${ARG%%:*}
48
+ MANIFEST_PATH=${ARG#*:}
49
+ MANIFEST_JSON=$(ssh "${MANIFEST_REMOTE}" "cat ${MANIFEST_PATH}")
50
+ elif [ -f "$ARG" ]; then
51
+ MANIFEST_PATH=$ARG
52
+ MANIFEST_JSON=$(cat "$ARG")
53
+ else
54
+ echo "FATAL: manifest not found: ${ARG}" >&2
55
+ exit 1
56
+ fi
57
+
58
+ # Parse files[] entries via python3.
59
+ if ! command -v python3 >/dev/null 2>&1; then
60
+ echo "FATAL: python3 required" >&2
61
+ exit 1
62
+ fi
63
+
64
+ ENTRIES=$(printf '%s' "$MANIFEST_JSON" | python3 -c '
65
+ import json,sys
66
+ m=json.load(sys.stdin)
67
+ for f in m.get("files",[]):
68
+ print(f.get("source","")+"\t"+f.get("destination","")+"\t"+f.get("sha256",""))
69
+ ')
70
+
71
+ if [ -z "$ENTRIES" ]; then
72
+ echo "FATAL: no files entries in manifest" >&2
73
+ exit 1
74
+ fi
75
+
76
+ echo "Manifest: ${MANIFEST_PATH}"
77
+ echo "Verifying ${REMOTE}:"
78
+ echo
79
+
80
+ OK=0
81
+ FAIL=0
82
+ while IFS=$'\t' read -r src dst expected; do
83
+ [ -z "$dst" ] && continue
84
+ # ssh -n redirects stdin from /dev/null so ssh does not consume the
85
+ # loop's heredoc and short-circuit after the first iteration.
86
+ remote_sha=$(ssh -n "${REMOTE}" "sudo sha256sum ${dst} 2>/dev/null" | awk '{print $1}' || echo "")
87
+ if [ -z "$remote_sha" ]; then
88
+ printf " MISSING %s\n" "$dst"
89
+ FAIL=$((FAIL+1))
90
+ elif [ "$remote_sha" = "$expected" ]; then
91
+ printf " OK %s\n" "$dst"
92
+ OK=$((OK+1))
93
+ else
94
+ printf " DIFF %s\n expected %s\n live %s\n" "$dst" "$expected" "$remote_sha"
95
+ FAIL=$((FAIL+1))
96
+ fi
97
+ done <<< "$ENTRIES"
98
+
99
+ echo
100
+ echo "Summary: ${OK} ok, ${FAIL} mismatched"
101
+ [ "$FAIL" -eq 0 ] && exit 0
102
+ exit 1